Problem
Simulation portfolios lean almost entirely on video: you see the result, never the judgment. I wanted to put a complete system on the table that anyone could open, play and audit.
Authoritative API on Cloudflare Workers · Durable Objects · live WebSocket leaderboard
A browser time-trial with an edge-verified leaderboard. You race the track record and your own best lap; every time is validated by an authoritative backend on Cloudflare Workers with Durable Objects, idempotency and rate limiting. Both repos are public: the C# game and the TypeScript API.

Simulation portfolios lean almost entirely on video: you see the result, never the judgment. I wanted to put a complete system on the table that anyone could open, play and audit.
I built a public game end to end: a Unity WebGL time-trial against the track record's ghost and your own best lap, with an authoritative backend on Workers that validates every time by bounds, with idempotency and rate limiting in Durable Objects.
A demo playable from the link, a live leaderboard over WebSocket and the API's open repo where the judgment can be audited commit by commit.
Six Hono endpoints with validation by bounds, single-use HMAC tokens, idempotency and rate limiting inside Durable Objects, and observability with structured logs.
Repository →Unity build in batchmode, WebGL hosting with Brotli and frame-ancestors headers, and QA/production environment deploys with wrangler.

From Unity requesting a token to the time existing on the leaderboard: who talks to whom, in what order it happens, and what state forces it. Three views of the same mechanism.
The game speaks HTTP only: Unity WebGL has no working System.Net.WebSockets. The web does open WS /v1/tracks/:id/live — that's the magenta edge —, hibernates in the track's LeaderboardDO through the Hibernation API (tag v:{trackVersion}) and receives the full top-100 on connect, without waiting for a change. The key that signs the runToken never leaves the Worker: the client receives it already signed and only hands it back — it signs nothing.
From Countdown to Result, ugly path included. If the finish POST fails, the result isn't lost: SubmitQueue persists it in persistentDataPath and retries with the same Idempotency-Key until the server confirms — idempotency is what makes that retry safe, not a race against your own previous submit. The WS broadcast only fires on an accepted submit: not on a rejected, not on a duplicate retry.
RunDirector is the sole owner of the state. Hard rule: nobody else freezes the car or touches Time.timeScale. If two systems can pause, there's a state bug that shows up once every 50 runs and never gets found.
Every check that fails cuts the path before the next one. The table below is the quick reference; this is the real shape of the mechanism.
The last step matters: the track config is uploaded from the repo, not by hand from the dashboard. If TrackConfig gets edited in Unity and not exported, the drift shows up in CI, not in production.
This is what you can read without opening the repos: the option I discarded, the one I chose, and why.
ATime.time
BFixedUpdate
The Fixed Timestep runs frozen at 50 Hz and the whole stopwatch depends on it: at 10 Hz sampling that's exactly five physics steps per sample, with dt = 0.1 nailed down. Time.time varies with the framerate and that jitter leaks straight into the reported time. Hard rule: nobody else touches Time.timeScale during a run.
ARe-simulating the run
BValidation by bounds
Unity physics is not deterministic across machines: re-simulating the run on the server to compare it bit by bit is a promise I can't keep. The bounds — wall-clock, per-sector minimums, top speed — don't prove a run is honest, but they do prove it's internally coherent, and that alone raises the cost of the attack a lot.
ARe-simulating inputs
BInterpolated positions
Re-simulating inputs inherits the same non-determinism problem as decision 2, and ghost determinism becomes a pit: you never touch bottom. The trail records position, yaw and speed quantized at 10 Hz; the ghost just interpolates those points. It's declared in the README as a decision, not an omission.
AD1 (relational)
BDurable Object
The DO serializes its own writes: two simultaneous submits from the same device can't interleave, and that guarantee comes for free. In D1 it would cost a transaction with retries, and a per-track ranking doesn't need a full relational database.
ADelete silently
BMark as flagged
Checks 4 and 5 — sectors at the theoretical minimum, trail speed and position — can fail because of a bad connection, not just cheating. Marking instead of deleting silently is more honest, and it makes a run's detail page much more interesting.
AIn KV
BInside the DO
KV is eventually consistent: two fast retries of the same submit might not see each other, which is exactly the case idempotency exists to cover. I store the full response against the Idempotency-Key inside LeaderboardDO itself: a retry receives exactly the same rank and the same status, not a 409 the client has to interpret.
They run in order and cut at the first failure: the cheap — verifying a signature — goes before the expensive — decoding an entire trail.
| Check | What it covers | Consequence |
|---|---|---|
| 01runToken HMAC valid, not expired, not consumed | Replays of the same payload | rejected |
| 02Server wall-clock ≥ reported time (500 ms margin) | Impossible times — the only truly authoritative check | rejected |
| 03Sector sum == total, correct order, none at 0 | Hand-crafted payloads | rejected |
| 04Each sector ≥ the track's theoretical minimum (KV) | Teleport or section skip | flagged |
| 05Coherent trail: speed ≤ vmax, positions inside the bounds | Speedhack, leaving the circuit | flagged |
| 06Rate limit per device + IP in RateLimiterDO | Submit spam | rejected |
The endpoints are public and the payload format is discoverable by reading the JavaScript bundle. Anyone can skip the game and call the API directly with fabricated but plausible data. That's why the design isn't sold as anti-cheat. It's sold as three concrete things: a hard floor (the server's wall-clock makes it impossible to report a time lower than what actually elapsed between start and finish), per-sector and per-trail bounds (a fabricated time has to be internally coherent to pass, which raises the cost of the attack a lot) and detectability (whatever passes the bounds but looks off comes in as flagged and stays auditable, instead of vanishing silently).
The Durable Object is already step 1 toward multiplayer. It goes in the README as a next step, not in the MVP.
The ranking identifies a device, not a person. For a portfolio time-trial that's the right friction: zero friction to play, zero credential surface to protect.
Four sectors with distinct characters are enough for the speed chart to say something. Adding tracks is content, not architecture.
No rival AI, no damage, no tuning, no multiple cars, no weather, no day/night cycle. Each of those is a weekend that doesn't exist.
Insurance platform · Web, mobile and CRM