- 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 Master Skill: godot-master