Software

Every Bug Report Is a Regression Test

Every developer who has shipped software to other people has received this bug report: “It crashed sometime on the third floor, I think I’d just used the thing.”

You can’t fix that. You can guess at it. You can ask follow-up questions the reporter has already forgotten the answers to. You can spend an afternoon trying to reach a state you were never in, fail, and close the ticket as “cannot reproduce” — which is a polite way of saying the bug is still there and you’ve agreed to stop looking for it.

I’ve spent the last several months building a game where that report doesn’t exist, because the report is the reproduction. A tester presses F8 and what gets filed isn’t a screenshot and a paragraph of recollection. It’s the seed and every command of their run. A verifier re-simulates the whole thing from a fresh session and lands in the exact state they were looking at when it broke — not an approximation, the same state, down to the individual die rolls. The seed then becomes a regression test, and that bug cannot come back.

None of the machinery is novel. What I think is worth writing down is the specific combination, because the existing literature on this technique is aimed almost entirely at databases and distributed systems, and it assumes the seeds come from a fuzzer. Mine come from users.

The bet: determinism by construction

All of it rests on one property: the same seed plus the same sequence of commands produces a byte-identical run, every time, on any machine. If that holds, a bug report only has to carry the seed and the command log, and those two things are sufficient to recreate the failure exactly.

Getting that property is less about clever code than about systematically refusing things. The rules on the engine:

  • No random module in the engine. The RNG is seeded and injected. Code that needs randomness receives a generator; it never reaches for an ambient one.
  • No I/O. The engine parses text and dicts. Reading files is the content loader’s job, on the other side of the boundary.
  • No globals, no mutable module state. Everything that affects a decision lives in the state object the command loop threads through.
  • No clock. Nothing branches on wall-clock time. Time in the game is turns, and turns are in the state.

Individually these are small and boring. Together they make the engine a pure function from (seed, commands) to outcome, and that is the only reason anything else in this post works. The engine is about 7,200 lines; the discipline is worth more than the code.

Constraints that rely on good intentions don’t survive

A rule written in a contributing guide decays. A rule that fails the build does not. So the constraints above are themselves tested, in a file that does nothing but assert architecture:

  • the engine never imports a UI toolkit or a front end
  • the engine never uses the random module directly
  • the engine never does file I/O
  • the engine never reads the clock or an ambient RNG
  • the content loader may import the engine and the stdlib, and nothing above it — so no package cycle can form
  • none of the three front ends may import a sibling front end
  • no pack content ids may appear as string literals in engine code

My favourite is the smallest one. The function that builds a run record is forbidden from reading the wall clock — the timestamp has to be passed in by the app layer. That’s a fussy-looking rule for one field, and it exists because a single time.time() in the wrong place is enough to make a record that can’t be reproduced byte-for-byte.

The content-id rule earned its place the hard way. Twice, the shipped pack’s ids leaked into engine code: a map gate hardcoded two item ids, so a differently-named content pack could never unlock the map, and the ship’s five resources were an engine enum, so no pack could ever count anything else. Both were real bugs found late. The test is the ratchet that stops them recurring — it’s not there because I predicted the failure, it’s there because I lived through it.

What a replay actually is

A replay is four things: the content pack, the pack’s version, the seed, and an ordered list of [verb, arguments] steps. It’s plain JSON, deliberately — a replay is something a player should be able to open, read, mail to somebody, and attach to a report.

The command log isn’t assembled by hand. The session methods that constitute a move are marked with a @recorded decorator, and recording is a side effect of calling them. There’s no parallel code path that has to be kept in sync with the real one, which matters, because a logging path that can drift from the gameplay path is a logging path that will eventually lie to you.

The pack name and version ride along for a specific reason. Content decides the numbers, so a run is only comparable to another run under the same pack and the same engine. A replay that won’t re-simulate fails loudly and names the step that broke — and that’s usually not corruption, it’s a replay from a different build.

One honest detail: the replay field on a report is nullable. Some situations — a crash on a menu screen before a run exists — produce a report worth filing with no run to attach. The system says so rather than pretending otherwise.

The same primitive settles two different arguments

Here’s the part I didn’t anticipate when I started. I built replays for bug reports. It turned out I’d built something more general: a way to make a claim about a run checkable rather than trustworthy.

A run-time claim — “seed 4711, these commands, four colonists home in 47 turns” — would go through exactly the same verifier as a crash report. The run is played again from a fresh session against the pack, and every number reported is read off the state that comes out. Nothing in the submitted file is taken at its word.

