Interactive Learning Guide

Godot 4.x Multiplayer & Networking

From transport layers to rollback netcode. Interactive lessons, runnable code examples, and knowledge checks.

Architecture Overview

The three-layer stack that powers all Godot 4.x networking

Click each layer to explore

▲ Scene Tree Layer
Every Node has a multiplayer property pointing to the active MultiplayerAPI. MultiplayerSpawner and MultiplayerSynchronizer nodes live here, automatically replicating children and properties across peers.
■ API Layer — SceneMultiplayer
The default MultiplayerAPI implementation. Routes RPCs by node path, manages peer authentication (auth_callback, send_auth, complete_auth), controls server relay, and enforces spawn limits. Swappable via get_tree().set_multiplayer(api, path).
● Transport Layer — MultiplayerPeer
Pluggable transport backends: ENetMultiplayerPeer (UDP), WebSocketMultiplayerPeer (TCP/WS), WebRTCMultiplayerPeer (P2P/DTLS). Create one, call create_server() or create_client(), then assign to 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.

GDScript
# ── 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())
Key Fact

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:

SignalFires OnUse Case
peer_connected(id)Server + all clientsSpawn player, update lobby
peer_disconnected(id)Server + all clientsRemove player, cleanup
connected_to_server()Client onlySend player info, enter game
connection_failed()Client onlyShow error, retry UI
server_disconnected()Client onlyReturn to menu, show message

IMPORTANT SceneMultiplayer Deep Dive

SceneMultiplayer extends MultiplayerAPI with practical features you'll need for production games:

PropertyDefaultWhat It Does
server_relaytrueServer relays messages between clients. Disable to save bandwidth if clients don't need direct communication.
auth_callbacknullCallable for custom authentication. Peers stay in "authenticating" until complete_auth(id) is called.
auth_timeout3.0Seconds before unauthenticated peers are kicked.
allow_object_decodingfalseSecurity flag — leave false unless you trust all peers (never in production).
max_sync_packet_size1350Max bytes per sync packet. Increase for many synced properties.
GDScript
# 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?

The server is always peer ID 1. This is hardcoded in the engine and cannot be changed. Clients receive random positive integers. You'll use 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.

GDScript
# 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))
Mesh Mode

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.

GDScript
# 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")
Critical Caveat

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.

GDScript
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).

FeatureENetWebSocketWebRTC
ProtocolUDPTCPUDP (DTLS)
Browser supportNoYesYes
Unreliable msgsYesNo (all TCP)Yes
NAT traversalManual / UPNPThrough proxiesBuilt-in ICE
TopologyClient-server + meshClient-serverFull mesh
Best forDesktop gamesTurn-based / browserP2P browser games

Knowledge Check

Which transport should you use if you need unreliable messages in an HTML5 browser game?

WebRTC is the only browser-compatible transport that supports true unreliable messages. ENet doesn't work in browsers (no raw UDP), and WebSocket forces all messages to be reliable via TCP.

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):

GDScript
@rpc(mode, sync, transfer_mode, channel)
func my_function():
    pass
ParameterOptionsDefaultMeaning
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
ChannelAny integer0Prevents reliable traffic from blocking unrelated channels

IMPORTANT Common RPC Patterns

Pattern 1: Client requests action from server

Client
request_action.rpc_id(1, data)
Server validates
apply_action.rpc(result)
All Clients
GDScript
# 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)

GDScript
@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

GDScript
# 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.

GDScript
# 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).

GDScript
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
Gotcha

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

PropertyTypePurpose
spawn_pathNodePathParent node where spawned children appear
spawn_limitintMax remote spawns allowed (0 = unlimited). Security measure against spawn flooding.
spawn_functionCallableCustom 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:

ModeEnum ValueBehaviorUse For
NEVER0Synced at spawn only (if spawn flag on)Player name, color, team
ALWAYS1Sent every interval, even if unchangedPosition, velocity, rotation
ON_CHANGE2Sent only when value differsHealth, ammo, score
GDScript
# 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
Cannot Replicate

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.

GDScript
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.

Scene Tree
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
Client gathers input
InputSync → server
Server runs physics
ServerSync → all
Clients update
GDScript — player.gd
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()
GDScript — input_sync.gd
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
Critical Convention

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.

GDScript — lobby.gd (autoload)
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)
No Host Migration

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:

GDScript
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.

GDScript
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

PluginLanguageFeatures
Snopek Rollback NetcodeGDScriptInput gathering, save/load state, mismatch detection, Log Inspector. WebRTC via Nakama adapter.
gdrollbackRust (GDExtension)High-performance rollback. Peer-to-peer focused.
MonkeNetC#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:

Bash
./your_game --headless --audio-driver Dummy
GDScript — detecting headless
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.

Rules

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().

GDScript
# ❌ 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:

GDScript
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.
0 / 0 lessons