Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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) <noreply@anthropic.com>

+1815 -1
+2
package.json
··· 86 86 "session:reset": "f() { cd session-server; npx jamsocket backend terminate $1 };f", 87 87 "session:alive": "cd session-server; npx jamsocket backend list", 88 88 "session:publish": "fish aesthetic-computer-vault/session-server/deploy.fish", 89 + "arena:probe": "node session-server/arena-probe.mjs", 90 + "arena:probe:local": "node session-server/arena-probe.mjs --url ws://localhost:8889", 89 91 "server:session": "cd session-server; npm run dev", 90 92 "server:session:build": "cd session-server; npm run build:jamsocket", 91 93 "server:session:deploy": "cd session-server; npm run deploy",
+552
plans/arena-multiplayer.md
··· 1 + # arena.mjs — Multiplayer Plan 2 + 3 + **Goal:** put more than one figure on the arena platform. Preserve the current 4 + single-player feel (Quake-style movement, lava pit, shadow, third-person). 5 + Add presence so other logged-in users appear as walking stick figures. 6 + 7 + --- 8 + 9 + ## 1. The networking model you already have 10 + 11 + Two transports, one session server (`session-server/session.mjs`): 12 + 13 + | Transport | Where defined | Library | Delivery | Used for | 14 + |---|---|---|---|---| 15 + | **WebSocket** | `net.socket(cb)` in disks | `ws` package, `wss` in session.mjs | reliable, ordered | join/leave, roster, chat, scoring, invites | 16 + | **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 | 17 + 18 + Both channels carry `{ type, content }` frames (content is a JSON string or 19 + object). On the server a client is a single logical identity with **two** 20 + connection IDs — one in `connections[id]` (WS) and one in `udpChannels[id]` 21 + (UDP). They are linked by the client's `handle`, glued via the `udp:identity` 22 + message that every UDP channel sends right after it opens. Lookup helper: 23 + `resolveUdpForHandle(handle)` scans `clients` to find the matching UDP channel. 24 + 25 + ### Three relay patterns — pick one per message type 26 + 27 + **A. `everyone(str)` — WS broadcast to all** 28 + Used for `roster`, `world:*`, `build:*`, `reload`. Simple broadcast. 29 + 30 + **B. `others(str)` / `channel.broadcast.emit(evt, data)` — relay all-except-sender** 31 + Used for `1v1:move`, `squash:move`. Session server is a dumb relay: it does no 32 + state management, no validation. Each client is the source of truth for its 33 + own avatar; others see a lagged copy. This is the pattern to copy for arena 34 + presence. 35 + 36 + **C. Server-authoritative (see `duel-manager.mjs`)** 37 + Server owns state. Clients send inputs only (`duel:input { seq, targetX, 38 + targetY }`). Server runs a 60 Hz tick, broadcasts snapshots at ~20 Hz. 39 + Clients predict locally, reconcile when ack'd input `seq` comes back in 40 + `lastInputSeq[myHandle]`. This is what dumduel uses. 41 + 42 + ### How `duel-manager.mjs` hooks in — the canonical manager pattern 43 + 44 + ```js 45 + // session.mjs 46 + import { DuelManager } from "./duel-manager.mjs"; 47 + const duelManager = new DuelManager(); 48 + duelManager.setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }); 49 + 50 + // WS message dispatch: 51 + if (msg.type === "duel:join") duelManager.playerJoin(handle, wsId); 52 + if (msg.type === "duel:input") duelManager.receiveInput(handle, parsed); 53 + 54 + // UDP channel handler: 55 + channel.on("duel:input", (data) => duelManager.receiveInput(handle, parsed)); 56 + ``` 57 + 58 + The Manager object is the only place game state lives. `session.mjs` just 59 + shuttles frames. This is the model to extend if/when arena needs 60 + server-authoritative combat. 61 + 62 + ### Identity — how handle becomes the key 63 + 64 + - On WS connect, client sends `{ type: "login" }` (see `chat-manager.mjs`) or 65 + piece-specific `*:join { handle }`. The session records 66 + `clients[id].handle = handle`. 67 + - On UDP connect, `geckos.io` issues a channel id. The client sends 68 + `udp:identity { handle, user }` as its first message; server writes 69 + `clients[channelId].handle`, and — if a DuelManager/arena manager wants the 70 + UDP channel — calls `resolveUdpChannel(handle, channelId)`. 71 + - `guest_xxxx` handles are first-class but typically demoted (dumduel puts 72 + them in `spectators`). 73 + 74 + --- 75 + 76 + ## 2. What arena.mjs looks like today 77 + 78 + - Single-player. Zero networking. Zero imports from `net`. 79 + - Uses `export const system = "fps"` → framework provides 80 + `system.fps.doll` (a `Camdoll` from `lib/cam-doll.mjs`) which owns camera, 81 + physics, crouch/jump, and groundY clamping. 82 + - Player position is `phys.playerCamX/Y/Z` (negated world coords — cam stores 83 + `-worldX`). `playerFacing` = `cam.rotY` except while orbiting. 84 + - Rendering body parts uses two 3D `Form` objects — `bodyFeet` and `bodyArms` 85 + — positioned/rotated each sim tick at the local player. The ground 86 + (`groundPlane`), skirt, platform, lava, and shadow are scene geometry. 87 + - Death = Y fell into the pit. Respawn = `doll.respawn(0,0)`. No concept of 88 + other players' life state. 89 + 90 + So to become multi-user, arena needs: 91 + 92 + 1. **A network identity** — resolve handle in `boot`, open WS + UDP. 93 + 2. **An `others` map** of remote players — pos, rotY (facing), jumping/crouch 94 + anim state, alive flag. 95 + 3. **Outgoing position sync** — throttled UDP send of own state. 96 + 4. **Incoming state handling** — buffered + lerped like dumduel's opponent. 97 + 5. **Per-remote render** — clone the `bodyFeet`/`bodyArms` Form recipe once 98 + per other; reposition each paint call. 99 + 6. **Presence lifecycle** — `arena:join` on connect, `arena:leave` on 100 + disconnect, roster updates. 101 + 102 + --- 103 + 104 + ## 3. Recommended approach — start with relay-only ("Tier 1") 105 + 106 + Mirror `squash.mjs` / `1v1.mjs`: session server is a dumb relay. Each client 107 + owns its own avatar. Keep the single-player `cam-doll` physics intact — 108 + authority for "where am I" stays local. We just paint other people. 109 + 110 + This lands the visible win (more figures on the board) with the smallest 111 + diff and no new server state. If we later want shooting, hit detection, or 112 + a death-pit-score shared across players, we add an `ArenaManager` alongside 113 + `DuelManager` (Tier 2 below). 114 + 115 + ### Message shape (Tier 1) 116 + 117 + ```js 118 + // Client → server, UDP, ~30Hz 119 + { 120 + type: "arena:move", 121 + content: { 122 + handle, 123 + x, y, z, // world coords 124 + rotY, // facing (degrees) 125 + vy, // vertical velocity (for jump animation on remotes) 126 + onGround, // bool 127 + crouch, // 0..1 128 + alive, // bool 129 + } 130 + } 131 + 132 + // Client → server, WS 133 + { type: "arena:join", content: { handle } } 134 + { type: "arena:leave", content: { handle } } 135 + { type: "arena:respawn", content: { handle } } // reliable event 136 + { type: "arena:died", content: { handle } } // reliable event 137 + ``` 138 + 139 + Server-side relay (no state held): 140 + 141 + ```js 142 + // In session.mjs WS dispatch, next to 1v1:move / squash:move 143 + if (msg.type === "arena:move") { others(JSON.stringify(msg)); return; } // WS fallback 144 + if (msg.type === "arena:join" || msg.type === "arena:leave" 145 + || msg.type === "arena:respawn" || msg.type === "arena:died") { 146 + everyone(JSON.stringify(msg)); return; 147 + } 148 + 149 + // In io.onConnection geckos handler, next to squash:move 150 + channel.on("arena:move", (data) => { 151 + if (channel.webrtcConnection.state === "open") { 152 + try { channel.broadcast.emit("arena:move", data); } catch {} 153 + } 154 + }); 155 + ``` 156 + 157 + ### Client changes (`disks/arena.mjs`) 158 + 159 + 1. **Boot signature** 160 + ```js 161 + function boot({ Form, penLock, system, screen, ui, api, painting, 162 + net: { socket, udp }, handle }) { ... } 163 + ``` 164 + 165 + 2. **New module state** 166 + ```js 167 + let myHandle = "guest"; 168 + let server, udpChannel; 169 + let others = {}; // { [handle]: { x,y,z, rotY, vy, onGround, crouch, alive, 170 + // serverX, serverY, serverZ, // latest from net 171 + // displayX, displayY, displayZ, // lerped 172 + // bodyFeet, bodyArms // 3D Forms built lazily 173 + // } } 174 + let lastUdpSend = 0; 175 + const UDP_SEND_INTERVAL = 4; // sim ticks, = 30Hz at 120Hz sim 176 + const LERP_SPEED = 0.25; 177 + ``` 178 + 179 + 3. **Connect in boot** 180 + ```js 181 + myHandle = handle?.() || "guest_" + Math.floor(Math.random()*9999); 182 + 183 + udpChannel = udp((type, content) => { 184 + if (type === "arena:move") { 185 + const d = typeof content === "string" ? JSON.parse(content) : content; 186 + if (d.handle === myHandle) return; 187 + upsertOther(d); 188 + } 189 + }); 190 + 191 + server = socket((id, type, content) => { 192 + if (type.startsWith("connected")) { 193 + server.send("arena:join", { handle: myHandle }); 194 + return; 195 + } 196 + const msg = typeof content === "string" ? JSON.parse(content) : content; 197 + if (type === "arena:move") upsertOther(msg); // WS fallback 198 + if (type === "arena:join") upsertOther({ handle: msg.handle, alive: true }); 199 + if (type === "arena:leave") delete others[msg.handle]; 200 + if (type === "arena:died") { if (others[msg.handle]) others[msg.handle].alive = false; } 201 + if (type === "arena:respawn") { if (others[msg.handle]) others[msg.handle].alive = true; } 202 + }); 203 + ``` 204 + 205 + 4. **Outgoing state in `sim`** — after the doll physics update, guarded by 206 + `simTime % (UDP_SEND_INTERVAL/SIM_HZ)`: 207 + ```js 208 + lastUdpSend++; 209 + if (lastUdpSend >= UDP_SEND_INTERVAL) { 210 + lastUdpSend = 0; 211 + const payload = { 212 + handle: myHandle, 213 + x: -playerCamX, y: -playerCamY, z: -playerCamZ, // to world 214 + rotY: playerFacing, vy: phys?.vy ?? 0, 215 + onGround: phys?.onGround ?? true, 216 + crouch: phys?.crouch ?? 0, 217 + alive: playerAlive, 218 + }; 219 + if (udpChannel?.connected) udpChannel.send("arena:move", payload); 220 + else server?.send("arena:move", payload); 221 + } 222 + ``` 223 + 224 + 5. **Interpolation in `sim`** — for every other, lerp display toward server: 225 + ```js 226 + for (const o of Object.values(others)) { 227 + o.displayX += (o.serverX - o.displayX) * LERP_SPEED; 228 + o.displayY += (o.serverY - o.displayY) * LERP_SPEED; 229 + o.displayZ += (o.serverZ - o.displayZ) * LERP_SPEED; 230 + } 231 + ``` 232 + 233 + 6. **Rendering in `paint`** — build + reposition a `bodyFeet`/`bodyArms` 234 + pair per other. Simplest: reuse the same form-building code from boot, 235 + factored into `makeBody()` that returns `{ feet, arms }`. On first 236 + snapshot for a new handle, build it; store on the `others[handle]` 237 + record; each frame update `.position` and `.rotation[1]`. Add to 238 + `paint`'s Form render list the same way local body parts are. 239 + 240 + Optional polish: fade new joiners in over ~0.5s; pulse on death. 241 + 242 + 7. **Teardown** — there's no `leave()` currently; add one to emit 243 + `arena:leave` before the piece unmounts. 244 + 245 + ### Server changes 246 + 247 + Two small patches to `session-server/session.mjs`: 248 + 249 + - **WS dispatch** near the `1v1:move` / `squash:move` / `duel:*` section 250 + (~line 2815): add the four `arena:*` handlers above. 251 + - **UDP handler** near `channel.on("squash:move", ...)` (~line 3607): add 252 + the `arena:move` broadcast. 253 + 254 + No new file, no new manager, no session-server state. 255 + 256 + --- 257 + 258 + ## 4. Open design questions 259 + 260 + 1. **Scope of presence.** Does "arena" mean one shared room across the whole 261 + session server, or one-per-spawn? Dumduel / squash assume one global room. 262 + Simplest to start the same way; partition later with `arena:<roomId>` 263 + message prefixes if needed. 264 + 2. **Guests.** Include `guest_xxx` as first-class figures or as ghosts / 265 + hide them? Dumduel demotes to spectators — that feels wrong for a 266 + platform game. Recommend: include, but render with 50% alpha + italic 267 + handle label. 268 + 3. **Remote body rendering cost.** Each remote = two Forms. With N players 269 + that's 2N forms plus shadows and labels. Acceptable up to ~10 remotes; 270 + above that we'd want a single batched form. 271 + 4. **Handle labels over heads.** Need to project world → screen (FPS camera). 272 + arena already has the inverse ray in `sim` for `hoverTile`; reuse the 273 + math to draw 2D text above each remote. 274 + 5. **Death pit as shared hazard?** Today each client decides its own death 275 + from local Y. For presence-only that's fine — the `arena:died` message 276 + is just cosmetic. If we want kills ("push someone into the pit") that 277 + becomes authority-contested → promote to Tier 2. 278 + 279 + --- 280 + 281 + ## 5. Tier 2 — Quake 3-caliber netcode on WS + geckos.io 282 + 283 + Target: competitive FPS feel (pro-mode quality) on our existing transports. 284 + No new infra — we **do not** add a raw UDP socket, a packet-level protocol, 285 + or a second server. Everything runs through `socket()` (reliable) and 286 + `udp()` (geckos.io WebRTC DataChannel), plus a new `ArenaManager` class 287 + sitting next to `DuelManager` in `session-server/`. 288 + 289 + ### 5.1 Q3 concepts → what they map to for us 290 + 291 + Q3 invented this pattern; everything below is a direct adaptation. Sources: 292 + [Fabien Sanglard's Q3 network review](https://fabiensanglard.net/quake3/network.php), 293 + [jfedor Q3 wire format](https://www.jfedor.org/quake3/), 294 + [id's `sv_snapshot.c`](https://github.com/id-Software/Quake-III-Arena/blob/master/code/server/sv_snapshot.c), 295 + [SnapNet on snapshot interpolation](https://snapnet.dev/blog/netcode-architectures-part-3-snapshot-interpolation/). 296 + 297 + | Q3 concept | Q3 implementation | AC adaptation | 298 + |---|---|---| 299 + | **Transport** | one UDP socket; reliable cmds multiplexed via seq+ack inside UDP | **split**: WS = reliable channel, geckos.io UDP = `cmd`/`snap` | 300 + | **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) | 301 + | **`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 | 302 + | **`usercmd_t`** | `{ serverTime, angles[3], forwardmove, rightmove, upmove, buttons, weapon }` | `{ ms, yaw, pitch, fwd, right, up, buttons }` — `buttons` = bitmask (jump\|crouch\|shoot\|dash) | 303 + | **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 | 304 + | **`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 | 305 + | **Delta compression** | bit-per-field "changed?" marker, terminate at last-changed index | identical algorithm; field table built once from an entity schema object | 306 + | **Snap ack** | every outgoing client packet includes `serverMessageSequence` = last snap seen | every `arena:cmd` includes `ack: lastSeenMessageNum` | 307 + | **`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 | 308 + | **`cl_snaps` / `cl_maxpackets`** | client requests 20–40 snaps, sends 30–125 cmds/s | cmd rate **60 Hz**, snap rate decided by server | 309 + | **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 | 310 + | **Interpolation (`cl_interp`)** | render remote entities ~100 ms in the past, between two known snaps | `INTERP_DELAY_MS = 100`; never extrapolate | 311 + | **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 | 312 + | **PVS culling** | only send entities in the player's potentially-visible set | arena is small (±14 units); skip PVS. Send all players always. | 313 + | **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` | 314 + 315 + ### 5.2 Why the split transport is actually *better* for us than Q3's single-socket design 316 + 317 + Q3 had to invent in-band reliable command acknowledgment because it only had 318 + UDP. We already have an ordered reliable channel (WS). That lets us: 319 + 320 + - Delete the reliable-command retransmit loop entirely. 321 + - Keep `arena:cmd` / `arena:snap` purely unreliable and delete-safe. 322 + - Avoid coupling snapshot loss to chat loss. 323 + 324 + The tradeoff: TCP head-of-line blocking on WS could delay a `kill` event 325 + by a few hundred ms during packet loss. That's fine for lifecycle/chat; 326 + it would not be fine for position data, which is why position stays on UDP. 327 + 328 + ### 5.3 Wire formats 329 + 330 + ```js 331 + // Client → Server, UDP, ~60Hz 332 + { type: "arena:cmd", 333 + content: { 334 + seq, // monotonic client cmd seq 335 + ack, // last snap messageNum we saw (0 if none) 336 + handle, // identity (geckos channel is already bound but include for safety) 337 + cmds: [ // last N=3 usercmds, oldest first 338 + { ms, yaw, pitch, fwd, right, up, buttons }, ... 339 + ] 340 + } 341 + } 342 + 343 + // Server → Client, UDP, 30Hz (per-client) 344 + { type: "arena:snap", 345 + content: { 346 + messageNum, // this client's monotonic snap seq 347 + deltaNum, // messageNum - deltaNum ago is the base; 0 = full snap 348 + tick, // server sim tick 349 + serverMs, // wall-clock ms, for client->server time offset estimation 350 + ackCmdSeq, // highest client cmd seq the server has processed 351 + players: [ // delta-encoded; only fields that changed vs base 352 + { h, x, y, z, yaw, pitch, vy, ground, crouch, alive, health, ... } 353 + ], 354 + events: [ // fire-and-forget one-shots since last snap (kill, respawn, spawn) 355 + { t: "kill", by: "a", of: "b", ms }, ... 356 + ] 357 + } 358 + } 359 + 360 + // WS — reliable sideband 361 + { type: "arena:hello", content: { handle } } // client on connect 362 + { type: "arena:welcome", content: { yourId, serverConfig, initialSnap } } 363 + { type: "arena:bye", content: { handle } } 364 + { type: "arena:kill", content: { by, of, ms } } // redundant with snap events but guaranteed 365 + { type: "arena:chat", content: { handle, text } } 366 + ``` 367 + 368 + ### 5.4 `session-server/arena-manager.mjs` 369 + 370 + ``` 371 + class ArenaManager { 372 + players // Map<handle, PlayerRecord> 373 + tick, serverMs 374 + tickInterval 375 + 376 + // Per-client state needed for delta compression: 377 + // record.snapHistory : ring buffer [32] of { messageNum, state } 378 + // record.lastAckMessageNum : highest snap the client confirmed receiving 379 + // record.nextMessageNum : monotonic counter for this client's snap stream 380 + // record.posHistory : ring buffer [~30] of {ms, x, y, z} for lag comp 381 + 382 + setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) 383 + 384 + playerJoin(handle, wsId) // WS arena:hello 385 + playerLeave(handle) 386 + receiveCmd(handle, frame) // UDP arena:cmd → applyUsercmd per cmd, update lastAckMessageNum 387 + receiveShoot(handle, frame) // optional — lag-comp hit test 388 + 389 + serverTick() // 60Hz: pmove each player w/ latest cmd, advance world 390 + buildSnapshotFor(handle) // compose state, delta-encode vs snapHistory[lastAckMessageNum] 391 + broadcastSnapshots() // 30Hz: for each handle → sendUDP(chan, "arena:snap", delta) 392 + } 393 + ``` 394 + 395 + Key implementation notes from the Q3 source: 396 + 397 + 1. **Per-client snap history ring is mandatory.** The delta base must be a 398 + snap we *know* the client has. Client signals this via `ack` in every 399 + cmd packet. Ring size 32 gives ~1 s of tolerance at 30 Hz before we're 400 + forced to send a full snap. 401 + 2. **Delta encoding uses a schema.** Q3 uses a `netField_t[]` table with 402 + `name, offset, bits`. We build the equivalent once as an array of field 403 + specs over a `PlayerState` object. Encode loop: for each field, compare 404 + to base, emit 1 bit; if changed, emit the value. Early-out after the 405 + last-changed index (Q3's big win). 406 + 3. **usercmd replay uses time deltas, not absolute time.** Server applies 407 + cmd *i* by computing `dt = cmd[i].ms - cmd[i-1].ms` (clamped to keep 408 + cheaters from moving faster). First cmd after join uses wall clock. 409 + 4. **`ackCmdSeq` in snaps** lets the client drop cmds from its pending 410 + queue (same as dumduel's `lastInputSeq`, just per-client per-packet). 411 + 5. **No need for Q3's `qport`** — geckos.io gives us a stable channel id. 412 + 413 + ### 5.5 Client (disk-side) rework 414 + 415 + #### 5.5.1 Factor out pmove 416 + 417 + Today `cam-doll.mjs` owns movement. For prediction parity we need a 418 + **pure function** both sides can call: 419 + 420 + ``` 421 + shared/pmove.mjs 422 + export function pmove(state, cmd, dt) { return newState; } 423 + ``` 424 + 425 + `state` = `{ x, y, z, vx, vy, vz, yaw, pitch, onGround, crouchT }`. 426 + `cmd` = the usercmd fields. Same code runs in browser (arena.mjs) and 427 + Node (arena-manager.mjs). Keep it dependency-free. 428 + 429 + `cam-doll` then becomes a thin wrapper: "read input → produce usercmd → 430 + feed pmove → write back to cam". This is the Carmack pattern: one 431 + function is the source of truth, everything else is input/output plumbing. 432 + 433 + #### 5.5.2 Client-side prediction & reconciliation 434 + 435 + ```js 436 + // On every sim tick locally: 437 + const cmd = makeUsercmd(input); 438 + pendingCmds.push({ seq: ++cmdSeq, cmd }); 439 + playerState = pmove(playerState, cmd, 1/SIM_HZ); 440 + // render playerState 441 + 442 + // On snap arrival: 443 + function onSnap(snap) { 444 + // Advance our "authoritative" state to what the server says 445 + authoritativeState = applySnapDelta(authoritativeState, snap); 446 + // Drop acked cmds 447 + pendingCmds = pendingCmds.filter(c => c.seq > snap.ackCmdSeq); 448 + // Re-run the unacked ones on top of the server's state 449 + let replayed = authoritativeState; 450 + for (const c of pendingCmds) replayed = pmove(replayed, c.cmd, 1/SIM_HZ); 451 + // If replayed is far from our displayed state, smooth over ~100ms instead of snapping 452 + playerState = smoothCorrect(playerState, replayed); 453 + } 454 + ``` 455 + 456 + #### 5.5.3 Interpolation for remote players 457 + 458 + Maintain a per-remote snapshot buffer `[{ ms, state }, ...]`. Render at 459 + `now - INTERP_DELAY_MS` (100 ms). Find the two buffered states bracketing 460 + that time, lerp between them. When the buffer empties (lost packets, 461 + server starved), **freeze** the remote — do not extrapolate. Carmack's 462 + rule: "you only ever show positions the entity actually had." 463 + 464 + ```js 465 + function renderRemote(handle, now) { 466 + const renderTime = now - INTERP_DELAY_MS; 467 + const buf = remotes[handle].buffer; 468 + // Find i such that buf[i].ms <= renderTime <= buf[i+1].ms 469 + // If not found, clamp to oldest/newest (freeze) 470 + // Else lerp between buf[i] and buf[i+1] 471 + } 472 + ``` 473 + 474 + #### 5.5.4 Clock sync 475 + 476 + For interp to work, client and server need a shared time. Each snap 477 + carries `serverMs`. Client estimates offset = `serverMs - receivedMs` with 478 + a rolling min filter (ping variance). All interp math uses 479 + `clientMs + offset` as "server time". 480 + 481 + ### 5.6 Lag compensation (for future combat) 482 + 483 + Only needed when we add hitscan shooting. On a shoot usercmd: 484 + 485 + 1. Server reads `snapMs = snapMsForMessageNum[cmd.ack]` — the moment the 486 + attacker *thought* they were shooting at. 487 + 2. For each other player, find the two entries in `posHistory` bracketing 488 + `snapMs - INTERP_DELAY_MS` and lerp to rewind their position. 489 + 3. Raycast against the rewound positions. Register hit on the player the 490 + attacker saw on their screen. 491 + 4. Restore positions and continue sim. 492 + 493 + Budget: `posHistory` = 30 entries × 8 players × ~48 bytes = ~12 KB. Free. 494 + 495 + ### 5.7 Perf & tuning targets 496 + 497 + - Snap size (full): ~120 bytes/player. 8 players × 120 = ~1 KB. Under MTU. 498 + - Snap size (delta, steady state): expect 10–30 bytes/player. 499 + - Uplink per client: 60 Hz × 3 cmds × ~16 bytes = ~3 KB/s. 500 + - Downlink per client: 30 Hz × ~200 bytes = ~6 KB/s. 501 + - 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. 502 + 503 + Tunables to expose on `serverConfig`: 504 + `tickRate`, `snapRate`, `cmdRate`, `cmdBackup`, `interpDelayMs`, 505 + `posHistoryMs`, `smoothCorrectMs`. 506 + 507 + --- 508 + 509 + ## 6. Suggested milestones 510 + 511 + Presence tier (relay-only, builds on squash/1v1 pattern): 512 + 513 + - **M1 — Presence skeleton.** Wire WS + UDP in arena.mjs, log incoming 514 + frames to console, no rendering. Confirm two tabs exchange `arena:move`. 515 + - **M2 — Remote body render.** Build per-remote feet/arms forms, position 516 + each paint. Ignore interpolation (snap to server pos). 517 + - **M3 — Death/respawn lifecycle + handle labels** (world→screen project). 518 + - **M4 — Naive lerp + polish.** Fade-in on join, alpha for guests, perf 519 + test with ≥4 players. 520 + 521 + Q3-caliber tier (server-authoritative): 522 + 523 + - **M5 — Extract `shared/pmove.mjs`** from cam-doll. Unit tests that 524 + fixed-seed command streams produce identical state in Node and browser. 525 + - **M6 — `ArenaManager` skeleton.** 60 Hz tick, full snapshots (no delta 526 + yet), 30 Hz broadcast, client drops M1-M4 relay code and uses manager 527 + snaps as truth. No client prediction yet — just visual lag. 528 + - **M7 — Client prediction + reconciliation.** Pending cmd queue, replay 529 + on ack, smooth-correct on mispredict. 530 + - **M8 — Interpolation buffer for remotes.** 100 ms delay, freeze on 531 + starvation, clock sync via snap `serverMs`. 532 + - **M9 — Delta compression.** Per-client snap ring, field schema, bit-per- 533 + field changed marker, last-changed early-out. Measure bandwidth drop. 534 + - **M10 — Command backup + ack-in-cmd.** Send last 3 cmds per packet, 535 + include `ack` of last snap seen. Server dedupes by seq. 536 + - **M11 — (optional) Lag-compensated hitscan.** Only if combat lands. 537 + 538 + Each is independently shippable. M5–M8 together reach "playable Q3-lite"; 539 + M9–M11 are the polish that makes it feel pro. 540 + 541 + --- 542 + 543 + ## 7. References 544 + 545 + - [Quake 3 Source Code Review: Network Model — Fabien Sanglard](https://fabiensanglard.net/quake3/network.php) 546 + - [Quake 3 Network Protocol (wire format) — jfedor](https://www.jfedor.org/quake3/) 547 + - [`sv_snapshot.c` in id's Quake 3 source](https://github.com/id-Software/Quake-III-Arena/blob/master/code/server/sv_snapshot.c) 548 + - [`sv_client.c` — client/cmd handling](https://github.com/id-Software/Quake-III-Arena/blob/master/code/server/sv_client.c) 549 + - [Netcode Architectures Part 3: Snapshot Interpolation — SnapNet](https://snapnet.dev/blog/netcode-architectures-part-3-snapshot-interpolation/) 550 + - 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) 551 + 552 + Each milestone is independently shippable.
+354
session-server/arena-manager.mjs
··· 1 + // Arena Manager, 2026.04.20 2 + // Server-authoritative state for arena.mjs. Quake 3-inspired: 3 + // - fixed 60 Hz sim tick 4 + // - clients send usercmd packets over UDP (geckos.io) 5 + // - server broadcasts per-client snapshots at 30 Hz 6 + // - per-client snapshot ring enables future delta compression (M9) 7 + // - reliable lifecycle events (join/leave/kill/chat/probe) ride WS 8 + // 9 + // Starts simple: full snapshots (no delta yet), no lag compensation, no 10 + // command backup decode. Those are later milestones — the wire formats 11 + // already carry the fields (messageNum, deltaNum, ackCmdSeq) so turning 12 + // them on is additive. 13 + 14 + import { newState, pmove, unpackCmd, DEFAULT_CFG, BTN } from "../system/public/aesthetic.computer/lib/pmove.mjs"; 15 + 16 + const PLAYER_FIELDS = ["h","x","y","z","vx","vy","vz","yaw","pitch","c","g","a"]; 17 + 18 + const TICK_RATE = 60; // sim ticks/sec 19 + const SNAP_RATE = 30; // snapshots/sec (per client) 20 + const SNAP_EVERY = TICK_RATE / SNAP_RATE; 21 + const SNAP_RING = 32; // per-client snapshot history depth 22 + const POS_HISTORY_MS = 500; // rolling pos history for lag comp 23 + 24 + // Default arena world config — must match disks/arena.mjs. 25 + export const ARENA_CFG = Object.freeze({ 26 + ...DEFAULT_CFG, 27 + runSpeed: 10, 28 + walkSpeed: 5, 29 + jumpVelocity: 8, 30 + gravity: 50, 31 + groundY: -1.5, 32 + eyeHeight: 2.0, 33 + crouchEyeHeight: 1.2, 34 + groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, 35 + deathFloorY: -30, 36 + simHz: TICK_RATE, 37 + }); 38 + 39 + // Spawn ring — spread players around the arena. 40 + const SPAWNS = [ 41 + { x: 6, z: 0 }, { x: -6, z: 0 }, { x: 0, z: 6 }, { x: 0, z: -6 }, 42 + { x: 5, z: 5 }, { x: -5, z: -5 }, { x: 5, z: -5 }, { x: -5, z: 5 }, 43 + ]; 44 + 45 + export class ArenaManager { 46 + constructor() { 47 + this.players = new Map(); // handle -> PlayerRecord 48 + this.probes = new Map(); // handle -> { wsId } — text-only spectators 49 + this.tick = 0; 50 + this.startMs = Date.now(); 51 + this.tickInterval = null; 52 + 53 + // Transport callbacks (set by session.mjs) 54 + this.sendUDP = null; // (channelId, event, data) -> bool 55 + this.sendWS = null; // (wsId, type, content) 56 + this.broadcastWS = null; // (type, content) 57 + this.resolveUdpForHandle = null; // (handle) -> channelId|null 58 + } 59 + 60 + setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) { 61 + this.sendUDP = sendUDP; 62 + this.sendWS = sendWS; 63 + this.broadcastWS = broadcastWS; 64 + this.resolveUdpForHandle = resolveUdpForHandle; 65 + } 66 + 67 + now() { return Date.now() - this.startMs; } 68 + 69 + // -- Lifecycle (WS-reliable) -- 70 + 71 + playerJoin(handle, wsId, opts = {}) { 72 + if (!handle) return; 73 + 74 + // Text-only spectator / probe: no player body, just receive snaps. 75 + if (opts.probe) { 76 + this.probes.set(handle, { wsId }); 77 + this.sendWS?.(wsId, "arena:welcome", { 78 + you: handle, 79 + probe: true, 80 + cfg: ARENA_CFG, 81 + serverMs: this.now(), 82 + tick: this.tick, 83 + roster: [...this.players.keys()], 84 + }); 85 + console.log(`🏟️ probe joined: ${handle} (${this.probes.size} probes)`); 86 + this.ensureTick(); 87 + return; 88 + } 89 + 90 + let rec = this.players.get(handle); 91 + if (rec) { 92 + // Re-join (page reload / reconnect): reuse the body but refresh wsId. 93 + rec.wsId = wsId; 94 + rec.udpChannelId = this.resolveUdpForHandle?.(handle) ?? null; 95 + } else { 96 + const spawn = SPAWNS[this.players.size % SPAWNS.length]; 97 + rec = { 98 + handle, 99 + wsId, 100 + udpChannelId: this.resolveUdpForHandle?.(handle) ?? null, 101 + state: newState({ x: spawn.x, z: spawn.z, cfg: ARENA_CFG }), 102 + lastCmdMs: this.now(), // for dt computation between cmds 103 + lastCmdSeq: 0, // highest client cmd seq processed 104 + snapHistory: new Array(SNAP_RING).fill(null), 105 + nextMessageNum: 1, // monotonic snap counter for this client 106 + lastAckMessageNum: 0, // highest snap the client acked 107 + posHistory: [], // [{ ms, x, y, z }] for lag comp 108 + lastSeenMs: this.now(), // used for timeout/presence 109 + }; 110 + this.players.set(handle, rec); 111 + } 112 + 113 + this.sendWS?.(wsId, "arena:welcome", { 114 + you: handle, 115 + probe: false, 116 + cfg: ARENA_CFG, 117 + serverMs: this.now(), 118 + tick: this.tick, 119 + initialState: rec.state, 120 + roster: [...this.players.keys()], 121 + }); 122 + 123 + this.broadcastWS?.("arena:join", { handle }); 124 + console.log(`🏟️ joined: ${handle} (${this.players.size} players)`); 125 + this.ensureTick(); 126 + } 127 + 128 + playerLeave(handle) { 129 + if (!handle) return; 130 + if (this.probes.delete(handle)) { 131 + console.log(`🏟️ probe left: ${handle} (${this.probes.size} probes)`); 132 + this.maybeStopTick(); 133 + return; 134 + } 135 + if (this.players.delete(handle)) { 136 + this.broadcastWS?.("arena:leave", { handle }); 137 + console.log(`🏟️ left: ${handle} (${this.players.size} players)`); 138 + } 139 + this.maybeStopTick(); 140 + } 141 + 142 + resolveUdpChannel(handle, channelId) { 143 + const rec = this.players.get(handle); 144 + if (rec) rec.udpChannelId = channelId; 145 + } 146 + 147 + // -- Input (UDP, high-frequency) -- 148 + 149 + receiveCmd(handle, frame) { 150 + const rec = this.players.get(handle); 151 + if (!rec) return; 152 + rec.lastSeenMs = this.now(); 153 + 154 + // Snap-ack: client tells us which snap they last saw. 155 + if (typeof frame.ack === "number" && frame.ack > rec.lastAckMessageNum) { 156 + rec.lastAckMessageNum = frame.ack; 157 + } 158 + 159 + const cmds = Array.isArray(frame.cmds) ? frame.cmds : []; 160 + const firstSeq = typeof frame.firstSeq === "number" ? frame.firstSeq : null; 161 + 162 + // Q3-style cmd processing: each cmd in the batch has an implicit seq 163 + // = firstSeq + index. Skip anything already applied (the cmd backup 164 + // window means most batches overlap with ones we've already seen). 165 + for (let i = 0; i < cmds.length; i++) { 166 + const c = unpackCmd(cmds[i]); 167 + const seq = firstSeq != null ? firstSeq + i : null; 168 + 169 + // De-dupe: prefer seq when present, fall back to ms monotonicity. 170 + if (seq != null) { 171 + if (seq <= rec.lastCmdSeq) continue; 172 + } else { 173 + if (c.ms <= rec.lastCmdMs) continue; 174 + } 175 + 176 + // dt from the previous applied cmd's ms; first cmd gets one tick. 177 + const dt = rec.lastCmdMs > 0 178 + ? Math.min((c.ms - rec.lastCmdMs) / 1000, 0.25) 179 + : 1 / TICK_RATE; 180 + rec.state = pmove(rec.state, { ...c, dt }, ARENA_CFG); 181 + rec.lastCmdMs = c.ms; 182 + if (seq != null && seq > rec.lastCmdSeq) rec.lastCmdSeq = seq; 183 + } 184 + } 185 + 186 + // -- Tick loop -- 187 + 188 + ensureTick() { 189 + if (this.tickInterval) return; 190 + this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE); 191 + console.log(`🏟️ arena tick loop started (${TICK_RATE}Hz, snap ${SNAP_RATE}Hz)`); 192 + } 193 + 194 + maybeStopTick() { 195 + if (this.players.size === 0 && this.probes.size === 0 && this.tickInterval) { 196 + clearInterval(this.tickInterval); 197 + this.tickInterval = null; 198 + console.log(`🏟️ arena tick loop stopped (idle)`); 199 + } 200 + } 201 + 202 + serverTick() { 203 + this.tick++; 204 + const nowMs = this.now(); 205 + 206 + // For each player with no fresh input this tick, advance using their 207 + // last-seen cmd (zero input => decays naturally via pmove's damping). 208 + // This keeps positions progressing during input starvation without 209 + // teleporting when input resumes. 210 + for (const rec of this.players.values()) { 211 + // No automatic pmove here — we only step on real cmds. This matches 212 + // Q3: server integrates usercmds as they arrive, not on empty ticks. 213 + // Append current position to history for lag comp. 214 + rec.posHistory.push({ ms: nowMs, x: rec.state.x, y: rec.state.y, z: rec.state.z }); 215 + // Trim old history beyond POS_HISTORY_MS. 216 + const cutoff = nowMs - POS_HISTORY_MS; 217 + while (rec.posHistory.length && rec.posHistory[0].ms < cutoff) { 218 + rec.posHistory.shift(); 219 + } 220 + } 221 + 222 + if (this.tick % SNAP_EVERY === 0) this.broadcastSnapshots(); 223 + } 224 + 225 + // -- Snapshots -- 226 + 227 + composePlayersBlob() { 228 + const blob = []; 229 + for (const rec of this.players.values()) { 230 + const s = rec.state; 231 + blob.push({ 232 + h: rec.handle, 233 + x: round3(s.x), y: round3(s.y), z: round3(s.z), 234 + vx: round3(s.vx), vy: round3(s.vy), vz: round3(s.vz), 235 + yaw: round2(s.yaw), pitch: round2(s.pitch), 236 + c: round3(s.crouchT), 237 + g: s.onGround ? 1 : 0, 238 + a: s.alive ? 1 : 0, 239 + }); 240 + } 241 + return blob; 242 + } 243 + 244 + /** 245 + * Q3-style delta of `current` players vs `base` players (keyed by h). 246 + * Returns { delta, removed, changedCount } — one "delta entry" per handle: 247 + * - first time seen: __new: {full blob} 248 + * - steady state: only changed fields (h is always present) 249 + * - unchanged: { h } only (bare handle marker) 250 + * Plus `removed: [h, ...]` for handles that existed in base but are gone. 251 + * The JSON representation is compact at small player counts; we 252 + * intentionally skip bit-packing (see plan §5.7 — premature at 8 peers). 253 + */ 254 + deltaPlayers(current, base) { 255 + const byHandleBase = new Map(); 256 + for (const p of base) byHandleBase.set(p.h, p); 257 + const delta = []; 258 + const seen = new Set(); 259 + let changedCount = 0; 260 + for (const p of current) { 261 + seen.add(p.h); 262 + const bp = byHandleBase.get(p.h); 263 + if (!bp) { delta.push({ h: p.h, __new: p }); changedCount++; continue; } 264 + // Compare each field; emit only changed values. 265 + const d = { h: p.h }; 266 + let any = false; 267 + for (const k of PLAYER_FIELDS) { 268 + if (k === "h") continue; 269 + if (p[k] !== bp[k]) { d[k] = p[k]; any = true; } 270 + } 271 + if (any) { delta.push(d); changedCount++; } 272 + else delta.push({ h: p.h }); 273 + } 274 + const removed = []; 275 + for (const [h] of byHandleBase) if (!seen.has(h)) removed.push(h); 276 + return { delta, removed, changedCount }; 277 + } 278 + 279 + broadcastSnapshots() { 280 + const serverMs = this.now(); 281 + const players = this.composePlayersBlob(); 282 + 283 + // Build one snapshot body per *player* because messageNum is per-client. 284 + for (const rec of this.players.values()) { 285 + const messageNum = rec.nextMessageNum++; 286 + 287 + // M9: delta-compress against the last snap the client confirmed 288 + // receiving (if it's still in our ring — falls off after SNAP_RING). 289 + let snap; 290 + const ack = rec.lastAckMessageNum; 291 + const base = ack > 0 ? rec.snapHistory[ack % SNAP_RING] : null; 292 + if (base && base.messageNum === ack) { 293 + const { delta, removed } = this.deltaPlayers(players, base.players); 294 + snap = { 295 + messageNum, 296 + deltaNum: ack, 297 + tick: this.tick, 298 + serverMs, 299 + ackCmdSeq: rec.lastCmdSeq, 300 + ackCmdMs: rec.lastCmdMs, 301 + you: rec.handle, 302 + delta, 303 + ...(removed.length ? { removed } : {}), 304 + }; 305 + } else { 306 + snap = { 307 + messageNum, 308 + deltaNum: 0, // full snap 309 + tick: this.tick, 310 + serverMs, 311 + ackCmdSeq: rec.lastCmdSeq, 312 + ackCmdMs: rec.lastCmdMs, 313 + you: rec.handle, 314 + players, 315 + }; 316 + } 317 + // Write to ring for future delta base lookup. 318 + rec.snapHistory[messageNum % SNAP_RING] = { messageNum, serverMs, players }; 319 + 320 + // Prefer UDP, fall back to WS. 321 + let ok = false; 322 + if (rec.udpChannelId != null && this.sendUDP) { 323 + ok = this.sendUDP(rec.udpChannelId, "arena:snap", snap); 324 + } 325 + if (!ok && rec.wsId != null && this.sendWS) { 326 + this.sendWS(rec.wsId, "arena:snap", snap); 327 + } 328 + } 329 + 330 + // Probes: always WS, full snap, messageNum=0 (they don't ack). 331 + for (const [handle, p] of this.probes) { 332 + if (p.wsId == null || !this.sendWS) continue; 333 + this.sendWS(p.wsId, "arena:snap", { 334 + messageNum: 0, 335 + deltaNum: 0, 336 + tick: this.tick, 337 + serverMs, 338 + ackCmdSeq: 0, 339 + you: handle, 340 + players, 341 + probe: true, 342 + }); 343 + } 344 + } 345 + 346 + // -- Probe-specific -- 347 + 348 + handlePing(handle, ts, wsId) { 349 + this.sendWS?.(wsId, "arena:pong", { ts, serverMs: this.now() }); 350 + } 351 + } 352 + 353 + function round2(n) { return Math.round(n * 100) / 100; } 354 + function round3(n) { return Math.round(n * 1000) / 1000; }
+195
session-server/arena-probe.mjs
··· 1 + #!/usr/bin/env node 2 + // arena-probe — a text-only spectator for the arena game. 3 + // 4 + // Connects to a session server over WebSocket, joins as a "probe", receives 5 + // snapshots, and reports latency / jitter / peer activity. Use to smoke-test 6 + // connectivity end-to-end (dev + prod), measure wire timing after a deploy, 7 + // or just watch the arena from a terminal. 8 + // 9 + // Usage: 10 + // node session-server/arena-probe.mjs # defaults to prod 11 + // node session-server/arena-probe.mjs --url wss://session.aesthetic.computer 12 + // node session-server/arena-probe.mjs --url ws://localhost:8889 --handle probe1 13 + // AC_PROBE_URL=ws://localhost:8889 node session-server/arena-probe.mjs 14 + // 15 + // Flags: 16 + // --url <ws-url> default: wss://session.aesthetic.computer 17 + // --handle <name> default: probe_<random4> 18 + // --ping <ms> default: 2000 (ping interval) 19 + // --status <ms> default: 1000 (status line interval) 20 + // --quiet suppress per-event logs; only print the status line 21 + // 22 + // Ctrl-C to exit. 23 + 24 + import { WebSocket } from "ws"; 25 + 26 + // --- Args --- 27 + 28 + const argv = process.argv.slice(2); 29 + const flag = (name, fallback) => { 30 + const i = argv.indexOf("--" + name); 31 + if (i === -1) return fallback; 32 + return argv[i + 1]; 33 + }; 34 + const has = (name) => argv.indexOf("--" + name) !== -1; 35 + 36 + const URL = flag("url", process.env.AC_PROBE_URL || "wss://session.aesthetic.computer"); 37 + const HANDLE = flag("handle", "probe_" + Math.random().toString(36).slice(2, 6)); 38 + const PING_MS = +flag("ping", 2000); 39 + const STATUS_MS = +flag("status", 1000); 40 + const QUIET = has("quiet"); 41 + 42 + // --- State --- 43 + 44 + let ws = null; 45 + let connectedAt = 0; 46 + let openedMs = 0; 47 + 48 + const stats = { 49 + snapsRx: 0, 50 + lastSnapAt: 0, 51 + snapIntervals: [], // recent gaps between snap arrivals (ms) 52 + pingRttSamples: [], // last N RTTs (ms) 53 + lastPingSentAt: 0, 54 + welcomed: false, 55 + yourTick: 0, 56 + serverCfg: null, 57 + players: [], // latest player blob from server 58 + events: [], // recent join/leave messages (scrolls) 59 + }; 60 + 61 + function ring(buf, v, cap = 30) { buf.push(v); while (buf.length > cap) buf.shift(); } 62 + function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; } 63 + function stddev(a) { 64 + if (a.length < 2) return 0; 65 + const m = mean(a); 66 + return Math.sqrt(mean(a.map((x) => (x - m) * (x - m)))); 67 + } 68 + 69 + // --- Wire helpers (match session.mjs's pack() format) --- 70 + 71 + function send(type, content) { 72 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 73 + ws.send(JSON.stringify({ type, content: JSON.stringify(content) })); 74 + } 75 + 76 + function log(...args) { if (!QUIET) console.log(...args); } 77 + 78 + // --- Connect + wire up --- 79 + 80 + function connect() { 81 + ws = new WebSocket(URL); 82 + 83 + ws.on("open", () => { 84 + connectedAt = Date.now(); 85 + openedMs = connectedAt; 86 + log(`\n🛰️ connected to ${URL} as "${HANDLE}"`); 87 + send("arena:hello", { handle: HANDLE, probe: true }); 88 + // Start ping loop 89 + tickPing(); 90 + }); 91 + 92 + ws.on("message", (data) => { 93 + let msg; 94 + try { msg = JSON.parse(data.toString()); } catch { return; } 95 + const t = msg.type; 96 + const body = typeof msg.content === "string" ? safeParse(msg.content) : msg.content; 97 + if (!t || !body) return; 98 + 99 + if (t === "arena:welcome") { 100 + stats.welcomed = true; 101 + stats.serverCfg = body.cfg; 102 + log(`🏟️ welcome: you=${body.you} probe=${!!body.probe} roster=[${(body.roster||[]).join(", ")}] tick=${body.tick} serverMs=${body.serverMs}`); 103 + return; 104 + } 105 + if (t === "arena:snap") { 106 + const now = Date.now(); 107 + stats.snapsRx++; 108 + if (stats.lastSnapAt) ring(stats.snapIntervals, now - stats.lastSnapAt); 109 + stats.lastSnapAt = now; 110 + stats.yourTick = body.tick; 111 + stats.players = body.players || []; 112 + return; 113 + } 114 + if (t === "arena:join") { 115 + const ev = `join ${body.handle}`; 116 + ring(stats.events, ev, 8); 117 + log(`🟢 ${ev}`); 118 + return; 119 + } 120 + if (t === "arena:leave") { 121 + const ev = `leave ${body.handle}`; 122 + ring(stats.events, ev, 8); 123 + log(`🔴 ${ev}`); 124 + return; 125 + } 126 + if (t === "arena:pong") { 127 + const rtt = Date.now() - body.ts; 128 + ring(stats.pingRttSamples, rtt, 20); 129 + return; 130 + } 131 + }); 132 + 133 + ws.on("close", () => { 134 + log("🚪 disconnected — retrying in 2s…"); 135 + stats.welcomed = false; 136 + setTimeout(connect, 2000); 137 + }); 138 + 139 + ws.on("error", (err) => { 140 + log("❌ ws error:", err.message); 141 + // `close` will follow; don't reconnect here. 142 + }); 143 + } 144 + 145 + function safeParse(s) { try { return JSON.parse(s); } catch { return s; } } 146 + 147 + // --- Timers --- 148 + 149 + function tickPing() { 150 + setInterval(() => { 151 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 152 + const ts = Date.now(); 153 + stats.lastPingSentAt = ts; 154 + send("arena:ping", { handle: HANDLE, ts }); 155 + }, PING_MS); 156 + } 157 + 158 + function formatStatusLine() { 159 + const upMs = openedMs ? Date.now() - openedMs : 0; 160 + const up = (upMs / 1000).toFixed(1) + "s"; 161 + const rtt = stats.pingRttSamples.length 162 + ? `${Math.round(mean(stats.pingRttSamples))}ms±${Math.round(stddev(stats.pingRttSamples))}` 163 + : "--"; 164 + const snapRate = stats.snapIntervals.length 165 + ? (1000 / mean(stats.snapIntervals)).toFixed(1) + "Hz" 166 + : "--"; 167 + const jitter = stats.snapIntervals.length >= 2 168 + ? Math.round(stddev(stats.snapIntervals)) + "ms" 169 + : "--"; 170 + const peers = stats.players.map((p) => p.h).join(",") || "none"; 171 + const youTick = stats.yourTick; 172 + return `[${up}] rtt=${rtt} snaps=${stats.snapsRx} @ ${snapRate} (±${jitter}) tick=${youTick} peers=[${peers}]`; 173 + } 174 + 175 + setInterval(() => { 176 + if (!ws || ws.readyState !== WebSocket.OPEN) { 177 + process.stdout.write(`\r[…] connecting to ${URL} `); 178 + return; 179 + } 180 + process.stdout.write(`\r${formatStatusLine()} `); 181 + }, STATUS_MS); 182 + 183 + // --- Graceful exit --- 184 + 185 + process.on("SIGINT", () => { 186 + console.log("\n👋 bye"); 187 + try { send("arena:bye", { handle: HANDLE }); } catch {} 188 + try { ws?.close(); } catch {} 189 + process.exit(0); 190 + }); 191 + 192 + // --- Go --- 193 + 194 + log(`🏟️ arena-probe → ${URL}`); 195 + connect();
+70
session-server/session.mjs
··· 167 167 168 168 // 🎯 Duel Manager — server-authoritative game for dumduel piece 169 169 const duelManager = new DuelManager(); 170 + // 🏟️ Arena Manager — Q3-style server-authoritative multiplayer for arena piece 171 + const arenaManager = new ArenaManager(); 170 172 171 173 import { filter } from "./filter.mjs"; // Profanity filtering. 172 174 import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. 173 175 import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. 176 + import { ArenaManager } from "./arena-manager.mjs"; // Server-authoritative arena game. 174 177 175 178 // *** AC Machines — remote device monitoring *** 176 179 // Devices connect via /machines?role=device&machineId=X&token=Y ··· 2869 2872 return; 2870 2873 } 2871 2874 2875 + // 🏟️ Arena messages — routed to ArenaManager (server-authoritative) 2876 + if (msg.type === "arena:hello") { 2877 + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2878 + if (parsed?.handle) arenaManager.playerJoin(parsed.handle, id, { probe: !!parsed.probe }); 2879 + return; 2880 + } 2881 + if (msg.type === "arena:bye") { 2882 + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2883 + if (parsed?.handle) arenaManager.playerLeave(parsed.handle); 2884 + return; 2885 + } 2886 + if (msg.type === "arena:cmd") { 2887 + // WS fallback path; the fast path is the UDP channel.on("arena:cmd", ...) handler. 2888 + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2889 + if (parsed?.handle) arenaManager.receiveCmd(parsed.handle, parsed); 2890 + return; 2891 + } 2892 + if (msg.type === "arena:ping") { 2893 + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2894 + if (parsed?.handle) arenaManager.handlePing(parsed.handle, parsed.ts, id); 2895 + return; 2896 + } 2897 + 2872 2898 everyone(JSON.stringify(msg)); // Relay any other message to every user. 2873 2899 } 2874 2900 }); ··· 2878 2904 log("🚪 Someone left:", id, "Online:", wss.clients.size, "🫂"); 2879 2905 const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); 2880 2906 if (departingHandle) duelManager.playerLeave(departingHandle); 2907 + // Arena uses the raw handle (matches arena:hello), not the @-normalized form. 2908 + const rawDepartingHandle = clients?.[id]?.handle; 2909 + if (rawDepartingHandle) arenaManager.playerLeave(rawDepartingHandle); 2881 2910 removeNotepatMidiSubscriber(id); 2882 2911 2883 2912 // Remove from VSCode clients if present ··· 3058 3087 }, 3059 3088 broadcastWS: (type, content) => { 3060 3089 everyone(pack(type, JSON.stringify(content), "duel")); 3090 + }, 3091 + resolveUdpForHandle: (handle) => { 3092 + for (const [id, client] of Object.entries(clients)) { 3093 + if (client.handle === handle && udpChannels[id]) return id; 3094 + } 3095 + return null; 3096 + }, 3097 + }); 3098 + 3099 + // 🏟️ Wire ArenaManager send functions (same shape as DuelManager; separate source tag). 3100 + arenaManager.setSendFunctions({ 3101 + sendUDP: (channelId, event, data) => { 3102 + const entry = udpChannels[channelId]; 3103 + if (entry?.channel?.webrtcConnection?.state === "open") { 3104 + try { entry.channel.emit(event, data); return true; } catch {} 3105 + } 3106 + return false; 3107 + }, 3108 + sendWS: (wsId, type, content) => { 3109 + connections[wsId]?.send(pack(type, JSON.stringify(content), "arena")); 3110 + }, 3111 + broadcastWS: (type, content) => { 3112 + everyone(pack(type, JSON.stringify(content), "arena")); 3061 3113 }, 3062 3114 resolveUdpForHandle: (handle) => { 3063 3115 for (const [id, client] of Object.entries(clients)) { ··· 3505 3557 log(`✅ UDP ${channel.id} handle: "${identity.handle}"`); 3506 3558 // Resolve UDP channel for duel if this handle is in a duel 3507 3559 duelManager.resolveUdpChannel(identity.handle, channel.id); 3560 + // Resolve UDP channel for arena if this handle is in the arena 3561 + arenaManager.resolveUdpChannel(identity.handle, channel.id); 3508 3562 } 3509 3563 } catch (e) { 3510 3564 error(`🩰 Failed to parse identity for ${channel.id}:`, e); ··· 3631 3685 } catch (err) { 3632 3686 console.warn("duel:input error:", err); 3633 3687 } 3688 + } 3689 + }); 3690 + 3691 + // 🏟️ Arena usercmd over UDP (fast path; WS is the fallback) 3692 + channel.on("arena:cmd", (data) => { 3693 + if (channel.webrtcConnection.state !== "open") return; 3694 + try { 3695 + const parsed = typeof data === "string" ? JSON.parse(data) : data; 3696 + const handle = clients[channel.id]?.handle || parsed.handle; 3697 + if (!handle) return; 3698 + arenaManager.receiveCmd(handle, parsed); 3699 + if (!clients[channel.id]?.handle && parsed.handle) { 3700 + arenaManager.resolveUdpChannel(parsed.handle, channel.id); 3701 + } 3702 + } catch (err) { 3703 + console.warn("arena:cmd error:", err); 3634 3704 } 3635 3705 }); 3636 3706
+448 -1
system/public/aesthetic.computer/disks/arena.mjs
··· 9 9 - [x] Large pre-tessellated ground plane 10 10 - [x] Speed meter HUD + FPS counter 11 11 - [x] Gravity + space-to-jump + shift-to-crouch (Quake-style) 12 + - [x] Multiuser: WS+UDP wiring to session-server/arena-manager.mjs 12 13 #endregion */ 13 14 15 + // --------------------------------------------------------------------------- 16 + // 🏟️ Multiuser networking (Q3-style: server-authoritative, UDP cmds + snaps). 17 + // See plans/arena-multiplayer.md §5 for the full design. 18 + // --------------------------------------------------------------------------- 19 + 20 + import { BTN, packCmd, pmove } from "../lib/pmove.mjs"; 21 + 22 + let myHandle = "guest"; 23 + let netServer = null; // WebSocket (reliable) 24 + let netUdp = null; // geckos.io channel (unreliable, low-latency) 25 + let netSendFn = null; 26 + let netConnectedAt = 0; 27 + 28 + let nextCmdSeq = 0; // monotonic per-cmd seq (implicit on wire via firstSeq) 29 + let lastSnapAck = 0; // highest server messageNum we've seen 30 + let serverClockOffset = 0; // add to Date.now() → server time estimate 31 + let lastPingSent = 0; 32 + let ping = 0; 33 + 34 + const CMD_RATE = 60; // cmd sends per sec 35 + const CMD_BACKUP = 3; // how many past cmds to include in each packet 36 + const SNAP_INTERP_MS = 100; // render remotes this far in the past 37 + const pendingCmds = []; // unacked cmds [{ seq, cmd }], oldest first 38 + const cmdOutbox = []; // last CMD_BACKUP cmds only (wire-level backup window) 39 + 40 + // Soft reconciliation tuning. 41 + const RECONCILE_SNAP_THRESHOLD = 0.75; // > this = hard snap 42 + const RECONCILE_SOFT_K = 0.2; // lerp factor toward predicted/frame 43 + const RECONCILE_DEAD_ZONE = 0.05; // < this = ignore (no correction noise) 44 + 45 + // Arena world cfg — MUST match ARENA_CFG in session-server/arena-manager.mjs. 46 + // Duplicated (not imported) because lib code shouldn't depend on server code. 47 + const ARENA_CFG = Object.freeze({ 48 + runSpeed: 10, walkSpeed: 5, jumpVelocity: 8, gravity: 50, 49 + groundY: -1.5, eyeHeight: 2.0, crouchEyeHeight: 1.2, crouchLerp: 0.25, 50 + groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, 51 + deathFloorY: -30, deathFloorClearance: 0.3, 52 + simHz: 60, hVelDecay: 0.9, 53 + }); 54 + 55 + // Remote players (everyone except me) 56 + const others = {}; // handle -> { buffer: [{serverMs,x,y,z,yaw,...}], bodyFeet, bodyArms } 57 + 58 + // M9: delta-snapshot bases. messageNum -> players[] (as reconstructed). 59 + const snapBases = new Map(); 60 + const SNAP_BASE_RING = 32; 61 + function rememberBase(messageNum, playersArr) { 62 + snapBases.set(messageNum, playersArr); 63 + // Prune oldest when over ring budget. 64 + if (snapBases.size > SNAP_BASE_RING) { 65 + const oldest = Math.min(...snapBases.keys()); 66 + snapBases.delete(oldest); 67 + } 68 + } 69 + function applyDelta(base, delta, removed) { 70 + // Start from a copy of base by handle, then overlay delta entries. 71 + const byH = new Map(); 72 + for (const p of base) byH.set(p.h, { ...p }); 73 + for (const d of delta) { 74 + if (d.__new) { byH.set(d.h, { ...d.__new }); continue; } 75 + const keys = Object.keys(d); 76 + if (keys.length === 1) continue; // { h } only → unchanged 77 + const cur = byH.get(d.h); 78 + if (!cur) { byH.set(d.h, { ...d }); continue; } 79 + for (const k of keys) if (k !== "h") cur[k] = d[k]; 80 + } 81 + if (removed?.length) for (const h of removed) byH.delete(h); 82 + return [...byH.values()]; 83 + } 84 + 85 + // My own server-authoritative state (from snaps) — used for soft correction. 86 + let myServerState = null; 87 + let myServerStateMs = 0; 88 + let myServerAckCmdMs = 0; 89 + // Cam reference captured in netSim; used by reconciler on snap arrival. 90 + let reconCamRef = null; 91 + let reconCorrectionMs = 0; // monotonic debug counter for HUD 92 + 93 + // Tunables exposed on screen. 94 + let netStats = { 95 + snapsRx: 0, 96 + cmdsTx: 0, 97 + lastSnapMs: 0, 98 + lastCmdMs: 0, 99 + }; 100 + 101 + // Key input state captured for usercmd composition. Hooked into the 102 + // existing keyboardState that arena already tracks for button highlights. 103 + const netInput = { 104 + fwd: 0, right: 0, 105 + jumping: false, crouching: false, 106 + }; 107 + 108 + function currentButtons() { 109 + let b = 0; 110 + if (netInput.jumping) b |= BTN.JUMP; 111 + if (netInput.crouching) b |= BTN.CROUCH; 112 + return b; 113 + } 114 + 115 + function enqueueCmd(cam) { 116 + if (!cam) return; 117 + const ms = Date.now() - netConnectedAt; 118 + const cmd = packCmd({ 119 + ms, 120 + fwd: netInput.fwd, 121 + right: netInput.right, 122 + yaw: cam.rotY, 123 + pitch: cam.rotX, 124 + buttons: currentButtons(), 125 + }); 126 + const seq = ++nextCmdSeq; 127 + pendingCmds.push({ seq, cmd }); 128 + cmdOutbox.push({ seq, cmd }); 129 + while (cmdOutbox.length > CMD_BACKUP) cmdOutbox.shift(); 130 + // Cap pending queue defensively (at 60Hz cmd rate + 1s RTT ceiling ≈ 60). 131 + while (pendingCmds.length > 120) pendingCmds.shift(); 132 + } 133 + 134 + function flushCmds() { 135 + if (cmdOutbox.length === 0) return; 136 + const frame = { 137 + handle: myHandle, 138 + firstSeq: cmdOutbox[0].seq, 139 + ack: lastSnapAck, 140 + cmds: cmdOutbox.map((e) => e.cmd), 141 + }; 142 + if (netUdp?.connected) netUdp.send("arena:cmd", frame); 143 + else netServer?.send("arena:cmd", frame); 144 + netStats.cmdsTx++; 145 + netStats.lastCmdMs = Date.now(); 146 + } 147 + 148 + function onSnap(snap) { 149 + netStats.snapsRx++; 150 + netStats.lastSnapMs = Date.now(); 151 + if (snap.messageNum > lastSnapAck) lastSnapAck = snap.messageNum; 152 + 153 + // Clock sync (simple: use the freshest server time as the offset anchor). 154 + // Real impl should min-filter + smooth; this is enough for interp. 155 + const localMs = Date.now(); 156 + serverClockOffset = snap.serverMs - (localMs - netConnectedAt); 157 + 158 + // M10: drop cmds the server has acked (seq-based; firstSeq implicit). 159 + if (typeof snap.ackCmdSeq === "number") { 160 + while (pendingCmds.length && pendingCmds[0].seq <= snap.ackCmdSeq) { 161 + pendingCmds.shift(); 162 + } 163 + } 164 + 165 + // M9: reconstruct full player list from either full or delta snap. 166 + let blobs; 167 + if (snap.deltaNum && snap.delta) { 168 + const base = snapBases.get(snap.deltaNum); 169 + if (!base) { 170 + // Base expired / never saw it. Server will send a full snap on the 171 + // next tick because our ack will re-anchor. Skip this one. 172 + return; 173 + } 174 + blobs = applyDelta(base, snap.delta, snap.removed); 175 + } else { 176 + blobs = snap.players || []; 177 + } 178 + // Commit as a base for future delta decoding. 179 + if (typeof snap.messageNum === "number" && snap.messageNum > 0) { 180 + rememberBase(snap.messageNum, blobs); 181 + } 182 + const seen = new Set(); 183 + for (const p of blobs) { 184 + if (p.h === myHandle) { 185 + myServerState = p; 186 + myServerStateMs = snap.serverMs; 187 + myServerAckCmdMs = typeof snap.ackCmdMs === "number" ? snap.ackCmdMs : myServerAckCmdMs; 188 + continue; 189 + } 190 + seen.add(p.h); 191 + let o = others[p.h]; 192 + if (!o) { 193 + o = others[p.h] = { buffer: [], bodyFeet: null, bodyArms: null, lastSeenMs: snap.serverMs }; 194 + } 195 + o.lastSeenMs = snap.serverMs; 196 + // Append to interpolation buffer (keep ~500ms of history). 197 + o.buffer.push({ 198 + ms: snap.serverMs, 199 + x: p.x, y: p.y, z: p.z, 200 + yaw: p.yaw, pitch: p.pitch, 201 + crouchT: p.c, 202 + onGround: !!p.g, 203 + alive: !!p.a, 204 + }); 205 + while (o.buffer.length > 32) o.buffer.shift(); 206 + } 207 + // Prune others not in this snap for >2s (graceful drop). 208 + for (const h of Object.keys(others)) { 209 + if (seen.has(h)) continue; 210 + if (snap.serverMs - others[h].lastSeenMs > 2000) delete others[h]; 211 + } 212 + 213 + // M7: client-side prediction reconciliation. 214 + reconcileLocal(); 215 + } 216 + 217 + // Starting from the server's authoritative state for me, replay every 218 + // still-unacked cmd → this is where the server WILL arrive once the rest 219 + // of our in-flight cmds reach it. Compare to cam-doll's current local 220 + // position; if divergent, soft-correct (small drift) or snap (big desync). 221 + function reconcileLocal() { 222 + if (!myServerState || !reconCamRef) return; 223 + const cam = reconCamRef; 224 + 225 + // Build a pmove-compatible state from the wire blob. 226 + let predicted = { 227 + x: myServerState.x, y: myServerState.y, z: myServerState.z, 228 + vx: myServerState.vx || 0, vy: myServerState.vy || 0, vz: myServerState.vz || 0, 229 + yaw: myServerState.yaw || 0, pitch: myServerState.pitch || 0, 230 + crouchT: myServerState.c || 0, 231 + onGround: !!myServerState.g, 232 + frozen: false, 233 + alive: !!myServerState.a, 234 + }; 235 + 236 + // Replay each pending cmd in order, using its ms delta for dt. The "base 237 + // ms" for the first pending cmd is the server's last-applied cmd ms. 238 + let prevMs = myServerAckCmdMs; 239 + for (const { cmd } of pendingCmds) { 240 + const dt = prevMs > 0 ? Math.min((cmd.ms - prevMs) / 1000, 0.25) : 1 / 60; 241 + predicted = pmove(predicted, { ...cmd, dt }, ARENA_CFG); 242 + prevMs = cmd.ms; 243 + } 244 + 245 + // Compare cam-doll's current world position to predicted. 246 + // cam.x/y/z store negated world coords (see cam-doll.mjs). 247 + const localX = -cam.x, localY = -cam.y, localZ = -cam.z; 248 + const dx = predicted.x - localX; 249 + const dy = predicted.y - localY; 250 + const dz = predicted.z - localZ; 251 + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); 252 + 253 + if (dist < RECONCILE_DEAD_ZONE) return; // within float-noise → ignore. 254 + 255 + if (dist > RECONCILE_SNAP_THRESHOLD) { 256 + // Large desync (teleport / forced respawn / long stall) — hard snap. 257 + cam.x = -predicted.x; 258 + cam.y = -predicted.y; 259 + cam.z = -predicted.z; 260 + reconCorrectionMs++; 261 + return; 262 + } 263 + 264 + // Small drift — blend cam toward predicted over ~5 frames. 265 + cam.x += -dx * RECONCILE_SOFT_K; 266 + cam.y += -dy * RECONCILE_SOFT_K; 267 + cam.z += -dz * RECONCILE_SOFT_K; 268 + reconCorrectionMs++; 269 + } 270 + 271 + function netBoot({ net, handle, send }) { 272 + netSendFn = send; 273 + myHandle = handle?.() || "guest_" + Math.floor(Math.random() * 9999); 274 + netConnectedAt = Date.now(); 275 + if (!net) return; 276 + 277 + const { socket, udp } = net; 278 + 279 + netUdp = udp?.((type, content) => { 280 + if (type !== "arena:snap") return; 281 + const s = typeof content === "string" ? JSON.parse(content) : content; 282 + onSnap(s); 283 + }); 284 + 285 + netServer = socket?.((id, type, content) => { 286 + if (type.startsWith("connected")) { 287 + netServer.send("arena:hello", { handle: myHandle }); 288 + return; 289 + } 290 + const msg = typeof content === "string" ? JSON.parse(content) : content; 291 + if (type === "arena:welcome") { 292 + console.log(`🏟️ welcome → ${msg.you}, ${msg.roster?.length ?? 0} already in`); 293 + return; 294 + } 295 + if (type === "arena:snap") { onSnap(msg); return; } // WS fallback 296 + if (type === "arena:join") { 297 + if (msg.handle !== myHandle && !others[msg.handle]) { 298 + others[msg.handle] = { buffer: [], bodyFeet: null, bodyArms: null, lastSeenMs: Date.now() }; 299 + } 300 + return; 301 + } 302 + if (type === "arena:leave") { delete others[msg.handle]; return; } 303 + if (type === "arena:pong") { ping = Date.now() - msg.ts; return; } 304 + }); 305 + } 306 + 307 + function netSim(cam) { 308 + reconCamRef = cam; // kept across frames so reconcileLocal can correct. 309 + // Poll input state → usercmd each sim tick (120 Hz). Send at CMD_RATE. 310 + // (arena.mjs already has `keyboardState` + `gamepadState` that we mirror.) 311 + // pmove convention: fwd=+1 moves along facing (forward), right=+1 strafes right. 312 + netInput.fwd = (keyboardState.w || keyboardState.arrowup) ? 1 313 + : (keyboardState.s || keyboardState.arrowdown) ? -1 : 0; 314 + netInput.right = (keyboardState.d || keyboardState.arrowright) ? 1 315 + : (keyboardState.a || keyboardState.arrowleft) ? -1 : 0; 316 + // Gamepad left-stick: stick-up (gy<0) is forward, stick-right (gx>0) is strafe right. 317 + if (gamepadState.connected) { 318 + const gx = gamepadState.axes[0] || 0, gy = gamepadState.axes[1] || 0; 319 + if (Math.abs(gx) > 0.3) netInput.right = gx > 0 ? 1 : -1; 320 + if (Math.abs(gy) > 0.3) netInput.fwd = gy > 0 ? -1 : 1; 321 + } 322 + netInput.jumping = !!keyboardState.space || !!gamepadState.buttons?.[0]; 323 + netInput.crouching = !!keyboardState.shift || !!gamepadState.buttons?.[1]; 324 + 325 + enqueueCmd(cam); 326 + // Throttle outbound packets to CMD_RATE (= every Nth 120Hz tick). 327 + if (!netSim._acc) netSim._acc = 0; 328 + netSim._acc++; 329 + if (netSim._acc >= 120 / CMD_RATE) { netSim._acc = 0; flushCmds(); } 330 + 331 + // Periodic ping for latency HUD. 332 + if (Date.now() - lastPingSent > 2000) { 333 + lastPingSent = Date.now(); 334 + netServer?.send("arena:ping", { handle: myHandle, ts: Date.now() }); 335 + } 336 + } 337 + 338 + function renderTimeNow() { 339 + return (Date.now() - netConnectedAt) + serverClockOffset - SNAP_INTERP_MS; 340 + } 341 + 342 + function sampleOther(o, t) { 343 + const buf = o.buffer; 344 + if (buf.length === 0) return null; 345 + if (t <= buf[0].ms) return buf[0]; 346 + if (t >= buf[buf.length - 1].ms) return buf[buf.length - 1]; // freeze on starve 347 + // Find bracketing entries. 348 + for (let i = 0; i < buf.length - 1; i++) { 349 + const a = buf[i], b = buf[i + 1]; 350 + if (t >= a.ms && t <= b.ms) { 351 + const k = (t - a.ms) / Math.max(1, b.ms - a.ms); 352 + return { 353 + x: a.x + (b.x - a.x) * k, 354 + y: a.y + (b.y - a.y) * k, 355 + z: a.z + (b.z - a.z) * k, 356 + yaw: lerpAngle(a.yaw, b.yaw, k), 357 + pitch: a.pitch + (b.pitch - a.pitch) * k, 358 + crouchT: a.crouchT + (b.crouchT - a.crouchT) * k, 359 + onGround: b.onGround, 360 + alive: b.alive, 361 + }; 362 + } 363 + } 364 + return buf[buf.length - 1]; 365 + } 366 + 367 + function lerpAngle(a, b, k) { 368 + let d = ((b - a + 540) % 360) - 180; // shortest arc 369 + return a + d * k; 370 + } 371 + 372 + // Build a small humanoid stick-figure as a single line Form. Cheap to clone 373 + // per remote (handful of verts) and matches the arena's visual language. 374 + function buildRemoteBody(Form, colorRGB) { 375 + const [r, g, b] = colorRGB; 376 + const col = [r / 255, g / 255, b / 255, 0.9]; 377 + const colDim = [r / 255, g / 255, b / 255, 0.5]; 378 + const head = 0.3, shoulder = -0.4, hip = -1.1, foot = -2.0; 379 + // coords are (x, y, z, w); y is "up" in the form's local space. 380 + const pts = [ 381 + // spine 382 + [0, head, 0, 1], [0, hip, 0, 1], 383 + // shoulders 384 + [-0.35, shoulder, 0, 1], [0.35, shoulder, 0, 1], 385 + // arms (slightly forward) 386 + [-0.35, shoulder, 0, 1], [-0.45, -0.9, 0.25, 1], 387 + [ 0.35, shoulder, 0, 1], [ 0.45, -0.9, 0.25, 1], 388 + // hips → feet 389 + [-0.18, hip, 0, 1], [-0.18, foot, 0, 1], 390 + [ 0.18, hip, 0, 1], [ 0.18, foot, 0, 1], 391 + ]; 392 + const cols = [col, col, col, col, col, colDim, col, colDim, col, col, col, col]; 393 + const f = new Form({ type: "line", positions: pts, colors: cols }, 394 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }); 395 + f.noFade = true; 396 + return f; 397 + } 398 + 399 + // Deterministic per-handle color so remotes are distinguishable at a glance. 400 + function handleColor(handle) { 401 + let h = 0; for (let i = 0; i < handle.length; i++) h = (h * 31 + handle.charCodeAt(i)) | 0; 402 + const hue = (h >>> 0) % 360; 403 + // HSL→RGB (s=0.7, l=0.6) 404 + const c = 0.7 * 0.6 * 2, x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); 405 + const m = 0.6 - c / 2; 406 + let r = 0, g = 0, b = 0; 407 + if (hue < 60) [r, g, b] = [c, x, 0]; 408 + else if (hue < 120) [r, g, b] = [x, c, 0]; 409 + else if (hue < 180) [r, g, b] = [0, c, x]; 410 + else if (hue < 240) [r, g, b] = [0, x, c]; 411 + else if (hue < 300) [r, g, b] = [x, 0, c]; 412 + else [r, g, b] = [c, 0, x]; 413 + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; 414 + } 415 + 416 + // Called from paint() once per frame. 417 + function paintRemotes(ink, form, Form) { 418 + const t = renderTimeNow(); 419 + for (const [handle, o] of Object.entries(others)) { 420 + const sample = sampleOther(o, t); 421 + if (!sample) continue; 422 + if (!o.body) o.body = buildRemoteBody(Form, handleColor(handle)); 423 + // Mirror local body positioning: Form.position uses (-x, y, -z). 424 + o.body.position[0] = -sample.x; 425 + o.body.position[1] = sample.y; 426 + o.body.position[2] = -sample.z; 427 + o.body.rotation[1] = sample.yaw; 428 + ink(255).form(o.body); 429 + } 430 + } 431 + 432 + // --------------------------------------------------------------------------- 433 + 14 434 15 435 let groundPlane; 16 436 let groundSkirt; // solid opaque plate just under the ground that blocks ··· 290 710 ]; 291 711 } 292 712 293 - function boot({ Form, penLock, system, screen, ui, api, painting }) { 713 + function boot({ Form, penLock, system, screen, ui, api, painting, net, handle, send }) { 294 714 penLock(); 295 715 FormRef = Form; 296 716 paintingRef = painting; ··· 298 718 const cam = system?.fps?.doll?.cam; 299 719 if (cam) { prevX = cam.x; prevY = cam.y; prevZ = cam.z; } 300 720 lastFrameTime = performance.now(); 721 + 722 + // 🏟️ Multiplayer: open WS + UDP, send arena:hello. 723 + netBoot({ net, handle, send }); 301 724 302 725 // 🎯 Set initial cursor style 303 726 if (api?.cursor) { ··· 939 1362 // time sees the fresh value. 940 1363 simTime += 1 / SIM_HZ; 941 1364 1365 + // 🏟️ Multiplayer: compose usercmd, batch & flush at CMD_RATE. 1366 + netSim(cam); 1367 + 942 1368 // 📱 Update mobile button states 943 1369 if (mobileButtons && doll) { 944 1370 for (const [name, btnData] of Object.entries(mobileButtons)) { ··· 1302 1728 if (bodyArms) ink(255).form(bodyArms); 1303 1729 } 1304 1730 1731 + // 🏟️ Remote players (interpolated from server snapshots, rendered ~100ms behind). 1732 + paintRemotes(ink, undefined, FormRef); 1733 + 1305 1734 // --- HUD (top-right) --- 1306 1735 const font = "MatrixChunky8"; 1307 1736 const margin = 4; ··· 1324 1753 ink(150, 200, 255); 1325 1754 rightLabel(`FOV ${FOV}`, margin + lineH * 2); 1326 1755 rightLabel(`RUN ${RUN_SPEED.toFixed(1)}u/s`, margin + lineH * 3); 1756 + 1757 + // 🏟️ Net — ping, snap rx, cmd tx, peer count. Under the "AIR/GROUND" row. 1758 + { 1759 + const wsOk = !!netServer; 1760 + const udpOk = !!netUdp?.connected; 1761 + const nowMs = Date.now(); 1762 + const snapAgeMs = netStats.lastSnapMs ? nowMs - netStats.lastSnapMs : Infinity; 1763 + const color = !wsOk ? [200, 80, 80] 1764 + : udpOk ? (snapAgeMs < 500 ? [120, 230, 120] : [230, 200, 80]) 1765 + : [200, 180, 80]; 1766 + ink(...color); 1767 + rightLabel(`${udpOk ? "UDP" : wsOk ? "WS" : "--"} ${ping}ms`, margin + lineH * 6); 1768 + ink(160, 160, 180); 1769 + rightLabel(`rx ${netStats.snapsRx} tx ${netStats.cmdsTx}`, margin + lineH * 7); 1770 + const peers = Object.keys(others).length; 1771 + ink(peers > 0 ? [180, 230, 180] : [130, 130, 130]); 1772 + rightLabel(`peers ${peers}`, margin + lineH * 8); 1773 + } 1327 1774 1328 1775 // 🏃 Current speed — colored by how close to max. 1329 1776 const upsNow = speedSmoothed * SIM_HZ;
+194
system/public/aesthetic.computer/lib/pmove.mjs
··· 1 + // pmove.mjs — pure player-movement function, shared by client prediction 2 + // and server authority. Node- and browser-compatible. No deps. 3 + // 4 + // The math mirrors lib/cam-doll.mjs's physics pass so a client running 5 + // cam-doll locally and the server running pmove on authoritative state 6 + // converge within float rounding. 7 + // 8 + // Coordinate convention: WORLD coordinates (not cam.* which is negated). 9 + // +X right, +Y up, +Z forward (player faces along yaw). 10 + // 11 + // All mutation is functional: pmove returns a *new* state object; the 12 + // caller decides when to commit. Keep this file dep-free — importable 13 + // from both @ac.mjs in the browser and from session-server in Node. 14 + 15 + export const DEFAULT_CFG = Object.freeze({ 16 + runSpeed: 10, // units/sec 17 + walkSpeed: 5, // units/sec while crouched 18 + jumpVelocity: 8, // initial upward velocity on jump (u/s) 19 + gravity: 50, // u/s² 20 + groundY: 0, // world Y of the solid ground plane 21 + eyeHeight: 2.0, // stand eye height above groundY 22 + crouchEyeHeight: 1.2, // crouched eye height 23 + crouchLerp: 0.25, // per-tick lerp toward crouch target 24 + groundBounds: null, // { xMin, xMax, zMin, zMax } or null 25 + deathFloorY: null, // world Y clamp for frozen players (lava pit) 26 + deathFloorClearance: 0.3, 27 + simHz: 120, 28 + // Dolly-style horizontal damping. cam-doll uses a 0.9 decay + push that 29 + // settles at `speed` units/sec. We fold that into a direct integration 30 + // here so the server doesn't need the Dolly object: hVelDecay per tick 31 + // and a push matching the same steady state. 32 + hVelDecay: 0.9, 33 + }); 34 + 35 + // Buttons bitmask (matches usercmd wire format). 36 + export const BTN = Object.freeze({ 37 + JUMP: 1 << 0, 38 + CROUCH: 1 << 1, 39 + SHOOT: 1 << 2, 40 + DASH: 1 << 3, 41 + }); 42 + 43 + /** 44 + * Build a fresh neutral player state at the given spawn. 45 + */ 46 + export function newState({ x = 0, z = 0, yaw = 0, pitch = 0, cfg = DEFAULT_CFG } = {}) { 47 + return { 48 + x, 49 + y: cfg.groundY + cfg.eyeHeight, // eye position in world Y 50 + z, 51 + vx: 0, 52 + vz: 0, 53 + vy: 0, // world-up velocity 54 + yaw, // degrees 55 + pitch, // degrees, clamped ±89 56 + crouchT: 0, 57 + onGround: true, 58 + frozen: false, 59 + alive: true, 60 + }; 61 + } 62 + 63 + /** 64 + * Apply one usercmd to a state. Pure: returns new state object. 65 + * 66 + * state : player state (see newState) 67 + * cmd : { 68 + * fwd: -1 | 0 | 1 // forward/back intent 69 + * right: -1 | 0 | 1 // strafe intent 70 + * yaw, pitch // camera angles (degrees) 71 + * buttons: bitmask // BTN.JUMP etc. 72 + * dt: seconds // elapsed wall time since last cmd 73 + * } 74 + * cfg : movement tuning (see DEFAULT_CFG) 75 + */ 76 + export function pmove(state, cmd, cfg = DEFAULT_CFG) { 77 + const s = { ...state }; 78 + const dt = clamp(cmd.dt ?? 1 / cfg.simHz, 0, 0.25); // cap dt so a pause doesn't teleport 79 + 80 + // --- Look: accept cmd-provided yaw/pitch verbatim, but clamp pitch. --- 81 + if (typeof cmd.yaw === "number") s.yaw = cmd.yaw; 82 + if (typeof cmd.pitch === "number") s.pitch = clamp(cmd.pitch, -89, 89); 83 + 84 + const crouching = (cmd.buttons & BTN.CROUCH) !== 0; 85 + const jumping = (cmd.buttons & BTN.JUMP) !== 0; 86 + 87 + // --- Horizontal movement: rotate (right, fwd) by yaw, integrate. --- 88 + const speed = crouching ? cfg.walkSpeed : cfg.runSpeed; 89 + 90 + // Normalise input vector so diagonals aren't faster. 91 + let ix = cmd.right || 0; 92 + let iz = cmd.fwd || 0; 93 + const ilen = Math.hypot(ix, iz); 94 + if (ilen > 1) { ix /= ilen; iz /= ilen; } 95 + 96 + // Rotate input into world space by yaw. 97 + // yaw 0 → facing +Z; +yaw rotates clockwise looking down. 98 + const yr = s.yaw * Math.PI / 180; 99 + const sy = Math.sin(yr), cy = Math.cos(yr); 100 + // Desired world velocity this frame from input: 101 + const wx = (ix * cy + iz * sy) * speed; 102 + const wz = (-ix * sy + iz * cy) * speed; 103 + 104 + // cam-doll uses Dolly's decay + push which converges to `speed` at full 105 + // stick. We approximate the same feel with a per-tick lerp toward the 106 + // input velocity. 107 + const decay = Math.pow(cfg.hVelDecay, dt * cfg.simHz); 108 + s.vx = s.vx * decay + wx * (1 - decay); 109 + s.vz = s.vz * decay + wz * (1 - decay); 110 + 111 + s.x += s.vx * dt; 112 + s.z += s.vz * dt; 113 + 114 + // --- Crouch lerp. --- 115 + const crouchTarget = (!s.frozen && crouching) ? 1 : 0; 116 + s.crouchT += (crouchTarget - s.crouchT) * cfg.crouchLerp; 117 + if (s.crouchT < 0.0005 && crouchTarget === 0) s.crouchT = 0; 118 + if (s.crouchT > 0.9995 && crouchTarget === 1) s.crouchT = 1; 119 + const effEye = cfg.eyeHeight + (cfg.crouchEyeHeight - cfg.eyeHeight) * s.crouchT; 120 + 121 + // --- Jump: initiate on edge, only when grounded and not frozen. --- 122 + if (!s.frozen && jumping && s.onGround) { 123 + s.vy = cfg.jumpVelocity; 124 + s.onGround = false; 125 + } 126 + 127 + // --- Vertical integration: always run gravity; frozen players still fall. --- 128 + if (!s.onGround || s.frozen) { 129 + s.vy -= cfg.gravity * dt; 130 + s.y += s.vy * dt; 131 + } 132 + 133 + // --- Ground clamp: only when over the solid ground rectangle. --- 134 + let onSolid = true; 135 + if (cfg.groundBounds) { 136 + const b = cfg.groundBounds; 137 + onSolid = s.x >= b.xMin && s.x <= b.xMax && s.z >= b.zMin && s.z <= b.zMax; 138 + } 139 + 140 + const floorY = cfg.groundY + effEye; 141 + if (onSolid && !s.frozen) { 142 + if (s.y <= floorY) { 143 + s.y = floorY; 144 + if (s.vy < 0) s.vy = 0; 145 + s.onGround = true; 146 + } else if (s.onGround) { 147 + // Crouch release bumped eye above floor — stick to it; no fall. 148 + s.y = floorY; 149 + } 150 + } else { 151 + s.onGround = false; 152 + } 153 + 154 + // --- Death floor clamp (lava pit). --- 155 + if (s.frozen && cfg.deathFloorY !== null && cfg.deathFloorY !== undefined) { 156 + const lavaY = cfg.deathFloorY + cfg.deathFloorClearance; 157 + if (s.y <= lavaY) { 158 + s.y = lavaY; 159 + s.vy = 0; 160 + } 161 + } 162 + 163 + return s; 164 + } 165 + 166 + /** 167 + * Encode a usercmd to a compact JSON object for the wire. 168 + * (Bit-packing is an optional later optimization — see plan §5.3.) 169 + */ 170 + export function packCmd(cmd) { 171 + return { 172 + ms: cmd.ms | 0, 173 + f: cmd.fwd | 0, 174 + r: cmd.right | 0, 175 + y: round2(cmd.yaw), 176 + p: round2(cmd.pitch), 177 + b: cmd.buttons | 0, 178 + }; 179 + } 180 + 181 + export function unpackCmd(w) { 182 + return { 183 + ms: w.ms | 0, 184 + fwd: w.f | 0, 185 + right: w.r | 0, 186 + yaw: +w.y || 0, 187 + pitch: +w.p || 0, 188 + buttons: w.b | 0, 189 + // dt is derived by the consumer: (this.ms - prevCmd.ms) / 1000. 190 + }; 191 + } 192 + 193 + function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } 194 + function round2(n) { return Math.round(n * 100) / 100; }