Bubble Shooter shipped two separate bugs that turned out to be the same bug wearing different clothes. Both were invisible until something re-derived a value independently and the two answers disagreed.

One: parity in two places

The board is a hex grid, so alternate rows are offset by half a cell. Whether a row is offset — its parity — was stored as a flag on the row, and also recomputed from the row's index in the array.

Then a descent happens. A descent unshifts a new row onto the front, which renumbers every index but does not touch the stored flags.

Rendering read the flag and stayed correct. Placement read the index and did not.

The consequences cascaded quietly. Snapping went half a cell out. An odd row could be written one column past its own width. That bubble was drawn on screen and was invisible to the neighbour walk — so it could never be matched, ever, and it still counted toward the total. The win condition could never fire.

Nothing threw at any point, because a wrong cell is a legal cell.

Two: the guide traced, the shot re-simulated

The aim guide draws the path your shot will take, bouncing off the walls. The shot then flies, integrating its own trajectory with the same physics.

Two computations of one path. Measured: a shot the guide promised into column 2 landed in column 5 after six bank shots.

Nothing was wrong with either computation. Floating point did it. Two independent integrations of the same curve diverge, and every bank multiplies the divergence.

The general shape

Two computations of one quantity are a consistency obligation that nobody maintains. Not because people are careless, but because there is no moment at which anyone is reminded the obligation exists. The second computation is correct on the day it is written, and the drift arrives later, from a change to something else entirely.

The fix, both times, was to make the second consumer use the first rather than repeat it. The shot now walks the traced path instead of re-integrating. Parity derives from a single absolute row id that insertion cannot renumber.

How to test for it

This is the part that took us longest to get right.

Testing that the two agree is not enough, because you can only test it where you thought to look. What works is having the test re-derive the invariant from the rules, sharing no code with the game.

For the hex grid, two invariants did all the work:

  • Neighbour symmetry: b is a neighbour of a if and only if a is a neighbour of b.
  • A descent moves every bubble down exactly one row height, and sideways exactly zero.

Both checked after driving real descents, not on a freshly built board. That second equality is the parity bug caught head-on: if parity has drifted, "sideways exactly zero" fails immediately.

Three harness traps from the same session

All three make a broken test look green, or a working game look broken, which is the same amount of wasted time.

Snapshot the board after tracing, never before. Tracing extends the grid downward. A board captured first is short a row, so every deep landing is silently discarded. This reported "0 shots played" twice before we spotted it.

A row index is not stable across a turn. A descent shifts every index by one, so a cell predicted before the shot lives at r+1 after it. Two separate "the guide lied" failures were the test forgetting this, not the guide lying.

Kill the mock API before restarting it. A second instance dies with EADDRINUSE, and the old one keeps serving. You then verify copy you already changed, and everything passes, and you learn nothing.


Play it: Bubble Shooter · how to play

Related reading: our solver said par 7 · our golf game was unloseable