Puzzles
Yin-Yang
Shade every cell black or white so each color forms one connected group, with no 2×2 block of a single color.
On this page
Yin-Yang is a shading puzzle. Some cells start with black or white stones. You color every other cell black or white so that all the black cells form one connected group and all the white cells form another, with no 2×2 block of a single color.
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/yin-yang/engine/: index.js (the kit’s contract), board.js (rules), generate.js, solver.js, count.js |
| Interface | games/static/games/yin-yang/js/: puzzle.js (words, settings, rules sheet), board.js (grid and shading), art.js (emblem, saving, stones) |
| Look | games/static/games/yin-yang/yin-yang.css: plum; graphite and white stones |
| Page | games/templates/games/yin-yang.html, the kit’s page |
| Server | puzzles/yin_yang.py: its Kind; the rest is the kit’s puzzles/ |
| Tests | tests/test_yin_yang.py; npm run check:puzzles |
| Design notes | docs/yin-yang.md, ADR 0023 |
| Sizes | 6×6, 8×8, 10×10 |
| Difficulty | One per size: easy, medium, hard (size_difficulty) |
| 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 cells, row by row, 0 blank, 1 black, 2 white (BLACK, WHITE; other(c) is 3 − c). An answer is the grid as a string of B and W: "BWWB…".
games/static/games/yin-yang/engine/board.js check · lines 91–105/**
* Whether `answer` ("BW…") solves the puzzle: every cell black or white,
* the givens kept, each color one group, and no 2×2 of one color. 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;
const sh = shape(puzzle);
if (sh.blocks.some(([a, b, c, d]) => values[a] === values[b] && values[a] === values[c] && values[a] === values[d])) return false;
return groupsOf(sh, values, BLACK).groups.length === 1 && groupsOf(sh, values, WHITE).groups.length === 1;
}
A grid is solved when every cell is colored and every given kept, no 2×2 block is one color (shape().blocks lists blocks by top-left corner), and each color is exactly one group (groupsOf()), joined by sides, not corners.
The solver leans on three consequences of those rules:
- No checkerboard block. A 2×2 with black on one diagonal and white on the other can’t appear, because the blacks would have to join round one side and the whites round the other, and the paths would cross.
- The border has at most one run of each color. Two black runs would have to join through the inside and cut the white in two.
- Both colors appear, since one color alone would be full of 2×2 blocks.
wellFormed() rejects a puzzle that isn’t square, 3 to 16 wide, with givens 0, 1 or 2.
What the board keeps#
The player’s colors, one digit per cell (0 none, 1 black, 2 white). Printed stones never change. A move is { c: [[cell, from, to], …] }, so a tap, a drag and Restart each take one Undo.
Making a puzzle#
From a code#
The seed is yin-yang|<ENGINE_VERSION>|<size>|<difficulty>|<code> (the engine pieces), so the same code always makes the same puzzle. generate(code, size) refuses any difficulty but the size’s own.
games/static/games/yin-yang/engine/generate.js generate · lines 77–119/**
* The puzzle for a code:
* { engine, slug, code, size, difficulty, n, givens: [n × n: 0 blank, 1 black, 2 white] }.
* 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, how each grid tried rated, and how many the
* plain search refused (`ambiguous`, none so far).
*/
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 tier = TIERS[difficulty];
const sh = shape({ n });
const r = stream(`${SLUG}|${ENGINE_VERSION}|${size}|${difficulty}|${code}`);
Object.assign(stats, { attempts: 0, ambiguous: 0, rated: {} });
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
stats.attempts++;
const answer = layAnswer(r, sh);
if (!answer) { stats.failed = (stats.failed || 0) + 1; continue; }
const puzzle = { engine: ENGINE_VERSION, slug: SLUG, code, size, difficulty, n, givens: Array.from(answer) };
// Take givens away, one at a time, while the solver still gets there
// with the techniques allowed.
for (const i of r.shuffle(Array.from({ length: sh.cells }, (_, k) => k))) {
puzzle.givens[i] = 0;
if (!solve(puzzle, tier, { sh }).solved) puzzle.givens[i] = answer[i];
}
const got = solve(puzzle, 3, { sh });
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; }
// The kit's contract (docs/puzzles.md): kept only if a plain search,
// sharing no code with the solver, finds this answer and no other, so a
// solver step that wasn't sound can't ship a puzzle with two answers.
if (!unique(puzzle, answerOf(got.answer))) { stats.ambiguous++; 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}.`);
}
A code gets up to ATTEMPTS = 200 tries (counted, not timed).
1. Lay an answer by forcing#
games/static/games/yin-yang/engine/generate.js layAnswer · lines 52–75// An answer: color a random blank a random color, and fill in everything
// that follows (tiers 1–2: every step sound, so a contradiction means no
// grid keeps the rules from here); on a contradiction the cell takes the
// other color, and if both fail, the answer is begun again. Returns the
// grid, or null after too many fresh starts.
function layAnswer(r, sh) {
for (let start = 0; start < RESTARTS; start++) {
const col = new Uint8Array(sh.cells);
let ok = true;
while (ok && col.includes(0)) {
const blanks = [];
col.forEach((v, i) => { if (!v) blanks.push(i); });
const i = r.pick(blanks), first = r.chance(0.5) ? BLACK : WHITE;
ok = false;
for (const v of [first, other(first)]) {
const next = col.slice();
next[i] = v;
if (settle(next, 2, sh)) { col.set(next); ok = true; break; }
}
}
if (ok) return col;
}
return null;
}
Give a random blank a random color, and let the solver’s tiers 1 and 2 fill in what follows (settle()). Those steps are sound, so a contradiction proves that color impossible and the cell takes the other. If both fail, start over (up to RESTARTS = 20 times). Repeat until the grid is full. The result is valid by construction; none of 900 measured failed.
2. Take stones away#
Every cell starts printed. In random order, each stone is removed if the solver can still decide the grid with only the puzzle’s tier and below, and put back if not. The result is minimal for those techniques.
3. Rate it#
The puzzle is solved again with every technique and kept only if the hardest tier it needed is the size’s, with at least DEPTH steps of that tier:
games/static/games/yin-yang/engine/generate.js DEPTH · lines 37–40// 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, so a grid the solver decides has exactly one answer. games/static/games/yin-yang/engine/count.js, a search sharing no code with the solver, proves it a second way. As the kit’s contract asks (The engine pieces), generate keeps a puzzle only if this search finds exactly the solver’s answer within its budget, else counts it in stats.ambiguous and tries again:
games/static/games/yin-yang/engine/generate.js unique · lines 42–47// Whether the plain search (count.js) finds exactly one answer, the one given
// (in its written form), within its budget.
export function unique(puzzle, answer) {
const found = count(puzzle, 2);
return found.count === 1 && !found.gaveUp && JSON.stringify(found.answers[0]) === JSON.stringify(answer);
}
An unsound solver step then costs a candidate instead of shipping a two-answer puzzle, whose referee would reject a correct but different solve. The gate has never refused one in 1,500 codes a size, so ENGINE_VERSION stayed 1. The search is slow on sparse grids, adding up to one and a half times again to the making time (at 8×8), but it stays well under a second, in the page’s worker.
The solver’s ladder#
games/static/games/yin-yang/engine/solver.js keeps each cell black, white or unknown, and takes one step at a time with the gentlest technique that works:
| Tier | Technique | What it does |
|---|---|---|
| 1 · in plain sight (easy) | 2×2 | Three cells of a block share a color: the fourth is the other. |
| One way out | A group with one blank beside it, and other groups of its color to reach, grows through it. | |
| Sealed off | Blanks a color can’t reach from its first cell are the other color. | |
| 2 · seeing a color whole (medium) | Checkerboard | A block with one diagonal in one color and a third cell in the other: the fourth matches the diagonal. |
| Border | Round the edge, the blanks between two same-colored cells on one run take that color. | |
| Only path | A blank every route between two groups of a color must cross is that color. | |
| 3 · looking ahead (hard) | Look ahead | Suppose a color here and force one round of 2×2 and checkerboard around it. If that breaks a visible rule, the cell is the other color. |
| 4 · trial (never rates) | Trial | Suppose a color and run tiers 1–2 to a contradiction. |
“Only path” is a depth-first search for articulation points (cells whose removal would split a group) over the cells that could still be one color:
games/static/games/yin-yang/engine/solver.js onlyPath · lines 151–184// Blanks every way between two cells of a color runs through: the cut
// vertices (Tarjan) of the color-or-blank cells, from one of the color's
// cells, that part its cells.
function onlyPath(sh, s) {
const { col } = s;
for (const color of [BLACK, WHITE]) {
if (groups(sh, col, color).length < 2) continue;
const not = other(color);
const total = col.reduce((sum, v) => sum + (v === color ? 1 : 0), 0);
const disc = new Int16Array(sh.cells).fill(-1), low = new Int16Array(sh.cells), held = new Int16Array(sh.cells);
let time = 0;
const cut = [];
const dfs = u => {
disc[u] = low[u] = time++;
held[u] = col[u] === color ? 1 : 0;
for (const v of sh.sides[u]) {
if (col[v] === not) continue;
if (disc[v] < 0) {
dfs(v);
held[u] += held[v];
low[u] = Math.min(low[u], low[v]);
if (!col[u] && low[v] >= disc[u] && held[v] > 0 && held[v] < total) cut.push(u);
} else low[u] = Math.min(low[u], disc[v]);
}
};
dfs(col.indexOf(color));
if (cut.length) {
let changed = false;
for (const u of cut) changed = set(s, u, color) || changed;
return note(s, 'only path', changed);
}
}
return false;
}
“Look ahead” is one step’s immediate effect, never a chain. check:puzzles fails any puzzle that needs trial.
games/static/games/yin-yang/engine/solver.js lookAhead · lines 253–263function lookAhead(sh, s) {
for (let i = 0; i < sh.cells; i++) {
if (s.col[i]) continue;
for (const v of [BLACK, WHITE]) {
const col = s.col.slice();
col[i] = v;
if (!forceAround(sh, col, i) || broken(sh, col)) return note(s, 'look ahead', set(s, i, other(v)));
}
}
return false;
}
What separates the sizes#
300 codes a size, measured on a desktop (Node 24) before generate ran the search (Uniqueness). All 900 had exactly one answer by count():
| Size | Tier | Printed stones (mean, range) | Solver steps (mean) | Steps of its tier (mean, min) | Make (median, p95, max) | Answers laid (mean, max) |
|---|---|---|---|---|---|---|
| 6×6 | easy | 12.2 of 36 (34%), 7–16 | 22 | (all of them) | 0.8 ms, 4 ms, 28 ms | 1.0, 1 |
| 8×8 | medium | 13.8 of 64 (22%), 9–20 | 23 | 4.8, 3 | 4.6 ms, 13 ms, 51 ms | 1.1, 4 |
| 10×10 | hard | 22.1 of 100 (22%), 14–33 | 39 | 3.1, 2 | 66 ms, 196 ms, 330 ms | 2.4, 12 |
Mean uses of each technique per puzzle:
| Size | 2×2 | One way out | Sealed off | Checkerboard | Border | Only path | Look ahead |
|---|---|---|---|---|---|---|---|
| 6×6 | 3.3 | 17.4 | 1.0 | ||||
| 8×8 | 7.0 | 11.4 | 0.1 | 2.7 | 1.6 | 0.5 | |
| 10×10 | 11.8 | 15.0 | 0.1 | 6.3 | 1.9 | 0.9 | 3.1 |
An 8×8 takes about as many steps as a 6×6: its medium steps settle several cells at once, and its difficulty comes from sparser stones. At 10×10, of 711 answers laid, 487 rated hard (187 of them too shallow) and 224 only medium.
The daily#
puzzles/yin_yang.py KIND · lines 24–38KIND = Kind(
slug="yin-yang",
title="Yin-Yang",
engine_file="games/static/games/yin-yang/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 shade the blanks with the answer in
# front of you, a dragged run at a time: about 24, 50 and 80 blanks.
min_seconds={"6": 8, "8": 15, "10": 25},
clean=clean,
wrong="Those colors don’t solve this puzzle.",
since=date(2026, 10, 13),
)
A day’s code is an HMAC (a hash made with a secret key) of yin-yang|<date>|<size>|<difficulty> (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-13 are in the archive.
Your move#
firstMove gives the kit’s first-move prompt (Your first move) its words: Shade your first cell (“Tap a cell for black, again for white”).
Secrets#
None in play: the engine runs in the browser, so 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 Bs and Ws.
puzzles/yin_yang.py clean · lines 15–21def clean(answer, size):
"""An answer is the whole grid, row by row, as one string of B (black)
and W (white): "BWWB…", n × n of them."""
n = GRIDS[size]
if not isinstance(answer, str) or len(answer) != n * n or not set(answer) <= COLORS:
raise ValueError("not a color in every cell")
return answer
The referee then checks the size’s minimum time and compares the answer with the one it made.
| Size | Fastest believable (seconds) |
|---|---|
| 6 | 8 |
| 8 | 15 |
| 10 | 25 |
puzzles/yin_yang.py, KIND.min_secondsEach minimum assumes shading the blanks a dragged run at a time, with the answer in front of you.
Rating#
Kit("yin-yang") rates it (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 solves in 8:10 against a par of 10:30. The time scores 0.623 and moves them +0.7. Yesterday’s 8×8 daily from the archive, in 3:20 against par 4:00: +0.5.
Worked: today’s 8×8, four solvers, each meeting the others at 0.50 of a game:
| Player | Before | Time | After | Change |
|---|---|---|---|---|
| Ray | 1620 | 3:34 | 1629 | +8.6 |
| Opal | 1480 | 4:35 | 1479 | −0.9 |
| June | 1500? | 3:08 | 1743? | +242.8 |
| Mo | 1550 | 5:30 | 1535 | −14.7 |
guide/worked/yin_yang.py, DAILY.rowsguide/worked/yin_yang.py computes these through ratings.sources.Kit and ratings.service.compute. The fastest 8×8 the referee believes, 15 seconds, would score 0.9961 against a 4:00 par.
Leaderboards and the account#
The kit’s: Daily, All-time by size and Ratings, at /games/yin-yang/leaderboard/ (puzzles/views.py), plus best times on the account page.
The interface#
Shading#
games/static/games/yin-yang/js/board.js meets the kit’s board contract on gridSurface (the board’s surface). Every cell is drawn as a stone; printed stones have a heavier rim and a faint plum wash.
| Input | Does |
|---|---|
| Tap or click a cell | empty → black → white → empty (Tap order puts white first). |
| Press and hold, right-click, Shift-click | Sets the second color at once; again clears it. |
| Press and drag | Every cell crossed takes what the first cell became. A drag begun on a printed stone paints its color. One Undo. |
| Keys | Arrows move, Space cycles, B black, W white, X (or Backspace, Delete) clears. |
Helpers (assists)#
Each has its own switch, all on by default:
| Option | What it computes |
|---|---|
| Flag 2×2 blocks | mistakes().blocks: every one-color block of four, hatched red. |
| Flag cut-off groups | mistakes().sealed: each color’s cells split into parts by what can still join them (the color and blanks); every part but the biggest is ringed in dashed red. |
| Check a full grid | Once every cell is shaded, the frame turns red if the grid doesn’t solve. |
All three read the board, never the answer: “cut off” means walled in by the other color, a mistake whatever the answer. With helpers off, the status line still says when a full grid isn’t solved.
Solved, sharing and saving#
When every cell is shaded and check() passes, the kit takes over (the kit’s step 7). The frame turns green and the stones swell outward from the last cell shaded. Share result reads “Yin-Yang · Daily 8×8 · Monday, October 19 / Solved in 3:20” plus the address. Saves live under uo:yin-yang:p:<size>/<difficulty>/<code>.
Accessibility#
The grid is one tab stop; a cell reads “Row 3, column 4, black, printed.” Helpers use shapes as well as color.
Its look#
Yin-Yang’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, plum #7a3a6a: the one fill, the ribbon, the kicker and the stamp, and a faint wash under each printed stone (--given-wash). Black stones are #22262b, the graphite of the ink; white stones are #fffdf7, the sheet’s own white. Red is a mistake and green is solved, as on the paper. |
| Faces | The paper’s: Gloock and Figtree |
| Corners and edges | The kit’s |
| Board | Every cell is drawn as a stone, black or white, ringed in graphite. Printed stones have a heavier rim and the faint plum wash under them. A 2×2 block of one color is hatched red, a cut-off group is ringed in dashed red, and a full grid that doesn’t solve is framed in red. |
| Emblem and art | The taijitu, its dark half in ink, ringed in the accent. Its box: a real 6×6 (ABC234) part-way through. |
| Voice | A puzzle everywhere (“New puzzle”, “Solved”); stones, black and white, are its own words. |
| Payoff | The frame turns green, the stones swell in turn outward from the last cell shaded, 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, the referee agreeing. Fingerprints catch an unbumped engine change. |
tests/test_yin_yang.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.
- “Hard” is one technique. Look ahead is the only tier-3 step, used two to five times among about 40 steps.
- Open (from the notes): other sizes at other tiers; symmetric givens; “highlight a completed board” read as Check a full grid; stones or shaded squares; the minimum times.
Changing it#
| Change | What it needs |
|---|---|
Anything in engine/ that could change which puzzle a code makes |
Bump ENGINE_VERSION, update FIXED in tools/puzzles/check.mjs, run npm run check:puzzles, and redraw the box art (games/templates/games/thumbs/yin-yang.html, ABC234 6×6) and the test puzzles. |
| The board or helpers | No bump. check:layout, check:details. |
| Sizes, slots, minimum times | puzzles/yin_yang.py and the engine’s SIZES. |
Then run npm run check:guide, and npm run guide:stamp yin-yang.