Seeded PRNGs in JavaScript

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 (use crypto.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) % m one-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.

"seed string" user / daily / match cyrb128 4 × 32-bit words sfc32 / mulberry32 state → stream 0.0 … 1.0 repeatable world stream loot stream vfx stream fast + repeatable, but NOT cryptographically secure
Hash the seed, then give each concern its own stream.

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() (or crypto.randomBytes in 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

  1. Paste in mulberry32 (or sfc32 if you want 128 bits) instead of reaching for a dependency.
  2. Hash the seed with cyrb128 before it touches the generator.
  3. Give every subsystem its own derived stream.
  4. Store the seed with the save/replay — a seed you can't reproduce is just noise.
  5. Write one test that asserts the first five outputs for a fixed seed. It catches the day someone "optimises" Math.imul away.
  6. 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.

Related
→ Lockstep vs rollback netcode → Mapping a SHA-256 hash to a game outcome → Portfolio & projects
Can you seed Math.random in JavaScript?

No. The spec leaves the algorithm and seeding implementation-defined, and no engine exposes a seed to page or Node code. V8, SpiderMonkey and JavaScriptCore all use xorshift128+ internally, seeded unpredictably per realm. If you need repeatable numbers, ship your own generator — it's about ten lines.

Which seeded PRNG should I use?

mulberry32 for most work: tiny, very fast, passes gjrand at 32 bits of state. sfc32 when you want 128 bits of state and PractRand-level quality. xoshiro128** is fast but has weak low bits and fails linear-complexity and binary-rank tests, so don't consume its low bits directly.

How do I turn a string into a seed?

Hash it. Raw strings or small integers give correlated first outputs across similar seeds. Run it through an avalanche hash such as cyrb128, which yields four 32-bit words — exactly what sfc32 takes, or use one word for mulberry32.

Is a seeded PRNG safe for a provably-fair casino game?

No. These generators are statistically good but not cryptographically secure — a short run of outputs reveals the state. Provably-fair games use HMAC-SHA-256 over a committed server seed, a client seed and a nonce, and secure seeds come from crypto.getRandomValues().