That property is unusually strong, and it’s worth being precise about why. You cannot fake a time by editing a save file, patching the binary, or submitting a doctored number, because none of those things are inputs. The only inputs are the seed and the commands. Signing the binary wouldn’t add anything either: a signature proves which artifact shipped, not what it did when it ran.

So a mechanism built for debugging would also underwrite a cheat-proof leaderboard. I haven’t built one and may never — the point is that the hard part is already done, and it was done for another reason entirely. That’s the pattern I’d point at if you take one thing from this post: when you make your system’s behavior a pure function of recorded inputs, verification stops being a feature you build and starts being a property you have.

Where this comes from, and how it differs

The technique is usually called deterministic simulation testing, and it has a serious pedigree. FoundationDB built its reputation on it. TigerBeetle leans on it heavily, Antithesis has commercialized it, and Phil Eaton’s introduction to what the big deal is is the best short explanation I know.

In nearly all of that work the simulator generates the seeds. You run thousands of randomized schedules against a distributed system, one trips an assertion, and you keep the failing seed. The system under test is a database, the nondeterminism being controlled is concurrency and network timing, and no human is anywhere near the loop.

What I’m describing points the same machinery in the opposite direction. The seeds come from players. The nondeterminism being controlled isn’t thread scheduling — it’s the enormous space of things a person might decide to do, which is a space no fuzzer I could write would explore as creatively as ten testers will in an afternoon. Randomized simulation is excellent at finding bugs whose shape you can describe. People are better at finding the ones you can’t.

There’s prior art on the player-facing side too: session-replay crash reporting exists in game development, and deterministic lockstep is standard in rollback netcode. The difference is intent. Those systems replay a session so a human can watch it. This one replays a session so a machine can re-execute it and keep it forever as a test.

Why this matters more when you’re coding with AI

A good part of this project was written with heavy AI assistance, with me driving the architecture and the review. The determinism isn’t incidental to working that way — it’s what makes working that way safe.

The failure mode of AI-assisted development isn’t code that won’t compile. It’s code that looks right, passes a plausible-looking test the model also wrote, and is subtly wrong in a way nobody notices for three weeks. The defense is an oracle the model doesn’t control: a suite that can drive thousands of complete games headlessly and report whether behavior changed, without anyone having to interpret the answer.

The numbers, for scale: about 30,000 lines of source against 49,000 lines of tests, and a suite of 3,555 tests that collects in three seconds. That ratio is only sustainable because the tests aren’t mocking their way around the game — they’re playing it. A test that runs a real 200-turn game to completion and asserts on the outcome is cheap to write when the engine is headless and deterministic, and it catches things no unit test will.

It’s the same instinct behind the chaos testbench I built for MCP servers in k5n-mcp-hub: when you can’t trust a component to behave, make the harness able to replay its misbehavior on demand.

What it costs

I don’t want to oversell this. The constraints are real and they are not free.

  • You pay up front, in architecture. Injecting the RNG everywhere and keeping state out of module scope is tedious, and it is far harder to retrofit than to start with. On an existing codebase with globals and ambient I/O, this is a rewrite, not a refactor.
  • Determinism is a property you lose silently. One iteration over a set, one platform-dependent float, one well-meaning time.time(), and replays stop matching. Without the architectural tests the property decays within weeks.
  • It constrains the design space. Anything genuinely nondeterministic — real-time networked multiplayer, wall-clock mechanics — has to sit outside the engine boundary or the model breaks.
  • Command logs are a privacy surface. They’re small, but they are a record of what someone did, and that deserves thought before you start collecting them.

The honest summary: a large fixed cost, then a near-zero marginal cost forever. For a long-lived project with real users filing real bugs, that trade is excellent. For a two-week prototype it plainly isn’t.

Where it applies beyond games

A game is an unusually convenient demonstration, because it’s already a state machine driven by discrete commands. But that shape isn’t rare. Anything that is fundamentally a sequence of user actions against evolving state can be built this way: workflow engines, financial ledgers, rules engines, configuration systems, interactive CLIs — and increasingly agent loops, where being able to replay a run exactly is fast becoming the only practical way to debug one.

The test is simple. Can you describe everything your system does as an initial state plus an ordered list of commands? If so, determinism is available to you, and your bug reports can start carrying their own reproductions.

The thing this came out of

All of this is in service of Derelict Ark, a turn-based sci-fi dungeon crawler in the tradition of the 1980s first-person grid crawlers. It’s playable free in a browser — no install, no account — and it’s in open beta, which is a generous way of saying that nobody outside me has finished it and you will certainly find something I can’t see.

When you do, press F8. You won’t have to explain what happened. The replay will do it better than either of us could.

Leave a Reply

Your email address will not be published. Required fields are marked *