About this site

How Microgrind verifies leaderboard runs: deterministic replay on a Cloudflare Worker

How a browser game checks leaderboard scores on the server: server-issued seeds, tick-stamped input logs, and a replay of the same simulation code in a Worker.

INPUT LOGtick 1 clicktick 4 praytick 9 clickSERVERreplayseed1300your inputsSCORED ✓

Practise this: the Tick Sense ladder

Every run on Microgrind's leaderboards is played a second time on the server before it counts. The browser never reports a score that anyone trusts. It sends the ticket it was given before the run and a list of inputs stamped with the tick they landed on, and a Cloudflare Worker re-runs the same simulation code over that list to work out the score itself. This is a write-up of how that fits together, why each piece is there, and what it does not stop.

Microgrind is a small browser game for practising tick timing. The drills are short (the longest ladder run is capped at 8.0 minutes), and the backend is deliberately small too: no accounts, no game servers, just one Worker and a Postgres database.

Why replay works here: a run is three values

The game runs on a fixed tick of 600 ms, and every rule (when a click counts, when a shot is blocked, when a monster swings back) lives in one deterministic simulation in src/sim. It takes a level, a seed and the inputs for each tick, and nothing else. There is no Math.random (a seeded mulberry32 generator instead), no clock, no floating-point physics and no DOM. So a run is fully described by (level, seed, inputs), and its score is a pure function of those three values.

