TinyGames · how it works
Mastermind. Four pegs from six colours, ten tries. A guess comes back as two numbers and neither of them tells you where anything is.
Open index.html. No build step, no dependencies, no images — the board is drawn.
codebreak/
index.html markup, and the restart button
style.css the frame around the canvas
js/code.js scoring, the possible set and the arithmetic
- no pixels, no engine, no DOM
js/codebreak.js the playable layer
test-code.js node test-code.js
Draft. It is built, compiled and tested like every other game, and it is not
on the hub: "draft": true in build/games.json keeps it off the cards, out of
the sitemap and without a readme page.
Black is a peg of the right colour in the right place. White is a peg of the right colour in the wrong place. Black is easy. White is where everybody, including this author's first draft, gets it wrong.
The naive version asks, of each peg that is not already black, does this colour appear anywhere in the answer — and counts a white when it does. Nothing is removed, and the blacks are never taken out of the running. It is the duplicate letter problem wearing different clothes.
answer R R G B two R, one G, one B
guess R G G G one R, three G
correct 2 black, 0 white the two blacks used up the only R and the only G
naive 2 black, 2 white four pegs of feedback from two pegs of agreement
It does not report more than four pegs — it cannot, it counts at most one per position — so a check on the total would sit there passing forever. It reports more than the two codes actually share, which is a different and much better thing to check.
black = the number of positions where guess and answer agree
black + white = SUM over colours of min(count in guess, count in answer)
The second line is the entire duplicate question. A colour can be reported only as many times as both codes hold it, and the blacks come out of the same allowance as the whites rather than sitting outside it.
score() deliberately does not compute it that way. It does it the two-pass
way — exact matches first, everything they used taken out of a pool, then the
leftovers matched against what remains — because that is the implementation a
person writes, and the one the arithmetic is worth checking. Two routes written
differently that agree everywhere is worth more than one route asserted against
hand-picked examples.
Four pegs from six colours is 1296 codes, so there are 1,679,616 ordered pairs. That is cheap. The test does all of them and does not have to argue about which ones matter.
| checked on every pair | how the test knows |
|---|---|
black is the positions that agree |
counted again with reduce, not the scorer's loop |
black + white is the sum of the minimums |
colour counts built with filter, sharing no code with tally |
| never more than four pegs, never negative | arithmetic |
| three black and one white never happens | it is impossible; only 14 of the 15 clue shapes ever occur |
| a code fits the clue its own guess produced | fits(), which computes it the other way round |
| swapping the guess and the answer changes nothing | see below |
$ node test-code.js
pairs swept 1,679,616
solver worst / mean 9 / 5.765 (random pick: 7 / 4.639)
guesses needed 1:1 2:4 3:25 4:108 5:305 6:602 7:196 8:49 9:6
100 passed, 0 failed
Scoring is symmetric. Swap the guess and the answer and both numbers come back the same. This was written into the plan as a thing to disprove — "scoring is not symmetric in general, but black is" — and it is wrong, provably:
It reads as though it should not hold, because "right colour, wrong place" sounds like a statement about the guess. It is not. And the payoff is real: the naive scorer is asymmetric, on 875,880 of the 1,679,616 pairs, so this one property alone catches the classic bug.
The same argument applied to positions gives a second free check: permuting the peg positions of every code cannot change any clue, so a solver run over the reversed enumeration must return not merely a similar worst case but the same distribution, code for code. It does.
Not "sometimes". Exactly on the duplicates.
| pairs | naive scorer wrong on |
|---|---|
| all 1,679,616 | 514,740 — 30.6% |
| the 129,600 where neither code repeats a colour | 0 |
| the 1,213,056 where the guess repeats a colour | 514,740 — 42.4% |
With no repeat in either code the naive version is right, and it has to be: every colour it finds has exactly one home, and no black can have taken that home, because that would need the guess to hold the colour twice. So the test asserts agreement on all 129,600 of those pairs and disagreement on a large, measured share of the rest. That localises the fault instead of merely detecting it.
The test also checks that the naive version only ever claims too much, never too little — the failure has a shape, and asserting the shape is stronger than asserting a difference.
fits, from the other sidefits(candidate, guess, marks) asks whether a candidate could still be the
answer. It is written the arithmetic way, so it shares no code with score(),
and the two are cross-checked both ways: a candidate fits a clue if and only
if it would have produced it. The easy direction runs over the whole space; the
hard one — fits must be false for all fourteen clues that are not the clue —
runs over all fifteen possible marks on a spread of 21,948 pairs, 329,220 clues
in all.
That second direction is not decoration. A fits that ignored the whites passes
the first direction perfectly and lets 73,160 wrong clues through the second.
The board keeps every code still consistent with every clue, and the footer says
how many there are. It is the one thing on screen a player cannot work out in
their head, and keeping it on the shipped path means fits and narrow are
exercised by playing rather than only by testing.
Four properties, all tested: a clue never adds a candidate back, never removes the true answer, leaves only codes that produce exactly that clue, and — the one that matters — a guess that was itself still possible always removes at least itself. That is the whole termination argument. Guess something consistent and either you are right or you have just eliminated it.
So a solver that does nothing cleverer than picking any still-possible code finishes. Measured over all 1296 answers, taking the lowest-numbered candidate: worst case 9 guesses, mean 5.765. Picking at random among the possible ones does better — worst 7, mean 4.639 — because consecutive codes in the enumeration differ in one peg and teach the same lesson twice.
Ten tries is therefore enough, but with less room than expected. That worst case of nine is a fact about the laziest legal strategy, not about the game, and it is printed by the suite so it cannot quietly drift.
Seventeen mutants, each appended to the shipped, minified js/code.js and
run against the suite. Fifteen broke something real; two changed nothing on
purpose.
| mutation | result |
|---|---|
| whites counted by membership, blacks never removed (the classic bug) | caught (20 assertions) |
| two passes, but the blacks are left in the pool | caught (17) |
| the pool is never consumed, so one peg matches twice | caught (13) |
| black counts colours held in common rather than positions | caught (19) |
fits ignores the whites |
caught (7) |
fits accepts any clue with no more whites than the pair allows |
caught (4) |
narrow keeps every candidate |
caught (9) |
submit forgets to narrow the possible set |
caught (2) |
submit scores a half-built guess |
caught (6) |
| the board allows one try too many | caught (2) |
allCodes is one code short |
caught (4) |
place allows a seventh colour |
caught (1) |
| undo takes back the first peg placed, not the last | caught (1) |
| the score ignores the guesses a run had to spare | caught (2) |
| every board deals the same code | caught (1) |
score wrapped, behaviour unchanged |
passes, as it should |
score called with the guess and the answer swapped |
passes, as it should |
The second no-op is the symmetry result being cashed in: calling the scorer backwards is not a mutation at all, and the suite is right to let it through.
Four of these mutants originally showed up as survivors. They were not — they were killing the suite outright, because a broken scorer can narrow the field down to nothing and the test then reached into an empty list. A suite that dies reports nothing, so the two places that could do it now count the empty field and fail on it like anything else.
Two of the test's own assertions were wrong before the code was. Both claimed the naive scorer would report more than four pegs; it never can, because it counts at most one per position. Suspecting the test before the code is the house rule and it earned its keep twice in one afternoon.
The drawing layer has no test file — the split exists precisely so it does not need one — but its geometry is arithmetic, and re-deriving the tray at the sizes the shell can produce found a real fault: the width budget allowed eight pixels per gap while the layout then spaced them by 28% of a swatch, so on a 380 pixel portrait the row of eight controls came out wider than the screen it had just been measured for. Both now use the same number.
Tap a colour to drop it in the next hole, or tap a hole first to aim. Tap a peg you have placed to take it out; UNDO removes the last one. CHECK lights up when the row is full. On a keyboard: 1-6 place, Backspace undoes, Enter checks, Escape or P pauses.
Crack a code and another is dealt. The run ends the first time one beats you, so the score is codes cracked plus every guess you did not need.
Every peg carries its initial. Six hues on one dark background is close to the worst case for colour blindness, and this is a game about telling colours apart. The letter is not decoration — it is the only reason the board can be read without relying on hue at all. The clue pegs follow the same rule: filled means right place, hollow means right colour, and neither is a shade of the other.
Advertisement