The game kit: the shared infrastructure of Wiz-War and Waving Hands as a template

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 11:00:05 -04:00
co-authored by Claude Fable 5.1
commit 1cd24e3ddd
59 changed files with 7420 additions and 0 deletions
@@ -0,0 +1,247 @@
<script lang="ts">
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte';
import { seatFor } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
let room = $state<Room | null>(null);
let name = $state('');
let joining = $state(false);
let busy = $state(false);
let error = $state('');
let copied = $state(false);
let reporting = $state(false);
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
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 && !room.started && room.view.seats.length < room.view.size);
// Return to a held seat, or take a place in the gallery and offer one.
$effect(() => {
const id = roomId;
room = null;
error = '';
const held = seatFor(id);
const arrive = held ? Room.resume(id, held) : Room.watch(id);
joining = !held;
if (!held) name = playerName();
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
$effect(() => {
const r = room;
if (!r) return;
return r.connect();
});
async function takeSeat(e: SubmitEvent) {
e.preventDefault();
busy = true;
error = '';
try {
rememberName(name);
room = await Room.join(roomId, name.trim());
joining = false;
} catch (err) {
error = err instanceof Error ? err.message : 'The seat could not be taken.';
} finally {
busy = false;
}
}
async function copyLink() {
try {
await navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 1600);
} catch {
// The link is on screen to copy by hand.
}
}
</script>
<svelte:head>
<title>__NAME__: a game</title>
</svelte:head>
<header class="masthead">
<div>
<h1>__NAME__</h1>
{#if room?.started && watching}
<p class="standfirst">From the Peanut Gallery you watch {room.others.map((s) => s.name).join(', ')} play.{#if !room.connected} <span class="muted">Reconnecting.</span>{/if}</p>
{:else if room?.started}
<p class="standfirst">You are <em>{room.nameOf(room.me)}</em>, playing {room.others.map((s) => s.name).join(', ')}.{#if !room.connected} <span class="muted">Reconnecting.</span>{/if}</p>
{:else}
<p class="standfirst">A game between people, played by turns whenever each of you has a moment.</p>
{/if}
</div>
<nav class="actions">
{#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button>
{#if room.view.audience > 0}
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery</span>
{/if}
{/if}
<a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a>
{#if room}
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
{/if}
<a class="quiet" href="/">The hall</a>
</nav>
</header>
{#if room}
<ReportSlip {room} bind:open={reporting} />
{/if}
{#if error}
<p class="notice">{error}</p>
{/if}
{#if joining && seatFree}
<section class="seat">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
</label>
<button type="submit" class="commit small" disabled={busy}>Sit down</button>
</form>
<p class="muted">Just watching? You are in the Peanut Gallery until you sit.</p>
</section>
{/if}
{#if room && !room.started}
<Lobby {room} {link} />
{:else if room}
<Board {room} />
{:else if !error}
<p class="notice muted">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
{/if}
<footer class="colophon">
<p>__NAME__. <a href="/rules">Read the full rules</a>. A rule read wrong or a bug found: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet.room b {
letter-spacing: 0.12em;
font-weight: 600;
}
.audience {
align-self: center;
font-size: 0.85rem;
font-style: italic;
color: var(--bone-faint);
white-space: nowrap;
}
.notice {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--gutter) 0.5rem;
color: var(--blood);
}
.notice.muted {
color: var(--bone-dim);
}
.seat {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 0.5rem;
}
.seat h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0.8rem 0;
}
.field {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.field > span {
color: var(--bone-dim);
font-style: italic;
}
.rename input {
width: 14em;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
@media (max-width: 860px) {
.masthead {
flex-direction: column;
}
}
</style>