Back to News & Insights
JavaScript August 11, 2026 · 8 min read

The penny invariant: correctness guarantees in a game that teaches a child about money

There is a bug class that only exists in software for children. The generator produces a question,...

The penny invariant: correctness guarantees in a game that teaches a child about money

There is a bug class that only exists in software for children. The generator produces a question, the question has no answer, and the player cannot tell. A six-year-old has no mental model of your round generator. They have a mental model of themselves. So they try coins, nothing works, and they arrive at the only explanation available to them, which is that they are bad at this. A game that shows a child an impossible question has failed at the only thing it was for, and it has failed invisibly, because nobody files that bug report.

I built a browser game called Coin Cafe where you serve customers and count out the coins. Eight stages, eight customers per stage, US denominations, about 12,000 lines of plain ES modules with no build step. Most of that is drawing. The part worth writing about is the 1,300 lines in money.js that make it structurally impossible to ask a question that cannot be answered.

A money game is the worst possible place to discover floating point, not because the errors are large, but because the comparison you care about is exact equality against a number the child computed in their head.

That is the whole model. 47 means forty-seven cents. No dollars anywhere, no decimal strings, no toFixed round trips.

Try the alternative. A nickel and a penny on the mat for a 6 cent order, then three dimes and two pennies for a 32 cent order, both in dollars:

Both are correct answers. Both fail total === target. The child is told they are wrong, on a screen designed to be gentle, for a reason that does not exist. You can paper over it with an epsilon, but that means picking a tolerance on a quantity that is exactly representable as an integer.

The rule is enforced at one seam. Denominations are always key strings, never values, and nothing converts a value back into a key. denomOf() throws rather than degrade:

A loud crash at integration time is cheaper than a tray that silently totals zero. I shipped the silent version of this bug in an earlier game, where one module stored a colour key and another used it as a hex string. It survived weeks.

Here is the guarantee, and it is one line of data rather than one line of logic. Every stage's coin tray contains a penny.

That is stage 0. Stage 4 is ['penny', 'nickel', 'dime'], stage 7 is all five. All eight rows start with penny, including the three separate round shapes that stage 7 rolls between.

With a penny in the tray, every integer-cent target from 1 upward is buildable. No combination of stage settings, price ranges, menus or difficulty bands can produce a round with no answer, because that failure mode does not exist in the space. You cannot configure your way into it.

Compare the shape everyone reaches for first: generate a round, run a solver, reroll if the solver fails. That works until somebody widens a price range, or drops the penny from a stage to make it look cleaner, and a fraction of rounds start falling through the retry loop into whatever the fallback is. The loop hides that regression instead of surfacing it. A structural guarantee cannot be tuned into a bug by someone who was not thinking about solvability, because there is nothing to tune.

One related fact is worth stating out loud. Greedy decomposition, largest coin first, is optimal for every tray this game ships: I checked it against a dynamic-programming minimum for every amount from 1 to 175 cents across all four trays, and greedy matches on all 700. That is a property of these specific denomination sets, not of greedy algorithms, so re-check it if you add a denomination.

The invariant makes bad rounds impossible. The verifier makes sure of it anyway.

verifyRound(spec) returns null for a sound round, or a plain English string naming what is wrong. It checks that every money field is an integer, that item prices sum to the total, that a change round's target really is paid minus price, and then it re-solves the round from scratch:

The important part is where it sits: the last line of the candidate builder, before the spec is handed back.

null means the caller keeps looking. makeRound() tries 40 random draws inside the customer's difficulty band, then sweeps every candidate value in the band and then the whole stage range across every shape, and only then falls back to one of eight hardcoded known-good rounds. It cannot hang and it cannot hand back something unsound, because every path out of it goes through the same verifier.

I ran the shipped code over 64,000 generated rounds, every stage, every customer position: zero verifier failures, and the hardcoded fallback was reached zero times.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation