<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SEN LLC</title>
    <description>The latest articles on DEV Community by SEN LLC (@sendotltd).</description>
    <link>https://dev.to/sendotltd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3871729%2F54856949-f27c-4da1-a1c3-3db40b93c8d8.png</url>
      <title>DEV Community: SEN LLC</title>
      <link>https://dev.to/sendotltd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sendotltd"/>
    <language>en</language>
    <item>
      <title>Solving Tents and Trees with bipartite matching: Hall's theorem as a puzzle move</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Mon, 20 Jul 2026 23:17:19 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-tents-and-trees-with-bipartite-matching-halls-theorem-as-a-puzzle-move-320c</link>
      <guid>https://dev.to/sendotltd/solving-tents-and-trees-with-bipartite-matching-halls-theorem-as-a-puzzle-move-320c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tents and Trees&lt;/strong&gt; (テント) in the browser with a &lt;strong&gt;bipartite-matching&lt;br&gt;
solver&lt;/strong&gt; inside. Pitch one tent for every tree on an orthogonally-adjacent&lt;br&gt;
cell; tents never touch — &lt;strong&gt;not even diagonally&lt;/strong&gt; — and each row/column&lt;br&gt;
clue counts its tents. Trees pair off with tents &lt;strong&gt;one-to-one&lt;/strong&gt;, so a&lt;br&gt;
solution &lt;em&gt;is&lt;/em&gt; a &lt;strong&gt;perfect matching&lt;/strong&gt; in the bipartite graph trees ×&lt;br&gt;
cells. The solver's star deduction is a &lt;strong&gt;vertex-deletion Hall test&lt;/strong&gt;:&lt;br&gt;
delete a candidate cell and if the maximum matching can no longer&lt;br&gt;
saturate the trees, that cell is a &lt;strong&gt;tent in every solution&lt;/strong&gt;; require a&lt;br&gt;
cell to be matched and if that's infeasible, it's &lt;strong&gt;grass&lt;/strong&gt;. The same&lt;br&gt;
skeleton as Régin's alldifferent filtering — and strong enough that some&lt;br&gt;
bundled boards keep only &lt;strong&gt;two&lt;/strong&gt; of their row/column clues.&lt;br&gt;
Solver-backed puzzle #17.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/tents/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/tents/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/tents" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/tents&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1tqfkbn99anycd29n4o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1tqfkbn99anycd29n4o.png" alt="Screenshot" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules: a solution is a perfect matching
&lt;/h2&gt;

&lt;p&gt;A finished board satisfies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every tree has a tent on an orthogonally-adjacent cell, and the
tree–tent correspondence is &lt;strong&gt;one-to-one&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;No two tents touch, in any of the 8 directions.&lt;/li&gt;
&lt;li&gt;Every row/column clue equals the number of tents in its line.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 1 is the interesting one. The tent count equals the tree count, and&lt;br&gt;
"which tent belongs to which tree" is an assignment — a &lt;strong&gt;perfect matching&lt;/strong&gt;&lt;br&gt;
in the bipartite graph between trees and tent cells. Unlike the Latin-square&lt;br&gt;
puzzles earlier in this series (Futoshiki, Skyscrapers), the matching isn't&lt;br&gt;
a solving trick bolted on: it's the &lt;em&gt;structure of the puzzle itself&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cheap rules: spacing, counting, budget
&lt;/h2&gt;

&lt;p&gt;The solver keeps a 4-valued cell state (UNKNOWN / GRASS / TENT / TREE) and&lt;br&gt;
runs the pen-and-paper openers to a fixpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;spacing&lt;/strong&gt; — a placed tent turns its whole 8-neighbourhood to grass; a
tent beside a tent is an immediate contradiction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;counting&lt;/strong&gt; — a line that reached its clue grasses the rest; a line
where &lt;code&gt;tents + unknowns == clue&lt;/code&gt; fills every unknown with a tent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;budget&lt;/strong&gt; — exactly one tent per tree, board-wide; both overflow and
underflow are contradictions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pre-pass&lt;/strong&gt; — any cell no tree can reach is grass before the player
even moves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Decent, but completely blind to the mid-game question: &lt;em&gt;which trees are&lt;br&gt;
fighting over which cells?&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The star: one feasibility oracle, three deductions
&lt;/h2&gt;

&lt;p&gt;Build the bipartite graph between the trees and the cells that could still&lt;br&gt;
hold their tents (state TENT or UNKNOWN, orthogonally adjacent). Every&lt;br&gt;
solution reachable from the current position induces a matching in this&lt;br&gt;
graph that saturates the trees — the tents &lt;em&gt;are&lt;/em&gt; the matched cells. So when&lt;br&gt;
&lt;code&gt;feasible()&lt;/code&gt; says no, that's a &lt;strong&gt;proof about all solutions at once&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// feasible(s, p, exclude?, require?): is there a matching that&lt;/span&gt;
&lt;span class="c1"&gt;//   (a) saturates every tree,&lt;/span&gt;
&lt;span class="c1"&gt;//   (b) covers every placed tent (plus `require`, if given),&lt;/span&gt;
&lt;span class="c1"&gt;//   (c) never uses `exclude`?&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;feasible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exclude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;require&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part (a) is Kuhn's augmenting-path matching from the tree side. Part (b)&lt;br&gt;
uses the &lt;strong&gt;Mendelsohn–Dulmage exchange&lt;/strong&gt;: from an uncovered required cell,&lt;br&gt;
walk alternating paths (any edge out of a cell, the matched edge out of a&lt;br&gt;
tree) until you reach a covered &lt;em&gt;non-required&lt;/em&gt; cell, then flip the path —&lt;br&gt;
trees stay saturated, and only a disposable cell loses its partner.&lt;/p&gt;

&lt;p&gt;One oracle, three deductions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;feasible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// Hall violation: contradiction&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;feasible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TENT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// vertex deletion: trees starve without i&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;feasible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GRASS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no tree to spare for i&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contradiction&lt;/strong&gt; — two trees squeezed onto one shared cell (&lt;code&gt;|N(S)| &amp;lt;
|S|&lt;/code&gt;) is invisible to counting rules but instant here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forced tent&lt;/strong&gt; — deleting cell &lt;code&gt;i&lt;/code&gt; breaks saturation ⇒ &lt;em&gt;every&lt;/em&gt; solution
uses &lt;code&gt;i&lt;/code&gt;. "A tree with a single candidate" is just the trivial case; the
same test catches multi-tree Hall-set squeezes with no extra code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forced grass&lt;/strong&gt; — if a placed tent's only neighbouring tree is &lt;code&gt;t&lt;/code&gt;,
then &lt;code&gt;t&lt;/code&gt; is claimed; a cell that could only pitch for &lt;code&gt;t&lt;/code&gt; fails the
require-test and must be grass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this skeleton looks familiar, it's because it &lt;em&gt;is&lt;/em&gt; familiar: &lt;strong&gt;Régin's&lt;br&gt;
alldifferent filtering&lt;/strong&gt; from constraint programming works exactly this way&lt;br&gt;
— compute one maximum matching, then reason about which vertices every / no&lt;br&gt;
maximum matching can use. Industrial CP solvers batch those questions with&lt;br&gt;
an SCC decomposition of the residual graph; on a puzzle-sized board,&lt;br&gt;
per-vertex retests are plenty (the whole 72-test suite runs in 0.2s).&lt;/p&gt;
&lt;h2&gt;
  
  
  Boards that keep only two clues
&lt;/h2&gt;

&lt;p&gt;The three-way deduction is strong. When the fixpoint stalls, the solver&lt;br&gt;
branches on the first unknown cell and counts solutions, cutting off at&lt;br&gt;
two — &lt;code&gt;count === 1&lt;/code&gt; is a &lt;strong&gt;uniqueness proof&lt;/strong&gt;. The generator authors each&lt;br&gt;
board backwards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scatter tents at random, rejecting 8-adjacency, and grow a tree on a
random free orthogonal neighbour of each — the pairing is a perfect
matching &lt;em&gt;by construction&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Read off &lt;strong&gt;all&lt;/strong&gt; row and column counts, and check the full-clue board
is unique at all (dense campsites occasionally admit a second
arrangement; reroll).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greedily delete clues&lt;/strong&gt; in random order, keeping a deletion only
while uniqueness survives.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What survives is locally minimal — dropping any single remaining clue&lt;br&gt;
breaks uniqueness, and the test suite verifies that for every clue of every&lt;br&gt;
board:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;every surviving clue is load-bearing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chopped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowClues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chopped&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result surprised me: the 6×6 "Clearing" keeps &lt;strong&gt;2 of 12&lt;/strong&gt; clues, the&lt;br&gt;
8×8 "Campground" &lt;strong&gt;2 of 16&lt;/strong&gt;. The row/column numbers nearly vanish — the&lt;br&gt;
tree layout plus the matching structure pin the board almost by themselves.&lt;br&gt;
The solver's strength converts directly into sparser, cleaner puzzles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When a puzzle's core constraint &lt;em&gt;is&lt;/em&gt; an assignment, model it as the
bipartite graph it is — the solver's best move falls out of the
structure instead of being pattern-matched onto it.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;feasible(exclude?, require?)&lt;/code&gt; oracle yields contradiction
detection, forced tents, and forced grass — Hall's theorem, playable.&lt;/li&gt;
&lt;li&gt;Author boards backwards and delete clues greedily: every surviving clue
is load-bearing, and the test suite proves it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Six boards from 6×6 to 10×10, each with a machine-checked unique solution.&lt;br&gt;
TypeScript, no runtime dependencies, 72 tests. Solver-backed puzzle #17.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Skyscrapers by projecting permutations: a line has n! futures, not n^n</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:19:30 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-skyscrapers-by-projecting-permutations-a-line-has-n-futures-not-nn-5923</link>
      <guid>https://dev.to/sendotltd/solving-skyscrapers-by-projecting-permutations-a-line-has-n-futures-not-nn-5923</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Skyscrapers&lt;/strong&gt; (ビルディング) in the browser with an &lt;strong&gt;exact&lt;br&gt;
line-projection solver&lt;/strong&gt; inside. Fill an n×n grid with building heights so&lt;br&gt;
every row and column holds 1..n &lt;strong&gt;exactly once&lt;/strong&gt;, while every &lt;strong&gt;edge&lt;br&gt;
clue&lt;/strong&gt; counts the buildings &lt;strong&gt;visible from that side&lt;/strong&gt; — taller buildings&lt;br&gt;
hide everything shorter, so the visible ones are exactly the running&lt;br&gt;
maxima. Humans open with a &lt;strong&gt;staircase rule&lt;/strong&gt; (clue c caps the cell k&lt;br&gt;
steps in at n−c+1+k); the solver's star is stronger: a line of a Latin&lt;br&gt;
square &lt;strong&gt;is a permutation&lt;/strong&gt;, so it has at most &lt;strong&gt;n! completions, not&lt;br&gt;
n^n&lt;/strong&gt;. Enumerate the permutations consistent with the current masks, keep&lt;br&gt;
those matching &lt;strong&gt;both&lt;/strong&gt; end clues, and intersect each cell with the&lt;br&gt;
&lt;strong&gt;union of the survivors&lt;/strong&gt; — the tightest line-local conclusion possible,&lt;br&gt;
quietly subsuming hidden singles, naked pairs, and the staircase itself.&lt;br&gt;
Solver-backed puzzle #16.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/skyscrapers/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/skyscrapers/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/skyscrapers" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/skyscrapers&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi9a06uzk334ceo5qty8i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi9a06uzk334ceo5qty8i.png" alt="Screenshot" width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules: visible = running maxima
&lt;/h2&gt;

&lt;p&gt;A finished board satisfies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every row and every column holds each height 1..n &lt;strong&gt;exactly once&lt;/strong&gt; (a
Latin square).&lt;/li&gt;
&lt;li&gt;Every &lt;strong&gt;edge clue&lt;/strong&gt; equals the number of buildings visible from its side.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Visible" has a one-line definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Buildings a viewer sees looking down the line: the running maxima. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;[2, 4, 1, 3]&lt;/code&gt; you see two buildings — 2, then 4; the 1 and the 3 hide in&lt;br&gt;
the 4's shadow. The tallest building n is visible from &lt;strong&gt;everywhere&lt;/strong&gt;. As in&lt;br&gt;
Futoshiki, the unknown is a value in 1..n, so each cell is a &lt;strong&gt;candidate&lt;br&gt;
bitmask&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The opening: a staircase of ceilings
&lt;/h2&gt;

&lt;p&gt;The pencil-and-paper classic. Put height v in the cell k steps (0-based)&lt;br&gt;
from a clue c, and every building taller than v must stand behind it —&lt;br&gt;
work out how many "steps" that leaves in front and you get the ceiling&lt;br&gt;
&lt;strong&gt;v ≤ n − c + 1 + k&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// initialState: a clue c carves staircase ceilings into its first c−1 cells&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// c=1: nearest cell IS n&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;clue&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;=&lt;/span&gt; &lt;span class="nf"&gt;fullMask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;clue&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;c = 1&lt;/code&gt; means one building visible: the nearest cell is the tower n itself.&lt;br&gt;
&lt;code&gt;c = n&lt;/code&gt; staircases the whole line into ascending 1, 2, …, n. Cheap and&lt;br&gt;
satisfying — but strictly an opening. It sees nothing of the midgame.&lt;/p&gt;
&lt;h2&gt;
  
  
  The star: a line is a permutation, so project it
&lt;/h2&gt;

