The Peanut Gallery: nameless read-only spectators, counted but never named

Watch a room by code alone: no seat, no ledger line, no voice in chat.
Views and events are redacted for the empty viewer id (which no seat can
hold), so hands, wards, ambushes and boobytrap truths stay dark. The
table sees only a head count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-21 10:41:33 -04:00
co-authored by Claude Fable 5
parent e30f3f0903
commit 3445d12206
4 changed files with 142 additions and 18 deletions
+72 -5
View File
@@ -9,6 +9,8 @@
// {type:"claimTransfer", code} claim a seat on a new device // {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away // {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room // {type:"chat", text} table talk to the room
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
// {type:"leave"} detach this socket from table or gallery
// {type:"myGames", seats} summaries for held seats // {type:"myGames", seats} summaries for held seats
// {type:"stats"} the engagement tally // {type:"stats"} the engagement tally
// {type:"hotseatReport", ...} anonymous hotseat game counts // {type:"hotseatReport", ...} anonymous hotseat game counts
@@ -19,6 +21,8 @@
// {type:"events", events} redacted for this recipient // {type:"events", events} redacted for this recipient
// {type:"state", view, seq} redacted full view (after every change) // {type:"state", view, seq} redacted full view (after every change)
// {type:"chat", player, text, at} one line of table talk // {type:"chat", player, text, at} one line of table talk
// {type:"watching", roomId} you are seated in the gallery
// {type:"audience", count} how many watch from the gallery
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"} // {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
// {type:"error", message} // {type:"error", message}
@@ -44,6 +48,7 @@ import {
roomCount, roomCount,
runCommand, runCommand,
seatTokenValid, seatTokenValid,
SPECTATOR,
startGame, startGame,
summarize, summarize,
viewForPlayer, viewForPlayer,
@@ -59,6 +64,7 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
const MAX_COMMAND_BYTES = 16384; // serialized game command const MAX_COMMAND_BYTES = 16384; // serialized game command
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
const MAX_AUDIENCE = 30; // gallery seats per room
const NAME_MAX = 24; const NAME_MAX = 24;
/** Player/room names: printable, trimmed, bounded. */ /** Player/room names: printable, trimmed, bounded. */
@@ -144,6 +150,8 @@ interface Session {
socket: WebSocket; socket: WebSocket;
playerId: PlayerId | null; playerId: PlayerId | null;
roomId: string | null; roomId: string | null;
/** In the Peanut Gallery: nameless, read-only, counted but never listed. */
spectator: boolean;
/** The raw seat token this connection authenticated with (memory only). */ /** The raw seat token this connection authenticated with (memory only). */
token: string | null; token: string | null;
claimFails: number; claimFails: number;
@@ -171,6 +179,25 @@ function send(socket: WebSocket, message: unknown): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
} }
function audienceCount(room: Room): number {
let n = 0;
for (const s of sessions) if (s.roomId === room.id && s.spectator) n++;
return n;
}
function broadcastAudience(room: Room): void {
broadcast(room, () => ({ type: "audience", count: audienceCount(room) }));
}
/** A watcher leaves the gallery (to sit down, watch elsewhere, or vanish). */
function leaveGallery(session: Session): void {
if (!session.spectator) return;
session.spectator = false;
const room = session.roomId ? getRoom(session.roomId) : undefined;
session.roomId = null;
if (room) broadcastAudience(room);
}
function roomInfo(room: Room) { function roomInfo(room: Room) {
return { return {
type: "room", type: "room",
@@ -178,6 +205,7 @@ function roomInfo(room: Room) {
players: room.players, players: room.players,
hostId: room.hostId, hostId: room.hostId,
started: room.state !== null, started: room.state !== null,
audience: audienceCount(room),
colors: Object.fromEntries(room.colorChoices), colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries( bots: Object.fromEntries(
// A mystery machine keeps its mood only while the game lives: once it // A mystery machine keeps its mood only while the game lives: once it
@@ -194,9 +222,10 @@ function roomInfo(room: Room) {
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void { function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
for (const s of sessions) { for (const s of sessions) {
if (s.roomId === room.id && s.playerId) { if (s.roomId !== room.id) continue;
send(s.socket, makeMessage(s.playerId)); // The gallery hears everything too, redacted for the nameless viewer.
} if (s.playerId) send(s.socket, makeMessage(s.playerId));
else if (s.spectator) send(s.socket, makeMessage(SPECTATOR));
} }
} }
@@ -283,14 +312,17 @@ wss.on("connection", (socket) => {
return; return;
} }
const session: Session = { const session: Session = {
socket, playerId: null, roomId: null, token: null, claimFails: 0, socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
}; };
sessions.add(session); sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" }); send(socket, { type: "welcome", game: "wizwar" });
socket.on("close", () => sessions.delete(session)); socket.on("close", () => {
sessions.delete(session);
leaveGallery(session); // an emptier gallery is news to the table
});
socket.on("message", (data) => { socket.on("message", (data) => {
if (!underRateLimit(session)) { if (!underRateLimit(session)) {
@@ -313,6 +345,7 @@ wss.on("connection", (socket) => {
return send(socket, { type: "error", message: "no new rooms right now — try again later" }); return send(socket, { type: "error", message: "no new rooms right now — try again later" });
} }
session.roomsCreated++; session.roomsCreated++;
leaveGallery(session);
const { room, token } = createRoom(name); const { room, token } = createRoom(name);
session.playerId = name; session.playerId = name;
session.roomId = room.id; session.roomId = room.id;
@@ -329,6 +362,7 @@ wss.on("connection", (socket) => {
if (!room) return send(socket, { type: "error", message: "no such room" }); if (!room) return send(socket, { type: "error", message: "no such room" });
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null); const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
if ("error" in result) return send(socket, { type: "error", message: result.error }); if ("error" in result) return send(socket, { type: "error", message: result.error });
leaveGallery(session);
session.playerId = name; session.playerId = name;
session.roomId = room.id; session.roomId = room.id;
session.token = result.token; session.token = result.token;
@@ -343,6 +377,39 @@ wss.on("connection", (socket) => {
runBots(room); runBots(room);
break; break;
} }
case "watch": {
// The Peanut Gallery: no name, no seat, no ledger line — a pure
// reader of the public broadcast, counted but never identified.
const roomId = String(msg.roomId ?? "").trim().toUpperCase().slice(0, 8);
if (!roomId) return send(socket, { type: "error", message: "roomId required" });
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
if (audienceCount(room) >= MAX_AUDIENCE) {
return send(socket, { type: "error", message: "the gallery is packed — try again later" });
}
leaveGallery(session); // switching galleries updates the old room's count
session.playerId = null;
session.token = null;
session.spectator = true;
session.roomId = room.id;
send(socket, { type: "watching", roomId: room.id });
send(socket, roomInfo(room));
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, SPECTATOR), replayed: true });
send(socket, { type: "state", view: viewForPlayer(room, SPECTATOR), seq: room.log.length });
}
broadcastAudience(room);
break;
}
case "leave": {
// Walk away from the table or the gallery: the seat itself (and
// its token) survives for a later resume; only this socket detaches.
leaveGallery(session);
session.playerId = null;
session.roomId = null;
session.token = null;
break;
}
case "addBot": { case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
+6
View File
@@ -361,6 +361,12 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
return room.state ? viewFor(room.state, playerId) : null; return room.state ? viewFor(room.state, playerId) : null;
} }
/** The Peanut Gallery's viewer id: the empty name can never hold a seat
* (joins reject blank names), so a view built for it shows public knowledge
* only — no hand, no ward, no ambushes, no boobytrap truths — and event
* redaction drops everything marked visibleTo a player. */
export const SPECTATOR: PlayerId = "";
export interface CatchUpStep { export interface CatchUpStep {
seq: number; seq: number;
actor: PlayerId; actor: PlayerId;
+24 -3
View File
@@ -1160,9 +1160,14 @@
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button> <button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button> <button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
{:else if net.roomId} {:else if net.roomId}
<span class="mast-room">room <b>{net.roomId}</b></span> <span class="mast-room">{net.spectating ? "watching" : "room"} <b>{net.roomId}</b></span>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
{/if}
{#if !net.spectating}
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button> <button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
<button class="mast-leave" onclick={() => net.leave()}>leave table</button> {/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
{/if} {/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}> <button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
help &amp; rules help &amp; rules
@@ -1549,6 +1554,11 @@
Join Join
</button> </button>
</div> </div>
<div class="gallery-row">
<button class="hint-cancel" disabled={!joinCode.trim()} onclick={() => net.watch(joinCode)}>
👁 …or just watch that game from the Peanut Gallery
</button>
</div>
<div class="hotseat-row"> <div class="hotseat-row">
<span class="hotseat-label">or play here, passing the device:</span> <span class="hotseat-label">or play here, passing the device:</span>
@@ -1636,6 +1646,9 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{#if net.spectating}
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{:else}
<div class="standee-row" role="group" aria-label="choose your wizard"> <div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)} {#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]} {@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
@@ -1681,6 +1694,7 @@
{:else} {:else}
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p> <p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
{/if} {/if}
{/if}
</div> </div>
</section> </section>
{:else if view} {:else if view}
@@ -1894,7 +1908,7 @@
onclick={() => local.rollTableDie()}>🎲 roll the die</button> onclick={() => local.rollTableDie()}>🎲 roll the die</button>
</div> </div>
{/if} {/if}
{#if !local.active && net.roomId} {#if !local.active && net.roomId && !net.spectating}
<form class="say-box" onsubmit={(e) => { <form class="say-box" onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
const t = chatDraft.trim(); const t = chatDraft.trim();
@@ -2191,6 +2205,9 @@
{/if} {/if}
<div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand"> <div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery hands stay secret, even from you.</span>
{/if}
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)} {#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
<Card <Card
{card} {card}
@@ -2261,6 +2278,10 @@
} }
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; } .mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; } .mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
.gallery-note { font-size: 0.85rem; color: #8d8672; font-style: italic; padding: 0.4rem 0; }
.gallery-row { margin-top: 0.35rem; text-align: center; }
.gallery-row button:disabled { opacity: 0.4; cursor: default; }
.mast-status { font-size: 0.8rem; color: #c98a2a; } .mast-status { font-size: 0.8rem; color: #c98a2a; }
.mast-leave { .mast-leave {
background: none; background: none;
+31 -1
View File
@@ -246,6 +246,10 @@ class Net {
roomColors = $state<Record<string, number>>({}); roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({}); roomBots = $state<Record<string, string>>({});
you = $state<string | null>(null); you = $state<string | null>(null);
/** Seated in the Peanut Gallery: watching nameless, read-only. */
spectating = $state(false);
/** How many watch from the gallery (0 hides the count). */
audience = $state(0);
view = $state<GameView | null>(null); view = $state<GameView | null>(null);
log = $state<string[]>([]); log = $state<string[]>([]);
error = $state<string | null>(null); error = $state<string | null>(null);
@@ -291,6 +295,10 @@ class Net {
this.status = "connected"; this.status = "connected";
// Mid-game reconnects (the socket dropped, not the page) walk straight // Mid-game reconnects (the socket dropped, not the page) walk straight
// back to the table; otherwise the lobby ledger is the front door. // back to the table; otherwise the lobby ledger is the front door.
if (this.spectating && this.roomId) {
// A dropped gallery socket rejoins the gallery, not a seat.
this.send({ type: "watch", roomId: this.roomId });
} else {
const saved = localStorage.getItem(SEAT_KEY); const saved = localStorage.getItem(SEAT_KEY);
if (saved && this.roomId) { if (saved && this.roomId) {
try { try {
@@ -300,6 +308,7 @@ class Net {
} }
} catch { localStorage.removeItem(SEAT_KEY); } } catch { localStorage.removeItem(SEAT_KEY); }
} }
}
this.refreshGames(); this.refreshGames();
}; };
ws.onclose = () => { ws.onclose = () => {
@@ -320,10 +329,18 @@ class Net {
} }
break; break;
} }
case "watching":
this.spectating = true;
this.roomId = msg.roomId;
break;
case "audience":
this.audience = msg.count ?? 0;
break;
case "room": case "room":
this.roomId = msg.roomId; this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {}; this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {}; this.roomBots = msg.bots ?? {};
this.audience = msg.audience ?? 0;
if (this.you && this.token) { if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -335,7 +352,7 @@ class Net {
break; break;
case "state": { case "state": {
this.view = msg.view; this.view = msg.view;
if (typeof msg.seq === "number" && this.roomId) { if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
this.currentSeq = msg.seq; this.currentSeq = msg.seq;
// Only the FIRST state after arriving carries a gap worth // Only the FIRST state after arriving carries a gap worth
// announcing. Later states were watched live: a caught-up // announcing. Later states were watched live: a caught-up
@@ -423,11 +440,20 @@ class Net {
create(name: string): void { create(name: string): void {
this.you = name; this.you = name;
this.spectating = false;
this.send({ type: "create", name }); this.send({ type: "create", name });
} }
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
watch(roomId: string): void {
this.you = null;
this.log = [];
this.send({ type: "watch", roomId: roomId.toUpperCase() });
}
join(roomId: string, name: string): void { join(roomId: string, name: string): void {
this.you = name; this.you = name;
this.spectating = false;
this.roomIdPending = roomId.toUpperCase(); this.roomIdPending = roomId.toUpperCase();
const existing = this.seats.find( const existing = this.seats.find(
(s) => s.roomId === this.roomIdPending && s.name === name, (s) => s.roomId === this.roomIdPending && s.name === name,
@@ -438,6 +464,7 @@ class Net {
/** Sit back down at a remembered seat. */ /** Sit back down at a remembered seat. */
resume(seat: Seat): void { resume(seat: Seat): void {
this.you = seat.name; this.you = seat.name;
this.spectating = false;
this.token = seat.token; this.token = seat.token;
this.roomIdPending = seat.roomId; this.roomIdPending = seat.roomId;
this.log = []; this.log = [];
@@ -540,6 +567,7 @@ class Net {
/** Forget the remembered seat and return to the lobby. */ /** Forget the remembered seat and return to the lobby. */
leave(): void { leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
localStorage.removeItem(SEAT_KEY); localStorage.removeItem(SEAT_KEY);
this.roomId = null; this.roomId = null;
this.roomIdPending = null; this.roomIdPending = null;
@@ -548,6 +576,8 @@ class Net {
this.players = []; this.players = [];
this.log = []; this.log = [];
this.token = null; this.token = null;
this.spectating = false;
this.audience = 0;
} }
start(expansion: boolean): void { start(expansion: boolean): void {