Puzzles
Griddles
A picture puzzle where you fill in grid cells by the numbers beside each line until a hidden picture appears.
On this page
Griddles is the family’s name for a nonogram. Each row and column has a list of numbers: the lengths of the runs of filled cells in that line, in order. Fill the cells so every line matches its numbers, and a black-and-white picture appears.
Unlike the kit’s other puzzles, Griddles doesn’t generate puzzles. It has a fixed library of pictures, made offline from icons and hand drawings and each proved fair before it ships, and a code just names a picture. This chapter covers the engine and board, then the pipeline that makes the pictures (Griddles’ pictures has the overview). The hub, clock, saving, posting and archive are the pencil-puzzle kit’s.
At a glance#
| Part | Where | What it is |
|---|---|---|
| Engine | games/static/games/griddles/engine/ |
generate.js (codes and clues), lines.js (one line’s logic), solver.js, count.js (the plain search), pictures.js (the built library), index.js (the kit’s contract) |
| Interface | games/static/games/griddles/js/ |
puzzle.js (words, settings, How to play), board.js (the board), art.js (emblem, small pictures) |
| Look | games/static/games/griddles/griddles.css |
Griddle-cake brown, graphite cells, heavier fives |
| Pictures pipeline | tools/griddles/ |
raster.mjs, sheet.mjs, touch.mjs, build.mjs, pictures.mjs and the source pictures.txt; play.mjs plays it in Chrome |
| Server | puzzles/griddles.py |
Its Kind: sizes, times, the answer’s shape, how a day’s picture is picked and kept, picture_key |
| Pages | games/templates/games/griddles.html, games/templates/games/thumbs/griddles.html |
The kit’s page; the box art |
| Tests | tests/test_griddles.py, tools/puzzles/griddles.test.mjs |
The server and referee; the board under Node |
| Design notes | docs/griddles.md |
Rules, tiers, sources and licenses, open questions |
Decisions: ADR 0023 (the kit), ADR 0027 (a size is a difficulty), ADR 0032 (the archive and picked dailies), ADR 0034 (one daily a game), ADR 0030 (ratings).
The rules in code#
A puzzle#
games/static/games/griddles/engine/generate.js generate · lines 97–112/**
* The puzzle for a code:
* { engine, slug, code, size, difficulty, n, id, title, rows, cols }
* (`id` and `title` say what the picture is, shown once it's solved).
* 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 the tier it was built to.
*/
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 [id, title, hex] = PICTURES[size][placeOf(code, size)];
// (for the checks: each picture was built to need its size's tier, tools/griddles/build.mjs)
Object.assign(stats, { attempts: 1, rated: { [difficulty]: 1 } });
return { engine: ENGINE_VERSION, slug: SLUG, code, size, difficulty, n, id, title, ...cluesOf(bitsOf(hex, n), n) };
}
A puzzle is { engine, slug, code, size, difficulty, n, id, title, rows, cols }. rows[r] and cols[c] are lists of run lengths; [] is an empty line, drawn as 0. id and title name the picture, shown only once it’s solved. cluesOf runs runsOf over every row and column.
An answer#
An answer is the grid as one string, row by row, 1 filled and 0 not. The engine accepts any grid whose runs match the clues, though every picture has exactly one:
games/static/games/griddles/engine/index.js check · lines 18–24/** Whether an answer (the grid as "0110…", row by row, 1 for filled) solves the puzzle: every row and column's runs are its clue. */
export function check(puzzle, answer) {
const { n } = puzzle;
if (typeof answer !== 'string' || answer.length !== n * n || !BITS.test(answer)) return false;
const got = cluesOf([...answer].map(Number), n);
return JSON.stringify(got.rows) === JSON.stringify(puzzle.rows) && JSON.stringify(got.cols) === JSON.stringify(puzzle.cols);
}
Crosses are the player’s notes; a crossed cell is a 0.
Codes name places#
A code’s six letters are a number in base 32 (ALPHABET in the kit’s code.js). That number mod 1024, then mod the size’s list length, is the picture’s place in its size’s list:
games/static/games/griddles/engine/generate.js placeOf · lines 51–54/** The picture's place in its size's list that a code names. */
export function placeOf(code, size) {
return (valueOf(code) % PLACES) % PICTURES[size].length;
}
games/static/games/griddles/engine/generate.js codeFor · lines 63–64/** A code naming exactly picture `place` (`salt` picks among the many that do: 0 ≤ salt < 2²⁰). */
export const codeFor = (place, salt = 0) => codeOf((salt % 2 ** 20) * PLACES + place);
codeFor(place, salt) writes a code for exactly that place, with up to 2²⁰ salts per place, so a daily’s code doesn’t reveal its place. The server has its own copy of the arithmetic (puzzles/griddles.py) and reads picture ids from the game’s pictures.js.
An endless code is newCode(size): a random dealt place and a random salt, from crypto.getRandomValues. Every dealt picture is equally likely, and retired ones never come up (Retired pictures). Six random letters would favor the first places slightly (1,024 doesn’t divide evenly by a list’s length) and would land on retired ones.
games/static/games/griddles/engine/generate.js DEALT · lines 56–61/**
* Each size's places that are dealt: every picture but the retired, which
* keep their places (so their codes, a past daily's among them, still open
* them) but are never picked again (tools/griddles/pictures.mjs).
*/
export const DEALT = Object.fromEntries(Object.entries(PICTURES).map(([size, list]) => [size, list.flatMap((p, k) => (p[4] === 'retired' ? [] : [k]))]));
games/static/games/griddles/engine/generate.js newCode · lines 66–80/**
* A fresh code for an endless puzzle. At a size, a random picture there
* that's dealt, each as likely, under a random salt (six random letters
* would favor the first places a little, 1,024 not dividing evenly by the
* list's length, and land on retired ones); without one, six random letters.
*/
export function newCode(size) {
const dealt = DEALT[size];
if (dealt) {
const [k, salt] = crypto.getRandomValues(new Uint32Array(2));
return codeFor(dealt[k % dealt.length], salt % 2 ** 20);
}
const bytes = crypto.getRandomValues(new Uint8Array(6));
return Array.from(bytes, b => ALPHABET[b % ALPHABET.length]).join('');
}
The line solver#
Each size keeps only pictures that need exactly its tier of technique. The solver works one row or column at a time and never guesses:
games/static/games/griddles/engine/solver.js TECHNIQUES · line 23export const TECHNIQUES = { simple: 1, edges: 2, line: 3, trial: 4 };
One line’s logic#
games/static/games/griddles/engine/lines.js works out everything one line can tell you, with a small dynamic program over (cell, run). reach[i][j] says the first j runs fit in the first i cells, each followed by an empty cell. back[i][j] says runs j… fit in cells i…. Together they give, for every cell, whether some placement fills it and whether some leaves it empty, plus each run’s earliest and latest start.
It also keeps pushed: each run pushed as far left, and as far right, as the runs beside it allow, without checking whether a filled cell further on is left uncovered. A person does the same when “pushing the runs to one side”, and it’s what separates the tiers:
games/static/games/griddles/engine/lines.js settle · lines 95–146/**
* The cells a line settles, as [[cell, value]], by one of three techniques,
* a person's ladder from quick to thorough:
* 'simple' (tier 1): slide the runs as far left and as far right as the
* crossed-out cells allow, ignoring what's filled: where a run's two
* places overlap is filled, cells no run can reach are empty; and a line
* with all its filled cells in is crossed off.
* 'edges' (tier 2): push the runs to each side honoring the filled cells
* too, each run as far as everything on that side of it allows: a run
* glued to a filled cell near an edge, held past a boundary it can't
* cross (the glue and run-boundary logic of a careful solver).
* 'line' (tier 3): every placement that agrees with the line at once, the
* most one line can tell: it also sees when a filled cell further on
* must be some run's, pulling the runs before it back, and cells every
* placement leaves empty between two runs' reaches.
* Returns null for a line that contradicts its clue.
*/
export function settle(clue, line, technique = 'line') {
const o = lineOptions(clue, line);
if (!o.ok) return null;
const out = [];
const L = line.length;
if (technique === 'line') {
for (let i = 0; i < L; i++) {
if (line[i] !== UNKNOWN) continue;
if (o.fill[i] && !o.empty[i]) out.push([i, FILLED]);
else if (o.empty[i] && !o.fill[i]) out.push([i, EMPTY]);
}
return out;
}
let { first, last } = o;
if (technique === 'simple') {
const filled = line.reduce((a, v) => a + (v === FILLED ? 1 : 0), 0);
if (filled === clue.reduce((a, b) => a + b, 0)) {
for (let i = 0; i < L; i++) if (line[i] === UNKNOWN) out.push([i, EMPTY]);
return out;
}
const loose = lineOptions(clue, line.map(v => (v === FILLED ? UNKNOWN : v)));
({ first, last } = loose);
} else if (technique === 'edges') ({ first, last } = o.pushed);
const reachable = new Array(L).fill(false), sure = new Array(L).fill(false);
clue.forEach((len, j) => {
for (let i = first[j]; i < last[j] + len; i++) reachable[i] = true;
for (let i = last[j]; i < first[j] + len; i++) sure[i] = true;
});
for (let i = 0; i < L; i++) {
if (line[i] !== UNKNOWN) continue;
if (sure[i]) out.push([i, FILLED]);
else if (!reachable[i]) out.push([i, EMPTY]);
}
return out;
}
| Tier | Technique | What it uses | What a person does |
|---|---|---|---|
| 1 · 10×10 | Simple | Starts computed from crosses only; the overlap is filled, unreachable cells empty. A line with all its filled cells is crossed off. | Slide each run both ways; where the two places overlap is filled. |
| 2 · 15×15 | Edges | pushed: starts that honor filled cells too. |
A run stuck to a filled cell near an edge, or held past a boundary. |
| 3 · 20×20 | Line | Every placement at once. | A filled cell further on must belong to some run, which pulls earlier runs back. |
| 4 | Trial | Suppose a cell, follow tiers 1–3, take the other value on a contradiction. | Never needed; it’s there so the checks can prove that. |
The ladder#
propagate sweeps every row, then every column, with the gentlest technique and applies what each line gives up. Whenever a harder technique makes progress, it starts again from the gentlest. A line step is one line giving something up to one technique. A picture’s measure is its line steps per technique, stored in pictures.js as "s63 e10 l17". A filled grid is checked once more against every clue. The solver’s tier is the hardest technique used, and rate() maps it to easy, medium or hard.
A puzzle every line settles by forced steps has one answer, which proves uniqueness. games/static/games/griddles/engine/count.js proves it again without line logic. It tries every way to fill each row (as bit masks), top to bottom, tracking each column’s progress as (which run, how long so far). It drops a branch once a column overruns its run or can’t fit what it still needs. It stops at 2 answers or 20 million rows tried.
Making the pictures#
The “generator” runs once, offline, by hand. What ships is games/static/games/griddles/engine/pictures.js: per size, [id, title, cells as hex, measure].
The source: pictures.txt#
tools/griddles/pictures.txt is the readable source. Each picture has a header, == <size> <id> <Title> | <source> (plus | retired if it’s no longer dealt), then its rows, # filled and . empty. The source is phosphor:<icon>, tabler:<icon> or drawn. tools/griddles/pictures.mjs reads and writes it and packs cells into hex. Within a size, a picture’s position is its place, which codes name.
1. Rasterize (raster.mjs)#
node tools/griddles/raster.mjs out.json phosphor=<dir> tabler=<dir> draws icons into grids in headless Chrome (the browser checks’ launcher), forty icons per page call:
- Crop. Draw each SVG at 512 × 512 and find its ink’s bounding box (alpha over 24).
- Fit the box into the N × N grid twelve ways: plain or stretched 10% on either axis, at six quarter-cell nudges.
- Measure each cell’s share of ink, drawing at 16 pixels a cell.
- Threshold at seven levels, 0.35 to 0.65. A candidate must be 22% to 80% filled, with no empty row or column splitting the picture.
- Choose. Of the 60 candidates closest to their ink shares, the first the solver finishes at exactly the size’s tier wins. Failing that, the closest it finishes at all is kept, with its ink shares, for touching up.
tools/griddles/raster.mjs SIZES · lines 25–27// Each size and the tier of technique its pictures must need (solver.js): 10×10
// simple sliding alone, 15×15 the edges, 20×20 the whole line.
const SIZES = [[10, 1], [15, 2], [20, 3]];
2. Look it over (sheet.mjs)#
node tools/griddles/sheet.mjs <candidates.json | pictures.txt> <size> <out-prefix> draws contact sheets of numbered thumbnails. A person keeps a picture only if it reads as what it is at that size. Of about 480 subjects, 85 icons read at 10×10, 187 at 15×15 and 173 at 20×20. Icons lose the most at 10×10, so many of its pictures are drawn by hand, cell by cell. Brand marks were left out, and drinks kept to a few.
3. Touch up (touch.mjs)#
Real pictures rarely need whole-line logic, so most 20×20s and many 15×15s are touched up: a few outline cells are flipped until the solver needs the size’s tier and still finishes without guessing.
tools/griddles/touch.mjs touchUp · lines 36–75/**
* The fewest, least visible flips that make a picture need tier `target`
* (one cell, else two, else three or four), or null. `cov` is each cell's ink share, if known.
*/
export function touchUp(bits, n, target, cov = null, budget = 1500) {
const at = (r, c) => (r < 0 || c < 0 || r >= n || c >= n ? 0 : bits[r * n + c]);
const edge = [];
for (let i = 0; i < n * n; i++) {
const r = Math.floor(i / n), c = i % n;
const nb = [at(r - 1, c), at(r + 1, c), at(r, c - 1), at(r, c + 1)];
const same = nb.filter(v => v === bits[i]).length;
// On the outline, and not left as a lone speck or a pinhole once flipped.
if (same === 4 || same === 0) continue;
if (4 - same < 2) continue;
edge.push(i);
}
const cost = i => (cov ? Math.abs(cov[i] - 0.5) : 0.25);
edge.sort((a, b) => cost(a) - cost(b));
const ok = flips => {
const b = bits.slice();
for (const i of flips) b[i] = 1 - b[i];
if (split(b, n)) return null;
return tierOf(b, n) === target ? b : null;
};
for (const i of edge.slice(0, 160)) { const b = ok([i]); if (b) return { bits: b, flips: [i] }; }
const few = edge.slice(0, 45);
for (let x = 0; x < few.length; x++) for (let y = x + 1; y < few.length; y++) { const b = ok([few[x], few[y]]); if (b) return { bits: b, flips: [few[x], few[y]] }; }
// Then three or four at once, drawn (deterministically) from the most
// ambiguous sixty, a bounded number of tries.
const pool = edge.slice(0, 60);
let seed = 7;
const rand = k => { seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; return seed % k; };
for (let t = 0; t < budget && pool.length > 4; t++) {
const k = 3 + (t % 2), flips = new Set();
while (flips.size < k) flips.add(pool[rand(pool.length)]);
const b = ok([...flips]);
if (b) return { bits: b, flips: [...flips] };
}
return null;
}
Only outline cells can flip (two or three of four neighbors differ), so a flip never leaves a lone speck or pinhole. Cells whose ink share was nearest half go first, so the picture changes least. The tool tries single flips among the first 160, every pair among the first 45, then up to 1,500 fixed draws of three or four from the first 60. A flip that splits the picture is refused.
touch.mjs <candidates.json> uses raster’s grid if it hit the right tier, else touches up. --write rewrites pictures.txt, and the results go back on the contact sheets. At 20×20, one to four cells of 400 changed.
4. Build and prove (build.mjs)#
node tools/griddles/build.mjs refuses the whole build, naming the line in pictures.txt, unless every picture:
- is a size the game makes, with an id unique at that size;
- is finished by the line solver, reaching this picture (not another grid with the same clues), at exactly its size’s tier;
- has exactly one answer by
count(which hasn’t given up); - is 15% to 85% filled.
Retired pictures are proved too, since their codes still open them, and are written with "retired" after the measure. A size with nothing left to deal is refused, and so is a row of the wrong width, which would shift every later row into a different picture. The build writes pictures.js and prints each size’s counts. --check writes nothing and fails if pictures.js isn’t byte for byte what pictures.txt makes, so a hand edit can’t slip through; npm run check:puzzles runs it.
Sources and licenses#
| Set | License | Used for |
|---|---|---|
| Phosphor Icons 2.1.1 (fill weight), © 2023 Phosphor Icons | MIT | Most pictures (phosphor:) |
| Tabler Icons 3.35.0 (filled), © 2020–2025 Paweł Kuna | MIT | The rest (tabler:) |
| Drawn here | The site’s own | 69 of the 122 dealt at 10×10, one at 20×20 (an ice cream cone) |
Both licenses ask that the copyright notice travel with the icons. The grids are derived works, credited in docs/griddles.md and at the head of pictures.txt. The icon sets aren’t installed: npm pack one into a scratch folder and point raster.mjs at it.
Determinism and versions#
Nothing in the pipeline runs at play time, so a code and size always name the same place, and a place the same picture, as long as the list doesn’t change.
The engine is at version 5. Adding pictures at the end of a size changes where endless codes wrap round. Changing, moving or removing one changes what its codes make. Any of these bumps the version; generate.js’s header has the history. Griddles reached the site at version 4, so every daily made before version 5 used version 4’s list. That’s how the older dailies learned their pictures (A day keeps its picture).
Retired pictures#
A picture that isn’t good enough isn’t removed, or its past days would open as whatever took its place. It’s retired: it keeps its place and cells, so every code naming it still opens it (a past daily, a saved game, a link), but it’s never dealt again. Endless draws from DEALT, the dailies’ pick skips it (A day’s picture), and the server reads which are retired from the same pictures.js (retired() in puzzles/griddles.py). No place moves, so the ids the migration copied still match today’s list, and the tests hold it to that.
Sizes and difficulty#
| Size | Difficulty |
|---|---|
| 10 | easy |
| 15 | medium |
| 20 | hard |
puzzles/griddles.py, KIND.size_difficultyMeasured on 2026-10-25 over every dealt picture (version 5) under Node 24. Times are from 2026-10-19, over version 4’s library on David’s Mac.
| 10×10 (easy) | 15×15 (medium) | 20×20 (hard) | |
|---|---|---|---|
| Pictures dealt | 122 (38 Phosphor, 15 Tabler, 69 drawn) | 153 (120 Phosphor, 33 Tabler) | 154 (117 Phosphor, 36 Tabler, 1 drawn) |
| Retired | 3 | 34 | 19 |
| Simple line steps, median (range) | 26 (14–39) | 52 (33–71) | 63 (18–97) |
| Edges line steps, median (range) | 0 | 6 (1–31) | 10 (0–39) |
| Whole-line steps, median (range) | 0 | 0 | 16 (2–74) |
| Cells settled by simple / edges / line (mean) | 100 / 0 / 0 | 209 / 16 / 0 | 308 / 24 / 68 |
solve time, median (95th pct), version 4 |
0.25 (1.2) ms | 0.67 (2.2) ms | 2.0 (7.2) ms |
count time, median (95th pct; max), version 4 |
0.1 (0.8; 8) ms | 0.7 (13; 68) ms | 4.5 (126; 2,640) ms |
Every stored measure matched the solver. A 10×10 is all sliding, a 15×15 adds a handful of edge steps, and on a 20×20 a sixth of the cells need whole-line logic. 21 of the 20×20s need the whole line but never the edges: a tier is the hardest technique used, not every one below it. The plain search can take seconds (the ladybug, a 20×20), so it runs only at build time and in checks, never in the browser.
The one daily is the lineup’s middle slot: from 2026-10-20, the 15×15 alone (One daily a game). From 2026-10-26 the week picks the size (ADR 0041, the week climbs): 10×10 on Monday and Tuesday, 15×15 Wednesday to Saturday, 20×20 on Sunday, each from its own size’s order. Earlier days keep their three.
A day’s picture#
Other kit puzzles take a daily code from an HMAC of the slot under the site’s secret key. Griddles can’t, because pictures should come round in order and never be a daily twice. So its Kind declares pick:
puzzles/griddles.py pick · lines 93–112def pick(kind, day, size, difficulty, used, anywhere=()):
"""A day's picture: the first, in an order of the pictures only the
site's key knows, not yet a daily at this size; once all have been, the
one longest ago. `used` is the pictures of every earlier daily at this
size, as they were kept (DailyPuzzle.picture), so a picture moved or
removed by a later list is still known. Its code names exactly that
picture (engine's codeFor), so adding pictures never changes a day's.
From the day the week picks the size (docs/adr/0041), a picture that's
been a daily at any size waits until every other has had its turn at
this one (`anywhere`, every size's pictures, oldest first). A retired
picture is never picked."""
ids = pictures()[size]
gone = retired()[size]
order = sorted((k for k in range(len(ids)) if ids[k] not in gone), key=lambda k: daily.seed(kind.slug, "order", size, ids[k]))
last = {p: k for k, p in enumerate(used)}
taken = set(last) | (set(anywhere) if daily.follows_week(day) else set())
fresh = [k for k in order if ids[k] not in taken] or [k for k in order if ids[k] not in last]
place = fresh[0] if fresh else min(order, key=lambda k: last[ids[k]])
salt = daily.seed(kind.slug, day.isoformat(), size, difficulty) % 2**20
return code_of(salt * PLACES + place)
Each size’s dealt pictures are sorted by daily.seed("griddles", "order", size, id), an order only the site’s key can produce. A day takes the first picture in that order not yet used as a daily at its size. used is the pictures of every earlier daily in that slot, as each day kept them. Once all are used, it takes the one used longest ago. The code is codeOf(salt × 1024 + place), with the salt from the day’s seed, so it names exactly that place.
Each pick depends on the ones before, so days are filled in date order from since (2026-10-01), under a row lock (Each game’s daily), though the archive lists only days from 2026-10-22 (ADR 0037). The 15×15 was the daily every day from October 1 to 25; at four days a week from then, its 153 dealt pictures last until about June 2027.
A day keeps its picture#
A code names a place, and a later list could put another picture there. So each daily stores the picture its code showed when the day was made, DailyPuzzle.picture (picture_of(code, size)). Whenever a day is served (today, or a month of the archive), its code is checked against it:
puzzles/griddles.py repoint · lines 81–90def repoint(code, picture, size):
"""The code that shows `picture` today with this code's salt: the code
itself while its place still holds the picture, else one naming the
picture's new place; None if the list no longer has it."""
ids = pictures()[size]
if ids[place_of(code, size)] == picture:
return code
if picture not in ids:
return None
return code_of(value_of(code) // PLACES * PLACES + ids.index(picture))
If the picture has moved, the day gets the code for its new place with the same salt, saved once, so the archive, boards and posting agree. A solve made on the day under the old code still counts by the solve’s own day (_as_kept). “Never a daily twice” also goes by the stored pictures.
A picture removed from the list can’t be shown again: its days keep their code, which then shows whatever holds the place (and this is logged). That’s why pictures are retired instead. A picture redrawn in place (same id) shows its new cells on its past days. Older dailies learned their pictures in a migration (puzzles/migrations/0003_keep_daily_pictures.py): from what solvers were given that day, else from version 4’s list, copied into the migration.
Your move#
firstMove gives the kit’s first-move prompt (Your first move) its words: Fill in your first run (“Start where a number is big for its line.”). It shows once per browser.
What the browser holds#
The browser has the whole library and every answer: pictures.js is a static file, and the board computes solution() for Check. That’s fine for a solo puzzle. What matters is what the server accepts, and a day’s picture can’t be known before its day without the key.
Posting and the referee#
A solve posts { code, size, difficulty, engine, answer, seconds }. Griddles’ shape check:
puzzles/griddles.py clean · lines 26–32def clean(answer, size):
"""An answer is the whole grid, row by row, as one string: 1 for a filled
cell, 0 for one left empty. "0011…", n × n of them."""
n = GRIDS[size]
if not isinstance(answer, str) or len(answer) != n * n or not set(answer) <= CELLS:
raise ValueError("not filled or empty in every cell")
return answer
The kit’s referee then makes the puzzle from the code under Node and accepts only the exact picture (The pencil-puzzle kit: the one answer). Otherwise it answers That isn’t the picture these clues make. (422).
A picture is solved once. Many codes name each picture, and a picture you’ve done is quick the next time, so the Kind sets picture_key = "id". A solve posted outside its day (Endless, or the archive) for a picture you’ve already solved at that size, under any code, gets your first solve back as already, and nothing is kept: no time, no board place, no rating. Today’s daily always counts on its day, because the daily is everyone’s.
puzzles/api.py _seen · lines 202–212def _seen(kind, mine, found):
"""For a puzzle whose many codes name each picture (Griddles), the
player's first solve of this picture at this size under any code, or
None: a picture you've solved is quick the next time a code lands on
it, so it's no new solve, time or rating (a day's daily still is)."""
key = kind.picture_key
picture = found.data.get(key) if key and isinstance(found.data, dict) else None
if picture is None:
return None
same = {"puzzle__kind": kind.slug, "puzzle__size": found.size, "puzzle__difficulty": found.difficulty, f"puzzle__data__{key}": picture}
return mine.filter(**same).order_by("created", "pk").first()
Minimum believable times, roughly one stroke a run with the picture in front of you:
| Size | Seconds |
|---|---|
| 10 | 15 |
| 15 | 30 |
| 20 | 60 |
puzzles/griddles.py, KIND.min_secondsRating#
Griddles is rated by Kit("griddles") like every timed puzzle (Timed puzzles): today’s daily at midnight in a field of its solvers, anything else against its size’s par at half weight once the size has 5 earlier solves. Check and Show mistakes don’t affect it.
Worked: an endless 15×15 (guide/worked/griddles.py). A settled 1550 ± 80 player, par 4:57, expected 0.531:
| Time | Score | Change at half weight | |
|---|---|---|---|
| A new picture | 4:10 | 0.585 | +0.4 |
| The same picture again, under another code, were it rated | 1:05 | 0.954 | +3.4 |
The second row is why a repeat isn’t rated: to the server each code is a different puzzle, so without picture_key a known picture would be a fast, rated solve. Repeats kept before that rule aren’t rated either (Kit.seen in ratings/sources.py).
The interface#
Marks and moves#
A cell is blank, filled or crossed (BLANK, FILL, CROSS), saved as { c: "0120…" }. A move is { c: [[cell, from, to], …] }, so a tap, a whole stroke and Restart are each one Undo.
A stroke is stroke(i, info) on the kit’s grid surface. With pressStrokes: true, a right- or Shift-drag, or a hold that then drags, comes to the same stroke with info.press, and paints what pressOf makes of the first cell instead of the tap’s next mark. A click or hold without a drag is still a press.
games/static/games/griddles/js/board.js pressOf · lines 120–124// Press and hold, right-click, Shift-click: straight to the other mark (or back to blank).
const pressOf = v => {
const other = fillFirst() ? CROSS : FILL;
return v === other ? BLANK : other;
};
| Input | Fill first (default) | × first (Options: Tap order) |
|---|---|---|
| Tap or click | blank → filled → crossed → blank | blank → crossed → filled → blank |
| Press and hold, right-click, Shift- or Alt-click | straight to crossed, or back to blank | straight to filled, or back to blank |
| Drag | A straight line along the first row or column it moves along, painting what the first cell became. Only cells that matched the first cell’s old mark change, so a stroke never rubs out other marks. Going back shortens it; a badge counts its cells. | The same |
| Right- or Shift-drag; hold, then drag | The same, painting what a press makes: crosses, or clearing them if it starts on a cross. A held finger’s first cell shows its cross as the hold lands. | The same with fills |
| Keys | Arrows move; Space or Enter taps (Shift: press); F fill (or clear a fill); X cross (or clear); Backspace, Delete or 0 clear; C Check |
Check and the helpers#
Check (the button under the board, or C) shows every wrong mark in red for three seconds and says how many: a red ring on a filled cell that should be empty, a red cross on a crossed cell that should be filled. It compares with solution(), worked out once on mount.
The helpers are in settings in games/static/games/griddles/js/puzzle.js:
- Gray out finished numbers (
dim, on). All of a line’s numbers fade when its filled runs match the clue. Otherwise a number fades if its run is closed off from an end of the line: walking in over crosses, every run met is filled, ended by a cross or the edge, and the right length. If the walks from the two ends close the same run as different numbers (a clue of 1 1 on a line marked filled, cross, cross, cross), the line is wrong and neither fades. A 0 fades while its line has nothing filled.
games/static/games/griddles/js/board.js finished · lines 30–71/**
* Which of a line's clue numbers are done: all of them when its filled runs
* are the clue, else those closed off from either end. A run is one
* number's: if the walks from the two ends close the same run as different
* numbers (or one number with two runs), the line is marked wrong there,
* and neither fades.
*/
export function finished(vals, clue) {
const runs = [];
let run = 0;
for (const v of vals) { if (v === FILL) run++; else if (run) { runs.push(run); run = 0; } }
if (run) runs.push(run);
if (runs.length === clue.length && runs.every((r, k) => r === clue[k])) return new Set(clue.map((_, k) => k));
const done = new Set(), wrong = new Set();
const runOf = new Map(), numberOf = new Map(); // number → its run's first cell, and back
const close = (k, first) => {
if (runOf.has(k) && runOf.get(k) !== first) wrong.add(k);
if (numberOf.has(first) && numberOf.get(first) !== k) wrong.add(k).add(numberOf.get(first));
runOf.set(k, first);
numberOf.set(first, k);
done.add(k);
};
const walk = (at, step, nth) => {
let i = at, j = 0;
const L = vals.length;
while (i >= 0 && i < L && j < clue.length) {
if (vals[i] === CROSS) { i += step; continue; }
if (vals[i] === BLANK) return;
let e = i;
while (e >= 0 && e < L && vals[e] === FILL) e += step;
if (e >= 0 && e < L && vals[e] === BLANK) return;
if (Math.abs(e - i) !== clue[nth(j)]) return;
close(nth(j), step > 0 ? i : e + 1);
j++;
i = e;
}
};
walk(0, 1, j => j);
walk(vals.length - 1, -1, j => clue.length - 1 - j);
for (const k of wrong) done.delete(k);
return done;
}
- Light your row and column (
guides, on): the row and column under the pointer or stroke, and their clues, are lit. - Show mistakes as you go (
mistakes, off): Check’s red, always on. It’s the one helper that starts off, against the site’s helpers-on default, since it would give the picture away (David’s call,docs/griddles.md). The status line then leads with how many marks are wrong. - Tap order (
order): as in the table. How to play follows it.
The status line shows the start hint, then “N marks are wrong.” (with mistakes on), else “N cells filled.”
Clues that stay in view#
A clue is set at .52 of a cell, as large as a line of clues holds it: clues in a row sit CW (.62 of a cell) apart, a two-digit clue’s neighbors in a line of 20 or fewer are one digit, and at .52 a lit “12” still keeps an eighth of a cell from them.
Zoomed in on a big grid, the clues would scroll out of sight. So each set of clues sits on a band that stays pinned to its edge once the grid scrolls far enough, moving with the grid along its own axis. A tap on a pinned band hits the clues, not the cells under it. pin() runs on every scroll frame and resize.
The reveal and the gallery#
When the filled cells match every clue, answer() returns the grid and the kit posts it. The crosses, grid lines and any red fade, and the cells settle into the picture in a wave from the top left (each delayed by row plus column, 420 ms ÷ n apart). The title rises in below, in space reserved from the start so nothing shifts. It’s instant under reduced motion.
The result sheet shows the picture and title (emblem). In the archive, gallery: true shows each solved past daily’s picture. Share result never gives the title, so it doesn’t spoil a day.
Accessibility#
The grid is one focusable SVG, labeled with the rules and keys. Each cell’s label gives its row, column, mark and both clues (“Row clue 3 1. Column clue 2.”). Strokes are spoken (“Filled 5 cells.”) with the status line. Wrong marks are a ring or cross shape as well as red. Finished numbers fade to #a8a196, 2.4:1 contrast on purpose since they’re done.
Stored in the browser#
The kit’s uo:griddles: keys (Sheets, saving and the hub) hold each picture opened (a daily under its day) with its marks, moves and time, plus recent puzzles, options, the tally, the dailies log and pending solves. A save records engine version 5, and a save from another version is ignored, since its code may name another picture.
Leaderboards and stats#
The kit’s boards (puzzles/views.py), by size: the day’s daily (fastest first, ties to who posted first), all-time best (each picture once), and the Griddles rating. The account line shows pictures solved (the Kind’s noun), the streak and best times.
Its look#
Griddles’ 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, griddle-cake brown #8a5410: the one fill, the ribbon, the kicker and the stamp, and the flash of each cell as the picture settles. Filled cells are #2a2724. A finished clue fades to #a8a196 on purpose, so the clues left to do stand out. Your row and column are lit in a pale amber wash (--gd-guide). Red is a mistake and green is solved, as on the paper. |
| Faces | The paper’s: Gloock and Figtree. Clues are Figtree; the picture’s title is Gloock. |
| Corners and edges | The kit’s |
| Board | Clues run across the top and down the left in graphite, faded once finished and bold on the row and column you’re on. Filled cells are graphite on the paper, with heavier lines every five cells and crosses in soft ink. A stroke shows a small badge counting its cells. Check, or mistakes as you go, puts a red ring on a wrong fill and a red cross where a fill belongs. Zoomed in, the clues pin to the grid’s edge on a band of paper. |
| Emblem and art | A little heart filled in on its grid; once solved, the result sheet shows the picture and its title. Its box: a real 10×10 (the sailboat) part-way through. |
| Voice | A picture on the boards and the account line (“pictures solved”); clues, fills, crosses and Check are its own words. |
| Payoff | The crosses, grid lines and any red fade, and the cells settle into the picture in a wave from the top left, each flashing the accent as it lands. The picture’s title rises in below, the frame turns green, and a “Solved 1:09” stamp in the accent presses onto the folio. Under reduced motion it’s there at once. |
| The glance | Now: the status line (“N cells filled.”, or how many marks are wrong with mistakes on). The countdown: the clock. |
| Exceptions | A finished clue is faded to 2.4:1, below the readable check’s contrast, so the clues left to do stand out (David’s call: keep the fade). |
Testing#
| Check | What it proves |
|---|---|
npm run check:puzzles |
Random codes at every size: the same puzzle twice, the right tier without trial, one answer, tampered answers refused (tools/puzzles/tamper.mjs), the referee agreeing. ABC234’s fixed fingerprints catch a library change without a version bump. Then build.mjs --check. |
tools/puzzles/griddles.test.mjs |
The board under a fake DOM: tap cycles, holding, keys, reload and Restart; number fading, including a fuzz test that correct marks never fade an unfinished number. Endless codes land evenly and never on retired pictures; retired pictures round-trip; a wrong-width row is refused. |
tests/test_griddles.py |
The Kind (at least 120 pictures a size); codes; picks (no repeats, no retired pictures, from the key, in date order); a day keeping its picture, and the migration’s backfill; a picture solved once; the archive and boards. Against the real referee: the browser makes the server’s picture, no trial needed, today’s daily lands on its board, tampering fails. |
npm run check:griddles:play (tools/griddles/play.mjs) |
Headless Chrome against a running site: a daily and an endless puzzle at each other size solved with real strokes (finger under 768px; POINTER=pen for a pen); crosses dragged on and off, each one Undo; Check, the reveal, the result sheet, and an archive day if there is one. With SESSION, the times are posted. |
Changing it#
- Add pictures: rasterize, look over and touch up, then add them at the end of their size in
pictures.txt. Runnode tools/griddles/build.mjs, bumpENGINE_VERSION(endless codes wrap differently), updateFIXEDintools/puzzles/check.mjsif ABC234’s fingerprints moved, and runnpm run check:puzzles. Existing dailies keep their pictures. - Change or move a picture: the same, knowing every endless code for a later place at that size now names another picture. Past dailies keep theirs (A day keeps its picture); one redrawn in place shows its new cells on its days.
- Drop a picture: retire it (
| retiredat the end of its header, cells unchanged), rebuild and bump. Don’t remove it, or its days would show whatever holds its place (Retired pictures). - The solver’s techniques: any change can move a picture’s tier, so rebuild, re-touch and bump.
- A helper or the board:
games/static/games/griddles/js/; no bump. Runnode --test tools/puzzles/griddles.test.mjsandnpm run check:details.
Known limitations#
- A finite library: 429 pictures dealt, 56 retired. The 15×15s come round again around June 2027, sooner if more are retired.
- Recognizability is a person’s call, checked only by eye on contact sheets.
- One color. Two-color nonograms are a different game.
- Endless repeats pictures, and a repeat is “already posted”, not a new solve.
- A daily may be a picture you’ve done in Endless. It still counts in full on its day.
- Tidy pictures are often too easy. Cleaning up a picture tends to drop it below its tier, so at 15×15 and 20×20 the review mostly kept or retired pictures rather than redrawing them.
Known issues#
None open. Fixed since this chapter was written: a known picture under a new code was a fast rated solve (now a picture is solved once); version 4’s moved pictures could change past dailies and break “never a daily twice” (a day keeps its picture); a wrong line could gray both numbers of one run; a wrong-width row could slip through the build; stale words; uneven endless odds.