Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

arena: takeover model for same-handle multi-tab

Matches field.mjs's mental model: when a second tab joins under the
same handle, the older tab becomes a spectator instead of both tabs
fighting over one avatar.

Server (arena-manager.mjs):
- On rejoin with same handle but different wsId, detect takeover:
emit "arena:takeover" to the old wsId, then register that old ws
as a spectator probe so it keeps receiving snapshots (tab stays
alive, the user can watch the other tab drive their avatar).
- playerLeave cleans up any "<handle>#spec<wsId>" probe entries
bound to the closing socket.

Client (arena.mjs):
- New netSpectator flag set on receiving arena:takeover.
- Suppress enqueueCmd / flushCmds while spectating (no cmd traffic).
- Suppress reconcileLocal while spectating (cam would be yanked toward
the other tab's position otherwise).
- HUD: "SPECTATE" row + center-screen banner explaining why.

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

+72 -11
+29 -5
session-server/arena-manager.mjs
··· 90 90 91 91 let rec = this.players.get(handle); 92 92 if (rec) { 93 - // Re-join (page reload / reconnect): keep the body continuity but reset 94 - // the per-connection bookkeeping. The new client starts its cmd seq and 95 - // snap-ack counters from zero, so we must wipe ours or every new cmd 96 - // would be "already seen" and dropped, freezing the avatar in place. 93 + // Re-join with same handle. Two cases: 94 + // (a) same wsId → page reload / reconnect on same socket; just 95 + // refresh seq bookkeeping. 96 + // (b) different wsId → another tab is joining under the same 97 + // handle. Do a *takeover*: tell the old wsId it's been 98 + // displaced (so the old tab can flip to spectator UI), then 99 + // register the old wsId as a probe so it keeps receiving snaps 100 + // and the tab stays alive / watchable. 101 + if (rec.wsId != null && rec.wsId !== wsId) { 102 + 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 }); 108 + } 97 109 rec.wsId = wsId; 98 110 rec.udpChannelId = this.resolveUdpForHandle?.(handle) ?? null; 99 111 rec.lastCmdSeq = 0; ··· 144 156 */ 145 157 playerLeave(handle, onlyIfWsId = undefined) { 146 158 if (!handle) return; 159 + 160 + // Cleanup any spectator-probe entries for this handle bound to the 161 + // closing wsId. (These are tabs that were displaced by a takeover.) 162 + if (onlyIfWsId !== undefined) { 163 + for (const key of this.probes.keys()) { 164 + if (!key.startsWith(`${handle}#spec`)) continue; 165 + const p = this.probes.get(key); 166 + if (p?.wsId === onlyIfWsId) this.probes.delete(key); 167 + } 168 + } 169 + 147 170 if (this.probes.delete(handle)) { 148 171 console.log(`🏟️ probe left: ${handle} (${this.probes.size} probes)`); 149 172 this.maybeStopTick(); 150 173 return; 151 174 } 152 175 const rec = this.players.get(handle); 153 - if (!rec) return; 176 + if (!rec) { this.maybeStopTick(); return; } 154 177 if (onlyIfWsId !== undefined && rec.wsId !== onlyIfWsId) { 178 + // Stale leave (reload race OR this was a spectator tab for a takeover). 155 179 console.log(`🏟️ stale leave for ${handle} (wsId=${onlyIfWsId} ≠ active ${rec.wsId}) — ignored`); 156 180 return; 157 181 }
+43 -6
system/public/aesthetic.computer/disks/arena.mjs
··· 30 30 let serverClockOffset = 0; // add to Date.now() → server time estimate 31 31 let lastPingSent = 0; 32 32 let ping = 0; 33 + let netSpectator = false; // true if another tab took over this handle 34 + let netTakeoverAt = 0; 33 35 34 36 const CMD_RATE = 60; // cmd sends per sec 35 37 const CMD_BACKUP = 3; // how many past cmds to include in each packet ··· 220 222 // position; if divergent, soft-correct (small drift) or snap (big desync). 221 223 function reconcileLocal() { 222 224 if (!myServerState || !reconCamRef) return; 225 + // While spectating, the "me" state in snapshots is being driven by another 226 + // tab — if we reconciled cam-doll against it, our cam would keep getting 227 + // yanked to wherever they are. So just skip local pmove reconciliation 228 + // (we still update cam to follow them below if we want spectator-follow). 229 + if (netSpectator) return; 223 230 const cam = reconCamRef; 224 231 225 232 // Build a pmove-compatible state from the wire blob. ··· 328 335 } 329 336 if (type === "arena:leave") { delete others[msg.handle]; return; } 330 337 if (type === "arena:pong") { ping = Date.now() - msg.ts; return; } 338 + if (type === "arena:takeover") { 339 + // Another tab under the same handle displaced us. Flip to spectator 340 + // mode: stop sending cmds, keep receiving snaps, let the user watch 341 + // the other tab drive their avatar. 342 + netSpectator = true; 343 + netTakeoverAt = Date.now(); 344 + console.log(`🪑 takeover: ${msg.handle} is now controlled from another tab — spectating.`); 345 + return; 346 + } 331 347 }); 332 348 } 333 349 ··· 349 365 netInput.jumping = !!keyboardState.space || !!gamepadState.buttons?.[0]; 350 366 netInput.crouching = !!keyboardState.shift || !!gamepadState.buttons?.[1]; 351 367 352 - enqueueCmd(cam); 353 - // Throttle outbound packets to CMD_RATE (= every Nth 120Hz tick). 354 - if (!netSim._acc) netSim._acc = 0; 355 - netSim._acc++; 356 - if (netSim._acc >= 120 / CMD_RATE) { netSim._acc = 0; flushCmds(); } 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 + } 357 375 358 - // Periodic ping for latency HUD. 376 + // Periodic ping for latency HUD (still useful while spectating). 359 377 if (Date.now() - lastPingSent > 2000) { 360 378 lastPingSent = Date.now(); 361 379 netServer?.send("arena:ping", { handle: myHandle, ts: Date.now() }); ··· 1797 1815 const peers = Object.keys(others).length; 1798 1816 ink(peers > 0 ? [180, 230, 180] : [130, 130, 130]); 1799 1817 rightLabel(`peers ${peers}`, margin + lineH * 8); 1818 + if (netSpectator) { 1819 + ink(255, 180, 60); 1820 + rightLabel(`SPECTATE`, margin + lineH * 9); 1821 + } 1822 + } 1823 + 1824 + // 🪑 Full-width spectator overlay (only renders when we were kicked from 1825 + // our own avatar by another tab). Center of screen, easy to notice. 1826 + if (netSpectator) { 1827 + const bannerY = Math.floor(screen.height / 2 - 8); 1828 + const msg1 = "SPECTATING"; 1829 + const msg2 = `@${myHandle.replace(/^@/, "")} is controlled`; 1830 + const msg3 = "from another tab"; 1831 + ink(0, 0, 0, 180).box(0, bannerY - 2, screen.width, 30); 1832 + ink(255, 220, 100); 1833 + write(msg1, { x: Math.floor(screen.width / 2 - msg1.length * 2), y: bannerY }, undefined, undefined, false, "MatrixChunky8"); 1834 + ink(200, 200, 200); 1835 + write(msg2, { x: Math.floor(screen.width / 2 - msg2.length * 2), y: bannerY + 10 }, undefined, undefined, false, "MatrixChunky8"); 1836 + write(msg3, { x: Math.floor(screen.width / 2 - msg3.length * 2), y: bannerY + 18 }, undefined, undefined, false, "MatrixChunky8"); 1800 1837 } 1801 1838 1802 1839 // 🏃 Current speed — colored by how close to max.