Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat: dumduel spectator mode, hitbox rings, network stats panel

- Anon/guest users join as spectators — see full game state (players,
dummy, bullets) but can't send inputs (pan only)
- Server tracks spectators separately, sends them WS snapshots
- "spectator" label at bottom center (MatrixChunky8)
- Network stats panel top-right: UDP/WS status + ping ms (MatrixChunky8)
- Hitbox ring (r=7) drawn around all alive players
- Spectators use server positions for all players (no local prediction)
- Removed debug clientlog relay

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

+81 -18
+24 -2
session-server/duel-manager.mjs
··· 23 23 export class DuelManager { 24 24 constructor() { 25 25 this.players = new Map(); // handle -> PlayerRecord 26 + this.spectators = new Map(); // handle -> { wsId } 26 27 this.roster = []; // handles in queue order 27 28 this.phase = "waiting"; 28 29 this.tick = 0; ··· 51 52 52 53 playerJoin(handle, wsId) { 53 54 if (!handle) return; 54 - // Only allow handled users (not guest_XXXX) 55 - if (handle.startsWith("guest_")) return; 55 + // Guests spectate — track their wsId so they receive snapshots 56 + if (handle.startsWith("guest_")) { 57 + this.spectators.set(handle, { wsId }); 58 + // Send current state so they see the game immediately 59 + this.sendWS?.(wsId, "duel:joined", { 60 + roster: this.roster, 61 + phase: this.phase, 62 + spectator: true, 63 + }); 64 + console.log(`🎯 Duel: ${handle} joined as spectator (${this.spectators.size} spectators)`); 65 + return; 66 + } 56 67 57 68 // Update existing or create new 58 69 let player = this.players.get(handle); ··· 99 110 100 111 playerLeave(handle) { 101 112 if (!handle) return; 113 + if (this.spectators.has(handle)) { 114 + this.spectators.delete(handle); 115 + console.log(`🎯 Duel: spectator ${handle} left (${this.spectators.size} spectators)`); 116 + return; 117 + } 102 118 const wasInRoster = this.roster.includes(handle); 103 119 const wasDueling = this.isDuelist(handle); 104 120 ··· 567 583 if (handle === DUMMY_HANDLE) continue; 568 584 if (player.wsId != null && this.sendWS) { 569 585 this.sendWS(player.wsId, "duel:snapshot", snapshot); 586 + } 587 + } 588 + // Send to spectators too 589 + for (const [, spec] of this.spectators) { 590 + if (spec.wsId != null && this.sendWS) { 591 + this.sendWS(spec.wsId, "duel:snapshot", snapshot); 570 592 } 571 593 } 572 594 }
+57 -16
system/public/aesthetic.computer/disks/dumduel.mjs
··· 15 15 let synth = null; 16 16 let sw = 0, sh = 0; 17 17 let frameCount = 0; 18 + let spectator = false; 18 19 19 20 // Input prediction 20 21 let inputSeq = 0; ··· 56 57 roundWinner = s.roundWinner; 57 58 roster = (s.roster || []).map((h) => ({ handle: h })); 58 59 59 - // Log first few snapshots + periodic + any with bullets → relay to server 60 - if (snapCount <= 3 || snapCount % 100 === 0 || s.bullets?.length > 0) { 61 - const msg = `snap #${snapCount} phase=${s.phase} players=${s.players?.length} bullets=${s.bullets?.length} tick=${s.tick}`; 62 - console.log(`📸 ${msg}`, s.bullets); 63 - server?.send("duel:clientlog", { handle: myHandle, msg, bullets: s.bullets || [] }); 60 + // Log first few snapshots + periodic 61 + if (snapCount <= 3 || snapCount % 100 === 0) { 62 + console.log(`📸 snap #${snapCount} phase=${s.phase} players=${s.players?.length} bullets=${s.bullets?.length} tick=${s.tick}`); 64 63 } 65 64 66 65 // Reconcile own prediction ··· 103 102 sw = screen.width; 104 103 sh = screen.height; 105 104 myHandle = handle?.() || "guest_" + Math.floor(Math.random() * 9999); 105 + spectator = myHandle.startsWith("guest_"); 106 106 synth = sound.synth; 107 107 sendFn = send; 108 108 ··· 156 156 if (type === "duel:joined" || type === "duel:roster") { 157 157 roster = (msg.roster || []).map((h) => ({ handle: h })); 158 158 if (msg.phase) phase = msg.phase; 159 - console.log(`🎯 ${type}: roster=[${msg.roster?.join(", ")}] phase=${msg.phase}`); 159 + if (msg.spectator) spectator = true; 160 + console.log(`🎯 ${type}: roster=[${msg.roster?.join(", ")}] phase=${msg.phase}${spectator ? " (spectator)" : ""}`); 160 161 } 161 162 162 163 if (type === "duel:countdown") { ··· 201 202 function sim() { 202 203 frameCount++; 203 204 204 - // Predict local movement 205 - if (phase === "fight" || phase === "countdown") { 205 + // Spectators skip local prediction — just use server positions 206 + if (!spectator && (phase === "fight" || phase === "countdown")) { 206 207 const dx = localTargetX - localX; 207 208 const dy = localTargetY - localY; 208 209 const dist = Math.sqrt(dx * dx + dy * dy); ··· 240 241 sw = screen.width; 241 242 sh = screen.height; 242 243 244 + // Spectators can pan but not send inputs 245 + if (spectator) { 246 + if (e.is("touch")) { panning = true; panStartX = e.x; panStartY = e.y; panCamStartX = camX; panCamStartY = camY; } 247 + if (e.is("draw") && panning) { camX = panCamStartX - (e.x - panStartX); camY = panCamStartY - (e.y - panStartY); } 248 + if (e.is("lift")) panning = false; 249 + return; 250 + } 251 + 243 252 if (e.is("touch")) { 244 253 touchStartX = e.x; 245 254 touchStartY = e.y; ··· 346 355 circle(ox + Math.round(b.x), oy + Math.round(b.y), BULLET_R, true); 347 356 } 348 357 349 - // Target indicator 350 - if (phase === "fight") { 358 + // Target indicator (not for spectators) 359 + if (phase === "fight" && !spectator) { 351 360 const meAlive = players.find((p) => p.handle === myHandle)?.alive; 352 361 if (meAlive) { 353 362 ink(50, 120, 200, 60).circle( ··· 358 367 } 359 368 } 360 369 361 - // Draw figures — use predicted position for self 370 + // Draw figures — spectators use server positions for everyone 362 371 for (const p of players) { 363 372 const col = isMe(p.handle) ? [50, 120, 200] : [200, 70, 60]; 364 - const drawP = isMe(p.handle) 365 - ? { ...p, x: localX, y: localY, targetX: localTargetX, targetY: localTargetY } 366 - : { ...p, x: opDisplayX, y: opDisplayY }; 373 + let drawP; 374 + if (spectator) { 375 + drawP = p; // All server positions 376 + } else if (isMe(p.handle)) { 377 + drawP = { ...p, x: localX, y: localY, targetX: localTargetX, targetY: localTargetY }; 378 + } else { 379 + drawP = { ...p, x: opDisplayX, y: opDisplayY }; 380 + } 367 381 drawFigure(ink, circle, box, line, ox, oy, drawP, col, frameCount); 368 382 383 + // Hitbox ring (server HIT_R = 7) 384 + const hx = ox + Math.round(drawP.x); 385 + const hy = oy + Math.round(drawP.y); 386 + if (drawP.alive) { 387 + ink(col[0], col[1], col[2], 35).circle(hx, hy, 7, false); 388 + } 389 + 369 390 // Handle label (MatrixChunky8, centered) + ping 370 391 const label = p.handle; 371 392 const pingStr = p.ping > 0 ? ` ${p.ping}` : ""; 372 393 const fullLabel = label + pingStr; 373 - const lx = isMe(p.handle) ? localX : opDisplayX; 374 - const ly = isMe(p.handle) ? localY : opDisplayY; 394 + const lx = spectator ? drawP.x : (isMe(p.handle) ? localX : opDisplayX); 395 + const ly = spectator ? drawP.y : (isMe(p.handle) ? localY : opDisplayY); 375 396 ink(...col, 150).write(fullLabel, { 376 397 x: ox + Math.round(lx) - Math.round(fullLabel.length * 2), 377 398 y: oy + Math.round(ly) + 9, ··· 413 434 write(r.handle, { x: stackX, y: stackY + 12 + si * 10 }); 414 435 si++; 415 436 } 437 + 438 + // Spectator label 439 + if (spectator) { 440 + ink(180, 100, 200, 200).write("spectator", { 441 + x: Math.floor(sw / 2 - 18), 442 + y: sh - 10, 443 + }, undefined, undefined, false, "MatrixChunky8"); 444 + } 445 + 446 + // Network stats panel (top-right, MatrixChunky8) 447 + const netX = sw - 4; 448 + const netY = 3; 449 + const udpOk = !!udpChannel?.connected; 450 + const wsOk = !!server; 451 + ink(udpOk ? 80 : 200, udpOk ? 180 : 80, udpOk ? 80 : 80, 180) 452 + .write(udpOk ? "udp ok" : "udp --", { x: netX - 24, y: netY }, undefined, undefined, false, "MatrixChunky8"); 453 + ink(wsOk ? 80 : 200, wsOk ? 180 : 80, wsOk ? 80 : 80, 180) 454 + .write(wsOk ? "ws ok" : "ws --", { x: netX - 20, y: netY + 8 }, undefined, undefined, false, "MatrixChunky8"); 455 + ink(160, 155, 145, 180) 456 + .write(ping > 0 ? ping + "ms" : "--ms", { x: netX - 20, y: netY + 16 }, undefined, undefined, false, "MatrixChunky8"); 416 457 } 417 458 418 459 function isMe(handle) {