Akari (Light Up / 美術館) with a constraint-propagation solver inside: three sound rules — wall counts, mutual visibility, and illumination — propagated to a fixpoint, then a backtracking search that proves every bundled puzzle has a unique solution. Every board's clues are derived by the solver from its own wall layout, so there is no hand-authored answer key to drift. TypeScript, no runtime dependencies.
Live demo: https://sen.ltd/portfolio/akari/
npm install
npm run dev # http://localhost:5173
npm test # 44 vitest cases
npm run build # production build to dist/Click a white cell to cycle bulb → mark → clear. Hint plays one guess-free deduction; Solve lights the whole board.
The grid is white cells and walls (black), some walls numbered 0..4. You drop light bulbs into white cells. A bulb lights its own cell and shoots a beam down its row and column that stops at the first wall. A solution must satisfy:
- Illumination — every white cell is lit.
- No mutual sight — no bulb sits in another bulb's beam.
- Wall counts — a numbered wall has exactly that many bulbs adjacent.
Each rule turns into a strict deduction — never a guess:
- Wall count — a numbered wall whose adjacent bulbs already equal its number empties its other neighbours; if its remaining free neighbours are exactly what it still needs, they're all bulbs.
- Mutual sight — a bulb empties every cell in its beam (and a second bulb in that beam is an immediate contradiction).
- Illumination — a still-dark cell whose only remaining light source (itself or a single cell in its beam) is one candidate forces a bulb there.
propagate() applies all three to a fixpoint, recording the order cells were
decided — which is exactly what Hint replays.
Some boards need a guess. The backtracking solver takes the darkest cell with the fewest bulb candidates (minimum-remaining-values on the illumination constraint), tries a bulb, and lets propagation prune. After propagation every lit cell has already been emptied, so the remaining unknowns are exactly the still-dark cells — the search only ever branches where light is genuinely missing.
if (bulbs > w.n) return { ok: false, forced }; // wall over-filled
if (count === 0) return { ok: false, forced }; // a cell can never be lit
if (count === 1) set(candidate, BULB); // its last light sourcehasUniqueSolution enumerates up to two solutions; the test suite asserts every
bundled puzzle stops at exactly one.
Each board is authored as nothing but a wall layout — '#' and '.'. The solver does the rest:
- Solve the bare layout with no clues; illumination logic alone yields a valid
bulb arrangement
S. - Number every wall from
S, and require the clued board to be unique. - Greedily blank clues while the solution stays unique, leaving real deductions.
So the picture (the walls) is the single source of truth, and the answer key is
the solver's own output — they cannot disagree. The tests re-solve the finished
board and assert the bulbs match S:
expect(hasUniqueSolution(b.puzzle)).toBe(true); // exactly one answer
expect(bulbsFound).toEqual(bulbsWant); // and it's SSame discipline as the Nonogram and Slitherlink entries: ship your levels through your solver.
src/akari.ts — pure engine: geometry, three-rule propagation, isWin/isSolved,
backtracking solver, uniqueness count
src/akari.test.ts — 44 cases (rules, contradictions, uniqueness, solver-exactness)
src/puzzles.ts — 5 wall-layout boards, all solver-generated and verified
src/main.ts — grid UI: clickable cells, light rendering, Hint, animated Solve
MIT
