Ghostline
A browser time-trial with an edge-verified leaderboard.
You race the track record and your own best lap — nothing else. I cut the top-3 ghosts on purpose: several translucent cars on the same racing line pile up and stop saying anything. Unity doesn't decide who won; it asks a backend of my own on Cloudflare Workers, and that backend takes nothing on faith.
- Unity 6
- WebGL
- Cloudflare Workers
- Durable Objects
- R2
- KV
- Hono
- TypeScript
Leaderboard
connecting…| # | Driver | Time | vs. leader |
|---|---|---|---|
| — | |||
The WS only streams verified runs: LeaderboardDO.topMessage builds the top-100 with getLeaderboard(trackVersion, true, 100), so a flagged run never arrives by push.
The "vs. leader" delta isn't in the payload — I compute it myself on the client (totalMs − top[0].totalMs).
Connect with the Upgrade: websocket header; the server sends the full top-100 as soon as the connection opens and keeps it alive with textual ping/pong. Cap of 64 sockets per track → 429 live_capacity_exceeded with Retry-After: 30; subscribing is limited to 30 attempts per 60 s per IP.
See also flagged (via REST)
These aren't in the stream: you fetch them separately with GET /v1/tracks/:id/leaderboard?verified=false.
The intersection, not the two halves
It isn't "I know Unity" and "I know backend" as separate claims: it's a real-time game client held up by server infrastructure I already run in production. The demo has to make that intersection visible in under 60 seconds for whoever opens the link.
Playable
The game is deliberately simple. The depth is in the pipeline — you open the link and you're already racing, nothing to install. If all you do is read this, you've already understood the project.
Hostile client
The endpoints are public and the payload can be read out of the JavaScript bundle: anyone can skip the game and call the API with fabricated data. I don't promise perfect anti-cheat. I promise to say so with judgment.
Real production
Idempotency inside a Durable Object, rate limiting, server-side validation, observability. It's the same guarantee as the distributed Redis lock at Mecánica MX, on a different substrate — the pattern transfers, the infrastructure doesn't.
A run crosses four boundaries
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.
Who talks to whom
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.
The sequence of a run
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.
The state machine that forces it
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.
The cascade, not the list
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.
Every push to main
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.
Six times, two reasonable paths
This is what you can read without opening the repos: the option I discarded, the one I chose, and why.
Stopwatch
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.
Run validation
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.
Ghost
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.
Source of truth
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.
Suspicious runs
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.
Idempotency
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.
Six checks, cheap to expensive
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 |
|---|---|---|
| 1.runToken HMAC valid, not expired, not consumed | Replays of the same payload | rejected |
| 2.Server wall-clock ≥ reported time (500 ms margin) | Impossible times — the only truly authoritative check | rejected |
| 3.Sector sum == total, correct order, none at 0 | Hand-crafted payloads | rejected |
| 4.Each sector ≥ the track's theoretical minimum (KV) | Teleport or section skip | flagged |
| 5.Coherent trail: speed ≤ vmax, positions inside the bounds | Speedhack, leaving the circuit | flagged |
| 6.Rate limit per device + IP in RateLimiterDO | Submit spam | rejected |
What it does NOT cover
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 trail reconstructs the lap
10 samples per second of position, yaw and speed — enough to reconstruct the real rhythm of the lap without storing more than a handful of KB per run.
What it doesn't cover, and why that's fine
No real-time multiplayer
The Durable Object is already step 1 toward multiplayer. It goes in the README as a next step, not in the MVP.
No accounts, no login
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.
A single track
Four sectors with distinct characters are enough for the speed chart to say something. Adding tracks is content, not architecture.
No variety, on purpose
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.
Two repos, one contract
The same dependency-direction discipline runs on both sides of the repo: domain in the API imports nothing from Cloudflare, Core in Unity imports nothing from Net. On its own, that consistency is already a signal of judgment.
- Unity 6
- WebGL
- Cloudflare Workers
- Durable Objects
- R2
- KV
- Hono
- TypeScript
