esc
navigate openSearch by Pagefind

Multiplayer

Scene-tree replication, ENet, authority gates, @rpc arguments, the co-op skeleton, intent RPCs vs synced state.

Godot high-level multiplayer patterns with godot-mcp

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 MultiplayerAPI per node. Each Node exposes multiplayer (verified), and by default all share the tree’s one API. It carries the peer, the connection signals, and get_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, which 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 over 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, so 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 OK

Connection 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 decides who owns each node

It settles 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), and everyone else just displays the replicated result (skeleton below).

  • Server owns the world, meaning enemies, doors, score, and 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.

    Remote procedure calls with @rpc

@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, where an invalid argument is a hard parse error. Confirmed-valid arguments (order-independent, all optional):

  • Who may call: "authority" (the default, only the node’s authority) or "any_peer" (required for client→server intent).
  • Local execution: "call_remote" (the 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, for state and chat), "unreliable" (the default, for high-frequency streams), "unreliable_ordered" (drops stale, keeps order); plus a trailing int channel (default 0) 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).

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

MultiplayerSpawner replicates 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’s spawn_path node, and that scene is in the spawnable list, the spawner recreates it on every client. That is the common path, and you never call spawn() yourself.
  • spawn_function handles custom-data spawns: a Callable(data) -> Node, then spawn(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 back
scene save

MultiplayerSynchronizer replicates node properties

Streams chosen properties from a node’s authority to everyone else, driven by a SceneReplicationConfig. Verified: root_path (NodePath, default .., meaning 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. .:position is the root node’s own position; Sprite2D:modulate a child’s. Make the synchronizer a child of what it syncs and leave root_path default.
  • sync vs watch: a synced property sends every interval; a watched one sends (on delta_interval) only when it changes, which is cheaper for rarely-moving state. spawn includes the value in the initial spawn snapshot.
  • replication_interval throttles frequency, and 0 (default) = every network frame (the bound setter names its arg milliseconds). 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=false
scene save

The canonical co-op skeleton

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 and self-assigns it from its node name:

scene create --path res://entities/player.tscn --root-type CharacterBody2D --root-name Player
scene open --path res://entities/player.tscn
node 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 CircleShape2D
multiplayer add-synchronizer --parent-path . --name Sync --root . --properties '[".:position"]'
script create --path res://entities/player.gd --extends CharacterBody2D
script attach --node-path . --script-path res://entities/player.gd
scene save
extends 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 holds a Players container plus the spawner over it:

scene create --path res://game.tscn --root-type Node2D --root-name Game
scene open --path res://game.tscn
node add --type Node --name Players --parent-path .
multiplayer add-spawner --parent-path . --name PlayerSpawner \
--spawn-path Players --scenes '["res://entities/player.tscn"]'
scene save

3. 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 it

Spawn 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, and health change every frame, and only the latest value matters (a dropped unreliable packet self-corrects).
  • Discrete events → RPCs. Fire, jump, pickup, chat, and “round over” are 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, or the receiver acts twice. And never sync derived state: replicate the source and recompute UI locally.

Transport security: ENet is plaintext UDP

Raw ENetMultiplayerPeer traffic is unencrypted. Anyone on the path can read and replay it, which is irrelevant for a LAN party and relevant the moment strangers connect over the internet. The engine ships DTLS for it: ENetConnection.dtls_server_setup(TLSOptions) on the host and dtls_client_setup(hostname, TLSOptions) on the client (both confirmed on the live 4.7 build), at the cost of certificate setup. Most small games skip DTLS and lean on the pattern this doc already teaches, where the server validates every intent RPC and owns every fact. That protects the game’s integrity but not the traffic’s privacy. Decide which of the two you are protecting before reaching for either.

Testing locally means being 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, and there is 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 _ready reads: godot --path . -- --server and godot --path . -- --client (parse OS.get_cmdline_args()Net.host() / Net.join(...)); a one-key test can spawn the second via OS.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_peer and 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 the if not is_multiplayer_authority(): return gate, or peers simulate every node and diverge.

  • Mismatched node paths: a player named str(id) on the server but auto-named Player2 on a client means every path-addressed message misses. Keep names identical everywhere.

  • Trusting client RPCs. An @rpc("any_peer") handler that skips get_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, not SceneTree; confirm any networking API with engine class-info before writing it.

Built with the help of godot-mcp. MIT licensed.