skills/thedivergentai/gd-agentic-skills/godot-adapt-single-to-multiplayer

godot-adapt-single-to-multiplayer

SKILL.md

Adapt: Single to Multiplayer

Expert guidance for retrofitting multiplayer into single-player games.

NEVER Do

  • NEVER trust client input — Always validate on server. Clients can send fake position/health/inventory data.
  • NEVER use get_tree().get_nodes_in_group() for authority checks — Use is_multiplayer_authority() on individual nodes. Group iteration is unreliable for network identity.
  • NEVER forget to set multiplayer_authority — Nodes without authority assignment will desync. Server should own world objects, clients own their player.
  • NEVER run physics on both client and server identically — Leads to double-speed movement. Use client prediction with server reconciliation OR server-only physics.
  • NEVER send raw input every frame — Buffer inputs client-side, send in batches (every 3-5 frames). Reduces bandwidth 60-80%.

Available Scripts

MANDATORY: Read the appropriate script before implementing the corresponding pattern.

multiplayer_sync.gd

Latency-aware synchronization with MultiplayerSynchronizer. Demonstrates peer interpolation (lerp to network position) and authority-based update logic.

rpc_bridge.gd

Signal-to-RPC bridge pattern. Shows authority guard pattern: client requests → server validates → server broadcasts. Essential for cheat prevention.


Architecture Patterns

Pattern 1: Authoritative Server (Recommended)

# Server validates ALL gameplay logic
# Clients send inputs → Server processes → Server broadcasts state

# Pros: Secure, prevents cheating
# Cons: Requires server hosting, lag affects gameplay

# Use for: Competitive games, PvP, games with economies

Pattern 2: Peer-to-Peer (Lockstep)

# All clients run identical simulation
# Inputs synced, deterministic physics

# Pros: No dedicated server needed
# Cons: Vulnerable to cheating, desyncs common

# Use for: Co-op, casual games, small player counts (2-4)

Pattern 3: Hybrid (Authority Transfer)

# Host acts as server
# Authority can transfer between peers

# Use for: 4-8 player co-op, party games

Step-by-Step Migration

Step 1: Separate Input from Logic

# ❌ BAD: Input directly modifies state (single-player)
extends CharacterBody2D

func _physics_process(delta: float) -> void:
    var input := Input.get_vector("left", "right", "up", "down")
    velocity = input.normalized() * SPEED
    move_and_slide()

# ✅ GOOD: Input → Logic separation

extends CharacterBody2D

var current_input := Vector2.ZERO

func _physics_process(delta: float) -> void:
    # Only read input if this is OUR player
    if is_multiplayer_authority():
        current_input = Input.get_vector("left", "right", "up", "down")
        # Send input to server (if we're client)
        if multiplayer.get_unique_id() != 1:  # Not server
            rpc_id(1, "receive_input", current_input)
    
    # EVERYONE processes movement (server + all clients)
    _process_movement(delta, current_input)

func _process_movement(delta: float, input: Vector2) -> void:
    velocity = input.normalized() * SPEED
    move_and_slide()

@rpc("any_peer", "call_remote", "unreliable")
func receive_input(input: Vector2) -> void:
    # Server receives client input
    current_input = input

Step 2: Set Up Multiplayer Authority

# server_setup.gd
extends Node

const PORT = 7777
const MAX_PLAYERS = 4

func host_game() -> void:
    var peer := ENetMultiplayerPeer.new()
    peer.create_server(PORT, MAX_PLAYERS)
    multiplayer.multiplayer_peer = peer
    
    multiplayer.peer_connected.connect(_on_player_connected)
    multiplayer.peer_disconnected.connect(_on_player_disconnected)
    
    print("Server started on port %d" % PORT)

func join_game(ip: String) -> void:
    var peer := ENetMultiplayerPeer.new()
    peer.create_client(ip, PORT)
    multiplayer.multiplayer_peer = peer
    
    print("Connecting to %s:%d" % [ip, PORT])

func _on_player_connected(id: int) -> void:
    print("Player %d connected" % id)
    spawn_player(id)

func _on_player_disconnected(id: int) -> void:
    print("Player %d disconnected" % id)
    despawn_player(id)

func spawn_player(id: int) -> void:
    var player := preload("res://player.tscn").instantiate()
    player.name = str(id)  # CRITICAL: Name must be unique and match peer ID
    player.set_multiplayer_authority(id)  # Client owns their own player
    get_node("/root/World").add_child(player, true)  # true = replicate to all peers

Step 3: Add MultiplayerSynchronizer

# Scene structure:
# Player (CharacterBody2D)
#   ├─ Sprite2D
#   ├─ CollisionShape2D
#   └─ MultiplayerSynchronizer

# MultiplayerSynchronizer setup (in editor):
# - Root Path: "../"  (points to Player node)
# - Replication Interval: 0.05  (20Hz updates)
# - Public Visibility: true
# - Synchronized Properties:
#     - position
#     - rotation
#     - velocity (optional, for interpolation)

# No code needed! MultiplayerSynchronizer auto-syncs properties

Client Prediction & Server Reconciliation

Problem: Lag Makes Game Feel Unresponsive

# Without prediction:
# 1. Client presses W
# 2. Input sent to server
# 3. Server processes (50ms later)
# 4. Server sends back position
# 5. Client sees movement (100ms RTT)
# Result: 100ms delay between input and visual feedback

