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
+162
View File
@@ -0,0 +1,162 @@
/* Design tokens. Every game picks its own palette and typeface here; the
components only ever name these tokens, so a new look is this block. */
:root {
--slate: #1c2830;
--slate-deep: #121a20;
--slate-raised: #24333d;
--bone: #e9e2d2;
--bone-dim: #98a4ab;
--bone-faint: #5d6b73;
--ember: #f0a53a;
--frost: #8cc8e0;
--blood: #e0655c;
--rule: rgba(233, 226, 210, 0.14);
--rule-strong: rgba(233, 226, 210, 0.32);
--font: 'Iowan Old Style', Georgia, serif;
--gutter: clamp(16px, 3vw, 40px);
}
* {
box-sizing: border-box;
}
html {
background: var(--slate-deep);
}
body {
margin: 0;
background: var(--slate-deep);
color: var(--bone);
font-family: var(--font);
font-size: 17px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-weight: 500;
line-height: 1.15;
margin: 0;
}
p {
margin: 0;
}
button,
select,
input,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button:focus-visible,
select:focus-visible,
input:focus-visible,
textarea:focus-visible,
summary:focus-visible {
outline: 2px solid var(--frost);
outline-offset: 2px;
}
select,
input[type='text'],
textarea {
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.3rem 0.6rem;
max-width: 100%;
color: var(--bone);
}
button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* An action written as a word in running text. */
.link {
background: none;
border: 0;
padding: 0;
font-size: inherit;
color: var(--frost);
text-decoration: underline;
}
/* The one button that commits a move. */
.commit {
background: var(--bone);
color: var(--slate-deep);
border: 0;
border-radius: 4px;
padding: 0.6rem 1.4rem;
font-size: 1.05rem;
font-weight: 500;
}
.commit:hover:not(:disabled) {
background: #fff;
}
.commit.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
/* A bordered, unemphatic button: navigation, secondary actions. */
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
.muted {
color: var(--bone-dim);
}
.warning {
color: var(--blood);
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--bone-faint);
margin-bottom: 0.2rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#121a20" />
<title>__NAME__</title>
<meta name="description" content="__NAME__: a game between friends, free in the browser." />
<link rel="canonical" href="https://__DOMAIN__/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="__NAME__" />
<meta property="og:title" content="__NAME__" />
<meta property="og:description" content="__NAME__: a game between friends, free in the browser." />
<meta property="og:url" content="https://__DOMAIN__/" />
<meta property="og:image" content="https://__DOMAIN__/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="__NAME__" />
<meta name="twitter:description" content="__NAME__: a game between friends, free in the browser." />
<meta name="twitter:image" content="https://__DOMAIN__/og.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<title>Waving Hands</title>
<!--
Gesture silhouettes adapted from Andrew Plotkin's Spellcast for X Windows,
bitmaps/bf.bm, bp.bm, bs.bm, bw.bm, bd.bm, bc.bm, and bk.bm.
The bitmap borders were removed and the contours converted to SVG with lightly
rounded corners. The original poses and left/right mirroring are retained.
Original notice (Spellcast README):
This implementation is by Andrew Plotkin (ap1i+@andrew.cmu.edu).
It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
distributed, and modified, as long as this copyright notice is retained.
The source code and any derivative works may not be sold for profit without
the permission of Andrew Plotkin and Richard Bartle.
-->
<rect width="48" height="48" rx="10" fill="#121a20" />
<g transform="translate(3 3) scale(.84)">
<path d="M6 31.33L6 27.3Q6 27 6.3 27L6.7 27Q7 27 7.07 26.71L7.71 24.16Q8 23 8.85 22.15L15.58 15.42Q16 15 16.6 15L17.4 15Q18 15 18.42 14.58L20.36 12.64Q21 12 21.9 12L23.15 12Q24 12 24.6 11.4L25.4 10.6Q26 10 26.85 10L28.1 10Q29 10 29.28 10.85L29.7 12.1Q30 13 30.9 13.3L32.1 13.7Q33 14 32.87 14.94L31.17 26.81Q31 28 31.96 28.72L34.28 30.46Q35 31 35 31.9L35 33.7Q35 34 35.3 34L35.7 34Q36 34 36 34.3L36 41.7Q36 42 35.7 42L35.3 42Q35 42 34.93 42.29L34.1 45.59Q34 46 33.7 46.3L33.3 46.7Q33 47 32.58 47L21.6 47Q21 47 21 46.4L21 45.6Q21 45 20.4 45L19.6 45Q19 45 19 44.4L19 43.6Q19 43 18.58 42.58L15.42 39.42Q15 39 14.4 39L13.6 39Q13 39 12.64 38.52L10.36 35.48Q10 35 9.4 35L8.6 35Q8 35 8 34.4L8 33.6Q8 33 7.46 32.73L6.6 32.3Q6 32 6 31.33ZM24.6 18.45L18.4 25.55Q18 26 18 26.6L18 27.4Q18 28 18.58 27.85L20.84 27.29Q22 27 22.85 26.15L25.15 23.85Q26 23 26.24 21.82L26.88 18.59Q27 18 26.4 18L25.6 18Q25 18 24.6 18.45Z" fill="#f0a53a" fill-rule="evenodd" />
<path d="m22 3 2 4m9-4-1 4m5 3 5-2m-3 9h6m-7 6 3 3" fill="none" stroke="#e9e2d2" stroke-width="2.2" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
// The demo game's board. A real game replaces this file entirely; the
// masthead, lobby, talk, gallery and reports around it stay.
import TableTalk from './TableTalk.svelte';
import { HIGHEST, TARGET } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props();
const g = $derived(room.state!);
let pick = $state(0);
async function play() {
if (!pick) return;
if (await room.submit({ pick })) pick = 0;
}
</script>
<main class="board">
<section class="play">
<table class="rounds">
<thead>
<tr>
<th class="turn"><span class="visually-hidden">Round</span></th>
{#each g.seats as id (id)}
<th class:you={id === room.me}>{g.players[id].name}{#if id === room.me}&nbsp;(you){/if}</th>
{/each}
</tr>
</thead>
<tbody>
{#each g.rounds as round, i (i)}
<tr>
<td class="turn">{i + 1}</td>
{#each g.seats as id (id)}
<td class:scored={round.scorer === id}>{round.picks[id]}</td>
{/each}
</tr>
{/each}
{#if !g.over}
<tr class="draft">
<td class="turn">{g.rounds.length + 1}</td>
{#each g.seats as id (id)}
<td class="muted">{id === room.me && pick ? pick : '?'}</td>
{/each}
</tr>
{/if}
</tbody>
</table>
{#if g.over}
<div class="verdict">
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
<p>{g.over.reason}</p>
<a class="commit" href="/">Back to the hall</a>
</div>
{:else if room.spectating}
<section class="move">
<h2>You watch from the Peanut Gallery</h2>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}</p>
</section>
{:else}
<section class="move">
<h2>Round {g.rounds.length + 1}: name a number</h2>
<p class="muted">The highest number named by exactly one player scores. First to {TARGET}.</p>
<div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
{/each}
</div>
<button type="button" class="commit" disabled={!pick || room.moved || room.sending} onclick={play}>
{room.moved ? `Waiting on ${room.awaitingText}` : room.sending ? 'Sending' : 'Commit'}
</button>
{#if room.error}<p class="warning">{room.error}</p>{/if}
</section>
{/if}
</section>
<aside class="rail">
<section class="standings">
<h2>Standings</h2>
<ul>
{#each g.seats as id (id)}
<li><span>{g.players[id].name}</span><span>{g.players[id].points} / {TARGET}</span></li>
{/each}
</ul>
</section>
<TableTalk {room} />
</aside>
</main>
<style>
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
}
.rounds {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
text-align: center;
}
.rounds th {
font-weight: 500;
font-style: italic;
font-size: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule-strong);
}
.rounds th.you {
color: var(--frost);
}
.rounds td {
padding: 0.35rem 0;
border-bottom: 1px solid var(--rule);
}
.rounds .turn {
width: 2.5rem;
font-size: 0.8rem;
color: var(--bone-faint);
}
.rounds td.scored {
color: var(--ember);
font-weight: 600;
}
.rounds tr.draft td {
background: var(--slate);
}
.move,
.verdict {
margin-top: 1.2rem;
}
.move h2,
.verdict h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
.picks {
display: flex;
gap: 0.5rem;
margin: 0.8rem 0;
}
.pick {
width: 3rem;
height: 3rem;
font-size: 1.3rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
color: var(--bone);
}
.pick.chosen {
border-color: var(--ember);
color: var(--ember);
}
.verdict .commit {
display: inline-block;
margin-top: 0.6rem;
text-decoration: none;
}
.standings h2 {
font-size: 1.1rem;
font-style: italic;
margin: 0.75rem 0 0.4rem;
}
.standings ul {
list-style: none;
margin: 0;
padding: 0;
}
.standings li {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
</style>
+500
View File
@@ -0,0 +1,500 @@
<script lang="ts">
// The hall: the landing page every game in the kit shares. Name, play the
// bot, open a table, join by code, the ledger of your tables, and the
// about panel with the rules, the guide and a way to reach the keeper.
import { goto } from '$app/navigation';
import { allSeats, forgetSeat, type Report } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk';
import type { RoomView } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
let name = $state(playerName());
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
/** Reports this browser's seats have filed, with the keeper's replies. */
let reports = $state<Report[]>([]);
let answers = $state<Record<string, string>>({});
let answerError = $state('');
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it is.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
api.view(held.roomId, held.token)
.then((v) => (games[held.roomId] = v))
.catch(() => (games[held.roomId] = null));
}
});
// The desk is asked once per visit for every seat this browser holds.
$effect(() => {
const held = seats;
if (!held.length) return;
api.myReports(held.map((h) => ({ roomId: h.roomId, token: h.token })))
.then((r) => (reports = r.reports.sort((a, b) => (a.at < b.at ? 1 : -1))))
.catch(() => (reports = []));
});
async function open(make: () => Promise<Room>) {
busy = true;
error = '';
try {
rememberName(name);
const room = await make();
await goto(`/join/${room.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The table could not be opened.';
} finally {
busy = false;
}
}
function join(e: SubmitEvent) {
e.preventDefault();
rememberName(name);
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
function forget(roomId: string) {
forgetSeat(roomId);
seats = allSeats();
}
function status(held: { roomId: string; seat: string }): string {
const g = games[held.roomId];
if (g === undefined) return 'looking';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) {
if (g.over.winner === held.seat) return 'you won';
if (g.over.winner === null) return 'a draw';
return `${game.nameOf(g.state, g.over.winner)} won`;
}
const round = `round ${g.turn}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
}
function unread(held: { roomId: string }): number {
const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
}
function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId];
return !!g?.state && !g.over && g.waitingOn.includes(held.seat);
}
async function answer(r: Report) {
const text = (answers[r.id] ?? '').trim();
const held = seats.find((h) => h.roomId === r.roomId);
if (!text || !held) return;
answerError = '';
try {
await api.answerReport(r.id, r.roomId, held.token, text);
r.thread.push({ at: new Date().toISOString(), text, from: 'player' });
answers[r.id] = '';
} catch (e) {
answerError = e instanceof Error ? e.message : 'The answer did not reach the keeper.';
}
}
</script>
<section class="lid">
<div class="lid-head">
<h1>__NAME__</h1>
<p class="pitch">One line that says what the game is.</p>
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a></p>
</div>
<div class="lid-play">
<label class="name">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label>
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim()))}>Play now against the bot</button>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length))}>Open a table</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
{#if error}<p class="warning">{error}</p>{/if}
</div>
<div class="lid-look" aria-hidden="true">
<!-- A still of the game at its most characteristic: a few rounds of the ledger, a board mid-play. -->
<p class="caption">A picture of the game goes here.</p>
</div>
{#if seats.length}
<div class="ledger">
<div class="ledger-head">your games</div>
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="ledger-forget" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
{#if reports.length}
<div class="ledger-head reports-head">your reports to the keeper</div>
{#each reports as r (r.id)}
{@const last = r.thread[r.thread.length - 1]}
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<p class="report-text">{r.happened}{#if r.image}<span class="muted" title="with your screenshot"> (with a picture)</span>{/if}</p>
{#each r.thread as line, j (j)}
{#if line.from === 'player'}
<p class="report-reply yours">you: {line.text}</p>
{:else}
<p class="report-reply"><span class="report-status">{line.status}</span> {line.text}</p>
{/if}
{/each}
{#if !last}
<p class="report-reply pending">the keeper is studying the moment</p>
{:else if last.from === 'player'}
<p class="report-reply pending">your word is with the keeper</p>
{/if}
{#if r.thread.some((l) => l.from === 'desk')}
<form class="report-answer" onsubmit={(e) => { e.preventDefault(); void answer(r); }}>
<input type="text" bind:value={answers[r.id]} maxlength="2000" placeholder="answer the keeper" aria-label="answer the keeper" />
<button type="submit" class="quiet" disabled={!(answers[r.id] ?? '').trim()}>Send</button>
</form>
{/if}
</div>
</div>
{/each}
{#if answerError}<p class="warning">{answerError}</p>{/if}
{/if}
</div>
{/if}
<details class="about" id="about" bind:open={aboutOpen}>
<summary>about this game</summary>
<div class="about-body">
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
<h3>Send word</h3>
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
<p class="made">__NAME__ is its designer's. This page was made by Eric and a very enthusiastic AI, 2026.</p>
</div>
</details>
</section>
<style>
.lid {
width: 100%;
max-width: 54rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
gap: 1.2rem 2.2rem;
}
@media (min-width: 901px) {
.lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
}
}
.lid-head {
grid-area: head;
text-align: center;
}
h1 {
font-size: clamp(2.6rem, 6vw, 3.6rem);
font-weight: 300;
letter-spacing: -0.01em;
}
.pitch {
font-style: italic;
font-size: 1.2rem;
margin-top: 0.3rem;
}
.tag {
color: var(--bone-dim);
max-width: 40em;
margin: 0.6rem auto 0.4rem;
font-size: 0.95rem;
}
.links {
font-size: 0.95rem;
color: var(--bone-faint);
}
.links a {
color: var(--frost);
}
.about-link {
font-style: italic;
text-decoration: underline dotted;
}
.lid-play {
grid-area: play;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.name {
display: block;
}
.name span {
display: block;
font-size: 0.8rem;
letter-spacing: 0.12em;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.name input {
width: 100%;
}
.commit.primary {
width: 100%;
text-align: center;
}
.small {
font-size: 0.85rem;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.or {
color: var(--bone-dim);
font-style: italic;
font-size: 0.95rem;
}
.code {
width: 6.5em;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.lid-look {
grid-area: look;
display: grid;
place-items: center;
min-height: 8rem;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 6px;
}
.caption {
font-size: 0.85rem;
color: var(--bone-dim);
text-align: center;
}
/* the games ledger */
.ledger {
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-move {
background: rgba(240, 165, 58, 0.08);
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.9rem;
min-width: 0;
color: inherit;
text-decoration: none;
padding: 0.25rem 0;
}
.ledger-resume:hover .ledger-code {
color: var(--frost);
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
flex-shrink: 0;
}
.ledger-info {
color: var(--bone-dim);
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.talk-new {
color: var(--frost);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.4rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.reports-head {
margin-top: 0.9rem;
}
.report-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.45rem 0;
border-top: 1px solid var(--rule);
}
.report-body {
min-width: 0;
flex: 1;
}
.report-text {
font-size: 0.95rem;
}
.report-reply {
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.2rem;
}
.report-reply.yours {
font-style: italic;
}
.report-reply.pending {
font-style: italic;
color: var(--bone-faint);
}
.report-status {
color: var(--ember);
font-style: italic;
}
.report-answer {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
.report-answer input {
flex: 1;
min-width: 0;
}
/* about */
.about {
grid-area: about;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--bone-dim);
}
.about-body {
max-width: 44em;
color: var(--bone-dim);
padding: 0.6rem 0 0.2rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.about-body a {
color: var(--frost);
}
.about-body h3 {
font-size: 1rem;
font-style: italic;
margin: 0.9rem 0 0.2rem;
color: var(--bone);
}
.about-body .made {
font-size: 0.85rem;
color: var(--bone-faint);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import TableTalk from './TableTalk.svelte';
import type { Room } from '$lib/net/room.svelte';
let { room, link }: { room: Room; link: string } = $props();
const v = $derived(room.view);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const seatFree = $derived(v.seats.length < v.size);
let copied = $state(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>
<section class="lobby">
{#if room.spectating}
<h2>At the table</h2>
{#if seatFree}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {v.roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat. Anyone who arrives once the game 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 v.seats as s (s.id)}
<li>
<span class="seat-name">{s.name}</span>
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}</span>
</li>
{/each}
<li class="empty muted">{v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}</li>
</ul>
{#if !room.spectating}
<div class="ways">
{#if seatFree}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button>
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
{:else}
<span class="muted">{hostName} will begin when the table is ready.</span>
{/if}
</div>
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="lobby-talk"><TableTalk {room} /></div>
</section>
<style>
.lobby {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 2rem;
}
.lobby h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.lobby p + p {
margin-top: 0.6rem;
}
.roster {
list-style: none;
margin: 0.9rem 0 0.6rem;
padding: 0;
max-width: 28em;
}
.roster li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
.roster li.empty {
border-bottom: 0;
justify-content: flex-start;
font-style: italic;
}
.seat-name {
font-weight: 500;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.9rem;
margin-top: 0.4rem;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
code {
font-family: var(--font);
background: var(--slate);
padding: 0.3rem 0.6rem;
border-radius: 4px;
word-break: break-all;
}
.lobby-talk {
max-width: 28em;
margin-top: 1rem;
}
</style>
@@ -0,0 +1,165 @@
<script lang="ts">
import { api, type Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
let happened = $state('');
let expected = $state('');
let image = $state<File | null>(null);
let preview = $state<string | null>(null);
let busy = $state(false);
let sent = $state(false);
let error = $state('');
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (preview) URL.revokeObjectURL(preview);
if (file && file.size > 2_500_000) {
error = 'That picture is over 2.5 MB; a smaller one, please.';
image = null;
preview = null;
return;
}
error = '';
image = file;
preview = file ? URL.createObjectURL(file) : null;
}
function close() {
open = false;
if (preview) URL.revokeObjectURL(preview);
happened = '';
expected = '';
image = null;
preview = null;
sent = false;
error = '';
}
async function send(e: SubmitEvent) {
e.preventDefault();
const r = room;
if (!happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
sent = true;
setTimeout(close, 2200);
} catch (err) {
error = err instanceof Error ? err.message : 'The report did not reach the keeper.';
} finally {
busy = false;
}
}
</script>
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
<h2 id="report-heading">Something amiss?</h2>
{#if sent}
<p class="thanks">Recorded beside the room's ledger, with the round. Thank you; the keeper will study the moment.{#if error} {error}{/if}</p>
{:else}
<label class="field">
<span>What happened?</span>
<textarea bind:value={happened} rows="3" maxlength="2000" placeholder="e.g. my move went in but nothing changed"></textarea>
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. a point for me"></textarea>
</label>
<label class="field picture">
<span>A screenshot, if you have one</span>
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small">The room code and the round ride along; the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
<button type="button" class="link" onclick={close}>never mind</button>
</div>
{/if}
</form>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 30rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.6rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.7rem;
}
.picture input {
font-size: 0.9rem;
}
.preview {
max-height: 8rem;
max-width: 100%;
border: 1px solid var(--rule);
border-radius: 3px;
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
margin-top: 0.6rem;
}
.warning {
color: var(--blood);
margin-top: 0.4rem;
}
.thanks {
color: var(--bone);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.9rem;
}
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style>
@@ -0,0 +1,109 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props();
const lines = $derived(room.view.chat);
let draft = $state('');
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table.
$effect(() => {
void lines.length;
void tick().then(() => {
if (list) list.scrollTop = list.scrollHeight;
});
});
async function say(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
sending = true;
if (await room.say(text)) draft = '';
sending = false;
}
</script>
<section class="talk" aria-label="Table talk">
<h2>Table talk{#if room.view.audience > 0}<span class="audience">{room.view.audience} in the gallery</span>{/if}</h2>
<ul bind:this={list}>
{#if lines.length === 0}
<li class="muted">Nobody has said a word.</li>
{/if}
{#each lines as line, i (line.at + ':' + i)}
<li class:you={line.id === room.me}><em>{line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
{/each}
</ul>
{#if room.spectating}
<p class="muted small">The gallery listens; only seated players speak.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
<button type="submit" class="quiet" disabled={!draft.trim() || sending}>Say</button>
</form>
{/if}
</section>
<style>
.talk {
padding: 0.75rem 0;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.4rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.audience {
font-size: 0.8rem;
font-style: normal;
color: var(--bone-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.4;
}
li + li {
margin-top: 0.2rem;
}
li em {
color: var(--bone-dim);
}
li.you em {
color: var(--frost);
}
form {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
input {
flex: 1;
min-width: 0;
}
.small {
font-size: 0.85rem;
margin-top: 0.4rem;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_RULES, createGame, game, resolveRound } from './index';
describe('High Card', () => {
it('scores the highest number named by exactly one seat', () => {
let s = createGame({ A: 'Ann', B: 'Bo', C: 'Cy' }, 1);
s = resolveRound(s, { A: { pick: 5 }, B: { pick: 5 }, C: { pick: 2 } });
expect(s.rounds[0].scorer).toBe('C');
expect(s.players.C.points).toBe(1);
});
it('ends when a seat reaches the target', () => {
let s = createGame({ A: 'Ann', B: 'Bo' }, 1);
for (let i = 0; i < 3; i++) s = resolveRound(s, { A: { pick: 3 }, B: { pick: 1 } });
expect(s.over?.winner).toBe('A');
expect(game.needsInput(s, 'A')).toBe(false);
});
it('replays to the same bot picks from the same seed', () => {
const a = createGame({ A: 'Ann', B: 'Bo' }, 42);
const b = createGame({ A: 'Ann', B: 'Bo' }, 42);
expect(game.botInput(a, 'B')).toEqual(game.botInput(b, 'B'));
});
it('stamps the rules revision', () => {
expect(createGame({ A: 'Ann', B: 'Bo' }).rules).toBe(CURRENT_RULES);
expect(createGame({ A: 'Ann', B: 'Bo' }, 1, 1).rules).toBe(1);
});
});
+95
View File
@@ -0,0 +1,95 @@
// The demo game, "High Card": every round each seat names a number from one
// to five; the highest number named by exactly one seat scores it a point,
// and the first to three points wins. It exists to show the shape of a game
// this kit can run: simultaneous hidden moves, a seeded bot, a view that
// withholds the round in progress, and a rules revision. Replace this file
// with your own game and keep the GameSpec shape.
import { SPECTATOR, type GameSpec, type Outcome, type SeatId } from './spec';
export const CURRENT_RULES = 1;
export const TARGET = 3;
export const HIGHEST = 5;
export interface Player {
id: SeatId;
name: string;
points: number;
}
export interface Round {
picks: Record<SeatId, number>;
scorer: SeatId | null;
}
export interface State {
rules: number;
seats: SeatId[];
players: Record<SeatId, Player>;
rounds: Round[];
over: Outcome | null;
rng: number;
}
export type Input = { pick: number };
/** Mulberry32: a small seeded generator, so a game replays to the same bot picks. */
function next(state: State): number {
let t = (state.rng += 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
state.rng = state.rng >>> 0;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function createGame(names: Record<SeatId, string>, seed = Date.now(), rules = CURRENT_RULES): State {
const seats = Object.keys(names);
return {
rules,
seats,
players: Object.fromEntries(seats.map((id) => [id, { id, name: names[id], points: 0 }])),
rounds: [],
over: null,
rng: seed >>> 0 || 1
};
}
export function resolveRound(previous: State, inputs: Record<SeatId, Input>): State {
const state = structuredClone(previous);
if (state.over) return state;
const picks: Record<SeatId, number> = {};
for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1;
const counts = new Map<number, SeatId[]>();
for (const id of state.seats) counts.set(picks[id], [...(counts.get(picks[id]) ?? []), id]);
let scorer: SeatId | null = null;
for (let n = HIGHEST; n >= 1 && !scorer; n--) {
const who = counts.get(n);
if (who?.length === 1) scorer = who[0];
}
if (scorer) state.players[scorer].points += 1;
state.rounds.push({ picks, scorer });
if (scorer && state.players[scorer].points >= TARGET) {
state.over = { winner: scorer, reason: `${state.players[scorer].name} reached ${TARGET} points.` };
}
return state;
}
export const game: GameSpec<State, Input> = {
seatIds: 'ABCDEFGH'.split(''),
minSeats: 2,
botNames: ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'],
currentRules: CURRENT_RULES,
create: createGame,
resolve: resolveRound,
needsInput: (state) => !state.over,
over: (state) => state.over,
turn: (state) => state.rounds.length + 1,
// Every finished round is public; nothing is hidden, so the gallery sees what a seat sees.
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
botInput: (state) => ({ pick: 1 + Math.floor(next(structuredClone(state)) * HIGHEST) }),
cleanInput: (raw) => {
const pick = Number((raw as { pick?: unknown })?.pick);
return { pick: Number.isInteger(pick) && pick >= 1 && pick <= HIGHEST ? pick : 1 };
},
nameOf: (state, seat) => state.players[seat]?.name ?? seat
};
+48
View File
@@ -0,0 +1,48 @@
// The contract between a game and everything else in this kit. The server,
// the client store and the hall know nothing about the game beyond this
// file: they create a state from names and a seed, feed it one round of
// inputs at a time, ask who still has to move, and hand each viewer the
// view they are allowed to see. A new game implements GameSpec once, in
// src/lib/game/index.ts, and the rest of the kit works unchanged.
/** A seat at the table: a single letter from GameSpec.seatIds. */
export type SeatId = string;
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
export const SPECTATOR: SeatId = '';
export interface Outcome {
winner: SeatId | null;
reason: string;
}
export interface GameSpec<State, Input> {
/** Seats in table order; the table takes at most this many. */
seatIds: SeatId[];
minSeats: number;
/** Names the server draws for bots, in preference order. */
botNames: string[];
/**
* The rules revision new games begin under. A game keeps the revision it
* started with, recorded on its ledger, so a later fix can keep the old
* path for old ledgers behind `state.rules < N`. Bump only when the deploy
* gate shows a fix changes how an already-played turn resolves.
*/
currentRules: number;
create(names: Record<SeatId, string>, seed: number, rules: number): State;
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
resolve(state: State, inputs: Record<SeatId, Input>): State;
/** Whether this seat's input is needed before the next resolution. */
needsInput(state: State, seat: SeatId): boolean;
over(state: State): Outcome | null;
/** The round being written, counted from one, for the ledger and the report pin. */
turn(state: State): number;
/** What one viewer may see. The server sends nothing else. */
view(state: State, viewer: SeatId): State;
botInput(state: State, seat: SeatId): Input;
/** Only the shapes the engine understands get through; the engine validates the rest. */
cleanInput(raw: unknown): Input;
/** A seat's name from the state, for the hall and the chronicle. */
nameOf(state: State, seat: SeatId): string;
}
+140
View File
@@ -0,0 +1,140 @@
// The browser's side of the game server: a few JSON calls, and the seats this
// browser holds, remembered so a shared link never has to carry a token.
import type { SeatId } from '$lib/game/spec';
import type { RoomView } from './view';
export interface HeldSeat {
seat: SeatId;
token: string;
}
const SEATS_KEY = '__SLUG__:seats';
function heldSeats(): Record<string, HeldSeat> {
try {
return JSON.parse(localStorage.getItem(SEATS_KEY) ?? '{}') as Record<string, HeldSeat>;
} catch {
return {};
}
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
/** Every seat this browser holds, newest last. */
export function allSeats(): { roomId: string; seat: SeatId; token: string }[] {
return Object.entries(heldSeats()).map(([roomId, h]) => ({ roomId, ...h }));
}
export function forgetSeat(roomId: string): void {
try {
const seats = heldSeats();
delete seats[roomId];
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
} catch {
// Nothing to forget without storage.
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
} catch {
// Without storage the seat lasts for this page only.
}
}
export class ServerError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
return data as T;
}
/** A report to the keeper, with the exchange under it. */
export interface Report {
id: string;
at: string;
roomId: string;
player: string;
seat: SeatId | null;
turn: number | null;
happened: string;
expected: string;
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
image?: string;
}
export function makeApi<State, Input>() {
type Seated = { seat: SeatId; token: string; view: RoomView<State> };
type View = RoomView<State>;
return {
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
addBot: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/bot`, { token }),
begin: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/begin`, { token }),
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
/** The gallery's view: no token, no seat, nothing any player could not see. */
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: Input) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => {
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
},
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
};
}
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null;
let closed = false;
let delay = 1000;
const open = () => {
if (closed) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const seat = token ? `&token=${encodeURIComponent(token)}` : '';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}${seat}`);
socket.onopen = () => {
delay = 1000;
onStatus(true);
onUpdate();
};
socket.onmessage = (e) => {
try {
if (JSON.parse(String(e.data)).type === 'update') onUpdate();
} catch {
// Not a message this client understands.
}
};
socket.onclose = () => {
onStatus(false);
if (!closed) setTimeout(open, delay);
delay = Math.min(delay * 2, 30000);
};
};
open();
return () => {
closed = true;
socket?.close();
};
}
+150
View File
@@ -0,0 +1,150 @@
// The reactive room a page shows: the seat this browser holds (or the
// gallery), the table, the talk, and the game's view. It knows the game only
// through GameSpec; a game's own store wraps or extends this one.
import { game } from '$lib/game';
import { SPECTATOR, type SeatId } from '$lib/game/spec';
import type { Input, State } from '$lib/game';
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import type { RoomView } from './view';
export const api = makeApi<State, Input>();
export const NAME_MAX = 24;
const NAME_KEY = '__SLUG__:name';
export function playerName(): string {
try {
return localStorage.getItem(NAME_KEY) ?? '';
} catch {
return '';
}
}
export function rememberName(raw: string): void {
const name = raw.trim().slice(0, NAME_MAX);
try {
if (name) localStorage.setItem(NAME_KEY, name);
else localStorage.removeItem(NAME_KEY);
} catch {
// The name still applies to this visit.
}
}
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
export function listText(names: string[]): string {
if (names.length <= 1) return names[0] ?? '';
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
export class Room {
view = $state<RoomView<State>>(null!);
token: string;
connected = $state(false);
error = $state('');
/** A move is on its way to the server. */
sending = $state(false);
constructor(view: RoomView<State>, token: string) {
this.view = view;
this.token = token;
}
me = $derived(this.view.me);
roomId = $derived(this.view.roomId);
/** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.view.me === SPECTATOR);
started = $derived(this.view.state !== null);
state = $derived(this.view.state);
isHost = $derived(!this.spectating && this.view.host === this.view.me);
/** Your move is in and the round waits on someone else. */
moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me));
yourMove = $derived(this.started && !this.view.over && this.view.waitingOn.includes(this.me));
/** Names of the seats the round is waiting on, yours aside. */
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
awaitingText = $derived(listText(this.awaiting));
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
nameOf(id: SeatId): string {
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);
}
apply(view: RoomView<State>): void {
this.view = view;
markTalkSeen(view.roomId, view.chat.length);
}
/** Fetch the view now, and keep fetching whenever the server says something changed. */
connect(): () => void {
const fetchView = () => (this.spectating ? api.watch(this.roomId) : api.view(this.roomId, this.token));
const refresh = () => {
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (this.error = e.message));
};
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
}
private async act(fn: () => Promise<RoomView<State>>, failure: string): Promise<boolean> {
this.error = '';
try {
this.apply(await fn());
return true;
} catch (e) {
this.error = e instanceof Error ? e.message : failure;
return false;
}
}
begin(): Promise<boolean> {
return this.act(() => api.begin(this.roomId, this.token), 'The game could not begin.');
}
addBot(): Promise<boolean> {
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.');
}
say(text: string): Promise<boolean> {
if (this.spectating) return Promise.resolve(false);
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
}
async submit(input: Input): Promise<boolean> {
this.sending = true;
try {
return await this.act(() => api.turn(this.roomId, this.token, input), 'The move did not reach the server.');
} finally {
this.sending = false;
}
}
/** Open a table on the server, seated as its host; it begins when the host says. */
static async create(name: string, size: number): Promise<Room> {
const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return new Room(seated.view, seated.token);
}
/** A room with a bot already seated, so a solo game is a ledger like any other. */
static async createWithBot(name: string): Promise<Room> {
const seated = await api.create(name, 2);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return new Room(await api.addBot(seated.view.roomId, seated.token), seated.token);
}
static async join(roomId: string, name: string): Promise<Room> {
const seated = await api.join(roomId, name);
rememberSeat(roomId, { seat: seated.seat, token: seated.token });
return new Room(seated.view, seated.token);
}
/** Return to a seat this browser already holds. */
static async resume(roomId: string, held: HeldSeat): Promise<Room> {
return new Room(await api.view(roomId, held.token), held.token);
}
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
static async watch(roomId: string): Promise<Room> {
return new Room(await api.watch(roomId), '');
}
}
+25
View File
@@ -0,0 +1,25 @@
// How much of each table's talk this browser has read, so the hall can say what is new.
const SEEN_KEY = '__SLUG__:talk-seen';
function seen(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record<string, number>;
} catch {
return {};
}
}
export function markTalkSeen(roomId: string, count: number): void {
try {
const all = seen();
if ((all[roomId] ?? 0) >= count) return;
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...all, [roomId]: count }));
} catch {
// Without storage every line reads as new, which is the safe side.
}
}
export function unreadTalk(roomId: string, count: number): number {
return Math.max(0, count - (seen()[roomId] ?? 0));
}
+32
View File
@@ -0,0 +1,32 @@
// What the server tells a viewer about a room. The game's own state is
// whatever GameSpec.view returned for that viewer; nothing else leaves.
import type { Outcome, SeatId } from '../game/spec';
/** One line of table talk. Only seated players speak; the gallery listens. */
export interface ChatLine {
id: SeatId;
text: string;
at: number;
}
export interface RoomView<State> {
roomId: string;
seq: number;
/** The most seats the table will take. */
size: number;
/** The viewer's seat, or SPECTATOR for the gallery. */
me: SeatId;
/** The player who opened the table and may begin the game. */
host: SeatId;
seats: { id: SeatId; name: string; bot: boolean }[];
/** Seats that still have to move before the round resolves. */
waitingOn: SeatId[];
/** The round being written, from one; null before the game begins. */
turn: number | null;
over: Outcome | null;
state: State | null;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
}
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
+4
View File
@@ -0,0 +1,4 @@
// Everything happens in the browser against the game server, so nothing is rendered on the
// server. Each route is prerendered as an empty shell that the client fills in.
export const ssr = false;
export const prerender = true;
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import Hall from '$lib/components/Hall.svelte';
</script>
<svelte:head>
<title>__NAME__</title>
</svelte:head>
<div class="hall">
<Hall />
</div>
<style>
.hall {
min-height: 100vh;
display: grid;
place-items: start center;
padding: clamp(1rem, 4vw, 3rem) var(--gutter) 3rem;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
// How to play: a walk through the page in the game's own voice, one
// screenshot per section, from the hall to a first game. Capture the
// figures from the running game into static/guide/ and describe what a
// new player is looking at, not what the code does.
</script>
<svelte:head>
<title>__NAME__: how to play</title>
</svelte:head>
<main class="guide">
<header>
<a class="back" href="/">Back to the hall</a>
<h1>How to play</h1>
<p class="lede">Two sentences that set the scene and say what this page walks through.</p>
<nav aria-label="Sections">
<a href="#hall">The hall</a>
<a href="#table">The table</a>
<a href="#board">The board</a>
<a href="#gallery">The Peanut Gallery and table talk</a>
<a href="#first">A first game</a>
</nav>
</header>
<section id="hall">
<h2>The hall</h2>
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the house's construct. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
</section>
<section id="table">
<h2>The table</h2>
<p>A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair, and they begin the game when the company suits them. Once begun, the seats are closed.</p>
</section>
<section id="board">
<h2>The board</h2>
<p>What the player sees during play, section by section, with a figure for each.</p>
</section>
<section id="gallery">
<h2>The Peanut Gallery and table talk</h2>
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated; a line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
</section>
<section id="first">
<h2>A first game</h2>
<p>The advice you would give a friend across the table for their first few turns.</p>
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or the page misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>; the hall's <em>about this game</em> has the other ways to reach the keeper.</p>
</section>
</main>
<style>
.guide {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 40em;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1rem;
margin: 1rem 0 0.5rem;
font-size: 0.95rem;
}
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
p + p {
margin-top: 0.7rem;
}
</style>
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
// Room codes are not known at build time; the app shell serves this route.
export const prerender = false;
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
// The rules page: the game's own text, structured for reading mid-game.
// Keep the original rules text verbatim in docs/ and render from it
// where you can, so the page and the engine follow one source.
</script>
<svelte:head>
<title>__NAME__: the rules</title>
</svelte:head>
<main class="rules">
<header>
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/guide">How to play</a>
<h1>The rules</h1>
<p class="lede">One paragraph on where these rules come from and which edition or text this page follows.</p>
</header>
<section id="turn">
<h2>A turn</h2>
<p>What each player does on a turn, in order, in short paragraphs. Put the thing a player checks mid-game first.</p>
</section>
<section id="winning">
<h2>Winning</h2>
<p>How the game ends and who wins, including draws.</p>
</section>
<section id="source">
<h2>The original text</h2>
<p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p>
</section>
<p class="word">If this page reads a rule differently from the original text, that is a bug: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
</main>
<style>
.rules {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 40em;
}
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
.word {
margin-top: 2rem;
color: var(--bone-faint);
font-size: 0.9rem;
}
</style>