TinyGames · how it works

Codebreak

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.


The whole game is one function, and the trap is in the second number

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.

The rule that pins it down completely

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.


So the property is tested over the whole space, not a sample of it

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

The thing that was expected to be false

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.


Where the bug actually lives

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 side

fits(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.


What the possible set is for

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.


What the tests are worth

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.


Playing it

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.