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.
Money is integer cents, and only integer cents
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.
export const CENTS = Object.freeze({
penny: 1,
nickel: 5,
dime: 10,
quarter: 25,
dollar: 100,
});
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:
0.05 + 0.01 => 0.060000000000000005
0.10 + 0.10 + 0.10 + 0.01 + 0.01 => 0.32000000000000006
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:
if (typeof entry === 'number') {
throw new Error(
'money.denomOf: got the number ' + entry + ' where a denomination key was expected. ' +
'Money is always stored as a key string like \'quarter\'; use CENTS[key] when you need ' +
'its value. Nothing in Coin Cafe ever converts a value back into a key.'
);
}
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.
The penny invariant
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.
{
index: 0,
name: 'Morning Shift',
mode: 'pay',
coins: ['penny', 'nickel'],
priceMin: 1,
priceMax: 25,
paid: 0,
cap: 6,
},
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.
Generate and check, with the check as an assertion
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:
if (spec.tray.indexOf('penny') === -1) return 'tray has no penny, so solvability is not guaranteed';
...
const mc = minimalCoins(spec.target, spec.tray);
if (countCoins(mc) !== spec.target) {
return 'target ' + spec.target + ' is not buildable from this tray';
}
if (mc.length !== spec.minCoins) return 'minCoins is ' + spec.minCoins + ' but the answer needs ' + mc.length;
if (mc.length > stage.cap) return 'needs ' + mc.length + ' coins, over stage ' + stage.index + ' cap of ' + stage.cap;
The important part is where it sits: the last line of the candidate builder, before the spec is handed back.
if (verifyRound(spec)) return null;
return spec;
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.
Difficulty that ramps inside the session, not just between stages
Eight customers per shift. If all eight draw uniformly from the stage's price range, the shift has no shape and customer one can be harder than customer eight. So the range is cut into thirds and each customer draws from their own third:
function bandFor(customerIndex, of) {
const n = of > 0 ? of : SHIFT_LENGTH;
const b = Math.floor((customerIndex * 3) / n);
return b < 0 ? 0 : b > 2 ? 2 : b;
}
With eight customers that gives 3 / 3 / 2. In stage 0 the observed target ranges come out at 1 to 8, 9 to 16, and 17 to 25 cents. Prices also never repeat inside a shift: the chosen total is added to a used set inside makeRound() itself, so a caller cannot forget to do it.
The subtle part is which number the band applies to. For a pay round it is the price. For a change round it is the change:
function difficultyRange(shape) {
if (shape.mode === 'change') {
const lo = Math.max(1, shape.paid - shape.priceMax);
const hi = Math.min(shape.paid - 1, shape.paid - shape.priceMin);
return [lo, Math.max(lo, hi)];
}
return [shape.priceMin, shape.priceMax];
}
Band on the price in a change stage and the ramp inverts. A 3 cent item paid with a quarter means 22 cents of change, the hardest round in the stage, handed to customer number one. The child builds the change, so the change is what the difficulty is about.
The coin cap, and why 24 cents is a bad first question
Every stage carries a cap: the maximum number of coins the minimal answer may use. Stage 0 is 6.
Stage 0 offers pennies and nickels, prices 1 to 25 cents. Arithmetically, 24 cents is fine. Four nickels and four pennies. It is also a terrible first screen for a six-year-old, because eight taps of near-identical metal is a dexterity exercise rather than a counting one, and the child will lose count around the sixth penny and conclude that counting is the thing they are bad at.
The cap removes it. Of the 25 possible prices in stage 0, exactly three exceed six coins: 19 and 23 need seven, 24 needs eight. They are never generated. The cap is checked twice, in the candidate builder and again in the verifier, and the rule scales up: stage 2 caps at 9, and across 12,800 sampled stage 2 rounds the largest minimal answer observed was exactly 9.
Nobody notices the absence of a bad question. That is the point.
A wrong answer has to teach, not score
An incorrect serve does not shake the screen, does not deduct points and does not end the round. It opens a full-screen number line.
explain() in money.js writes every sentence on that screen. The renderer in numberline.js composes none of them, because two files writing copy is two voices. Each coin the child placed becomes a labelled hop along the line, re-sorted largest first, because that re-sort is the lesson. Every landing point prints its running total, so 0 · 25 · 35 · 40 · 41 is visible as a sequence. Under it sits the equation, 25 + 10 + 5 + 1 = 41¢, an amber band covering the distance still to travel, and one instruction:
headline = 'You are ' + fmt(gap) + ' short.';
gapLabel = fmt(gap) + ' to go';
const need = sortDesc(minimalCoins(gap, tray));
advice = 'Add ' + fmt(gap) + ' more. Try ' + coinPhrase(need) + '.';
If the child overshot, a subset sum over the coins actually on the mat finds the exact set to remove, so the advice becomes "Take off a nickel and two pennies", naming coins physically in front of them, rather than "try again". The words wrong, no, failed, incorrect and oops appear in no string this file produces. Being short is described as a distance to travel.
Two decisions follow. The button out of the screen is called Fix it, and it returns you to the same round with your coins exactly where you left them:
closeCheck() {
if (this._phase !== 'checking') return false;
this._phase = 'serving';
return true;
}
The number line is worth nothing if you cannot immediately act on it. A new round would be a punishment dressed as a reset.
And that round can still be worth a star. Stars are 3 for a first-try minimal answer, 2 for a first-try correct answer, 1 for a correct answer after a check. Never 0 for a customer you served. Points work the same way: an incorrect serve drops the round from the 100 tier to the 50 tier, a forgone bonus rather than a deduction. Nothing in the scoring path subtracts. The self-check button, which opens the same number line before you serve, costs nothing at all, because self-checking is the behaviour you want and charging for it is a trap the child cannot see.
What I would do differently
The argument-order tolerance was a mistake. explain() accepts (mat, round) or (round, mat) and sniffs which is which; makeRound()'s options parameter accepts a Set, an array, or an object with several spellings. That was written to survive parallel work on modules meeting in the middle, and it works, but it is the exact opposite of what denomOf() does two hundred lines earlier. One seam throws loudly on a shape mismatch and the other quietly repairs it. The loud one has been worth more.
verifyRound() is exported and documented as an assertion seam, and nothing outside money.js calls it. There is no committed test file exercising it either. The 64,000-round sweep above is real and ran against the shipping code, but it lives in my shell history rather than in the repository, so the guarantee is currently enforced by a comment and my memory. That is the weakest link in everything described here.
The renderer also duplicates the model's normalisation. numberline.js carries its own coin parser, its own cents table and its own palette fallback, all defensive copies of things money.js and art.js already own. Defensive copies drift, and one of them already disagrees about whether a half dollar exists.
None of this is exotic. Pick an exact representation, encode the safety property in data rather than in a retry loop, and assert it on the way out. It matters more when the person who cannot detect the bug is six.
The rest of the set is at freegamesonline.com/games-for/teachers.
Kenneth Hartog builds and runs freegamesonline.com, where the originals are plain ES modules with no build step.
Top comments (0)