&lt;p&gt;Here is the observation this puzzle rewards. When you want stronger&lt;br&gt;
propagation you usually add named patterns one by one (hidden pairs, X-wing,&lt;br&gt;
…). But a Skyscrapers line has decisive structure: &lt;strong&gt;a line of a Latin&lt;br&gt;
square is a permutation of 1..n&lt;/strong&gt;. A row doesn't have n^n possible&lt;br&gt;
completions — it has at most &lt;strong&gt;n!&lt;/strong&gt;. Even at n = 7 that's 5040. You can just&lt;br&gt;
enumerate them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;projectLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint16Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;View&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;union&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// DFS over permutations consistent with the current masks.&lt;/span&gt;
  &lt;span class="c1"&gt;// The front clue prunes as we go: too many maxima already — cut;&lt;/span&gt;
  &lt;span class="c1"&gt;// not enough "headroom" left (remaining cells, remaining heights) — cut.&lt;/span&gt;
  &lt;span class="c1"&gt;// The back clue is checked at each leaf by a reverse scan.&lt;/span&gt;
  &lt;span class="c1"&gt;// Surviving permutations OR their values into union[i]…&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;union&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// no permutation survives: contradiction&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;union&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;         &lt;span class="c1"&gt;// intersect with the survivors' union&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Intersecting each cell with the &lt;strong&gt;union of surviving permutations&lt;/strong&gt; is,&lt;br&gt;
