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.
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):
- Dart API —
dart:mathRandom: the built-in generator whose hidden, non-serializable internal state is exactly what pushed us to vendor a generator we can freeze and restore. - Dart package —
crypto: the Dart-team-maintained SHA-256 implementation our state hash is built on, deterministic across the platforms we ship to. - George Marsaglia, “Xorshift RNGs,” Journal of Statistical Software (2003): the public-domain generator family our small vendored PRNG comes from.
Keep reading: all posts on the WizusLabs Engineering blog.