docs play ▸

Protocol

What the game and the server say to each other. The terminal game talks over SSH (it runs the system ssh); the web game over HTTPS. Both carry the same requests and the same one-line answers, so the game parses them the same way whichever way they came.

Versioning

LayerVersionChanges when
commandsv1 (SSH), /v1 (HTTP)a request or an answer changes shape
replay bytesformatthe byte layout changes (replays)
rulesengineresults would change (engine)

Released games keep calling what they were built with, so a v1 command never changes meaning: a different shape gets v2, next to it. /health, share pages and ping are unversioned: they're for Railway, for people, and for checking the door is open.

Commands

SSH (ssh -p 36080 -l typepper ssh.typepper.com …)HTTP (https://api.typepper.com)Does
v1 submit, submission on stdinPOST /v1/runs, submission as the bodyplays and stores each replay; ranks it if the caller is a linked player
v1 board <mode> <period>GET /v1/boards/{mode}?period=<period>a leaderboard
v1 whoamiok <login>, or ok anonymous
ssh -l <github-login> … v1 linklinks the key that logged in to that GitHub account
v1 unlinklets go of the key that logged in
GET /v1/runs/{id}a stored replay's bytes
GET https://r.typepper.com/{id}a run's share page
pingGET /healthpong; readiness

The SSH user matters: typepper is the game, and any other user is a GitHub login asking to link (see server). Anything else over SSH answers err unknown-command <it> with exit status 1; a plain ssh without a command prints that there's no shell and exits.

Submitting

The submission

submission = item × 1..20                       back to back: the outbox in one go
item       = claimed: f64 LE (8 bytes) | replay
# one replay, by hand (bash): NaN as the claim, then the bytes
{ printf '\x00\x00\x00\x00\x00\x00\xf8\x7f'; cat run.replay; } \
  | ssh -p 36080 -l typepper ssh.typepper.com v1 submit
curl --data-binary @submission.bin https://api.typepper.com/v1/runs

The answer

One line per item, in order:

ok <id> <score> <rank> <link>
err <code> <why>
ok 7Xf9qyuC 108 - https://r.typepper.com/7Xf9qyuC         anonymous: no rank
ok nEGsHePP 0 3 https://r.typepper.com/nEGsHePP           a linked player: #3 all time on that mode
err old-engine update typepper to submit
err bad-replay truncated
CodeMeansThe game's outbox
old-enginerecorded by other rules than the server'sdrops it
bad-replayunreadable, a mode the game doesn't offer, or a round that never endsdrops it
too-biga replay over 1 MiB, or more than a submission over SSHdrops it
too-manypast the 20th itemkeeps it
busythe server couldn't store it right nowkeeps it
rate-limitedover the caller's hourly allowance; the whole submissionkeeps it
no answerthe network, a timeout, the server downkeeps everything

Over HTTP, rate-limited comes with status 429; everything else is 200, with the outcome in the lines.

Boards

v1 board time:30 week                      GET /v1/boards/time:30?period=week
1 131 leodiegues 2026-10-23
2 118 y0sif 2026-10-21
3 108 OctoTest 2026-10-24
me 31 61 someone 2026-10-20

<rank> <score> <login> <day>; ties share a rank and go to whoever got there first. An empty board is no lines. A bad mode or period: err bad-board … (404 over HTTP). HTTP boards may be 30 s old.

Linking

ssh -p 36080 -l <github-login> ssh.typepper.com v1 link
ok linked <Login>                  (as GitHub spells it)

The SSH login only gets in with a key listed on github.com/<github-login>.keys; otherwise ssh reports Permission denied. v1 link as typepper answers err not-linking …. typepper link <login> runs this for you. After linking, v1 whoami as typepper with that key answers ok <login>, and its submissions are that player's. v1 unlink answers ok unlinked (or ok not-linked).

Replays and share pages

Ids are 8 base62 characters ([0-9A-Za-z]).

Health

GET /health is readiness: 200 once Postgres answers, 503 when it doesn't (within 3 s). Railway only switches traffic to a new deploy once it's 200. The body follows the IETF health check draft (application/health+json), so its field names are the draft's camelCase:

{
  "status": "pass",
  "version": "1",
  "releaseId": "0.4.0",
  "serviceId": "typepper-server",
  "description": "deployment c5856d37",
  "checks": {
    "postgres:responseTime": [
      { "componentType": "datastore", "status": "pass", "observedValue": 5, "observedUnit": "ms" }
    ]
  }
}

version is the API's, releaseId the server's (the release). Failures never say why; that goes to the logs. JSON we design ourselves will be snake_case.

Browsers

The web game calls api.typepper.com from typepper.com. CORS allows https://typepper.com and local http://localhost:* / http://127.0.0.1:* origins (for trunk serve), for GET and POST with a content-type header. No cookies yet: the web is anonymous until web login exists.

Limits