Back to the log
[insight]2025.07.027 min readdrawn by Muhammed Musthafa S · Founder & Lead Developer

Your Leaderboard Is Lying: Verifying Scores by Replaying the Game

Anything the client computes, the client can forge. The fix is not obfuscation or rate limits — it is refusing to accept a score at all and replaying the moves server-side.

The first version of the Snapzule leaderboard worked like almost every leaderboard on the internet. The game finishes, the client knows the score, the client writes the score.

It took about four minutes to break it. Open devtools, find the submission call, change the number, send it. Rank one, six moves, 0.4 seconds. No hacking required — just a browser and mild curiosity.

This is not a Snapzule problem. It is the shape of the problem: anything the client computes, the client can lie about. A leaderboard is a public claim about who is best, which makes it the one feature where being wrong is visible to everyone.

The three things that don't work

Before the thing that does, the three that people reach for first.

Obfuscating the payload. Minify the code, encode the score, hash it with a secret. The secret is in the bundle. Everything the browser must know to produce a valid request is in the browser, and "in the browser" means "in the attacker's hands." This buys minutes.

Rate limiting and sanity bounds. Reject scores below some threshold — nobody solves a 6×6 in under twelve seconds, so reject anything faster. This is worth doing and it does not solve anything. The cheater submits thirteen seconds. You have not stopped the lie, you have negotiated its size.

Checking a signature the client generates. Same problem as obfuscation with more ceremony.

The pattern in all three is that they try to make the client's claim trustworthy. It cannot be made trustworthy. The only move is to stop accepting the claim.

Don't submit the score, submit the game

The client does not report its result. It reports the moves it made, and the server plays them.

{ seed, gridSize, moves: "3a7f2b1e9c...", elapsedMs }

No score anywhere in that payload. The server takes the seed, regenerates the identical scrambled puzzle, applies every move in order, and checks whether the board is solved. If it is, the server computes the score itself — from the move count it just replayed and the elapsed time it just validated.

There is nothing to tamper with, because the client is no longer asserting an outcome. It is submitting evidence, and the server derives the outcome. A forged submission has to be a genuinely valid sequence of moves that actually solves the puzzle — which is not forgery, that is playing the game.

TIP — The general form

Never accept a computed result from an untrusted client. Accept the inputs, recompute the result on your side. It applies far beyond games — order totals, discount eligibility, permission checks, usage-based billing. If the client sends you a number you could have derived yourself, derive it yourself.

This only works because the puzzle is deterministic

Replay verification has a hard prerequisite: the server must be able to reconstruct the exact same puzzle the player saw. If the shuffle used Math.random(), the server has no way to know what board the moves were made against, and the whole approach collapses.

So the scramble is generated from a seed, through a PRNG that is deterministic across both runtimes:

// Mulberry32 — small, fast, and identical everywhere it runs.
function mulberry32(seed) {
  return function () {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

The daily challenge seeds from a hash of the UTC date, so everyone on Earth gets the same board on the same day without any coordination. A shared scramble URL carries its seed, so a link reproduces the puzzle exactly.

Determinism was originally a product feature — shareable puzzles, fair daily challenges. It turned out to be the security mechanism too. That is not a coincidence: verifiability and reproducibility are the same property viewed from two directions.

Duplicating code on purpose

The puzzle model and the PRNG exist twice — once in the client bundle, once in the Cloud Function. Byte for byte the same logic, in two files, with a comment at the top of each saying if you change one, change both.

Every instinct says extract a shared package. I didn't, and I would make the same call again at this size. A shared module between a browser bundle and a Cloud Function means a build step, a versioning story, and a deploy ordering problem — because if the client ships a new shuffle before the function does, every score in flight fails verification. Two files and a loud comment cost nothing and fail loudly in review.

The honest framing: the duplication is the cheap option now and the wrong option at scale. When a third consumer appears, or when the model changes more than once a quarter, extract it. Until then a comment is doing the job a package would.

The rules the server actually enforces

Replaying the moves proves the puzzle was solved. It does not prove a human solved it. A script can produce a perfect solve sequence in three milliseconds.

So there is a floor on how fast a move can be: 120 milliseconds between swaps. That is comfortably below human reaction on a deliberate drag, and comfortably above what any automated solver would bother simulating. Submissions that beat it are rejected regardless of whether the board is solved.

The full server-side check:

  1. Regenerate the board from the seed and grid size
  2. Apply every move in the submitted sequence
  3. Confirm the final board is solved — reject if not
  4. Confirm move count and elapsed time are internally consistent
  5. Confirm the minimum-per-swap floor holds
  6. Compute the score from the replay, ignoring anything the client claimed
  7. Write the entry

Only then does a row appear on the leaderboard.

Close the direct write path

All of the above is theatre if the client can still write to the leaderboard collection. The Firestore rules deny it outright:

match /snapzule_scores/{doc} {
  allow read: if true;
  allow write: if false;
}

false for everyone. No exceptions, no authenticated bypass. The Cloud Function writes with the Admin SDK, which operates outside the rules — so the only path to the leaderboard runs through the verifier.

This is the step that is easy to skip and fatal to skip. A verification function is worth exactly nothing if there is a second door next to it.

The unexpected payoff

Storing move sequences instead of scores was a security decision. It handed me three features for free.

Replays. The move list is already there, so the site can animate any solve step by step, with play, pause, and speed control.

Ghost racing. Race the current number one in real time — their moves are on file, so their progress bar can advance alongside yours as you play.

Shareable proof. The move list encodes to base36 and fits in a URL. A score is a claim; a replay is a receipt.

None of that was planned. It fell out of storing what happened rather than storing a summary of what happened.

What generalises

Games are the loud version of this. The quiet version is everywhere:

  • A checkout that accepts a total from the client instead of pricing the cart server-side
  • A usage meter that trusts the SDK's reported count
  • A feature gate evaluated in the front end and never re-checked
  • Any workflow where the browser decides whether a step was completed

Same fix every time. Accept inputs, not conclusions. Store what happened, not what it added up to. Put the only write path behind the thing that checks.

The client is not your code. It is a suggestion running on someone else's computer.

#security#anti-cheat#Cloud Functions#determinism#Firebase

Enjoyed this entry?

Project

Outshorts

AI platforms, full-stack SaaS, custom systems, and ready-to-ship solutions — built by a studio that ships fast.

Title block

DRAWN BY
MUSTHAFA
SHEET
OUTSHORTS.IN
REV
2026

© 2026 OUTSHORTS — ALL SHEETS CURRENT