A few months ago I built a daily brain-training game with prize tournaments — ten small cognitive games, a fresh challenge every day, and a leaderboard that decides who wins real rewards. The first design question wasn't about graphics or game feel. It was this:
If everyone is competing on a "random" puzzle, how do you make sure everyone got the same random puzzle — and that nobody can lie about their score?
Those are actually two problems, and they pull in opposite directions. Fairness wants the randomness to be shared and predictable — every player on a given day should face the identical challenge, or the leaderboard is meaningless. Anti-cheat wants the server to be able to independently verify a score without trusting the player's device. The thing that solves both at once is a seeded RNG.
This post is about that pattern: how a single short string makes a "random" puzzle identical for every player and recomputable on the server, why I deliberately made the seed public (the opposite of what you'd do for a high-score game), and the one game that broke the whole model and forced me to think differently.
A quick note on stack: my client is Flutter (Dart) and my backend is TypeScript edge functions, so that's what the snippets look like. But the idea is language-agnostic — it works the same with Kotlin, React Native, or anything else. The trick isn't the language, it's getting two different runtimes to agree on what "random" means.
The core idea: randomness from a string
A normal random number generator gives you different numbers every time, which is exactly what you don't want here. If my phone rolls a different set of math problems than yours, we're not playing the same game, so comparing our scores is nonsense.
A seeded RNG fixes this. You give it a starting value — the seed — and from that point it produces a fixed, repeatable sequence of "random" numbers. Same seed in, same sequence out, every single time, on every device. It only looks random; it's completely deterministic.
So the daily challenge seed is just a human-readable string:
2026-03-16-speed_calc
Date, plus the game name. That's it. Everyone playing Speed Calc on March 16th derives their puzzle from that exact string, so everyone gets the exact same equations in the exact same order. Tomorrow the date changes, the seed changes, and a fresh challenge appears for everyone at once.
String dailySeed(DateTime date, GameType game) {
final dateStr = DateFormat('yyyy-MM-dd').format(date);
return '$dateStr-${game.seedName}';
}
There's no server round-trip to "fetch today's puzzle." The client can generate the whole challenge offline from the date, and the server can regenerate the identical challenge whenever it needs to check a score. The seed is the single source of truth, and it weighs almost nothing.
The RNG itself
The generator is small on purpose. It takes the seed string, hashes it with SHA-256 to get a stable starting state, then runs a classic 48-bit linear congruential generator — the same constants you'd recognize from older Java Random implementations:
class SeededRandom {
int _state;
static const int _mask = (1 << 48) - 1;
static const int _multiplier = 0x5DEECE66D;
static const int _increment = 0xB;
SeededRandom(String seed) : _state = _deriveSeed(seed);
static int _deriveSeed(String seed) {
final digest = sha256.convert(utf8.encode(seed));
final byteData = ByteData.sublistView(Uint8List.fromList(digest.bytes));
return byteData.getInt64(0).abs();
}
double nextDouble() {
_state = (_state * _multiplier + _increment) & _mask;
return _state / (_mask + 1);
}
int nextInt(int max) => (nextDouble() * max).floor();
int range(int min, int max) => min + nextInt(max - min);
}
Nothing here is cryptographically fancy, and it doesn't need to be — the goal isn't unpredictability, it's reproducibility. The most important comment in my entire codebase lives right above this class: "same seed always produces the same sequence across Dart and TypeScript." That cross-runtime guarantee is the whole foundation. If the Dart client and the TypeScript server ever disagree about what number comes next, everything downstream falls apart.
On top of the raw generator there's a seeded Fisher-Yates shuffle, so a game can pre-generate a pool of, say, 30 equations and shuffle them deterministically. Both client and server build the pool, shuffle it with the same RNG calls in the same order, and walk through it with a cursor. As long as both sides consume the RNG identically, they stay in lockstep forever.
Why I made the seed public (and when you shouldn't)
Here's the design decision I want to call out, because it's the opposite of what a lot of anti-cheat advice tells you to do.
For a high-score game where each run should be unguessable, you'd want a secret, per-run seed issued by the server right before play — so the player can't precompute or predict the run. Unpredictability is the security property there.
A daily challenge is the inverse. The seed is derived from the public date, so it's completely predictable — anyone can work out tomorrow's seed. And that's fine, because fairness, not secrecy, is the goal. Everyone facing the identical puzzle is a feature. Knowing the seed in advance doesn't help you cheat, because the seed only tells you which equations you'll get — it doesn't solve them for you, and it doesn't let you submit a fake score.
That last part is the key, and it's where validation comes in. A public seed is only safe if the server independently recomputes the result instead of trusting the client. The seed makes the puzzle fair; the server-side recompute makes the score honest.
Validation: the server recomputes, then verifies
Every score submission goes through a validate-score function before it counts. The shape is:
- Authenticate and rate-limit the user (caps per minute and per hour, enforced in the database so they survive cold starts).
-
Check the request signature. Each submission is HMAC-SHA256 signed with a secret compiled into the app, plus a timestamp and a one-time nonce. The server rebuilds the signature over
nonce.timestamp.body, compares it in constant time, rejects anything older than a 10-second window, and burns the nonce so the same request can't be replayed. - Recompute the puzzle from the seed and check the player's answers against it. That third step is the payoff of the whole seeded design. For Speed Calc, the server re-derives the exact same shuffled equation pool the player saw — same seed, same RNG call order, same ranges — and checks each submitted answer against the equation it knows was on screen. The client's reported score is never trusted; it's recomputed.
The catch is that the two sides have to generate equations identically, in two different languages. Here's the same level-2 equation (addition or subtraction, operands 1–50, subtraction arranged to stay non-negative) in the Dart client and the TypeScript server:
// Dart — client
Equation _level2() {
final isAdd = _rng.nextInt(2) == 0;
if (isAdd) {
final a = _rng.range(1, 51);
final b = _rng.range(1, 51);
return Equation(expression: '$a + $b', answer: a + b, difficulty: 2);
} else {
var a = _rng.range(1, 51);
var b = _rng.range(1, 51);
if (b > a) { final t = a; a = b; b = t; } // keep result non-negative
return Equation(expression: '$a - $b', answer: a - b, difficulty: 2);
}
}
// TypeScript — server. MUST match Dart exactly: same RNG call order, same ranges.
case 2: {
const isAdd = rng.nextInt(2) === 0;
let a = rng.range(1, 51);
let b = rng.range(1, 51);
if (isAdd) return { expression: `${a} + ${b}`, answer: a + b };
if (b > a) [a, b] = [b, a];
return { expression: `${a} - ${b}`, answer: a - b };
}
Look closely at the order of the RNG calls — nextInt(2) for the operator, then range(1, 51) twice — because that order is the contract. The server has to pull values off the generator in precisely the same sequence as the client, or the two pools diverge and a perfectly honest player's score fails to validate. The comment in the server file isn't decoration; it's a warning to my future self.
For nine of the ten games, this is clean: the puzzle is pure logic, there's a single correct answer per question, and the server can reproduce it exactly. And then there was the tenth.
The game that broke the model: Reaction Tap
Every other game is computed. An equation has one right answer. A scrambled word unscrambles to one word. A pattern has one next step. The server regenerates the puzzle and checks your answer against a known-correct value. Simple.
Reaction Tap isn't like that, and it was genuinely the hard one to figure out. The game shows a target after a random delay and measures how fast you tap it. The delay is seeded — the server can regenerate exactly when the target appeared. But the score depends on your reaction time, which is a human measurement the server never witnessed. There's no "correct answer" to recompute. I couldn't validate it the way I validated the other nine, and that's the part that took me a while to get right.
The realization was that you stop trying to recompute the score and instead validate the boundaries of what's physically possible. The server still advances the RNG through every round to stay in sync (each round consumes three values — the delay and the target's x/y position), but the actual checks are about plausibility:
const MIN_REACTION_MS = 100;
const tooFast = reactionMs < MIN_REACTION_MS;
// A sub-100ms reaction can never legitimately be a correct hit
if (tooFast && answer.correct) {
return { valid: false, reason: 'reaction below human minimum — cannot be correct' };
}
// Points are recomputed server-side, never trusted from the client
const points = tooFast ? 0 : Math.max(0, Math.min(1000, 1000 - reactionMs));
Negative reaction time is impossible, so it's rejected. A reaction under ~100ms is faster than a human nervous system can actually respond, so it can't count as a legitimate hit — a tap-ahead bot or a patched timer would show up here. And the points themselves are recomputed from the reported time with the same formula the client uses, so a client that fibs about its score gets overridden.
It's a different shape of validation — verifying a plausible range instead of recomputing an exact answer — and it's the case that taught me the seeded-recompute pattern has a hard edge: it works beautifully for anything deterministic, and not at all for anything that depends on real-world human timing. For those, you fall back to trust boundaries.
What I'd take to the next project
If you're building anything where players compete on shared randomness:
- Derive shared puzzles from a seed, not from a per-device random call, so everyone genuinely plays the same thing and the server can reproduce it.
- Pick your seed's secrecy to match your goal. Public, predictable seeds for fairness (daily challenges). Secret, server-issued seeds for unpredictability (high-score runs). Same tool, opposite settings.
- A seed is only safe if the server recomputes the outcome. Sharing the puzzle is fine; trusting the score is not.
- Watch out for anything that isn't purely computed. Reaction time, sensor input, anything measured rather than derived — you can't recompute it, so validate the physically-possible range instead. The seeded RNG ended up being maybe sixty lines of code. But it quietly answers both of the questions I started with — is this fair, and is this real — and that's most of what a competitive game actually needs.
Top comments (0)