The modernisation plan was always going to be a dangerous beast. I was about to break the engine into many more packages and swap out the build tool underneath it, all while upgrading the rendering-heavy dependencies that decide what everything on screen looks like. If I nudged a value in the wrong place, things could begin to break and I would have no way of knowing until far too late.
So before I touched any of it, I built a safety net composed of two critical parts. The first was a game clock I could use to stop the game deterministically. The second was a test suite that stares at the pixels and shouts the moment they move.
One clock to advance them all
Every time-based system in the engine used to reach for performance.now() and setTimeout on its own. This included things like animations, pacing delays between combat actions and the simulated delays between AI controlled combat turns. All of these used to keep a private sense of time. The first move was to take that away from them and hand it to a single authority, a Clock.
The clock has two modes. In real-time mode, the shipped-game default, it advances by real elapsed milliseconds and behaves exactly like the scattered performance.now() calls it replaced. In manual mode, it only moves when something calls advance() with an explicit number of milliseconds.
Centralising time like this buys two things that look small and turn out to be enormous. The first is pause. Freeze the clock and the entire simulation holds still together, animations and AI delays and state transitions all suspended on the same frame. The second is determinism. When time only moves in fixed steps you control, animation phase and combat timing become a pure function of the step count.
Determinism by construction
A game is a machine for producing surprises. Damage rolls lean on randomness, as does the jitter of particles. The tile an AI unit decides to walk to, and the order two effects resolve in are dependent on random numbers. Run the same encounter twice and you get two different-looking frames. That is what you want when playing but it's the wrong thing entirely when you are writing a test that should only fail deterministically.
To achieve this, I needed to remove all randomness from the runtime during testing. Time was already taken care of in both scenarios (playing and testing) via the game clock, which left RNG – something you do want when playing but not while testing. This resulted in me building a dedicated test harness that swaps out Math.random for a seeded mulberry32 generator, so every roll in the game draws from the same stream.
One call sets the whole thing up – enterDeterministicMode(seed) seeds that generator, resets the clock to zero, and pauses it. From there the harness walks time forward with advanceSteps(n), one sixtieth of a second per step, the same frame budget the real game runs at. Combat then unfolds as a pure function of step count, and the same seed with the same steps produces byte-identical frames every single time.
These are the tools I needed to test a combat encounter that is supposed to look different on every run. Freeze the clock, seed the noise, and even chaos holds still long enough to snapshot.
Watching the pixels
A frozen, deterministic engine is only half the safety net. The other half is something that actually looks at the output and checks for drift.
Enter Microsoft's Playwright library, which now powers my visual-regression suite. Each test boots the engine, drives it into a known state, advances a fixed number of steps, and captures the frame. The captured image gets compared against a baseline PNG committed to the repo. If the two match within tolerance the test passes. If a pixel moved, the test fails and hands me a diff showing exactly what changed. It's up to me to decide whether any visual diff is expected or unexpected.
There are sixteen of these specs, split into two families. The rendering suite is harness-driven and covers the things the engine draws: the combat simulator, atmospheric weather, combat VFX, and the various editors for tiles, lights and encounters. The editing suite is control-driven, poking the real tools through their own controls to prove that an edit-then-save round trip still lands the right result.
The baselines are rendered locally off the deterministic clock and committed alongside the code, and a pre-push hook re-renders and diffs the whole set before anything leaves my machine. A push that would have changed a single frame does not get to slip out quietly.
Getting frames this stable took a few bruises. A unit's idle animation, for one, runs on its own real-time animator rather than the game clock, so freezing the clock is not enough to still it. That one has to be stopped separately before a capture, or the unit keeps marching on the spot between runs and every baseline drifts. Small things like that are exactly what a pixel diff is built to catch.
Why this came first
This is the opening chapter for a reason. Everything the rest of this series describes, from splitting the engine into packages to swapping out the build tool, was only safe because this net was already strung underneath it.
The clearest example arrived when I upgraded three-nebula from version 11 to 12, a jump that quietly changed how particles rendered. I did not have to spot that by eye and hope. The moment I ran the suite, the VFX and atmosphere diffs lit up. I could look at exactly which frames had moved, confirm the shift was the upgrade doing its job rather than a bug, and regenerate the baselines on purpose. Without the net, that same upgrade is a leap off a cliff with the lights off.
This is the pay off in investing in robust dev tooling no one will ever see. I can now YOLO features or experiments at will because the tests will tell me what I broke.
With the net finally strung up, it was time to start pulling the codebase apart, which is where the next post picks up.