Solution: Client-Side Prediction

# player_controller.gd
extends CharacterBody2D

var input_buffer: Array = []
var server_state := {"position": Vector2.ZERO, "tick": 0}

func _physics_process(delta: float) -> void:
    if is_multiplayer_authority():
        var input := Input.get_vector("left", "right", "up", "down")
        
        # Client predicts movement IMMEDIATELY
        var tick := Engine.get_physics_frames()
        input_buffer.append({"input": input, "tick": tick})
        process_movement(input)
        
        # Send input to server
        if multiplayer.get_unique_id() != 1:
            rpc_id(1, "server_receive_input", input, tick)
    
    else:
        # Other players: just display synced position (no prediction)
        pass

@rpc("any_peer", "call_remote", "unreliable")
func server_receive_input(input: Vector2, client_tick: int) -> void:
    # Server processes input
    process_movement(input)
    
    # Send authoritative state back
    rpc_id(multiplayer.get_remote_sender_id(), "client_receive_state", position, client_tick)

@rpc("authority", "call_remote", "unreliable")
func client_receive_state(server_pos: Vector2, server_tick: int) -> void:
    # Reconciliation: check if prediction was correct
    var error := position.distance_to(server_pos)
    
    if error > 5.0:  # Threshold for correction
        # Snap to server position
        position = server_pos
        
        # Replay inputs that happened after server_tick
        for buffered_input in input_buffer:
            if buffered_input.tick > server_tick:
                process_movement(buffered_input.input)
    
    # Clean old inputs
    input_buffer = input_buffer.filter(func(i): return i.tick > server_tick)

func process_movement(input: Vector2) -> void:
    velocity = input.normalized() * SPEED
    move_and_slide()

Lag Compensation Techniques

Interpolation (Other Player Smoothing)

# Other players appear choppy due to packet loss/jitter
# Solution: Interpolate between received states

extends CharacterBody2D

var position_buffer: Array = []
const BUFFER_SIZE = 3  # Store last 3 positions

func _ready() -> void:
    if not is_multiplayer_authority():
        # Disable local physics, use interpolation
        set_physics_process(false)

func _process(delta: float) -> void:
    if not is_multiplayer_authority() and position_buffer.size() >= 2:
        # Interpolate between buffered positions
        var from := position_buffer[0]
        var to := position_buffer[1]
        var t := 0.2  # Interpolation speed
        
        position = position.lerp(to, t)
        
        if position.distance_to(to) < 1.0:
            position_buffer.pop_front()

# Called by MultiplayerSynchronizer when position updates
func _on_position_synced(new_pos: Vector2) -> void:
    position_buffer.append(new_pos)
    if position_buffer.size() > BUFFER_SIZE:
        position_buffer.pop_front()

Anti-Cheat Measures

Server-Side Validation

# server_validator.gd
extends Node

const MAX_SPEED = 300.0
const MAX_TELEPORT_DISTANCE = 50.0

@rpc("any_peer", "call_remote", "reliable")
func request_move(new_position: Vector2) -> void:
    var sender_id := multiplayer.get_remote_sender_id()
    var player := get_node("/root/World/" + str(sender_id))
    
    # Validate movement
    var distance := player.position.distance_to(new_position)
    var delta := get_physics_process_delta_time()
    var max_allowed := MAX_SPEED * delta
    
    if distance > max_allowed:
        push_warning("Player %d teleported %f units (max: %f)" % [sender_id, distance, max_allowed])
        # Reject movement, force server position
        rpc_id(sender_id, "force_position", player.position)
        return
    
    # Accept movement
    player.position = new_position

@rpc("authority", "call_remote", "reliable")
func force_position(server_position: Vector2) -> void:
    position = server_position

Bandwidth Optimization

Input Buffering

# ❌ BAD: Send input every frame (60 packets/s)
func _physics_process(delta: float) -> void:
    var input := get_input()
    rpc_id(1, "receive_input", input)

# ✅ GOOD: Send every 3rd frame (20 packets/s)
var input_timer := 0.0
const INPUT_SEND_RATE = 0.05  # 20 Hz

func _physics_process(delta: float) -> void:
    input_timer += delta
    if input_timer >= INPUT_SEND_RATE:
        var input := get_input()
        rpc_id(1, "receive_input", input)
        input_timer = 0.0

Testing Multiplayer Locally

# Launch multiple instances for testing
# Run from command line:

# Windows:
# Server: Godot.exe --path . res://main.tscn -- --server
# Client 1: Godot.exe --path . res://main.tscn -- --client
# Client 2: Godot.exe --path . res://main.tscn -- --client

# Parse arguments in code:
func _ready() -> void:
    var args := OS.get_cmdline_args()
    if "--server" in args:
        host_game()
    elif "--client" in args:
        join_game("127.0.0.1")

Decision Tree: Which Architecture?

Factor Authoritative Server P2P Lockstep
Player count 8-100+ 2-4
Cheat prevention Critical Not important
Server hosting Available Not available
Gameplay type PvP, competitive Co-op, casual
Lag tolerance Medium (prediction helps) Low (desyncs)
Development complexity High Medium

Reference

Weekly Installs
41
GitHub Stars
35
First Seen
Feb 10, 2026
Installed on
gemini-cli41
opencode41
codex41
kimi-cli40
amp40
github-copilot40