The bug that paid 290 points for an empty garden

Same Seed had a bug that passed the balance probe, the goal probe, and most of the test suite, and for a stretch it even looked like a design choice. The whole thing fit in one line. The line paid a full 290 points to a garden with nothing in it. I want to tell you about it because the reason it survived is the part I keep thinking about, and it is not the reason you would guess.

One falsy branch handed every empty garden the top variety bonus. It sailed past a 400-seed probe and died on the most boring assertion in the file.
One falsy branch handed every empty garden the top variety bonus. It sailed past a 400-seed probe and died on the most boring assertion in the file.

A bonus table where zero is a real answer

Same Seed pays a variety bonus for harvesting different species. The bonus is a table of seven values, indexed by how many distinct species you cut: 0, 10, 35, 75, 130, 200, 290. A full six species bouquet is worth 290 on its own, which is more than most single harvests, on purpose. The index 0 entry is not a placeholder. Zero is a legitimate answer, because a player who never harvests anything, or a state object that was just created, has exactly zero distinct species. The table has a row for that, and the row says zero. That is the entire setup for the bug.

The line

The original version of the function looked like this: VARIETY_BONUS[distinct] || VARIETY_BONUS[last]. I wrote the fallback half to guard an impossible case, a distinct count above the table length, so it would clamp to the top rung instead of returning undefined. What I did not consider is that in JavaScript the number zero is falsy, the same as null and an empty string. So when distinct was 0, the left side of the || was 0, JavaScript shrugged, and the function handed back the last entry. 290. For an empty garden.

The irony is the shape of it. The fallback was written to catch the most extreme case the table could see, and instead it caught the most ordinary one. I have read enough other people's bug postmortems to know that "I did not expect 0 to be falsy" sounds like a beginner mistake. I did expect it, in the abstract. I just never once thought about it while writing that specific line, because while writing that line I was thinking about the top of the table, not the bottom, and the top is where the game design actually lives. It is the same way I have thought about the ceiling on moisture, where the math is also honest and the edge is also where a plant dies, and I only noticed the pattern after writing this sentence, which is how I usually notice anything: by noticing it too late.

Why the 400-seed probe did not catch it

Here is the part I am embarrassed by. Before I ship a tuning change to Same Seed I run the balance probe: four hundred daily seeds, seven strategies, and a table of means, medians, p10, p90 and max for each. It is the closest thing we have to a lab. With the bug in, every single strategy's total had 290 added to it. Random play went from a mean of 52 to 342. The variety greedy bot went from 802 to 1092. Every column moved up by exactly the same constant. What the probe looks at is the ordering between strategies, and adding a constant to everyone does not change the order. The variety greedy bot still won. The monoculture table still sat where it sat. The one derived number that did shift, the skill gradient, went from 15.3x to 3.2x, and I would have read that as a softening of the curve rather than a bug, because I never once cross-checked a probe mean against a hand-computed score for one specific seed. That is the actual lesson, and it is small: a probe answers the question you asked it, and mine asked "which strategy wins", and a constant on every score changes nothing it was checking. Four hundred seeds is a lot of computation. It is also only as good as the question, and that question had a hole the size of an empty garden. The deeper version of that lesson is that a probe is a contract with yourself about what you think you are measuring, and I wrote the contract for the wrong quantity, because the quantity that would have caught the bug is not "which strategy is best" but "what does a correct game produce on this exact seed", which is a different measurement entirely, and I had never written a single one of those down.

What actually caught it

Not the probe. The most boring assertion in the file. When I went back and wrote the regression block for the variety bonus, the first line I wrote was "an empty garden scores 0". It turned red. I stared at the function for about thirty seconds, and the direction of my own intent came back to me: the fallback branch was not guarding the top of the table, it was swallowing the bottom. I do not want to frame that as a moment of insight. There was no insight. A test for the empty case is the cheapest test in the file and the one you forget to write fastest, and the only reason it was there is that I was tired that evening and wrote the test block top to bottom without skipping anything. Some of my best catches look exactly like this. They are not clever.

The fix, and six assertions around it

The fixed version does two things. It treats 0 as a legitimate index by using an explicit range check instead of a truthiness test, and it returns 0 for negative counts, which were previously a different flavor of undefined behavior. The regression block that now sits in the test suite makes six points: an empty garden scores 0, zero distinct species adds nothing to the coin total, distinct 1 and 3 and 6 each pay exactly the table value, and the whole table is monotonic. Not just the red cell. The whole table. Because the bug's actual lesson was not "0 is special". It was that every row of a lookup table deserves to be indexed by something that does not lie to you about what it is. One small fossil: the comment above that test block still says the old bug handed monoculture runs a free 250. That was the top of the ruleset 1 ladder, before we retuned the table to 290. I retuned the numbers and left the comment, and I have not bothered fixing it since, which feels right in a way I cannot quite articulate. The comment is a record of when it was caught, and fixing it would quietly delete the archaeology.

A second lie, two weeks later, about the undo button

A different flavor of the same disease showed up when we added the undo button. The first draft reversed the math: undo a water action by subtracting 6 from the moisture. Fine, until the moisture is already at the ceiling. A seedling starts at 6. A pour adds 6. 6 plus 6 is 12, the ceiling is 8, so the stored value is 8. Undo subtracts 6 and you land on 2, not 6. The pre-action state is gone, because capping is a one-way door and subtracting cannot walk it backwards. The fix was to stop reversing anything. Now apply() snapshots the plot before the action mutates it, and undo restores from the snapshot instead of computing backwards. The snapshot rides along in the log entry, and the score server's replay ignores it entirely, it only reads type, plot and species, so the determinism contract does not break. The test for this one is three lines: water a plot to the ceiling, undo, and assert the moisture is 6, not 2. It is the exact difference between the two designs, and it would have been the only test in the whole suite to fail on the first draft.

What the two bugs share

Both bugs live at a boundary, and both are perfectly correct in the middle. The falsy index lies at zero. The capped moisture lies at the ceiling. Everything between the two is honest, which is why the middle-range tests all pass and the probes, which run bots that play the middle range, see nothing wrong. Bots are good players, and good players rarely hit a boundary on purpose. The boundaries are where no player is but the rules still apply. So the process rule I now follow is short, and a bit unglamorous. Any function that takes an integer index or clamps against a limit gets a test for zero, a test for the limit, and then the normal cases, in that order. And any number I put in a probe report has to be traceable to one specific hand-played seed, or it stays a guess. "The probe said so" is not a source. The suite now runs 302 assertions, all of them passing. Six of them exist to remember that an empty garden is worth nothing, and two exist to remember that a ceiling is a wall. The rest are the actual game, the part where the player is alive and the soil is real and the only thing you have to worry about is whether you remembered to water the sunflowers before the dry spell arrived, which is a relief of a different kind than the relief of a passing test, and it is the reason the whole file is worth keeping.

← Back to the blog