Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
345 lines
20 KiB
Markdown
345 lines
20 KiB
Markdown
# House conventions for a game in this kit
|
|
|
|
Every game built from the kit shares one shape, so a player who has been at
|
|
one table knows the next, and the keeper runs them all the same way. This is
|
|
the shape. When a game strays from it, the reason should be in the game's
|
|
README.
|
|
|
|
## The vocabulary
|
|
|
|
- **The hall** is the landing page (`/`). Name, *Play now against the bot*,
|
|
*Open a table*, *or join one* with a four-letter code, then *your games*
|
|
(the ledger of tables this browser holds a seat at, with whose move it
|
|
is and unread talk; for someone coming back it comes first, and finished
|
|
games wait further down), *your reports to the keeper*, and *about this game*,
|
|
which opens the about panel: a dialog of tabs (Origins, Games so far,
|
|
More games, Send word; a game may rename the first two in its own world,
|
|
as Waving Hands' Duels so far, or add its own, as Dip's Playing here), `TabbedDialog.svelte`, with each game's words in its hall.
|
|
The address names the dialog (`#about`, `#about/tally`), so a plain link
|
|
opens it; a game may put its guide and rules in dialogs the same way, as
|
|
Dip does. Its strip of tabs is `Tabs.svelte`.
|
|
- **A table** is a room. Its code is four letters or digits from an alphabet
|
|
with no look-alikes, and its link is `/join/CODE`. The masthead button
|
|
reads *invite friends · room CODE* and copies the link. Whoever opened
|
|
the table is **the host**; they may *Seat a bot* and they *Begin* when the
|
|
company suits them, at any size of table; there is no automatic start.
|
|
- **The Peanut Gallery** is where a link takes anyone without a seat: a
|
|
read-only view showing only what every seat could see, counted for the
|
|
table (*3 in the gallery*). While the table is laid, a watcher is offered
|
|
a seat first; the gallery is the afterthought.
|
|
- **Table talk** is the chat panel, seated players only unless the host
|
|
*lets the Peanut Gallery talk*; then a watcher signs a name no seat holds
|
|
and their lines read *Name (gallery)*. Each line is a ledger line, so it
|
|
replays with the game, and the hall counts lines said while you were away.
|
|
A line said while a game is running carries its round (`turn`). A seat
|
|
may also speak privately, naming seats (`room.say(text, to)`): the line
|
|
is shown to its sender and those seats alone, never to the gallery, and
|
|
a game whose talk is its heart puts the choice of whom in its talk box.
|
|
- **A rematch**: a finished table may call for one. A new table of the same
|
|
size opens with the same bots, seats held for the same people, and the
|
|
caller as host; a second call finds it already open. Held seats refuse
|
|
strangers until their players are back.
|
|
- **The keeper's bell**: while a table is laid, a seated player may
|
|
*Challenge the keeper*. A seat is held under the keeper's name, the call
|
|
goes on the ledger, and the keeper's phone rings through Sentry. The note
|
|
beside the button says what hour it is where the keeper lives, so a
|
|
caller knows whether to wait ten minutes or come back tomorrow.
|
|
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
|
|
words good for ten minutes and one use; the hall's *bring a seat from
|
|
another device* claims them. The browser that claims holds the seat too.
|
|
- **The keeper can bring a seat back**: a player whose browser lost the
|
|
seat (Safari's seven days, an app's own browser, cleared data) writes to
|
|
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
|
|
a new token and prints a link carrying it in the fragment. The old token
|
|
opens nothing after. The script answers through the game server's
|
|
`/keeper` door, which Caddy does not proxy. A spectator at a game in play
|
|
is told both ways back, and the hall asks before forgetting a game in play.
|
|
- **Held seats are doubted before they are dropped**: the hall forgets a
|
|
seat only on the second refusal in a row from the server. A restart, a
|
|
stale answer or the wrong backend answer with ignorance, and ignorance
|
|
is not deletion.
|
|
- **The ledger** is the append-only JSONL file that *is* the game: room,
|
|
seats, start (seed and rules revision), each seat's submitted input, the
|
|
turn that resolves them, talk, over. A room is its ledger replayed
|
|
through a deterministic engine; nothing else is saved. A half-written
|
|
round survives a restart because every input is written as it arrives.
|
|
Ledgers survive deploys and restarts and are backed up nightly, so they
|
|
hold nothing secret: a seat line carries the SHA-256 of its token, never
|
|
the token, which goes once to the browser that earned it.
|
|
- **Report** in the masthead opens the slip: what happened, what you
|
|
expected, a screenshot if you have one. It is pinned to the room and the
|
|
round. **The keeper** replies; the reply appears under the report in the
|
|
player's hall, and the player may answer. Every report and answer rings
|
|
the keeper's phone through Sentry; the desk's own replies ring nothing.
|
|
- **The rules page** (`/rules`) is the game's text, structured for reading
|
|
mid-game, with the original source verbatim in collapsible sections. The
|
|
engine follows that text and nothing else; a divergence is a bug. The
|
|
page ends with *send word*.
|
|
- **How to play** (`/guide`) walks the page in the game's own voice, one
|
|
screenshot per section, from the hall to a first game.
|
|
- **Send word** is the contact paragraph: mail, Bluesky and Mastodon, and
|
|
where the keeper roosts. It lives in the about panel; the colophons, the
|
|
rules page and the guide each carry a one-line mail link.
|
|
- **The family list** in the about panel, *More games in Kestrel's Hall*,
|
|
comes from `family.json` and its heading links to the family's landing
|
|
page, gamehall.kestrelsnest.social. A new game is added to the kit's
|
|
`family.json` and given a card on that page (`gamehall/` in the kit);
|
|
both are hand-kept.
|
|
- **A move names the round it was made for.** The room counts rounds
|
|
played (`resolved` in its view), and a page sends that count with each
|
|
move; the server refuses a move whose count is behind, so a slow
|
|
connection, a second click or a stale tab can never be taken as the
|
|
move for the next round. A page without the count is told to reload.
|
|
- **The hosting line** follows the family list in the about panel: the
|
|
games are free and will stay free, the servers cost a few dollars a
|
|
month, and ko-fi.com/kestrelsnest is where anyone may chip in. It says
|
|
chipping in unlocks nothing and nothing is held back without it, and it
|
|
never names a game's rights holder: a tip is for the hosting, not the
|
|
game.
|
|
|
|
- **Preferences** are what a browser remembers about its player: a panel
|
|
opened from *preferences* in the hall and the masthead, kept in
|
|
localStorage and nowhere else. The kit's own settings are motion (full or
|
|
reduced, and the system's reduced-motion request counts too) and
|
|
confirming a move before it is sent; a game declares more in
|
|
`GameSpec.preferences` and the panel renders them. The hall also
|
|
remembers the host's last table options as the next table's defaults.
|
|
- **The tally** sits in the about panel: what the ledgers add up to
|
|
(tables, games begun and finished, names seated, turns, time at the
|
|
table, games with a bot, talk), counted on demand from the files and
|
|
cached a minute. A game adds its own lines through `GameSpec.tally` and
|
|
`GameSpec.tallyBegun`.
|
|
- **Table options** are the host's choices when opening a table (a
|
|
variant, a side, a length): the game declares them in `GameSpec.options`,
|
|
the hall renders them as selects, the room line records them, and
|
|
`create` receives them. A game with one way to play declares none. An
|
|
option that changes how many can sit (a smaller map) says so through
|
|
`GameSpec.seatsFor`. The server, the tally and the ledger replay all read
|
|
a room line through `cleanOptions` and size a table with `tableSize`,
|
|
both in `spec.ts`, so each creates the same game from the same ledger.
|
|
|
|
## The engine
|
|
|
|
- Pure and deterministic: `create(names, seed, rules, options)` and
|
|
`resolve(state, inputs)` are functions of their arguments with a seeded
|
|
generator inside the state. Bots are functions of the state too. This is
|
|
what lets a ledger be the whole record.
|
|
- Simultaneous by default: every seat that `needsInput` submits, then the
|
|
round resolves at once, bots included. A seat that owes an extra turn
|
|
alone (a time stop, a bonus move) is just a state where only it needs
|
|
input; the server resolves rounds owed only to bots straight away. A few
|
|
such rounds are played at once; past that (a table every person is out
|
|
of), the bots play on a round every two seconds so the table can be
|
|
watched and the server answers everyone else between rounds. The view
|
|
says so (`playingOn`), and a seat may ask for the rest at once
|
|
(`room.hurry()`).
|
|
- Hidden information is the engine's business: `view(state, viewer)`
|
|
returns what one seat may see, and the SPECTATOR view is the
|
|
intersection of everyone's. The server sends nothing else.
|
|
- **The rules revision** is stamped on every game at its start line. Bump
|
|
it only when the deploy gate shows a fix changes how an already-played
|
|
round resolves; keep the old path behind `state.rules < N` and pin both
|
|
with tests. A fix that diverges from no ledger ships without a bump.
|
|
|
|
## Optional hooks
|
|
|
|
A game implements only the hooks it needs; without one, the kit behaves as
|
|
if it were never there.
|
|
|
|
- `validate(state, seat, input)`: a reason to refuse a move, shown to the player.
|
|
- `seatsFor(options)`: fewer seats for some table options.
|
|
- `settled(input)`: whether an input is final. A game with drafts answers
|
|
false until the player says it is done; a draft may be replaced (and a
|
|
settled move taken back as a draft) until the round resolves, and the
|
|
round waits for every seat's settled input. A seat's view carries its
|
|
own input in waiting (`yourInput`), so a page reopened shows the draft,
|
|
and its input in the last round it played (`yourLastInput`), so the page
|
|
can tell it what became of its own choice.
|
|
- `rankable(options)`: the choices a table asks each seat to rank before
|
|
the deal (sides, roles, colours). The lobby shows a seat its own ranking
|
|
and how many have ranked, nothing more; the start line records every
|
|
ranking and `create` receives them. A solo table asking for one waits in
|
|
the lobby for the player's.
|
|
- `botPress(state, seat, press, people, trigger)`: bots that talk. A bot
|
|
speaks when a round opens and when a person writes to it (or says a first
|
|
word to the table); its lines go privately to the people it names, as
|
|
ledger lines, with a `note` for its own memory that no view ever carries.
|
|
`botInput` receives the same press, so a bot can keep what was agreed.
|
|
`parting(press, viewer)` gives the bots' last words once the game is
|
|
over. Without `botPress`, private talk to a bot is refused.
|
|
- `tally(state)`: the game's own lines for the tally, from a finished game;
|
|
`tallyBegun(options)`: lines from every game begun, finished or not, by
|
|
its table's options (games begun on each variant).
|
|
- `preferences`, `options`: the preferences panel's settings and the
|
|
host's table choices (above).
|
|
|
|
## The server
|
|
|
|
One Node process per game, on its own port, run from source with `tsx` as
|
|
a sandboxed systemd service, behind Caddy `handle` blocks that route `/api`
|
|
and `/ws` before the static files. Rooms in memory are evicted after a week
|
|
idle and come back from disk on the next visit. Rate limits sit on the doors
|
|
a stranger can knock on (rooms, seats, talk, the desk), and a looser one
|
|
on a seat's own actions, each a ledger line; a table of friends never nears
|
|
them. An address holds at most twenty sockets, a socket that stops
|
|
answering pings is closed, and a request has seconds, not minutes, to
|
|
arrive. A seat token is minted once, sent once, and compared by
|
|
hash in constant time ever after; a shared link carries none. Room codes
|
|
are checked against the disk as well as memory, so an evicted room's code
|
|
is never reissued over its ledger. A game may refuse a move with a reason
|
|
(`validate`), and the player sees the reason.
|
|
|
|
## Operations
|
|
|
|
Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
|
|
|
- `deploy/deploy.sh <ip>`: checks, tests, build, **the determinism gate**
|
|
(every production ledger replayed with the engine about to ship and
|
|
compared with the server; a DIFFERS or REFUSED stops the deploy), rsync,
|
|
restart. Hashed assets from the previous week stay so an open page can
|
|
still fetch what it was built against.
|
|
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
|
|
today, every room opened and how far it got. Names seated for the first
|
|
time are marked NEW.
|
|
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
|
|
acted; with a seat, a new token for a player locked out, as a link.
|
|
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
|
|
the rollup's last week, the backup's last word, the box's vitals.
|
|
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
|
|
no addresses. `<slug>-backup.sh` nightly at 07:23 UTC: rclone to the
|
|
shared Space, mirror plus dated snapshots pruned after 90 days. On the
|
|
shared host the hall's `hall-nightly.sh` runs both for every game and
|
|
checks in with Sentry Crons once for all, so a missed night is noticed.
|
|
- The reports desk (skill `<slug>-reports`): `pull-reports.sh` mirrors the
|
|
desk to the Desktop with a digest; `report-reply.sh <ip> <id> <status>
|
|
"text"` answers with `resolved`, `by-design` or `open`. A fix goes live
|
|
before its reply goes out.
|
|
- Sentry: one project per game; `sentry-slack-alert.sh` routes every issue
|
|
to `#<slug>-notifications`, run once by the keeper with their own token.
|
|
- `KEEPER` and `KEEPER_TZ` in the service unit name the keeper a table may
|
|
call and where they sleep.
|
|
|
|
## The look
|
|
|
|
Each game chooses its own palette and typeface in `src/app.css`; the
|
|
components name only the tokens. Keep the page quiet: one accent, no
|
|
animation that does not answer an action, the game itself as the hero. No
|
|
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
|
|
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.
|
|
- **Plain chrome.** Headings, tabs, captions and notes say what is there
|
|
in ordinary words. The images live in what happens, not in the labels
|
|
around it. The patterns readers take as generated text are listed under
|
|
"Writing for players" below.
|
|
- **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.
|
|
|
|
## Writing for players
|
|
|
|
Players have learned the habits of generated text, and once they spot one
|
|
they trust the rest of the page less. Every game built from this kit has
|
|
needed an editing pass to remove them. Write without them from the start,
|
|
and read each new page against this list before it ships.
|
|
|
|
- **One semicolon is fine. A semicolon in every paragraph is a tell.** Use
|
|
two sentences, or join with "and" or "so". Lists of rules may keep their
|
|
semicolons.
|
|
- **No statement, colon, explanation.** "The table is yours: seat a bot,
|
|
invite a friend" becomes "You can seat a bot or invite a friend". The
|
|
same goes for a label and a colon at the start of a line ("Shieldwall:
|
|
two or more pieces..."), and for a heading that ends in a colon.
|
|
- **No headings made of "The" and an abstract noun** ("The game", "The
|
|
tally"). Name what is there ("Origins", "Games so far").
|
|
- **A button may name its action and its cost** ("Absorb: take 2
|
|
instead"). The colon rule is for sentences, not for controls.
|
|
- **No clipped fragments in threes** as a habit. "Four towers. One crown.
|
|
No second chances." Write one sentence instead. A game may keep one
|
|
deliberate triplet as its tagline when the energy is the point, as
|
|
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
|
|
does. Once is a choice, and a pattern is a tell.
|
|
- **No closing aphorisms.** A paragraph ends on its last fact, not on a line
|
|
meant to sound wise ("This is the whole art", "Nothing is unlocked by it
|
|
and nothing is withheld without it").
|
|
- **No "X, not Y" flourishes** ("drawn, not borrowed"). Say what it is.
|
|
- **No page that talks about itself.** Avoid "This page walks the room", "the
|
|
page keeps watch" and "a walk through the page". Describe the game and
|
|
what the player does.
|
|
- **No spaced em dashes** in the interface, and few anywhere.
|
|
- **Nothing copied from another game that is untrue here.** Text carried over
|
|
from the kit or a sibling game is the most common source of errors. Look
|
|
for hidden information in a game with none, points in a game without
|
|
them, a bot's name from another game, and "both" at a table that seats
|
|
more than two. Read every inherited line as if it were new.
|
|
- **Every claim checked.** A date, a count, "only" or "never" is checked
|
|
against the source and the engine. "A thousand years before chess" was
|
|
wrong, and so was "a shield is the only defence".
|
|
- **Words for any table size.** Use "every player" for what each does on a
|
|
turn, and plurals for opponents ("your rivals"), unless the game seats
|
|
exactly two.
|
|
- **Some words are load-bearing.** Before rewording, grep for the string.
|
|
An end-of-game reason written to a ledger line is matched later by exact
|
|
text, and rewording one needs a migration. The client may match a server
|
|
error by its words. Replay screenshots kept as goldens include captions,
|
|
and stored shares carry event text. A bot's lines may be told apart by
|
|
their shape, and the examples it gives players must still parse.
|
|
- **Bots should not share scaffolding.** When each personality recites the
|
|
same clause ("I keep out of X, you keep out of Y, through 1903"), they
|
|
stop sounding like people. Give each its own way of saying the terms, and
|
|
avoid assistant phrasing ("Certainly", "Happy to help", "I hope you
|
|
understand").
|
|
|
|
The game's own voice (above) is not on this list. The chronicle's images,
|
|
the in-world names, the keeper and a game's whimsy are what the editing
|
|
passes protect. The template's plain lines are a default. A game may say
|
|
the same thing in its own voice ("The gallery listens. The host has not
|
|
opened its mouth.") as long as the fact survives.
|
|
|
|
## The move
|
|
|
|
- **Every history reads top to bottom**, oldest first, and scrolls to the
|
|
newest line: the ledger, the chronicle, table talk. That is the order
|
|
people already know from chat.
|
|
- **The move and its button stay together and in view.** Whatever the
|
|
player has chosen this turn, what it will do, any warning, and the
|
|
button that commits it sit in one row that does not scroll away.
|
|
- **Warn before, not after.** A move that is legal but wasted is pointed
|
|
out while it can still be changed, and left legal.
|
|
- **A move can be typed** when the game's moves have short names, with
|
|
Enter to commit and Backspace to take one back.
|
|
|
|
## Things learned the hard way
|
|
|
|
- Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the
|
|
proxy sits in its own `handle` block first.
|
|
- `npm install --omit=optional` on the droplet breaks Vite; the server
|
|
carries its own small `package.json` and installs only that.
|
|
- A dynamic `/join/[code]` route must set `prerender = false`.
|
|
- Two tabs on one origin share a seat; test a second player from
|
|
`127.0.0.1` against `localhost` (Vite needs `--host`).
|
|
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
|
|
- Old saved states lack fields added later: backfill on load, and bump the
|
|
save version when the shape changes.
|
|
- A token typed into a session lands in the transcript; run token-bearing
|
|
scripts yourself and revoke afterwards.
|