--- name: godot-adapt-single-to-multiplayer description: "Expert patterns for adding multiplayer to single-player games including client-server architecture, authoritative server design, MultiplayerSynchronizer, lag compensation (client prediction, server reconciliation), input buffering, and anti-cheat measures. Use when retrofitting multiplayer, porting to online play, or designing networked gameplay. Trigger keywords: MultiplayerPeer, ENetMultiplayerPeer, SceneMultiplayer, MultiplayerSynchronizer, rpc, rpc_id, multiplayer_authority, client_prediction, server_reconciliation, lag_compensation, rollback." --- # 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](scripts/net_prediction_reconciliation.gd) Expert CharacterBody3D prediction with input-buffer replaying for server reconciliation. ### [net_snapshot_interpolation.gd](scripts/net_snapshot_interpolation.gd) Professional snapshot interpolation logic for smoothing peer movement via jitter buffers. ### [net_auth_server_validator.gd](scripts/net_auth_server_validator.gd) Authoritative server validator for anti-cheat (Position, Speed, and Action checks). ### [net_rpc_rate_limiter.gd](scripts/net_rpc_rate_limiter.gd) Expert rate-limiter to prevent RPC flooding and macro-abuse by clients. ### [net_interest_management.gd](scripts/net_interest_management.gd) Distance-based visibility management to optimize binary bandwidth per-peer. ### [net_delta_compression_sync.gd](scripts/net_delta_compression_sync.gd) Expert quantization and significance-checking logic for delta-compression. ### [net_upnp_discovery_logic.gd](scripts/net_upnp_discovery_logic.gd) Robust script for P2P network discovery and automatic port forwarding via UPNP. ### [net_debug_overlay_monitor.gd](scripts/net_debug_overlay_monitor.gd) In-game diagnostic overlay reporting RTT (Ping), Packet Loss, and Jitter. ### [net_lag_compensation.gd](scripts/net_lag_compensation.gd) Expert server-side state rewinding (Lag Compensation) for accurate hit-registration. ### [net_lobby_late_join_sync.gd](scripts/net_lobby_late_join_sync.gd) Professional state-initialization logic to bridge 'Late Joiners' into a synced session. ### [net_latency_simulator.gd](scripts/net_latency_simulator.gd) Editor-only tool for simulating high-ping and loss conditions for stress-testing. --- ## Architecture Patterns ### Pattern 1: Authoritative Server (Recommended) ```gdscript # 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) ```gdscript # 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) ```gdscript # 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 ```gdscript # ❌ 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # 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) ```gdscript # 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() ### Server-Side Lag Compensation (Hit Rewind) To ensure clients can hit targets accurately despite latency, the server must "rewind" the world state to the exact moment the client fired. **Expert Pattern:** 1. **Record History**: Store global transforms of all hit-able entities (players, enemies) in a rolling buffer indexed by `Engine.get_physics_frames()`. 2. **Hit Request**: Client sends a "Fire" RPC including the `tick` when they pressed the button. 3. **Rewind**: Server retrieves the state for that `tick`, temporarily moves all RIDs back to those transforms via `PhysicsServer3D.body_set_state()`. 4. **Validate**: Perform a raycast query. 5. **Restore**: Move all RIDs back to their "present day" transforms. > [!TIP] > Always use `PhysicsServer3D` directly for rewinding to bypass `SceneTree` overhead and prevent unwanted signal/node update cascades. ``` --- ## Anti-Cheat Measures ### Server-Side Validation ```gdscript # 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 ```gdscript # ❌ 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 ```gdscript # 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 | ## Advanced Networking Topics ### Peer-to-Peer NAT Traversal (Hole Punching) In P2P architectures, clients often sit behind firewalls. **UPNP** (Universal Plug and Play) is the first line of defense, allowing the game to request port forwarding from the router automatically using `net_upnp_discovery_logic.gd`. For cases where UPNP fails: - **STUN/TURN**: Use a STUN server to discover public IP/port pairings. - **Relay Servers**: If direct connection is impossible, fallback to a relay server (TURN) to bridge the two peers. ### Network Profiling & Visualization Visualizing the packet timeline is critical for debugging jitter. Propose an overlay that graphs: - **Packet Arrival**: A scrolling timeline showing when packets arrive relative to physics frames. - **Buffer Health**: A visualization of the interpolation jitter buffer size. - **RTT (Round Trip Time)**: Real-time graph of latency spikes. ## Reference - Master Skill: [godot-master](../godot-master/SKILL.md)