scenario-godot-multiplayer
About scenario-godot-multiplayer
A skill for building multiplayer networking in Godot 4.7.2. It helps network programmers define authority and replication, then validate behavior with multiple processes, injected latency, and browser clients using measured results.
Key features
- 01Design authority per object
- 02Build scene replication configuration
- 03Run multi-client session tests
- 04Inject latency, jitter, and loss
- 05Measure replication bandwidth
- 06Test WebSocket and dedicated servers
Use cases
- 01Check server validation of client RPCs
- 02Test late joins and player departures
- 03Assess latency effects on movement
- 04Estimate multiplayer replication bandwidth
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ scenario-labs/ skills --skill scenario-godot-multiplayerAfter installing, type/scenario-godot-multiplayerin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/critique.mdexpert-notes.mdgui-paths.mdprocedures.mdsources.mdscripts/gd_net.pyscripts/agentkit/multiplayer/net_audit.gdnet_boot.gdnet_build.gdnet_input.gdnet_lab.gdnet_player.gdnet_prop.gdnet_session_job.gdnet_util.gdDetails
From the project READMEGodot 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_treecallsset_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_idwas still 0 in_enter_tree. - Every
any_peerRPC is a request. Readmultiplayer.get_remote_sender_id()first (it is 0 after anawait, 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
@rpcdeclaration 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 calledband the server rana. A different mode only fails per call ("not allowed"), a different argument count fails per call ("expected 2 argument(s)"), differentcall_localor 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.UdpLagProxyadds 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 setreplication_interval. Cut by visibility (Di Staso [00:14:29]). - Know which connection event fires where. On a joining client,
peer_connectedfires once per existing peer, server id 1 included;connected_to_serverfires on clients only, so send client info there, not frompeer_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_serverfeature, 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), thennet_build.gd(make_sync,make_spawner) writes scenes withSceneReplicationConfigset 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:scenehas no flags (root paths resolve, no Object or RID property, no@Node@2names, 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 throughauth_callbackpluscomplete_authon 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
UdpLagProxyat 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 (
bandwidthin 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 needswss://. A background tab stops_processand 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--headlessand connect clients. Setapplication/run/flush_stdout_on_print=truefor systemd or journald logs (docs delta 12; default false, measured). GATE:dedicated_serverfeature true, displayheadless, 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_auditflags empty; every roleokwith no unexpected engine error;players_seen_maxequals the player count everywhere; authority body 1, input = peer; teleport counts as expected; RTT andreplication_delayat 100 and 250 ms; KiB/s per mode;rpc_auditreviewed,rpc_pair_checkok; 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_configbecameget_node_rpc_config(4.5);@rpctables live onScript.get_rpc_config().auth_timeoutis the property; the docs page spellsauth_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_auditflags 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
references/procedures.md: P1 to P14 with code, test ids and results.references/expert-notes.md: claims by expert with timestamps and verified corrections.references/critique.md,references/gui-paths.md(Replication panel, Run Instances, Network Profiler),references/sources.md.scripts/gd_net.py,scripts/agentkit/multiplayer/: the harness and kit.