Why Trust a Client-Computed Score? A Leaderboard's Server-Side Floor
A KeyDo postmortem: WPM is computed in the browser, so how does a global leaderboard stop someone from just POSTing a 9999? Cap validation, one row per person per mode, best-score logic in SQL.
KeyDo’s scores — WPM, points — are all computed in your browser. The frontend computes, then POSTs to the backend to make the leaderboard. The problem is obvious: anyone can open the console and manually fetch('/api/score', ...) a 9999 WPM. For a game that scores purely on the frontend, how can a global leaderboard have any credibility at all? This post is about the few not-complicated but mandatory floors on the backend.
The backend is one Cloudflare Worker + D1 (SQLite), the whole worker/index.js at 128 lines.
Floor one: a validity cap, reject anything over it
However absurd a number the frontend can compute, the backend doesn’t care; but the backend knows numbers a human can’t reach. So each mode gets a hard cap, and anything over it is rejected outright:
// Server-side validity caps: reject anything over (basic anti-spam)
const VALUE_CAPS = { speed: 300, challenge: 50000, rain: 500000, time: 18_000_000 }
The speed (WPM) cap is 300 — the world-record typing speed is only around 200, so 300 is a “can’t be a human” safety line. The time (cumulative practice seconds) cap is 5000 hours. Submission gets a hard veto:
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return bad('invalid value')
if (value > VALUE_CAPS[mode]) return bad('value out of range')
This won’t stop “submitting a fake WPM of 250” — that kind of cheat the backend can’t distinguish. But it stops the dumbest and most common kind: casually filling in an astronomical number to top the board. The goal of server-side validation isn’t “eliminate all cheating,” but “keep the leaderboard from being ruined by one 9999.” For a free typing game, that most cost-effective floor is enough.
Trust no input
Beyond value, the other fields all get validated too, because they all come from an untrusted client:
if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/i.test(id)) return bad('invalid id') // must be a UUID
if (!MODES.has(mode)) return bad('invalid mode') // whitelist
const safeName = String(name ?? '').trim().slice(0, 24) || 'Nameless Warrior' // truncate + fallback
id must match the UUID format, mode must be in the whitelist set, name is cut to 24 chars with a default for empty. Accuracy is range-checked 0–100 too, out of range treated as unsubmitted (stored null). The assumption “the frontend sends valid data” can’t hold even once on the backend — the frontend is for the user, and also for the attacker. All SQL goes through D1’s prepared statements with bound parameters, closing injection at the root.
Best score per person per mode: expressed in one SQL
The leaderboard keeps only each person’s best per mode, not a log. This “update only when higher” logic is written straight into one INSERT ... ON CONFLICT, no read-then-decide:
INSERT INTO scores (user_id, mode, name, value, accuracy, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (user_id, mode) DO UPDATE SET
value = CASE WHEN excluded.value > scores.value THEN excluded.value ELSE scores.value END,
accuracy = CASE WHEN excluded.value > scores.value THEN excluded.accuracy ELSE scores.accuracy END,
updated_at = excluded.updated_at
The unique constraint on (user_id, mode) guarantees one row per person per mode; on conflict, a CASE WHEN decides — update value and its accuracy only when the new score is higher, otherwise keep the old. Handing “take the max” to the database in one atomic statement is one fewer round trip than “SELECT the old value, compare in JS, then UPDATE,” with no race under concurrency. The practice-time board is even simpler: the client submits cumulative seconds each time, and the server naturally takes the MAX, monotonically increasing.
An anonymous but stable identity
With no login, how do you recognize “each person”? On first open, a crypto.randomUUID() is generated and stored in localStorage — that’s your identity. The name is auto-generated too — but using the first 8 hex digits of the UUID for a deterministic name (hex to number, indexing a set of dojo-style prefixes and suffixes), so refreshing the page never changes the name; the same id is always the same “Candle-Bearing Warrior.”
On the leaderboard, only the name and the first 4 UUID digits are returned as a distinguishing tag (substr(user_id, 1, 4)), never the full id. The identity can also be exported as a KEYDO-<base64> token to migrate across devices — a Chinese name is UTF-8-encoded before base64, dodging the trap of btoa not accepting non-Latin1. Anonymous doesn’t mean unstable: no account needed, but the identity is constant on this device, migratable across devices, and doesn’t expose the full identifier on the public board.
Takeaways
Building a leaderboard for a purely frontend-scored game that won’t get ruined:
- A validity cap: one hard cap per mode (WPM 300, etc.), reject anything over — stops the dumbest cheat, topping the board with astronomical numbers;
- Trust no input: id validated as UUID, mode via whitelist, name truncated with a fallback, all SQL via prepared statements;
- Best score in one SQL:
INSERT ... ON CONFLICT DO UPDATE+CASE WHENtakes the max atomically, saving a round trip with no race; - Anonymous but stable identity: UUID + deterministic name, constant locally, exportable for migration, only the first 4 digits on the board.
In one line: client data is never trustworthy, but the server needn’t chase “eliminate all cheating” either — set a floor “no human can cross,” keep the dumbest kind out the door, and for a small game that’s enough.
Comments