docs play ▸

The server

crates/typepper-server is one binary that serves HTTP (axum) and SSH (russh) over one Postgres. It stores runs, links ssh keys to GitHub accounts, answers boards, and forgets what no board needs. It's deployed on Railway with each release and never shipped to players (dist = false, release = false).

Modules

src/
├── main.rs       # config from the environment, migrations, the hourly sweep, HTTP + SSH
├── http.rs       # routes: thin, request → core → response
├── ssh.rs        # the SSH door: auth, commands → core → answer lines
├── runs.rs       # submit (decode, simulate, store), replay bytes, share pages, the quota
├── board.rs      # boards, ranks, the sweep
├── accounts.rs   # players and their ssh keys: link, unlink, who a key is
├── github.rs     # a login's public keys and numeric id, from GitHub
└── id.rs         # random base62 ids, with_new_id
migrations/
├── 0001_runs.sql
├── 0002_players.sql
└── 0003_boards.sql

http.rs and ssh.rs hold no logic: both call the same core functions, which own their SQL. Queries are plain sqlx::query with compile-time string literals (sqlx 0.9 refuses SQL built at runtime), checked against the database at runtime, so building needs no database.

Startup

  1. Read the configuration (below). A missing required variable stops it.
  2. Connect lazily: the pool is created without waiting for Postgres.
  3. Run the migrations (compiled into the binary), retrying for about half a minute while Postgres comes up.
  4. Start the hourly sweep, then serve HTTP and SSH. If either stops, the process exits and Railway restarts it.

Configuration

VariableDefault
TYPEPPER_DATABASE_URLrequiredPostgres (on Railway, a reference to typepper-postgres's DATABASE_URL)
TYPEPPER_SSH_HOST_KEYrequiredthe SSH host key, OpenSSH private key text. Never generated: a new key would make every player's ssh refuse the server
PORT8080HTTP (Railway's variable; its domains and health check use it)
TYPEPPER_SSH_PORT2222SSH (the TCP proxy's target)
TYPEPPER_GITHUBGitHubpoints the GitHub calls at a stand-in, for tests
RAILWAY_DEPLOYMENT_IDshown in /health

typepper's own variables are prefixed TYPEPPER_.

Database

The schema is the sum of the migrations. Migrations only add: tables, nullable or defaulted columns, indexes, constraints. Drops and renames wait for a later release, once nothing reads the old shape. An applied migration never changes: sqlx checksums them and refuses to start otherwise.

create table runs (
  id         bigint primary key,
  player_id  bigint references players on delete cascade,  -- null: anonymous
  replay     bytea  not null check (octet_length(replay) <= 1048576),
  engine     int    not null,
  mode       text   not null,               -- Mode::key: 'time:30', 'pursuit:7'
  score      double precision,              -- the server's own; null in zen
  wpm        double precision not null,
  accuracy   double precision not null,
  claimed    double precision,              -- the client's number, to spot drift
  created_at timestamptz not null default now()
);

create table players (
  id         bigint primary key,
  github_id  bigint not null unique,        -- the identity; logins can be renamed
  login      text   not null,               -- refreshed on every link
  created_at timestamptz not null default now()
);

create table ssh_keys (
  id          bigint primary key,
  fingerprint text   not null unique,       -- SHA256:… of the public key
  player_id   bigint not null references players on delete cascade,
  added_at    timestamptz not null default now()
);

-- indexes: runs (created_at); runs (mode, player_id, score desc) and
-- runs (mode, created_at), both where player_id and score are set

Ids

Every table's id is a random bigint in [0, 62⁸), written as 8 base62 characters (7Xf9qyuC). Random, so share links can't be counted through; small, so links stay short and indexes stay integers. Every insert goes through with_new_id, which draws an id and retries only when the insert collides on a primary key (a constraint ending in _pkey). At 62⁸ ≈ 2.2·10¹⁴ that practically never happens; a test forces one.

Submitting

runs::submit(db, player, body) handles both transports:

  1. Read items off the body: 8 bytes of claim, then Replay::decode_prefix. A broken item ends the answer.
  2. For each: over 1 MiB → too-big; engine != VERSION → old-engine; a mode the game doesn't offer → bad-replay.
  3. simulate the replay on a blocking thread (a long zen is millions of steps). A round that never ends → bad-replay. A claim that differs from the server's score is logged.
  4. Insert the run with a fresh id; for a linked player, look up their all-time rank on that mode.
  5. Answer ok <id> <score> <rank> https://r.typepper.com/<id>.

Accounts and linking

A player is a GitHub account; the terminal game is known by an ssh key linked to it. The SSH user says what the connection is for:

UserAcceptedMeans
typeppera linked key (that player), or no key at allthe game; no key, or an unlinked one, means anonymous
a GitHub logina key listed on github.com/<login>.keys"link this key to that account"
sequenceDiagram
    participant C as ssh (typepper link leodiegues)
    participant S as server
    participant GH as GitHub
    C->>S: user leodiegues, offering key K
    S->>GH: GET github.com/leodiegues.keys
    GH-->>S: the account's public keys
    S-->>C: K is one of them: go on (before anything is signed)
    C->>S: signature with K, then "v1 link"
    S->>GH: GET api.github.com/users/leodiegues
    GH-->>S: id 424242, login "leodiegues"
    S->>S: upsert player (github_id), link K's fingerprint to it
    S-->>C: ok linked leodiegues

Web login (GitHub OAuth, server-side sessions) isn't built yet; see the roadmap.

Boards

board::board(db, mode, period, player) ranks each player's best run on a mode, for all time or since the current UTC day, week (from Monday), month or year began. The time zone is explicit in the SQL (date_trunc($2, now(), 'UTC')), whatever the session's.

with best as (
  select distinct on (r.player_id) r.player_id, r.score, r.created_at
  from runs r
  where r.mode = $1 and r.player_id is not null and r.score is not null
    and ($2::text is null or r.created_at >= date_trunc($2, now(), 'UTC'))
  order by r.player_id, r.score desc, r.created_at
)
select rank() over (order by b.score desc, b.created_at) as rank, …

The sweep

Hourly, in main.rs: delete runs older than 8 days unless they're a player's best for their mode in their calendar month (UTC).

Long term, that's at most players × modes played × months, about 2 KB each.

Quota

An in-memory allowance of 60 submissions an hour per caller: the ssh key's fingerprint, else the connection's address (HTTP: the first x-forwarded-for address, as Railway's edge passes it). One server, so no table. Entries older than an hour are forgotten at each call.

SSH details

Tests

TestNeeds
base62 round trips, the share page's text, the quota, GitHub login rules, the health bodynothing
a submission end to end (two replays, a wrong claim, junk, an old engine)Postgres
a forced id collision retriesPostgres
link, relink after a rename, unlinkPostgres
boards: best per player, periods, ties, anonymous runs; the sweep keeps exactly what boards needPostgres

The Postgres tests run when TYPEPPER_TEST_DATABASE_URL is set, migrating it first, and pass with a note otherwise. CI provides Postgres 18, Railway's major version. See CONTRIBUTING.