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 — served by zig-metal-unikernel behind Caddy (TLS); see "Where it runs" below. 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 zig-metal-unikernel, 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). - The library is zig-webbox, a
sibling checkout (
../zig-webbox): the serving, the store, the cookies. This repo's server is an application of it, as Dog Park is. Its signed admin scripts are Shale's, another sibling (../shale). - The chat's markdown dialect and its browser client are siblings too,
chat-markdown and chat-client;
zig-server/build.zig.zonnames every sibling the build reads. - zig-metal-unikernel is the machine. Its build (
zig build gopher→gopher.elf) compileszig-server/src, zig-webbox, shale, chat-markdown and chat-client from their sibling checkouts as they are, and hands them metal's ownIoashost_io(std's here: zig-webbox'shost_io_std.zig), rewriting no line; its gates judge metal against this repo's Linux build, page by page, and run metal-vmm (test-only). zig-metal-unikernel'sDEPLOY.mdsays how a release reaches the droplet, and the way back. - A change to the program reaches the site as a new zig-metal-unikernel 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). - The way back is the previous zig-metal-unikernel image on the same volume
(zig-metal-unikernel's
DEPLOY.md, "The way back"). Restarting the Linux server (zig-metal-unikernel'sCUTOVER.md) is the last resort: its data is a copy frozen at the 2026-10-04 cutover, so everything written since would be lost. - Keep
zig-server/srcportable to metal. Server code reaches the host only through the seam indocs/SEAM.md;tools/lint_portable.shale(run byops/check_zig) fails a change that reaches around it. A change ships only after zig-metal-unikernel's gates pass on it. - The two hosts are one machine, by contract (zig-metal-unikernel
HOST.md,STORE.md): Linux runs one handler at a time as metal's single loop does (the turn in webbox'slinux_host.zig); every bound on a request is inzig-server/src/limits.zig, which both hosts size their buffers from andtools/check_caddy_limits.pyholds the Caddyfile to; and zig-webbox'sstore.ziganswers the same on Linux and on metal, which zig-metal-unikernel'szig build store-judgechecks operation by operation. - Which commit is live:
https://lynrummy.com/versionreports the running build'scommit;git tag --points-at <commit>names its release (zig-metal-unikernel's README, "Where it stands", says how to read the rest). Itsversionfield is a constant (home.zig), socommitis the field that names a build. The other fields:result,rejects(the edge's reject counters),mem(the live-byte meter),login_throttle,now_ms,started_ms.
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; /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 |
|---|---|---|---|---|
| Shale | /shale/ |
A landing page for Shale, the scripting language for agents, and its tutorial: fifteen lessons whose scripts run in the browser, building a small blog's files. The pages and the wasm build are the sibling shale checkout's zig build web, copied by ops/build_shale_web into learn_shale/ (not in git). |
Zig (WASM) + JS | shale's README.md |
| 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 one short text grammar for moves and sessions (the 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 |
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/— seezig-server/README.md.
The rest of this README is for developers and agents working on the code.
bash ops/start # zig server on :9001The build needs zig-webbox,
shale, chat-markdown and chat-client
cloned beside this repo (../zig-webbox, ../shale, and the other two the
same way): zig-server/build.zig.zon names them all. ops/check_zig runs
zig-webbox's and shale's tests too. Shale's shale binary
must also be on PATH: ops/check_zig runs its lints with it.
ops/start is the canonical dev loop: it kills anything on :9001,
rebuilds the front-end bundles and WASM cores (ops/build_embedded runs
every builder that zig-server/assets.zon names: 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.
GOPHER_CONFIG names a config file (data_dir + auth); unset, the server
uses repo-relative defaults (linux_config.zig). 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 these must be present to build fresh. The npm
packages are pinned by each directory's committed package-lock.json:
| Tool | Version | Builds | Install |
|---|---|---|---|
| Zig | 0.17.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) + Shale's browser build, from the sibling shale checkout (ops/build_shale_web → learn_shale/) |
system install — zig version |
| Elm | 0.19.1 | the Lyn Rummy client | npm install in games/lynrummy/elm/ |
| TypeScript | 6.0.3 | the Delivery display client + the Lyn Rummy DSL/geometry layer and test harnesses (both solvers are zig; each .ts solver stays as the port reference) |
npm install in delivery/, games/lynrummy/ts/ and games/driving/ |
| Node | 22.18 or later | 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. Node 22.18 is the first release that strips types without a flag, so it is the floor. - Browser-side — two bundles are transpiled (
esbuildbundles each into one IIFE JS file,@embedFiled into the zig binary at compile time), and both 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, the one-line "why nothing plays" explanations the code calls 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 is a Zig→WASM core plus a JS blitter (ops/build_safari_wasm), not transpiled;HISTORY.mdsays where Safari and Delivery sit on the zig↔TS spectrum and why (Chess Toys is in its own README).
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 <package>/node_modules/.bin), so a fresh checkout needs
npm install in four places: games/lynrummy/elm/,
games/lynrummy/ts/, delivery/, and games/driving/.
Each of these is otherwise learned from a failing build.
- Node 22.18 or later, for the node-side TS above.
npm installin all four package directories.- Zig 0.17.0, on
PATH. Everyops/build_*script and the server itself go through it. libx11-devandlibxrender-dev, and only forops/build_safari_download(whichops/build_downloadsruns).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'.ops/build_downloadsbefore a site disk:downloads/is one ofzig-server/assets.zon's.sitedirectories, built and not in git, and zig-metal-unikernel's image and judge copy every.sitedirectory.ops/startdoes not need it (the two download links 404 until it runs).- 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/startdoes). 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 environment variables, all named in
zig-server/src/server.zig:
GOPHER_CONFIG: the config file (above).GOPHER_PORT: the listen port,:9001unless set.GOPHER_BIND: the listen address.GOPHER_TRUSTED_PROXY: whoseX-Forwarded-Fornames the client (default127.0.0.1, the local Caddy).GOPHER_KEEPALIVE_MS: how long a quiet SSE stream waits before a ping; for tests.GOPHER_GAME_FLOOR=off: turns off the game store's free-space floor, for a test host whose data sits on a full disk.
webbox's linux_host.zig reads the first five; server.zig reads the last.
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 {auth_dir}/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: the account resolver reads that cookie
only when it is all digits, so 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 zig-metal-unikernel 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) |
/shale/ |
Shale's landing page and in-browser tutorial (public; shale_page.zig) |
/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 |
Lyn Rummy: full game (player name required), puzzle client |
/chat, /channel/<name> |
DMs + channels over SSE, /chat/docs authoring; /chat/recent, /chat/images, /chat/code, /chat/search, /chat/msg/<id> (members only; chat.zig) |
/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/apikey); /admin/host (the running server), /admin/log (the log as text), /admin/backup (an archive of data/ and auth/), /admin/secret (change the session secret), /admin/retire (retire old topics and users), /admin/search?key= (every chat message containing key); /admin/lynrummy (the game roster); /admin/script (a signed Shale script, below) and /admin/script-key. 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 player
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. uid 1's key is therefore admin, and it
is the only credential /admin/script takes.
/admin/lognarrows bysince=,until=,request=,path=andboot=(zig-webbox'sadmin_log.zig)./admin/retireis two steps: a dry run that lists what would go, then a confirm. Like/admin/backupand/admin/secret, it asks for the password again./admin/searchreads every transcript the admin can see, with no index, and holds the turn while it does: every other request waits./admin/scripttakes two locks: the admin's API key asAuthorization: Bearer(a session cookie is not enough), then an Ed25519 signature on the envelope, checked against the public key in{auth_dir}/shale.conf; with no key there the route refuses. Amode=backgroundrun is answered at once and stepped between requests on both hosts, one at a time;GET /admin/scriptshows its state and output, andmode=killends it. The language is shale'sSHALE.md; how to sign and send a script is zig-metal-unikernel'sDEPLOY.md.
| 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 zig-server/README.md. |
chat/ |
the chat's README and the reference API client / example bot (chat_client.py: discover, read, post); the browser client is the chat-client repository, and the markdown dialect the chat-markdown repository, each a sibling checkout |
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/ |
prod's Caddyfile, the Linux fallback's systemd unit and 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-client's 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.
The gates, cheapest first (ops/list gives each one's rough time):
ops/test_docs Docs gate: doc_xref --all (dead links/paths)
ops/test_elm Elm client suite
ops/test_ts TS suite
ops/check_zig zig server compiles + the lints + the Caddy limits + every unit test, here and zig-webbox's
ops/check_chat Chat gate: check_common + test_chat
ops/check_lynrummy Lyn Rummy gate: check_common + test_elm + test_ts + check_solver
ops/check Pre-commit gate: 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
Standalone, run after editing their part:
ops/check_markdown Markdown dialect regression
ops/check_delivery Delivery zig-solver conformance: native gold check +
the built solver.wasm driven over every gold shift
The rest:
ops/start Start the zig server on :9001 (rebuild + relaunch)
ops/list List ops commands
ops/deploy While metal serves: copy prod's watchdog only. The full
Linux build + ship runs only with ~/linux-serves on prod
(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) | zig-server/README.md |
| Lyn Rummy rules / architecture | games/lynrummy/RULES.md, games/lynrummy/ARCHITECTURE.md |
| Releasing to lynrummy.com | zig-metal-unikernel's DEPLOY.md |
| The Linux fallback host, prod's Caddy | 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.