Godot multiplayer (network programmer)
Target: Godot 4.7.2, SceneMultiplayer over ENet and WebSocket.
Expert level here means the networked game is proven with several processes, under injected latency, before anyone plays it: authority is a written decision per object, every client message is a request the server checks, and replication cost is measured in KiB/s. This skill builds the replication in code, runs a server and 2 to 3 clients headless on localhost (plus a browser client), and passes only on numbers from those runs. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-godot-expert (channels, gd_run, review loop, 4.7 traps).
Status (2026-10-02): 16 live tests (up to 3 clients, ENet, WebSocket, exported server, headless Chrome) and 18 offline tests pass. Evidence: tests/live_evidence/godot-multiplayer/.
Stance (the expert delta)
- Decide authority per object before writing code. Someone must own each object's data (AndrooDev, jff9oxO8v1s [00:04:01]). Client authority over the body is fine for casual co-op; competitive or cheat-prone games keep the body on the server and give clients their input only (Tamas Galffy, iP_xdJ0peFo [00:03:46, 00:06:47]; BatteryAcidDev, GqHTNmRspjU [00:00:33]). Deciding condition: cost of cheating against cost of input lag. Measured at 250 ms RTT: server authority shows your own move after 294 to 310 ms, client authority after 11 to 22 ms, and a client-authority cheater's 50 m jump stuck.
- Set authority from the node name in
_enter_tree. Name each player node with its peer id; _enter_tree calls set_multiplayer_authority(name.to_int()) so it holds before _ready (Travis Hunter, tK2ACXUGcrY [00:46:16]). Do not read a spawn-synced property there: on every client copy measured, peer_id was still 0 in _enter_tree.
- Every
any_peer RPC is a request. Read multiplayer.get_remote_sender_id() first (it is 0 after an await, measured), check that the sender owns the target and that the values are possible, then act. A forged teleport for another player and a 30 m jump were both rejected in the live test.
- The RPC checksum compares method names only, whatever the docs say. The high-level docs say it covers every
@rpc declaration and skips arguments; two live runs in 4.7.2 (M2, round 1 and round 2) disagree. A name missing on one side logs "The rpc node checksum failed" on both sides and RPCs are matched by sorted index: the client called b and the server ran a. A different mode only fails per call ("not allowed"), a different argument count fails per call ("expected 2 argument(s)"), different call_local or transfer mode passes silently. Keep server and client on one script per node path (one codebase: Davide Di Staso, nyKBuM9Y_-Q).
- Test with latency and three players. Localhost hides every latency bug; two players hide what a bystander sees (Galffy, iP_xdJ0peFo [00:09:58, 00:42:31]).
gd_net.UdpLagProxy adds delay, jitter and loss on localhost without root.
- Replication cost is a design input. ALWAYS sends every network frame whether or not the value changed; ON_CHANGE is reliable and costs more than ALWAYS while values move (measured: 72.6 against 100 KiB/s for 50 moving props). A headless server is uncapped: 145 fps here, 2.2 to 2.4 times the bandwidth of 60 fps. Cap it (
--max-fps, Engine.max_fps) or set replication_interval. Cut by visibility (Di Staso [00:14:29]).
- Know which connection event fires where. On a joining client,
peer_connected fires once per existing peer, server id 1 included; connected_to_server fires on clients only, so send client info there, not from peer_connected (docs delta 7; FinePointCGI e0JLO_5UgQo [00:22:50]). After the server leaves, get_unique_id() reads 0: skip id 0 in registries and spawn loops (pA9RGn87Uag [00:47:31]). All measured (M14).
- The server stops speed hacks, not wall hacks. Server authority blocks impossible moves and infinite health; it does not stop wall hacks or aimbots, because every client still receives everyone's position. Interest management (synchronizer visibility, server line-of-sight checks) limits the leak (MfjEB1cowsE [00:18:55]; iP_xdJ0peFo [00:53:28]).
- The dedicated server is the same project exported in "dedicated server" mode. That mode adds the
dedicated_server feature, which forces headless display and dummy audio even without --headless (measured on the exported macOS binary), and Strip Visuals removed the 3.1 MB texture from the server pack (BatteryAcidDev, jgJuX04cq7k [00:04:47]).
Establish first
Genre and player count; who may cheat (competitive, co-op, friends only); platforms (browser means WebSocket or WebRTC, no ENet); topology (dedicated server, listen server, relay or P2P); a backend already chosen (Steam, Nakama, Colyseus, own server); hosting budget (cloud costs money: ask before any deployment); tick and target RTT (default 60 Hz server, play tested at 100 and 250 ms). Defaults: one project, server authority with input-only clients for anything competitive, ENet for desktop and mobile, WebSocket behind TLS (wss://) for the web.
Workflow
- Network design note. Per object: owner, what replicates (synchronizer property and mode), what is an event (RPC with mode, transfer and channel), what a client may request. Streams of unreliable_ordered updates get their own transfer channel, channel last in
@rpc (docs deltas 4, 5); idle players send nothing below a velocity threshold (pA9RGn87Uag [00:42:16]). GATE: no object without an owner; no client-owned health, score or inventory unless the brief accepts cheating.
- Build the replication in code.
gd_net.install_netkit(P), then net_build.gd (make_sync, make_spawner) writes scenes with SceneReplicationConfig set in code; the Replication panel is for humans (references/gui-paths.md). Players: name = peer id, add_child(node, true), authority in _enter_tree. GATE: net_audit.gd:scene has no flags (root paths resolve, no Object or RID property, no @Node@2 names, intervals match modes).
- Session test on localhost.
gd_net.session(P, roles) runs a server and 2 to 3 clients, each in its own APFS clone, all machine slots held first. Cover the lobby gate, a late joiner, a leaver, cheat RPCs, and a password through auth_callback plus complete_auth on both sides (P5). GATE: every client sees every player, the late joiner sees all, the leaver's node is freed everywhere, authority report correct, forged RPCs rejected, logs clean.
- Latency pass. Rerun through
UdpLagProxy at 100 and 250 ms, then 250 ms with 30 ms jitter and 5 % loss; replication_delay() measures how far behind the server truth a remote client shows a player. GATE: the numbers fit the genre, or prediction is planned (netfox or own).
- Bandwidth pass. Server KiB/s per client from ENet counters (
bandwidth in the result), per mode, interval and visibility. GATE: under the budget at max players, fps capped.
- Web and transports. Same session with
transport="websocket"; for a browser build, export Web and run the client in headless Chrome (procedure P9). WebSocket is TCP: "unreliable" RPCs arrive reliable and ordered, with head-of-line blocking under loss; WebRTC gives unreliable browser channels but needs signalling, STUN/TURN and webrtc-native on desktop (WebRTC doc; wgIqB6JNcro [00:19:35]). An HTTPS page needs wss://. A background tab stops _process and the socket drops: build reconnect (web doc). GATE: the browser client joins, sees all players, RTT measured.
- Dedicated server.
gd_net.server_preset(), export, compare client and server packs (pack_report), run the exported binary without --headless and connect clients. Set application/run/flush_stdout_on_print=true for systemd or journald logs (docs delta 12; default false, measured). GATE: dedicated_server feature true, display headless, no client textures in the pack, exit 0.
- Look. A windowed observer client captures the replicated world (
capture_at); open it. GATE: players where the server says, colors per peer, nothing missing.
Harness in one call
import sys; sys.path[:0] = ["<skills>/scenario-godot-expert/scripts", "<skills>/scenario-godot-multiplayer/scripts"]
import gd_net
gd_net.install_netkit(P); gd_net.build_scenes(P) # res://net/world.tscn, player.tscn, props
roles = [gd_net.role("server", role="server", port=24010, lobby_size=2),
gd_net.role("c1", role="client", port=24011, index=0, delay_s=0.3, cheat_at=2.0),
gd_net.role("c2", role="client", port=24011, index=1, delay_s=0.3)]
with gd_net.UdpLagProxy(24011, 24010, rtt_ms=100, jitter_ms=0, loss=0.0):
s = gd_net.session(P, roles) # one APFS clone and one Godot per role
s["roles"]["c1"]["result"]["rtt_median_ms"] # every NetLab number is in AGENT_RESULT
Your own game uses the same pattern: a job script per role (role(name, script=...)), user args after --, AGENT_RESULT printed by each process. Keep ports unique per project; two roles never share a project folder.
Prediction, rollback and backends (not run here)
netfox gives server authority with client prediction and rollback: input gathered on the network tick, logic in _rollback_tick, NetworkTime.physics_factor around move_and_slide, TickInterpolator for display (GqHTNmRspjU [00:06:58, 00:18:55, 00:21:24]; iP_xdJ0peFo [00:28:00]). Prove it on a small prototype first: it is opinionated (GqHTNmRspjU [00:34:53]). Deterministic input-only rollback fits fighting and RTS-like games with 2 to 8 players (David Snopek, zvqQPbT8rAE). Steam (GodotSteam, SteamMultiplayerPeer), Nakama, Colyseus and WebRTC P2P swap the peer and lobby calls; authority, spawner and synchronizer code stays the same (Gwizz, fUBdnocrc3Y; AndrooDev, wgIqB6JNcro [00:07:18]). None of these ran here: they need a third-party addon, an account or a signalling server. Verify against the installed release before quoting an API.
Numbers
| Value | Measured here (2026-10-02, M-series Mac, localhost) | Relative to |
|---|
| RPC round trip, no proxy | 16 to 19 ms | 60 fps both ends; one frame each way dominates |
| ENet RTT statistic | 15 to 23 ms | same |
| Own input to visible motion, server authority | 47 to 52 ms | about 3 frames |
| Remote view behind server truth | 20 to 30 ms, 0.07 m error at 3 m/s | no proxy |
| Same at 100 / 250 ms RTT | 70 to 80 / 150 to 155 ms; 0.22 / 0.44 m | UdpLagProxy, adds about 6 ms per direction |
| 50 props, ALWAYS, 60 fps | 72.6 KiB/s, moving or idle | 1 client |
| ON_CHANGE moving / idle | 98 to 101 / 2.3 KiB/s | same |
ALWAYS, replication_interval 0.05 | 21 to 22 KiB/s | same |
| ALWAYS, uncapped headless server (145 fps) | 160 to 175 KiB/s | same |
auth_timeout default; kick of a silent peer | 3.0 s; 1.50 s after setting 1.5 | server |
| Web export, single-thread | 40 MB, wasm 39.5 MB (10.1 MB gzip) | handoff to scenario-godot-performance-export |
| Dedicated server pack / client pack | 0.11 / 3.27 MB (3.1 MB texture stripped) | same project |
spawn_limit | 0 = unlimited (default); limit 2: third spawn() logs "Spawn limit reached!", returns null, clients get 2 | M14; raise it to max players (jgJuX04cq7k [00:18:34]) |
Quality gates
- Measurable:
net_audit flags empty; every role ok with no unexpected engine error; players_seen_max equals the player count everywhere; authority body 1, input = peer; teleport counts as expected; RTT and replication_delay at 100 and 250 ms; KiB/s per mode; rpc_audit reviewed, rpc_pair_check ok; exported server boot dict correct.
- Visual: observer capture shows every player; under 250 ms no streak after a teleport; the browser screenshot shows the replicated players.
Common mistakes
| Mistake | What it looks like | Fix |
|---|
Players spawned only on the server without a spawner, or spawner spawn_path empty | client sees nobody | MultiplayerSpawner with spawn_path and the scene in its list; server calls add_child |
Authority read from a synced property in _enter_tree | authority 1 everywhere on clients | authority from name.to_int() |
Runtime node without add_child(node, true) | @Node@2 names, "node not found" on RPC | readable names |
await before get_remote_sender_id() | sender 0, validation passes or fails wrongly | read the sender first |
| RPC added on the server script only | checksum error, the wrong method runs | one script, or rpc_pair_check |
| Object or Resource as RPC argument | no error; receiver gets EncodedObjectAsID | send ids, paths or plain data |
| Client owns its body in a competitive game | speed and teleport hacks stick | input-only authority plus server simulation |
| Headless server not capped | 2.4 times the bandwidth | --max-fps 60 or intervals |
server_relay turned off for a P2P-style feature | "Attempt to call RPC with unknown peer ID" | keep relay on, or route through the server |
| Freeing a networked branch in the wrong order | "isn't currently active", "Node not found" | offline peer, free contents, drop custom API, free branch |
| Server export in normal mode | full-size pack, window opens | dedicated server export mode, Strip Visuals |
Server start inside assert() | works from the editor; the release export never listens | var err := peer.create_server(port), then check err (M15: assert body stripped in release) |
Client info sent from peer_connected, or id 0 registered | duplicate nodes on late joiners, a phantom player | connected_to_server; skip id 0 |
| Client and server on different Godot versions | connection or protocol failures | same 4.7.2 build (protocol changed in 4.3) |
Handoffs
- Receives: the game scene and player controller from scenario-godot-gameplay and scenario-godot-3d-world or scenario-godot-2d; architecture rules from scenario-godot-architecture.
- Delivers: network design note, kit scenes or scripts, session evidence JSON, server preset; the web build size and headers go to scenario-godot-performance-export, CI jobs to scenario-godot-pipeline-automation, lobby screens to scenario-godot-ui.
Godot 4.7 notes
get_rpc_config became get_node_rpc_config (4.5); @rpc tables live on Script.get_rpc_config().
auth_timeout is the property; the docs page spells auth_timout.
- 4.7.2 fixed peers that stopped replicating after deleting a spawner-spawned node.
- Godot 3 names (
NetworkedMultiplayerENet, remote, rset, get_rpc_sender_id) do not exist; gd_net.rpc_audit flags them.
- WebRTC on desktop needs the webrtc-native extension:
initialize() returns OK and only logs an error without it (measured).
- Open: under heavy machine load, 4 of 20 ALWAYS runs sent about 10 packets/s instead of 68 (ENet throttle at 0). Log
NetUtil.enet_throttle() in long sessions (P8).
- Two peers leaving in one server frame log "Unable to send packet on channel 0" (ENet) or
ready_state != STATE_OPEN (WebSocket); harmless, whitelisted by exact text.
References