WizusLabs Engineering · Frameworks

Deterministic by design: seeding randomness so bugs reproduce

The most useful thing a game engine can give you is not speed or prettier particles. It is the ability to make a bug happen again, on command. That turns out to be an architecture decision you make on day one — or one you spend the rest of the project wishing you had.

By WizusLabs Engineering · 2026-07-19 · ~8 min read

A bug you can reproduce is a nuisance. A bug you cannot reproduce is a rumour — you patch it against a hunch, ship, and wait to learn whether you were right. Game code is a factory for the second kind, because the interesting failures live inside a swirl of randomness, timing, and physics that never lines up the same way twice. So the question we cared about was never how to log our way back to a cause after the fact. It was whether we could build the whole simulation so that any run — including the one that broke — could be played back bit-for-bit from a seed and a list of inputs. This is a first-hand account of how our arcade engine does exactly that, and the discipline tax we pay to keep it true.

A random number is where reproducibility goes to die

Nondeterminism does not arrive as one big mistake. It leaks in through small, reasonable-looking lines. A Random() constructed with no argument seeds itself from the wall clock, so every launch draws a different sequence. A DateTime.now() read inside the simulation makes the outcome depend on when you played, not what you did. Iterating a map or a set “in order” leans on an ordering the language never promised, so two identical game states can walk their entities in different sequences. Even a floating-point sum can produce different bits depending on the order you added the terms, and if that result lands in saved state, you have quietly made the save non-portable across devices.

Each of these is a fork in the road that you cannot retrace, because nothing recorded which branch you took. The tell is the sentence every game developer has said out loud at least once: “it only happens sometimes.” That sentence is not a description of the bug. It is a confession that the system has hidden inputs. Deterministic design is the discipline of having no hidden inputs — of making the entire future of a run a pure function of a seed and the frames the player fed it.

One seed, one owner, one stream

The first rule is the one that sounds obvious and is constantly violated: all randomness flows through a single seeded generator owned by the engine. No entity, no enemy AI, no level-generation path is allowed to construct its own Random. There is one pseudo-random number generator, it lives on the simulation, and every dice roll in the game — the mystery power-up that picks one of four kinds, the wandering enemy that rerolls its direction at a junction, a bot’s weighted choice between hunting and walling itself in — draws from it. Just as importantly, the generator is seeded from the level, which carries its own seed value, never from the operating system clock. Same level, same seed, same story.

The generator we reach for is deliberately humble. We vendored roughly thirty lines of xorshift — a public-domain algorithm whose entire state is a single 64-bit integer — rather than lean on the platform’s built-in Random or pull in a heavyweight Mersenne Twister. That choice looks backwards until you name what we actually needed, which was never better statistics; a four-way roll does not care. It was serializable state. The platform Random hides its internal state, so you cannot freeze it to disk mid-run and thaw it exactly where you left off. Our generator’s whole state is those eight bytes: we save them, restore them, and the sequence continues as if nothing happened. A Mersenne Twister would hand us statistical quality we have no use for in exchange for kilobytes of state to serialize on every save. The humble option is the correct one because it fits the job, not despite being small.

The sub-stream we keep deciding not to build

A well-known refinement is to give each concern its own forked sub-stream — one generator for loot, one for spawns, one for AI — so that adding a draw in one system does not shift every number in the others. It is a good technique, and we do not use it, which is a decision rather than an oversight. Our engine keeps a single ordered stream, and it can, because the tick resolves in a fixed ten-step order at a fixed rate: every consumer draws at the same position every tick, so the mystery roll, the wanderer’s turn, and the bot’s coin flip are stable relative to one another run after run.

What keeps this honest is that we are forced to revisit it on purpose. Any change that touches the simulation contract has to pass an engineering-readiness checklist before it is built, and one of its standing questions is, in so many words, does this introduce a new RNG sub-stream? — alongside whether it adds snapshot fields or decomposes the tick. For this game we have kept a single ordered stream inside each run and never forked a per-concern sub-stream within the tick — the one time the checklist answered “yes,” a boss-rush mode, we minted a fresh per-round seed rather than splitting the live stream. But the day a feature would reshuffle the draw order within a tick is the day the checklist tells us to split it. The trade-off is plain: one stream is simpler to reason about and cheaper to fingerprint, at the cost of coupling draw order across every system — which is exactly why we treat the resolution order as a locked contract and not a detail.

A fingerprint for a single moment of play

Reproducibility is only useful if you can check it, and eyeballing two playthroughs does not count. So the engine can hash its own live state into a single number — a state hash — that acts as a fingerprint for a precise moment of the simulation. It is a SHA-256 taken over a canonicalized byte stream assembled in a fixed, written-down order: the tick counter first, then the generator’s state, then the grid row by row, then every apprentice, enemy, bomb, and pickup — each collection folded in a fixed order, entities by id and pickups by grid position. The sorting is not fussiness; it is the whole point. Iterate those collections in whatever order the runtime feels like and two byte-for-byte-identical game states will hash differently, and your fingerprint is worthless.

