◆ Architecture Overview
The three-layer stack that powers all Godot 4.x networking
Click each layer to explore
multiplayer property pointing to the active MultiplayerAPI. MultiplayerSpawner and MultiplayerSynchronizer nodes live here, automatically replicating children and properties across peers.get_tree().set_multiplayer(api, path).multiplayer.multiplayer_peer.CORE The Setup Flow
Every Godot multiplayer game follows the same initialization sequence. Create a peer, configure it as server or client, assign it, and listen for signals.
# ── autoload: network_manager.gd ── func host_game(port: int = 4433, max_players: int = 16): var peer = ENetMultiplayerPeer.new() peer.create_server(port, max_players) multiplayer.multiplayer_peer = peer multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) print("Server started on port ", port) func join_game(address: String, port: int = 4433): var peer = ENetMultiplayerPeer.new() peer.create_client(address, port) multiplayer.multiplayer_peer = peer multiplayer.connected_to_server.connect(_on_connected) multiplayer.connection_failed.connect(_on_failed) func _on_peer_connected(id: int): print("Peer connected: ", id) func _on_connected(): print("My peer ID: ", multiplayer.get_unique_id())
The server always has peer ID 1. Clients receive random positive integers. This is a fundamental invariant you'll rely on everywhere.
Five essential signals on MultiplayerAPI:
| Signal | Fires On | Use Case |
|---|---|---|
peer_connected(id) | Server + all clients | Spawn player, update lobby |
peer_disconnected(id) | Server + all clients | Remove player, cleanup |
connected_to_server() | Client only | Send player info, enter game |
connection_failed() | Client only | Show error, retry UI |
server_disconnected() | Client only | Return to menu, show message |
IMPORTANT SceneMultiplayer Deep Dive
SceneMultiplayer extends MultiplayerAPI with practical features you'll need for production games:
| Property | Default | What It Does |
|---|---|---|
server_relay | true | Server relays messages between clients. Disable to save bandwidth if clients don't need direct communication. |
auth_callback | null | Callable for custom authentication. Peers stay in "authenticating" until complete_auth(id) is called. |
auth_timeout | 3.0 | Seconds before unauthenticated peers are kicked. |
allow_object_decoding | false | Security flag — leave false unless you trust all peers (never in production). |
max_sync_packet_size | 1350 | Max bytes per sync packet. Increase for many synced properties. |
# Custom authentication example func _ready(): var sm = multiplayer as SceneMultiplayer sm.auth_callback = _authenticate_peer sm.auth_timeout = 5.0 func _authenticate_peer(id: int, data: PackedByteArray): var token = data.get_string_from_utf8() if validate_token(token): (multiplayer as SceneMultiplayer).complete_auth(id) else: multiplayer.multiplayer_peer.disconnect_peer(id)
Knowledge Check
What peer ID does the server always have in Godot's multiplayer system?
multiplayer.get_unique_id() == 1 or multiplayer.is_server() to check.◆ Transport Layers
ENet, WebSocket, and WebRTC — choosing the right one
ENet wraps UDP with optional reliability, ordering, and channels. The recommended default for desktop games.
# Server with bandwidth limits var peer = ENetMultiplayerPeer.new() peer.create_server( 4433, # port 16, # max_clients (up to 4095) 0, # max_channels (0 = auto) 0, # in_bandwidth (0 = unlimited) 0 # out_bandwidth ) multiplayer.multiplayer_peer = peer # Access low-level ENet for RTT stats var enet_peer = peer.get_peer(client_id) print("RTT: ", enet_peer.get_statistic( ENetPacketPeer.PEER_ROUND_TRIP_TIME))
ENet also supports full mesh via create_mesh(unique_id) + add_mesh_peer() for peer-to-peer without a central server.
WebSocket uses TCP underneath. The only transport for HTML5 exports. Browsers can't use raw UDP.
# Server (with optional TLS) var peer = WebSocketMultiplayerPeer.new() peer.create_server(8080, "*", tls_options) # Client var peer = WebSocketMultiplayerPeer.new() peer.create_client("ws://127.0.0.1:8080")
Because WebSocket runs over TCP, all messages are reliable regardless of transfer mode. Setting "unreliable" on an RPC is silently ignored. Not suitable for fast-paced position updates.
WebRTC creates a full mesh of P2P connections with built-in NAT traversal via ICE/STUN/TURN. Requires an external signaling server.
var rtc_mp = WebRTCMultiplayerPeer.new() rtc_mp.initialize(my_peer_id, true) multiplayer.multiplayer_peer = rtc_mp # For each remote peer: var conn = WebRTCPeerConnection.new() conn.initialize({ "iceServers": [{ "urls": ["stun:stun.l.google.com:19302"] }] }) rtc_mp.add_peer(conn, remote_id)
Three data channels per connection are created automatically: reliable, unreliable, and ordered. You must implement SDP offer/answer exchange through a separate signaling channel (typically a lightweight WebSocket server).
| Feature | ENet | WebSocket | WebRTC |
|---|---|---|---|
| Protocol | UDP | TCP | UDP (DTLS) |
| Browser support | No | Yes | Yes |
| Unreliable msgs | Yes | No (all TCP) | Yes |
| NAT traversal | Manual / UPNP | Through proxies | Built-in ICE |
| Topology | Client-server + mesh | Client-server | Full mesh |
| Best for | Desktop games | Turn-based / browser | P2P browser games |
Knowledge Check
Which transport should you use if you need unreliable messages in an HTML5 browser game?
◆ Remote Procedure Calls
The @rpc annotation — modes, sync, transfer, channels
CORE @rpc Annotation Anatomy
The @rpc annotation takes up to four parameters in any order (except the channel integer, which must be last):
@rpc(mode, sync, transfer_mode, channel) func my_function(): pass
| Parameter | Options | Default | Meaning |
|---|---|---|---|
| Mode | "authority" / "any_peer" | "authority" | Who is allowed to call this RPC |
| Sync | "call_remote" / "call_local" | "call_remote" | Whether the caller also executes it locally |
| Transfer | "reliable" / "unreliable" / "unreliable_ordered" | "reliable" | Delivery guarantee |
| Channel | Any integer | 0 | Prevents reliable traffic from blocking unrelated channels |
IMPORTANT Common RPC Patterns
Pattern 1: Client requests action from server
# Client → Server: "any_peer" lets clients call this @rpc("any_peer", "reliable") func request_action(action: Dictionary): var sender = multiplayer.get_remote_sender_id() if validate(sender, action): apply_action.rpc(action) # Broadcast to all # Server → All: default "authority" mode @rpc("authority", "call_local", "reliable") func apply_action(action: Dictionary): # Runs on server + all clients perform(action)
Pattern 2: Fast position sync (unreliable ordered, separate channel)
@rpc("authority", "call_remote", "unreliable_ordered", 2) func sync_position(pos: Vector3, vel: Vector3): position = pos velocity = vel
Pattern 3: Targeted RPC to a specific peer
# Send only to peer 42 sync_position.rpc_id(42, position, velocity) # Send to server only request_action.rpc_id(1, {"type": "shoot"})
Knowledge Check
A client wants to tell the server it jumped. Which @rpc mode is required on the server's receiving function?
"authority" means only the node's authority (server by default) can call it. Since the client initiates this call, the function needs "any_peer" to permit non-authority callers. Always validate multiplayer.get_remote_sender_id() inside any_peer RPCs.◆ MultiplayerSpawner
Automatic node lifecycle replication across all peers
CORE Auto-Spawn vs Custom Spawn
MultiplayerSpawner watches a spawn_path node. When the authority adds a matching child, it's replicated to all peers — including late joiners.
Add scenes to the spawner's list. When the authority calls add_child(), it replicates automatically.
# In editor: set spawn_path to "Players" node # Add "res://player.tscn" to spawnable scenes list func add_player(id: int): var player = preload("res://player.tscn").instantiate() player.name = str(id) # CRITICAL: must match on all peers player.set_multiplayer_authority(id) $Players.add_child(player) # Auto-replicated!
Set spawn_function for full control. The callable receives data and returns a Node (not yet in tree).
func _enter_tree(): $MultiplayerSpawner.spawn_function = _spawn_player func _spawn_player(peer_id: int) -> Node: var player = preload("res://player.tscn").instantiate() player.name = str(peer_id) player.set_multiplayer_authority(peer_id) return player # Do NOT add_child — spawner handles it func add_player(id: int): $MultiplayerSpawner.spawn(id) # Runs _spawn_player on ALL peers
In custom spawn mode, do not call add_child() yourself — the spawner does it. Returning the node is enough. Also, the spawn_function must return a Node that isn't already in the tree.
IMPORTANT Properties & Signals
| Property | Type | Purpose |
|---|---|---|
spawn_path | NodePath | Parent node where spawned children appear |
spawn_limit | int | Max remote spawns allowed (0 = unlimited). Security measure against spawn flooding. |
spawn_function | Callable | Custom spawn logic. Receives data, returns Node. |
Signals: spawned(node) and despawned(node) fire on remote peers only — not on the authority. Use these for client-side setup like camera attachment or UI binding.
◆ MultiplayerSynchronizer
Continuous property replication from authority to all peers
CORE Replication Modes
Each property in the SceneReplicationConfig has a spawn flag and a replication mode:
| Mode | Enum Value | Behavior | Use For |
|---|---|---|---|
NEVER | 0 | Synced at spawn only (if spawn flag on) | Player name, color, team |
ALWAYS | 1 | Sent every interval, even if unchanged | Position, velocity, rotation |
ON_CHANGE | 2 | Sent only when value differs | Health, ammo, score |
# Programmatic config var config = SceneReplicationConfig.new() config.add_property(".:position") config.property_set_spawn(".:position", true) config.property_set_replication_mode( ".:position", SceneReplicationConfig.REPLICATION_MODE_ALWAYS ) $MultiplayerSynchronizer.replication_config = config $MultiplayerSynchronizer.replication_interval = 0.05 # 20 Hz
Object references, Object IDs, and Resource IDs cannot be synced. Only value types work: int, float, String, Vector2/3, arrays, dictionaries of value types.
ADVANCED Visibility / Interest Management
Per-peer visibility lets you hide distant entities. When a spawner-managed node becomes invisible to a peer, it's despawned on that peer. Re-enabling respawns it.
var sync = $MultiplayerSynchronizer sync.public_visibility = false # Invisible by default sync.add_visibility_filter(func(peer_id: int) -> bool: var peer_pos = get_player_position(peer_id) return position.distance_to(peer_pos) < 100.0 ) # Force re-evaluation (e.g., after teleport) sync.update_visibility(0) # 0 = all peers
This is Godot's built-in interest management. For large worlds, combine with spatial partitioning to avoid O(n²) distance checks.
Knowledge Check
You want to sync a player's health bar, which only changes when hit. Which replication mode is best?
ON_CHANGE only sends data when the value differs, saving bandwidth for infrequently changing properties like health. ALWAYS would waste bandwidth sending the same health value every tick. NEVER would only sync at spawn.◆ Architecture Patterns
Input sync, server authority, and the recommended scene tree layout
CORE Input Sync with Server Authority (The Official Pattern)
The recommended architecture from Godot's core networking developer: split each player into two synchronization domains.
Player (CharacterBody3D) ← authority: SERVER ├── ServerSync (MultiplayerSynchronizer) ← authority: SERVER │ └── Syncs: position, velocity [ALWAYS mode] ├── InputSync (MultiplayerSynchronizer) ← authority: OWNING PEER │ └── Syncs: direction, jumping [ALWAYS mode] ├── CollisionShape3D ├── MeshInstance3D └── Camera3D
extends CharacterBody3D @export var player := 1: set(id): player = id $InputSync.set_multiplayer_authority(id) func _physics_process(delta): # Server reads client's synced input, runs authoritative physics var input = $InputSync if input.jumping and is_on_floor(): velocity.y = JUMP_VELOCITY input.jumping = false var dir = (transform.basis * Vector3( input.direction.x, 0, input.direction.y )).normalized() velocity.x = dir.x * SPEED if dir else move_toward(velocity.x, 0, SPEED) velocity.z = dir.z * SPEED if dir else move_toward(velocity.z, 0, SPEED) move_and_slide()
extends MultiplayerSynchronizer # These properties are synced to the server @export var direction := Vector2.ZERO @export var jumping := false func _ready(): # Only the owning peer gathers input set_process(is_multiplayer_authority()) func _process(_delta): direction = Input.get_vector("left","right","forward","back") if Input.is_action_just_pressed("jump"): jumping = true
Player node names must be str(peer_id) to ensure identical node paths across all peers. This is the single most important naming convention for multiplayer.
IMPORTANT Lobby System Pattern
Godot has no built-in lobby. Standard approach: autoload singleton with RPCs for player tracking and ready-up.
var players: Dictionary = {} # peer_id -> {name, ready} @rpc("any_peer", "reliable") func register_player(info: Dictionary): var sender = multiplayer.get_remote_sender_id() players[sender] = info # Broadcast updated list to all sync_player_list.rpc(players) @rpc("any_peer", "reliable") func set_ready(is_ready: bool): var sender = multiplayer.get_remote_sender_id() players[sender]["ready"] = is_ready if multiplayer.is_server(): if players.values().all( func(p): return p["ready"] ): start_game.rpc() @rpc("authority", "call_local", "reliable") func start_game(): get_tree().change_scene_to_packed(game_scene)
If the host disconnects, the session dies. Godot has no built-in host migration. For production, use dedicated servers or implement manual state serialization with backup host designation.
◆ Advanced Topics
Interpolation, prediction, rollback, and dedicated servers
ADVANCED Client-Side Interpolation (Manual)
MultiplayerSynchronizer has no built-in interpolation. You must sync to a target variable and lerp toward it:
var target_pos: Vector3 var prev_pos: Vector3 var t: float = 0.0 var sync_interval: float = 0.05 # Match replication_interval func _on_synchronized(): prev_pos = global_position target_pos = synced_position # Property synced by MultiplayerSynchronizer t = 0.0 func _process(delta): if is_multiplayer_authority(): return t = min(t + delta / sync_interval, 1.0) global_position = prev_pos.lerp(target_pos, t)
ADVANCED Client-Side Prediction & Reconciliation
The Gambetta pattern: client sends numbered inputs, applies them immediately (prediction), then replays unacknowledged inputs when server state arrives.
var input_seq: int = 0 var pending: Array = [] func _physics_process(delta): if not is_multiplayer_authority(): return var input = gather_input() input_seq += 1 input.seq = input_seq send_input.rpc_id(1, input) # Send to server apply_input(input) # Predict locally pending.append(input) func on_server_state(state): # Drop acknowledged inputs pending = pending.filter( func(i): return i.seq > state.last_input ) # Snap to server truth position = state.position # Replay unacknowledged for input in pending: apply_input(input)
ADVANCED Rollback Netcode Plugins
| Plugin | Language | Features |
|---|---|---|
| Snopek Rollback Netcode | GDScript | Input gathering, save/load state, mismatch detection, Log Inspector. WebRTC via Nakama adapter. |
| gdrollback | Rust (GDExtension) | High-performance rollback. Peer-to-peer focused. |
| MonkeNet | C# | Overwatch-style client sim speed adjustment with lag compensation. |
IMPORTANT Dedicated Server Deployment
Any Godot 4 export can run headless — no special server binary needed:
./your_game --headless --audio-driver Dummy
func _ready(): if DisplayServer.get_name() == "headless": start_dedicated_server() elif OS.has_feature("dedicated_server"): start_dedicated_server() # Parse CLI args var args = OS.get_cmdline_user_args() # Run: ./game -- --port=7777 --max-players=32
Deployment options: Docker containers on any cloud (AWS, GCP, Azure), Hathora (has Godot plugin), W4 Cloud (Godot-optimized managed hosting).
◆ Common Gotchas
The bugs that eat hours — learn them here instead
GOTCHA Node Path Mismatches (The #1 Bug)
RPCs and synchronizers route by node path. If the scene tree differs between peers, everything silently breaks.
1. Always name player nodes str(peer_id).
2. Never use change_scene_to_packed() during multiplayer — use MultiplayerSpawner for level transitions.
3. Identical add_child() order across all peers.
GOTCHA Authority Timing Is Treacherous
_ready() fires before MultiplayerSpawner sets authority. If you check authority in _ready(), it will always be the default (server). Set authority during instantiation or in _enter_tree(), not _ready().
# ❌ WRONG — authority not set yet in _ready func _ready(): if is_multiplayer_authority(): # Always true (defaults to server=1) $Camera.make_current() # ✅ RIGHT — use @export setter triggered by spawner @export var player_id := 1: set(id): player_id = id set_multiplayer_authority(id) if id == multiplayer.get_unique_id(): $Camera.make_current()
GOTCHA call_remote Doesn't Execute Locally
If the host calls my_func.rpc() with @rpc("call_remote"), it runs on all other peers but NOT on the host. If the host needs to also run it, use "call_local".
Similarly, rpc_id(1, ...) from the server to itself does nothing with "call_remote".
GOTCHA Reparenting Breaks Synchronization
Moving a node with a MultiplayerSynchronizer to a different parent invalidates cached paths. The synchronizer will stop working silently. If you need to reparent, remove and re-add the node properly.
GOTCHA OfflineMultiplayerPeer Looks Connected
Before networking starts, multiplayer.multiplayer_peer is an OfflineMultiplayerPeer that reports CONNECTION_CONNECTED. Check for offline state explicitly:
var is_online = not ( multiplayer.multiplayer_peer is OfflineMultiplayerPeer )
GOTCHA Both Peers Need the @rpc Method
The @rpc-annotated function must exist in scripts on both the sender and receiver. Godot uses a checksum of all RPC-annotated methods. If they don't match, you get cryptic "RPC node checksum failed" errors. Even if one side never calls the function locally, it must be present in the script.
Final Challenge
You call my_func.rpc() from the server with @rpc("authority", "call_remote"). What happens on the server?
"call_remote" means the function runs on remote peers only — the calling peer (server) does NOT execute it. Use "call_local" if you want it to run everywhere including on the caller.