◧Puzzle15
The 4×4 sliding-tile puzzle written twice in Rust — once as a state machine you can play and search with BFS, and once as a bit-vector transition system whose next-state logic is a mux tree a solver can reason about.
Timeline
November 2024
Team
Solo build
Role
Rust & circuit modelling
Skills
Rust, Formal Methods, Digital Logic
Built with
LONG STORY SHORT
I built the same sliding puzzle twice — once as Rust you can play, once as a circuit — and the second one taught me what a rule actually costs in hardware.
The 15-puzzle is fifteen numbered tiles in a 4×4 frame with one square left empty. You slide a neighbouring tile into the gap, over and over, until the board is ordered. It is the kind of problem that looks like a toy right up until you try to write down its rules precisely.
This repo makes you write them down twice. The first half, lib.rs, is a plain Rust model: a board, four moves, a parser, and a breadth-first search that finds the shortest sequence between any two positions. The second half, circuits.rs, throws that away and asks for the same game as a transition system — bit-vector registers and next-state functions, built with patronus, the kind of representation a model checker consumes.
It came to me as an exercise scaffold: a fork, a file of failing tests, and an instruction to work through them in order because each one builds on the last. What makes it worth writing up is not the puzzle. It is that the two halves solve an identical problem and disagree completely about what a rule is.
The board, and where its numbers come from
The state is one fixed-size array: [[Option<u8>; 4]; 4]. No heap, cheap to copy — which matters later, because the search clones boards by the thousand. The empty square is not a magic 0; it is None, so the type system knows an empty square is not a tile you can read.
The layout is worth a moment. Rather than typing out sixteen assignments, I read three known cells off the finished grid, wrote them as three simultaneous equations, and solved for the coefficients — the derivation is still sitting in the source as a comment. What comes back is one expression that fills the whole board.
The closed form
tile(x, y) = 4y + x + 1
Read off three known cells, solve the simultaneous equations, and the whole board falls out of one expression — the highlighted cell is 4(1) + 2 + 1 = 7.
The type
[[Option<u8>; 4]; 4]
Fixed size, no heap, cheap to copy — which matters when the search clones thousands of boards. The hole is not a sentinel number; it is None, so the type system knows an empty square is not a tile.
The one thing to keep straight is that x is the column and y is the row, so the array is indexed tile_array[x][y] — and Display transposes it back on the way out, printing row by row so the string matches how a human reads a grid.
Four moves, named for the tile
The Move enum is where I got caught first, and where reading the tests carefully paid for itself. A variant is named for what the tile does, not what the hole does. LeftToRight does not move the hole to the right. It takes the tile sitting to the left of the hole and slides it rightward into the gap — so the hole ends up one column further left.
Once that clicks, perform_move is short: find the None, check the bound in the direction the hole is about to travel, swap. If the bound fails, the move returns false and the board is untouched.
Move::LeftToRight
The tile to the left of the hole slides right; the hole moves to x − 1.
returns false when x == 0
Move::RightToLeft
The tile to the right of the hole slides left; the hole moves to x + 1.
returns false when x == 3
Move::TopToBottom
The tile above the hole drops down; the hole moves to y − 1.
returns false when y == 0
Move::BottomToTop
The tile below the hole rises up; the hole moves to y + 1.
returns false when y == 3
perform_moves then plays a whole slice and returns how many moves succeeded, not whether all of them did. That is a deliberately forgiving contract — an illegal move in the middle of a sequence is skipped, not fatal — and the tests lean on it: a five-move sequence that returns 3 is the assertion, not a failure.
Parsing a board without trusting it
from_str reads the same text Display writes, which sounds trivial and isn't. A row splits on | into six fields, not four: the delimiters at each end produce empty strings that have to be stepped over. Four spaces mean the hole. Anything else has to parse as a number and land in range.
The real work is the last gate. After sixteen cells are read, the whole board goes into a HashSet and the count has to be exactly 16 — which rejects a duplicated tile and a second hole with the same check, because two Nones collapse into one entry just like two 7s do.
Every one of those rejects is a fixture in the test file. Writing the failing cases turned out to be more instructive than writing the happy path: it forced the question of what "a valid board" actually means, rather than what the parser happened to accept.
Finding the shortest path, not just a path
find_shortest_path walks from one board to another. The interesting constraint is in the doc comment: it might run forever if no path exists, so the guarantee has to come from the traversal itself rather than from a bound on the work.
It is breadth-first, and the reason it returns a minimal path is entirely structural. The frontier is strictly first-in-first-out, so states are reached in nondecreasing order of depth, and the first time you touch a state is also the cheapest way to have touched it. Marking each state as seen at enqueue time rather than at visit time keeps duplicates out of the queue instead of merely ignoring them later.
Frontier
FIFO
insert(0) in, pop() out
Seen
HashSet
marked on enqueue, not on visit
Branching
2 – 4
corner, edge, interior
Reachable
16! / 2
10,461,394,944 boards
Two things I would change if this were more than an exercise. The frontier is a Vec used as a deque — pop() from the back, insert(0, …) at the front — and that front insert shifts the whole buffer, so it is O(n) per push where a VecDeque would be O(1). And with 16!/2 ≈ 10.5 billion reachable boards, plain BFS is fine for the shallow cases the tests ask for and hopeless for a scrambled board; that is where an A* with a Manhattan-distance heuristic would belong.
What the search does give you for free is GameState's Hash and Eq implementations doing real work. The board being a small, ordinary, comparable value is what makes the visited set possible at all.
The turn: the same game, as a circuit
Here the exercise changes shape. circuits.rs asks for the puzzle again — but as a transition system, the representation hardware verification tools actually work on. You declare symbols, give each one an initial value and a next-state expression, and you have described a circuit rather than a procedure. patronus can then serialise it, simulate it, or hand it to an SMT solver.
The gap between the two halves is the whole point, and it is not about syntax:
Software model
lib.rs
A board you mutate
- State
- One [[Option<u8>; 4]; 4] owned by a GameState.
- A move
- Scan for None, bounds-check, swap two cells.
- Illegal
- perform_move returns false and nothing changes.
- Time
- Sequential — one swap happens, then the next.
Circuit model
circuits.rs
A board that recomputes itself
- State
- Sixteen bv<4> registers, 0 standing in for the hole.
- A move
- A 2-bit input; all 16 registers evaluate at once.
- Illegal
- Every mux selects 'unchanged' — nothing to reject.
- Time
- Parallel — one clock edge advances the whole board.
In Rust, a move is one swap, and I find the hole by scanning for it. A circuit cannot scan. There is no loop and no "then"; there is only what every register evaluates to on the same clock edge. So the question stops being "which two squares do I swap?" and becomes "for each of the sixteen squares independently, what does it become?" — and the answer for most of them, most of the time, is the same thing it already was.
None of that machinery is mine. The exercise hands you three crates and expects the modelling to happen in them:
patronus
The transition-system IR: an expression context, states with init/next functions, inputs, a serialiser, and the interpreter the tests step through.
0.22 · Context · TransitionSystem · Interpreter
baa
Bit-vector arithmetic. Every constant on the board is a BitVecValue, so widths are checked rather than assumed.
0.14 · BitVecValue · WidthInt
easy-smt
The SMT bridge patronus hands a system to when a property has to be proven rather than simulated.
0.2 · the road past simulation
std only, otherwise
The game model leans on arrays, Option, and HashSet — the search needs Hash + Eq on GameState and nothing more.
Rust 2021 · no unsafe
Learning the primitives on three counters
The file walks you in through counters, and the progression is well chosen. The first is given: a register that adds one to itself forever. The second has to stop at a configurable maximum. The third gains an en input that decides whether it ticks at all.
The hint that unlocks it is that ctx.bv_ite — pick a or b based on a condition — is a multiplexer. Which means the second counter is not "an if-statement in a circuit"; the comparator and the mux are the circuit, and the if was only ever a way of talking about them.
[next] add(count, 2'b01)
The reference design: one adder, wraps at 2ʷ. Nothing decides anything.
[next] ite(ugte(count, 32'x00000007), 32'x00000007, add(count, 32'x00000001))
A comparator feeds a mux. This is the moment an if-statement becomes a piece of hardware.
[next] ite(en, ite(ugte(count, 32'x0000007b), 32'x0000007b, add(count, 32'x00000001)), count)
An input gates the whole thing. Nesting the muxes is how control flow is written in a circuit.
Those expressions are the real output of the test run — patronus serialises each system, so you can read back the thing you just described and check it says what you meant. Watching a nested ite appear where I had written nested conditionals is the moment the mental model flipped for me: control flow in hardware is structure, not sequence. The enabled counter passes 20,000 simulated cycles against a reference count, toggling the enable every other one.
Sixty-four bits, sixty-four muxes
The board becomes sixteen bv<4> registers named pos_x_y, with 0 standing in for the hole — four bits is exactly enough for 0–15, so the encoding is tight rather than convenient. The move is a 2-bit input decoded into four one-hot conditions, and is_empty for each square is just a comparison against zero.
Then the next-state function. For one square, it is a chain of four muxes — one per direction — each defaulting to "hold" and only overriding when its direction is selected, the square is in bounds, and the neighbour it would trade with is the hole:
Repeated for all sixteen squares: 64 bits of state, 64 muxes, and one 2-bit move input decoded into four one-hot conditions.
That structure is repeated sixteen times, and the shape of the result is the lesson. In software, an illegal move is something you detect and reject. Here there is nothing to reject: an illegal move is simply a set of select conditions that all evaluate false, and every register quietly holds its value. The bounds check isn't a guard clause — it's a wire that never asserts.
Where it stands
The software half is done and green. The circuit half is where the work stopped, and I would rather show that accurately than tidy it up after the fact.
Board, display, parsing, moves, equality, and shortest path — the whole software model.
The reference free-running counter, checked against its serialised form.
Saturating at 7 over 16 simulated cycles.
20,000 cycles with the enable toggling, against a reference count.
Panics building the L→R mux for x = 0 — the arms are constructed eagerly, so pos_to_index(x − 1, y) underflows before the guard can matter.
test result: FAILED. 4 passed; 1 failed; 0 ignored
The failure is not mysterious, and it is a genuinely Rust-flavoured mistake. Each mux arm is built inside a closure that patronus evaluates eagerly — it is constructing an expression graph, not deferring a branch. So even though the guard correctly refuses to read a neighbour when x == 0, the other arm still calls pos_to_index(x - 1, y) on an unsigned x, and 0 - 1 panics before the guard is ever consulted. That is precisely the "overflow error" the last commit is named after.
There is a second problem queued up behind it: where the guard falls back it yields c.zero(4), a four-bit zero, and that gets and-ed with a one-bit move condition. Mismatched widths — the same class of bug, caught by the type system of the bit-vector library rather than by Rust's.
The fix is to hoist the neighbour lookup out of the closure so the index is only computed when it exists, and to make the fallback a one-bit false. What I would build on top, given the time, is the thing the easy-smt dependency is sitting there for: stop simulating single moves and start proving properties — that tile values are a permutation at every step, that exactly one square is ever empty, that the circuit and the Rust model agree for every move from every reachable board.
Why I built it this way
- Read the tests before writing the code. The move names, the six-field row format, the "count the successes" contract — none of that is stated anywhere else. The test file was the specification.
- Let the type system carry the hole.
Option<u8>instead of a sentinel means an empty square can never be mistaken for a tile, and it makes the uniqueness check catch two holes for free. - Get the guarantee from the structure. BFS returns a minimal path because the frontier is FIFO, not because anything measures or compares lengths afterwards.
- Build the second model to find out what the first assumed. Writing the puzzle as a circuit exposed every place the Rust version quietly relied on doing one thing at a time.
- Report the state honestly. Four tests pass, one fails, and I can name the line and the fix. An exercise you can diagnose is worth more than one you can only claim.
NEXT UP …


