work · web app

goosetype

react · vite · typescript · supabase · vercel edge · github →

goosetype · racing a university-themed prompt, live

goosetype is a monkeytype-style typing race for students, where every result rolls up into a per-university leaderboard. the fun part is the typing. the part i actually built it to learn was the part nobody sees: how do you keep a public leaderboard honest when the only thing standing between a real score and a fake one is a browser you don't control? the answer isn't one clever check. it's defense in depth, several cheap honesty constraints layered so that beating all of them is harder than just typing.

an accuracy that never forgives a correction

the first honesty constraint is in the accuracy formula itself. it would be easy to compute accuracy from the final, corrected text, but then backspace-spamming your way to a clean result costs nothing. so the denominator folds in corrected mistakes: once you mistype a character, fixing it still permanently lowers your accuracy.

const totalCharsTyped = correctChars + incorrectChars + mistakes;
const accuracy = totalCharsTyped > 0
  ? (correctChars / totalCharsTyped) * 100
  : 100;

the metric remembers what happened, not just where you ended up. that single design choice quietly closes the most obvious way to fake a perfect run.

a clock that doesn't drift

a naive countdown decrements a counter on a setInterval(…, 1000), and quietly accumulates drift because intervals are never exactly a second. goosetype computes time left from the wall-clock delta and re-syncs to the next exact second boundary, so the timer stays true over a sixty-second test.

const elapsedMs = Date.now() - startTime;
const msUntilNextSecond = 1000 - (elapsedMs % 1000);
setTimeout(() => {
  updateTimeLeft();
  setInterval(updateTimeLeft, 1000);   // now aligned to the second
}, msUntilNextSecond);

wpm is words over real elapsed minutes, and the elapsed time is measured, not counted. when the denominator of your score is time, you do not get to be sloppy about time.

keystroke biometrics, the part i'm proudest of

the deepest piece is a keystroke tracker that records the timing of every key the way behavioral-biometrics research does. for each keystroke it captures dwell (how long the key is held), flight (the gap from the previous release, which goes negative when you press the next key before releasing the last, the signature of a genuinely fast typist), and digraph latency (the gap between consecutive key-presses). it pairs keydowns to keyups by physical key code through a pending-press map, and it times everything with performance.now(), a monotonic, sub-millisecond clock, not the wall clock.

const flight   = lastKeyup   != null ? now - lastKeyup   : null;  // <0 = rollover
const digraph  = lastKeydown != null ? now - lastKeydown  : null;
this.pendingKeydowns.set(e.code, ks);   // handle overlapping presses
dwell · key held key A key B digraph flight < 0 · rollover ↓A ↓B ↑A
dwell, digraph, and flight, timed on a monotonic sub-millisecond clock. when flight goes negative, the next key was pressed before the last was released, the signature of a real fast typist.

a human's typing has a rhythm, and a script's doesn't. this is the raw signal you'd need to tell them apart. it ships to an env-gated endpoint with keepalive so the data survives the page unloading, and it swallows every error so it can never disrupt the actual test. it's collection infrastructure, the substrate for human-vs-bot scoring, not yet the judge.

making the database the final authority

client checks are bypassable by anyone who finds the api, so the constraints that truly count live in postgres. every submission carries a client-generated uuid under a UNIQUE constraint, so a retry or a double-click can't double-count, and the duplicate surfaces as error code 23505, which the client treats as success.

// duplicate request_id -> "already submitted", not an error
if (error?.code === "23505") return { ok: true, duplicate: true };

row-level security pins inserts to auth.uid() = user_id, a vercel edge middleware rate-limits sixty requests a minute per ip, and a trigger keeps running per-university sums so the leaderboard reads pre-computed averages instead of scanning the whole table on every load. and crucially, rank isn't a single hero run, it's an accuracy-weighted average diluted across submissions, so one cheated score barely moves a university's number. the system is built so honesty is the path of least resistance.

the only check a cheater can't route around is the one the database enforces. everything in the browser is a speed bump, not a wall.

i'll name the limits plainly: most of the anti-cheat is client-side and bypassable by hitting supabase directly, and the keystroke endpoint is unset in this repo, so the biometrics are collected and discarded for now. but the architecture, cheap honest constraints stacked, with the database as the one authority you can't argue with, is exactly how i'd reason about it again. trust, like a reputation, is built in layers and lost in one shortcut. 一步一个脚印, one step, one footprint.

next · scrollify → all work →