Anti-cheat event ledger
1. Foundational mental model
Section titled “1. Foundational mental model”Solo games run entirely in the browser. The server never sees a question until the run is over, so it cannot hold the answers the way multiplayer does. Instead the client keeps an append-only list of events: a q event when a flag appears, an a event when the player answers or the timer runs out. At the end, the client signs the list and POSTs it to /api/game-results. The game server re-derives the signature, checks that every question has exactly one answer, and recomputes score and reaction times from the timestamps. Nothing the client claims about its own score is trusted.
The signature is a SHA-256 over the events, a salt, and the session token. The salt comes from PUBLIC_SOLO_GAME_SALT, a public env value that the web app ships to every browser. Anyone who reads the bundle can sign any ledger they like. The signature therefore catches accidental corruption and lazy edits to a captured request body. It does not stop a player who writes their own signer. The timing rules in §4 do the real filtering, and they only look at plausibility.
The simulator below runs the same three timing rules as scoreAnswers. A parity test (apps/docs/src/lib/ledger-reaction-rules.test.ts) replays each preset and 300 random ledgers through the real ledgerManager.dryRunReplay and asserts the same verdict.
Interactive
Solo ledger reaction-time validator
Build a run's per-question reaction times and check them against the same three humanity rules the game server applies in ledger-manager.ts.
- Answers
- 10
- Mean μ
- 1098.0 ms
- Std dev σ
- 443.2 ms
- Every answer ≥ 100 ms Fastest: 610 ms Pass
- Mean ≥ 350 ms Mean: 1098.0 ms Pass
- Std dev ≥ 50 ms (only when > 5 answers) σ: 443.2 ms Pass
Accepted: the run is scored
Response the client sees{ "status": "success", "xpToken": "<token or null>" }On acceptance the server recomputes the score from the ledger. The status is success, opted_out or no_country; xpToken is only minted for signed-in
players on runs long enough to earn XP.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Signing
Section titled “Signing”The signer sorts the events by timestamp in place, serializes them, appends salt and session token, and hashes:
export const DEFAULT_SOLO_GAME_SALT = "flags-default-salt-2026";
/** * Generates a deterministic signature for a sequence of game events. */export async function signLedger( events: GameEvent[], salt: string, sessionId: string): Promise<string> { const data = JSON.stringify(events.sort((a, b) => a.ts - b.ts)) + salt + sessionId; const encoder = new TextEncoder(); const buffer = encoder.encode(data); const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");}
export async function verifyLedgerSignature( ledger: GameLedger, salt: string, sessionId: string): Promise<boolean> { const expected = await signLedger(ledger.events, salt, sessionId); return ledger.signature === expected;}This is a plain hash of message ‖ key, not an HMAC. Because the key is public, the difference does not matter here. JSON.stringify output depends on key order, so the server’s parsed objects must list keys in the same order the client created them.
The server refuses to boot in production with the default salt:
if ( env.NODE_ENV === "production" && env.PUBLIC_SOLO_GAME_SALT === DEFAULT_SOLO_GAME_SALT) { throw new Error("PUBLIC_SOLO_GAME_SALT must be set in production");}Recording events
Section titled “Recording events”game-runtime.svelte.ts pushes a q event when a question is shown and an a event on answer or timeout. A timeout records the literal code timeout; a wrong pick with no selected code records wrong.
this.#ledgerEvents.push({ type: "q", code: questionData.currentCountry.code, ts: Date.now(), index: questionIndex, options: questionData.options.map((option) => option.code), });The server schema that parses those events:
export const GameEventSchema = z.object({ type: z.enum(["q", "a"]), code: z.string(), ts: z.number(), index: z.number(),});
export const GameLedgerSchema = z.object({ events: z.array(GameEventSchema), signature: z.string(),});Replay order
Section titled “Replay order”replayLedger checks the signature first, then structure, then timing. The first failure wins.
The timing thresholds are three private constants:
class LedgerManager { private readonly MIN_HARD_FLOOR_MS = 100; // Physically impossible per question private readonly MIN_BIOLOGICAL_AVG_MS = 350; // Human recognition + motor limit private readonly MIN_JITTER_STD_DEV = 50; // Bots are too consistent private readonly invalidReasonCounts = new Map<string, number>();
private markInvalid(reason: string): void { const nextCount = (this.invalidReasonCounts.get(reason) ?? 0) + 1; this.invalidReasonCounts.set(reason, nextCount); if (nextCount % 25 === 0) { logger.warn("Ledger validation rejections summary", { reason, count: nextCount, }); } }And the mean and standard deviation checks, run after every answer has passed the floor:
const avgTime = reactionTimes.reduce((a, b) => a + b, 0) / reactionTimes.length;
if (avgTime < this.MIN_BIOLOGICAL_AVG_MS) { return this.reject( "avg_below_human_limit", `Average time (${Math.round(avgTime)}ms) below human limit`, totalQuestions, totalAnswers, recordRejections ); }
const variance = reactionTimes.reduce((acc, b) => acc + (b - avgTime) ** 2, 0) / reactionTimes.length; const stdDev = Math.sqrt(variance);
if (stdDev < this.MIN_JITTER_STD_DEV && reactionTimes.length > 5) { return this.reject( "insufficient_variance", `Insufficient response variance (${Math.round(stdDev)}ms) - potential bot`, totalQuestions, totalAnswers, recordRejections ); }The withheld reason
Section titled “The withheld reason”postSoloGameResults logs the full replay result and returns a fixed body:
if (!replay.isValid) { logRejectedSoloGameResult({ sessionLogRef, reason: replay.reason ?? "unknown", countryCode, origin, body: validation.data, replay, }); recordSoloGameRejected({ startedAt: getSoloLedgerStartTimestamp(validation.data), questionsCount: replay.totalQuestions || validation.data.totalQuestions, rejectionCode: replay.code ?? "unknown", sourceIp: getClientIPAddress(req), trustedCountryCode: countryCode, }); return createValidatedJsonResponse( GameResultsVerificationFailedResponseSchema, { error: "Game result could not be verified" }, 403, origin ); }A script that probes the endpoint learns only pass or fail, not which rule it tripped.
The client treats a 403 as a possibly stale session. submitSoloGameResults refreshes the token, re-signs, and retries once:
if (!result.ok && result.status === 403) { const refreshedToken = await flagsApiSubmitDeps.refreshSessionToken(); if (!refreshedToken) { return { error: result.error }; } activeSignature = await flagsApiSubmitDeps.signLedger( params.ledgerEvents, salt, refreshedToken ); result = await postSoloGameResults( url, buildSoloGameResultsBody(params, activeSignature) );Debugging with MCP
Section titled “Debugging with MCP”When the optional agent MCP server is enabled (docs/reference/mcp-server.md), replay_solo_ledger runs the same replay without side effects. It calls dryRunReplay, which skips the rejection counters, and returns the full result including code and reason:
"replay_solo_ledger", { description: "Dry-run solo ledger validation and scoring. Read-only — does not persist stats, issue XP tokens, or update rejection counters.", inputSchema: { sessionToken: z .string() .min(1) .describe("Anonymous session token used when the ledger was signed."), ledger: GameLedgerSchema.describe("Signed solo game ledger from the client."), }, }, async ({ sessionToken, ledger }) => { const replay = await ledgerManager.dryRunReplay(ledger, sessionToken);
return jsonToolResult({ persisted: false, replay, }); }The tool input goes through the same GameLedgerSchema, so options is kept and the dry-run hash matches the signature the client produced.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Reaction time
Section titled “Reaction time”For question index with question timestamp and answer timestamp (both client Date.now() in milliseconds):
Timeouts count. A player who lets the timer expire contributes a large , which raises the mean and the spread.
With answered questions, the server computes the arithmetic mean and the population standard deviation (it divides by , not ):
A ledger is rejected by the first rule that holds, checked in this order:
All comparisons are strict. passes the floor, passes the mean rule, and passes the jitter rule. The floor rule reports the first offending answer in timestamp order, not the smallest.
| Rule | Code | Threshold | Applies when |
|---|---|---|---|
| Hard floor | inhuman_reaction_time | 100 ms | every answer |
| Biological mean | avg_below_human_limit | 350 ms | |
| Jitter | insufficient_variance | 50 ms |
What it takes to evade
Section titled “What it takes to evade”A script that knows the rules only has to pick timestamps inside the accepted region. For reaction times drawn uniformly from , the standard deviation is . Passing the jitter rule needs
and passing the floor needs . So uniform(450, 650) passes every rule. The rules raise the bar from “replay a fixed delay” to “add noise”, which is a few characters of code.
False positives
Section titled “False positives”The jitter rule can also fire on a real player. A fast, consistent run such as the simulator’s Fast and steady preset (mean 419.5 ms, ms over 10 answers) is rejected. The rule only bites when the mean is already near the floor: at a 1-second mean, means a spread under 5 % of the mean, which is rare for real players. The Fast but human preset (mean 453.5 ms, ms) passes.
Rejection log sampling
Section titled “Rejection log sampling”markInvalid keeps a per-code counter for the process lifetime and emits one warn line every 25 rejections of the same code. Individual rejections are still logged by logRejectedSoloGameResult in the handler.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Key order is part of the signature.
signLedgerhashesJSON.stringifyof the events. The client writes question events astype,code,ts,index,options. Zod emits keys in schema order, andGameEventSchemauses that same order, withoptionsoptional so events that omit it still verify. Reordering the schema or the client object changes the string and every submission failsinvalid_signature.packages/shared/src/schemas/game-ledger-schema.test.tslocks both shapes. - Public salt. A player who extracts
PUBLIC_SOLO_GAME_SALTfrom the bundle and theirsession_tokenfrom the session-token endpoint can sign any ledger. The signature catches casual edits only. - Client clock. All timestamps come from
Date.now()in the browser. Adjusting the system clock mid-run can produce negative or huge . Negative values fail the floor; huge values inflate the mean and pass. - Equal timestamps. Events are sorted by
tswithArray.prototype.sort, which is stable. Aqandawith the sametskeep their insertion order, and fails the floor anyway. - In-place sort.
signLedgerandreplayLedgerboth sortledger.eventsin place. Callers that reuse the array see it reordered. - Session requirement. Without a
session_tokencookie the server returnsskippedand records nothing. Replays are not deduplicated by the ledger itself; the Convex forward uses asourceKeybuilt from session, start time and totals. - Rate limit before replay.
/api/game-resultsis limited to 12 submissions per session and 40 per IP per minute before any hashing happens. See Rate limiting and moderation.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”The ledger is for keeping honest leaderboards and country stats clean of edited request bodies and naive bots, at zero latency cost during play.
It is not for proving a run happened. A determined player can forge a passing ledger. Stakes in solo are XP and aggregate country stats, and the fingerprint and cohort checks described in Device fingerprint engine add a second, independent signal on the stats path.
| Alternative | Why it was not chosen |
|---|---|
| Server-issued questions per round | Needs a round trip per flag and breaks offline and instant-start solo play. Multiplayer already works this way. |
| Secret HMAC key on the server only | The client must produce the signature, so any key it holds is public. A real MAC would need the server to sign each event as it happens. |
| Report the rejection reason in the 403 | Tells a script author exactly which threshold to tune past. Operators get the reason from logs and replay_solo_ledger instead. |
| Sample standard deviation () | Slightly more lenient for short runs. The server uses population , and the jitter rule is skipped below 6 answers, which covers the small-sample bias. |
| Per-player adaptive thresholds | Requires history per player and opens a slow-poisoning attack. Fixed thresholds are simple to reason about and test. |