Multiplayer Architecture — analysis & the land/WorldManager refactor #
2026.06.09 — written alongside the land (3D meadow) build. Companion to
plans/arena-multiplayer.md (the original Q3-style design doc).
The stack today #
piece (land.mjs / arena.mjs, in the disk worker)
│ usercmds @60Hz (cmd backup ×3, seq-numbered) snaps @30Hz (delta vs ack)
▼ ▲
net.udp ── geckos.io WebRTC datachannel (unordered/unreliable)
net.socket ─ WebSocket (reliable; hello/bye/join/leave/ping + cmd/snap fallback)
│
▼
session-server (ONE monolith node process for everything:
chat ×3 instances, duel, worlds, notepat MIDI, DAW, status streams, machines)
└─ WorldManager per world {arena, land}: 60Hz tick, 30Hz per-client
delta snapshots, pmove integration on cmd arrival (Q3-style)
Client prediction: cam-doll integrates the same pmove model locally;
reconcileLocal() replays unacked cmds from the server's authoritative state
and soft-corrects (dead zone 0.5u, soft-K 0.18, hard snap > 4u). Remote
players render ~100ms in the past from an interpolation buffer, with ≤250ms
velocity extrapolation when the buffer starves.
Why it felt skippy #
Ranked by observed impact:
- Silent UDP death (found & fixed today). A WebRTC datachannel can die
while
webrtcConnection.statestays"open"andchannel.emit()keeps returning success. The server's "prefer UDP, fall back to WS" check never falls back, so the client receives nothing — remotes freeze, then the piece coasts on extrapolation, then teleports. Reproduced 100% in headless Chromium (UDP resolves, then rx dies ~8s in); the same failure shape exists on flaky NATs/mobile in prod, where it reads as "skippy". Fix (shipped):- server: stall watchdog in
broadcastSnapshots— a player on UDP whose snap-ack hasn't advanced for ~2s of sends is demoted to WS snapshots, with a 30s re-promotion block so a dead-but-open channel can't flap; - session.mjs re-resolves the UDP channel on every UDP cmd (freshest liveness signal), gated by that block;
- client (arena + land): if snaps go quiet >1.5s while UDP claims connected, cmds are mirrored over WS so the server keeps seeing acks.
- server: stall watchdog in
- The lobby leak (fixed today).
broadcastWSwaseveryone()— every arena join/leave hit every client on the monolith, including chat tabs; anything not explicitly routed in session.mjs also relays to everyone. World events are now member-scoped inside WorldManager (it already knows every member wsId), and all<world>:*verbs are consumed by the world router instead of falling through to the relay. - Server integrates only on cmd arrival. Faithful to Q3, but cmds ride an unreliable channel in bursts; a late batch means the victim's snapshot position stalls then jumps, which the remote interp buffer reproduces faithfully. The cmd-backup window (×3) plus extrapolation mostly covers it; if it still shows, the next lever is server-side per-tick coasting (integrate held inputs on empty ticks), which trades a little authority drift for smoothness.
- Event-loop contention on the monolith. The 60Hz world tick shares one node event loop with chat history loads, Mongo round-trips, status streams, etc. Timer jitter directly becomes snap jitter (clients max-filter + slew the clock to absorb it, but the floor is set by the server). Worth measuring before splitting; the clean split is one process (or worker thread) per world, same protocol.
- JSON wire format. Fine at ≤8 players (delta snaps are small); bit-packing was deliberately deferred — still the right call.
The lobby model after the refactor #
session-server/world-manager.mjs—WorldManagerclass, one instance per world; prefix parameterizes the protocol (arena:*,land:*). Everything else (tick, snapshots, takeover/spectate, stale sweep, delta compression, lag-comp history) is shared.- Adding a world = one cfg module in
lib/<name>-world.mjs+ one line in session.mjs'sworldManagersmap + a client piece. Routing (hello / bye / cmd / ping, WS close, UDP identity/cmd) is generic over the map. landproves the pattern: its entire world cfg lives inlib/land-world.mjs, imported by both client and server — no duplicated MUST-MATCH block (arena still has one; migrate it the same way when convenient).
Current scope is one room per world (matches the single global chat).
Next steps when a meadow gets crowded: shard key world:instance, spawn a
WorldManager per instance, and have hello return the assigned instance —
the protocol already carries everything needed.
Test harness (all local, no prod risk) #
npm testinsession-server/— vitest;tests/world.test.mjscovers prefixed events, member-scoped broadcasts, pmove integration, takeover, reload races, multi-world isolation.npm run land:probe:local/arena:probe:local— terminal probe (--worldflag) for live snap-rate/RTT/roster against any server.node artery/test-land-multiplayer.mjs— two headless Chromium players on/land+ a wire-level walker bot; asserts snaps flow to both browsers and that the bot appears in both rosters (this is the test that caught the silent-UDP-death bug).--keepruns headful for manual driving.