Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at main 452 lines 17 kB view raw
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 14import { newState, pmove, unpackCmd, DEFAULT_CFG, BTN } from "../system/public/aesthetic.computer/lib/pmove.mjs"; 15 16const PLAYER_FIELDS = ["h","x","y","z","vx","vy","vz","yaw","pitch","c","g","a"]; 17 18const TICK_RATE = 60; // sim ticks/sec 19const SNAP_RATE = 30; // snapshots/sec (per client) 20const SNAP_EVERY = TICK_RATE / SNAP_RATE; 21const SNAP_RING = 32; // per-client snapshot history depth 22const POS_HISTORY_MS = 500; // rolling pos history for lag comp 23const STALE_TIMEOUT_MS = 30_000; // evict players idle this long 24 25// Default arena world config — must match disks/arena.mjs. 26export const ARENA_CFG = Object.freeze({ 27 ...DEFAULT_CFG, 28 runSpeed: 10, 29 walkSpeed: 5, 30 jumpVelocity: 8, 31 gravity: 50, 32 groundY: -1.5, 33 eyeHeight: 2.0, 34 crouchEyeHeight: 1.2, 35 groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, 36 deathFloorY: -30, 37 simHz: TICK_RATE, 38}); 39 40// Spawn ring — spread players around the arena. 41const SPAWNS = [ 42 { x: 6, z: 0 }, { x: -6, z: 0 }, { x: 0, z: 6 }, { x: 0, z: -6 }, 43 { x: 5, z: 5 }, { x: -5, z: -5 }, { x: 5, z: -5 }, { x: -5, z: 5 }, 44]; 45 46export class ArenaManager { 47 constructor() { 48 this.players = new Map(); // handle -> PlayerRecord 49 this.probes = new Map(); // handle -> { wsId } — text-only spectators 50 this.tick = 0; 51 this.startMs = Date.now(); 52 this.tickInterval = null; 53 54 // Transport callbacks (set by session.mjs) 55 this.sendUDP = null; // (channelId, event, data) -> bool 56 this.sendWS = null; // (wsId, type, content) 57 this.broadcastWS = null; // (type, content) 58 this.resolveUdpForHandle = null; // (handle) -> channelId|null 59 this.isLive = null; // (wsId) -> bool; used to tell reconnect 60 // (old ws dead) apart from a genuine 61 // takeover (old ws still alive). 62 } 63 64 setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle, isLive }) { 65 this.sendUDP = sendUDP; 66 this.sendWS = sendWS; 67 this.broadcastWS = broadcastWS; 68 this.resolveUdpForHandle = resolveUdpForHandle; 69 this.isLive = isLive; 70 } 71 72 now() { return Date.now() - this.startMs; } 73 74 // -- Lifecycle (WS-reliable) -- 75 76 playerJoin(handle, wsId, opts = {}) { 77 if (!handle) return; 78 79 // Text-only spectator / probe: no player body, just receive snaps. 80 if (opts.probe) { 81 this.probes.set(handle, { wsId }); 82 this.sendWS?.(wsId, "arena:welcome", { 83 you: handle, 84 probe: true, 85 cfg: ARENA_CFG, 86 serverMs: this.now(), 87 tick: this.tick, 88 roster: [...this.players.keys()], 89 }); 90 console.log(`🏟️ probe joined: ${handle} (${this.probes.size} probes)`); 91 this.ensureTick(); 92 return; 93 } 94 95 let rec = this.players.get(handle); 96 if (rec) { 97 // Re-join with same handle. Two cases: 98 // (a) same wsId → page reload / reconnect on same socket; just 99 // refresh seq bookkeeping. 100 // (b) different wsId → another tab is joining under the same 101 // handle. Do a *takeover*: tell the old wsId it's been 102 // displaced (so the old tab can flip to spectator UI), then 103 // register the old wsId as a probe so it keeps receiving snaps 104 // and the tab stays alive / watchable. 105 if (rec.wsId != null && rec.wsId !== wsId) { 106 const oldWsId = rec.wsId; 107 // Only treat as a takeover if the old socket is *still* live. 108 // Otherwise this is a reconnect (tab reload / transient network 109 // drop) and the old connection is already gone — no spectating 110 // needed, just refresh bookkeeping silently. 111 const oldAlive = this.isLive ? !!this.isLive(oldWsId) : true; 112 if (oldAlive) { 113 console.log(`🏟️ takeover: ${handle} (old wsId=${oldWsId} → new ${wsId})`); 114 this.sendWS?.(oldWsId, "arena:takeover", { handle, by: wsId }); 115 this.probes.set(`${handle}#spec${oldWsId}`, { wsId: oldWsId }); 116 } else { 117 console.log(`🏟️ reconnect: ${handle} (old wsId=${oldWsId} dead → new ${wsId})`); 118 } 119 } 120 rec.wsId = wsId; 121 rec.udpChannelId = this.resolveUdpForHandle?.(handle) ?? null; 122 rec.lastCmdSeq = 0; 123 rec.lastCmdMs = 0; 124 rec.lastAckMessageNum = 0; 125 rec.nextMessageNum = 1; 126 rec.snapHistory.fill(null); 127 rec.lastSeenMs = this.now(); 128 } else { 129 const spawn = SPAWNS[this.players.size % SPAWNS.length]; 130 rec = { 131 handle, 132 wsId, 133 udpChannelId: this.resolveUdpForHandle?.(handle) ?? null, 134 state: newState({ x: spawn.x, z: spawn.z, cfg: ARENA_CFG }), 135 lastCmdMs: this.now(), // for dt computation between cmds 136 lastCmdSeq: 0, // highest client cmd seq processed 137 snapHistory: new Array(SNAP_RING).fill(null), 138 nextMessageNum: 1, // monotonic snap counter for this client 139 lastAckMessageNum: 0, // highest snap the client acked 140 posHistory: [], // [{ ms, x, y, z }] for lag comp 141 lastSeenMs: this.now(), // used for timeout/presence 142 }; 143 this.players.set(handle, rec); 144 } 145 146 this.sendWS?.(wsId, "arena:welcome", { 147 you: handle, 148 probe: false, 149 cfg: ARENA_CFG, 150 serverMs: this.now(), 151 tick: this.tick, 152 initialState: rec.state, 153 roster: [...this.players.keys()], 154 }); 155 156 this.broadcastWS?.("arena:join", { handle }); 157 console.log(`🏟️ joined: ${handle} (${this.players.size} players)`); 158 this.ensureTick(); 159 } 160 161 /** 162 * Leave. `onlyIfWsId` (optional) guards the reload race: when a tab 163 * reloads quickly, the new ws may hello before the old ws's close 164 * handler fires. Without this guard, the close would delete the 165 * freshly-rebound player. Pass the wsId that was on the closing socket 166 * and we only delete if it's still the active one. 167 */ 168 playerLeave(handle, onlyIfWsId = undefined) { 169 if (!handle) return; 170 171 // Cleanup any spectator-probe entries for this handle bound to the 172 // closing wsId. (These are tabs that were displaced by a takeover.) 173 if (onlyIfWsId !== undefined) { 174 for (const key of this.probes.keys()) { 175 if (!key.startsWith(`${handle}#spec`)) continue; 176 const p = this.probes.get(key); 177 if (p?.wsId === onlyIfWsId) this.probes.delete(key); 178 } 179 } 180 181 if (this.probes.delete(handle)) { 182 console.log(`🏟️ probe left: ${handle} (${this.probes.size} probes)`); 183 this.maybeStopTick(); 184 return; 185 } 186 const rec = this.players.get(handle); 187 if (!rec) { this.maybeStopTick(); return; } 188 if (onlyIfWsId !== undefined && rec.wsId !== onlyIfWsId) { 189 // Stale leave (reload race OR this was a spectator tab for a takeover). 190 console.log(`🏟️ stale leave for ${handle} (wsId=${onlyIfWsId} ≠ active ${rec.wsId}) — ignored`); 191 return; 192 } 193 this.players.delete(handle); 194 this.broadcastWS?.("arena:leave", { handle }); 195 console.log(`🏟️ left: ${handle} (${this.players.size} players)`); 196 this.maybeStopTick(); 197 } 198 199 resolveUdpChannel(handle, channelId) { 200 const rec = this.players.get(handle); 201 if (rec) rec.udpChannelId = channelId; 202 } 203 204 // -- Input (UDP, high-frequency) -- 205 206 receiveCmd(handle, frame) { 207 const rec = this.players.get(handle); 208 if (!rec) return; 209 if (!rec._firstCmdLogged) { 210 console.log(`🏟️ cmd:first handle=${handle} cmds=${(frame.cmds || []).length}`); 211 rec._firstCmdLogged = true; 212 } 213 rec._cmdRxCount = (rec._cmdRxCount || 0) + 1; 214 rec.lastSeenMs = this.now(); 215 216 // Snap-ack: client tells us which snap they last saw. 217 if (typeof frame.ack === "number" && frame.ack > rec.lastAckMessageNum) { 218 rec.lastAckMessageNum = frame.ack; 219 } 220 221 const cmds = Array.isArray(frame.cmds) ? frame.cmds : []; 222 const firstSeq = typeof frame.firstSeq === "number" ? frame.firstSeq : null; 223 224 // Q3-style cmd processing: each cmd in the batch has an implicit seq 225 // = firstSeq + index. Skip anything already applied (the cmd backup 226 // window means most batches overlap with ones we've already seen). 227 for (let i = 0; i < cmds.length; i++) { 228 const c = unpackCmd(cmds[i]); 229 const seq = firstSeq != null ? firstSeq + i : null; 230 231 // De-dupe: prefer seq when present, fall back to ms monotonicity. 232 if (seq != null) { 233 if (seq <= rec.lastCmdSeq) continue; 234 } else { 235 if (c.ms <= rec.lastCmdMs) continue; 236 } 237 238 // dt from the previous applied cmd's ms; first cmd gets one tick. 239 const dt = rec.lastCmdMs > 0 240 ? Math.min((c.ms - rec.lastCmdMs) / 1000, 0.25) 241 : 1 / TICK_RATE; 242 rec.state = pmove(rec.state, { ...c, dt }, ARENA_CFG); 243 rec.lastCmdMs = c.ms; 244 if (seq != null && seq > rec.lastCmdSeq) rec.lastCmdSeq = seq; 245 } 246 } 247 248 // -- Tick loop -- 249 250 ensureTick() { 251 if (this.tickInterval) return; 252 this.tickInterval = setInterval(() => this.serverTick(), 1000 / TICK_RATE); 253 console.log(`🏟️ arena tick loop started (${TICK_RATE}Hz, snap ${SNAP_RATE}Hz)`); 254 } 255 256 maybeStopTick() { 257 if (this.players.size === 0 && this.probes.size === 0 && this.tickInterval) { 258 clearInterval(this.tickInterval); 259 this.tickInterval = null; 260 console.log(`🏟️ arena tick loop stopped (idle)`); 261 } 262 } 263 264 serverTick() { 265 this.tick++; 266 const nowMs = this.now(); 267 268 // For each player with no fresh input this tick, advance using their 269 // last-seen cmd (zero input => decays naturally via pmove's damping). 270 // This keeps positions progressing during input starvation without 271 // teleporting when input resumes. 272 for (const rec of this.players.values()) { 273 // No automatic pmove here — we only step on real cmds. This matches 274 // Q3: server integrates usercmds as they arrive, not on empty ticks. 275 // Append current position to history for lag comp. 276 rec.posHistory.push({ ms: nowMs, x: rec.state.x, y: rec.state.y, z: rec.state.z }); 277 // Trim old history beyond POS_HISTORY_MS. 278 const cutoff = nowMs - POS_HISTORY_MS; 279 while (rec.posHistory.length && rec.posHistory[0].ms < cutoff) { 280 rec.posHistory.shift(); 281 } 282 } 283 284 // Stale sweep: if we haven't seen a hello / cmd / ack from a player in 285 // STALE_TIMEOUT_MS, evict. Belt-and-suspenders for cases the ws close 286 // handler misses (crashed tabs, NATed mobile backgrounds, etc). 287 if (this.tick % TICK_RATE === 0) this.sweepStale(nowMs); 288 289 if (this.tick % SNAP_EVERY === 0) this.broadcastSnapshots(); 290 291 // Periodic rate log (every 5s at TICK_RATE=60). 292 if (this.tick % (TICK_RATE * 5) === 0 && this.players.size > 0) { 293 const rows = []; 294 for (const rec of this.players.values()) { 295 const cmdRx = rec._cmdRxCount || 0; 296 const snapTx = rec._snapTxCount || 0; 297 const transport = rec.udpChannelId != null ? "UDP" : "WS"; 298 rows.push(`${rec.handle}(${transport} rx=${cmdRx} tx=${snapTx})`); 299 rec._cmdRxCount = 0; 300 rec._snapTxCount = 0; 301 } 302 console.log(`🏟️ stats[5s] ${rows.join(" ")}`); 303 } 304 } 305 306 sweepStale(nowMs) { 307 for (const [handle, rec] of this.players) { 308 if (nowMs - rec.lastSeenMs > STALE_TIMEOUT_MS) { 309 console.log(`🏟️ sweep ${handle} (idle ${((nowMs - rec.lastSeenMs) / 1000).toFixed(1)}s)`); 310 this.players.delete(handle); 311 this.broadcastWS?.("arena:leave", { handle }); 312 } 313 } 314 for (const [handle, p] of this.probes) { 315 // Probes also expire; lastSeen isn't tracked for them today, so use 316 // a simple heuristic: if the ws is gone (we don't know), skip. No-op. 317 void handle; void p; 318 } 319 this.maybeStopTick(); 320 } 321 322 // -- Snapshots -- 323 324 composePlayersBlob() { 325 const blob = []; 326 for (const rec of this.players.values()) { 327 const s = rec.state; 328 blob.push({ 329 h: rec.handle, 330 x: round3(s.x), y: round3(s.y), z: round3(s.z), 331 vx: round3(s.vx), vy: round3(s.vy), vz: round3(s.vz), 332 yaw: round2(s.yaw), pitch: round2(s.pitch), 333 c: round3(s.crouchT), 334 g: s.onGround ? 1 : 0, 335 a: s.alive ? 1 : 0, 336 }); 337 } 338 return blob; 339 } 340 341 /** 342 * Q3-style delta of `current` players vs `base` players (keyed by h). 343 * Returns { delta, removed, changedCount } — one "delta entry" per handle: 344 * - first time seen: __new: {full blob} 345 * - steady state: only changed fields (h is always present) 346 * - unchanged: { h } only (bare handle marker) 347 * Plus `removed: [h, ...]` for handles that existed in base but are gone. 348 * The JSON representation is compact at small player counts; we 349 * intentionally skip bit-packing (see plan §5.7 — premature at 8 peers). 350 */ 351 deltaPlayers(current, base) { 352 const byHandleBase = new Map(); 353 for (const p of base) byHandleBase.set(p.h, p); 354 const delta = []; 355 const seen = new Set(); 356 let changedCount = 0; 357 for (const p of current) { 358 seen.add(p.h); 359 const bp = byHandleBase.get(p.h); 360 if (!bp) { delta.push({ h: p.h, __new: p }); changedCount++; continue; } 361 // Compare each field; emit only changed values. 362 const d = { h: p.h }; 363 let any = false; 364 for (const k of PLAYER_FIELDS) { 365 if (k === "h") continue; 366 if (p[k] !== bp[k]) { d[k] = p[k]; any = true; } 367 } 368 if (any) { delta.push(d); changedCount++; } 369 else delta.push({ h: p.h }); 370 } 371 const removed = []; 372 for (const [h] of byHandleBase) if (!seen.has(h)) removed.push(h); 373 return { delta, removed, changedCount }; 374 } 375 376 broadcastSnapshots() { 377 const serverMs = this.now(); 378 const players = this.composePlayersBlob(); 379 380 // Build one snapshot body per *player* because messageNum is per-client. 381 for (const rec of this.players.values()) { 382 const messageNum = rec.nextMessageNum++; 383 384 // M9: delta-compress against the last snap the client confirmed 385 // receiving (if it's still in our ring — falls off after SNAP_RING). 386 let snap; 387 const ack = rec.lastAckMessageNum; 388 const base = ack > 0 ? rec.snapHistory[ack % SNAP_RING] : null; 389 if (base && base.messageNum === ack) { 390 const { delta, removed } = this.deltaPlayers(players, base.players); 391 snap = { 392 messageNum, 393 deltaNum: ack, 394 tick: this.tick, 395 serverMs, 396 ackCmdSeq: rec.lastCmdSeq, 397 ackCmdMs: rec.lastCmdMs, 398 you: rec.handle, 399 delta, 400 ...(removed.length ? { removed } : {}), 401 }; 402 } else { 403 snap = { 404 messageNum, 405 deltaNum: 0, // full snap 406 tick: this.tick, 407 serverMs, 408 ackCmdSeq: rec.lastCmdSeq, 409 ackCmdMs: rec.lastCmdMs, 410 you: rec.handle, 411 players, 412 }; 413 } 414 // Write to ring for future delta base lookup. 415 rec.snapHistory[messageNum % SNAP_RING] = { messageNum, serverMs, players }; 416 417 // Prefer UDP, fall back to WS. 418 let ok = false; 419 if (rec.udpChannelId != null && this.sendUDP) { 420 ok = this.sendUDP(rec.udpChannelId, "arena:snap", snap); 421 } 422 if (!ok && rec.wsId != null && this.sendWS) { 423 this.sendWS(rec.wsId, "arena:snap", snap); 424 } 425 if (ok || rec.wsId != null) rec._snapTxCount = (rec._snapTxCount || 0) + 1; 426 } 427 428 // Probes: always WS, full snap, messageNum=0 (they don't ack). 429 for (const [handle, p] of this.probes) { 430 if (p.wsId == null || !this.sendWS) continue; 431 this.sendWS(p.wsId, "arena:snap", { 432 messageNum: 0, 433 deltaNum: 0, 434 tick: this.tick, 435 serverMs, 436 ackCmdSeq: 0, 437 you: handle, 438 players, 439 probe: true, 440 }); 441 } 442 } 443 444 // -- Probe-specific -- 445 446 handlePing(handle, ts, wsId) { 447 this.sendWS?.(wsId, "arena:pong", { ts, serverMs: this.now() }); 448 } 449} 450 451function round2(n) { return Math.round(n * 100) / 100; } 452function round3(n) { return Math.round(n * 1000) / 1000; }