Files
game-kit/CONVENTIONS.md

175 lines
10 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), *your reports to the keeper*, and *about this game —
a labor of love*.
- **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 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.
- **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 in the blog repo; both are
hand-kept.
- **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
nothing is unlocked by it and nothing withheld 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`.
- **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.
## The engine
- Pure and deterministic: `create(names, seed, rules)` 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.
- 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.
## 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); a table of friends
never nears them. 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>-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. Both check
in with Sentry Crons 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.
## 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.