I got a bit fixated on the numbers round, so I built it.
Pick your six, beat the clock, and afterwards it shows you the best answer the board allowed. It works that out by checking every legal sum there is, so it isn't guessing.
No dependencies, not even development ones, and no build step. Plain ES modules, tested with
node:test and a real browser.
npm start # serves the game at http://localhost:8000
npm testA server is needed only because ES modules are blocked over file://; any static host will do.
- Six numbers. Large tiles are 25, 50, 75 and 100, one of each; small tiles are 1 to 10, two of each. You choose how many come from the top.
- A random three-digit target, 101 to 999.
- Thirty seconds, using
+ − × ÷. - Each number may be used once, and you need not use all six.
- Every intermediate result must be a positive whole number. That one constraint is what makes the round hard.
- 10 points for exact, 7 within five, 5 within ten, nothing further out.
- Against an opponent, only the closer declaration scores; a tie scores for both.
You combine two numbers at a time. The result becomes a new tile and the two you used leave the board, which is how contestants actually do it. Illegal steps are refused with a reason, so the positive-whole-number rule enforces itself.
Subtraction and division orient themselves — only one ordering can be legal, so clicking 3 then
− then 8 gives 8 − 3. The working records what was actually computed.
Keyboard: 1–6 pick a number, + - * / choose an operator,
Backspace undoes, Enter declares.
Rounds are seeded. The result screen shows a ?seed= link that replays the identical round.
Given six numbers and a target, search() walks every legal expression:
search(numbers, target) -> { best, byDiff }best is the closest reachable value, preferring fewer steps. byDiff holds one example solution
per distance from the target, which is how the CPU opponent plays a genuinely reachable but
imperfect answer instead of an invented one.
Two things keep it down to a few tens of milliseconds:
- Multiset deduplication. States are keyed on the sorted multiset of live values, since two identical multisets reach identical results. Arithmetic gets to the same board by many routes, so this is the difference between milliseconds and minutes.
- Pruning ×1 and ÷1. Both hand back a value already on the board while using up an extra number, so nothing reachable is lost by skipping them.
Both claims are checked rather than assumed: a second, deliberately naive brute force with neither optimisation has to agree on the best achievable distance for every board tested.
It solves the 1997 round — 952 from {25, 50, 75, 100, 3, 6} — in five steps. About 94% of random boards have an exact solution.
The search runs when the round is dealt, before the clock starts, so it never eats into the player's time.
index.html markup
src/
solver.js arithmetic rules and the search
game.js draws, scoring, round state
cpu.js opponent difficulty profiles
rng.js seedable PRNG
clock.js the countdown
store.js settings and stats
sound.js WebAudio blips, no asset files
ui.js DOM rendering
main.js controller
scripts/serve.js static server for npm start and the tests
test/
helpers.js the rules, re-implemented independently
browser/ checks that drive the real page
Only ui.js and main.js touch the DOM, which is what makes the rest testable in plain node.
npm test runs 50 tests — the rules in node, the interface in a real browser. No test framework,
nothing to install.
In node:
- Every solution the solver reports is replayed from scratch and checked for tile availability, exact division and positivity, across 400 seeded boards.
- The solver's answers are cross-checked against unoptimised brute force, which is what justifies its two optimisations.
- Draws respect the real tile pools over 2,500 draws per configuration.
- The full head-to-head scoring table, including ties and out-of-range declarations.
- Undo restores tile identity, so duplicate values never alias.
- The opponent can never beat the optimum and is ordered by difficulty.
- The clock runs on an injected scheduler, so a fake one can prove it still expires exactly once when frames stop — the background-tab case.
- The dev server refuses to serve anything outside the project directory.
In the browser, 67 checks drive the real page inside an iframe with genuine clicks and key presses: the staged reveal, combining and undoing, refused moves, keyboard play, focus surviving a step, head-to-head results, the clock running down and declaring at time up, seed reproducibility across reloads, and a pass over accessible names and ARIA state.
That pass needs Chrome or Chromium on PATH, or CHROME_BIN pointing at one. Without a browser it
skips locally, but never on CI, so the coverage cannot quietly disappear.
CI runs the whole suite on Node 18, 20 and 22.
- The reveal is staged like the show: numbers placed one at a time, then the target settles, then the clock starts. Click or press a key to skip it.
- The countdown pairs a frame loop with an interval, because
requestAnimationFramestops in a background tab and the round still has to end on time. - Honours
prefers-reduced-motion.
MIT


