Skip to content
Backend / DevOps Developer

Ghostline

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.

  • TypeScript
  • Cloudflare Workers
  • Durable Objects
  • Hono
Ghostline
~60 ms
Run validation at the edge
6
Server-side checks per submit
64
Live sockets per track (WS + hibernation)
2
Public repos: game (C#) and API (TypeScript)
01

The story

01.1

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.

01.2

Solution

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.

01.3

Result

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.

02

What I built

  1. 02.1Backend Developer

    Authoritative API on Cloudflare Workers

    Six Hono endpoints with validation by bounds, single-use HMAC tokens, idempotency and rate limiting inside Durable Objects, and observability with structured logs.

    Repository
  2. 02.2DevOps

    Build and deploy pipeline

    Unity build in batchmode, WebGL hosting with Brotli and frame-ancestors headers, and QA/production environment deploys with wrangler.

03
04

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.

Loading...

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.

Loading...

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.

Loading...

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.

Loading...

Every push to main

Loading...

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.

05

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

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.

Run validation

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.

Ghost

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.

Source of truth

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.

Suspicious runs

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.

Idempotency

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.

06

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.

CheckWhat it coversConsequence
01runToken HMAC valid, not expired, not consumedReplays of the same payloadrejected
02Server wall-clock ≥ reported time (500 ms margin)Impossible times — the only truly authoritative checkrejected
03Sector sum == total, correct order, none at 0Hand-crafted payloadsrejected
04Each sector ≥ the track's theoretical minimum (KV)Teleport or section skipflagged
05Coherent trail: speed ≤ vmax, positions inside the boundsSpeedhack, leaving the circuitflagged
06Rate limit per device + IP in RateLimiterDOSubmit spamrejected

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).

07

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

Next project

Insurtech Ecosystem

Insurance platform · Web, mobile and CRM