mathematically, the &lt;strong&gt;projection of the line constraint onto that cell&lt;/strong&gt; —&lt;br&gt;
the tightest conclusion any line-local reasoning can reach. Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;hidden singles&lt;/strong&gt;, &lt;strong&gt;naked pairs&lt;/strong&gt;, and the &lt;strong&gt;staircase itself&lt;/strong&gt; are all
subsumed, silently;&lt;/li&gt;
&lt;li&gt;every line-local pattern that doesn't have a name yet gets "used" anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Contradiction detection falls out for free. Opposite clues on one line can&lt;br&gt;
sum to at most &lt;strong&gt;n + 1&lt;/strong&gt; (the tallest building is counted from both sides).&lt;br&gt;
Put 3 + 3 on a 4-line: the staircase leaves every mask non-empty, yet&lt;br&gt;
projection instantly reports zero surviving permutations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;detects a line with no surviving permutation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;puz&lt;/span&gt;&lt;span class="p"&gt;([...],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;propagate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What projection cannot do
&lt;/h2&gt;

&lt;p&gt;Projection is complete &lt;strong&gt;per line&lt;/strong&gt;, not per board. Rows constrain columns&lt;br&gt;
and columns constrain rows back, so the projections are iterated to a&lt;br&gt;
&lt;strong&gt;fixpoint&lt;/strong&gt;; when even that stalls, a &lt;strong&gt;fewest-candidates&lt;/strong&gt; backtracking&lt;br&gt;
search branches, re-propagates, and counts. &lt;code&gt;solveAll(p, 2)&lt;/code&gt; is a complete&lt;br&gt;
enumeration that stops at two solutions, so &lt;code&gt;length === 1&lt;/code&gt; is a &lt;strong&gt;proof of&lt;br&gt;
uniqueness&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Boards are authored by deletion
&lt;/h2&gt;

&lt;p&gt;The generator works answer-first: build a random Latin square, read &lt;strong&gt;all&lt;br&gt;
4n view clues&lt;/strong&gt; off its edges (the board is trivially unique at that point),&lt;br&gt;
then &lt;strong&gt;greedily delete clues in random order&lt;/strong&gt; — givens first — keeping a&lt;br&gt;
deletion only while the engine still proves exactly one solution. What&lt;br&gt;
survives is &lt;strong&gt;locally minimal&lt;/strong&gt;: removing any single remaining clue breaks&lt;br&gt;
uniqueness, and the test suite checks that on every board:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is minimal: dropping the first view clue breaks uniqueness&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six boards, 4×4 through 7×7. The 4×4 pins a unique skyline with just&lt;br&gt;
&lt;strong&gt;four view clues&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;A Skyscrapers clue is a &lt;strong&gt;non-local aggregate&lt;/strong&gt; — a count of running maxima&lt;br&gt;
— that refuses to decompose into per-edge clamps the way Futoshiki's signs&lt;br&gt;
did. What worked instead was a &lt;strong&gt;domain-structure observation&lt;/strong&gt;: a line is a&lt;br&gt;
permutation, so its futures number n!, not n^n, and enumerate → filter →&lt;br&gt;
&lt;strong&gt;project&lt;/strong&gt; (intersect with the survivors' union) becomes the strongest&lt;br&gt;
line-local inference at a practical cost. The staircase opening is subsumed&lt;br&gt;
by it; the cross-line remainder falls to fixpoint iteration and a&lt;br&gt;
fewest-candidates search that proves all six boards unique. 56 tests.&lt;br&gt;
Solver-backed puzzle #16.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Futoshiki with bounds propagation: one-edge rules, chain-length conclusions</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Sat, 18 Jul 2026 23:12:38 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-futoshiki-with-bounds-propagation-one-edge-rules-chain-length-conclusions-432f</link>
      <guid>https://dev.to/sendotltd/solving-futoshiki-with-bounds-propagation-one-edge-rules-chain-length-conclusions-432f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Futoshiki&lt;/strong&gt; (不等号 / "inequality") in the browser with a&lt;br&gt;
&lt;strong&gt;bounds-propagation solver&lt;/strong&gt; inside. Fill an n×n grid so every row and&lt;br&gt;
column holds each value 1..n &lt;strong&gt;exactly once&lt;/strong&gt; while every &lt;strong&gt;&lt;code&gt;&amp;lt;&lt;/code&gt; / &lt;code&gt;&amp;gt;&lt;/code&gt;&lt;br&gt;
sign&lt;/strong&gt; between neighbouring cells tells the truth. Unlike the shading&lt;br&gt;
puzzles earlier in this series, the unknown is a &lt;strong&gt;value in 1..n&lt;/strong&gt;, so a&lt;br&gt;
cell is a &lt;strong&gt;candidate bitmask&lt;/strong&gt; and the rules shrink sets: duplicate&lt;br&gt;
eviction, hidden singles, and — the star — a &lt;strong&gt;bounds rule&lt;/strong&gt; that clamps&lt;br&gt;
one floor and one ceiling per sign. That rule only ever looks at &lt;strong&gt;one&lt;br&gt;
edge&lt;/strong&gt;, yet run to a fixpoint it performs &lt;strong&gt;interval arithmetic over whole&lt;br&gt;
inequality chains&lt;/strong&gt;: the cell k steps up a chain ends up with floor k+1, a&lt;br&gt;
global, chain-length conclusion no single rule ever computed. Solver-backed&lt;br&gt;
puzzle #15.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/futoshiki/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/futoshiki/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/futoshiki" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/futoshiki&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyi2e6vv87kuals8sr6da.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyi2e6vv87kuals8sr6da.png" alt="Screenshot" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and a change of model
&lt;/h2&gt;

&lt;p&gt;A Futoshiki board is &lt;strong&gt;n×n&lt;/strong&gt;. A finished board satisfies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every row and every column holds each of 1..n &lt;strong&gt;exactly once&lt;/strong&gt; (a Latin
square).&lt;/li&gt;
&lt;li&gt;Every &lt;strong&gt;sign&lt;/strong&gt; between two orthogonally adjacent cells is true (&lt;code&gt;a &amp;lt; b&lt;/code&gt;
means &lt;em&gt;a&lt;/em&gt; really is smaller).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Clues come in two kinds — a few &lt;strong&gt;given digits&lt;/strong&gt;, and the &lt;strong&gt;inequality&lt;br&gt;
signs&lt;/strong&gt; in the gutters. Most of the bundled boards are &lt;strong&gt;signs only&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The shading puzzles before this one (Nurikabe, LITS, Hitori…) had a one-bit&lt;br&gt;
unknown per cell, so a three-valued cell was enough. Futoshiki's unknown is a&lt;br&gt;
&lt;strong&gt;value&lt;/strong&gt;, so each cell becomes a &lt;strong&gt;candidate set&lt;/strong&gt;, stored as a bitmask in&lt;br&gt;
one integer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fullMask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// all of 1..n possible&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;minVal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clz32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// smallest candidate = floor&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maxVal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clz32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// largest candidate = ceiling&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of the encoding: &lt;code&gt;minVal&lt;/code&gt; / &lt;code&gt;maxVal&lt;/code&gt; read directly as the &lt;strong&gt;ends of&lt;br&gt;
an interval&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three propagation rules
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Duplicate&lt;/strong&gt; — a solved cell evicts its value from its row and column; an&lt;br&gt;
emptied mask is a contradiction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;=&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="nx"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hidden single&lt;/strong&gt; — if a value has exactly one home left in a line, it moves&lt;br&gt;
in; zero homes is a contradiction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounds&lt;/strong&gt; — a sign &lt;code&gt;a &amp;lt; b&lt;/code&gt; translates into two clamps: &lt;code&gt;a&lt;/code&gt; may only keep&lt;br&gt;
values &lt;strong&gt;below &lt;code&gt;b&lt;/code&gt;'s current ceiling&lt;/strong&gt;, and &lt;code&gt;b&lt;/code&gt; only values &lt;strong&gt;above &lt;code&gt;a&lt;/code&gt;'s&lt;br&gt;
current floor&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ineqs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clampedLo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;below&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;maxVal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;      &lt;span class="c1"&gt;// lo &amp;lt; max(hi)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clampedHi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;above&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;minVal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// hi &amp;gt; min(lo)&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three run to a fixpoint. No guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The show: one edge per step, whole chains per fixpoint
&lt;/h2&gt;

&lt;p&gt;Each application of the bounds rule looks at &lt;strong&gt;one edge&lt;/strong&gt;. Iterated to a&lt;br&gt;
fixpoint, something bigger emerges: &lt;strong&gt;interval arithmetic over the whole&lt;br&gt;
inequality graph&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Take &lt;code&gt;a &amp;lt; b &amp;lt; c &amp;lt; d&lt;/code&gt; across a 4×4 row. Pass one: &lt;code&gt;a &amp;lt; b&lt;/code&gt; lifts &lt;code&gt;b&lt;/code&gt;'s floor to&lt;br&gt;
2, &lt;code&gt;b &amp;lt; c&lt;/code&gt; lifts &lt;code&gt;c&lt;/code&gt;'s to 3, &lt;code&gt;c &amp;lt; d&lt;/code&gt; lifts &lt;code&gt;d&lt;/code&gt;'s to 4 — while the ceilings&lt;br&gt;
tighten the other way and &lt;code&gt;a&lt;/code&gt; drops to 1. In general &lt;strong&gt;the cell k steps from&lt;br&gt;
the bottom of a chain gets floor k+1, and k steps from the top gets ceiling&lt;br&gt;
n−k&lt;/strong&gt;. A chain of n−1 signs pins all n cells outright, and the test suite&lt;br&gt;
states exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;derives chain-length floors from pairwise clamps alone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// a &amp;lt; b &amp;lt; c &amp;lt; d: every rule step saw one sign,&lt;/span&gt;
  &lt;span class="c1"&gt;// yet the fixpoint pins the whole row to 1234.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;puz&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;....&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;....&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;....&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;....&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;propagate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;solvedVal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In constraint-programming terms this is &lt;strong&gt;bounds consistency&lt;/strong&gt; enforced&lt;br&gt;
edge-by-edge rather than full arc consistency: it never looks at holes in the&lt;br&gt;
middle of a candidate set, but inequality is an order constraint, so floors&lt;br&gt;
and ceilings carry all the strength you need. Cheap local clamps, run to a&lt;br&gt;
fixpoint, flood bounds across chains, forks and joins of the sign DAG.&lt;/p&gt;

&lt;p&gt;Contradiction detection falls out of the same machinery for free — put four&lt;br&gt;
&lt;code&gt;&amp;lt;&lt;/code&gt; signs in a 4-value board and some floor demands a 5, emptying a mask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contradicts a chain longer than the board allows&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 4 strict increases need 5 distinct values — impossible in a 4×4.&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;propagate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Search and the uniqueness proof
&lt;/h2&gt;

&lt;p&gt;When propagation stalls, the solver branches on the &lt;strong&gt;fewest-candidates&lt;/strong&gt;&lt;br&gt;
cell, tries values in ascending order, and re-propagates. &lt;code&gt;solveAll(p, 2)&lt;/code&gt; is&lt;br&gt;
a complete enumeration that stops at two solutions, so &lt;code&gt;length === 1&lt;/code&gt; is a&lt;br&gt;
&lt;strong&gt;proof of uniqueness&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Boards are authored by deletion
&lt;/h2&gt;

&lt;p&gt;The generator (&lt;code&gt;tools/generate.mts&lt;/code&gt;) works answer-first. Build a random Latin&lt;br&gt;
square by backtracking, read the true sign off &lt;strong&gt;every&lt;/strong&gt; adjacent pair — all&lt;br&gt;
2n(n−1) of them — at which point the board is trivially unique. Then&lt;br&gt;
&lt;strong&gt;greedily delete clues in random order&lt;/strong&gt;, keeping a deletion only while the&lt;br&gt;
engine still proves exactly one solution. Givens go first, so the boards end&lt;br&gt;
up &lt;strong&gt;sign-driven&lt;/strong&gt; (two of the six have no givens at all).&lt;/p&gt;

&lt;p&gt;What survives is a &lt;strong&gt;locally minimal&lt;/strong&gt; clue set: removing any single&lt;br&gt;
remaining clue breaks uniqueness. The test suite checks that claim on every&lt;br&gt;
board:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is minimal: dropping any one sign breaks uniqueness&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;givens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ineqs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ineqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not just "this board is unique" but "&lt;strong&gt;every remaining sign is&lt;br&gt;
load-bearing&lt;/strong&gt;." 55 tests in all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Futoshiki is this series' first &lt;strong&gt;multi-valued domain&lt;/strong&gt; — cells became&lt;br&gt;
candidate bitmasks and propagation became set-shrinking. The star is the&lt;br&gt;
bounds rule: a cheap clamp that sees one edge at a time, yet whose fixpoint&lt;br&gt;
performs interval arithmetic across entire inequality chains, producing&lt;br&gt;
global, chain-length conclusions (floor k+1 at height k) that no single rule&lt;br&gt;
ever computed. Duplicate eviction and hidden singles cover the Latin side; a&lt;br&gt;
fewest-candidates search proves uniqueness; and the bundled boards are thinned&lt;br&gt;
from the full sign set until every surviving clue carries weight.&lt;br&gt;
Solver-backed puzzle #15.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Hitori with constraint propagation: the puzzle where propagation can't even start</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Fri, 17 Jul 2026 23:15:27 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-hitori-with-constraint-propagation-the-puzzle-where-propagation-cant-even-start-1ccm</link>
      <guid>https://dev.to/sendotltd/solving-hitori-with-constraint-propagation-the-puzzle-where-propagation-cant-even-start-1ccm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hitori&lt;/strong&gt; (ひとりにしてくれ / "leave me alone") in the browser with a&lt;br&gt;
&lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Every cell carries a number; you&lt;br&gt;
&lt;strong&gt;keep&lt;/strong&gt; (white) or &lt;strong&gt;shade&lt;/strong&gt; (black) each one so that kept numbers &lt;strong&gt;never&lt;br&gt;
repeat in a row or column&lt;/strong&gt;, shaded cells &lt;strong&gt;never touch side-to-side&lt;/strong&gt;, and&lt;br&gt;
the kept cells stay &lt;strong&gt;one connected region&lt;/strong&gt;. The unknown is the usual&lt;br&gt;
one bit per cell, the model the usual three-valued cell — but on an untouched&lt;br&gt;
board &lt;strong&gt;every dynamic rule is starved&lt;/strong&gt;: the duplicate rule needs a white&lt;br&gt;
cell to exist, the adjacency rule needs a black one, connectivity needs both.&lt;br&gt;
Pure propagation is a &lt;strong&gt;no-op on move one&lt;/strong&gt;. The only ways in are the&lt;br&gt;
puzzle's two textbook openings — &lt;em&gt;sandwich&lt;/em&gt; and &lt;em&gt;pair&lt;/em&gt; — each a &lt;strong&gt;one-step&lt;br&gt;
case analysis whose branches agree&lt;/strong&gt;, which is exactly the condition that&lt;br&gt;
lets a case split masquerade as a sound propagation rule. Solver-backed&lt;br&gt;
puzzle #14.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/hitori/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/hitori/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/hitori" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/hitori&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faej1ks8a6an7ky9ke3n1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faej1ks8a6an7ky9ke3n1.png" alt="Screenshot" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the usual model
&lt;/h2&gt;

&lt;p&gt;A Hitori grid is &lt;strong&gt;R×C&lt;/strong&gt; with a number in every cell. A finished board keeps or&lt;br&gt;
shades each cell so that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Among the &lt;strong&gt;kept&lt;/strong&gt; cells, &lt;strong&gt;no number repeats&lt;/strong&gt; in any row or column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shaded&lt;/strong&gt; cells are never orthogonally adjacent.&lt;/li&gt;
&lt;li&gt;The kept cells form &lt;strong&gt;one orthogonally-connected region&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same unknown as Nurikabe — one bit per cell — so the model is the same&lt;br&gt;
three-valued cell, shrunk by &lt;strong&gt;sound, guess-free implications&lt;/strong&gt; only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WHITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// kept&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLACK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// shaded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three dynamic rules write themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;duplicate&lt;/strong&gt; — a cell decided WHITE evicts every copy of its number from its
row and column (two kept copies in one line is a contradiction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;adjacency&lt;/strong&gt; — a cell decided BLACK whitens all four neighbours (two
adjacent blacks is a contradiction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cut cell&lt;/strong&gt; — an UNKNOWN cell whose shading would disconnect the whites must
itself be WHITE. No trial assignment needed: flood the non-black graph with
that cell removed and see whether every white is still reached.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The trap: propagation has no seed
&lt;/h2&gt;

&lt;p&gt;Here is where Hitori differs from every earlier puzzle in this series. A fresh&lt;br&gt;
board is &lt;strong&gt;all UNKNOWN&lt;/strong&gt;. The duplicate rule fires off an existing white, the&lt;br&gt;
adjacency rule off an existing black, the connectivity check needs two whites —&lt;br&gt;
so &lt;strong&gt;none of the three can fire on move one&lt;/strong&gt;. Run them to a fixpoint and&lt;br&gt;
nothing happens. In Nurikabe or Akari the printed clues pin cells from the&lt;br&gt;
start; Hitori's clues (the numbers) directly decide &lt;em&gt;no&lt;/em&gt; cell's colour.&lt;/p&gt;

&lt;p&gt;The test suite states it outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is a clean no-op on a board with no pattern&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;puz&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;21&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;propagate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The way in: case splits whose branches agree
&lt;/h2&gt;

&lt;p&gt;Human solvers open every Hitori the same way, with two textbook patterns — and&lt;br&gt;
implementing them is where the design clicks. Both are &lt;strong&gt;case analyses&lt;/strong&gt;, yet&lt;br&gt;
both are legitimate propagation rules, because &lt;strong&gt;every branch reaches the same&lt;br&gt;
conclusion&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandwich&lt;/strong&gt; — &lt;code&gt;a ? a&lt;/code&gt; in one line. Shade the middle and both flanking &lt;code&gt;a&lt;/code&gt;s&lt;br&gt;
survive as kept duplicates. So whichever &lt;code&gt;a&lt;/code&gt; lives, &lt;strong&gt;the middle cell is&lt;br&gt;
white&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sandwich: a ? a  =&amp;gt;  the middle cell is white.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;WHITE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pair&lt;/strong&gt; — an adjacent &lt;code&gt;a a&lt;/code&gt;. Not both kept (duplicate), not both shaded&lt;br&gt;
(adjacent) — so &lt;strong&gt;exactly one survives&lt;/strong&gt;. You don't know which, but you do know&lt;br&gt;
the line now owns a kept &lt;code&gt;a&lt;/code&gt;, so &lt;strong&gt;every other &lt;code&gt;a&lt;/code&gt; in that line is black&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pair: a a  =&amp;gt;  every other a in the line is black.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both rules are static — their conclusions depend only on the printed numbers —&lt;br&gt;
but they still run inside the fixpoint, because a "must be white" landing on an&lt;br&gt;
already-black cell is a contradiction worth catching. The moment they inoculate&lt;br&gt;
the board with its first blacks and whites, the dynamic rules cascade: a black&lt;br&gt;
whitens its neighbours, a white evicts its twins, and the cut-cell rule rescues&lt;br&gt;
whites about to be walled in.&lt;/p&gt;
&lt;h2&gt;
  
  
  An aside: Hitori never asks for a reason to shade
&lt;/h2&gt;

&lt;p&gt;One rule subtlety only implementation made obvious: there is &lt;strong&gt;no minimality&lt;br&gt;
constraint&lt;/strong&gt;. Nothing requires a shaded cell to &lt;em&gt;deserve&lt;/em&gt; shading — any shading&lt;br&gt;
that breaks no rule is a solution. A duplicate-free 2×2 board therefore has&lt;br&gt;
&lt;strong&gt;five&lt;/strong&gt; solutions (keep everything, or shade any single cell; the two diagonal&lt;br&gt;
double-shadings die by disconnection):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;puz&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;21&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a &lt;em&gt;well-posed&lt;/em&gt; board must be unique the hard way: &lt;strong&gt;every gratuitous extra&lt;br&gt;
shade has to die by adjacency or disconnection&lt;/strong&gt;. That lands squarely on the&lt;br&gt;
generator.&lt;/p&gt;
&lt;h2&gt;
  
  
  Search, and the uniqueness proof
&lt;/h2&gt;

&lt;p&gt;When propagation stalls, the search branches on the UNKNOWN cell with the&lt;br&gt;
&lt;strong&gt;most decided neighbours&lt;/strong&gt;, tries &lt;code&gt;BLACK&lt;/code&gt; then &lt;code&gt;WHITE&lt;/code&gt;, and re-propagates.&lt;br&gt;
&lt;code&gt;solveAll(p, 2)&lt;/code&gt; is a complete enumeration that stops at two solutions, so&lt;br&gt;
&lt;code&gt;length === 1&lt;/code&gt; is a genuine &lt;strong&gt;uniqueness proof&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Boards are authored answer-first
&lt;/h2&gt;

&lt;p&gt;The generator (&lt;code&gt;tools/generate.mts&lt;/code&gt;) writes the solved shading before the&lt;br&gt;
numbers. Scatter black cells (never adjacent, whites kept connected), then give&lt;br&gt;
the white cells a &lt;strong&gt;shuffled Latin square's&lt;/strong&gt; values — row/column uniqueness&lt;br&gt;
among the whites holds &lt;strong&gt;by construction&lt;/strong&gt;. Each black cell then &lt;strong&gt;copies a&lt;br&gt;
value from a white cell in its own row or column&lt;/strong&gt;, so every shaded cell has a&lt;br&gt;
visible reason: keep it and its line gains a duplicate. Finally the engine must&lt;br&gt;
prove &lt;strong&gt;exactly one&lt;/strong&gt; solution — which, per the aside above, includes proving&lt;br&gt;
that every do-nothing extra shade dies. The tests re-check all of it per board:&lt;br&gt;
the authored shading is legal, every black has its excuse, and the solver&lt;br&gt;
recovers exactly the authored answer. 45 tests, 5×5 through 10×10.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Hitori hands you back the humble cell-bit unknown — and then starves the&lt;br&gt;
propagator: no clue decides a cell, so &lt;strong&gt;sound dynamic rules alone cannot make&lt;br&gt;
the first move&lt;/strong&gt;. The openings that unlock it are the puzzle's two classic&lt;br&gt;
patterns, and the reason they're classic is structural: each is a &lt;strong&gt;one-step&lt;br&gt;
case split whose branches agree&lt;/strong&gt;, the exact shape a case analysis must have to&lt;br&gt;
count as propagation. Seed the board with them, let duplicate / adjacency /&lt;br&gt;
cut-cell chain to a fixpoint, and a fewest-options search proves each bundled&lt;br&gt;
board has exactly one answer. Solver-backed puzzle #14.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Shikaku with constraint propagation: when the unknown is a rectangle, not a cell</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Thu, 16 Jul 2026 23:12:53 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-shikaku-with-constraint-propagation-when-the-unknown-is-a-rectangle-not-a-cell-856</link>
      <guid>https://dev.to/sendotltd/solving-shikaku-with-constraint-propagation-when-the-unknown-is-a-rectangle-not-a-cell-856</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Shikaku&lt;/strong&gt; (四角に切れ, "divide by squares") in the browser with a&lt;br&gt;
&lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Cut the grid into rectangles: every&lt;br&gt;
rectangle holds &lt;strong&gt;exactly one number&lt;/strong&gt;, and that number is its &lt;strong&gt;area&lt;/strong&gt;. This&lt;br&gt;
is where the previous twelve solvers' model breaks. They all put the unknown&lt;br&gt;
&lt;strong&gt;in a cell&lt;/strong&gt; — a colour bit, a digit domain, an edge bit. Shikaku's unknown is&lt;br&gt;
coarser: &lt;strong&gt;which rectangle does each number take?&lt;/strong&gt; Enumerate those candidates&lt;br&gt;
up front and the puzzle collapses into a plain &lt;strong&gt;exact cover&lt;/strong&gt;, with&lt;br&gt;
propagation running over &lt;strong&gt;sets of rectangles&lt;/strong&gt; instead of cells. Two sound,&lt;br&gt;
mutually &lt;strong&gt;dual&lt;/strong&gt; rules (&lt;strong&gt;only-coverer&lt;/strong&gt; and &lt;strong&gt;forced-cell&lt;/strong&gt;) run to a&lt;br&gt;
fixpoint, then a fewest-candidates search proves uniqueness. Solver-backed&lt;br&gt;
puzzle #13.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/shikaku/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/shikaku/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/shikaku" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/shikaku&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5twvoupns2qbmdr2fax.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5twvoupns2qbmdr2fax.png" alt="Screenshot" width="800" height="813"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the logic that follows
&lt;/h2&gt;

&lt;p&gt;A Shikaku grid is &lt;strong&gt;R×C&lt;/strong&gt; with some cells carrying a number. A finished board&lt;br&gt;
cuts it into axis-aligned rectangles so that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;every rectangle contains &lt;strong&gt;exactly one&lt;/strong&gt; number;&lt;/li&gt;
&lt;li&gt;a rectangle's &lt;strong&gt;area equals&lt;/strong&gt; that number;&lt;/li&gt;
&lt;li&gt;the rectangles &lt;strong&gt;tile&lt;/strong&gt; the grid — every cell covered exactly once.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Why the cell model is the wrong one here
&lt;/h2&gt;

&lt;p&gt;Ask "what colour is this cell?" and Shikaku gives a useless answer: &lt;strong&gt;every cell&lt;br&gt;
is covered, always&lt;/strong&gt;. The information isn't in the cell — it's in the &lt;strong&gt;cut&lt;/strong&gt;. So&lt;br&gt;
the domain moves up a level, from the cell to the &lt;strong&gt;clue&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// every box that contains the clue, has exactly its area, and eats no other clue&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;candidatesFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rect&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// only integer h×w factorisations&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;12&lt;/code&gt; clue has at most six shapes (1×12, 2×6, 3×4, 4×3, 6×2, 12×1), each with a&lt;br&gt;
handful of placements — a domain of a few dozen at worst. Small enough to&lt;br&gt;
&lt;strong&gt;enumerate once and then just filter&lt;/strong&gt;, which is the whole trick.&lt;/p&gt;

&lt;p&gt;Note the third condition doing quiet work: a candidate that would &lt;strong&gt;swallow&lt;br&gt;
another clue&lt;/strong&gt; never enters the domain at all. The closer the numbers sit, the&lt;br&gt;
smaller every domain starts out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two rules are duals
&lt;/h2&gt;

&lt;p&gt;The entire propagation is two sound rules — and they're mirror images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// only-coverer: a cell nobody else can reach pins its clue&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// orphan cell — nothing can cover it&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cm"&gt;/* kill k's candidates that miss c */&lt;/span&gt;

&lt;span class="c1"&gt;// forced-cell: a cell one clue always takes is closed to the rest&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;common&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cm"&gt;/* kill every OTHER clue's candidate covering c */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One says &lt;em&gt;a cell needs someone&lt;/em&gt;; the other says &lt;em&gt;a cell allows only one&lt;/em&gt;.&lt;br&gt;
Together they're enough — and the rule you'd reach for first, &lt;strong&gt;"a clue down to a&lt;br&gt;
single candidate commits it, and everything overlapping dies"&lt;/strong&gt;, needs &lt;strong&gt;no code&lt;br&gt;
at all&lt;/strong&gt;. It falls out of forced-cell: a singleton domain's intersection &lt;em&gt;is&lt;/em&gt; its&lt;br&gt;
own rectangle, so every cell of that box closes to everyone else automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs both to a &lt;strong&gt;fixpoint&lt;/strong&gt;, returning &lt;code&gt;false&lt;/code&gt; the moment a clue&lt;br&gt;
loses every candidate or a cell loses every coverer.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Some boards need a guess. The search branches on the clue with the &lt;strong&gt;fewest&lt;br&gt;
surviving candidates&lt;/strong&gt;, pins it, re-propagates, and counts. A complete assignment&lt;br&gt;
is accepted only when &lt;code&gt;isSolved()&lt;/code&gt; agrees — an &lt;strong&gt;independent&lt;/strong&gt; validator that&lt;br&gt;
re-checks areas, containment, overlap and full coverage from scratch, sharing no&lt;br&gt;
logic with the propagator, so a solver bug can't cancel out a validator bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;solveAll&lt;/code&gt; enumerates up to a limit; &lt;code&gt;hasUniqueSolution&lt;/code&gt; stops at two, and the&lt;br&gt;
tests assert every bundled puzzle stops at exactly one.&lt;/p&gt;

&lt;p&gt;There's also a nearly-free sanity check worth doing before any of it: &lt;strong&gt;the clues&lt;br&gt;
must sum to the area of the grid&lt;/strong&gt;. One pass, and it rejects a whole class of&lt;br&gt;
malformed boards before a single rectangle is enumerated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;R&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Puzzles are solver-verified, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Each board is authored as &lt;strong&gt;its solved tiling&lt;/strong&gt; — one letter per cell naming its&lt;br&gt;
rectangle, &lt;strong&gt;uppercase&lt;/strong&gt; on the cell that carries the number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'aaabBcc'      region 'b' is a 1×2 box, so its clue reads 2
'aAadDCc'      region 'a' is a 3×3 box, so its clue reads 9
'aaaeecc'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both halves of a clue are &lt;strong&gt;derived&lt;/strong&gt;: its &lt;em&gt;position&lt;/em&gt; is the uppercase cell, its&lt;br&gt;
&lt;em&gt;number&lt;/em&gt; is the &lt;strong&gt;area&lt;/strong&gt; of that letter's region. Nothing is typed twice, so a&lt;br&gt;
clue &lt;strong&gt;cannot&lt;/strong&gt; drift out of sync with its answer. Better, a drawing that isn't a&lt;br&gt;
real tiling — an L-shaped region, a region with two numbers, a region with none —&lt;br&gt;
&lt;strong&gt;fails to parse at all&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`board "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" region "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is not a filled rectangle`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator (&lt;code&gt;tools/generate.mts&lt;/code&gt;) cuts a grid into random rectangles and tries&lt;br&gt;
&lt;strong&gt;several clue placements per tiling&lt;/strong&gt; — &lt;em&gt;which&lt;/em&gt; cell in a box carries the number&lt;br&gt;
changes the puzzle completely, and that turned out to be the search axis that&lt;br&gt;
mattered — keeping a board only if the solver &lt;strong&gt;proves&lt;/strong&gt; a single solution. The&lt;br&gt;
tests re-solve every board and assert it recovers &lt;strong&gt;exactly&lt;/strong&gt; the drawn tiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;coverMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// and it's the drawn one&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more generator knob that earned its keep: &lt;strong&gt;penalise 1×1 rectangles&lt;/strong&gt;&lt;br&gt;
(weight 1 vs 3). A board of single cells is perfectly unique and perfectly&lt;br&gt;
boring. Same discipline as the Nurikabe, Masyu, Hashiwokakero, Star Battle,&lt;br&gt;
Kakuro and Akari entries: ship your levels through your solver. The bundled&lt;br&gt;
boards run &lt;strong&gt;5×5 up to 10×10&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  A small UI lesson
&lt;/h2&gt;

&lt;p&gt;Colouring the rectangles, I first cycled hue by &lt;code&gt;k * 47 % 360&lt;/code&gt;. &lt;strong&gt;Red came up.&lt;/strong&gt;&lt;br&gt;
Red was already the colour for an &lt;em&gt;illegal&lt;/em&gt; rectangle, so a perfectly good box&lt;br&gt;
rendered as an error. Fixed by exiling the hue from the red band:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Kept inside 40°..330° — red is reserved for illegal boxes and must not collide.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;67&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;290&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated palettes don't just look bad when they collide — they &lt;strong&gt;break meaning&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The real lesson of Shikaku is that &lt;strong&gt;the granularity you put the unknown at&lt;br&gt;
decides how hard the puzzle is&lt;/strong&gt;. Put it in the cell and you get a useless&lt;br&gt;
answer: everything is covered, always. Move it to the &lt;strong&gt;cut&lt;/strong&gt; — which rectangle&lt;br&gt;
does each number take — and the domain becomes a small enumerable set, the puzzle&lt;br&gt;
collapses into exact cover, and you need exactly two rules, which are duals of&lt;br&gt;
each other, and which contain the obvious third rule &lt;strong&gt;for free&lt;/strong&gt;. Get the model&lt;br&gt;
right and the code gets shorter. Solver-backed puzzle #13.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Nurikabe with constraint propagation: three-valued cells, a reachability flood, and a uniqueness proof</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Thu, 16 Jul 2026 01:57:37 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-nurikabe-with-constraint-propagation-three-valued-cells-a-reachability-flood-and-a-3a6l</link>
      <guid>https://dev.to/sendotltd/solving-nurikabe-with-constraint-propagation-three-valued-cells-a-reachability-flood-and-a-3a6l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Nurikabe&lt;/strong&gt; (ぬりかべ / "plaster wall") in the browser with a&lt;br&gt;
&lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Every cell is &lt;strong&gt;white&lt;/strong&gt; (part of a&lt;br&gt;
numbered &lt;em&gt;island&lt;/em&gt;) or &lt;strong&gt;black&lt;/strong&gt; (the &lt;em&gt;sea&lt;/em&gt;), and the solver keeps a&lt;br&gt;
&lt;strong&gt;three-valued&lt;/strong&gt; cell — &lt;code&gt;UNKNOWN&lt;/code&gt; / &lt;code&gt;WHITE&lt;/code&gt; / &lt;code&gt;BLACK&lt;/code&gt; — running &lt;strong&gt;sound&lt;br&gt;
deductions to a fixpoint&lt;/strong&gt; before it ever guesses. Four rules feed each other:&lt;br&gt;
the &lt;strong&gt;pool rule&lt;/strong&gt; (no 2×2 all-black), the &lt;strong&gt;sealed island&lt;/strong&gt; (a white region the&lt;br&gt;
size of its clue seals its border), the &lt;strong&gt;merge / over-size check&lt;/strong&gt;, and the&lt;br&gt;
one that does the heavy lifting — a &lt;strong&gt;reachability flood&lt;/strong&gt; that expands every&lt;br&gt;
clue outward and &lt;strong&gt;blacks out any cell no clue can still reach&lt;/strong&gt;. Each bundled&lt;br&gt;
board is authored as its own solved sea, so the clues are read off the answer&lt;br&gt;
and can never drift. Solver-backed puzzle #12.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/nurikabe/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/nurikabe/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/nurikabe" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/nurikabe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmw8mqncyxdqitzu9u449.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmw8mqncyxdqitzu9u449.png" alt="Screenshot" width="800" height="1216"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the logic that follows
&lt;/h2&gt;

&lt;p&gt;A Nurikabe grid is &lt;strong&gt;R×C&lt;/strong&gt; with some cells carrying a number. A finished board&lt;br&gt;
paints every cell white or black so that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;each &lt;strong&gt;island&lt;/strong&gt; is one orthogonally-connected white block holding &lt;strong&gt;exactly
one number&lt;/strong&gt;, and its size &lt;strong&gt;equals&lt;/strong&gt; that number;&lt;/li&gt;
&lt;li&gt;islands &lt;strong&gt;never touch&lt;/strong&gt; orthogonally (a black cell always separates them);&lt;/li&gt;
&lt;li&gt;all black cells form &lt;strong&gt;one connected sea&lt;/strong&gt; with &lt;strong&gt;no 2×2 pool&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The unknown here is simple — one bit per cell, white or black — but the rules&lt;br&gt;
tangle &lt;em&gt;connectivity&lt;/em&gt; (islands and sea) with &lt;em&gt;counting&lt;/em&gt; (island sizes) with a&lt;br&gt;
&lt;em&gt;local&lt;/em&gt; shape ban (the 2×2 pool). We model the cell three-valued (&lt;code&gt;UNKNOWN&lt;/code&gt;&lt;br&gt;
until decided) and shrink it with strict, guess-free deductions.&lt;/p&gt;
&lt;h3&gt;
  
  
  The pool rule and sealed islands
&lt;/h3&gt;

&lt;p&gt;Two are immediate. No 2×2 window may be all black, so &lt;strong&gt;three blacks force the&lt;br&gt;
fourth white&lt;/strong&gt;, and four blacks is an instant contradiction. And a white region&lt;br&gt;
whose size already &lt;strong&gt;equals its clue&lt;/strong&gt; is finished — every unknown cell touching&lt;br&gt;
it becomes sea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;clue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nb&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nbrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same region scan rejects two clues merged into one region, or a region grown&lt;br&gt;
past its number.&lt;/p&gt;
&lt;h3&gt;
  
  
  The reachability flood
&lt;/h3&gt;

&lt;p&gt;This is the crux. A cell can be white &lt;strong&gt;only if some clue's island could still&lt;br&gt;
grow to include it.&lt;/strong&gt; So flood every clue outward through unknown cells, spending&lt;br&gt;
one unit of its &lt;em&gt;remaining budget&lt;/em&gt; (&lt;code&gt;clue − current size&lt;/code&gt;) per unknown cell&lt;br&gt;
entered, and &lt;strong&gt;black out every cell no flood reaches&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;clued&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;clue&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// cells this island may still add&lt;/span&gt;
  &lt;span class="c1"&gt;// BFS from the island, cost = unknown cells spent; mark everything ≤ budget&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reach&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// unreachable → sea&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;WHITE&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// stranded island&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtle part is &lt;strong&gt;soundness&lt;/strong&gt;. For "unreachable ⇒ black" to be safe, the&lt;br&gt;
flood must never miss a genuinely reachable cell — its reach set has to be a&lt;br&gt;
&lt;em&gt;superset&lt;/em&gt; of the truth. So the flood is deliberately &lt;strong&gt;generous&lt;/strong&gt;: it lets&lt;br&gt;
orphan white cells be crossed for free and ignores the finer "can't sit next to&lt;br&gt;
another island" refinement. Over-counting reach only ever leaves &lt;em&gt;fewer&lt;/em&gt; cells&lt;br&gt;
to black out, so every cell it does black is provably sea. Being generous is&lt;br&gt;
what makes the deduction correct.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs the pool rule, sealing, the region checks and the flood to a&lt;br&gt;
&lt;strong&gt;fixpoint&lt;/strong&gt;, returning &lt;code&gt;false&lt;/code&gt; the instant any rule contradicts.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Some boards need a guess. The backtracker branches on the &lt;strong&gt;most constrained&lt;/strong&gt;&lt;br&gt;
unknown cell (the one touching the most already-decided cells), tries it &lt;code&gt;BLACK&lt;/code&gt;&lt;br&gt;
then &lt;code&gt;WHITE&lt;/code&gt;, and lets propagation prune. A complete assignment is accepted only&lt;br&gt;
when &lt;code&gt;isSolved()&lt;/code&gt; holds: every island exact and clued, the sea one connected&lt;br&gt;
region, and no 2×2 pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;solveAll&lt;/code&gt; branches &lt;code&gt;BLACK&lt;/code&gt;/&lt;code&gt;WHITE&lt;/code&gt; on undecided cells — a &lt;strong&gt;complete&lt;/strong&gt;&lt;br&gt;
enumeration — pruned only by &lt;strong&gt;sound&lt;/strong&gt; propagation. It stops at two solutions, so&lt;br&gt;
the "unique" it reports is real.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles are solver-verified, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Each board is authored as &lt;strong&gt;its solved grid&lt;/strong&gt;. The generator&lt;br&gt;
(&lt;code&gt;tools/generate.mts&lt;/code&gt;) is &lt;strong&gt;sea-first&lt;/strong&gt;: it grows one connected sea by a random&lt;br&gt;
walk that &lt;strong&gt;never completes a 2×2 pool&lt;/strong&gt;, then reads the leftover white&lt;br&gt;
components off as islands — so connectivity and pool-freeness hold &lt;em&gt;by&lt;br&gt;
construction&lt;/em&gt;, not by rejection sampling. It drops one number per island and&lt;br&gt;
keeps the clue set only if the solver &lt;strong&gt;proves&lt;/strong&gt; a single solution. The tests&lt;br&gt;
re-solve every board and assert it recovers &lt;strong&gt;exactly&lt;/strong&gt; the drawn sea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;                  &lt;span class="c1"&gt;// and it's the drawn one&lt;/span&gt;
  &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One tuning note worth its own line: raising the sea's coverage produces&lt;br&gt;
&lt;strong&gt;smaller, more numerous islands&lt;/strong&gt;, which means &lt;strong&gt;more clues&lt;/strong&gt;, which makes a&lt;br&gt;
single-clue-per-island board far likelier to be uniquely solvable — and cheaper&lt;br&gt;
to verify. That one knob was the difference between the 10×10 generating in&lt;br&gt;
seconds versus not at all. Same discipline as the Masyu, Hashiwokakero, Star&lt;br&gt;
Battle, Kakuro and Akari entries: ship your levels through your solver. The&lt;br&gt;
bundled boards run &lt;strong&gt;5×5 up to 10×10&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Nurikabe looks like a shading puzzle but it's really a &lt;strong&gt;reachability&lt;/strong&gt; puzzle.&lt;br&gt;
Once the unknown is a three-valued cell, "no pool" and "sealed island" are cheap&lt;br&gt;
local rules, "one connected sea" is a leaf check, and the real deductions come&lt;br&gt;
from a &lt;strong&gt;generous flood&lt;/strong&gt; that blacks out everything no clue can claim — sound&lt;br&gt;
precisely &lt;em&gt;because&lt;/em&gt; it over-counts. Branch the most-constrained cell, prune with&lt;br&gt;
that logic, and the search proves uniqueness while staying complete.&lt;br&gt;
Solver-backed puzzle #12.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Masyu with constraint propagation: an edge model, local patterns, a closure guard, and a uniqueness proof</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:41:55 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-masyu-with-constraint-propagation-an-edge-model-local-patterns-a-closure-guard-and-a-36g2</link>
      <guid>https://dev.to/sendotltd/solving-masyu-with-constraint-propagation-an-edge-model-local-patterns-a-closure-guard-and-a-36g2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Masyu&lt;/strong&gt; (ましゅ / "pearls") in the browser with a &lt;strong&gt;single-loop&lt;br&gt;
constraint-propagation solver&lt;/strong&gt; inside. Three ideas carry it. &lt;strong&gt;(1) The&lt;br&gt;
unknowns are edges, not cells&lt;/strong&gt; — each grid edge is one bit, &lt;em&gt;in the loop&lt;/em&gt; or&lt;br&gt;
not; every cell has degree &lt;strong&gt;0 or 2&lt;/strong&gt;, and a pearl's degree is exactly 2.&lt;br&gt;
&lt;strong&gt;(2) Each cell's edges must match one of its legal local patterns&lt;/strong&gt; — a white&lt;br&gt;
○ runs &lt;em&gt;straight&lt;/em&gt;, a black ● &lt;em&gt;turns&lt;/em&gt;, a plain cell is unused or bent — and a&lt;br&gt;
black additionally forces the cell one step beyond each arm to run straight.&lt;br&gt;
All strict implications, never guesses. &lt;strong&gt;(3) A closure guard&lt;/strong&gt; kills any loop&lt;br&gt;
that closes before it has swallowed every pearl. Each bundled board is authored&lt;br&gt;
as its own solved loop, so the pearls are read off the answer and can never&lt;br&gt;
drift. Solver-backed puzzle #11.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/masyu/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/masyu/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/masyu" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/masyu&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhw2h2bx4izy2z2qzirfy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhw2h2bx4izy2z2qzirfy.png" alt="Screenshot" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the logic that follows
&lt;/h2&gt;

&lt;p&gt;A Masyu grid is &lt;strong&gt;R×C&lt;/strong&gt;. Some cells hold a &lt;strong&gt;white ○&lt;/strong&gt; or &lt;strong&gt;black ●&lt;/strong&gt; pearl. You&lt;br&gt;
draw &lt;strong&gt;one&lt;/strong&gt; closed loop between orthogonally adjacent cell centres that never&lt;br&gt;
crosses or reuses itself, and:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;White ○&lt;/strong&gt; — the loop passes &lt;strong&gt;straight through&lt;/strong&gt;, and &lt;strong&gt;turns&lt;/strong&gt; in at least
one of the two cells immediately before/after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Black ●&lt;/strong&gt; — the loop &lt;strong&gt;turns&lt;/strong&gt; here, and runs &lt;strong&gt;straight through&lt;/strong&gt; the cell
immediately on each side.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything starts with &lt;em&gt;picking the right unknown&lt;/em&gt;. For Nonogram or Star Battle&lt;br&gt;
the unknown is a cell; for Masyu the loop &lt;strong&gt;is a set of edges&lt;/strong&gt;, so we make the&lt;br&gt;
&lt;strong&gt;edges&lt;/strong&gt; the unknowns — &lt;code&gt;R*(C-1)&lt;/code&gt; horizontal + &lt;code&gt;(R-1)*C&lt;/code&gt; vertical, each&lt;br&gt;
&lt;code&gt;UNKNOWN&lt;/code&gt; / &lt;code&gt;ON&lt;/code&gt; / &lt;code&gt;OFF&lt;/code&gt;. A cell's &lt;strong&gt;degree&lt;/strong&gt; (its ON edges) is &lt;strong&gt;0 or 2&lt;/strong&gt;; a&lt;br&gt;
pearl's is exactly 2. Two strict, guess-free implications fall out.&lt;/p&gt;
&lt;h3&gt;
  
  
  Local patterns
&lt;/h3&gt;

&lt;p&gt;Every cell's ON edges must equal one of the &lt;strong&gt;local patterns&lt;/strong&gt; allowed for it,&lt;br&gt;
which we enumerate up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain&lt;/strong&gt;: unused (degree 0) or any two edges (degree 2)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;White ○&lt;/strong&gt;: only its two &lt;em&gt;collinear&lt;/em&gt; edges — &lt;code&gt;{left,right}&lt;/code&gt; or &lt;code&gt;{up,down}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Black ●&lt;/strong&gt;: one horizontal + one vertical — &lt;code&gt;{L,U}&lt;/code&gt; &lt;code&gt;{L,D}&lt;/code&gt; &lt;code&gt;{R,U}&lt;/code&gt; &lt;code&gt;{R,D}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Propagation is then trivial: keep only the patterns consistent with the decided&lt;br&gt;
edges, and whenever the survivors all agree on an edge, force it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;inc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inPat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inPat&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inPat&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// contradiction&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;inc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                              &lt;span class="c1"&gt;// agreed on by all → forced&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anyOn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anyOff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anyOn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;anyOff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anyOff&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;anyOn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"White runs straight" and "black turns" are both captured purely by these&lt;br&gt;
patterns. A pearl squeezed against the border with no room to run straight/turn&lt;br&gt;
simply has zero surviving patterns — a contradiction.&lt;/p&gt;
&lt;h3&gt;
  
  
  Black's "straight on each side" as an edge rule
&lt;/h3&gt;

&lt;p&gt;The half of the black rule about neighbours running straight drops out as an&lt;br&gt;
&lt;strong&gt;edge implication&lt;/strong&gt;: if a black's arm is ON, the edge one cell further along&lt;br&gt;
&lt;strong&gt;must&lt;/strong&gt; be ON too (that neighbour runs straight). If the outer edge is OFF, the&lt;br&gt;
arm can't point that way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// no room to run straight&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                     &lt;span class="c1"&gt;// this arm is impossible&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;White's "turn immediately before/after" depends on a neighbour's shape — it's&lt;br&gt;
&lt;strong&gt;non-local&lt;/strong&gt;, so it's checked at the leaf, not during propagation.&lt;/p&gt;
&lt;h3&gt;
  
  
  The closure guard
&lt;/h3&gt;

&lt;p&gt;This is the loop-specific crux. &lt;strong&gt;A closed cycle can never grow again.&lt;/strong&gt; So the&lt;br&gt;
instant ON edges form a finished loop, it must already contain &lt;strong&gt;every pearl and&lt;br&gt;
every ON edge&lt;/strong&gt; — any earlier closure would spawn a second loop and is a dead&lt;br&gt;
branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;compHasOpen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasOpen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                       &lt;span class="c1"&gt;// still growing&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;compEdges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;totalOn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ON edges outside it&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pearls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;NONE&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a pearl left out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs all of this to a &lt;strong&gt;fixpoint&lt;/strong&gt;, then applies the closure guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Some boards need a guess. The backtracker branches on the edge that &lt;strong&gt;extends an&lt;br&gt;
existing loop fragment&lt;/strong&gt; (a cell already holding one ON edge), tries it &lt;code&gt;ON&lt;/code&gt; then&lt;br&gt;
&lt;code&gt;OFF&lt;/code&gt;, and lets propagation prune. A complete assignment is accepted only when&lt;br&gt;
&lt;code&gt;isSolution()&lt;/code&gt; holds: every cell degree 0 or 2, every pearl rule (including the&lt;br&gt;
non-local white/black neighbour checks), and the ON edges form &lt;strong&gt;exactly one&lt;/strong&gt;&lt;br&gt;
cycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search branches &lt;code&gt;ON&lt;/code&gt;/&lt;code&gt;OFF&lt;/code&gt; on &lt;em&gt;every&lt;/em&gt; undecided edge — a &lt;strong&gt;complete&lt;/strong&gt;&lt;br&gt;
enumeration — pruned only by &lt;strong&gt;sound&lt;/strong&gt; propagation and the closure guard. So the&lt;br&gt;
"unique" it reports is real.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles are solver-verified, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Each board is authored as &lt;strong&gt;its solved loop&lt;/strong&gt; plus the pearls that loop earns.&lt;br&gt;
The generator (&lt;code&gt;tools/generate.mts&lt;/code&gt;) grows one rectilinear loop from a 2×2 cycle&lt;br&gt;
by repeatedly &lt;strong&gt;bumping a straight segment outward&lt;/strong&gt; into two empty cells (every&lt;br&gt;
bump preserves a single self-avoiding loop), reads every &lt;strong&gt;legal&lt;/strong&gt; white/black&lt;br&gt;
spot off the finished loop, then &lt;strong&gt;greedily drops&lt;/strong&gt; pearls while the solver still&lt;br&gt;
proves a single solution. The tests re-solve every board and assert it recovers&lt;br&gt;
&lt;strong&gt;exactly&lt;/strong&gt; the drawn loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;edgeKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;edgeKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// and it's the drawn loop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same discipline as the Hashiwokakero, Star Battle, Kakuro and Akari entries: ship&lt;br&gt;
your levels through your solver. The bundled boards run &lt;strong&gt;5×5 up to 7×7&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The whole puzzle unlocks once you move the unknown &lt;strong&gt;from cells to edges&lt;/strong&gt;. Then&lt;br&gt;
"white straight / black turn" becomes each cell's &lt;strong&gt;local pattern&lt;/strong&gt;, "black runs&lt;br&gt;
straight on both sides" becomes an &lt;strong&gt;edge implication&lt;/strong&gt;, and "one closed loop"&lt;br&gt;
becomes a &lt;strong&gt;closure guard&lt;/strong&gt; plus a single-cycle check at the leaf. Branch the&lt;br&gt;
undecided edges, prune with sound logic, and the search proves uniqueness while&lt;br&gt;
staying complete. Solver-backed puzzle #11.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>puzzle</category>
    </item>
    <item>
      <title>Solving Star Battle with Constraint Propagation and a Uniqueness Proof</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Mon, 13 Jul 2026 23:17:06 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-star-battle-with-constraint-propagation-and-a-uniqueness-proof-2kbb</link>
      <guid>https://dev.to/sendotltd/solving-star-battle-with-constraint-propagation-and-a-uniqueness-proof-2kbb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Star Battle&lt;/strong&gt; (Doppelstern / スターバトル), the star-placement logic puzzle,&lt;br&gt;
built for the browser with a &lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Three&lt;br&gt;
hinges: &lt;strong&gt;(1)&lt;/strong&gt; the unknowns are one bit per cell — &lt;em&gt;star&lt;/em&gt; or &lt;em&gt;empty&lt;/em&gt; — and&lt;br&gt;
each of the board's &lt;strong&gt;3n lines&lt;/strong&gt; (n rows, n columns, n irregular regions) is a&lt;br&gt;
&lt;strong&gt;count-exactly-k&lt;/strong&gt; constraint. When a line's stars are all placed, its rest is&lt;br&gt;
struck empty; when the only way to reach &lt;em&gt;k&lt;/em&gt; is to use every open cell, they&lt;br&gt;
are all stars; and a placed star &lt;strong&gt;empties its eight neighbours&lt;/strong&gt;. Each step is&lt;br&gt;
a strict implication, never a guess. &lt;strong&gt;(2)&lt;/strong&gt; When logic stalls, a backtracking&lt;br&gt;
search branches on a cell in the &lt;strong&gt;line with the fewest open cells&lt;/strong&gt;, lets&lt;br&gt;
propagation prune, and counts solutions. &lt;strong&gt;(3)&lt;/strong&gt; Every bundled board is drawn&lt;br&gt;
as its own solved grid, so the region shapes — the &lt;em&gt;only&lt;/em&gt; clue — are read off&lt;br&gt;
the answer and can't drift. Solver-equipped puzzle #10.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/star-battle/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/star-battle/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/star-battle" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/star-battle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fstar-battle%2Fassets%2Fscreenshot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fstar-battle%2Fassets%2Fscreenshot.png" alt="Star Battle" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the logic that follows
&lt;/h2&gt;

&lt;p&gt;A Star Battle grid is an &lt;strong&gt;n×n&lt;/strong&gt; board carved into exactly &lt;strong&gt;n&lt;/strong&gt; irregular&lt;br&gt;
&lt;strong&gt;regions&lt;/strong&gt;. You place stars so that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rows&lt;/strong&gt; — every row holds exactly &lt;em&gt;k&lt;/em&gt; stars (&lt;em&gt;k&lt;/em&gt; is 1 or 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Columns&lt;/strong&gt; — every column holds exactly &lt;em&gt;k&lt;/em&gt; stars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regions&lt;/strong&gt; — every region holds exactly &lt;em&gt;k&lt;/em&gt; stars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spacing&lt;/strong&gt; — no two stars touch, not even diagonally.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first move is to notice what the unknowns actually are. Each cell is a single&lt;br&gt;
bit — &lt;strong&gt;star&lt;/strong&gt; or &lt;strong&gt;empty&lt;/strong&gt; — so we track its state as &lt;code&gt;UNKNOWN&lt;/code&gt; / &lt;code&gt;STAR&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;EMPTY&lt;/code&gt; and shrink it with two rules that are strict deductions, never guesses.&lt;/p&gt;
&lt;h3&gt;
  
  
  Exact count
&lt;/h3&gt;

&lt;p&gt;Every row, column and region is a &lt;em&gt;count-exactly-k&lt;/em&gt; line. Count what is already&lt;br&gt;
placed and what is still open, and both extremes are forced. If the stars are all&lt;br&gt;
placed (&lt;code&gt;placed === k&lt;/code&gt;), every open cell in the line is &lt;strong&gt;empty&lt;/strong&gt;. If the only&lt;br&gt;
way to reach &lt;em&gt;k&lt;/em&gt; is to use every open cell (&lt;code&gt;placed + open === k&lt;/code&gt;), they are all&lt;br&gt;
&lt;strong&gt;stars&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;        &lt;span class="c1"&gt;// too many → contradiction&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// can't reach k → contradiction&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;              &lt;span class="c1"&gt;// line full → clear the rest&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;EMPTY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// only enough cells left → all stars&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;STAR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice part is that the three kinds of line don't need special-casing: rows,&lt;br&gt;
columns and regions are all just &lt;em&gt;a list of cell ids&lt;/em&gt; with the same&lt;br&gt;
exactly-k constraint, so one loop handles every unit.&lt;/p&gt;
&lt;h3&gt;
  
  
  Spacing
&lt;/h3&gt;

&lt;p&gt;The moment a star is placed, its &lt;strong&gt;eight neighbours become empty&lt;/strong&gt; — and two&lt;br&gt;
stars that touch are an immediate contradiction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;STAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nb&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;neighbors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;STAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// adjacent → contradiction&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;EMPTY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs both rules to a &lt;strong&gt;fixpoint&lt;/strong&gt;, recording the order cells are&lt;br&gt;
pinned — which is exactly what the demo's &lt;strong&gt;Hint&lt;/strong&gt; replays, one guess-free&lt;br&gt;
deduction at a time.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Plenty of boards — the 2-star ones especially — need a guess. The backtracking&lt;br&gt;
solver picks a cell in the &lt;strong&gt;still-unsatisfied line with the fewest open cells&lt;/strong&gt;&lt;br&gt;
(the tightest place a single decision prunes), fixes it to &lt;code&gt;STAR&lt;/code&gt; then &lt;code&gt;EMPTY&lt;/code&gt;,&lt;br&gt;
and lets propagation prune each branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chooseCell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;units&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;firstOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;STAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firstOpen&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;firstOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stars&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// skip satisfied lines&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bestOpen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bestOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;firstOpen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A complete assignment is accepted only when &lt;code&gt;isSolved()&lt;/code&gt; holds — every line has&lt;br&gt;
exactly &lt;em&gt;k&lt;/em&gt; stars and no two stars touch. &lt;code&gt;solveAll&lt;/code&gt; enumerates up to a limit;&lt;br&gt;
&lt;code&gt;hasUniqueSolution&lt;/code&gt; stops at two, and the test suite asserts every bundled puzzle&lt;br&gt;
stops at exactly one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what makes Star Battle interesting to generate. There are &lt;strong&gt;no number&lt;br&gt;
clues at all&lt;/strong&gt; — the &lt;em&gt;region shapes are the only clue&lt;/em&gt;. So "solvable by pure&lt;br&gt;
logic" and "has a unique solution" are properties the region carving has to earn,&lt;br&gt;
which is why the generator leans on the solver as its oracle.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles are solver-verified, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Every board is authored as &lt;strong&gt;the solved grid&lt;/strong&gt;, one character per cell. The&lt;br&gt;
&lt;strong&gt;letter&lt;/strong&gt; names the region (&lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt;, …); an &lt;strong&gt;uppercase&lt;/strong&gt; letter is an&lt;br&gt;
empty cell, the same letter &lt;strong&gt;lowercase&lt;/strong&gt; marks the star:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BBaCC      row 0: region B, B, a STAR in region A, C, C
bBBCC      row 1: a STAR in region B, then B, B, C, C
BDBcC      …
BdDCC
BDDDe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The region map and the star solution are read off the &lt;em&gt;same&lt;/em&gt; grid, and because a&lt;br&gt;
region's cells all share one letter, the shapes are exact by construction. The&lt;br&gt;
generator (&lt;code&gt;tools/generate.mts&lt;/code&gt;) first places a legal star pattern (&lt;em&gt;k&lt;/em&gt; per row&lt;br&gt;
and column, none touching), then &lt;strong&gt;seeds one region per group of stars and grows&lt;br&gt;
the regions outward by flood-fill&lt;/strong&gt; — since every star is a seed, growth only&lt;br&gt;
ever adds empty cells, so each region keeps exactly &lt;em&gt;k&lt;/em&gt; stars. It keeps a board&lt;br&gt;
only if the regions are all connected and the solver proves the layout admits a&lt;br&gt;
&lt;em&gt;single&lt;/em&gt; solution. The tests re-solve every finished board and assert it recovers&lt;br&gt;
exactly the drawn stars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sol&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;    &lt;span class="c1"&gt;// and it's the drawn map&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same discipline as the Hashiwokakero, Kakuro, Nonogram and Akari entries: ship&lt;br&gt;
your levels through your solver. The six bundled boards run 5×5 (1★) up to 10×10&lt;br&gt;
(2★).&lt;/p&gt;
&lt;h2&gt;
  
  
  The UI
&lt;/h2&gt;

&lt;p&gt;The board is one &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;. Cells are region-tinted rects, with a &lt;strong&gt;thick border&lt;br&gt;
drawn only where two cells of different regions meet&lt;/strong&gt; (compare neighbouring&lt;br&gt;
region ids — that's the whole test). Click cycles a cell empty → star (★) →&lt;br&gt;
× (an empty pencil-mark) → empty. A player-facing &lt;code&gt;verdict()&lt;/code&gt; mirrors the&lt;br&gt;
solver's rules but reports &lt;em&gt;which&lt;/em&gt; things are wrong — stars that touch, lines&lt;br&gt;
with the wrong count — for live highlighting, and declares a win only when every&lt;br&gt;
line holds exactly &lt;em&gt;k&lt;/em&gt; stars with none adjacent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/star.ts        — pure engine: 3n exact-count lines + 8-way spacing, sound
                     propagation to a fixpoint, region connectivity check,
                     fewest-options backtracking, uniqueness count
src/star.test.ts   — 43 vitest cases
src/puzzles.ts     — 6 solved grids, all generator-made and solver-verified
src/main.ts        — SVG UI: region-tinted cells, thick region borders, the
                     star / × / empty cycle, Hint, animated Solve
tools/generate.mts — placement + region-growth generator that authors unique boards
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No runtime dependencies; TypeScript throughout. &lt;strong&gt;MIT-licensed&lt;/strong&gt; — clone it, read&lt;br&gt;
the engine, lift the exact-count + spacing pattern.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/star-battle/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/star-battle/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/star-battle" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/star-battle&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series of 100+ small, sharp, open-source builds by&lt;br&gt;
&lt;a href="https://sen.ltd" rel="noopener noreferrer"&gt;SEN, LLC&lt;/a&gt;. Solver-equipped puzzle #10 — after Hashiwokakero,&lt;br&gt;
Kakuro, Akari, Slitherlink, Nonogram and friends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>constraintsolving</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Solving Hashiwokakero (Bridges) with Constraint Propagation and a Connectivity Check</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Sun, 12 Jul 2026 23:19:22 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-hashiwokakero-bridges-with-constraint-propagation-and-a-connectivity-check-1ne2</link>
      <guid>https://dev.to/sendotltd/solving-hashiwokakero-bridges-with-constraint-propagation-and-a-connectivity-check-1ne2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hashiwokakero&lt;/strong&gt; (Bridges / 橋をかけろ), the island-and-bridge logic puzzle,&lt;br&gt;
built for the browser with a &lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Three&lt;br&gt;
hinges: &lt;strong&gt;(1)&lt;/strong&gt; the unknowns are exactly one integer per neighbour pair — an&lt;br&gt;
&lt;em&gt;edge&lt;/em&gt; carrying 0, 1 or 2 bridges — and each island's clue becomes a&lt;br&gt;
&lt;strong&gt;bounds-consistency&lt;/strong&gt; constraint: an edge must carry at least what its&lt;br&gt;
island's other edges &lt;em&gt;cannot&lt;/em&gt; cover, and at most what is left once they take&lt;br&gt;
their minimum. Crossing edges strike each other to zero. Each step is a strict&lt;br&gt;
implication, never a guess. &lt;strong&gt;(2)&lt;/strong&gt; When logic stalls, a backtracking search&lt;br&gt;
branches on the &lt;strong&gt;edge with the fewest options&lt;/strong&gt;, lets propagation prune, and&lt;br&gt;
— crucially — rejects any complete assignment whose bridges leave the islands&lt;br&gt;
in more than one &lt;strong&gt;connected component&lt;/strong&gt;. That connectivity check is what&lt;br&gt;
makes a Hashi solver more than a system of linear equations. &lt;strong&gt;(3)&lt;/strong&gt; Every&lt;br&gt;
bundled board is drawn as its own solved map, so the clues are read off the&lt;br&gt;
answer and can't drift. Solver-equipped puzzle #9.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/hashiwokakero/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/hashiwokakero/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/hashiwokakero" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/hashiwokakero&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fhashiwokakero%2Fassets%2Fscreenshot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fhashiwokakero%2Fassets%2Fscreenshot.png" alt="Hashiwokakero" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the logic that follows
&lt;/h2&gt;

&lt;p&gt;A Hashiwokakero grid is a scattering of &lt;strong&gt;islands&lt;/strong&gt;, each carrying a number. You&lt;br&gt;
draw &lt;strong&gt;bridges&lt;/strong&gt; — straight horizontal or vertical runs over open water — so&lt;br&gt;
that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Degree&lt;/strong&gt; — the bridges touching an island total exactly its number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity&lt;/strong&gt; — at most two bridges join any one pair of islands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No crossing&lt;/strong&gt; — a horizontal and a vertical bridge may never overlap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connected&lt;/strong&gt; — every island ends up in one single group.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first move is to notice what the unknowns actually are. Two islands are&lt;br&gt;
&lt;strong&gt;neighbours&lt;/strong&gt; when they share a row or column with nothing but water between&lt;br&gt;
them; that gap is the &lt;em&gt;only&lt;/em&gt; place a bridge between them can go. So the entire&lt;br&gt;
puzzle is one integer per neighbour pair — an &lt;strong&gt;edge&lt;/strong&gt; carrying 0, 1 or 2&lt;br&gt;
bridges. We track each edge's live range as an interval &lt;em&gt;[lo, hi]&lt;/em&gt; ⊆ &lt;em&gt;[0, 2]&lt;/em&gt;&lt;br&gt;
and shrink it with two rules that are strict deductions, never guesses.&lt;/p&gt;
&lt;h3&gt;
  
  
  Degree bounds
&lt;/h3&gt;

&lt;p&gt;An island clued &lt;em&gt;n&lt;/em&gt; has some incident edges; their bridge counts must sum to&lt;br&gt;
&lt;em&gt;n&lt;/em&gt;. That is a classic sum constraint, and its bounds-consistency form is exact:&lt;br&gt;
each edge must carry &lt;strong&gt;at least&lt;/strong&gt; what the others cannot cover, and &lt;strong&gt;at most&lt;/strong&gt;&lt;br&gt;
what is left once the others take their minimum.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sumLo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sumHi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;inc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sumLo&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="nx"&gt;sumHi&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;sumLo&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sumHi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// impossible&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;inc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantLo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sumHi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// others can't cover this — floor&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantHi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sumLo&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// others take their min — ceiling&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wantLo&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nf"&gt;raiseLo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wantLo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wantHi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nf"&gt;lowerHi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wantHi&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An island clued &lt;code&gt;8&lt;/code&gt; with four neighbours forces a double bridge on every side&lt;br&gt;
(&lt;code&gt;sumHi&lt;/code&gt; is 8, so every &lt;code&gt;wantLo&lt;/code&gt; is 2). A &lt;code&gt;1&lt;/code&gt; next to a single neighbour whose&lt;br&gt;
edge already carries a bridge is done. Everything in between falls out of the&lt;br&gt;
same two lines.&lt;/p&gt;
&lt;h3&gt;
  
  
  No crossing
&lt;/h3&gt;

&lt;p&gt;Because a bridge floats over specific cells, a horizontal edge and a vertical&lt;br&gt;
edge can &lt;strong&gt;share a cell&lt;/strong&gt; — and then they cannot both carry a bridge. The moment&lt;br&gt;
one commits to &lt;code&gt;lo ≥ 1&lt;/code&gt;, the other's ceiling drops to zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;lowerHi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;lowerHi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crossings are precomputed once from the geometry: a horizontal edge's floated&lt;br&gt;
cells all sit in one row and a vertical edge's in one column, so any overlap is a&lt;br&gt;
single cell — a fast set test.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs both rules to a &lt;strong&gt;fixpoint&lt;/strong&gt;, recording the order edges are&lt;br&gt;
pinned to a single value — which is exactly what the demo's &lt;strong&gt;Hint&lt;/strong&gt; replays,&lt;br&gt;
one guess-free bridge at a time.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness — &lt;em&gt;and&lt;/em&gt; connectivity
&lt;/h2&gt;

&lt;p&gt;Plenty of boards need a guess. The backtracking solver branches on the &lt;strong&gt;edge&lt;br&gt;
with the fewest options left&lt;/strong&gt; (a binary &lt;code&gt;hi − lo == 1&lt;/code&gt; choice is ideal), fixes&lt;br&gt;
a value, and lets propagation prune each branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chooseEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestSpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bestSpan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bestSpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtle part is the acceptance test. Degree bounds are &lt;strong&gt;local&lt;/strong&gt; — they know&lt;br&gt;
nothing about whether the whole map holds together. A board can satisfy every&lt;br&gt;
clue, respect every capacity, cross nothing, and still split into two&lt;br&gt;
island-groups that never touch. Those are not valid Hashi solutions, so the leaf&lt;br&gt;
check runs a tiny &lt;strong&gt;union-find&lt;/strong&gt; over the committed bridges and demands a single&lt;br&gt;
component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// disconnected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejecting disconnected leaves is the entire difference between a Hashi solver and&lt;br&gt;
a linear solver. &lt;code&gt;solveAll&lt;/code&gt; enumerates up to a limit; &lt;code&gt;hasUniqueSolution&lt;/code&gt; stops&lt;br&gt;
at two, and the test suite asserts every bundled puzzle stops at exactly one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One test makes the point directly: four corner islands each clued &lt;code&gt;1&lt;/code&gt;. Every&lt;br&gt;
degree-valid assignment is two disjoint bridges — top+bottom, or left+right —&lt;br&gt;
never one component. The solver must return &lt;strong&gt;no&lt;/strong&gt; solution, not a disconnected&lt;br&gt;
one, and it does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles are solver-verified, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Every board is authored as &lt;strong&gt;the solved map, drawn in ASCII&lt;/strong&gt; — islands as &lt;code&gt;O&lt;/code&gt;,&lt;br&gt;
water as &lt;code&gt;.&lt;/code&gt;, and the bridges themselves as the characters that join them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-  single horizontal      =  double horizontal
|  single vertical        "  double vertical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is one bundled board (6×6, 8 islands) as it lives in the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;O=O--O
|.|.."
|.|.."
O.O-O"
.."..O
..O...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clue on each island is &lt;strong&gt;derived&lt;/strong&gt; by summing the bridges drawn into it, so&lt;br&gt;
there is no hand-typed number that could drift out of sync with the answer — and&lt;br&gt;
because every cell holds exactly one character, an authored solution can never&lt;br&gt;
contain a crossing. Rule 3 is enforced by the medium itself.&lt;/p&gt;

&lt;p&gt;A small growth generator authors them: it grows a connected, crossing-free bridge&lt;br&gt;
structure one island at a time (validity by construction), derives the clues,&lt;br&gt;
then keeps the board only if the solver proves those clues admit a &lt;em&gt;single&lt;/em&gt;&lt;br&gt;
solution. The tests re-solve every finished board and assert it recovers exactly&lt;br&gt;
the drawn bridges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sol&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// and it's the drawn map&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same discipline as the Kakuro, Nonogram, Slitherlink and Akari entries: ship your&lt;br&gt;
levels through your solver. The six bundled boards run 4×4 (5 islands) up to&lt;br&gt;
10×10 (20 islands).&lt;/p&gt;
&lt;h2&gt;
  
  
  The UI
&lt;/h2&gt;

&lt;p&gt;The board is one &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;. Islands are clued discs; each neighbour pair gets an&lt;br&gt;
invisible fat hit-line over the water plus the drawn bridge lines (a double is&lt;br&gt;
two parallel strokes, offset perpendicular to the run). Click cycles an edge&lt;br&gt;
0 → 1 → 2 → 0. A player-facing &lt;code&gt;verdict()&lt;/code&gt; mirrors the solver's rules but reports&lt;br&gt;
&lt;em&gt;which&lt;/em&gt; things are wrong — islands over their clue, edges that cross — for live&lt;br&gt;
highlighting, and declares a win only when every clue is met, nothing crosses,&lt;br&gt;
and the same union-find shows one connected map.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/hashi.ts       — pure engine: edge geometry + crossings, bounds-consistency
                     propagation to a fixpoint, union-find connectivity,
                     fewest-options backtracking, uniqueness count
src/hashi.test.ts  — 49 vitest cases
src/puzzles.ts     — 6 ASCII-drawn boards, all generator-made and solver-verified
src/main.ts        — SVG UI: island discs, clickable water, single/double
                     bridges, Hint, animated Solve
tools/generate.mts — growth generator that authors unique boards
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No runtime dependencies; TypeScript throughout. &lt;strong&gt;MIT-licensed&lt;/strong&gt; — clone it, read&lt;br&gt;
the engine, lift the bounds-consistency + connectivity pattern.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/hashiwokakero/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/hashiwokakero/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/hashiwokakero" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/hashiwokakero&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series of 100+ small, sharp, open-source builds by&lt;br&gt;
&lt;a href="https://sen.ltd" rel="noopener noreferrer"&gt;SEN, LLC&lt;/a&gt;. Solver-equipped puzzle #9 — after Kakuro, Akari,&lt;br&gt;
Slitherlink, Nonogram, Sokoban and friends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>constraintsolving</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Solving Kakuro (Cross Sums) with Constraint Propagation — Sum-Combination Domains, Fewest-Candidate Search, and Puzzles Proven Unique</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Sat, 11 Jul 2026 23:30:57 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-kakuro-cross-sums-with-constraint-propagation-sum-combination-domains-fewest-candidate-4mje</link>
      <guid>https://dev.to/sendotltd/solving-kakuro-cross-sums-with-constraint-propagation-sum-combination-domains-fewest-candidate-4mje</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Kakuro&lt;/strong&gt; (cross sums / 加算クロス), the addition logic puzzle, built for the&lt;br&gt;
browser with a &lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Three hinges:&lt;br&gt;
&lt;strong&gt;(1)&lt;/strong&gt; every run turns its clue into a &lt;em&gt;domain&lt;/em&gt; — of all the ways to pick&lt;br&gt;
&lt;em&gt;k&lt;/em&gt; distinct digits that total the clue, the &lt;strong&gt;union of every valid&lt;br&gt;
combination&lt;/strong&gt; is exactly the digits that can appear, so each cell keeps only&lt;br&gt;
what some completion actually uses. Each step is a strict implication, never a&lt;br&gt;
guess. &lt;strong&gt;(2)&lt;/strong&gt; When logic stalls, a backtracking search takes the cell with&lt;br&gt;
the &lt;strong&gt;fewest candidates&lt;/strong&gt;, tries each digit, lets propagation prune, and&lt;br&gt;
&lt;em&gt;proves&lt;/em&gt; the answer is unique. &lt;strong&gt;(3)&lt;/strong&gt; Every bundled board is authored as a&lt;br&gt;
filled digit grid — the solver reads the clues off the answer, so picture and&lt;br&gt;
solution can't drift. Solver-equipped puzzle #8.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/kakuro/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/kakuro/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/kakuro" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/kakuro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fkakuro%2Fassets%2Fscreenshot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fkakuro%2Fassets%2Fscreenshot.png" alt="Kakuro" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and the logic that follows
&lt;/h2&gt;

&lt;p&gt;A Kakuro grid is white cells and shaded clue cells. White cells group into&lt;br&gt;
&lt;strong&gt;runs&lt;/strong&gt; — maximal horizontal or vertical strips of two-or-more cells. The&lt;br&gt;
shaded cell left of a horizontal run carries its &lt;strong&gt;across&lt;/strong&gt; sum; the one above a&lt;br&gt;
vertical run carries its &lt;strong&gt;down&lt;/strong&gt; sum. A solution fills every white cell with a&lt;br&gt;
digit 1–9 so that, for every run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sum&lt;/strong&gt; — its digits total the clue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinct&lt;/strong&gt; — no digit repeats.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both rules become strict deductions — no guessing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sum combinations.&lt;/strong&gt; For a run that still needs &lt;em&gt;k&lt;/em&gt; digits totalling &lt;em&gt;s&lt;/em&gt;,
enumerate every size-&lt;em&gt;k&lt;/em&gt; subset of 1–9 that sums to &lt;em&gt;s&lt;/em&gt;. Their &lt;strong&gt;union&lt;/strong&gt; is
exactly the digits that can appear, so each open cell keeps only digits some
valid completion uses. A pair summing to 3 must be {1,2}; a pair summing to 17
must be {8,9}; a triple summing to 6 must be {1,2,3}.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinctness.&lt;/strong&gt; A digit pinned in one cell is struck from every other cell
in the same run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Digits are &lt;strong&gt;bitmasks&lt;/strong&gt; over 1–9 and a cell's &lt;em&gt;domain&lt;/em&gt; is the set still possible&lt;br&gt;
for it. &lt;code&gt;propagate()&lt;/code&gt; runs both rules to a &lt;strong&gt;fixpoint&lt;/strong&gt;, recording the order&lt;br&gt;
cells were pinned — exactly what the demo's &lt;em&gt;Hint&lt;/em&gt; replays, one guess-free step&lt;br&gt;
at a time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dup&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fixedSum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// run broken&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;comboUnion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remTarget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// sum-combo union&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;              &lt;span class="c1"&gt;// no completion&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;shrink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;comboUnion&lt;/code&gt; walks digits in ascending order, so &lt;code&gt;if (d &amp;gt; rem) break&lt;/code&gt; prunes the&lt;br&gt;
whole tail the moment the smallest remaining digit overshoots — no wasted&lt;br&gt;
branches.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Some boards need a guess. The backtracking solver takes the cell with the&lt;br&gt;
&lt;strong&gt;fewest candidates&lt;/strong&gt; (minimum-remaining-values), tries each digit, and lets&lt;br&gt;
propagation prune. &lt;code&gt;solveAll&lt;/code&gt; enumerates up to a limit; &lt;code&gt;hasUniqueSolution&lt;/code&gt;&lt;br&gt;
stops at two, and the test suite asserts every bundled puzzle stops at exactly&lt;br&gt;
one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Puzzle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;solveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same philosophy as the Sokoban (#286), Nonogram (#287), Slitherlink (#288) and&lt;br&gt;
Akari (#289) entries: ship your levels through your solver.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles are solver-verified, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Each board is authored as &lt;strong&gt;nothing but a filled digit grid&lt;/strong&gt; — &lt;code&gt;#&lt;/code&gt; and the&lt;br&gt;
digits &lt;code&gt;1&lt;/code&gt;–&lt;code&gt;9&lt;/code&gt;. The solver does the rest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sum every run of the solution and write that total onto its head as the
across/down clue.&lt;/li&gt;
&lt;li&gt;Require the resulting clued board to be &lt;strong&gt;unique&lt;/strong&gt; (reject the layout
otherwise).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the answer grid is the single source of truth and the clues are derived from&lt;br&gt;
it — they cannot disagree. The tests re-solve the finished board and assert the&lt;br&gt;
digits match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;solved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;           &lt;span class="c1"&gt;// and it's the authored grid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five bundled boards (5×5 to 8×8, every white cell woven into both an across&lt;br&gt;
and a down run) were found by random-fill search over fixed layouts, kept only&lt;br&gt;
where the derived clues force a single solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Kakuro goes a long way on &lt;strong&gt;fixpoint propagation of sum-combination domains&lt;/strong&gt;,&lt;br&gt;
and when that stalls, &lt;strong&gt;fewest-candidate search&lt;/strong&gt; proves the unique answer. That&lt;br&gt;
makes eight solver-equipped puzzles — Sudoku, Lights Out (GF(2)), the 15-puzzle&lt;br&gt;
(parity + IDA*), Sokoban (pull-BFS), Nonogram (constraint propagation),&lt;br&gt;
Slitherlink (edge propagation), Akari (illumination propagation), and Kakuro&lt;br&gt;
(sum-combination domains + a uniqueness proof) — each wearing a different piece&lt;br&gt;
of mathematics. Hit Solve and watch the grid fill in.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/kakuro/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/kakuro/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/kakuro" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/kakuro&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>constraintsolving</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Solving Akari (Light Up) with Constraint Propagation — Fixpoint Wall/Sight/Illumination Rules, Darkest-Cell Search, and Puzzles Proven Unique</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Fri, 10 Jul 2026 23:22:12 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-akari-light-up-with-constraint-propagation-fixpoint-wallsightillumination-rules-4njj</link>
      <guid>https://dev.to/sendotltd/solving-akari-light-up-with-constraint-propagation-fixpoint-wallsightillumination-rules-4njj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Akari&lt;/strong&gt; (Light Up / 美術館), the illumination logic puzzle, built for the&lt;br&gt;
browser with a &lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Three hinges:&lt;br&gt;
&lt;strong&gt;(1)&lt;/strong&gt; three sound rules propagated to a fixpoint — wall counts,&lt;br&gt;
mutual visibility (no bulb may light another), and illumination (a dark cell&lt;br&gt;
whose last possible light source is one candidate forces a bulb). Each is a&lt;br&gt;
strict implication, never a guess. &lt;strong&gt;(2)&lt;/strong&gt; When logic stalls, a backtracking&lt;br&gt;
search takes the &lt;strong&gt;darkest cell with the fewest candidates&lt;/strong&gt;, tries a bulb,&lt;br&gt;
lets propagation prune, and &lt;em&gt;proves&lt;/em&gt; the solution is unique. &lt;strong&gt;(3)&lt;/strong&gt; Every&lt;br&gt;
bundled board is authored as a bare wall layout — the solver derives both the&lt;br&gt;
clues and the answer key, so picture and solution can't drift. Solver-equipped&lt;br&gt;
puzzle #7.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/akari/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/akari/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/akari" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/akari&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fakari%2Fassets%2Fscreenshot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fakari%2Fassets%2Fscreenshot.png" alt="Akari" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The rules, and why logic suffices
&lt;/h2&gt;

&lt;p&gt;The grid is white cells and walls (black), some walls numbered 0..4. You drop&lt;br&gt;
&lt;strong&gt;light bulbs&lt;/strong&gt; into white cells. A bulb lights its own cell and shoots a beam&lt;br&gt;
down its row and column that stops at the first wall. A solution must satisfy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Illumination&lt;/strong&gt; — every white cell is lit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No mutual sight&lt;/strong&gt; — no bulb sits in another bulb's beam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wall counts&lt;/strong&gt; — a numbered wall has exactly that many bulbs adjacent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each rule becomes a strict deduction — no guessing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wall count&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutual sight&lt;/strong&gt; — a bulb empties every cell in its beam (and a second bulb in
that beam is an immediate contradiction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Illumination&lt;/strong&gt; — a still-dark cell whose only remaining light source (itself,
or a single unknown in its beam) is one candidate forces a bulb there; zero
candidates is a contradiction.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bulbs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// wall over-filled&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;forced&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// a cell can never be lit&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BULB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// its last light source&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs all three to a &lt;strong&gt;fixpoint&lt;/strong&gt;, recording the order cells were&lt;br&gt;
decided — exactly what the demo's &lt;em&gt;Hint&lt;/em&gt; replays, one guess-free step at a time.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Some boards need a guess. The backtracking solver takes the &lt;strong&gt;darkest cell with&lt;br&gt;
the fewest bulb candidates&lt;/strong&gt; (minimum-remaining-values on the illumination&lt;br&gt;
constraint), tries a bulb, and lets propagation prune. There's a neat invariant&lt;br&gt;
that makes this tight: after propagation, every &lt;em&gt;lit&lt;/em&gt; cell has already been&lt;br&gt;
emptied by the mutual-sight rule, so the remaining unknowns are exactly the&lt;br&gt;
still-dark cells. The search only ever branches where light is genuinely missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chooseCell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;whites&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;BULB&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isLit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip already-lit cells&lt;/span&gt;
    &lt;span class="c1"&gt;// count the unknowns that could light i; keep the smallest set&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hasUniqueSolution&lt;/code&gt; enumerates up to two solutions, and the test suite asserts&lt;br&gt;
every bundled puzzle stops at exactly one. Same philosophy as the Sokoban (#286),&lt;br&gt;
Nonogram (#287) and Slitherlink (#288) entries: ship your levels through your&lt;br&gt;
solver.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles are solver-generated, not hand-keyed
&lt;/h2&gt;

&lt;p&gt;Each board is authored as &lt;strong&gt;nothing but a wall layout&lt;/strong&gt; — &lt;code&gt;#&lt;/code&gt; and &lt;code&gt;.&lt;/code&gt;. The solver&lt;br&gt;
does the rest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Solve the bare layout with &lt;em&gt;no&lt;/em&gt; clues; illumination + mutual-sight logic alone
yields a valid bulb arrangement &lt;code&gt;S&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Number &lt;em&gt;every&lt;/em&gt; wall from &lt;code&gt;S&lt;/code&gt;, and require the clued board to be &lt;strong&gt;unique&lt;/strong&gt;
(reject the layout otherwise).&lt;/li&gt;
&lt;li&gt;Greedily blank clues while the solution stays unique, leaving real deductions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the picture (the walls) is the single source of truth, and the answer key is&lt;br&gt;
the solver's own output — they cannot disagree. The tests re-solve the finished&lt;br&gt;
board and assert the bulbs match &lt;code&gt;S&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bulbsFound&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bulbsWant&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// and it's S&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five bundled boards (7×7 to 10×10, all 180°-rotationally symmetric) are&lt;br&gt;
generated this way. The demo's &lt;em&gt;Solve&lt;/em&gt; lights the unique arrangement one bulb at&lt;br&gt;
a time, so the dark room fills with light — the header image is the 10×10&lt;br&gt;
"Gallery" the instant it finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Akari goes a long way on &lt;strong&gt;fixpoint propagation of three sound rules&lt;/strong&gt;, and when&lt;br&gt;
that stalls, &lt;strong&gt;darkest-cell search&lt;/strong&gt; proves the unique answer. That makes seven&lt;br&gt;
solver-equipped puzzles — Sudoku, Lights Out (GF(2)), the 15-puzzle&lt;br&gt;
(parity + IDA*), Sokoban (pull-BFS), Nonogram (constraint propagation),&lt;br&gt;
Slitherlink (edge propagation), and Akari (illumination propagation + a&lt;br&gt;
uniqueness proof) — each wearing a different piece of mathematics. Hit Solve and&lt;br&gt;
watch the room light up.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/akari/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/akari/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/akari" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/akari&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>constraintsolving</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Solving Slitherlink with Constraint Propagation — Fixpoint Clue/Vertex Rules, Closed-Loop Pruning, and Puzzles Proven Unique</title>
      <dc:creator>SEN LLC</dc:creator>
      <pubDate>Fri, 10 Jul 2026 02:27:40 +0000</pubDate>
      <link>https://dev.to/sendotltd/solving-slitherlink-with-constraint-propagation-fixpoint-cluevertex-rules-closed-loop-pruning-500j</link>
      <guid>https://dev.to/sendotltd/solving-slitherlink-with-constraint-propagation-fixpoint-cluevertex-rules-closed-loop-pruning-500j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Slitherlink&lt;/strong&gt;, the single-loop logic puzzle, built for the browser with a&lt;br&gt;
&lt;strong&gt;constraint-propagation solver&lt;/strong&gt; inside. Three hinges: &lt;strong&gt;(1)&lt;/strong&gt; sound local&lt;br&gt;
rules propagated to a fixpoint — a clued cell that already has enough lines&lt;br&gt;
crosses off the rest, and every lattice vertex must finish with loop degree&lt;br&gt;
&lt;strong&gt;0 or 2&lt;/strong&gt;, which forces or forbids edges. Each is a strict implication, never&lt;br&gt;
a guess. &lt;strong&gt;(2)&lt;/strong&gt; When logic stalls, a backtracking search takes over, pruning&lt;br&gt;
any branch where a stray sub-loop closes (a union-find back-edge), and it&lt;br&gt;
&lt;em&gt;proves&lt;/em&gt; the solution is unique. &lt;strong&gt;(3)&lt;/strong&gt; Every bundled puzzle's clues are&lt;br&gt;
derived from a picture (a region mask), and that picture is verified to be the&lt;br&gt;
one and only solution. Solver-equipped puzzle #6.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/slitherlink/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/slitherlink/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/slitherlink" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/slitherlink&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fslitherlink%2Fassets%2Fscreenshot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsen.ltd%2Fportfolio%2Fslitherlink%2Fassets%2Fscreenshot.png" alt="Slitherlink" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The board is edges, not cells
&lt;/h2&gt;

&lt;p&gt;Slitherlink is an R×C grid of cells, some carrying a clue 0..3. You draw segments&lt;br&gt;
on the lattice &lt;strong&gt;edges&lt;/strong&gt; to make one closed loop; a clue counts how many of its&lt;br&gt;
cell's four edges the loop uses. Each edge is &lt;code&gt;LINE&lt;/code&gt;, &lt;code&gt;CROSS&lt;/code&gt;, or &lt;code&gt;UNKNOWN&lt;/code&gt;. Two&lt;br&gt;
rules make it solvable by pure logic — both strict implications, no guessing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clue rule&lt;/strong&gt; — a clued cell whose lines already equal its number crosses off
its remaining edges; if the remainder is exactly what's still needed, they're
all lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vertex rule&lt;/strong&gt; — every lattice vertex must finish with loop degree &lt;strong&gt;0 or 2&lt;/strong&gt;.
Two lines cross off the rest; one line with a single unknown forces that
unknown to a line (to reach degree 2); a lone unknown at an empty vertex is
crossed (one edge can't reach degree 2).
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* cross the rest */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* that edge is a LINE */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* that edge is a CROSS */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;propagate()&lt;/code&gt; runs these to a &lt;strong&gt;fixpoint&lt;/strong&gt;, recording the order edges were&lt;br&gt;
forced. That's exactly what the demo's &lt;em&gt;Hint&lt;/em&gt; replays — one guess-free deduction.&lt;/p&gt;
&lt;h2&gt;
  
  
  When logic stalls: search that proves uniqueness
&lt;/h2&gt;

&lt;p&gt;Sparse boards need a guess. The backtracking solver picks an unknown edge&lt;br&gt;
(preferring one that must continue a half-built path), tries both values, and&lt;br&gt;
lets propagation prune. The big accelerator is &lt;strong&gt;premature-loop pruning&lt;/strong&gt;. Since&lt;br&gt;
every vertex has degree ≤ 2, the drawn segments are always disjoint paths and&lt;br&gt;
cycles — so a union-find &lt;strong&gt;back-edge&lt;/strong&gt; (both endpoints already connected) means a&lt;br&gt;
loop just &lt;em&gt;closed&lt;/em&gt;. A closed loop can't grow, so unless it's already the whole&lt;br&gt;
answer, the branch is dead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isSolved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;puzzle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;solutions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;crossUnknowns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasLineCycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a stray sub-loop closed — dead end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hasUniqueSolution&lt;/code&gt; enumerates up to two solutions, and the test suite asserts&lt;br&gt;
every bundled puzzle stops at exactly one. Same philosophy as the Sokoban (#286)&lt;br&gt;
and Nonogram (#287) entries: ship your levels through your solver.&lt;/p&gt;
&lt;h2&gt;
  
  
  Puzzles: the picture is the single source of truth
&lt;/h2&gt;

&lt;p&gt;Each puzzle is authored as a &lt;strong&gt;region mask&lt;/strong&gt; — a simply-connected blob of &lt;code&gt;#&lt;/code&gt;&lt;br&gt;
cells. A simply-connected region's boundary is, by construction, one closed loop.&lt;br&gt;
Clues are derived from the mask (an edge is a line iff it separates inside from&lt;br&gt;
outside), so clue and picture can't drift apart. The least-constraining clues&lt;br&gt;
(the 2s) are then blanked greedily, but &lt;em&gt;only while the solution stays unique&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasUniqueSolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toPuzzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// exactly one answer&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foundLines&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedLines&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// and it's the picture&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five bundled puzzles (diamond, plus, heart, S, arrow) are built this way. The&lt;br&gt;
demo's &lt;em&gt;Solve&lt;/em&gt; walks the unique loop in order and draws it, so the picture rises&lt;br&gt;
out of the board — the header image is a heart the instant it closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Slitherlink goes a long way on &lt;strong&gt;fixpoint propagation of sound local rules&lt;/strong&gt;, and&lt;br&gt;
when that stalls, &lt;strong&gt;closed-loop-pruned search&lt;/strong&gt; proves the unique answer. That&lt;br&gt;
makes six solver-equipped puzzles — Sudoku, Lights Out (GF(2)), the 15-puzzle&lt;br&gt;
(parity + IDA*), Sokoban (pull-BFS), Nonogram (constraint propagation), and&lt;br&gt;
Slitherlink (edge propagation + a uniqueness proof) — each wearing a different&lt;br&gt;
piece of mathematics. Hit Solve and watch a single loop close.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://sen.ltd/portfolio/slitherlink/" rel="noopener noreferrer"&gt;https://sen.ltd/portfolio/slitherlink/&lt;/a&gt;&lt;br&gt;
📦 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sen-ltd/slitherlink" rel="noopener noreferrer"&gt;https://github.com/sen-ltd/slitherlink&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>constraintsolving</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