That is the same property deterministic lockstep networking relies on: send the inputs, not the state, and let each side compute the state (Glenn Fiedler's Deterministic Lockstep explains the idea and its hard part, which is getting bit-identical results everywhere). Microgrind gets that for free by running literally the same TypeScript in both places. The Worker's entry point imports src/sim/runner.ts, and Wrangler bundles it from the same commit the browser bundle is built from.

The input log: ticks, not milliseconds

A click is not applied when it happens. It waits for the next tick boundary (how that works), so the browser converts each input to a tick number as soon as it is made:

let tick = inputTick(ts, this.t0, this.L);
if (tick < this.nextTick) tick = this.nextTick;
this.queue.push({ tick, action, ts });
src/game/session.ts

When the tick is processed, each input that was queued for it goes into the run's record:

this.record.push(encodeInput(k, q.action));
this.offsets.push(Math.round(q.ts - this.boundary(k - 1)));
src/game/session.ts

A record entry is a short array of integers: the tick, an action code and the action's arguments.

/** Compact replay record: [tick, code, ...args]. */
export type InputRecord = number[];

const CODES = { strike: 1, ward: 2, move: 3, attack: 4, run: 5, gear: 6, tap: 7 } as const;
src/sim/types.ts

The offsets line matters as much as the record. It keeps how many milliseconds into the tick each input was made, and the results screen draws that as a timing chart. That number never leaves the browser. Sub-tick timing cannot be verified (the server has no way to know when you really pressed a key), so it is feedback only and never part of a score. Only the tick an input landed on is recorded, and only that can change the outcome.

The logs are small. The scripted "perfect" players the tests use earn gold on every ladder level with between 6 and 51 inputs, and the largest of those logs is 523 bytes of JSON.

Replay a logTry it

Does the same log always give the same score?

The built-in log is a gold run of Tick Sense 1: First Beat, 16 strikes as [tick, code]: [4,1], [7,1], [9,1], [13,1], [17,1], [19,1], [23,1], [25,1], [28,1], [31,1], [35,1], [37,1], [41,1], [45,1], [49,1], [52,1]

What replay() makes of it
LogScoreMedal
The log as played (seed 1)1600Gold
The same log, replayed again1600Gold
Strike on tick 13 moved to 141450Silver
Strike on tick 9 moved past tick 13Rejectedinputs out of order
The same log on seed 20None

Same seed, same inputs: score 1600 every time.

Server-issued seeds: no seed shopping

Some levels are generated from the seed: the order and spacing of shots in the two-colour ward levels, for example. If the browser picked its own seed, a player could restart until an easy layout came up, or replay one layout they had memorised. So ranked runs start from a ticket that the database issues:

v_seed := floor(random() * 4294967295)::bigint;
supabase/migrations/20260928032153_init.sql

A ticket belongs to one player key, can be redeemed once, and expires after 45 minutes. The browser fetches the next ticket while you are still looking at the results of the last run, so starting is instant and the seed is already fixed before you press Start. The daily challenge is the exception: its seed is derived from the UTC date, so everyone gets the same level, and the database only issues daily tickets for dates within a day of today.

The Worker: replay and record

When a ranked run ends, the browser posts its player key, ticket id, platform, input log and SIM_VERSION to /api/submit-run. The Worker looks the ticket up, so the level and the seed come from the database rather than from the browser, then replays:

const seed = isDailyId(levelId) ? dailySeed(levelId.slice(DAILY_PREFIX.length)) : Number(ticket.seed);
const out = verifyRun(levelId, seed, inputs);
if (!out.ok) return json(422, { error: 'rejected', detail: out.error });
src/worker.ts

verifyRun checks the log as it reads it: every record must decode to a valid action for that drill, ticks must be in order and inside the run, there can be at most 12 inputs on one tick and 6,000 in total. Then it steps the simulation to the end of the run, however long the browser claims it was:

while (!sim.over && sim.tick < sim.maxTicks) sim.step(byTick.get(sim.tick + 1) ?? []);
return { ok: true, result: sim.result(), ticks: sim.tick };
src/sim/runner.ts

The result of that loop is what goes on the board. The browser does send its own score, which is stored next to the verified one, but only so a mismatch between the two can be spotted and debugged. The longest ladder level, Gilded Champion, can run for at most 800 ticks (8.0 minutes of play), so a replay is a loop of a few hundred steps over plain integers.

What the server checks, in order, and what the player is told
CheckWhereRejected as
The client runs an older version of the rulesWorkeroutdated: refresh to submit
The ticket is unknown, used, expired or belongs to someone elseDatabase, via the Workerticket invalid
The input log is malformed, out of order, outside the run or too longReplay in the Workerrejected
The ticket was redeemed in the meantime (checked again under a row lock)Databaseticket used
The run was finished faster than real timeDatabasetoo fast

The Worker talks to the database through a handful of Postgres functions that check a shared secret before doing anything. The database stores only that secret's hash, and the tables themselves are closed to the public key: clients can read profiles, boards and runs, and cannot write to any table directly (sign-ups and renames go through functions too) (row level security does the work).

Real time: a run cannot be faster than its ticks

Replay proves a log is legal. It does not prove it was played live: a script could compute a perfect log in a millisecond. So when the result is recorded, the database compares the time since the ticket was issued with the run's length in ticks, at 0.6 seconds each, with a small allowance. A run that would have needed more time than has passed is refused. Because tickets are fetched before the run starts, the gap includes time spent in menus, so the check only ever errs on the side of letting a real player through.

Changing the rules: SIM_VERSION

Determinism has a cost: if a rule changes, an old log can produce a different score. So the simulation carries a version string (currently 1.2.2), bumped whenever a change would alter the outcome of an existing replay. The Worker refuses submissions from any other version:

if (simVersion !== SIM_VERSION) return json(409, { error: 'outdated', simVersion: SIM_VERSION });
src/worker.ts

The browser turns that into "a new version is out: refresh the page to submit scores". Every stored run keeps the version it was verified under, and the replay viewer will not play back a run from older rules rather than show something that never happened. The same version is stamped on these guides: every table in these guides is computed from src/sim when the page is built, and the build fails until each guide has been re-checked against a new version.

Identity without accounts

There are no emails or passwords. The first time you pick a name, the browser generates a random key (32 bytes from crypto.getRandomValues) and keeps it locally; the database stores only its SHA-256 hash and the name. Every RPC that acts for a player takes the key and looks the player up by its hash. To play on another device you copy the key across in Settings.

The trade-offs are the obvious ones. Lose the key and the name is gone. Anyone who has your key is you. Sign-ups are rate-limited per IP address (stored hashed), which slows down throwaway names without stopping a determined person. For a free practice game with no money or personal data involved, I think that is the right amount of identity.

What replay does not stop

  • A program playing in real time. Replay checks that a run obeys the rules, not that a person produced it. A script with perfect timing that waits out the clock produces a perfectly legal log. The scripted players in the tests are exactly that, minus the waiting.
  • Shared keys. Two people playing under one key look like one player.
  • Anything outside the simulation. The server sees ticks, not hands, so it cannot tell how an input was made.

What it does give is accountability. Every verified run is stored with its full input log, and the runs table is public, so anyone can open the leaderboards and watch the run behind any entry play back, tick for tick, with the same code that scored it.

Quick answers

How are leaderboard runs verified?

By replaying them on the server before they count. A Cloudflare Worker re-runs the same simulation code over the run's input log, with the level and seed from a server-issued ticket, and the score it works out is the one that goes on the board.

Can a bot still get on the leaderboard?

Yes, if it plays in real time. Replay checks that a run obeys the rules, not that a person produced it, so a script with perfect timing that waits out the clock produces a perfectly legal log.

Do I need an account for the leaderboards?

No. There are no emails or passwords: the first time you pick a name, the browser generates a random key and keeps it locally, and the database stores only its SHA-256 hash and the name.

Practise this

Free in the browser, no sign-up. Slowed ticks and other practice aids are in each level's settings.

Sources

Not affiliated with or endorsed by Jagex Ltd. Old School RuneScape is a trademark of Jagex Ltd.