Puzzles
Fillomino
Write a number in every cell so each block of matching numbers has exactly that many cells.
On this page
Fillomino is a number puzzle on a grid. Some cells start with numbers printed in them. You fill in every other cell so that each block of matching numbers side by side has exactly that many cells: a 3 sits in a block of three 3s, a 4 in a block of four.
It runs on the pencil-puzzle kit (ADR 0023), which handles everything around the board (how a puzzle plugs in). This chapter covers the rest.
At a glance#
| What | Where |
|---|---|
| Engine | games/static/games/fillomino/engine/: index.js (the kit’s contract), board.js (rules), generate.js, solver.js, count.js |
| Interface | games/static/games/fillomino/js/: puzzle.js (words, settings, rules sheet), board.js (grid, pad, picks, lines), art.js (emblem, saving, the small grid) |
| Look | games/static/games/fillomino/fillomino.css: terracotta, a wash per number |
| Page | games/templates/games/fillomino.html, the kit’s page |
| Server | puzzles/fillomino.py: its Kind; the rest is the kit’s puzzles/ |
| Tests | tests/test_fillomino.py; npm run check:puzzles |
| Design notes | docs/fillomino.md, ADR 0023 |
| Sizes | 6×6, 8×8, 10×10 |
| Difficulty | One per size: easy, medium, hard (size_difficulty), so a size is a puzzle’s whole name |
| The daily | 8×8, one a day from 2026-10-20; all three sizes before |
| Engine version | 1 |
The rules in code#
A puzzle and an answer#
A puzzle is { n, givens }: n × n numbers, row by row, 0 for a blank. A cell is row * n + column everywhere. An answer is every cell’s number as one string of digits ("338777318777…"); no piece is bigger than MAX = 9, so one digit a cell is enough.
games/static/games/fillomino/engine/board.js check · lines 70–82/**
* Whether `answer` (a digit string) solves the puzzle: a number 1–9 in every
* cell, every given kept, and every group exactly as big as its number. Any
* answer that does counts.
*/
export function check(puzzle, answer) {
if (!wellFormed(puzzle)) return false;
const { n, givens } = puzzle;
const values = valuesOf(n, answer);
if (!values) return false;
if (givens.some((v, i) => v && v !== values[i])) return false;
return groupsOf(n, values).groups.every(g => g.cells.length === g.value);
}
groupsOf() joins neighboring cells with the same number into groups. The rule is then one line: every group is exactly as big as its number, and every given is kept. The rest follows:
- Two pieces of the same size can’t share a side, or they’d be one group, too big. So two 1s never touch.
- A piece can be any shape, and nothing fixes how many pieces there are.
- The rules don’t need a printed number in every piece, but ours always have one (below).
wellFormed() refuses a puzzle that isn’t square, 3 to 16 wide, with givens 0 to 9.
What the board keeps#
The player’s numbers (0 for none) and their own lines between cells. A line’s edge id is 2 × cell for the cell’s right side, +1 for its lower side. It saves as one string, "0302…|12,57". A move is { c: [[cell, from, to], …], w: [[edge, from, to], …] }, so a fill of many cells, a line drag and Restart are each one Undo.
Making a puzzle#
From a code#
The seed string is fillomino|<ENGINE_VERSION>|<size>|<difficulty>|<code> (the engine pieces), so a code always makes the same puzzle. generate(code, size) refuses any difficulty but the size’s own.
games/static/games/fillomino/engine/generate.js generate · lines 150–192/**
* The puzzle for a code:
* { engine, slug, code, size, difficulty, n, givens: [n × n, 0 for blank] }.
* Throws if the code or size isn't one the game makes, or the difficulty
* isn't the size's. `stats`, if given, is told how the making went (for
* tools/puzzles/): attempts, answers laid, and how each puzzle rated.
*/
export function generate(code, size, difficulty = SIZES[size]?.difficulty, stats = {}) {
size = Number(size);
if (!Object.hasOwn(SIZES, size) || SIZES[size].difficulty !== difficulty || !CODE_RE.test(code)) throw new Error('Not a puzzle the game makes.');
const { n } = SIZES[size];
const N = n * n;
const tier = TIERS[difficulty];
const sides = sidesOf(n);
const r = stream(`${SLUG}|${ENGINE_VERSION}|${size}|${difficulty}|${code}`);
Object.assign(stats, { attempts: 0, failed: 0, rated: {} });
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
stats.attempts++;
const answer = layAnswer(r, n, sides, MIX[difficulty]);
if (!answer) { stats.failed++; continue; }
const puzzle = { engine: ENGINE_VERSION, slug: SLUG, code, size, difficulty, n, givens: answer.slice() };
// Take givens away, one at a time, while the solver still gets there
// with the techniques allowed; but a piece's last number stays, so every
// piece shows at least one (and the slowest tries, on a sparse grid,
// are never made).
const pieces = groupsOf(n, answer);
for (const i of removalOrder(r, n, answer)) {
if (pieces.groups[pieces.of[i]].cells.every(j => j === i || !puzzle.givens[j])) continue;
puzzle.givens[i] = 0;
if (!solve(puzzle, tier).solved) puzzle.givens[i] = answer[i];
}
const got = solve(puzzle, 3);
const rated = got.solved ? DIFFICULTIES[got.tier - 1] : null;
stats.rated[rated] = (stats.rated[rated] || 0) + 1;
if (rated !== difficulty) continue;
if (deep(got.used, tier) < DEPTH[difficulty]) { stats.shallow = (stats.shallow || 0) + 1; continue; }
stats.steps = got.steps;
stats.used = got.used;
stats.givens = puzzle.givens.filter(Boolean).length;
return puzzle;
}
throw new Error(`No ${n}×${n} puzzle came of ${code}.`);
}
It tries up to ATTEMPTS = 200 answers per code, each in three steps.
1. Lay an answer#
games/static/games/fillomino/engine/generate.js layAnswer · lines 52–111// Cut the grid into polyominoes: from each cell not yet taken, in random
// order, grow a piece toward a size drawn from the mix. Then mend: two
// pieces of a size side by side merge (into one of double the size), until
// none do. Returns the number in every cell, or null if it couldn't mend.
function layAnswer(r, n, sides, mix) {
const N = n * n;
const piece = new Int16Array(N).fill(-1);
const cellsOf = [];
const total = mix.reduce((a, b) => a + b, 0);
for (const c of r.shuffle(Array.from({ length: N }, (_, i) => i))) {
if (piece[c] >= 0) continue;
let x = r.int(total), target = 0;
while ((x -= mix[target]) >= 0) target++;
const p = cellsOf.length, cells = [c];
piece[c] = p;
const edgeOf = () => {
const edge = [];
for (const i of cells) for (const j of sides[i]) if (piece[j] < 0 && !edge.includes(j)) edge.push(j);
return edge;
};
const grow = () => {
const edge = edgeOf();
if (!edge.length) return false;
const j = r.pick(edge);
piece[j] = p;
cells.push(j);
return true;
};
while (cells.length < target && grow());
// Beside a piece of its own size, it grows one more cell, or gives back
// the last it took (the rest stays in one piece), a few times over.
const clashes = () => cells.some(i => sides[i].some(j => piece[j] >= 0 && piece[j] !== p && cellsOf[piece[j]].length === cells.length));
for (let k = 0; k < 4 && clashes(); k++) {
if (cells.length < MAX && r.chance(0.5) && grow()) continue;
if (cells.length > 1) piece[cells.pop()] = -1;
}
cellsOf.push(cells);
}
if (piece.includes(-1)) return null;
let thinned = false;
for (let k = 0; k < REPAIRS; k++) {
let clash = null;
for (let i = 0; i < N && !clash; i++) {
for (const j of sides[i]) {
const a = piece[i], b = piece[j];
if (a !== b && cellsOf[a].length === cellsOf[b].length) { clash = [a, b]; break; }
}
}
if (!clash) {
if (!thinned) { thinned = true; thinOnes(r, sides, piece, cellsOf); continue; }
return Array.from(piece, p => cellsOf[p].length);
}
const [a, b] = clash;
if (cellsOf[a].length * 2 > MAX) return null;
for (const i of cellsOf[b]) piece[i] = a;
cellsOf[a] = cellsOf[a].concat(cellsOf[b]);
cellsOf[b] = [];
}
return null;
}
Free cells are taken in shuffled order. Each starts a piece with a target size drawn from the difficulty’s mix (weights for sizes 0 to 9; 0 never comes up):
games/static/games/fillomino/engine/generate.js MIX · lines 33–41// How likely each polyomino size is when the answer is laid (1…9, whole
// numbers), by difficulty: small grids keep to small pieces; bigger ones
// let a few 6s to 9s sprawl, which take longer to trace. What comes out is
// a little smaller (pieces get boxed in, and leftovers are 1s and 2s).
const MIX = {
easy: [0, 2, 10, 14, 13, 9, 5, 2, 0, 0],
medium: [0, 2, 9, 13, 13, 11, 8, 5, 3, 2],
hard: [0, 2, 8, 12, 13, 12, 9, 7, 5, 4],
};
The piece grows into random free neighbors until it reaches its size or is boxed in. If it ends beside a piece of its own size, it tries up to four times to fix that by growing or shrinking a cell. Then up to REPAIRS = 60 rounds merge touching same-size pieces into one of double the size (an answer that would pass 9 is dropped), and thinOnes() joins about half the leftover 1s to a neighbor. A cell given back after the order passed it leaves a hole, which drops the answer; holes are most of the failures measured below.
2. Take numbers away#
Every cell starts printed. removalOrder() takes one cell from each shuffled piece in turn, so pieces thin out evenly, with each piece’s first cell last.
games/static/games/fillomino/engine/generate.js removalOrder · lines 113–122// The order givens are tried for taking away: a cell from every piece in
// turn (pieces and their cells in random order), so pieces thin out evenly,
// and each piece's first cell only once every other has had its go. That
// leaves about one number to a piece, and none spelled out whole.
function removalOrder(r, n, answer) {
const pieces = r.shuffle(groupsOf(n, answer).groups.map(g => r.shuffle(g.cells.slice())));
const turns = [];
for (let k = 1; turns.length < n * n - pieces.length; k++) for (const cells of pieces) if (k < cells.length) turns.push(cells[k]);
return [...turns, ...pieces.map(cells => cells[0])];
}
A number is removed if the solver, limited to the puzzle’s own tier and below, can still fill the grid; otherwise it goes back. A piece’s last printed number is never tried. The result is minimal for those techniques.
3. Rate it#
The finished puzzle is solved again with every technique. It’s kept only if the hardest tier it needed is the size’s, with at least DEPTH steps of that tier, so a medium isn’t an easy with one medium step:
games/static/games/fillomino/engine/generate.js DEPTH · lines 46–49// How many steps of its own tier a puzzle must need, so a medium isn't an
// easy one with a single medium step, nor a hard one a medium with one look
// ahead.
const DEPTH = { easy: 0, medium: 3, hard: 2 };
Uniqueness#
Every solver step is sound (it rules out a number only when no answer could have it there), so a grid the solver fills has exactly one answer. games/static/games/fillomino/engine/count.js, a search sharing no code with the solver, proves it a second way in check:puzzles.
The kit’s contract (The engine pieces) would have generate run that search too, as Aquarium, Galaxies and Yin-Yang do. Fillomino’s doesn’t: at 10×10 the search takes a median 4 times as long as making the puzzle and up to 18 times, which a phone and the referee’s 20-second limit can’t afford. So in production, uniqueness rests on the solver alone (a known issue, below).
The solver’s ladder#
games/static/games/fillomino/engine/solver.js keeps the numbers still possible in each cell (a 9-bit mask) and takes one step at a time, with the gentlest technique that works:
| Tier | Technique | What it does |
|---|---|---|
| 1 · one group at a time (easy) | Finished | A group as big as its number: no cell beside it can hold that number. |
| Too big | A blank that would join groups past their number can’t hold it (“a 3 here would make 3 + 2”). | |
| One way out | A short group with one blank to grow into takes it. | |
| Fits | A short group whose room is exactly its number fills it. | |
| No room | A number can’t go where the patch that could hold it is smaller than the number. | |
| 2 · following a group’s growth (medium) | Way through | A cell every way of growing a group passes through is part of it. |
| Every way | List every way a group can still grow (at most WAYS[2]). Cells every way takes are its; blanks every way borders can’t hold its number. |
|
| 3 · looking ahead (hard) | Crowding | A number here would fence a group in short of its size, so it isn’t that number. |
| Room | Every way again, with longer lists (at most WAYS[3]), keeping only ways that don’t crowd out a neighbor. |
|
| 4 · trial (never rates) | Trial | Suppose a number, run tiers 1–2 to a contradiction. |
Past WAYS, a group’s ways aren’t listed at all, since nobody holds more than a few in their head:
games/static/games/fillomino/engine/solver.js WAYS · line 47const WAYS = { 2: 12, 3: 40 };
“Way through” does most of the medium work. reach() finds the cells a group can get to within the steps it still needs, and wayThrough asks, for the nearest few, whether the group could still reach its size without that cell:
games/static/games/fillomino/engine/solver.js wayThrough · lines 225–239function wayThrough(sh, s) {
for (const g of groups(sh, s).list) {
const need = g.value - g.cells.length;
if (need <= 0) continue;
const cells = reach(sh, s.dom, g.cells, g.value, -1, true);
if (cells.length < need) throw new Contradiction();
// Cells come nearest first, and those nearer than y don't need it: so
// only the first `need` of them can be one every way passes through.
for (const y of cells.slice(0, need)) {
if (single(s.dom[y])) continue;
if (reach(sh, s.dom, g.cells, g.value, y) < need && fix(s, y, g.value)) return note(s, 'way through', true);
}
}
return false;
}
Both hard techniques look one step ahead, never down a chain of guesses. check:puzzles fails any puzzle that needs trial.
What separates the sizes#
Measured for this chapter: 300 codes a size on a desktop (Node 24). All 900 had exactly one answer by count().
| Size | Tier | Printed numbers (mean, range) | Solver steps (mean) | Steps of its tier (mean, min) | Make (median, p95, max) | Answers laid (mean, max) |
|---|---|---|---|---|---|---|
| 6×6 | easy | 15.0 of 36 (42%), 10–20 | 21 | (all of them) | 1.2 ms, 3.5 ms, 29 ms | 1.1, 3 |
| 8×8 | medium | 24.2 of 64 (38%), 18–30 | 47 | 10.4, 3 | 13 ms, 29 ms, 55 ms | 1.3, 4 |
| 10×10 | hard | 37.1 of 100 (37%), 27–43 | 73 | 4.2, 2 | 129 ms, 289 ms, 402 ms | 2.2, 12 |
Mean uses of each technique per puzzle:
| Size | Finished | Too big | One way out | Fits | No room | Way through | Every way | Crowding | Room |
|---|---|---|---|---|---|---|---|---|---|
| 6×6 | 5.1 | 0.3 | 13.5 | 2.2 | 0.2 | ||||
| 8×8 | 6.7 | 0.5 | 18.2 | 3.6 | 7.3 | 9.6 | 0.8 | ||
| 10×10 | 8.8 | 0.5 | 25.9 | 4.9 | 12.5 | 14.1 | 2.0 | 0.06 | 4.2 |
Each size up is roughly twice the work and one tier deeper. Of the answers laid, 35, 91 and 255 were dropped before rating; at 10×10, 36 more rated medium and 61 were too shallow.
The daily#
puzzles/fillomino.py KIND · lines 24–38KIND = Kind(
slug="fillomino",
title="Fillomino",
engine_file="games/static/games/fillomino/engine/generate.js",
sizes={"6": "6×6", "8": "8×8", "10": "10×10"},
# The bigger the grid, the deeper the reasoning: a size is a difficulty.
size_difficulty={"6": "easy", "8": "medium", "10": "hard"},
daily=(("6", "easy"), ("8", "medium"), ("10", "hard")),
# Roughly the time it takes just to fill the blanks with the answer in
# front of you, a stroke a piece: about 20, 40 and 60 blanks.
min_seconds={"6": 8, "8": 15, "10": 25},
clean=clean,
wrong="Those numbers don’t solve this puzzle.",
since=date(2026, 10, 12),
)
A day’s code is an HMAC of fillomino|<date>|<size>|<difficulty>, a hash only the server can make because it uses a secret key (the kit’s dailies). The one daily is the 8×8 until 2026-10-26, when the weekday picks it (ADR 0041, the week climbs): 6×6 Monday and Tuesday, 8×8 Wednesday to Saturday, 10×10 Sunday. Dailies back to 2026-10-12 are in the archive.
Your move#
firstMove gives the kit’s first-move prompt (Your first move) its words: Write in a number (“Tap a cell, then a number on the pad”, or with a mouse, “type its number”).
Secrets#
None in play: the engine runs in the browser, and solution() is a console call away. The server keeps only tomorrow’s code and each puzzle’s answer (What a cheater could and couldn’t do).
Posting and the referee#
A solve posts { code, size, difficulty, engine, answer, seconds } to the kit’s view (the kit’s referee). clean() checks shape only: exactly n × n digits 1 to 9.
puzzles/fillomino.py clean · lines 15–21def clean(answer, size):
"""An answer is the whole grid's numbers, row by row, as one string of
digits 1–9 (no piece is bigger than 9): "3312…", n × n of them."""
n = GRIDS[size]
if not isinstance(answer, str) or len(answer) != n * n or not set(answer) <= DIGITS:
raise ValueError("not a number in every cell")
return answer
Then come the size’s minimum time and the answer, checked against the one the referee made under Node.
| Size | Fastest believable (seconds) |
|---|---|
| 6 | 8 |
| 8 | 15 |
| 10 | 25 |
puzzles/fillomino.py, KIND.min_secondsEach minimum is roughly the time to fill the blanks with the answer in front of you, one stroke a piece.
Rating#
Rated by Kit("fillomino") (timed puzzles), with par set by size. Endless and archive solves are rated against par when posted, at weight 0.5. The day’s daily is rated as a field at midnight.
Worked: an endless 10×10. A settled 1550 ± 80 player in 11:20, par 14:00: the time scores 0.604 and moves them +0.6. Yesterday’s 8×8 daily from the archive in 4:50, par 5:30: +0.3.
Worked: today’s 8×8, four solvers, each meeting the others at 0.50 of a game:
| Player | Before | Time | After | Change |
|---|---|---|---|---|
| Ray | 1620 | 5:01 | 1629 | +8.6 |
| Opal | 1480 | 6:28 | 1479 | −0.9 |
| June | 1500? | 4:22 | 1743? | +242.8 |
| Mo | 1550 | 7:35 | 1535 | −14.7 |
guide/worked/fillomino.py, DAILY.rowsguide/worked/fillomino.py computes these through ratings.sources.Kit and ratings.service.compute. The fastest 8×8 the referee believes, 15 seconds, would score 0.9979 against a 5:30 par.
Leaderboards and the account#
The kit’s: Daily, All-time by size and Ratings, at /games/fillomino/leaderboard/ (puzzles/views.py), plus best times on the account page.
The interface#
Filling in: picks and the pad#
games/static/games/fillomino/js/board.js was the kit’s first board whose drag selects cells instead of painting them (cellSelection(), the board’s surface). A number then fills every picked cell.
| Input | Does |
|---|---|
| Tap or click a cell | Picks it (a printed cell too). |
| Drag across cells | Picks every cell crossed except printed ones. A drag begun on a number lights it on the pad, so one tap fills the pick. |
| Shift-click, Shift + arrow | Stretches the pick to a rectangle. |
| A number (pad, or 1–9) | Fills every picked cell except printed ones, as one Undo. The pick stays, so a wrong number can be overwritten at once. |
| Rub out (pad, Backspace, Delete, X) | Empties the pick. Press and hold or right-click empties one cell. |
| Escape, or a tap off the grid | Lets go of the pick. |
| Lines (pad, or L) | Switches to drawing your own lines between cells, by tap or drag. |
The pad is the board’s tools, which the kit puts in a tray under the board.
Helpers (assists)#
Each has its own switch, all on by default:
| Option | What it computes |
|---|---|
| Draw borders | borders(n, grid, { complete: true }): a heavy line between different numbers, and around a complete piece. |
| Shade finished pieces | Every group exactly its number’s size, washed in its number’s color. |
| Show broken rules | mistakes(): a group too big (over), or short with no blank beside it (stuck), hatched red; your line between two equal numbers (split), dashed red. |
Each works from the numbers on the board, never the answer, so a finished piece means “the right size”, not “right”.
Solved, sharing and saving#
When every cell is filled and check() passes, the kit takes over (the kit’s step 7), and celebrate() washes the pieces in outward from the last number written. Share result reads “Fillomino · Daily 8×8 · Monday, October 19 / Solved in 4:51” plus the address. Saves live under uo:fillomino:p:<size>/<difficulty>/<code>.
Accessibility#
The grid is one tab stop. A cell reads as “Row 3, column 4, 4, printed. Its piece has 2 of 4 cells.”, and a pick says “5 cells selected”. Helpers use shapes as well as colors, and the player’s numbers keep 5.3:1 contrast or better on every wash.
Its look#
Fillomino’s personality, in the design system’s personality sheet. Everything else is the puzzle book’s (Puzzles).
| Family | Puzzles: the pencil-puzzle kit’s look, on the paper |
| Palette | Its accent, terracotta #a4472a: the one fill, the ribbon, the kicker and the stamp. Your numbers and lines are pencil blue #2a5fa0, signal blue a shade deeper, so they keep 5.3:1 or more on every wash. A finished piece is washed in its number’s color: the kit’s tints for 2 to 8, sand #ebe5d6 for 1 and pink #f1dbe6 for 9. Red is a mistake, as on the paper. |
| Faces | The paper’s: Gloock and Figtree |
| Corners and edges | The kit’s |
| Board | Printed numbers are heavy graphite and yours pencil blue, centered in their cells, with thin graphite between cells and a heavy frame. The helper draws heavy graphite lines between different numbers and round a finished piece; your own lines are pencil blue with round ends. A piece too big, or shut in too small, is hatched red, and a line of yours between equal numbers is dashed red. The picked cell wears a signal-blue ring and tint; a drag’s pick is washed in signal blue and ringed as one. The number pad sits in the kit’s tray: 44px outlined keys on a 2px lip, the picked cell’s number lit in pencil blue, a rub-out key, and a Lines key that fills graphite when on. |
| Emblem and art | An L of three and a domino beside it, as a finished corner of a grid. Its box: a real 6×6 (ABC234) part-way through. |
| Voice | A puzzle everywhere (“New puzzle”, “Solved”); numbers, pieces and lines are its own words. |
| Payoff | A number written pops. Solved, the pieces wash in one after another outward from the last number written, and a “Solved 1:09” stamp in the accent presses onto the folio. |
| The glance | Now: the status line, which names a broken rule. The countdown: the clock. |
| Exceptions | None |
Testing#
| Check | Proves |
|---|---|
npm run check:puzzles |
For 20 codes a size: the same puzzle every time, rated as asked with no trial, one answer by count(), tampered copies refused (tools/puzzles/tamper.mjs), the referee agreeing. Fingerprints catch an unbumped engine change. |
tests/test_fillomino.py |
Its Kind, the answer’s shape, the daily endpoint, boards, the account line and the page. The referee under Node: a daily lands on its board, bad answers and fake times are refused, a hard puzzle needs looking ahead and never trial. |
Known issues and open questions#
Found while writing this chapter; those fixed since are gone.
- Crowding barely works. It made progress 0.06 times a puzzle at 10×10, and Room does almost all the hard work. Harmless, but “hard” is in practice one technique.
- Uniqueness rests on the solver alone in production (Uniqueness). All 900 measured puzzles were unique, but an unsound solver step would ship ambiguous puzzles, and the referee would refuse a correct but different answer. A faster search, or one with a budget that falls back on the solver, would close it.
- Open questions: other sizes at other tiers; pieces with no number for an expert level; symmetric givens; colors by number; the minimum times.
Changing it#
| Change | What it needs |
|---|---|
Anything in engine/ that could change which puzzle a code makes (MIX, DEPTH, the solver) |
Bump ENGINE_VERSION, update FIXED in tools/puzzles/check.mjs, run npm run check:puzzles, and redraw the box art (games/templates/games/thumbs/fillomino.html, ABC234 6×6) and the test puzzles. |
| The board, pad or helpers | No bump. check:layout, check:details. The selection mechanics are the kit’s (kit/grid.js). |
| Sizes, slots, minimum times | puzzles/fillomino.py and the engine’s SIZES. |
Then npm run check:guide, and npm run guide:stamp fillomino.