DEV Community

Bitstrider
Bitstrider

Posted on

My AI reviewer proved the fix worked. It didn't.

Curbside devlog #1 — building a co-op car theft game in Unity 6 with a three-agent loop.

Curbside is a co-op game about stealing cars badly with friends. It is currently grey boxes on a grey plane: one district, drivable streets, doors you walk to instead of menus, and one heist tier that works end to end across two machines. No art, no NPCs, no sound.

This post isn't about the game. It's about the thing I built to build the game, and the fortnight it took to learn that my test suite was lying to me — in a way that mutation testing, the technique specifically designed to catch lying test suites, could not detect.

The setup

Three AI agents, strictly separated, none allowed to do another's job:

  • Architect writes a spec: which module owns the feature, which interface it crosses, which peer decides, and what would prove it works. Writes no code.
  • Builder implements that spec. Doesn't design the seam, doesn't grade its own work.
  • Critic reviews the result cold, having never seen the Builder's reasoning. Its job is to prove the feature doesn't work.

The rule that makes the Critic useful is its standard of proof: mutation, not reading. It isn't allowed to say "this test looks weak". It has to name the line to break and the assertion that should then fail — then break it and run the suite. If it can describe a change to the production code that leaves the suite green, that's a finding, no matter how good the test reads.

This works. It caught a mechanic that shipped with its entry point having no caller, under a fully green suite. The tests exercised every rule of a feature that nothing could reach.

The slice

One car in the world is missing a wheel. A player carries the wheel back, presses to fit it, and the car becomes drivable. In multiplayer the server decides all of that — clients ask, they don't act.

The bit that went wrong: if the car is destroyed while the wheel is fitted, the wheel should come off and return to where it started, so the next car has to be repaired by hand. Otherwise the wheel is welded to a corpse and the mechanic quietly stops existing.

Here's the guard that handled it:

private void OnDestroy()
{
    if (!DecidesHere()) { return; }   // only the deciding peer un-stows
    _wheel.Unstow();
}
Enter fullscreen mode Exit fullscreen mode

Test written. Suite green: 161 of 161. Mutation applied — delete the Unstow() call, watch the test fail. It failed. Evidence complete, by the standard I'd set.

The bug was still there.

Why

DecidesHere() asks FishNet whether this peer has authority, which reads the NetworkObject's initialised state. And FishNet tears down in this order (Runtime/Managing/Object/ManagedObjects.cs):

418:    lNob.Deinitialize(asServer);
...
437:    UnityEngine.Object.Destroy(firstNob.gameObject);
Enter fullscreen mode Exit fullscreen mode

Deinitialize calls SetInitializedStatus(false, asServer) at NetworkObject.cs:1147. The object is de-initialised before it is destroyed. So by the time OnDestroy runs, the honest answer to "do I decide here?" is no — on every peer, always. The guard took the early exit 100% of the time in production. The un-stow never ran once.

The test passed because its StubArbiter reported DecidesInteractions = true right through destruction. The real one can't. My fixture was more cooperative than reality, in exactly the spot the feature lived.

The part that should worry you

I had mutation evidence. The mutation was correctly applied and the test correctly failed. And the conclusion I drew from it was wrong.

A mutation proves a test is load-bearing for what it asserts. It cannot prove the test models production. If the fixture is wrong, the mutated and unmutated runs both fail for the same wrong reason, the evidence looks perfect, and the fix never executes once on the path it was written for.

This happened twice in a row before I understood it. The first time, a teardown defect was "verified" with both objects kept alive — a defect that only occurs after one is destroyed. Green, mutation-proved, and it fixed nothing.

What finally settled it was embarrassingly cheap: drive the real path once. A test that genuinely starts a server, stops it, and starts it again — StartServer()StopConnection(true)StartServer() — in a single process. I had written that off as flaky process-management engineering. It's 180 lines, runs in about 40 seconds, and uses a launcher another test already used.

With the real lifecycle, the failure was immediate and obvious.

The fix is a latch: remember that this peer ever decided, and check the memory rather than asking a corpse.

if (!_everDecided) { return; }
_wheel.Unstow();
Enter fullscreen mode Exit fullscreen mode

Proof, this time from the Critic rather than from me: restore the old guard and critic-mutation-decideshere.xml reads 2 tests, 0 passed, 2 failed — one of them on "the wheel is STILL FITTED after a real server stop". Against the full suite the same mutation gives 159 of 161, failing exactly the two tests that exist to catch it.

The reviewer is not an oracle either

Two rounds earlier the Critic raised a BLOCKING finding: a built assembly whose timestamp implied it predated the source, so every cross-process test was supposedly running old code. The reasoning was sound. It was also wrong — I moved the build aside, forced a clean rebuild, and compared: all eight assemblies byte-identical. Unity doesn't rewrite an assembly whose content hasn't changed, so an old timestamp means "the contents last changed then", not "this was built from stale source".

It raised that finding in the one round where it couldn't execute anything, because I'd killed a previous agent mid-run and left the single Unity licence seat locked. A reviewer that can only read is a much weaker reviewer, and its failure mode is confident and specific.

Then, on the round I thought was clean, the ledger claimed all three server builds had been rebuilt. I checked contents rather than timestamps: the new field appeared zero times in all three. The claim was false, written by an agent that had just fixed a real bug correctly. The code discipline was ahead of the bookkeeping discipline.

What I changed

  • A mutation cannot tell you your fixture is wrong. For anything touching teardown, disconnection, respawn or framework lifecycle: read the framework's source for what the real object does at that moment, and drive the production path once.
  • Never write "fixed". The ledger records what was proven and by what. "The wheel comes off on a real server restart, critic-probe-restart.xml" is a fact. "Fixed by making the claim explicit" is a claim about intent — and it was false both times it was written.
  • A rebuild is verified by content, never by timestamps or by "Succeeded" in a build log.
  • One writing agent on the tree at a time. The reviewer mutates source, so it's a writing agent whatever its role description says. I once dispatched a Builder onto a tree a Critic was still mutating. It survived on luck and a hash check.

Where the game actually is

One district, greybox. The Unlocked heist tier runs end to end across two processes — find the wheel, fit it, drive the car out, get paid — tested Editor-client against a standalone server build rather than host-only, because host-only testing hid every authority bug I've found.

There are no NPCs, no audio, no save data, and the home base is still Unity's template scene. The roadmap has five items on it I haven't costed, any one of which could eat a month.

Next devlog: whatever breaks next. There's always something, and it's never the thing I planned to write about.

Top comments (0)