← All writing

The Piece That Couldn't Tell It Was Done

A generative tree whose 'am I finished?' check could return false forever, caught by a test that failed up to a third of its runs. The halting condition is part of the algorithm, not an afterthought.

In June I rebuilt an old side repo into a small gallery of generative-art pieces — Canvas 2D simulations that grow on screen and eventually stop. Every piece signs the same four-method contract: step(), render(), restart(), and isComplete(). Most of it came out of one Claude Code session with Opus 4.8.

isComplete() is not decorative. The runtime keeps the animation loop alive until it returns true. And for visitors with prefers-reduced-motion it matters even more: instead of animating, the driver fast-forwards the whole simulation to the end and renders one finished frame.

One piece is a space-colonization tree. 240 attractor points are scattered across the field; a tree grows up from the bottom edge, each branch extending 5.5 pixels per step toward the attractors within reach; an attractor is consumed once a branch comes within 9 pixels of it. Done, I decided, meant four or fewer attractors left alive — or 40 consecutive steps in which no branch grew.

The test suite has a test called "consumes attractors and completes": run up to 5,000 steps, then assert the piece says it's finished. It failed somewhere between 10 and 35 percent of runs.

The diagnosis took longer than the fix. Sometimes a small residual cluster of attractors ends up in a dead spot — close enough to keep pulling branches toward it, never close enough to be consumed. Meanwhile the rest of the tree keeps growing, so new nodes appear every step and the stall counter resets forever. Both of my halting conditions assumed the simulation would either finish the job or stop moving. This one did neither.

A handful of attractors hovered just outside the nine-pixel kill radius while the tree grew happily everywhere else.

The fix is one counter. noKill tracks consecutive steps in which no attractor was consumed, and the piece declares itself complete once that reaches 120. During healthy growth the longest no-kill streak we observed was about 40 steps, so 120 sits comfortably outside normal behavior. The growth algorithm and its constants weren't touched.

Without it, the only thing standing between a reduced-motion visitor and an infinite loop was a blunt 200,000-step guard in the driver — which would have given up and rendered a half-grown tree.

The rule is now written into the repo's CLAUDE.md, so every future piece starts from it: keep the simulation finite and bounded; isComplete() must eventually return true.

Making the tree grow was the easy half. The hard half was teaching it to notice it had stopped making progress.