The Peanut Gallery and table talk

A room's link without a seat is now a place in the gallery: a tokenless
view that withholds any gesture hidden from anyone at the table, counted
for the table and the lobby. Seated wizards can talk; each line is a
ledger line, shown in the rail and in the lobby, and the hall counts
what was said while you were away. The hall's pitch no longer says two
wizards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 09:50:06 -04:00
co-authored by Claude Fable 5.1
parent 75c2dc9241
commit 8e7376d59a
19 changed files with 488 additions and 80 deletions
+67 -23
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import TableTalk from '$lib/components/TableTalk.svelte';
import { seatFor } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
@@ -15,8 +16,11 @@
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
const watching = $derived(room?.spectating ?? false);
/** A watcher may still sit while the table is laid and a seat is free. */
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
// Return to a held seat, or offer one.
// Return to a held seat, or take a place in the gallery and offer one.
$effect(() => {
const id = roomId;
room = null;
@@ -25,6 +29,9 @@
if (!held) {
name = playerName();
joining = true;
Duel.watchRoom(id)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
return;
}
joining = false;
@@ -84,7 +91,13 @@
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if room?.remote?.started}
{#if room?.remote?.started && watching}
<p class="standfirst">
From the Peanut Gallery you watch
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each} duel.
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
</p>
{:else if room?.remote?.started}
<p class="standfirst">
You are <em>{room.you.name}</em>, duelling
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
@@ -97,8 +110,11 @@
<nav class="actions">
{#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
<span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button>
{#if room.remote && room.remote.audience > 0}
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery</span>
{/if}
{/if}
<a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a>
@@ -110,11 +126,11 @@
<p class="notice">{error}</p>
{/if}
{#if joining}
{#if joining && seatFree}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A wizard has opened this table and is gathering opponents.</p>
<p>A wizard has opened this table and is gathering opponents. Choose a name and sit; the duel begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
@@ -122,15 +138,27 @@
</label>
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
</form>
<p class="muted">Just watching? You are in the Peanut Gallery until you sit.</p>
</section>
{:else if room && lobby}
{/if}
{#if room && lobby}
{@const r = room.remote!}
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{#if watching}
<h2>At the table</h2>
{#if seatFree}
<p>{hostName} is gathering wizards. Sit down above, or watch from the gallery until the duel begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the duel begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen. Anyone who arrives once the duel has begun watches from the Peanut Gallery.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if}
<ul class="roster">
{#each r.seats as s (s.id)}
<li>
@@ -138,25 +166,28 @@
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
</li>
{/each}
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty</li>
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{/if}</li>
</ul>
<div class="ways">
{#if r.seats.length < r.size}
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
{:else}
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
{/if}
</div>
{#if !watching}
<div class="ways">
{#if r.seats.length < r.size}
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
{:else}
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
{/if}
</div>
{/if}
{#if r.error}<p class="notice">{r.error}</p>{/if}
<div class="lobby-talk"><TableTalk duel={room} /></div>
</section>
{:else if room}
<Board duel={room} />
{:else if !error}
<p class="notice muted">Finding your seat.</p>
<p class="notice muted">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
{/if}
<footer class="colophon">
@@ -217,6 +248,19 @@
font-weight: 600;
}
.audience {
align-self: center;
font-size: 0.85rem;
font-style: italic;
color: var(--bone-faint);
white-space: nowrap;
}
.lobby-talk {
max-width: 28em;
margin-top: 1rem;
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;