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
+25 -4
View File
@@ -1160,9 +1160,14 @@
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
{:else if net.roomId}
<span class="mast-room">room <b>{net.roomId}</b></span>
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
<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>
{/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
{/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
help &amp; rules
@@ -1549,6 +1554,11 @@
Join
</button>
</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">
<span class="hotseat-label">or play here, passing the device:</span>
@@ -1636,6 +1646,9 @@
</li>
{/each}
</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">
{#each [0, 1, 2, 3, 4, 5] as c (c)}
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
@@ -1681,6 +1694,7 @@
{:else}
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
{/if}
{/if}
</div>
</section>
{:else if view}
@@ -1894,7 +1908,7 @@
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
</div>
{/if}
{#if !local.active && net.roomId}
{#if !local.active && net.roomId && !net.spectating}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
@@ -2191,6 +2205,9 @@
{/if}
<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)}
<Card
{card}
@@ -2261,6 +2278,10 @@
}
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.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-leave {
background: none;
+39 -9
View File
@@ -246,6 +246,10 @@ class Net {
roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
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);
log = $state<string[]>([]);
error = $state<string | null>(null);
@@ -291,14 +295,19 @@ class Net {
this.status = "connected";
// Mid-game reconnects (the socket dropped, not the page) walk straight
// back to the table; otherwise the lobby ledger is the front door.
const saved = localStorage.getItem(SEAT_KEY);
if (saved && this.roomId) {
try {
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
if (seat.roomId === this.roomId) {
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
} catch { localStorage.removeItem(SEAT_KEY); }
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);
if (saved && this.roomId) {
try {
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
if (seat.roomId === this.roomId) {
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
} catch { localStorage.removeItem(SEAT_KEY); }
}
}
this.refreshGames();
};
@@ -320,10 +329,18 @@ class Net {
}
break;
}
case "watching":
this.spectating = true;
this.roomId = msg.roomId;
break;
case "audience":
this.audience = msg.count ?? 0;
break;
case "room":
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
this.audience = msg.audience ?? 0;
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -335,7 +352,7 @@ class Net {
break;
case "state": {
this.view = msg.view;
if (typeof msg.seq === "number" && this.roomId) {
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
this.currentSeq = msg.seq;
// Only the FIRST state after arriving carries a gap worth
// announcing. Later states were watched live: a caught-up
@@ -423,11 +440,20 @@ class Net {
create(name: string): void {
this.you = name;
this.spectating = false;
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 {
this.you = name;
this.spectating = false;
this.roomIdPending = roomId.toUpperCase();
const existing = this.seats.find(
(s) => s.roomId === this.roomIdPending && s.name === name,
@@ -438,6 +464,7 @@ class Net {
/** Sit back down at a remembered seat. */
resume(seat: Seat): void {
this.you = seat.name;
this.spectating = false;
this.token = seat.token;
this.roomIdPending = seat.roomId;
this.log = [];
@@ -540,6 +567,7 @@ class Net {
/** Forget the remembered seat and return to the lobby. */
leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
@@ -548,6 +576,8 @@ class Net {
this.players = [];
this.log = [];
this.token = null;
this.spectating = false;
this.audience = 0;
}
start(expansion: boolean): void {