docs play ▸

Decisions

Why typepper's online side is built the way it is. Each entry: what was decided, what it beat, and what it costs. Newest last.

1. Online is opt-in; offline is the product

The game makes no network call at startup or during play. Sharing, boards and uploads happen only on the player's action (or because they linked), fail as one dim line, and online: off removes them. Instead of accounts at startup, background sync, or update checks. Cost: no telemetry, and features have to be designed around an action the player takes.

2. Replays are inputs, not video

A run is its seed plus each input at its millisecond, about 2.5 bytes a key; playing it means running the engine again, live, in the real game. Instead of recording keys and rendering video on a queue into a bucket: no workers, no ffmpeg, no storage bill, and the replay looks exactly like the game, because it is the game. Cost: the engine must be deterministic forever (decision 3), and a replay needs the engine version that recorded it.

3. A deterministic engine, in its own crate

The rules moved out of the game into typepper-engine, with a fixed 120 Hz timestep, millisecond inputs, SplitMix64 written out, and libm for transcendental maths. The same code runs in the game, the browser and the server. Cost: every rules change is a VERSION bump with golden numbers to re-record; frame-time effects can't touch the rules.

4. The server scores every run itself

Uploads carry inputs; the server simulates them and keeps its own score. The client's number is only logged when it differs. Instead of trusting a posted score, which would put any number on the boards with one curl. Cost: CPU per submission (on a blocking thread), and replays from other engine versions can't be scored (old-engine).

5. A custom binary format, stored as bytea

Format 1 is a small fixed header plus LEB128 varints, versioned by its format byte and readable without the engine. Each replay is one bytea in its run's row. Instead of a row per keystroke: that would be about 30 times the storage, millions of rows to sweep, and the format defined twice (bytes on the wire, rows in the database). Cost: no SQL over individual keystrokes; statistics would be computed from the bytes when needed.

6. SSH for the terminal, with GitHub keys as identity

The terminal game runs the system ssh; a player is a GitHub account, proven by a key on github.com/<login>.keys. Instead of an HTTP client in the game plus OAuth device flow: no tokens on disk, no new dependency, and keys players already have. The SSH user carries intent (typepper plays, a login links), and keys are judged before signing, so a wrong key never prompts. Cost: SSH needs a TCP proxy and a fixed port (decision 9), and agents like 1Password may ask to approve each linked upload.

7. Anonymous sharing without a key

The server lets the game in through keyboard-interactive with nothing asked, and the game offers no key while anonymous. Instead of requiring a key or an account to share. Cost: without BatchMode, the game has to rule out every possible prompt itself (host key checked, no passwords, askpass nowhere). Anonymous callers share a quota by address.

8. Railway, declared in the repo, deployed by CI on tags

One service and Postgres on Railway; railway.ts describes them, CI applies it and uploads the tagged commit, and Railway builds the image. Instead of building and pushing images to a registry, or deploying on every push. The server always runs the release the game shipped with. Cost: IaC can't register custom domains or carry dashboard changes, so those must be mirrored in railway.ts by hand.

9. The TCP proxy's port is part of the game

Railway assigns the SSH port (36080), and the game ships it. Instead of port 22, which Railway can't give. Cost: the proxy must never be recreated; TYPEPPER_SSH is the escape hatch.

10. Random bigint ids, written in base62

Every id is random below 62⁸, shown as 8 characters. Instead of UUIDs (36 characters, or 22 encoded; twice the index size) or sequential ids (links could be counted through). Cost: a retry on the rare collision, handled by with_new_id.

11. Boards from a sweep-friendly schema

Boards are queries over runs: each player's best per mode, all time or since a UTC calendar period began. An hourly sweep keeps the last 8 days and each month's best per player and mode. Instead of separate board tables kept in sync. Cost: only current periods are browsable (past months' bests are kept, so they can come later).

12. Health in the IETF format

/health gates Railway's traffic switch with its status code and describes itself in application/health+json, with that draft's camelCase names. Instead of an always-200 status page (a broken deploy would take traffic) or our own JSON. Cost: camelCase in one place; our own JSON will be snake_case.

13. One version for everything

Every crate inherits the workspace version; a release tag ships the game, the site and the server. Instead of versioning the server on its own. Cost: a release redeploys the server even when it didn't change.

14. No database crate, no repository layer

The server is the only program that touches Postgres, and each core module owns its SQL. Instead of a typepper-database crate or traits over storage. When a second program needs the database, the server becomes a library with a second binary first.