The voice: every game speaks in its own world

Conventions gain a section on in-game language: an image with every
fact, the fact exact, unfolding effects told a turn at a time and naming
the turn they end on, our own words rather than an older version's, and
chronicle wording shipped without a rules revision. new-game.sh points
at it, and the demo game's lines follow it. Born in Waving Hands'
atmospheric chronicle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-24 23:57:59 -04:00
co-authored by Claude Opus 5.5
parent bce01a013a
commit cd9d14ec35
4 changed files with 28 additions and 3 deletions
+23
View File
@@ -158,6 +158,29 @@ emoji in the interface; the words do the work. Phone width first: a sticky
bar for the move if the board runs long, lists that shorten, gutters of bar for the move if the board runs long, lists that shorten, gutters of
16px, no horizontal scroll. 16px, no horizontal scroll.
## The voice
Every game speaks in its own world. Wherever the page tells a player what
happened, it gives an image as well as the fact: not "Corwin takes 5 from
a fireball" but "A fireball bursts over Corwin, for 5". The fact stays
exact and whole (the number, the duration, who is affected, the turn a
thing will happen), and the image carries it.
- **The chronicle** is where the voice matters most. Each move, blow,
enchantment, arrival and death gets its own picture. Something that
unfolds over turns (an illness, a siege, a fuse) is told a turn at a
time, names the turn it ends on when it begins, and warns on the last
turn anything can be done about it.
- **The rest of the surround** speaks the same way: the lobby, the guide,
the status lines, the verdict. Plain words for controls and buttons;
the images belong to what happens, not to what you press.
- **Our own words.** An older implementation's prose is a guide to the
tone, never a source to copy.
- **Changing text is not changing rules.** Chronicle wording is not part
of the ledger, so it ships without a rules revision; the deploy gate
compares outcomes, not sentences. A test that pins a phrase should pin
the fact inside it.
## Things learned the hard way ## Things learned the hard way
- Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the - Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the
+2
View File
@@ -37,6 +37,8 @@ Next:
and src/lib/components/Board.svelte with its board. Keep the tests green. and src/lib/components/Board.svelte with its board. Keep the tests green.
2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte), 2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte),
the rules page, the guide, src/app.html's description, and the palette in src/app.css. the rules page, the guide, src/app.html's description, and the palette in src/app.css.
Write every line a player reads in the game's own voice: an image with each
fact (docs/conventions.md, "The voice").
Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180): Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180):
app.html already points at them, and the static fallback serves the hall's app.html already points at them, and the static fallback serves the hall's
HTML for a missing file, so a share card without the image breaks quietly. HTML for a missing file, so a share card without the image breaks quietly.
+2 -2
View File
@@ -56,12 +56,12 @@
{:else if room.spectating} {:else if room.spectating}
<section class="move"> <section class="move">
<h2>You watch from the Peanut Gallery</h2> <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> <p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
</section> </section>
{:else} {:else}
<section class="move"> <section class="move">
<h2>Round {g.rounds.length + 1}: name a number</h2> <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> <p class="muted">Every player lays a card face down; the highest card nobody else laid takes the point. First to {TARGET}.</p>
<div class="picks"> <div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)} {#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> <button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
+1 -1
View File
@@ -68,7 +68,7 @@ export function resolveRound(previous: State, inputs: Record<SeatId, Input>): St
if (scorer) state.players[scorer].points += 1; if (scorer) state.players[scorer].points += 1;
state.rounds.push({ picks, scorer }); state.rounds.push({ picks, scorer });
if (scorer && state.players[scorer].points >= TARGET) { if (scorer && state.players[scorer].points >= TARGET) {
state.over = { winner: scorer, reason: `${state.players[scorer].name} reached ${TARGET} points.` }; state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
} }
return state; return state;
} }