Monorepo for Aesthetic.Computer
aesthetic.computer
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; }