There is one honest wrinkle worth stating. A number on the web is a 53-bit integer, not a full 64, so the hash we compare is truncated to meet in the middle at 53 bits across every platform we ship to. That is a deliberate, eyes-open acceptance of an astronomically small collision chance in exchange for one hash that means the same thing on a phone, a browser, and a desktop. And the reason the byte layout is pinned in an architecture note rather than left implicit in the code is that it catches the single most common determinism bug there is: someone adds a field to an entity, forgets to fold it into the hash, saves keep working — and the golden fingerprint quietly drifts until the test harness screams on the next run.

Replay is just a seed and a list of inputs

Put those pieces together and replay stops being a feature you build and becomes a property you already have. Given a seed, a level, and the list of input frames a player produced, two runs land on an identical final fingerprint — that is the core guarantee. A second, sharper guarantee falls out of the serializable generator state: a run that is saved partway through, restored, and finished lands on the same fingerprint as the run that never saved at all. Save and load are not a separate code path to be tested in isolation; they are proven to be indistinguishable from an uninterrupted run. We keep a small set of golden seeds — the same handful the test suite has used since the engine was young — each paired with a recorded input trace and a recorded final hash, and every platform we ship to replays them and checks the number matches.

Two runs from the same seed produce the same state hash Two lanes. In the top lane, a full run starts from a seed plus an input trace, passes through the simulation engine running a fixed ten-step tick with one seeded generator, and ends at a state hash. In the bottom lane, the same seed and inputs are used, but the run is saved partway through, restored, and resumed before reaching its state hash. Both lanes converge on an identical state hash, illustrating replay determinism and save/load continuity. Run A — one pass Run B — save at tick K, restore, resume Seed 42 + input trace Simulation engine 30 Hz · 10-step tick stateHash() Seed 42 + input trace Simulation engine restore + continue stateHash() Identical fingerprint
Same seed, same input trace, same final fingerprint — whether the run happens in one pass or is saved mid-game and resumed. That equivalence is what makes a bug reproducible on demand.

What this does to debugging

Here is the payoff the whole apparatus exists for. A heisenbug — the failure that vanishes the moment you look at it — becomes an ordinary fixture. When a tester or an automated check trips over a broken state, we do not need a screen recording and a theory. We need the seed and the input frames, and the failure reproduces on our machine, deterministically, as many times as we care to run it. The bug is no longer a story someone tells; it is a file we can open. This is the same lesson we relearned the hard way in the browser tells the truth — reproduce before you theorise — except a deterministic engine makes the reproduction free instead of something you have to fight for. It is also the reason an automated gate can assert anything meaningful about a simulation at all: as we wrote in an AI QA gate for games, a determinism harness is only worth building if the thing under test is actually deterministic.

None of this is free, and pretending otherwise would be the dishonest version of this post. Determinism is a standing tax on how you are allowed to write code: no bare Random, no wall-clock reads inside the simulation, no iterating a collection without first sorting it, and real care with any floating-point arithmetic whose result becomes part of saved state. The state-hash byte layout is frozen, so adding a single field to an entity means bumping that layout and regenerating every golden hash the test suite compares against — a small, recurring chore that the discipline demands and the checklist enforces. We pay it because the alternative is a game whose bugs we can only ever describe, never summon, and that is a far more expensive way to lose an afternoon.

Randomness is what makes an arcade game feel alive — the roll you did not expect, the enemy that turned the wrong way at the worst moment. Determinism is what lets us keep that aliveness honest, because a surprise we can replay is a surprise we can debug. A bug you can summon on command is already half-solved; the seed is the summoning. If you want the long version of how this fits the rest of how we build, it is all on the WizusLabs Engineering blog — which is, as ever, the point of writing it down.

Notes

This is a first-hand account of the engine behind our own grid-arcade game, not a general Dart tutorial. The specifics — a vendored thirty-line xorshift generator, an eight-byte generator state, a SHA-256 state hash truncated to fit a web integer, the fixed 30 Hz ten-step tick, and the small set of golden seeds the test suite replays — are our own design decisions, recorded in our architecture notes, not benchmarks or public figures. It deliberately contains no timing numbers. We could quote a microsecond cost for a state hash, but we would rather say plainly that it is cheap enough that we call it only on save and inside the test harness — never every tick — than publish a figure we have not measured on the hardware you happen to be holding. The absence of that number is the honest position, not a gap in it.

Sources

The public building blocks this post leans on (living documents; accessed 2026-07-19):

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog