Tobias Reithmeier

Blog

Building Logic Puzzles With Exactly One Solution

Making a puzzle is easy. Proving it has only one solution is the actual work. Here is how that works for the Logic Squares, and where I got it wrong twice.

A logic puzzle with two solutions is not a logic puzzle. Sooner or later you reach a cell where two digits fit equally well, and from there on you are guessing. So no puzzle of mine goes out until its uniqueness has been checked. That was true for my Sudoku app, and it is true for the four games in Logic Squares.

Four puzzles, one grid

All four are played on a square grid. What sets them apart is the kind of clue.

  • Futoshiki: a Latin square, meaning every digit from 1 to n appears exactly once in each row and column. The < and > signs between neighbouring cells say which one is larger.
  • Skyscrapers: another Latin square, but each digit is the height of a building. The numbers on the edge say how many buildings you can see from there. Shorter ones hide behind taller ones.
  • Magic square: the numbers 1 to 9 or 1 to 16, each used once. Every row, every column and both diagonals add up to the same total.
  • Calcudoku, also known as KenKen: a Latin square divided into cages. The cells of each cage combine to a target number using addition, subtraction, multiplication or division.

The magic square is the odd one out. It is not a Latin square, so it needs its own search. The other three share one solver.

Built in advance, played in the browser

The puzzles are not created while you play. A Python script, generate-quadrate.py, builds them ahead of time and writes them to four JSON files. Four games, four levels, five puzzles per level: 80 puzzles.

The browser runs neither a generator nor a solver. The script on the page draws the board and takes input. Whether a digit is right is checked against the solution stored in the bank. A rulebook in the browser would be a second source of truth next to the generator, and two sources of truth drift apart sooner or later. It also means the computing happens once, on my machine, not on every visit.

Every puzzle has an address

Generation is random but reproducible. Each puzzle gets its seed from its address in the bank: game, level and number.

def seed_for(variant, difficulty, number):
    key = f"{variant}/{difficulty}/{number}"
    value = 0
    for ch in key:
        value = (value * 131 + ord(ch)) & 0xFFFFFFFF
    return value

That has two consequences. A second run produces the same files, byte for byte. And when I rework one level, every other puzzle stays as it was, because its seed does not depend on the rest of the run.

With --check, the script recomputes everything and only reports whether a bank would change. The last answer was: none. On my machine that takes just under a minute and a half. That is why the script is not part of every deploy. It only runs when the difficulty changes.

The proof: count to two

Uniqueness is checked by a solver that counts solutions, but stops at two. The second solution is proof enough. Looking for a third only costs time.

The solver fills the grid row by row, starting top left. For each cell it tries the digits still free in that row and column, and steps back as soon as a rule is broken. The technique is called backtracking. The order is deliberate: when a cell gets filled, its left and upper neighbours are already fixed. A Futoshiki sign can be checked right away, and a Skyscrapers row is complete exactly when its last cell is set. No rule ever has to look past an empty cell.

The three Latin-square games each plug their extra rule into this solver. For Skyscrapers that includes a partial check: if more buildings are already visible from the left than the edge clue allows, the row can no longer work out. The count only goes up. On a 6×6 that prunes most of the search tree.

Working down from the full set

Futoshiki, Skyscrapers and the magic square follow the same pattern. Start with a random finished solution. Add every clue that matches it: all the inequalities, all the edge clues, or for the magic square all the numbers. Then thin. Each clue is removed on trial. If the puzzle stays unique, it stays out, otherwise it goes back.

Going the other way, adding clues one at a time until the puzzle becomes unique, often ends with one too many. The last clue makes it unique, an earlier one is now redundant, and nobody checks again. Thinning from the full set until nothing more can go leaves no clue the puzzle could do without.

Trap 1: all clues does not mean unique

Both this trap and the next one cost me a rewrite. The obvious assumption is that a puzzle with every clue in place is unique, so thinning can simply start there. It is not. A 4×4 Skyscrapers grid with all sixteen edge clues regularly has four to six solutions. An edge clue tells you how many buildings stick out, not which ones. Futoshiki has the same problem: the inequalities describe the order within a row or column, not the values, and often allow two different squares.

The nasty part is that nothing looks broken. If the puzzle is ambiguous before thinning, every single removal fails and the puzzle keeps all its clues. It just looks very generous, and nothing in the output tells you the thinning never kicked in.

The fix is called fill_until_unique. Before any thinning, it places digits from the solution into the grid until exactly one solution is left:

def fill_until_unique(n, solution, givens, spots, count):
    placed = []
    while count() != 1:
        if not spots:
            raise RuntimeError("kein eindeutiges Rätsel trotz voller Vorgaben")
        index = spots.pop()
        r, c = divmod(index, n)
        givens[r][c] = solution[r][c]
        placed.append(index)
    return placed

(The error message is German: "no unique puzzle despite full givens".) The loop is guaranteed to stop: once every cell is given, there is exactly one solution. Afterwards those digits are the first to be thinned out again, as far as possible, and only then the signs or edge clues. A Skyscrapers puzzle is meant to be solved from the edge, and every digit already in the grid takes that away. Futoshiki works the same way: the easy level should show more signs, not more digits.

Trap 2: sums that count twice

A magic square has no "each digit once per row" rule like the other three, only sums. So for every row, column and diagonal it has started, the search checks whether the target can still be reached with the numbers left. It adds up the smallest and the largest free numbers, one per open cell. That is the bound. For 3×3 the target is 15, for 4×4 it is 34. At the end of a row the search does not guess, it works out the missing value.

The bug was in the running totals. At first I seeded them from the grid, givens included. A given to the right of the current cell was then already in the total, while the bound still counted its cell as open. It counted twice, and the search threw away paths that were perfectly valid. Now the totals advance with the search: the row total only covers the cells to the left of the current one. A given number is still blocked for every other cell right away, even before the search reaches it.

Difficulty lives in one table

How hard a puzzle gets comes down to a single table called PLAN. For each game and level it sets the grid size and keep, the number of clues at which thinning stops. Zero means all the way down to the minimum.

  • Futoshiki runs from 4×4 to 6×6. The easiest level keeps 12 clues, the Advanced level on 5×5 gets by with 9 to 12.
  • Skyscrapers stays at 4×4 for the first two levels. The second thins to the minimum and ends up with 4 or 5 clues.
  • Magic square stays at 3×3 and 4×4. From 5×5 on, the sum barely narrows the search, solving turns into trial and error, and generating a single square takes longer than a whole bank of the other games.
  • Calcudoku grows from 4×4 with cages of at most two cells to 6×6 with cages of up to four.

For the magic square, the minimum is deliberately not the goal. A 4×4 can be thinned to three givens and still be unique, but then it can only be solved by brute search, which is exactly what a logic puzzle is supposed to spare you. So the two 4×4 levels stop at nine and six givens.

Calcudoku breaks the pattern. The cages cover the whole grid, so dropping one would leave a hole. Instead the script rerolls the cages, up to 400 times. After 250 failures the largest cage may have one cell fewer, because small cages give more away. A few rules keep the cages readable. Subtraction and division only appear in two-cell cages, since with three the order would be undefined. "Divide by 1" is out, because it gives nothing away. So are products above 200, which allow almost any combination.

Why the script looks so plain

Anyone who knows Python will find parts of it clunky. That is on purpose: plain recursion, lists and bitmasks, no Python idioms. The script is the reference for Quadrivium, my iOS collection of the same four games, on the App Store since August 2026. The algorithms should carry over to Swift without being reinvented. Changing the script means changing the reference.

What differs is the division of labour. The browser page plays a fixed bank of 80 puzzles, while the app generates every puzzle fresh when you start it. The standard is the same: solvable without guessing.

Sources

  • Wikipedia: Futoshiki, rules of the inequality puzzle
  • Wikipedia: Latin square, the basic rule shared by three of the four games
  • Wikipedia: Magic square, structure and magic constant
  • Wikipedia: Backtracking, the search technique behind generator and solver
  • Conceptis Puzzles: Skyscrapers rules, rules of the Skyscrapers puzzle
  • Simon Tatham: Towers, Skyscrapers from another generator, which also places given towers in harder or larger puzzles
  • Wikipedia: KenKen, cage arithmetic puzzle, also known as Calcudoku