arena.mjs — Multiplayer Plan #
Goal: put more than one figure on the arena platform. Preserve the current single-player feel (Quake-style movement, lava pit, shadow, third-person). Add presence so other logged-in users appear as walking stick figures.
1. The networking model you already have #
Two transports, one session server (session-server/session.mjs):
| Transport | Where defined | Library | Delivery | Used for |
|---|---|---|---|---|
| WebSocket | net.socket(cb) in disks |
ws package, wss in session.mjs |
reliable, ordered | join/leave, roster, chat, scoring, invites |
| UDP (WebRTC DataChannel) | net.udp(cb) in disks |
@geckos.io/server, io in session.mjs |
low-latency, may drop | position/velocity sync, audio data, real-time input |
Both channels carry { type, content } frames (content is a JSON string or
object). On the server a client is a single logical identity with two
connection IDs — one in connections[id] (WS) and one in udpChannels[id]
(UDP). They are linked by the client's handle, glued via the udp:identity
message that every UDP channel sends right after it opens. Lookup helper:
resolveUdpForHandle(handle) scans clients to find the matching UDP channel.
Three relay patterns — pick one per message type #
A. everyone(str) — WS broadcast to all
Used for roster, world:*, build:*, reload. Simple broadcast.
B. others(str) / channel.broadcast.emit(evt, data) — relay all-except-sender
Used for 1v1:move, squash:move. Session server is a dumb relay: it does no
state management, no validation. Each client is the source of truth for its
own avatar; others see a lagged copy. This is the pattern to copy for arena
presence.
C. Server-authoritative (see duel-manager.mjs)
Server owns state. Clients send inputs only (duel:input { seq, targetX, targetY }). Server runs a 60 Hz tick, broadcasts snapshots at ~20 Hz.
Clients predict locally, reconcile when ack'd input seq comes back in
lastInputSeq[myHandle]. This is what dumduel uses.
How duel-manager.mjs hooks in — the canonical manager pattern #
// session.mjs
import { DuelManager } from "./duel-manager.mjs";
const duelManager = new DuelManager();
duelManager.setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle });
// WS message dispatch:
if (msg.type === "duel:join") duelManager.playerJoin(handle, wsId);
if (msg.type === "duel:input") duelManager.receiveInput(handle, parsed);
// UDP channel handler:
channel.on("duel:input", (data) => duelManager.receiveInput(handle, parsed));
The Manager object is the only place game state lives. session.mjs just
shuttles frames. This is the model to extend if/when arena needs
server-authoritative combat.
Identity — how handle becomes the key #
- On WS connect, client sends
{ type: "login" }(seechat-manager.mjs) or piece-specific*:join { handle }. The session recordsclients[id].handle = handle. - On UDP connect,
geckos.ioissues a channel id. The client sendsudp:identity { handle, user }as its first message; server writesclients[channelId].handle, and — if a DuelManager/arena manager wants the UDP channel — callsresolveUdpChannel(handle, channelId). guest_xxxxhandles are first-class but typically demoted (dumduel puts them inspectators).
2. What arena.mjs looks like today #
- Single-player. Zero networking. Zero imports from
net. - Uses
export const system = "fps"→ framework providessystem.fps.doll(aCamdollfromlib/cam-doll.mjs) which owns camera, physics, crouch/jump, and groundY clamping. - Player position is
phys.playerCamX/Y/Z(negated world coords — cam stores-worldX).playerFacing=cam.rotYexcept while orbiting. - Rendering body parts uses two 3D
Formobjects —bodyFeetandbodyArms— positioned/rotated each sim tick at the local player. The ground (groundPlane), skirt, platform, lava, and shadow are scene geometry. - Death = Y fell into the pit. Respawn =
doll.respawn(0,0). No concept of other players' life state.
So to become multi-user, arena needs:
- A network identity — resolve handle in
boot, open WS + UDP. - An
othersmap of remote players — pos, rotY (facing), jumping/crouch anim state, alive flag. - Outgoing position sync — throttled UDP send of own state.
- Incoming state handling — buffered + lerped like dumduel's opponent.
- Per-remote render — clone the
bodyFeet/bodyArmsForm recipe once per other; reposition each paint call. - Presence lifecycle —
arena:joinon connect,arena:leaveon disconnect, roster updates.
3. Recommended approach — start with relay-only ("Tier 1") #
Mirror squash.mjs / 1v1.mjs: session server is a dumb relay. Each client
owns its own avatar. Keep the single-player cam-doll physics intact —
authority for "where am I" stays local. We just paint other people.
This lands the visible win (more figures on the board) with the smallest
diff and no new server state. If we later want shooting, hit detection, or
a death-pit-score shared across players, we add an ArenaManager alongside
DuelManager (Tier 2 below).
Message shape (Tier 1) #
// Client → server, UDP, ~30Hz
{
type: "arena:move",
content: {
handle,
x, y, z, // world coords
rotY, // facing (degrees)
vy, // vertical velocity (for jump animation on remotes)
onGround, // bool
crouch, // 0..1
alive, // bool
}
}
// Client → server, WS
{ type: "arena:join", content: { handle } }
{ type: "arena:leave", content: { handle } }
{ type: "arena:respawn", content: { handle } } // reliable event
{ type: "arena:died", content: { handle } } // reliable event
Server-side relay (no state held):
// In session.mjs WS dispatch, next to 1v1:move / squash:move
if (msg.type === "arena:move") { others(JSON.stringify(msg)); return; } // WS fallback
if (msg.type === "arena:join" || msg.type === "arena:leave"
|| msg.type === "arena:respawn" || msg.type === "arena:died") {
everyone(JSON.stringify(msg)); return;
}
// In io.onConnection geckos handler, next to squash:move
channel.on("arena:move", (data) => {
if (channel.webrtcConnection.state === "open") {
try { channel.broadcast.emit("arena:move", data); } catch {}
}
});
Client changes (disks/arena.mjs) #
-
Boot signature
function boot({ Form, penLock, system, screen, ui, api, painting, net: { socket, udp }, handle }) { ... } -
New module state
let myHandle = "guest"; let server, udpChannel; let others = {}; // { [handle]: { x,y,z, rotY, vy, onGround, crouch, alive, // serverX, serverY, serverZ, // latest from net // displayX, displayY, displayZ, // lerped // bodyFeet, bodyArms // 3D Forms built lazily // } } let lastUdpSend = 0; const UDP_SEND_INTERVAL = 4; // sim ticks, = 30Hz at 120Hz sim const LERP_SPEED = 0.25; -
Connect in boot
myHandle = handle?.() || "guest_" + Math.floor(Math.random()*9999); udpChannel = udp((type, content) => { if (type === "arena:move") { const d = typeof content === "string" ? JSON.parse(content) : content; if (d.handle === myHandle) return; upsertOther(d); } }); server = socket((id, type, content) => { if (type.startsWith("connected")) { server.send("arena:join", { handle: myHandle }); return; } const msg = typeof content === "string" ? JSON.parse(content) : content; if (type === "arena:move") upsertOther(msg); // WS fallback if (type === "arena:join") upsertOther({ handle: msg.handle, alive: true }); if (type === "arena:leave") delete others[msg.handle]; if (type === "arena:died") { if (others[msg.handle]) others[msg.handle].alive = false; } if (type === "arena:respawn") { if (others[msg.handle]) others[msg.handle].alive = true; } }); -
Outgoing state in
sim— after the doll physics update, guarded bysimTime % (UDP_SEND_INTERVAL/SIM_HZ):lastUdpSend++; if (lastUdpSend >= UDP_SEND_INTERVAL) { lastUdpSend = 0; const payload = { handle: myHandle, x: -playerCamX, y: -playerCamY, z: -playerCamZ, // to world rotY: playerFacing, vy: phys?.vy ?? 0, onGround: phys?.onGround ?? true, crouch: phys?.crouch ?? 0, alive: playerAlive, }; if (udpChannel?.connected) udpChannel.send("arena:move", payload); else server?.send("arena:move", payload); } -
Interpolation in
sim— for every other, lerp display toward server:for (const o of Object.values(others)) { o.displayX += (o.serverX - o.displayX) * LERP_SPEED; o.displayY += (o.serverY - o.displayY) * LERP_SPEED; o.displayZ += (o.serverZ - o.displayZ) * LERP_SPEED; } -
Rendering in
paint— build + reposition abodyFeet/bodyArmspair per other. Simplest: reuse the same form-building code from boot, factored intomakeBody()that returns{ feet, arms }. On first snapshot for a new handle, build it; store on theothers[handle]record; each frame update.positionand.rotation[1]. Add topaint's Form render list the same way local body parts are.Optional polish: fade new joiners in over ~0.5s; pulse on death.
-
Teardown — there's no
leave()currently; add one to emitarena:leavebefore the piece unmounts.
Server changes #
Two small patches to session-server/session.mjs:
- WS dispatch near the
1v1:move/squash:move/duel:*section (~line 2815): add the fourarena:*handlers above. - UDP handler near
channel.on("squash:move", ...)(~line 3607): add thearena:movebroadcast.
No new file, no new manager, no session-server state.
4. Open design questions #
- Scope of presence. Does "arena" mean one shared room across the whole
session server, or one-per-spawn? Dumduel / squash assume one global room.
Simplest to start the same way; partition later with
arena:<roomId>message prefixes if needed. - Guests. Include
guest_xxxas first-class figures or as ghosts / hide them? Dumduel demotes to spectators — that feels wrong for a platform game. Recommend: include, but render with 50% alpha + italic handle label. - Remote body rendering cost. Each remote = two Forms. With N players that's 2N forms plus shadows and labels. Acceptable up to ~10 remotes; above that we'd want a single batched form.
- Handle labels over heads. Need to project world → screen (FPS camera).
arena already has the inverse ray in
simforhoverTile; reuse the math to draw 2D text above each remote. - Death pit as shared hazard? Today each client decides its own death
from local Y. For presence-only that's fine — the
arena:diedmessage is just cosmetic. If we want kills ("push someone into the pit") that becomes authority-contested → promote to Tier 2.
5. Tier 2 — Quake 3-caliber netcode on WS + geckos.io #
Target: competitive FPS feel (pro-mode quality) on our existing transports.
No new infra — we do not add a raw UDP socket, a packet-level protocol,
or a second server. Everything runs through socket() (reliable) and
udp() (geckos.io WebRTC DataChannel), plus a new ArenaManager class
sitting next to DuelManager in session-server/.
5.1 Q3 concepts → what they map to for us #
Q3 invented this pattern; everything below is a direct adaptation. Sources:
Fabien Sanglard's Q3 network review,
jfedor Q3 wire format,
id's sv_snapshot.c,
SnapNet on snapshot interpolation.
| Q3 concept | Q3 implementation | AC adaptation |
|---|---|---|
| Transport | one UDP socket; reliable cmds multiplexed via seq+ack inside UDP | split: WS = reliable channel, geckos.io UDP = cmd/snap |
| Packet MTU | fragment at 1400 bytes to avoid router splits | geckos.io handles fragmentation; we still size snaps conservatively (<1 KB target, hard cap 8 KB) |
clc_move (input) |
≤8 usercmd_t per packet, bit-packed with 1-bit "changed?" per field, timestamps |
arena:cmd UDP frame: { seq, ack, cmds: [last N usercmds], ms } — JSON for M1, bitpack later |
usercmd_t |
{ serverTime, angles[3], forwardmove, rightmove, upmove, buttons, weapon } |
{ ms, yaw, pitch, fwd, right, up, buttons } — buttons = bitmask (jump|crouch|shoot|dash) |
| Command backup | cl_packetdup — every packet carries the last N cmds so one drop ≠ lost input |
Start at N=3, each cmd ~20 bytes, fine under the MTU |
svc_snapshot |
delta-compressed vs a previously-acked snap; server keeps 32-snap ring per client | arena:snap with { messageNum, deltaNum, tick, serverMs, entities }; server keeps 32-snap ring per client |
| Delta compression | bit-per-field "changed?" marker, terminate at last-changed index | identical algorithm; field table built once from an entity schema object |
| Snap ack | every outgoing client packet includes serverMessageSequence = last snap seen |
every arena:cmd includes ack: lastSeenMessageNum |
sv_fps / sv_snaps |
server tick 20–40 Hz (pro: 40–125) | tickRate = 60 Hz, snapRate = 30 Hz to start; per-client override possible later |
cl_snaps / cl_maxpackets |
client requests 20–40 snaps, sends 30–125 cmds/s | cmd rate 60 Hz, snap rate decided by server |
Client prediction (pmove) |
identical movement code client+server; client replays unacked cmds on latest authoritative state each frame | extract pmove into shared/pmove.mjs so disk + session-server run byte-identical simulation |
Interpolation (cl_interp) |
render remote entities ~100 ms in the past, between two known snaps | INTERP_DELAY_MS = 100; never extrapolate |
| Lag compensation (Unlagged) | on hitscan, server rewinds other players by ping + interp to the attacker's view time |
keep 500 ms position history per player; on shoot, rewind & raycast |
| PVS culling | only send entities in the player's potentially-visible set | arena is small (±14 units); skip PVS. Send all players always. |
| Reliable cmds | chat, disconnect, config strings multiplexed into the UDP stream with per-cmd seq | send over WS instead: arena:hello, arena:bye, arena:kill, arena:config, arena:chat |
5.2 Why the split transport is actually better for us than Q3's single-socket design #
Q3 had to invent in-band reliable command acknowledgment because it only had UDP. We already have an ordered reliable channel (WS). That lets us:
- Delete the reliable-command retransmit loop entirely.
- Keep
arena:cmd/arena:snappurely unreliable and delete-safe. - Avoid coupling snapshot loss to chat loss.
The tradeoff: TCP head-of-line blocking on WS could delay a kill event
by a few hundred ms during packet loss. That's fine for lifecycle/chat;
it would not be fine for position data, which is why position stays on UDP.
5.3 Wire formats #
// Client → Server, UDP, ~60Hz
{ type: "arena:cmd",
content: {
seq, // monotonic client cmd seq
ack, // last snap messageNum we saw (0 if none)
handle, // identity (geckos channel is already bound but include for safety)
cmds: [ // last N=3 usercmds, oldest first
{ ms, yaw, pitch, fwd, right, up, buttons }, ...
]
}
}
// Server → Client, UDP, 30Hz (per-client)
{ type: "arena:snap",
content: {
messageNum, // this client's monotonic snap seq
deltaNum, // messageNum - deltaNum ago is the base; 0 = full snap
tick, // server sim tick
serverMs, // wall-clock ms, for client->server time offset estimation
ackCmdSeq, // highest client cmd seq the server has processed
players: [ // delta-encoded; only fields that changed vs base
{ h, x, y, z, yaw, pitch, vy, ground, crouch, alive, health, ... }
],
events: [ // fire-and-forget one-shots since last snap (kill, respawn, spawn)
{ t: "kill", by: "a", of: "b", ms }, ...
]
}
}
// WS — reliable sideband
{ type: "arena:hello", content: { handle } } // client on connect
{ type: "arena:welcome", content: { yourId, serverConfig, initialSnap } }
{ type: "arena:bye", content: { handle } }
{ type: "arena:kill", content: { by, of, ms } } // redundant with snap events but guaranteed
{ type: "arena:chat", content: { handle, text } }
5.4 session-server/arena-manager.mjs #
class ArenaManager {
players // Map<handle, PlayerRecord>
tick, serverMs
tickInterval
// Per-client state needed for delta compression:
// record.snapHistory : ring buffer [32] of { messageNum, state }
// record.lastAckMessageNum : highest snap the client confirmed receiving
// record.nextMessageNum : monotonic counter for this client's snap stream
// record.posHistory : ring buffer [~30] of {ms, x, y, z} for lag comp
setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle })
playerJoin(handle, wsId) // WS arena:hello
playerLeave(handle)
receiveCmd(handle, frame) // UDP arena:cmd → applyUsercmd per cmd, update lastAckMessageNum
receiveShoot(handle, frame) // optional — lag-comp hit test
serverTick() // 60Hz: pmove each player w/ latest cmd, advance world
buildSnapshotFor(handle) // compose state, delta-encode vs snapHistory[lastAckMessageNum]
broadcastSnapshots() // 30Hz: for each handle → sendUDP(chan, "arena:snap", delta)
}
Key implementation notes from the Q3 source:
- Per-client snap history ring is mandatory. The delta base must be a
snap we know the client has. Client signals this via
ackin every cmd packet. Ring size 32 gives ~1 s of tolerance at 30 Hz before we're forced to send a full snap. - Delta encoding uses a schema. Q3 uses a
netField_t[]table withname, offset, bits. We build the equivalent once as an array of field specs over aPlayerStateobject. Encode loop: for each field, compare to base, emit 1 bit; if changed, emit the value. Early-out after the last-changed index (Q3's big win). - usercmd replay uses time deltas, not absolute time. Server applies
cmd i by computing
dt = cmd[i].ms - cmd[i-1].ms(clamped to keep cheaters from moving faster). First cmd after join uses wall clock. ackCmdSeqin snaps lets the client drop cmds from its pending queue (same as dumduel'slastInputSeq, just per-client per-packet).- No need for Q3's
qport— geckos.io gives us a stable channel id.
5.5 Client (disk-side) rework #
5.5.1 Factor out pmove #
Today cam-doll.mjs owns movement. For prediction parity we need a
pure function both sides can call:
shared/pmove.mjs
export function pmove(state, cmd, dt) { return newState; }
state = { x, y, z, vx, vy, vz, yaw, pitch, onGround, crouchT }.
cmd = the usercmd fields. Same code runs in browser (arena.mjs) and
Node (arena-manager.mjs). Keep it dependency-free.
cam-doll then becomes a thin wrapper: "read input → produce usercmd →
feed pmove → write back to cam". This is the Carmack pattern: one
function is the source of truth, everything else is input/output plumbing.
5.5.2 Client-side prediction & reconciliation #
// On every sim tick locally:
const cmd = makeUsercmd(input);
pendingCmds.push({ seq: ++cmdSeq, cmd });
playerState = pmove(playerState, cmd, 1/SIM_HZ);
// render playerState
// On snap arrival:
function onSnap(snap) {
// Advance our "authoritative" state to what the server says
authoritativeState = applySnapDelta(authoritativeState, snap);
// Drop acked cmds
pendingCmds = pendingCmds.filter(c => c.seq > snap.ackCmdSeq);
// Re-run the unacked ones on top of the server's state
let replayed = authoritativeState;
for (const c of pendingCmds) replayed = pmove(replayed, c.cmd, 1/SIM_HZ);
// If replayed is far from our displayed state, smooth over ~100ms instead of snapping
playerState = smoothCorrect(playerState, replayed);
}
5.5.3 Interpolation for remote players #
Maintain a per-remote snapshot buffer [{ ms, state }, ...]. Render at
now - INTERP_DELAY_MS (100 ms). Find the two buffered states bracketing
that time, lerp between them. When the buffer empties (lost packets,
server starved), freeze the remote — do not extrapolate. Carmack's
rule: "you only ever show positions the entity actually had."
function renderRemote(handle, now) {
const renderTime = now - INTERP_DELAY_MS;
const buf = remotes[handle].buffer;
// Find i such that buf[i].ms <= renderTime <= buf[i+1].ms
// If not found, clamp to oldest/newest (freeze)
// Else lerp between buf[i] and buf[i+1]
}
5.5.4 Clock sync #
For interp to work, client and server need a shared time. Each snap
carries serverMs. Client estimates offset = serverMs - receivedMs with
a rolling min filter (ping variance). All interp math uses
clientMs + offset as "server time".
5.6 Lag compensation (for future combat) #
Only needed when we add hitscan shooting. On a shoot usercmd:
- Server reads
snapMs = snapMsForMessageNum[cmd.ack]— the moment the attacker thought they were shooting at. - For each other player, find the two entries in
posHistorybracketingsnapMs - INTERP_DELAY_MSand lerp to rewind their position. - Raycast against the rewound positions. Register hit on the player the attacker saw on their screen.
- Restore positions and continue sim.
Budget: posHistory = 30 entries × 8 players × ~48 bytes = ~12 KB. Free.
5.7 Perf & tuning targets #
- Snap size (full): ~120 bytes/player. 8 players × 120 = ~1 KB. Under MTU.
- Snap size (delta, steady state): expect 10–30 bytes/player.
- Uplink per client: 60 Hz × 3 cmds × ~16 bytes = ~3 KB/s.
- Downlink per client: 30 Hz × ~200 bytes = ~6 KB/s.
- Server CPU: 60 Hz pmove × N players. Pmove is <1 µs in JS, so 8 players = ~0.5 ms/tick budget used, leaving plenty for delta encode.
Tunables to expose on serverConfig:
tickRate, snapRate, cmdRate, cmdBackup, interpDelayMs,
posHistoryMs, smoothCorrectMs.
6. Suggested milestones #
Presence tier (relay-only, builds on squash/1v1 pattern):
- M1 — Presence skeleton. Wire WS + UDP in arena.mjs, log incoming
frames to console, no rendering. Confirm two tabs exchange
arena:move. - M2 — Remote body render. Build per-remote feet/arms forms, position each paint. Ignore interpolation (snap to server pos).
- M3 — Death/respawn lifecycle + handle labels (world→screen project).
- M4 — Naive lerp + polish. Fade-in on join, alpha for guests, perf test with ≥4 players.
Q3-caliber tier (server-authoritative):
- M5 — Extract
shared/pmove.mjsfrom cam-doll. Unit tests that fixed-seed command streams produce identical state in Node and browser. - M6 —
ArenaManagerskeleton. 60 Hz tick, full snapshots (no delta yet), 30 Hz broadcast, client drops M1-M4 relay code and uses manager snaps as truth. No client prediction yet — just visual lag. - M7 — Client prediction + reconciliation. Pending cmd queue, replay on ack, smooth-correct on mispredict.
- M8 — Interpolation buffer for remotes. 100 ms delay, freeze on
starvation, clock sync via snap
serverMs. - M9 — Delta compression. Per-client snap ring, field schema, bit-per- field changed marker, last-changed early-out. Measure bandwidth drop.
- M10 — Command backup + ack-in-cmd. Send last 3 cmds per packet,
include
ackof last snap seen. Server dedupes by seq. - M11 — (optional) Lag-compensated hitscan. Only if combat lands.
Each is independently shippable. M5–M8 together reach "playable Q3-lite"; M9–M11 are the polish that makes it feel pro.
7. References #
- Quake 3 Source Code Review: Network Model — Fabien Sanglard
- Quake 3 Network Protocol (wire format) — jfedor
sv_snapshot.cin id's Quake 3 sourcesv_client.c— client/cmd handling- Netcode Architectures Part 3: Snapshot Interpolation — SnapNet
- In-repo:
session-server/duel-manager.mjs— the dumbed-down version of this pattern already running in production (no delta, no cmd backup, no interp buffer, no lag comp)
Each milestone is independently shippable.