An exact, provably move-optimal solver for the Unflip puzzle, with a step-by-step walkthrough printer.
Unflip is played on an N×N board of black and white tiles. A move selects any k×k square (2 ≤ k ≤ N) and flips every tile inside it — white becomes black, black becomes white. The board is solved when every tile is white, and the goal is to get there in the fewest possible moves.
This solver finds that minimum — not a heuristic answer, a proven one — and prints each move as an annotated board card. Boards up to 13×13 (650 candidate moves) solve in well under a second for almost every level.
Step 1 of 3 — flip the 2×2 square covering rows 2–3, cols 3–4:
1 2 3 4 5 6 7
1 · · · · · · ·
┌───┐
2 · ·│■ ■│· · ·
3 · ·│· ·│■ · ·
└───┘
4 · · ■ ■ ■ · ·
...
· is a white (solved) tile, ■ is black. The flip square is outlined and highlighted
in color on ANSI terminals.
| File | Purpose |
|---|---|
unflip.py |
Front end: loads levels, runs the solver, renders step cards, verifies solutions |
unflip_solver.cpp |
C++17 search core — exact optimal, multithreaded |
daily_levels_book.json |
159 daily levels (4×4 – 10×10, with par) |
main_levels_book.json |
120 main levels (4×4 – 9×9, par on all but one) |
weekly_challenges_book.json |
25 weekly challenges (9×9 – 13×13, no par given) |
python unflip.py daily 5 # solve daily level 5, print step cards
python unflip.py weekly 20 # solve weekly challenge 20 (13×13)
python unflip.py main 1 # solve main level 1
python unflip.py daily --list # list the levels in a book
python unflip.py weekly --all # solve every level, print a summary table
python unflip.py board.txt # solve a raw text board (0/1 or 1/2 digits)Options: --compact (move list only, no boards), --ascii (no Unicode glyphs),
--no-color (no ANSI colors).
After printing a solution, unflip.py re-applies every move to the original board and
checks that all tiles are white before declaring success — the output is verified, not
just trusted.
The C++ core is a standalone program that reads a board from stdin or a file:
Get-Content board.txt | .\unflip_solver.exe
.\unflip_solver.exe board.txt --threads 8
.\unflip_solver.exe board.txt --target either| Flag | Meaning |
|---|---|
--target 0 |
finish with every tile color 0 — the game's goal (default) |
--target 1 |
finish with every tile color 1 |
--target either |
race both targets bound-by-bound; optimal over both |
--threads T |
worker threads (default: all logical cores) |
Input is N lines of N digits, either 0/1 or 1/2 (the latter is mapped to 0/1).
Output is a small line protocol that unflip.py parses (this is the real output for the
included board.txt, a 5×5 board with two stray black tiles that nonetheless needs
8 moves):
TARGET 0 # which input color the board ends as
MOVES 8 # optimal move count
1 1 2 # one move per line: row col size (0-based, top-left corner)
1 1 3
0 3 2
0 0 3
0 0 5
2 2 2
2 2 3
3 0 2
NODES 65 # search nodes expanded
TIME 0.002 # seconds
Progress lines (bound 7 exhausted, nodes=52) go to stderr as each depth is ruled out.
g++ -O3 -march=native -std=c++17 -o unflip_solver.exe unflip_solver.cppNo dependencies beyond a C++17 compiler. If the executable is missing, unflip.py
builds it automatically with g++ on first use.
The size of the raw move space is what makes this puzzle hard: a 13×13 board has 650 distinct flip squares, any subset of which could be a solution, and each move touches up to 169 tiles. The solver's core idea is a change of representation that makes every move local — touching exactly 4 points instead of up to k² tiles — and then an IDA* search with strong pruning over that compact domain.
Let t[i][j] be 1 for each tile that still needs to be flipped (the black tiles).
Define the 2D XOR-difference grid on the (N+1)×(N+1) lattice of tile corners:
D[i][j] = t[i][j] ^ t[i-1][j] ^ t[i][j-1] ^ t[i-1][j-1]
(out-of-range cells count as 0). This is a discrete second difference over GF(2), and it has two properties that drive everything else:
- Moves become 4-point toggles. Flipping a k×k square at (r, c) toggles a solid
k² block of
t, but in the difference domain the interior cancellations are exact: only the four corner pointsD[r][c],D[r][c+k],D[r+k][c],D[r+k][c+k]change. - Solved ⇔ all zero. The transform is invertible (
tis the 2D prefix-XOR ofD), soDis all-zero exactly when no tile needs flipping.
A "flip 25 tiles" operation is now "toggle 4 points", and the goal is "clear the grid". The whole (N+1)² ≤ 196-point grid fits in a 256-bit bitboard (four 64-bit words), so applying a move is 4 XORs and the solved test is an OR of four words.
The search is iterative-deepening A*: try to solve within 0 moves, then 1, then 2, … The first depth that yields a solution is optimal by construction, because every shallower depth was exhausted first.
At each node the solver does not branch over all 650 moves. It takes the first
remaining 1-point of D in row-major order. Any solution must clear that point, and a
move can only touch it if the point is one of the move's four corners — so the branch
set is just the moves having that corner, a small fraction of the total. Candidates are
ordered by how many current 1-points their four corners clear (4 down to 1), so the most
productive moves are tried first. Since moves are involutions and order is irrelevant
over GF(2), each move is also used at most once along any search path.
Each move contributes exactly one column pair {c, c+s} (with s ≥ 2) to each of 2 rows
of D, and one row pair to each of 2 columns. Geometry constrains which pairs a line can
receive: row i can only host pair distances up to max(i, N−i), because the square
must fit on the board.
At startup, a BFS over line bitmasks precomputes, for every possible line state and distance limit, the minimum number of pairs whose XOR clears that line. From these per-line costs come three lower bounds on the moves still required:
ceil(Σ row costs / 2)— every move feeds exactly 2 rows,ceil(Σ column costs / 2)— likewise for columns,- the largest single-line cost — a move adds at most one pair to any line.
Their maximum is admissible, and it is maintained incrementally: applying or undoing a move re-evaluates only the 2 rows and 2 columns it touches (the transposed grid is kept in a second bitboard so column lookups are as cheap as row lookups). Any branch whose bound exceeds the remaining move budget is cut immediately.
A transposition table shared by all threads records, for each difference state seen, the largest remaining-move budget that already failed from it. Reaching the same state with an equal or smaller budget is pruned without re-searching — this is what removes the massive duplication IDA* otherwise suffers when different move orders reach the same position.
Each entry is a single 64-bit word — a 59-bit hash fingerprint plus the failed budget in the low 5 bits — so plain atomic loads and stores stay consistent across threads with no locks. Buckets hold two slots (depth-preferred and always-replace), and the state hash is a splitmix64 mix of the four bitboard words. The table is 256 MB (2²⁴ buckets) on boards of 9×9 and up, 16 MB below that. Results are only stored when the search genuinely exhausted the subtree — a search aborted because another thread found a solution proves nothing and is not recorded.
Within each depth iteration, the root's candidate moves are distributed across a thread
pool via an atomic work-stealing counter. Each worker keeps its own search state; the
transposition table is the only shared structure. The first thread to find a solution
sets a shared flag and the rest wind down. With --target either, both uniform-color
goals are raced bound-by-bound, so the answer is optimal across both finishes.
unflip.py is the human-facing layer. It loads a level from one of the JSON books (or a
raw text file), converts the tiles to the solver's 0/1 input (in the books, value 1 is
the goal color and 2 needs flipping), invokes unflip_solver.exe --target 0 as a
subprocess, and parses the move list. It then renders each step as a labeled board card
with the flip square outlined, re-applies all moves to verify the final board is uniform,
and — with --all — checks every solved level's move count against the book's par.
All solutions are independently verified by replaying the moves on the original board.
- 159/159 daily levels solved optimally, every move count equal to par, in ~2 s total (slowest single level ~10 ms).
- 120/120 main levels solved optimally.
- 25/25 weekly challenges (9×9 – 13×13, optima up to 25 moves, not known beforehand) solved optimally; all but two levels in under ~1 s. The two hardest (24-move optima) take ~7 s and ~38 s on a 20-thread machine. For comparison, the first of those did not finish in over 4 minutes with a single-threaded prototype using a weaker bound and no transposition table.
Weekly Challenges — solving all 25 levels
lvl board par moves time title
1 10x10 - 12 0.001s Weekly #1
2 10x10 - 18 0.021s Weekly #2
3 11x11 - 18 0.032s Weekly #3
4 11x11 - 19 0.040s Weekly #4
5 11x11 - 17 0.264s Weekly #5
6 11x11 - 24 6.727s Weekly #6A
7 11x11 - 16 0.002s Weekly #6B
8 10x10 - 17 0.005s Weekly #7
9 10x10 - 19 0.042s Weekly #8
10 10x10 - 13 0.003s Weekly #9
11 10x10 - 18 0.005s Weekly #10
12 10x10 - 12 0.001s Weekly #11
13 9x9 - 13 0.006s Weekly #12
14 9x9 - 17 0.072s Weekly #13
15 12x12 - 14 0.006s Weekly #14
16 12x12 - 24 37.840s Weekly #15
17 11x11 - 25 0.001s Weekly #16
18 12x12 - 14 0.006s Weekly #17
19 12x12 - 11 0.002s Weekly #18
20 11x11 - 19 0.273s Weekly #19
21 13x13 - 14 0.036s Weekly #20
22 11x11 - 18 0.009s Weekly #21
23 11x11 - 13 0.011s Weekly #22
24 11x11 - 18 0.003s Weekly #23
25 12x12 - 17 0.003s Weekly #24
total solver time: 45.41s
Timings measured on a 20-thread desktop CPU; node counts and times are printed per run.
- The game's only winning state is all-white. White is tile value
1in the JSON books, which maps to0after the front end's conversion, hence--target 0. - With multiple threads, the specific optimal solution returned can vary between runs (whichever thread wins the race reports its line), but the move count is always the proven minimum.