This is the source for lynrummy.com — Steve Howell's personal website. Every line of code here was written by Claude. "Angry Gopher" is a dated inside joke; don't ask.
The site is a handful of small apps served from one self-contained zig binary — no database, storage is plain files on disk, deployed behind Caddy (TLS) under systemd. Each app has its own README as the canonical home for its design intent; this file is the developer/agent map of the whole thing.
lynrummy.com is served by gopher-metal, this repo's zig server compiled into a kernel with no operating system under it, on its own DigitalOcean droplet. The two repos stay separate:
- This repo is the program. Every route, page and app is written and
tested here, on Linux, as always (
ops/start,ops/check). - gopher-metal is the machine. Its
port.shreadszig-server/srcfrom a sibling checkout and swapsstd.Iofor metal's own, one line per file; its gates judge metal against this repo's Linux build, page by page, and run metal-vmm (the third repo, test-only). ItsREADME.mdandCUTOVER.mdsay how a release reaches the droplet. - A change to the program reaches the site as a new gopher-metal image,
not through
ops/deploy. The old Linux droplet still runs Caddy (TLS, body caps), which proxies to metal over the private network (deploy/Caddyfile); its own zig server is stopped and must stay stopped.ops/deployfails closed: it restarts that server only when~/linux-servesis on prod (deploy/README.md). Starting it again is the way back, in gopher-metal'sCUTOVER.md.
The home page (/) is a launch pad for six apps, in the display order
below. That order lives in pages/home.txt and nowhere else is
authoritative — this table mirrors it by hand, and went stale the first
time a row moved; /gallery (zig-server/src/gallery.zig) derives its
cards from the file at request time. If they disagree, pages/home.txt is
right.
| App | Path | What it is | Stack | README |
|---|---|---|---|---|
| Safari Screensaver | /driving |
A self-driving first-person motorcycle ride down a winding road, drawn from rider-relative coordinates. A Zig core (compiled to WebAssembly) computes the geometry; a tiny JS blitter fills the polygons. | Zig (WASM) + JS | games/driving/README.md |
| Chat | /chat |
Real-time chat, docs, and channels over Server-Sent Events — the live surface we use daily; a multi-page app still mostly rendered on the front end. | JavaScript + Zig | chat/README.md |
| Seattle Delivery | /delivery |
A CVRP route-planning sim — eight trucks fan out across a not-to-scale Seattle. Watch a hand-built Clarke-Wright solver think. A Zig solver (compiled to WASM) plans the routes; the TS client draws and animates them. | Zig (WASM) + TS | delivery/README.md |
| Lyn Rummy | /game |
Two-player rummy against an agent that knows the rules — a Zig solver (compiled to WASM) picks the plays and hints, a TS layer turns them into table gestures, an Elm UI plays them, all speaking a DSL over the wire. | Zig (WASM) + Elm + TS | games/lynrummy/README.md |
| Lyn Rummy Puzzles | /puzzles |
A single mid-game board to solve; shares the solver and rules, with deterministic undo and replay. | Zig (WASM) + Elm + TS | games/lynrummy/elm/src/Puzzle/README.md |
| Chess Toys | /chess |
The newest addition: Knight's Tour and Eight Queens as watchable, scrubbable backtracking searches — each search narrates onto an event tape, and the sources themselves are exhibited at /chess/code. |
Zig (WASM) + JS | games/chess/README.md |
The server is the zig implementation in
zig-server/— seeSERVER.md. (It was ported from a Go original, now removed; the routes, layout, and DSL below describe the live zig server.)
The rest of this README is for developers and agents working on the code.
bash ops/start # zig server on :9001ops/start is the canonical dev loop: it kills anything on :9001,
rebuilds the front-end bundles and WASM cores (ops/build_elm,
ops/build_delivery, ops/build_safari_wasm, ops/build_chess_wasm) and
the zig binary — which embeds them (see zig-server/build.zig) — then relaunches
and waits for the port to respond. Always use it; don't hand-roll
zig build run.
The server needs GOPHER_CONFIG pointing at a config file (data_dir +
auth); ops/start uses ~/AngryGopher/gopher.conf. All persistent data
lives under that data_dir, outside the source tree — the tree is freely
rm-able without touching data, and vice versa.
Dependencies are few, but three compilers must be present to build fresh. We pin these versions:
| Tool | Version | Builds | Install |
|---|---|---|---|
| Zig | 0.16.0 | the server (zig-server/) + the Lyn Rummy solver's WASM build (games/lynrummy/zig/ → solver.wasm) + the Safari Screensaver's WASM core (games/driving/wasm/ → games/driving/safari.wasm) + the Delivery solver's WASM build (delivery/zig/ → delivery/solver.wasm) + the Chess Toys' WASM cores (games/chess/*.zig → games/chess/*.wasm) |
system install — zig version |
| Elm | 0.19.1 | the Lyn Rummy client | npm install in games/lynrummy/elm/ (pinned in its package.json) |
| TypeScript | 6.0.3 | the Delivery display client + the Lyn Rummy DSL/geometry layer and test harnesses (both solvers are now zig; each .ts solver stays as the port reference) |
npm install in delivery/, games/lynrummy/ts/ and games/driving/ (pinned in each package.json) |
| Node | 22.18+ | runs the TS directly + hosts the npm-installed elm/tsc |
system install — node --version |
| X11 dev headers | — | only ops/build_safari_download, the free-standing Linux Safari binary |
apt install libx11-dev libxrender-dev (see below) |
TypeScript runs two ways, and only one of them is transpiled:
- Node-side — the TS engine's tests and the self-play harness run the
.tsfiles directly via Node's type-stripping, never transpiled (so a Node new enough for that is required; dev uses v24). - Browser-side — two bundles are transpiled (
esbuildbundles each into one IIFE JS file,@embedFiled into the zig binary at compile time), and both now pair with a zig brain. The Delivery sim (delivery/main.ts→delivery/app.js) builds its own canvas and owns the whole display — but the solver runs indelivery/solver.wasm(delivery/zig/,ops/build_delivery_wasm), which the bundle calls for each shift's plan. The Lyn Rummy engine bundle (games/lynrummy/ts/elm_api/engine_entry.ts→games/lynrummy/elm/engine.js) is a supporting layer, not a client — the thinking (hints, futility certificates, the agent opponent) lives in the zig solver compiled tosolver.wasm, and the TS bundle translates its answers into the DSL and table gestures (locations, drag paths) while Elm owns the UI.ops/build_delivery/ops/build_engine_jsrun these (alongside the Elm output);esbuildis a pinned local devDependency (calling its binary directly skipsnpx's ~1s-per-call resolution tax). (The Safari Screensaver used to be a pure-TS client too; it's now a Zig→WASM core + a JS blitter —ops/build_safari_wasm— and no longer transpiled. Each toy's.tssource is kept as the port reference; seeHISTORY.mdfor who sits where on the zig↔TS spectrum and why.)
tsc itself only ever typechecks (npm run typecheck) — it never emits
the JS that ships. Elm, tsc, and esbuild are all project-local (run
from each package's node_modules/.bin), so a fresh checkout needs
npm install in four places: games/lynrummy/elm/,
games/lynrummy/ts/, delivery/, and games/driving/.
Recorded 2026-08-28, standing up a second dev box, because every item below was discovered by a build failing rather than by reading this file.
- Node. The version above is the floor for unflagged type-stripping, which the node-side TS relies on. 22.23.2 works; an older 22 will not.
npm installin all four package directories — the fourth,games/driving/, was missing from this list.- Zig 0.16.0, on
PATH. Everyops/build_*script and the server itself go through it. libx11-devandlibxrender-dev, and only forops/build_safari_download.games/driving/build.zig:61-62links exactlyX11andXrender; nothing else in the tree needs a system library. The error isunable to find dynamic system library 'X11', and it arrives part-way throughops/deploy, after the front-end bundles are built and before the server is — the worst moment to learn it.- The WASM cores before
zig build.zig-server/build.zig@embedFiles three artifacts that a fresh clone does not have (they are gitignored) —games/driving/safari.wasmandgames/chess/{knight,queens}.wasm— soops/build_safari_wasmandops/build_chess_wasmmust run first (ops/startandops/deployboth do). The two solver WASMs,delivery/solver.wasmandgames/lynrummy/zig/solver.wasm, are committed; rebuild them withops/build_delivery_wasmandops/build_lynrummy_wasmonly after editing their zig. The failure isfailed to check cache: '…/safari.wasm' file_hash FileNotFound, which does not name the script that produces it.
The config is a flat key = value file (# comments). The zig server
honors exactly two keys — everything else (including any port =
line) is ignored. The listen port is :9001 unless GOPHER_PORT says
otherwise; GOPHER_BIND picks the address, and GOPHER_TRUSTED_PROXY
whose X-Forwarded-For names the client (default 127.0.0.1, the
local Caddy). All three are read in zig-server/src/server.zig.
data_dir = /home/steve/AngryGopher/local # all writable state lives here
auth_dir = /home/steve/Auth # account store; defaults to ~/Auth
data_dir holds four trees: {data_dir}/lynrummy, /chat, /users,
/players (roots.zig points them all).
auth_dir is the shared account store — one directory per uid
({auth_dir}/<id>/{name,password,api-key}) plus next-id.txt for
allocation. One uid is the same person across every surface. With the
config above, ~/Auth/1 resolves to Steve (uid 1), so a browser hits
/chat as Steve rather than getting bounced to /login/full.
Policy: keep server data OUTSIDE the repository. Point data_dir and
auth_dir at paths outside the source tree (e.g. ~/AngryGopher/… and
~/Auth, as above) — never inside the checkout. The tree stays freely
rm-able without touching data, accounts/credentials never risk being
committed, and there's nothing to .gitignore. The config file itself
also lives outside the repo (ops/start defaults to
~/AngryGopher/gopher.conf).
The site optimizes for frictionless exploration, asking for a password only where it must — at the chat boundary, which holds private data. That produces a natural progression:
STRANGER → PLAYER → FULL MEMBER
- Stranger — no cookie, no identity. Can browse public surfaces (e.g.
/driving). - Player — a name, no password, in the LOCAL player store
(
{data_dir}/players/<id>/name, seeplayer.zig). You become one by entering a name at/play; enough for Lyn Rummy and the puzzles, which need somewhere to file a board and a name to print on it. Names are not reserved, so anyone may type any name. Thegopher_uidcookie is signed (uid_cookie.zig), so one set by hand names no one; a legacy unsigned cookie is re-signed once, inside its window. Nothing behind a real gate — chat, settings, admin, uploads — resolves through this tier. - Full member — an account in
{auth_dir}with a password. Required for chat. Logging in mirrors the member into the player store under the same id, so their game history follows them.
A locally-minted player id is spelled p<n>; an account id is a bare
decimal. The two identities ride the same gopher_uid cookie, so the
spellings are disjoint on purpose, and the account resolver's guest arm
requires all digits — a player can never be read as a chat principal.
The /login/full page handles the member on-ramps (see login.zig): a
stranger picks Log in or Create account, and an existing member
verifies a password.
Two accounts stand apart from this progression:
- Admin — uid 1 (Steve). The first account; both admin screens
(
/adminfor chat,/admin/lynrummyfor the game) are hardcoded to uid 1 viaadmin_ui.requireAdmin, not a per-account flag. - Agent — uid 3 (Claude). A full member that authenticates by API key instead of a browser session (read + write, never admin). See "Reading chat as the agent" below.
Starting from an empty data_dir + auth_dir (no accounts yet), there
are three steps. Account ids are allocated 1, 2, 3, … in registration
order (the counter floors at 1). Convention: uid 1 and 2 are people;
uid 3 is the agent (Claude).
1. Seed the session secret. Members get a signed session cookie,
keyed by {data_dir}/chat/_session_secret, and so is every player's
gopher_uid. The server reads this file but never creates the first
one — so registration 500s ("session unavailable"), and /play sets no
cookie, until it exists. (/admin/secret replaces one that exists;
SECRET-LEAK.md in gopher-metal says when.) Seed it once with ≥ 32 random bytes:
mkdir -p "$DATA_DIR/chat"
head -c 48 /dev/urandom > "$DATA_DIR/chat/_session_secret"
chmod 600 "$DATA_DIR/chat/_session_secret"2. Register the accounts in order. Each registration POST without a
cookie allocates the next id, so order is what assigns the uids. Do it
in the browser at /login/full (enter a name + password twice), or by
curl:
for who in Steve apoorva Claude; do # → uid 1, 2, 3
curl -s -o /dev/null -X POST http://localhost:9001/login/full \
--data-urlencode "name=$who" \
--data-urlencode "password=CHANGEME-$who" \
--data-urlencode "confirm=CHANGEME-$who" \
--data-urlencode "next=/"
doneThis writes {auth_dir}/<id>/{name,password}.
3. Give the agent an API key. The agent (uid 3) authenticates by key,
not cookie — but it generates that key like any member: log in as Claude,
then POST /settings/apikey (in the browser: /settings → Generate
key). The key lands at {auth_dir}/3/api-key and is what the agent
hands over as Authorization: Bearer <key>:
JAR=$(mktemp)
curl -s -o /dev/null -c "$JAR" -X POST http://localhost:9001/login/full \
--data-urlencode "name=Claude" --data-urlencode "password=CHANGEME-Claude" --data-urlencode "next=/"
curl -s -o /dev/null -b "$JAR" -X POST http://localhost:9001/settings/apikey
cat "$AUTH_DIR/3/api-key" # 3-<32 hex>Claude is uid 3, an API-key-only agent. To read what Steve sent on a
given topic, act as Claude against the dogfooded reference client
(chat/chat_client.py). The conversation key pairs the two principals
(Steve 1 × Claude 3 → conv 1_3); a topic is a named session.
# List this key-holder's conversations + sessions (partner × topic matrix):
GOPHER_API_KEY="$(cat ~/Auth/3/api-key)" \
python3 chat/chat_client.py conversations http://localhost:9001
# Read one topic (partner=1 Steve, session="yo"):
GOPHER_API_KEY="$(cat ~/Auth/3/api-key)" \
python3 chat/chat_client.py fetch http://localhost:9001 1 yoLocal vs prod keys differ. The local agent key is
~/Auth/3/api-key; the prod agent key is ~/claude_gopher_api_key
(and Steve's prod key is ~/.gopher_api_key). Use the local store's key
against http://localhost:9001, and the prod key against
https://lynrummy.com — they are not interchangeable.
The authoritative dispatch is route() in zig-server/src/router.zig
(one prefix match per surface — read it for the full story). The map:
| Path | What |
|---|---|
/ |
Home / launch pad (public; viewer resolved for the top bar, never gated) |
/delivery |
Seattle Delivery sim (public) |
/driving, /safari_download, /downloads |
Safari Screensaver + its native-download landing page and artifacts (public) |
/chess |
Chess Toys: /chess/knight, /chess/queens, sources at /chess/code (public) |
/game, /puzzles, /tutorial |
Lyn Rummy: full game (guest name required), puzzle client, beginner tutorial (tutorial public) |
/chat, /channel/<name> |
DMs + channels over SSE, /chat/docs authoring (members only) |
/settings |
Per-user settings incl. API-key generation (members) |
/play, /login/full, /logout |
Name-only player login / member password login |
/admin |
The chat roster and API keys; /admin/host (the running server), /admin/backup (an archive of data/ and auth/), /admin/secret (change the session secret); /admin/lynrummy (the game roster). All hardcoded to uid 1 |
/gallery, /images |
Home-page app emblems / brand assets (public) |
/steve-resume |
Server-owned markdown page + pre-built PDF |
/version, /debug/mem |
Build version JSON; live allocator counters (the leak smoke detector) |
There is no site-wide login gate — most surfaces are deliberately
public and ungated (they resolve the viewer only to label the top
bar). The gates that exist are per-surface: Lyn Rummy asks for a guest
name, chat requires a full member. /play and login set a signed
gopher_uid cookie; members additionally get a signed session cookie,
gopher_auth. An API key
(Authorization: Bearer) resolves to its principal exactly like a
session — read + write as that uid, never admin.
| Where | Role |
|---|---|
zig-server/ |
the server (zig) — every surface (home, login, chat, docs, Lyn Rummy /game + /puzzles, driving, /admin, /settings) as per-module handlers in src/*.zig over the shared data tree; front-end assets embedded via build.zig. See SERVER.md. |
chat/ |
the embedded chat client (chat.js) + the reference API client / example bot (chat_client.py: discover, read, post) |
delivery/ |
the Delivery display client (TS) + delivery/zig/ — the CVRP solver, compiled to solver.wasm, gated bit-exact against the retired TS solver (ops/check_delivery) |
games/lynrummy/elm/ |
the autonomous Elm client (dealer + referee + UI) |
games/lynrummy/zig/ |
the solver — the strategic brain (hints, certificates, Player Two, self-play sim), compiled to solver.wasm for the browser |
games/lynrummy/ts/ |
the original TypeScript engine, mostly retired — still the DSL/geometry layer (gesture choreography for the zig solver's plays) + the self-play harness |
ops/ |
the build / run / test scripts (ops/list enumerates them) |
deploy/ |
Caddyfile, systemd unit, deploy runbook |
The server stays deliberately dumb — URL-keyed file storage plus the
per-surface handlers — and pushes logic to the client wherever it can.
Lyn Rummy is the clearest case: the strategic brain is the zig solver
running as WASM in the browser, the TS layer turns its answers into table
gestures, and the Elm client owns the full game (dealer, referee, UI). Its
README covers that split and the
DSL-over-the-wire idea in full.
Responsive / mobile (the chat surfaces — our mobile user is Apoorva;
Steve is desktop-only). Small-screen layout is decided client-side. One JS
authority, Viewport (chat/viewport.js), owns the single breakpoint and
exposes it two ways — an html.vp-narrow class for CSS and onChange for
JS — so the number lives in exactly one place. The shared nav drawer
(chrome_drawer.js) and the chat page's mobile layout (chat_responsive.js)
both key off it; the server (chat_chrome.zig) just ships the desktop top bar and
loads the widgets. Start at Viewport and follow the breadcrumbs.
ops/start Start the zig server on :9001 (rebuild + relaunch)
ops/list List ops commands
ops/check Pre-commit gate (~35s warm): check_common + test_elm + test_ts + test_chat + check_safari + check_chess + check_solver
ops/check_full Milestone gate: ops/check + the heavy conformance suites + agent self-play + benches
ops/check_zig zig server compiles + two lints + every unit test (~20s)
ops/check_markdown Markdown dialect regression (~3s)
ops/check_delivery Delivery zig-solver conformance (~30s warm): native
gold check + the built solver.wasm driven over every
gold shift (standalone; run after delivery/ edits)
ops/test_ts Fast TS gate (~4s warm)
ops/test_elm Fast Elm gate (~4s)
ops/test_docs Fast docs gate (~1s): doc_xref --all (dead links/paths)
ops/deploy Build + ship to the prod droplet (see deploy/README.md)
Don't hand-compose zig build / elm make / tsc — the ops
scripts encode sequencing, prerequisites, and the cross-language
consistency checks that bare commands silently skip.
| Looking for… | Read |
|---|---|
| Any one app's design intent | its README — see The apps above |
| The server internals (routing, modules) | SERVER.md |
| Lyn Rummy rules / architecture | games/lynrummy/RULES.md, games/lynrummy/ARCHITECTURE.md |
| Deploy / host setup | deploy/README.md |
| Agent-collaboration conventions | ~/showell_repos/claude-collab/agent_collab/ |
Per-file domain knowledge lives in module top-of-file comments. Commit history is the authoritative design-decision record.