All four of my games, Arrows, Decanta, Moat and Drift, have the same rule written into their lint configuration:

'no-restricted-properties': [
  'error',
  {
    object: 'Math',
    property: 'random',
    message: 'Math.random is banned: draw from a seeded Rng (see packages/engine/src/rng).',
  },
],

Every random draw goes through a seeded generator instead. That one rule is what lets the games have endless levels, a daily challenge that is the same for everyone, and a league, with no server at all. It also makes them far easier to test. Here’s how it works and what it buys.

Same seed, same game, on every device

A seed is just a string that names a piece of content: level:37, daily:2026-10-10, board:event:autumn-2026:181. The generator hashes the string into its starting state, and from then on its output is fixed. The same seed produces the same sequence of numbers in a browser in Lisbon and on an iPhone in São Paulo:

same seed on any machine: 56 50 84 69 25 12

So “level 37” doesn’t need to be stored or downloaded. It is regenerated on demand, identically, from its name. The daily challenge is daily: plus today’s date, so every player who opens the same date gets the same board without asking a server what it is. (“Today” is the device’s local date, so players in different time zones roll over at different moments, and with no server, nothing stops someone from setting their clock ahead.)

The generator itself

The games use xoshiro128**, a small, fast generator with good statistical quality, seeded through splitmix32 from cyrb53, a string hash (here keeping both 32-bit halves, 64 bits in all). It runs on 32-bit integer arithmetic (Math.imul, unsigned shifts), so its integer draws (next, int, pick, shuffle) are identical in every JavaScript engine. normal() uses Math.log and Math.cos, which can differ in the last bit between engines, so anything that must match exactly across devices avoids it or rounds its result coarsely:

export interface Rng {
  next(): number; // a float in [0, 1)
  int(n: number): number; // an integer in [0, n), unbiased
  pick<T>(items: readonly T[]): T;
  shuffle<T>(items: T[]): T[]; // Fisher–Yates
  normal(): number; // Box–Muller
  fork(label: string): Rng; // an independent stream
  readonly seed: string;
}

int(n) uses rejection sampling rather than next() * n or a modulo, so every value in the range has exactly the same probability. That matters more than it sounds: with a plain modulo, some outcomes come up a tiny bit more often, and in a casino game or a fair daily challenge “a tiny bit” is a bug. (A real-money game would also need a cryptographically secure generator; xoshiro isn’t one.)

Forked streams: changes that don’t ripple

A puzzle generator draws numbers in several steps. Moat, for example, places the castles, then grows the realms, then moves border cells until the solution is unique. If all three steps share one stream, a change to the first step (one extra draw) shifts every number the later steps receive, and every level in the game changes:

shared stream, step 2 before: 64 2 49 86 73 | after one extra draw in step 1: 2 49 86 73 71

So each step gets its own stream, forked from the seed with a label. A fork is just a new generator seeded with seed/label. Because it depends only on that string, fork('x') twice gives the same stream, so labels must be distinct (wave:${i}, not wave):

const rng = createRng(seed);
const castles = placeCastles(size, rng.fork('place'));
const grown = growRealms(size, castles, rng.fork('grow'));
const realmOf = makeUnique(size, grown, castles, rng.fork('unique'));

Now a change in one step leaves the others’ numbers alone:

forked streams, grow before: 96 37 86 53 46 | after one extra draw in place: 96 37 86 53 46

Sub-seeds are also how retries stay deterministic. When a generated board doesn’t land in its difficulty band, the generator tries level:37#1, then level:37#2, and so on. Level 37 is always the first sub-seed that works, so it’s still the same board everywhere.

Advertisement

Content as a contract: fingerprint tests

Determinism turns content into something you can pin in a test. Each game hashes a few generated boards (levels, dailies, an event board) and compares them with a snapshot:

it('pins a few levels, dailies and an event board', () => {
  expect({
    level1: fingerprint(generateLevel(1)),
    level13: fingerprint(generateLevel(13)),
    level300: fingerprint(generateLevel(300)),
    weekday: fingerprint(generateDaily('2026-10-05')),
    event: fingerprint(generateEventBoard('autumn-fair-2026', 1)),
  }).toMatchInlineSnapshot(`…`);
});

Players remember levels. Someone stuck on level 214 who updates the app should find the same level 214 waiting. So a refactoring of the generator that silently changes every board fails this test, and the snapshot is only updated on purpose, with the commit message saying so.

Replays: an arcade game as a pure function

Puzzles are easy to make deterministic; an arcade shooter is harder, because the player’s finger is part of the input sixty times a second. Drift handles it by treating a run as a pure function of its inputs:

a run is (seed, mode, phase, upgrades, inputs[])

The simulation advances in fixed steps of 1/60 s, never by the frame’s real duration, so the same inputs produce the same game whatever the device’s frame rate. The renderer only draws the state; it never decides anything. Enemies, drops and bullet patterns all draw from the run’s seeded streams.

That makes a strong test possible: play a long scripted run twice and compare a hash of the whole game state at the end.

it('plays the same run twice identically', () => {
  const a = replay(options, inputs);
  const b = replay(options, inputs);
  expect(hashState(a)).toBe(hashState(b));
  expect(a.score).toBe(b.score);
});

The scripted inputs come from their own seed too: a “wandering finger” that drifts to a new spot now and then and lifts off sometimes, like a player. A second test checks the opposite: change the seed, the mode, the phase or the inputs, and the hash changes. If anything in the simulation reads the clock, iterates a collection whose insertion order depends on timing, sorts with an inconsistent comparator, or calls Math.random through some library, these tests catch it within one engine. A pinned hash, like the fingerprint tests, catches unintended changes between builds; only running the suite in other engines would catch engine differences.

What determinism buys you

  • No server for content. Endless levels, dailies and events are generated on the device from their names.
  • Fair shared challenges. Everyone’s daily is the same, so scores can be compared, and the league can be simulated on each phone.
  • Bugs you can reproduce. A player’s report of “level 214 can’t be solved” is a seed plus the app version, and that pair is the whole bug report.
  • Strong tests. Fingerprints pin content, replays pin behaviour, and a calibration suite can generate thousands of levels and check their difficulty, with the same result on every run.

The traps

  • One stray Math.random anywhere in the engine breaks everything quietly. Ban it with a lint rule, and back the rule with tests: it misses aliases like globalThis.Math.random, and it never sees dependencies.
  • Floating point. Math.sin and friends can differ in the last bit between engines. Generation that must match bit for bit should stay in integer arithmetic; Drift’s simulation does use floating-point trigonometry, and its players run it in different engines (V8 in Chrome, JavaScriptCore on the iPhone), so a replay is only guaranteed bit for bit on the engine that recorded it. Its replay tests run in CI on V8; sharing replays across devices would need fixed-point math.
  • Shared streams. Fork per step, so a change in one place doesn’t reshuffle the world.
  • Real time. Never step a simulation by the frame’s duration; use a fixed step and let rendering interpolate.

None of this is specific to games. Any system that generates content, simulates, or needs reproducible tests benefits from treating randomness as an input you control rather than a global you call.