Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

arena: fix self-takeover on reconnect + free-fly spectator cam

Two bugs users reported:

1. Self-takeover on single-tab reloads. When a tab reconnects (page
reload, transient network drop, live-reload polling), it sends a
fresh arena:hello on a new wsId. My code treated any wsId change as
a takeover and demoted the "old" connection to spectator — but that
old connection was actually already dead. The tab that should have
been the active player was instead told it had been displaced.
Fix: session.mjs now exposes isLive(wsId) (= ws is OPEN). In
ArenaManager.playerJoin, only emit arena:takeover + register a
spectator probe if the old wsId is still live. Otherwise it's a
silent reconnect — just refresh the bookkeeping.

2. Spectator cam still driven by cam-doll. The user asked for a
free-float cam (Quake noclip) while spectating. Added a spectator
controller in netSim that, when netSpectator is true, overwrites
cam.x/y/z each frame from a local specPos vector driven by WASD +
space/shift. Runs after cam-doll, so cam-doll's pushes get stomped
on. Rotation still handled by cam-doll (mouselook works normally).
Speed 20u/s, includes pitch in forward vector so look-down-and-W
flies you down. specPos resets to null when re-entering play mode.

Also simplified netSim's non-spectator path (removed the now-redundant
!netSpectator guard since spectator returns early).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+73 -14
+17 -6
session-server/arena-manager.mjs
··· 56 56 this.sendWS = null; // (wsId, type, content) 57 57 this.broadcastWS = null; // (type, content) 58 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). 59 62 } 60 63 61 - setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle }) { 64 + setSendFunctions({ sendUDP, sendWS, broadcastWS, resolveUdpForHandle, isLive }) { 62 65 this.sendUDP = sendUDP; 63 66 this.sendWS = sendWS; 64 67 this.broadcastWS = broadcastWS; 65 68 this.resolveUdpForHandle = resolveUdpForHandle; 69 + this.isLive = isLive; 66 70 } 67 71 68 72 now() { return Date.now() - this.startMs; } ··· 100 104 // and the tab stays alive / watchable. 101 105 if (rec.wsId != null && rec.wsId !== wsId) { 102 106 const oldWsId = rec.wsId; 103 - console.log(`🏟️ takeover: ${handle} (old wsId=${oldWsId} → new ${wsId})`); 104 - this.sendWS?.(oldWsId, "arena:takeover", { handle, by: wsId }); 105 - // Demote old connection to a spectator probe. Key the probe by 106 - // wsId so multiple displaced tabs don't collide. 107 - this.probes.set(`${handle}#spec${oldWsId}`, { wsId: oldWsId }); 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 + } 108 119 } 109 120 rec.wsId = wsId; 110 121 rec.udpChannelId = this.resolveUdpForHandle?.(handle) ?? null;
+3
session-server/session.mjs
··· 3126 3126 } 3127 3127 return null; 3128 3128 }, 3129 + // Used to distinguish reconnect (old ws dead → silent refresh) from a 3130 + // real takeover (old ws still alive → demote to spectator). 3131 + isLive: (wsId) => connections[wsId]?.readyState === WebSocket.OPEN, 3129 3132 }); 3130 3133 // #endregion 3131 3134
+53 -8
system/public/aesthetic.computer/disks/arena.mjs
··· 32 32 let ping = 0; 33 33 let netSpectator = false; // true if another tab took over this handle 34 34 let netTakeoverAt = 0; 35 + // Free-fly spectator cam (Quake noclip). Initialised on entering spectator. 36 + let specPos = null; // { x, y, z } in world coords 37 + const SPEC_SPEED = 20; // units/sec (faster than runSpeed; no collision) 38 + const SPEC_FAST_MUL = 3; // when no other modifier: ctrl / etc 35 39 36 40 const CMD_RATE = 60; // cmd sends per sec 37 41 const CMD_BACKUP = 3; // how many past cmds to include in each packet ··· 349 353 350 354 function netSim(cam) { 351 355 reconCamRef = cam; // kept across frames so reconcileLocal can correct. 356 + 357 + // 🪑 Spectator free-fly camera (no gravity, no collision, no avatar). 358 + // cam-doll has already run its physics for this frame; we overwrite 359 + // cam.x/y/z and let cam-doll keep handling rotation (mouselook, etc). 360 + if (netSpectator) { 361 + if (!specPos) { 362 + // Seed from the current cam position, lifted a bit so we start with a 363 + // nice angle on the arena instead of clipping into the ground. 364 + specPos = { x: -cam.x, y: -cam.y + 5, z: -cam.z }; 365 + } 366 + const dt = 1 / SIM_HZ; 367 + // Read the same keyboard + gamepad state arena already tracks. 368 + const fwd = (keyboardState.w || keyboardState.arrowup) ? 1 369 + : (keyboardState.s || keyboardState.arrowdown) ? -1 : 0; 370 + const right = (keyboardState.d || keyboardState.arrowright) ? 1 371 + : (keyboardState.a || keyboardState.arrowleft) ? -1 : 0; 372 + const up = (keyboardState.space ? 1 : 0) - (keyboardState.shift ? 1 : 0); 373 + // Direction vectors from cam rotation. 374 + const yr = cam.rotY * Math.PI / 180; 375 + const pr = cam.rotX * Math.PI / 180; 376 + const cy = Math.cos(yr), sy = Math.sin(yr); 377 + const cp = Math.cos(pr); 378 + // Forward in world: include pitch so look-down-and-W flies you down. 379 + const fx = sy * cp, fy = Math.sin(pr), fz = cy * cp; 380 + const rx = cy, rz = -sy; // strafe right (horizontal only) 381 + const speed = SPEC_SPEED; 382 + specPos.x += (fwd * fx + right * rx) * speed * dt; 383 + specPos.y += (fwd * fy + up ) * speed * dt; 384 + specPos.z += (fwd * fz + right * rz) * speed * dt; 385 + // Commit to cam (stored as negated world coords). 386 + cam.x = -specPos.x; 387 + cam.y = -specPos.y; 388 + cam.z = -specPos.z; 389 + // Skip the usercmd path entirely — spectator sends nothing. 390 + // Still ping so the HUD latency value stays live. 391 + if (Date.now() - lastPingSent > 2000) { 392 + lastPingSent = Date.now(); 393 + netServer?.send("arena:ping", { handle: myHandle, ts: Date.now() }); 394 + } 395 + return; 396 + } 397 + specPos = null; // reset when re-entering spectator later. 398 + 352 399 // Poll input state → usercmd each sim tick (120 Hz). Send at CMD_RATE. 353 400 // (arena.mjs already has `keyboardState` + `gamepadState` that we mirror.) 354 401 // pmove convention: fwd=+1 moves along facing (forward), right=+1 strafes right. ··· 365 412 netInput.jumping = !!keyboardState.space || !!gamepadState.buttons?.[0]; 366 413 netInput.crouching = !!keyboardState.shift || !!gamepadState.buttons?.[1]; 367 414 368 - // Spectators don't drive an avatar; skip cmd emission entirely. 369 - if (!netSpectator) { 370 - enqueueCmd(cam); 371 - if (!netSim._acc) netSim._acc = 0; 372 - netSim._acc++; 373 - if (netSim._acc >= 120 / CMD_RATE) { netSim._acc = 0; flushCmds(); } 374 - } 415 + // Non-spectator: produce + send usercmds. 416 + enqueueCmd(cam); 417 + if (!netSim._acc) netSim._acc = 0; 418 + netSim._acc++; 419 + if (netSim._acc >= 120 / CMD_RATE) { netSim._acc = 0; flushCmds(); } 375 420 376 - // Periodic ping for latency HUD (still useful while spectating). 421 + // Periodic ping for latency HUD. 377 422 if (Date.now() - lastPingSent > 2000) { 378 423 lastPingSent = Date.now(); 379 424 netServer?.send("arena:ping", { handle: myHandle, ts: Date.now() });