Sooner or later every game, simulation or procedural-generation project hits the same wall:
you want the same random numbers again. Same level layout, same shuffle, same bug.
And then you discover that Math.random() takes no seed, has never taken a seed,
and is not going to. The fix is small — a seedable generator is ten lines — but the choices
around it (which algorithm, how to seed it, when a PRNG is the wrong tool entirely) are where
people get it wrong. This is the practical version.
Why Math.random() can't be seeded
The ECMAScript spec says Math.random() returns a number in [0,1) "chosen randomly
or pseudo-randomly with approximately uniform distribution over that range, using an
implementation-defined algorithm or strategy". Implementation-defined means the engine picks,
and no engine exposes the knob.
In practice all the major engines converged on the same algorithm. V8 replaced its old MWC1616
generator with xorshift128+ in v4.9 (shipping in Chrome 49) after a public
report of poor quality; Firefox and Safari switched to xorshift128+ too. It has 128 bits of
state, a period of 2128−1, passes the TestU01 suite, and V8 generates it in batches
into a cache for speed. It is seeded per realm from an unpredictable source — and while V8 has
an internal --random-seed flag for its own testing, nothing in the language surface
lets your page reach it.
Two consequences people trip on:Math.random()is not cryptographically secure (usecrypto.getRandomValues()for anything security-relevant), and each browsing context has its own state — so "just call it in a fixed order" gives you nothing reproducible across a reload.
The generators worth using
You want something small enough to paste into your codebase, fast, and statistically decent. The reference table most JS developers end up at is bryc's PRNG survey, which benchmarks and test-suites the popular short generators. The short version:
- mulberry32 — 32 bits of state, one of the fastest in the table, and it passes gjrand. The best 232-state option in JavaScript and the sane default.
- splitmix32 — same size and speed class, derived from murmurhash3's finaliser. Excellent as a seeder for other generators.
- sfc32 — 128 bits of state, passes PractRand, still fast. The best 128-bit-state generator in the survey and what to use when 232 feels tight.
- jsf32b — well-tested, slightly slower, a fine alternative to sfc32.
- xoshiro128** — fast and popular, but its low bits are weak: it fails linear-complexity and binary-rank tests (all the xorshift family share this). Usable, but never consume its low bits directly.
- Plain LCGs — the classic
seed = (a*seed + c) % mone-liner you'll find in a hundred StackOverflow answers. Fastest of all, fails statistical tests almost immediately, and shows visible structure in 2D. Don't ship one.
Here is mulberry32, complete:
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Two details in there matter more than they look. Math.imul does a true 32-bit
integer multiply — using * would silently promote to a double and give different
numbers. And the final >>> 0 forces the unsigned interpretation before
dividing by 232; without it you get negative outputs half the time.
Seeding it properly
This is the step almost everyone skips. A generator's job is to expand state, not to fix a bad
start. Seed mulberry32 with 1, 2, 3 and the first output
of each stream is noticeably correlated; seed sfc32 with a level name truncated to a small
number and neighbouring levels look related.
Hash the seed first. The standard trick in JS is cyrb128, an avalanche hash that turns any string into four well-mixed 32-bit words — exactly the four arguments sfc32 wants, or take one word for mulberry32:
const [a, b, c, d] = cyrb128("world-seed:" + userInput);
const rand = sfc32(a, b, c, d); // 128-bit state
const rand2 = mulberry32(a); // 32-bit state
And when the seed itself should be unguessable — a daily-challenge seed, a match seed — generate
it with crypto.getRandomValues(new Uint32Array(4)), not with
Date.now(). Timestamps are a small, ordered, entirely searchable space.
One generator per concern
The mistake that quietly destroys reproducibility is sharing a single global generator. If world generation, loot rolls and particle effects all pull from the same stream, then adding a single extra particle call changes every subsequent roll — and your "seeded" world is no longer the same world after a cosmetic patch.
Derive a child generator per subsystem from the master seed instead: mulberry32(a ^ 0x9E3779B9)
for terrain, another for loot, another for effects. Now visual code can churn through as many
numbers as it likes without touching gameplay. The same discipline is what makes deterministic
netcode work — I covered why in
lockstep vs rollback netcode, where every client must produce
bit-identical simulation state from the same input log.
Where a PRNG is the wrong tool
These generators are fast because they are simple, and simple means invertible. Feed an attacker a few dozen mulberry32 outputs and the 32-bit state falls out by brute force in milliseconds; sfc32's 128 bits are harder but it was never designed to resist analysis. So:
- Session tokens, password resets, shuffle-for-money: use
crypto.getRandomValues()(orcrypto.randomBytesin Node). - Casino outcomes: a seeded PRNG isn't just insecure, it's unverifiable. The provably-fair pattern uses HMAC-SHA-256 over a committed server seed plus the player's client seed and a nonce, so the player can re-derive every result afterwards — the mechanics are in mapping a SHA-256 hash to a game outcome.
- Anything a player benefits from predicting — daily challenges, drop tables in a competitive game — should be derived server-side, even if the client re-simulates it.
A five-minute checklist
- Paste in mulberry32 (or sfc32 if you want 128 bits) instead of reaching for a dependency.
- Hash the seed with cyrb128 before it touches the generator.
- Give every subsystem its own derived stream.
- Store the seed with the save/replay — a seed you can't reproduce is just noise.
- Write one test that asserts the first five outputs for a fixed seed. It catches the day
someone "optimises"
Math.imulaway. - Never use it for anything secret. That's
crypto's job.
None of this is exotic. It's ten lines of code and one habit — hash your seed — and it converts "random" from a debugging liability into something you can replay, diff and test.