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

godot-adapt-single-to-multiplayer

Installation
SKILL.md

Adapt: Single to Multiplayer

Expert guidance for retrofitting multiplayer into single-player games.

NEVER Do (Expert Multiplayer Rules)

Security & Authority

  • NEVER trust client-reported state — Clients own their 'Input', NOT their 'Position' or 'Health'. Server must validate every coordinate and health change.
  • NEVER use get_tree() groups for authority checks — Use is_multiplayer_authority(). Group registration is non-deterministic in high-latency joins.
  • NEVER allow unrestricted RPC rates — A malicious client can call a 'FireWeapon' RPC 10,000 times per second. Always implement rate-limiting (net_rpc_rate_limiter.gd).

Movement & Lag

  • NEVER skip Client-Side Prediction — Movement without prediction feels 'heavy' and unresponsive. Predict movement locally, then correct only on server disagreement.
  • NEVER sync peers at 60Hz — Sending entire state every frame will saturate client bandwidth. Use a lower tick-rate (20-30Hz) and interpolate between packets.
  • NEVER snap peer positions — Abrupt position updates cause 'jitter'. Store a buffer of past states and lerp between them with a 100ms delay.

Bandwidth & Sync

  • NEVER sync 'Full Floats' if possible — Quantize Vector3 data (truncating decimals) to save 50%+ bandwidth. Use MultiplayerSynchronizer with delta-sync enabled.
  • NEVER ignore 'Late Joiners' — Players who join mid-game won't see existing environmental changes. Broadcast a full world-state 'Snapshot' on peer connection.
  • NEVER test on 0ms ping — Everything works on localhost. Use a simulator (net_latency_simulator.gd) with 150ms ping to identify sync bugs.

Available Scripts

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

net_prediction_reconciliation.gd

Expert CharacterBody3D prediction with input-buffer replaying for server reconciliation.

net_snapshot_interpolation.gd

Professional snapshot interpolation logic for smoothing peer movement via jitter buffers.

net_auth_server_validator.gd

Authoritative server validator for anti-cheat (Position, Speed, and Action checks).

net_rpc_rate_limiter.gd

Expert rate-limiter to prevent RPC flooding and macro-abuse by clients.

net_interest_management.gd

Distance-based visibility management to optimize binary bandwidth per-peer.

net_delta_compression_sync.gd

Expert quantization and significance-checking logic for delta-compression.

net_upnp_discovery_logic.gd

Robust script for P2P network discovery and automatic port forwarding via UPNP.

net_debug_overlay_monitor.gd

In-game diagnostic overlay reporting RTT (Ping), Packet Loss, and Jitter.

net_lobby_late_join_sync.gd

Professional state-initialization logic to bridge 'Late Joiners' into a synced session.

net_latency_simulator.gd

Editor-only tool for simulating high-ping and loss conditions for stress-testing.


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
74
GitHub Stars
138
First Seen
2 days ago