Multiplayer
Scene-tree replication, ENet, authority gates, @rpc arguments, the co-op skeleton, intent RPCs vs synced state.
Building co-op / versus games on Godot’s high-level multiplayer (SceneMultiplayer +
RPC + the replication nodes), mapped to the CLI. The multiplayer command group wires the
two replication nodes; everything else is plain node.* / script.* plus the live API.
Verify APIs against the running engine (engine class-info --class MultiplayerAPI). The class surface
below was read from the running build, but two-peer behavior cannot be exercised from
this single-instance CLI — the runtime claims are the standard architecture, proven with two
real instances (see “Testing locally”), not something this session watched replicate.
The mental model#
High-level multiplayer replicates a scene tree, not packets. Every peer runs the same
scene with the same node paths — /root/Game/Players/12345 must exist and mean the same
thing everywhere, because RPCs and synchronizers address nodes by path. Break path
symmetry and every message silently misfires.
- One
MultiplayerAPIper node. EachNodeexposesmultiplayer(verified) — by default all share the tree’s one API. It carries the peer, the connection signals, andget_unique_id()/is_server()/get_peers()/get_remote_sender_id()(all verified). - Server-authoritative is the default. State lives on the server; clients send intent (“move left”, “fire”), the server simulates, the result streams back. Clients never own truth — that is what makes a game hard to cheat. The pragmatic co-op shortcut (client owns its own player) is under Authority, with its tradeoff stated.
- Unique ids. The server is always peer
1; each client gets a random positive id.multiplayer.get_unique_id()returns this peer’s id (1= “I am the server”).
This doc is realtime state replication; for request/response web traffic (leaderboards) use
HTTPRequest, not this stack — see game-patterns.md “Networking — HTTP”.
Connection & peers#
ENetMultiplayerPeer is the transport. Verified: create_server(port, max_clients, ...) and
create_client(address, port, ...) both return an Error int (OK == 0 — check it).
Assign the peer to multiplayer.multiplayer_peer and the tree is networked.
extends Node # autoload "Net", registered via `project add-autoload`const PORT := 8910
func host() -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_server(PORT, 4) # port, max_clients if err != OK: return err # port in use — never assume it bound multiplayer.multiplayer_peer = peer # this peer is now id 1 (the server) return OK
func join(address: String) -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_client(address, PORT) if err != OK: return err multiplayer.multiplayer_peer = peer return OKConnection signals live on MultiplayerAPI, not SceneTree (verified — the engine moved them
off the tree). Wire once in the autoload’s _ready: peer_connected(id) /
peer_disconnected(id) fire on every peer; connected_to_server() / connection_failed() /
server_disconnected() are client-only. Server-side peer_connected is where you spawn the
newcomer’s player. Host/Join buttons and the lobby menu belong to a Control scene — build
it with the container-driven skeleton in menus-settings.md; the pressed handler calls
Net.host() / Net.join(address). Autoload tiering: gdscript-architecture.md.
Authority — who owns each node#
Authority decides which peer’s copy of a node is truth (verified Node methods):
set_multiplayer_authority(id, recursive = true) claims a node (and its subtree);
is_multiplayer_authority() asks “do I own this?”; get_multiplayer_authority() returns the
owner. Authority defaults to the server (1) until reassigned. Gate simulation on it —
the owner runs the logic (if not is_multiplayer_authority(): return at the top of
_physics_process), everyone else just displays the replicated result (skeleton below).
- Server owns the world — enemies, doors, score, physics. Authority stays
1. Discrete facts go out as RPCs from the server; continuous state rides a synchronizer. - Each client owns its own player (client-authoritative input) — the pragmatic co-op
shortcut. In
_ready,set_multiplayer_authority(str(name).to_int())(node named for its peer id, below); the gate lets only the owner drive it. Cheap, responsive, no round-trip lag on your own avatar. Tradeoff: it trusts the client — a modded client can teleport or speed-hack, because its position is the truth. Fine for friendly co-op, wrong for competitive, where authority stays on the server: the client sends an intent RPC (request_move(dir)), the server validates and simulates, and the synchronizer streams the vetted position back.
@rpc — remote procedure calls#
@rpc marks a function callable across the wire. Its annotation arguments cannot be read
from ClassDB (parser sugar), so the accepted set was verified by compiling variants
through script validate — an invalid argument is a hard parse error. Confirmed-valid
arguments (order-independent, all optional):
- Who may call:
"authority"(default — only the node’s authority) or"any_peer"(required for client→server intent). - Local execution:
"call_remote"(default — remote peers only) or"call_local"(also runs on the caller, so the sender sees its own effect). - Transfer mode:
"reliable"(resent until acked — state, chat),"unreliable"(default — high-frequency streams),"unreliable_ordered"(drops stale, keeps order); plus a trailing int channel (default0) to avoid head-of-line blocking between traffic.
Verified to compile: @rpc, @rpc("any_peer"), @rpc("call_local"), @rpc("reliable"),
@rpc("unreliable_ordered"), and the full @rpc("any_peer", "call_local", "reliable", 2).
Verified to fail: @rpc("bogus_mode") and @rpc(..., "super_reliable", 0) — a wrong mode
string is a parse error, so validate any hand-written annotation before trusting it.
Invoke with rpc("method", args...) (all peers) or rpc_id(1, "method", args...) (one target
— here the server). Always validate an any_peer RPC’s caller with
multiplayer.get_remote_sender_id() (verified), or a client calls your server functions
directly. Author RPCs with script edit (annotated methods on the node’s script) + editor reload.
@rpc("any_peer", "reliable")func request_fire() -> void: var who := multiplayer.get_remote_sender_id() if not _player_owns_a_gun(who): # never trust the call blindly return _spawn_bullet_for(who) # server authoritative: server does the workMultiplayerSpawner — replicate node existence#
A synchronizer replicates properties of existing nodes; a MultiplayerSpawner replicates
the existence of nodes. Verified: add_spawnable_scene(path), spawn_path (NodePath),
spawn_limit (int), spawn_function (Callable), spawn(data).
- Auto-spawn: when the server
add_child()s a node under the spawner’sspawn_pathnode, and that scene is in the spawnable list, the spawner recreates it on every client — the common path; you never callspawn()yourself. spawn_functionhandles custom-data spawns: aCallable(data) -> Node, thenspawn(data)(character choice, spawn point). Our command doesn’t set it; assign in code.
Our command wires the awkward spawn_path NodePath (a scene-relative path it resolves to
the stored spawner-relative one) and the scene list, validating each exists (missing_scenes);
--spawn-limit caps concurrent spawns:
node add --type Node --name Players --parent-path .multiplayer add-spawner --parent-path . --name PlayerSpawner \ --spawn-path Players --scenes '["res://entities/player.tscn"]'multiplayer info --node-path PlayerSpawner # read spawn_path + spawnable_scenes backscene saveMultiplayerSynchronizer — replicate node properties#
Streams chosen properties from a node’s authority to everyone else, driven by a
SceneReplicationConfig. Verified: root_path (NodePath, default .. — its parent),
replication_config, replication_interval, delta_interval, public_visibility (bool),
set_visibility_for(peer, visible), visibility_update_mode. The config is built with
add_property(path) + property_set_spawn/sync/watch(path, enabled) (all verified).
- Property paths are relative to
root_path..:positionis the root node’s ownposition;Sprite2D:modulatea child’s. Make the synchronizer a child of what it syncs and leaveroot_pathdefault. syncvswatch: a synced property sends every interval; a watched one sends (ondelta_interval) only when it changes — cheaper for rarely-moving state. spawn includes the value in the initial spawn snapshot.replication_intervalthrottles frequency —0(default) = every network frame (the bound setter names its argmilliseconds). Visibility:public_visibility = false+set_visibility_for(peer, true)scopes a synchronizer to specific peers (fog-of-war).
The direction gotcha: a synchronizer replicates FROM the authority of its root_path
node TO everyone else. If the player’s authority is its owning client, that client’s
position is the source and streams outward; if authority is the server, the server’s copy
wins. Set authority before relying on the sync, or you stream the wrong copy and rubber-band.
multiplayer add-synchronizer --parent-path . --name Sync --root . \ --properties '[".:position", ".:rotation"]'multiplayer add-sync-property --node-path Sync --property ".:velocity" --watch=true --sync=falsescene saveThe canonical co-op skeleton#
The flagship — a host/join lobby, a spawner minting one player per peer, per-peer authority, and a synchronizer streaming each player’s transform. Client-authoritative input (the shortcut); the notes above say how to harden it to server-authoritative.
1. Player scene — gates on authority, self-assigns authority from its node name:
scene create --path res://entities/player.tscn --root-type CharacterBody2D --root-name Playerscene open --path res://entities/player.tscnnode add --type Sprite2D --name Sprite --parent-path .node add --type CollisionShape2D --name Col --parent-path .node add-resource --node-path Col --property shape --resource-type CircleShape2Dmultiplayer add-synchronizer --parent-path . --name Sync --root . --properties '[".:position"]'script create --path res://entities/player.gd --extends CharacterBody2Dscript attach --node-path . --script-path res://entities/player.gdscene saveextends CharacterBody2D@export var speed := 300.0
func _ready() -> void: set_multiplayer_authority(str(name).to_int()) # node is named for its peer id
func _physics_process(_delta: float) -> void: if not is_multiplayer_authority(): return # non-owners are moved by the Sync node var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") velocity = dir * speed move_and_slide()2. Game scene — a Players container plus the spawner over it:
scene create --path res://game.tscn --root-type Node2D --root-name Gamescene open --path res://game.tscnnode add --type Node --name Players --parent-path .multiplayer add-spawner --parent-path . --name PlayerSpawner \ --spawn-path Players --scenes '["res://entities/player.tscn"]'scene save3. Server spawns on connect (in the Net autoload). The server instances a player named
for the peer id under Players; the spawner replicates it, and each player’s _ready claims
authority from its name — so the owner drives it and its synchronizer streams the result out:
const PLAYER := preload("res://entities/player.tscn")
func _on_peer_connected(id: int) -> void: if not multiplayer.is_server(): return # only the server spawns var p := PLAYER.instantiate() p.name = str(id) # path symmetry: /Game/Players/<id> everywhere get_node("/root/Game/Players").add_child(p) # spawner sees the add → replicates itSpawn the host’s own player the same way after host() (call _on_peer_connected(1)).
Intent RPCs vs synchronized state — never both for one fact#
The most common design error is replicating one fact two ways. Split by shape:
- Continuous state →
MultiplayerSynchronizer. Positions, rotations, health — changes every frame, only the latest value matters (a dropped unreliable packet self-corrects). - Discrete events → RPCs. Fire, jump, pickup, chat, “round over” — one-shot facts that
must arrive exactly once (
"reliable") and are meaningless to poll.
Pick one per fact. Do not sync a bool is_firing and send a fire() RPC — the receiver
acts twice. And never sync derived state: replicate the source and recompute UI locally.
Testing locally — be honest about the harness#
Networking needs two running instances, and this CLI cannot provide the second: the
runtime / input groups broker one game process over file IPC — no multi-instance
driver here. Options, most practical first:
- Editor “Debug ▸ Customize Run Instances” — Godot’s editor launches N instances at once, each with its own args/feature tags (one server, the rest clients). An editor feature, not a CLI one; drive it by hand.
- Two OS processes with a role flag your
_readyreads:godot --path . -- --serverandgodot --path . -- --client(parseOS.get_cmdline_args()→Net.host()/Net.join(...)); a one-key test can spawn the second viaOS.create_process. - What the CLI can verify on its single instance: scene structure (
scene tree), that the spawner/synchronizer hold the right config (multiplayer info), that scripts compile and RPC annotations parse (script validate). The replication itself you confirm across two windows.
Common mistakes to avoid#
- Peer set after scenes load. Assign
multiplayer.multiplayer_peerand wire its signals before the networked scene enters the tree, or early spawns/RPCs fire into a peerless tree. - Missing authority checks — every networked
_physics_process/ input handler needs theif not is_multiplayer_authority(): returngate, or peers simulate every node and diverge. - Mismatched node paths — a player named
str(id)on the server but auto-namedPlayer2on a client means every path-addressed message misses. Keep names identical everywhere. - Trusting client RPCs — an
@rpc("any_peer")handler that skipsget_remote_sender_id()validation lets any client run server logic with forged args. - Syncing derived state, or both an RPC and a synced property for one fact (receiver double-acts) — see “Intent vs state”.
- Trusting a remembered signal home — connection signals are on
MultiplayerAPI, notSceneTree; confirm any networking API withengine class-infobefore writing it.