From 515d39bbd33885825f1ea7984ef6ac550e8117d0 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Mon, 20 Apr 2026 19:44:44 +0000 Subject: [PATCH] arena: multiuser via ArenaManager — Q3-style pmove + delta snaps Adds server-authoritative multiplayer to arena.mjs using the existing WS + geckos.io UDP transports (no new infra). - shared/pmove.mjs (under system/public/.../lib): pure movement function, dep-free, runs identically in browser and Node. - session-server/arena-manager.mjs: 60Hz tick, 30Hz snaps, per-client snap ring (32), delta encoding, lag-comp pos history. - session-server/arena-probe.mjs + npm run arena:probe: text-only spectator CLI. Reports rtt, snap rate, jitter, peer list. - session.mjs: routes arena:hello/bye/cmd/ping (WS) and arena:cmd (UDP). - arena.mjs client: per-cmd seq with firstSeq batching, pending-cmd queue, reconcileLocal replay-and-soft-correct, interp buffer for remote players (~100ms, freeze-on-starve), per-handle colored stick-figure bodies, net HUD lines. Smoke: replay parity within 1.5cm (under 5cm dead zone); delta steady-state 131B vs 307B full (~58% saving). See plans/arena-multiplayer.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 ++ plans/arena-multiplayer.md | 552 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ session-server/arena-manager.mjs | 354 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ session-server/arena-probe.mjs | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ session-server/session.mjs | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/public/aesthetic.computer/disks/arena.mjs | 449 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- system/public/aesthetic.computer/lib/pmove.mjs | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 file(s) changed, 1815 insertion(s)(+), 1 deletion(s)(-) diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -86,6 +86,8 @@ "publish:m4l": "node ac-m4l/publish.mjs", "session:reset": "f() { cd session-server; npx jamsocket backend terminate $1 };f", "session:alive": "cd session-server; npx jamsocket backend list", "session:publish": "fish aesthetic-computer-vault/session-server/deploy.fish", + "arena:probe": "node session-server/arena-probe.mjs", + "arena:probe:local": "node session-server/arena-probe.mjs --url ws://localhost:8889", "server:session": "cd session-server; npm run dev", "server:session:build": "cd session-server; npm run build:jamsocket", "server:session:deploy": "cd session-server; npm run deploy", diff --git a/plans/arena-multiplayer.md b/plans/arena-multiplayer.md new file mode 100644 --- /dev/null +++ b/plans/arena-multiplayer.md @@ -0,0 +1,552 @@ +# 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 + +```js +// 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" }` (see `chat-manager.mjs`) or + piece-specific `*:join { handle }`. The session records + `clients[id].handle = handle`. +- On UDP connect, `geckos.io` issues a channel id. The client sends + `udp:identity { handle, user }` as its first message; server writes + `clients[channelId].handle`, and — if a DuelManager/arena manager wants the + UDP channel — calls `resolveUdpChannel(handle, channelId)`. +- `guest_xxxx` handles are first-class but typically demoted (dumduel puts + them in `spectators`). + +--- + +## 2. What arena.mjs looks like today + +- Single-player. Zero networking. Zero imports from `net`. +- Uses `export const system = "fps"` → framework provides + `system.fps.doll` (a `Camdoll` from `lib/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.rotY` except while orbiting. +- Rendering body parts uses two 3D `Form` objects — `bodyFeet` and `bodyArms` + — 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: + +1. **A network identity** — resolve handle in `boot`, open WS + UDP. +2. **An `others` map** of remote players — pos, rotY (facing), jumping/crouch + anim state, alive flag. +3. **Outgoing position sync** — throttled UDP send of own state. +4. **Incoming state handling** — buffered + lerped like dumduel's opponent. +5. **Per-remote render** — clone the `bodyFeet`/`bodyArms` Form recipe once + per other; reposition each paint call. +6. **Presence lifecycle** — `arena:join` on connect, `arena:leave` on + 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) + +```js +// 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): + +```js +// 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`) + +1. **Boot signature** + ```js + function boot({ Form, penLock, system, screen, ui, api, painting, + net: { socket, udp }, handle }) { ... } + ``` + +2. **New module state** + ```js + 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; + ``` + +3. **Connect in boot** + ```js + 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; } + }); + ``` + +4. **Outgoing state in `sim`** — after the doll physics update, guarded by + `simTime % (UDP_SEND_INTERVAL/SIM_HZ)`: + ```js + 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); + } + ``` + +5. **Interpolation in `sim`** — for every other, lerp display toward server: + ```js + 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; + } + ``` + +6. **Rendering in `paint`** — build + reposition a `bodyFeet`/`bodyArms` + pair per other. Simplest: reuse the same form-building code from boot, + factored into `makeBody()` that returns `{ feet, arms }`. On first + snapshot for a new handle, build it; store on the `others[handle]` + record; each frame update `.position` and `.rotation[1]`. Add to + `paint`'s Form render list the same way local body parts are. + + Optional polish: fade new joiners in over ~0.5s; pulse on death. + +7. **Teardown** — there's no `leave()` currently; add one to emit + `arena:leave` before 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 four `arena:*` handlers above. +- **UDP handler** near `channel.on("squash:move", ...)` (~line 3607): add + the `arena:move` broadcast. + +No new file, no new manager, no session-server state. + +--- + +## 4. Open design questions + +1. **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:` + message prefixes if needed. +2. **Guests.** Include `guest_xxx` as 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. +3. **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. +4. **Handle labels over heads.** Need to project world → screen (FPS camera). + arena already has the inverse ray in `sim` for `hoverTile`; reuse the + math to draw 2D text above each remote. +5. **Death pit as shared hazard?** Today each client decides its own death + from local Y. For presence-only that's fine — the `arena:died` message + 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](https://fabiensanglard.net/quake3/network.php), +[jfedor Q3 wire format](https://www.jfedor.org/quake3/), +[id's `sv_snapshot.c`](https://github.com/id-Software/Quake-III-Arena/blob/master/code/server/sv_snapshot.c), +[SnapNet on snapshot interpolation](https://snapnet.dev/blog/netcode-architectures-part-3-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:snap` purely 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 + +```js +// 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 + 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: + +1. **Per-client snap history ring is mandatory.** The delta base must be a + snap we *know* the client has. Client signals this via `ack` in every + cmd packet. Ring size 32 gives ~1 s of tolerance at 30 Hz before we're + forced to send a full snap. +2. **Delta encoding uses a schema.** Q3 uses a `netField_t[]` table with + `name, offset, bits`. We build the equivalent once as an array of field + specs over a `PlayerState` object. 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). +3. **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. +4. **`ackCmdSeq` in snaps** lets the client drop cmds from its pending + queue (same as dumduel's `lastInputSeq`, just per-client per-packet). +5. **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 + +```js +// 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." + +```js +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: + +1. Server reads `snapMs = snapMsForMessageNum[cmd.ack]` — the moment the + attacker *thought* they were shooting at. +2. For each other player, find the two entries in `posHistory` bracketing + `snapMs - INTERP_DELAY_MS` and lerp to rewind their position. +3. Raycast against the rewound positions. Register hit on the player the + attacker saw on their screen. +4. 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.mjs`** from cam-doll. Unit tests that + fixed-seed command streams produce identical state in Node and browser. +- **M6 — `ArenaManager` skeleton.** 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 `ack` of 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](https://fabiensanglard.net/quake3/network.php) +- [Quake 3 Network Protocol (wire format) — jfedor](https://www.jfedor.org/quake3/) +- [`sv_snapshot.c` in id's Quake 3 source](https://github.com/id-Software/Quake-III-Arena/blob/master/code/server/sv_snapshot.c) +- [`sv_client.c` — client/cmd handling](https://github.com/id-Software/Quake-III-Arena/blob/master/code/server/sv_client.c) +- [Netcode Architectures Part 3: Snapshot Interpolation — SnapNet](https://snapnet.dev/blog/netcode-architectures-part-3-snapshot-interpolation/) +- In-repo: [`session-server/duel-manager.mjs`](../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. diff --git a/session-server/arena-manager.mjs b/session-server/arena-manager.mjs new file mode 100644 --- /dev/null +++ b/session-server/arena-manager.mjs @@ -0,0 +1,354 @@ +// Arena Manager, 2026.04.20 +// Server-authoritative state for arena.mjs. Quake 3-inspired: +// - fixed 60 Hz sim tick +// - clients send usercmd packets over UDP (geckos.io) +// - server broadcasts per-client snapshots at 30 Hz +// - per-client snapshot ring enables future delta compression (M9) +// - reliable lifecycle events (join/leave/kill/chat/probe) ride WS +// +// Starts simple: full snapshots (no delta yet), no lag compensation, no +// command backup decode. Those are later milestones — the wire formats +// already carry the fields (messageNum, deltaNum, ackCmdSeq) so turning +// them on is additive. + +import { newState, pmove, unpackCmd, DEFAULT_CFG, BTN } from "../system/public/aesthetic.computer/lib/pmove.mjs"; + +const PLAYER_FIELDS = ["h","x","y","z","vx","vy","vz","yaw","pitch","c","g","a"]; + +const TICK_RATE = 60; // sim ticks/sec +const SNAP_RATE = 30; // snapshots/sec (per client) +const SNAP_EVERY = TICK_RATE / SNAP_RATE; +const SNAP_RING = 32; // per-client snapshot history depth +const POS_HISTORY_MS = 500; // rolling pos history for lag comp + +// Default arena world config — must match disks/arena.mjs. +export const ARENA_CFG = Object.freeze({ + ...DEFAULT_CFG, + runSpeed: 10, + walkSpeed: 5, + jumpVelocity: 8, + gravity: 50, + groundY: -1.5, + eyeHeight: 2.0, + crouchEyeHeight: 1.2, + groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, + deathFloorY: -30, + simHz: TICK_RATE, +}); + +// Spawn ring — spread players around the arena. +const SPAWNS = [ + { x: 6, z: 0 }, { x: -6, z: 0 }, { x: 0, z: 6 }, { x: 0, z: -6 }, + { x: 5, z: 5 }, { x: -5, z: -5 }, { x: 5, z: -5 }, { x: -5, z: 5 }, +]; + +export class ArenaManager { + constructor() { + this.players = new Map(); // handle -> PlayerRecord + this.probes = new Map(); // handle -> { wsId } — text-only spectators + this.tick = 0; + this.startMs = Date.now(); + this.tickInterval = null; + + // Transport callbacks (set by session.mjs) + this.sendUDP = null; // (channelId, event, data) -> bool + this.sendWS = null; // (wsId, type, content) + this.broadcastWS = null; // (type, content) + this.resolveUdpForHandle = null; // (handle) -> channelId|null + } + + setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) { + this.sendUDP = sendUDP; + this.sendWS = sendWS; + this.broadcastWS = broadcastWS; + this.resolveUdpForHandle = resolveUdpForHandle; + } + + now() { return Date.now() - this.startMs; } + + // -- Lifecycle (WS-reliable) -- + + playerJoin(handle, wsId, opts = {}) { + if (!handle) return; + + // Text-only spectator / probe: no player body, just receive snaps. + if (opts.probe) { + this.probes.set(handle, { wsId }); + this.sendWS?.(wsId, "arena:welcome", { + you: handle, + probe: true, + cfg: ARENA_CFG, + serverMs: this.now(), + tick: this.tick, + roster: [...this.players.keys()], + }); + console.log(`🏟️ probe joined: ${handle} (${this.probes.size} probes)`); + this.ensureTick(); + return; + } + + let rec = this.players.get(handle); + if (rec) { + // Re-join (page reload / reconnect): reuse the body but refresh wsId. + rec.wsId = wsId; + rec.udpChannelId = this.resolveUdpForHandle?.(handle) ?? null; + } else { + const spawn = SPAWNS[this.players.size % SPAWNS.length]; + rec = { + handle, + wsId, + udpChannelId: this.resolveUdpForHandle?.(handle) ?? null, + state: newState({ x: spawn.x, z: spawn.z, cfg: ARENA_CFG }), + lastCmdMs: this.now(), // for dt computation between cmds + lastCmdSeq: 0, // highest client cmd seq processed + snapHistory: new Array(SNAP_RING).fill(null), + nextMessageNum: 1, // monotonic snap counter for this client + lastAckMessageNum: 0, // highest snap the client acked + posHistory: [], // [{ ms, x, y, z }] for lag comp + lastSeenMs: this.now(), // used for timeout/presence + }; + this.players.set(handle, rec); + } + + this.sendWS?.(wsId, "arena:welcome", { + you: handle, + probe: false, + cfg: ARENA_CFG, + serverMs: this.now(), + tick: this.tick, + initialState: rec.state, + roster: [...this.players.keys()], + }); + + this.broadcastWS?.("arena:join", { handle }); + console.log(`🏟️ joined: ${handle} (${this.players.size} players)`); + this.ensureTick(); + } + + playerLeave(handle) { + if (!handle) return; + if (this.probes.delete(handle)) { + console.log(`🏟️ probe left: ${handle} (${this.probes.size} probes)`); + this.maybeStopTick(); + return; + } + if (this.players.delete(handle)) { + this.broadcastWS?.("arena:leave", { handle }); + console.log(`🏟️ left: ${handle} (${this.players.size} players)`); + } + this.maybeStopTick(); + } + + resolveUdpChannel(handle, channelId) { + const rec = this.players.get(handle); + if (rec) rec.udpChannelId = channelId; + } + + // -- Input (UDP, high-frequency) -- + + receiveCmd(handle, frame) { + const rec = this.players.get(handle); + if (!rec) return; + rec.lastSeenMs = this.now(); + + // Snap-ack: client tells us which snap they last saw. + if (typeof frame.ack === "number" && frame.ack > rec.lastAckMessageNum) { + rec.lastAckMessageNum = frame.ack; + } + + const cmds = Array.isArray(frame.cmds) ? frame.cmds : []; + const firstSeq = typeof frame.firstSeq === "number" ? frame.firstSeq : null; + + // Q3-style cmd processing: each cmd in the batch has an implicit seq + // = firstSeq + index. Skip anything already applied (the cmd backup + // window means most batches overlap with ones we've already seen). + for (let i = 0; i < cmds.length; i++) { + const c = unpackCmd(cmds[i]); + const seq = firstSeq != null ? firstSeq + i : null; + + // De-dupe: prefer seq when present, fall back to ms monotonicity. + if (seq != null) { + if (seq <= rec.lastCmdSeq) continue; + } else { + if (c.ms <= rec.lastCmdMs) continue; + } + + // dt from the previous applied cmd's ms; first cmd gets one tick. + const dt = rec.lastCmdMs > 0 + ? Math.min((c.ms - rec.lastCmdMs) / 1000, 0.25) + : 1 / TICK_RATE; + rec.state = pmove(rec.state, { ...c, dt }, ARENA_CFG); + rec.lastCmdMs = c.ms; + if (seq != null && seq > rec.lastCmdSeq) rec.lastCmdSeq = seq; + } + } + + // -- Tick loop -- + + ensureTick() { + if (this.tickInterval) return; + this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE); + console.log(`🏟️ arena tick loop started (${TICK_RATE}Hz, snap ${SNAP_RATE}Hz)`); + } + + maybeStopTick() { + if (this.players.size === 0 && this.probes.size === 0 && this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + console.log(`🏟️ arena tick loop stopped (idle)`); + } + } + + serverTick() { + this.tick++; + const nowMs = this.now(); + + // For each player with no fresh input this tick, advance using their + // last-seen cmd (zero input => decays naturally via pmove's damping). + // This keeps positions progressing during input starvation without + // teleporting when input resumes. + for (const rec of this.players.values()) { + // No automatic pmove here — we only step on real cmds. This matches + // Q3: server integrates usercmds as they arrive, not on empty ticks. + // Append current position to history for lag comp. + rec.posHistory.push({ ms: nowMs, x: rec.state.x, y: rec.state.y, z: rec.state.z }); + // Trim old history beyond POS_HISTORY_MS. + const cutoff = nowMs - POS_HISTORY_MS; + while (rec.posHistory.length && rec.posHistory[0].ms < cutoff) { + rec.posHistory.shift(); + } + } + + if (this.tick % SNAP_EVERY === 0) this.broadcastSnapshots(); + } + + // -- Snapshots -- + + composePlayersBlob() { + const blob = []; + for (const rec of this.players.values()) { + const s = rec.state; + blob.push({ + h: rec.handle, + x: round3(s.x), y: round3(s.y), z: round3(s.z), + vx: round3(s.vx), vy: round3(s.vy), vz: round3(s.vz), + yaw: round2(s.yaw), pitch: round2(s.pitch), + c: round3(s.crouchT), + g: s.onGround ? 1 : 0, + a: s.alive ? 1 : 0, + }); + } + return blob; + } + + /** + * Q3-style delta of `current` players vs `base` players (keyed by h). + * Returns { delta, removed, changedCount } — one "delta entry" per handle: + * - first time seen: __new: {full blob} + * - steady state: only changed fields (h is always present) + * - unchanged: { h } only (bare handle marker) + * Plus `removed: [h, ...]` for handles that existed in base but are gone. + * The JSON representation is compact at small player counts; we + * intentionally skip bit-packing (see plan §5.7 — premature at 8 peers). + */ + deltaPlayers(current, base) { + const byHandleBase = new Map(); + for (const p of base) byHandleBase.set(p.h, p); + const delta = []; + const seen = new Set(); + let changedCount = 0; + for (const p of current) { + seen.add(p.h); + const bp = byHandleBase.get(p.h); + if (!bp) { delta.push({ h: p.h, __new: p }); changedCount++; continue; } + // Compare each field; emit only changed values. + const d = { h: p.h }; + let any = false; + for (const k of PLAYER_FIELDS) { + if (k === "h") continue; + if (p[k] !== bp[k]) { d[k] = p[k]; any = true; } + } + if (any) { delta.push(d); changedCount++; } + else delta.push({ h: p.h }); + } + const removed = []; + for (const [h] of byHandleBase) if (!seen.has(h)) removed.push(h); + return { delta, removed, changedCount }; + } + + broadcastSnapshots() { + const serverMs = this.now(); + const players = this.composePlayersBlob(); + + // Build one snapshot body per *player* because messageNum is per-client. + for (const rec of this.players.values()) { + const messageNum = rec.nextMessageNum++; + + // M9: delta-compress against the last snap the client confirmed + // receiving (if it's still in our ring — falls off after SNAP_RING). + let snap; + const ack = rec.lastAckMessageNum; + const base = ack > 0 ? rec.snapHistory[ack % SNAP_RING] : null; + if (base && base.messageNum === ack) { + const { delta, removed } = this.deltaPlayers(players, base.players); + snap = { + messageNum, + deltaNum: ack, + tick: this.tick, + serverMs, + ackCmdSeq: rec.lastCmdSeq, + ackCmdMs: rec.lastCmdMs, + you: rec.handle, + delta, + ...(removed.length ? { removed } : {}), + }; + } else { + snap = { + messageNum, + deltaNum: 0, // full snap + tick: this.tick, + serverMs, + ackCmdSeq: rec.lastCmdSeq, + ackCmdMs: rec.lastCmdMs, + you: rec.handle, + players, + }; + } + // Write to ring for future delta base lookup. + rec.snapHistory[messageNum % SNAP_RING] = { messageNum, serverMs, players }; + + // Prefer UDP, fall back to WS. + let ok = false; + if (rec.udpChannelId != null && this.sendUDP) { + ok = this.sendUDP(rec.udpChannelId, "arena:snap", snap); + } + if (!ok && rec.wsId != null && this.sendWS) { + this.sendWS(rec.wsId, "arena:snap", snap); + } + } + + // Probes: always WS, full snap, messageNum=0 (they don't ack). + for (const [handle, p] of this.probes) { + if (p.wsId == null || !this.sendWS) continue; + this.sendWS(p.wsId, "arena:snap", { + messageNum: 0, + deltaNum: 0, + tick: this.tick, + serverMs, + ackCmdSeq: 0, + you: handle, + players, + probe: true, + }); + } + } + + // -- Probe-specific -- + + handlePing(handle, ts, wsId) { + this.sendWS?.(wsId, "arena:pong", { ts, serverMs: this.now() }); + } +} + +function round2(n) { return Math.round(n * 100) / 100; } +function round3(n) { return Math.round(n * 1000) / 1000; } diff --git a/session-server/arena-probe.mjs b/session-server/arena-probe.mjs new file mode 100644 --- /dev/null +++ b/session-server/arena-probe.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// arena-probe — a text-only spectator for the arena game. +// +// Connects to a session server over WebSocket, joins as a "probe", receives +// snapshots, and reports latency / jitter / peer activity. Use to smoke-test +// connectivity end-to-end (dev + prod), measure wire timing after a deploy, +// or just watch the arena from a terminal. +// +// Usage: +// node session-server/arena-probe.mjs # defaults to prod +// node session-server/arena-probe.mjs --url wss://session.aesthetic.computer +// node session-server/arena-probe.mjs --url ws://localhost:8889 --handle probe1 +// AC_PROBE_URL=ws://localhost:8889 node session-server/arena-probe.mjs +// +// Flags: +// --url default: wss://session.aesthetic.computer +// --handle default: probe_ +// --ping default: 2000 (ping interval) +// --status default: 1000 (status line interval) +// --quiet suppress per-event logs; only print the status line +// +// Ctrl-C to exit. + +import { WebSocket } from "ws"; + +// --- Args --- + +const argv = process.argv.slice(2); +const flag = (name, fallback) => { + const i = argv.indexOf("--" + name); + if (i === -1) return fallback; + return argv[i + 1]; +}; +const has = (name) => argv.indexOf("--" + name) !== -1; + +const URL = flag("url", process.env.AC_PROBE_URL || "wss://session.aesthetic.computer"); +const HANDLE = flag("handle", "probe_" + Math.random().toString(36).slice(2, 6)); +const PING_MS = +flag("ping", 2000); +const STATUS_MS = +flag("status", 1000); +const QUIET = has("quiet"); + +// --- State --- + +let ws = null; +let connectedAt = 0; +let openedMs = 0; + +const stats = { + snapsRx: 0, + lastSnapAt: 0, + snapIntervals: [], // recent gaps between snap arrivals (ms) + pingRttSamples: [], // last N RTTs (ms) + lastPingSentAt: 0, + welcomed: false, + yourTick: 0, + serverCfg: null, + players: [], // latest player blob from server + events: [], // recent join/leave messages (scrolls) +}; + +function ring(buf, v, cap = 30) { buf.push(v); while (buf.length > cap) buf.shift(); } +function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; } +function stddev(a) { + if (a.length < 2) return 0; + const m = mean(a); + return Math.sqrt(mean(a.map((x) => (x - m) * (x - m)))); +} + +// --- Wire helpers (match session.mjs's pack() format) --- + +function send(type, content) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ type, content: JSON.stringify(content) })); +} + +function log(...args) { if (!QUIET) console.log(...args); } + +// --- Connect + wire up --- + +function connect() { + ws = new WebSocket(URL); + + ws.on("open", () => { + connectedAt = Date.now(); + openedMs = connectedAt; + log(`\n🛰️ connected to ${URL} as "${HANDLE}"`); + send("arena:hello", { handle: HANDLE, probe: true }); + // Start ping loop + tickPing(); + }); + + ws.on("message", (data) => { + let msg; + try { msg = JSON.parse(data.toString()); } catch { return; } + const t = msg.type; + const body = typeof msg.content === "string" ? safeParse(msg.content) : msg.content; + if (!t || !body) return; + + if (t === "arena:welcome") { + stats.welcomed = true; + stats.serverCfg = body.cfg; + log(`🏟️ welcome: you=${body.you} probe=${!!body.probe} roster=[${(body.roster||[]).join(", ")}] tick=${body.tick} serverMs=${body.serverMs}`); + return; + } + if (t === "arena:snap") { + const now = Date.now(); + stats.snapsRx++; + if (stats.lastSnapAt) ring(stats.snapIntervals, now - stats.lastSnapAt); + stats.lastSnapAt = now; + stats.yourTick = body.tick; + stats.players = body.players || []; + return; + } + if (t === "arena:join") { + const ev = `join ${body.handle}`; + ring(stats.events, ev, 8); + log(`🟢 ${ev}`); + return; + } + if (t === "arena:leave") { + const ev = `leave ${body.handle}`; + ring(stats.events, ev, 8); + log(`🔴 ${ev}`); + return; + } + if (t === "arena:pong") { + const rtt = Date.now() - body.ts; + ring(stats.pingRttSamples, rtt, 20); + return; + } + }); + + ws.on("close", () => { + log("🚪 disconnected — retrying in 2s…"); + stats.welcomed = false; + setTimeout(connect, 2000); + }); + + ws.on("error", (err) => { + log("❌ ws error:", err.message); + // `close` will follow; don't reconnect here. + }); +} + +function safeParse(s) { try { return JSON.parse(s); } catch { return s; } } + +// --- Timers --- + +function tickPing() { + setInterval(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const ts = Date.now(); + stats.lastPingSentAt = ts; + send("arena:ping", { handle: HANDLE, ts }); + }, PING_MS); +} + +function formatStatusLine() { + const upMs = openedMs ? Date.now() - openedMs : 0; + const up = (upMs / 1000).toFixed(1) + "s"; + const rtt = stats.pingRttSamples.length + ? `${Math.round(mean(stats.pingRttSamples))}ms±${Math.round(stddev(stats.pingRttSamples))}` + : "--"; + const snapRate = stats.snapIntervals.length + ? (1000 / mean(stats.snapIntervals)).toFixed(1) + "Hz" + : "--"; + const jitter = stats.snapIntervals.length >= 2 + ? Math.round(stddev(stats.snapIntervals)) + "ms" + : "--"; + const peers = stats.players.map((p) => p.h).join(",") || "none"; + const youTick = stats.yourTick; + return `[${up}] rtt=${rtt} snaps=${stats.snapsRx} @ ${snapRate} (±${jitter}) tick=${youTick} peers=[${peers}]`; +} + +setInterval(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + process.stdout.write(`\r[…] connecting to ${URL} `); + return; + } + process.stdout.write(`\r${formatStatusLine()} `); +}, STATUS_MS); + +// --- Graceful exit --- + +process.on("SIGINT", () => { + console.log("\n👋 bye"); + try { send("arena:bye", { handle: HANDLE }); } catch {} + try { ws?.close(); } catch {} + process.exit(0); +}); + +// --- Go --- + +log(`🏟️ arena-probe → ${URL}`); +connect(); diff --git a/session-server/session.mjs b/session-server/session.mjs --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -167,10 +167,13 @@ chatManager.setPresenceResolver(getHandlesOnPiece); // 🎯 Duel Manager — server-authoritative game for dumduel piece const duelManager = new DuelManager(); +// 🏟️ Arena Manager — Q3-style server-authoritative multiplayer for arena piece +const arenaManager = new ArenaManager(); import { filter } from "./filter.mjs"; // Profanity filtering. import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. +import { ArenaManager } from "./arena-manager.mjs"; // Server-authoritative arena game. // *** AC Machines — remote device monitoring *** // Devices connect via /machines?role=device&machineId=X&token=Y @@ -2869,6 +2872,29 @@ if (parsed?.handle) duelManager.receiveInput(parsed.handle, parsed); return; } + // 🏟️ Arena messages — routed to ArenaManager (server-authoritative) + if (msg.type === "arena:hello") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.playerJoin(parsed.handle, id, { probe: !!parsed.probe }); + return; + } + if (msg.type === "arena:bye") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.playerLeave(parsed.handle); + return; + } + if (msg.type === "arena:cmd") { + // WS fallback path; the fast path is the UDP channel.on("arena:cmd", ...) handler. + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.receiveCmd(parsed.handle, parsed); + return; + } + if (msg.type === "arena:ping") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.handlePing(parsed.handle, parsed.ts, id); + return; + } + everyone(JSON.stringify(msg)); // Relay any other message to every user. } }); @@ -2878,6 +2904,9 @@ ws.on("close", () => { log("🚪 Someone left:", id, "Online:", wss.clients.size, "🫂"); const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); if (departingHandle) duelManager.playerLeave(departingHandle); + // Arena uses the raw handle (matches arena:hello), not the @-normalized form. + const rawDepartingHandle = clients?.[id]?.handle; + if (rawDepartingHandle) arenaManager.playerLeave(rawDepartingHandle); removeNotepatMidiSubscriber(id); // Remove from VSCode clients if present @@ -3058,6 +3087,29 @@ connections[wsId]?.send(pack(type, JSON.stringify(content), "duel")); }, broadcastWS: (type, content) => { everyone(pack(type, JSON.stringify(content), "duel")); + }, + resolveUdpForHandle: (handle) => { + for (const [id, client] of Object.entries(clients)) { + if (client.handle === handle && udpChannels[id]) return id; + } + return null; + }, +}); + +// 🏟️ Wire ArenaManager send functions (same shape as DuelManager; separate source tag). +arenaManager.setSendFunctions({ + sendUDP: (channelId, event, data) => { + const entry = udpChannels[channelId]; + if (entry?.channel?.webrtcConnection?.state === "open") { + try { entry.channel.emit(event, data); return true; } catch {} + } + return false; + }, + sendWS: (wsId, type, content) => { + connections[wsId]?.send(pack(type, JSON.stringify(content), "arena")); + }, + broadcastWS: (type, content) => { + everyone(pack(type, JSON.stringify(content), "arena")); }, resolveUdpForHandle: (handle) => { for (const [id, client] of Object.entries(clients)) { @@ -3505,6 +3557,8 @@ clients[channel.id].handle = identity.handle; log(`✅ UDP ${channel.id} handle: "${identity.handle}"`); // Resolve UDP channel for duel if this handle is in a duel duelManager.resolveUdpChannel(identity.handle, channel.id); + // Resolve UDP channel for arena if this handle is in the arena + arenaManager.resolveUdpChannel(identity.handle, channel.id); } } catch (e) { error(`🩰 Failed to parse identity for ${channel.id}:`, e); @@ -3631,6 +3685,22 @@ } } catch (err) { console.warn("duel:input error:", err); } + } + }); + + // 🏟️ Arena usercmd over UDP (fast path; WS is the fallback) + channel.on("arena:cmd", (data) => { + if (channel.webrtcConnection.state !== "open") return; + try { + const parsed = typeof data === "string" ? JSON.parse(data) : data; + const handle = clients[channel.id]?.handle || parsed.handle; + if (!handle) return; + arenaManager.receiveCmd(handle, parsed); + if (!clients[channel.id]?.handle && parsed.handle) { + arenaManager.resolveUdpChannel(parsed.handle, channel.id); + } + } catch (err) { + console.warn("arena:cmd error:", err); } }); diff --git a/system/public/aesthetic.computer/disks/arena.mjs b/system/public/aesthetic.computer/disks/arena.mjs --- a/system/public/aesthetic.computer/disks/arena.mjs +++ b/system/public/aesthetic.computer/disks/arena.mjs @@ -9,8 +9,428 @@ - [x] Fork from fps.mjs - [x] Large pre-tessellated ground plane - [x] Speed meter HUD + FPS counter - [x] Gravity + space-to-jump + shift-to-crouch (Quake-style) + - [x] Multiuser: WS+UDP wiring to session-server/arena-manager.mjs #endregion */ +// --------------------------------------------------------------------------- +// 🏟️ Multiuser networking (Q3-style: server-authoritative, UDP cmds + snaps). +// See plans/arena-multiplayer.md §5 for the full design. +// --------------------------------------------------------------------------- + +import { BTN, packCmd, pmove } from "../lib/pmove.mjs"; + +let myHandle = "guest"; +let netServer = null; // WebSocket (reliable) +let netUdp = null; // geckos.io channel (unreliable, low-latency) +let netSendFn = null; +let netConnectedAt = 0; + +let nextCmdSeq = 0; // monotonic per-cmd seq (implicit on wire via firstSeq) +let lastSnapAck = 0; // highest server messageNum we've seen +let serverClockOffset = 0; // add to Date.now() → server time estimate +let lastPingSent = 0; +let ping = 0; + +const CMD_RATE = 60; // cmd sends per sec +const CMD_BACKUP = 3; // how many past cmds to include in each packet +const SNAP_INTERP_MS = 100; // render remotes this far in the past +const pendingCmds = []; // unacked cmds [{ seq, cmd }], oldest first +const cmdOutbox = []; // last CMD_BACKUP cmds only (wire-level backup window) + +// Soft reconciliation tuning. +const RECONCILE_SNAP_THRESHOLD = 0.75; // > this = hard snap +const RECONCILE_SOFT_K = 0.2; // lerp factor toward predicted/frame +const RECONCILE_DEAD_ZONE = 0.05; // < this = ignore (no correction noise) + +// Arena world cfg — MUST match ARENA_CFG in session-server/arena-manager.mjs. +// Duplicated (not imported) because lib code shouldn't depend on server code. +const ARENA_CFG = Object.freeze({ + runSpeed: 10, walkSpeed: 5, jumpVelocity: 8, gravity: 50, + groundY: -1.5, eyeHeight: 2.0, crouchEyeHeight: 1.2, crouchLerp: 0.25, + groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, + deathFloorY: -30, deathFloorClearance: 0.3, + simHz: 60, hVelDecay: 0.9, +}); + +// Remote players (everyone except me) +const others = {}; // handle -> { buffer: [{serverMs,x,y,z,yaw,...}], bodyFeet, bodyArms } + +// M9: delta-snapshot bases. messageNum -> players[] (as reconstructed). +const snapBases = new Map(); +const SNAP_BASE_RING = 32; +function rememberBase(messageNum, playersArr) { + snapBases.set(messageNum, playersArr); + // Prune oldest when over ring budget. + if (snapBases.size > SNAP_BASE_RING) { + const oldest = Math.min(...snapBases.keys()); + snapBases.delete(oldest); + } +} +function applyDelta(base, delta, removed) { + // Start from a copy of base by handle, then overlay delta entries. + const byH = new Map(); + for (const p of base) byH.set(p.h, { ...p }); + for (const d of delta) { + if (d.__new) { byH.set(d.h, { ...d.__new }); continue; } + const keys = Object.keys(d); + if (keys.length === 1) continue; // { h } only → unchanged + const cur = byH.get(d.h); + if (!cur) { byH.set(d.h, { ...d }); continue; } + for (const k of keys) if (k !== "h") cur[k] = d[k]; + } + if (removed?.length) for (const h of removed) byH.delete(h); + return [...byH.values()]; +} + +// My own server-authoritative state (from snaps) — used for soft correction. +let myServerState = null; +let myServerStateMs = 0; +let myServerAckCmdMs = 0; +// Cam reference captured in netSim; used by reconciler on snap arrival. +let reconCamRef = null; +let reconCorrectionMs = 0; // monotonic debug counter for HUD + +// Tunables exposed on screen. +let netStats = { + snapsRx: 0, + cmdsTx: 0, + lastSnapMs: 0, + lastCmdMs: 0, +}; + +// Key input state captured for usercmd composition. Hooked into the +// existing keyboardState that arena already tracks for button highlights. +const netInput = { + fwd: 0, right: 0, + jumping: false, crouching: false, +}; + +function currentButtons() { + let b = 0; + if (netInput.jumping) b |= BTN.JUMP; + if (netInput.crouching) b |= BTN.CROUCH; + return b; +} + +function enqueueCmd(cam) { + if (!cam) return; + const ms = Date.now() - netConnectedAt; + const cmd = packCmd({ + ms, + fwd: netInput.fwd, + right: netInput.right, + yaw: cam.rotY, + pitch: cam.rotX, + buttons: currentButtons(), + }); + const seq = ++nextCmdSeq; + pendingCmds.push({ seq, cmd }); + cmdOutbox.push({ seq, cmd }); + while (cmdOutbox.length > CMD_BACKUP) cmdOutbox.shift(); + // Cap pending queue defensively (at 60Hz cmd rate + 1s RTT ceiling ≈ 60). + while (pendingCmds.length > 120) pendingCmds.shift(); +} + +function flushCmds() { + if (cmdOutbox.length === 0) return; + const frame = { + handle: myHandle, + firstSeq: cmdOutbox[0].seq, + ack: lastSnapAck, + cmds: cmdOutbox.map((e) => e.cmd), + }; + if (netUdp?.connected) netUdp.send("arena:cmd", frame); + else netServer?.send("arena:cmd", frame); + netStats.cmdsTx++; + netStats.lastCmdMs = Date.now(); +} + +function onSnap(snap) { + netStats.snapsRx++; + netStats.lastSnapMs = Date.now(); + if (snap.messageNum > lastSnapAck) lastSnapAck = snap.messageNum; + + // Clock sync (simple: use the freshest server time as the offset anchor). + // Real impl should min-filter + smooth; this is enough for interp. + const localMs = Date.now(); + serverClockOffset = snap.serverMs - (localMs - netConnectedAt); + + // M10: drop cmds the server has acked (seq-based; firstSeq implicit). + if (typeof snap.ackCmdSeq === "number") { + while (pendingCmds.length && pendingCmds[0].seq <= snap.ackCmdSeq) { + pendingCmds.shift(); + } + } + + // M9: reconstruct full player list from either full or delta snap. + let blobs; + if (snap.deltaNum && snap.delta) { + const base = snapBases.get(snap.deltaNum); + if (!base) { + // Base expired / never saw it. Server will send a full snap on the + // next tick because our ack will re-anchor. Skip this one. + return; + } + blobs = applyDelta(base, snap.delta, snap.removed); + } else { + blobs = snap.players || []; + } + // Commit as a base for future delta decoding. + if (typeof snap.messageNum === "number" && snap.messageNum > 0) { + rememberBase(snap.messageNum, blobs); + } + const seen = new Set(); + for (const p of blobs) { + if (p.h === myHandle) { + myServerState = p; + myServerStateMs = snap.serverMs; + myServerAckCmdMs = typeof snap.ackCmdMs === "number" ? snap.ackCmdMs : myServerAckCmdMs; + continue; + } + seen.add(p.h); + let o = others[p.h]; + if (!o) { + o = others[p.h] = { buffer: [], bodyFeet: null, bodyArms: null, lastSeenMs: snap.serverMs }; + } + o.lastSeenMs = snap.serverMs; + // Append to interpolation buffer (keep ~500ms of history). + o.buffer.push({ + ms: snap.serverMs, + x: p.x, y: p.y, z: p.z, + yaw: p.yaw, pitch: p.pitch, + crouchT: p.c, + onGround: !!p.g, + alive: !!p.a, + }); + while (o.buffer.length > 32) o.buffer.shift(); + } + // Prune others not in this snap for >2s (graceful drop). + for (const h of Object.keys(others)) { + if (seen.has(h)) continue; + if (snap.serverMs - others[h].lastSeenMs > 2000) delete others[h]; + } + + // M7: client-side prediction reconciliation. + reconcileLocal(); +} + +// Starting from the server's authoritative state for me, replay every +// still-unacked cmd → this is where the server WILL arrive once the rest +// of our in-flight cmds reach it. Compare to cam-doll's current local +// position; if divergent, soft-correct (small drift) or snap (big desync). +function reconcileLocal() { + if (!myServerState || !reconCamRef) return; + const cam = reconCamRef; + + // Build a pmove-compatible state from the wire blob. + let predicted = { + x: myServerState.x, y: myServerState.y, z: myServerState.z, + vx: myServerState.vx || 0, vy: myServerState.vy || 0, vz: myServerState.vz || 0, + yaw: myServerState.yaw || 0, pitch: myServerState.pitch || 0, + crouchT: myServerState.c || 0, + onGround: !!myServerState.g, + frozen: false, + alive: !!myServerState.a, + }; + + // Replay each pending cmd in order, using its ms delta for dt. The "base + // ms" for the first pending cmd is the server's last-applied cmd ms. + let prevMs = myServerAckCmdMs; + for (const { cmd } of pendingCmds) { + const dt = prevMs > 0 ? Math.min((cmd.ms - prevMs) / 1000, 0.25) : 1 / 60; + predicted = pmove(predicted, { ...cmd, dt }, ARENA_CFG); + prevMs = cmd.ms; + } + + // Compare cam-doll's current world position to predicted. + // cam.x/y/z store negated world coords (see cam-doll.mjs). + const localX = -cam.x, localY = -cam.y, localZ = -cam.z; + const dx = predicted.x - localX; + const dy = predicted.y - localY; + const dz = predicted.z - localZ; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + + if (dist < RECONCILE_DEAD_ZONE) return; // within float-noise → ignore. + + if (dist > RECONCILE_SNAP_THRESHOLD) { + // Large desync (teleport / forced respawn / long stall) — hard snap. + cam.x = -predicted.x; + cam.y = -predicted.y; + cam.z = -predicted.z; + reconCorrectionMs++; + return; + } + + // Small drift — blend cam toward predicted over ~5 frames. + cam.x += -dx * RECONCILE_SOFT_K; + cam.y += -dy * RECONCILE_SOFT_K; + cam.z += -dz * RECONCILE_SOFT_K; + reconCorrectionMs++; +} + +function netBoot({ net, handle, send }) { + netSendFn = send; + myHandle = handle?.() || "guest_" + Math.floor(Math.random() * 9999); + netConnectedAt = Date.now(); + if (!net) return; + + const { socket, udp } = net; + + netUdp = udp?.((type, content) => { + if (type !== "arena:snap") return; + const s = typeof content === "string" ? JSON.parse(content) : content; + onSnap(s); + }); + + netServer = socket?.((id, type, content) => { + if (type.startsWith("connected")) { + netServer.send("arena:hello", { handle: myHandle }); + return; + } + const msg = typeof content === "string" ? JSON.parse(content) : content; + if (type === "arena:welcome") { + console.log(`🏟️ welcome → ${msg.you}, ${msg.roster?.length ?? 0} already in`); + return; + } + if (type === "arena:snap") { onSnap(msg); return; } // WS fallback + if (type === "arena:join") { + if (msg.handle !== myHandle && !others[msg.handle]) { + others[msg.handle] = { buffer: [], bodyFeet: null, bodyArms: null, lastSeenMs: Date.now() }; + } + return; + } + if (type === "arena:leave") { delete others[msg.handle]; return; } + if (type === "arena:pong") { ping = Date.now() - msg.ts; return; } + }); +} + +function netSim(cam) { + reconCamRef = cam; // kept across frames so reconcileLocal can correct. + // Poll input state → usercmd each sim tick (120 Hz). Send at CMD_RATE. + // (arena.mjs already has `keyboardState` + `gamepadState` that we mirror.) + // pmove convention: fwd=+1 moves along facing (forward), right=+1 strafes right. + netInput.fwd = (keyboardState.w || keyboardState.arrowup) ? 1 + : (keyboardState.s || keyboardState.arrowdown) ? -1 : 0; + netInput.right = (keyboardState.d || keyboardState.arrowright) ? 1 + : (keyboardState.a || keyboardState.arrowleft) ? -1 : 0; + // Gamepad left-stick: stick-up (gy<0) is forward, stick-right (gx>0) is strafe right. + if (gamepadState.connected) { + const gx = gamepadState.axes[0] || 0, gy = gamepadState.axes[1] || 0; + if (Math.abs(gx) > 0.3) netInput.right = gx > 0 ? 1 : -1; + if (Math.abs(gy) > 0.3) netInput.fwd = gy > 0 ? -1 : 1; + } + netInput.jumping = !!keyboardState.space || !!gamepadState.buttons?.[0]; + netInput.crouching = !!keyboardState.shift || !!gamepadState.buttons?.[1]; + + enqueueCmd(cam); + // Throttle outbound packets to CMD_RATE (= every Nth 120Hz tick). + if (!netSim._acc) netSim._acc = 0; + netSim._acc++; + if (netSim._acc >= 120 / CMD_RATE) { netSim._acc = 0; flushCmds(); } + + // Periodic ping for latency HUD. + if (Date.now() - lastPingSent > 2000) { + lastPingSent = Date.now(); + netServer?.send("arena:ping", { handle: myHandle, ts: Date.now() }); + } +} + +function renderTimeNow() { + return (Date.now() - netConnectedAt) + serverClockOffset - SNAP_INTERP_MS; +} + +function sampleOther(o, t) { + const buf = o.buffer; + if (buf.length === 0) return null; + if (t <= buf[0].ms) return buf[0]; + if (t >= buf[buf.length - 1].ms) return buf[buf.length - 1]; // freeze on starve + // Find bracketing entries. + for (let i = 0; i < buf.length - 1; i++) { + const a = buf[i], b = buf[i + 1]; + if (t >= a.ms && t <= b.ms) { + const k = (t - a.ms) / Math.max(1, b.ms - a.ms); + return { + x: a.x + (b.x - a.x) * k, + y: a.y + (b.y - a.y) * k, + z: a.z + (b.z - a.z) * k, + yaw: lerpAngle(a.yaw, b.yaw, k), + pitch: a.pitch + (b.pitch - a.pitch) * k, + crouchT: a.crouchT + (b.crouchT - a.crouchT) * k, + onGround: b.onGround, + alive: b.alive, + }; + } + } + return buf[buf.length - 1]; +} + +function lerpAngle(a, b, k) { + let d = ((b - a + 540) % 360) - 180; // shortest arc + return a + d * k; +} + +// Build a small humanoid stick-figure as a single line Form. Cheap to clone +// per remote (handful of verts) and matches the arena's visual language. +function buildRemoteBody(Form, colorRGB) { + const [r, g, b] = colorRGB; + const col = [r / 255, g / 255, b / 255, 0.9]; + const colDim = [r / 255, g / 255, b / 255, 0.5]; + const head = 0.3, shoulder = -0.4, hip = -1.1, foot = -2.0; + // coords are (x, y, z, w); y is "up" in the form's local space. + const pts = [ + // spine + [0, head, 0, 1], [0, hip, 0, 1], + // shoulders + [-0.35, shoulder, 0, 1], [0.35, shoulder, 0, 1], + // arms (slightly forward) + [-0.35, shoulder, 0, 1], [-0.45, -0.9, 0.25, 1], + [ 0.35, shoulder, 0, 1], [ 0.45, -0.9, 0.25, 1], + // hips → feet + [-0.18, hip, 0, 1], [-0.18, foot, 0, 1], + [ 0.18, hip, 0, 1], [ 0.18, foot, 0, 1], + ]; + const cols = [col, col, col, col, col, colDim, col, colDim, col, col, col, col]; + const f = new Form({ type: "line", positions: pts, colors: cols }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }); + f.noFade = true; + return f; +} + +// Deterministic per-handle color so remotes are distinguishable at a glance. +function handleColor(handle) { + let h = 0; for (let i = 0; i < handle.length; i++) h = (h * 31 + handle.charCodeAt(i)) | 0; + const hue = (h >>> 0) % 360; + // HSL→RGB (s=0.7, l=0.6) + const c = 0.7 * 0.6 * 2, x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = 0.6 - c / 2; + let r = 0, g = 0, b = 0; + if (hue < 60) [r, g, b] = [c, x, 0]; + else if (hue < 120) [r, g, b] = [x, c, 0]; + else if (hue < 180) [r, g, b] = [0, c, x]; + else if (hue < 240) [r, g, b] = [0, x, c]; + else if (hue < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} + +// Called from paint() once per frame. +function paintRemotes(ink, form, Form) { + const t = renderTimeNow(); + for (const [handle, o] of Object.entries(others)) { + const sample = sampleOther(o, t); + if (!sample) continue; + if (!o.body) o.body = buildRemoteBody(Form, handleColor(handle)); + // Mirror local body positioning: Form.position uses (-x, y, -z). + o.body.position[0] = -sample.x; + o.body.position[1] = sample.y; + o.body.position[2] = -sample.z; + o.body.rotation[1] = sample.yaw; + ink(255).form(o.body); + } +} + +// --------------------------------------------------------------------------- + let groundPlane; let groundSkirt; // solid opaque plate just under the ground that blocks @@ -290,7 +710,7 @@ 1.0, ]; } -function boot({ Form, penLock, system, screen, ui, api, painting }) { +function boot({ Form, penLock, system, screen, ui, api, painting, net, handle, send }) { penLock(); FormRef = Form; paintingRef = painting; @@ -298,6 +718,9 @@ const cam = system?.fps?.doll?.cam; if (cam) { prevX = cam.x; prevY = cam.y; prevZ = cam.z; } lastFrameTime = performance.now(); + + // 🏟️ Multiplayer: open WS + UDP, send arena:hello. + netBoot({ net, handle, send }); // 🎯 Set initial cursor style if (api?.cursor) { @@ -939,6 +1362,9 @@ // Advance the sim clock first so any logic below that wants elapsed // time sees the fresh value. simTime += 1 / SIM_HZ; + // 🏟️ Multiplayer: compose usercmd, batch & flush at CMD_RATE. + netSim(cam); + // 📱 Update mobile button states if (mobileButtons && doll) { for (const [name, btnData] of Object.entries(mobileButtons)) { @@ -1302,6 +1728,9 @@ if (bodyFeet) ink(255).form(bodyFeet); if (bodyArms) ink(255).form(bodyArms); } + // 🏟️ Remote players (interpolated from server snapshots, rendered ~100ms behind). + paintRemotes(ink, undefined, FormRef); + // --- HUD (top-right) --- const font = "MatrixChunky8"; const margin = 4; @@ -1324,6 +1753,24 @@ // FOV + run speed (Quake-style spec) ink(150, 200, 255); rightLabel(`FOV ${FOV}`, margin + lineH * 2); rightLabel(`RUN ${RUN_SPEED.toFixed(1)}u/s`, margin + lineH * 3); + + // 🏟️ Net — ping, snap rx, cmd tx, peer count. Under the "AIR/GROUND" row. + { + const wsOk = !!netServer; + const udpOk = !!netUdp?.connected; + const nowMs = Date.now(); + const snapAgeMs = netStats.lastSnapMs ? nowMs - netStats.lastSnapMs : Infinity; + const color = !wsOk ? [200, 80, 80] + : udpOk ? (snapAgeMs < 500 ? [120, 230, 120] : [230, 200, 80]) + : [200, 180, 80]; + ink(...color); + rightLabel(`${udpOk ? "UDP" : wsOk ? "WS" : "--"} ${ping}ms`, margin + lineH * 6); + ink(160, 160, 180); + rightLabel(`rx ${netStats.snapsRx} tx ${netStats.cmdsTx}`, margin + lineH * 7); + const peers = Object.keys(others).length; + ink(peers > 0 ? [180, 230, 180] : [130, 130, 130]); + rightLabel(`peers ${peers}`, margin + lineH * 8); + } // 🏃 Current speed — colored by how close to max. const upsNow = speedSmoothed * SIM_HZ; diff --git a/system/public/aesthetic.computer/lib/pmove.mjs b/system/public/aesthetic.computer/lib/pmove.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/lib/pmove.mjs @@ -0,0 +1,194 @@ +// pmove.mjs — pure player-movement function, shared by client prediction +// and server authority. Node- and browser-compatible. No deps. +// +// The math mirrors lib/cam-doll.mjs's physics pass so a client running +// cam-doll locally and the server running pmove on authoritative state +// converge within float rounding. +// +// Coordinate convention: WORLD coordinates (not cam.* which is negated). +// +X right, +Y up, +Z forward (player faces along yaw). +// +// All mutation is functional: pmove returns a *new* state object; the +// caller decides when to commit. Keep this file dep-free — importable +// from both @ac.mjs in the browser and from session-server in Node. + +export const DEFAULT_CFG = Object.freeze({ + runSpeed: 10, // units/sec + walkSpeed: 5, // units/sec while crouched + jumpVelocity: 8, // initial upward velocity on jump (u/s) + gravity: 50, // u/s² + groundY: 0, // world Y of the solid ground plane + eyeHeight: 2.0, // stand eye height above groundY + crouchEyeHeight: 1.2, // crouched eye height + crouchLerp: 0.25, // per-tick lerp toward crouch target + groundBounds: null, // { xMin, xMax, zMin, zMax } or null + deathFloorY: null, // world Y clamp for frozen players (lava pit) + deathFloorClearance: 0.3, + simHz: 120, + // Dolly-style horizontal damping. cam-doll uses a 0.9 decay + push that + // settles at `speed` units/sec. We fold that into a direct integration + // here so the server doesn't need the Dolly object: hVelDecay per tick + // and a push matching the same steady state. + hVelDecay: 0.9, +}); + +// Buttons bitmask (matches usercmd wire format). +export const BTN = Object.freeze({ + JUMP: 1 << 0, + CROUCH: 1 << 1, + SHOOT: 1 << 2, + DASH: 1 << 3, +}); + +/** + * Build a fresh neutral player state at the given spawn. + */ +export function newState({ x = 0, z = 0, yaw = 0, pitch = 0, cfg = DEFAULT_CFG } = {}) { + return { + x, + y: cfg.groundY + cfg.eyeHeight, // eye position in world Y + z, + vx: 0, + vz: 0, + vy: 0, // world-up velocity + yaw, // degrees + pitch, // degrees, clamped ±89 + crouchT: 0, + onGround: true, + frozen: false, + alive: true, + }; +} + +/** + * Apply one usercmd to a state. Pure: returns new state object. + * + * state : player state (see newState) + * cmd : { + * fwd: -1 | 0 | 1 // forward/back intent + * right: -1 | 0 | 1 // strafe intent + * yaw, pitch // camera angles (degrees) + * buttons: bitmask // BTN.JUMP etc. + * dt: seconds // elapsed wall time since last cmd + * } + * cfg : movement tuning (see DEFAULT_CFG) + */ +export function pmove(state, cmd, cfg = DEFAULT_CFG) { + const s = { ...state }; + const dt = clamp(cmd.dt ?? 1 / cfg.simHz, 0, 0.25); // cap dt so a pause doesn't teleport + + // --- Look: accept cmd-provided yaw/pitch verbatim, but clamp pitch. --- + if (typeof cmd.yaw === "number") s.yaw = cmd.yaw; + if (typeof cmd.pitch === "number") s.pitch = clamp(cmd.pitch, -89, 89); + + const crouching = (cmd.buttons & BTN.CROUCH) !== 0; + const jumping = (cmd.buttons & BTN.JUMP) !== 0; + + // --- Horizontal movement: rotate (right, fwd) by yaw, integrate. --- + const speed = crouching ? cfg.walkSpeed : cfg.runSpeed; + + // Normalise input vector so diagonals aren't faster. + let ix = cmd.right || 0; + let iz = cmd.fwd || 0; + const ilen = Math.hypot(ix, iz); + if (ilen > 1) { ix /= ilen; iz /= ilen; } + + // Rotate input into world space by yaw. + // yaw 0 → facing +Z; +yaw rotates clockwise looking down. + const yr = s.yaw * Math.PI / 180; + const sy = Math.sin(yr), cy = Math.cos(yr); + // Desired world velocity this frame from input: + const wx = (ix * cy + iz * sy) * speed; + const wz = (-ix * sy + iz * cy) * speed; + + // cam-doll uses Dolly's decay + push which converges to `speed` at full + // stick. We approximate the same feel with a per-tick lerp toward the + // input velocity. + const decay = Math.pow(cfg.hVelDecay, dt * cfg.simHz); + s.vx = s.vx * decay + wx * (1 - decay); + s.vz = s.vz * decay + wz * (1 - decay); + + s.x += s.vx * dt; + s.z += s.vz * dt; + + // --- Crouch lerp. --- + const crouchTarget = (!s.frozen && crouching) ? 1 : 0; + s.crouchT += (crouchTarget - s.crouchT) * cfg.crouchLerp; + if (s.crouchT < 0.0005 && crouchTarget === 0) s.crouchT = 0; + if (s.crouchT > 0.9995 && crouchTarget === 1) s.crouchT = 1; + const effEye = cfg.eyeHeight + (cfg.crouchEyeHeight - cfg.eyeHeight) * s.crouchT; + + // --- Jump: initiate on edge, only when grounded and not frozen. --- + if (!s.frozen && jumping && s.onGround) { + s.vy = cfg.jumpVelocity; + s.onGround = false; + } + + // --- Vertical integration: always run gravity; frozen players still fall. --- + if (!s.onGround || s.frozen) { + s.vy -= cfg.gravity * dt; + s.y += s.vy * dt; + } + + // --- Ground clamp: only when over the solid ground rectangle. --- + let onSolid = true; + if (cfg.groundBounds) { + const b = cfg.groundBounds; + onSolid = s.x >= b.xMin && s.x <= b.xMax && s.z >= b.zMin && s.z <= b.zMax; + } + + const floorY = cfg.groundY + effEye; + if (onSolid && !s.frozen) { + if (s.y <= floorY) { + s.y = floorY; + if (s.vy < 0) s.vy = 0; + s.onGround = true; + } else if (s.onGround) { + // Crouch release bumped eye above floor — stick to it; no fall. + s.y = floorY; + } + } else { + s.onGround = false; + } + + // --- Death floor clamp (lava pit). --- + if (s.frozen && cfg.deathFloorY !== null && cfg.deathFloorY !== undefined) { + const lavaY = cfg.deathFloorY + cfg.deathFloorClearance; + if (s.y <= lavaY) { + s.y = lavaY; + s.vy = 0; + } + } + + return s; +} + +/** + * Encode a usercmd to a compact JSON object for the wire. + * (Bit-packing is an optional later optimization — see plan §5.3.) + */ +export function packCmd(cmd) { + return { + ms: cmd.ms | 0, + f: cmd.fwd | 0, + r: cmd.right | 0, + y: round2(cmd.yaw), + p: round2(cmd.pitch), + b: cmd.buttons | 0, + }; +} + +export function unpackCmd(w) { + return { + ms: w.ms | 0, + fwd: w.f | 0, + right: w.r | 0, + yaw: +w.y || 0, + pitch: +w.p || 0, + buttons: w.b | 0, + // dt is derived by the consumer: (this.ms - prevCmd.ms) / 1000. + }; +} + +function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } +function round2(n) { return Math.round(n * 100) / 100; } -- tangled.sh