Begin Hnefatafl from the game kit
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
|
||||
- **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.
|
||||
Reference in New Issue
Block a user