SplitCircuit: a puzzle prototype with a solver and auto-player
In early March I built SplitCircuit, a small seed-based puzzle prototype in Unity. Ten commits in about 29 hours took it from an empty repository to:
- a playable game loop with a pure-data core and per-subsystem seeds,
- a brute-force solver that plays the game without a renderer,
- a seed scanner and edit-mode tests that run the solver twice and require identical answers,
- an endless runner that pre-solves random puzzles and plays them back through the real game, visibly, forever.
That last piece is live as a WebGL build. You can watch it play itself here: SplitCircuit endless auto-run demo.
| Commit | What landed | Size |
|---|---|---|
6bbd3a2 | Proof of concept: board, cut, nudge, settle, circuit check | 154 files, +11,051 |
6a8d79e | Solver, playback, seed scanner, test infrastructure | 48 files, +2,679 / -51 |
6dba7d7 | Playback solves round by round against the live board | 3 files, +147 / -110 |
53e7a66 | Endless runner, four presets, one-click WebGL build per preset | 21 files, +1,725 / -539 |
The code was written by AI coding agents working from my design and rules file; I set the constraints, reviewed each step and decided what came next. This post is about the design decisions, because they are what made the auto-player cheap to build.
The game
A polyomino spawns in the center of a grid. You cut it into four fragments with one vertical and one horizontal slice. The fragments slide outward, one toward each edge, one grid step per tick. Between ticks you can nudge them sideways, but the controls are inversely coupled: pressing Up moves the right fragment up and the left fragment down. A fragment stops when it hits the edge or material that settled in an earlier round, Tetris-style, and becomes permanent conductive board.
After every settle the game runs a breadth-first search from the Start node. If it reaches the End node, the circuit is complete and you win. If a fragment cannot advance even one step, or the center is too crowded to spawn the next shape, you lose.
Two rules that paid for everything else
The project's rules file, written before any solver existed, set two constraints:
- Pure-data core.
BoardState,CutResult,FragmentStateandRouteResultare plain C# classes with noMonoBehaviour. The renderer and the game manager sit on top. - Deterministic seeds. All generation and simulation uses
System.Randomwith explicit seeds, neverUnityEngine.Random. Shape, color, Start/End placement, relays and pre-placed wires each have their own seed, so changing one does not reshuffle the others. Later rounds derive their shapes from the shape seed and the round number.
Those two choices are why a solver was a day's work and not a rewrite. The solver can create a board, apply a cut, run the fragment simulator and check the circuit thousands of times without touching a scene. And because the next shape comes from a seeded generator, the solver can spawn exactly the shape the real game would spawn next. The comment in the solver says so directly: "Spawn next shape (mirroring GameManager's RNG progression)".
How the solver searches
The solver in 6a8d79e is deliberately simple: exhaustive search within a
round, greedy across rounds.
For one round it tries every legal cut. On a 16 by 16 board that is 14 vertical positions times 14 horizontal positions, 196 cuts. For each cut it first probes how far each nudge axis can move before fragments collide, then tries every vertical and horizontal nudge offset inside those bounds, simulates the fragments to rest, and checks the circuit.
for vCut in 1 .. width-2
for hCut in 1 .. height-2
probe nudge bounds for this cut
for vNudge in minV .. maxV
for hNudge in minH .. maxH
simulate settle, check circuit
If any combination completes the circuit this round, it takes it. If none does, it scores every combination with a heuristic and keeps the best one:
- Gap (dominant). Run a breadth-first search from Start and another from End through conductive cells. The score is the smallest Manhattan distance between the two reachable sets, weighted by 100,000. Closing that gap is the whole game.
- Spawn clearance (secondary). The share of the center spawn footprint that is still empty, weighted by 100. A move that closes the gap but chokes the center loses two rounds later.
- Reachable size (tiebreaker). More connected material is better.
Then it applies the chosen step, spawns the next shape from the seeded
generator, and repeats until it wins, runs out of valid moves, congests the
center, or hits the round limit. Each result carries a status
(Solved, Unsolvable, MaxRoundsReached), the steps taken, the number of
combinations evaluated and the elapsed time.
It is greedy, so it is not optimal, and it can call a solvable puzzle unsolvable. For a prototype that is the right trade: it is simple to reason about, and the question it answers ("can a reasonable player finish this seed?") is the one a puzzle designer needs.
Proving it is deterministic
The edit-mode tests include one that runs the solver twice on the same configuration and asserts that the status, the step count and every step match. It is a small test, but it is the one that keeps the solver honest: if anything in generation or simulation starts reading an unseeded random source or a frame-dependent value, the two runs disagree and the test fails.
The same commit added a seed scanner (an editor panel, a menu runner and a test utility) that sweeps a range of seeds and reports which ones the solver can finish, plus a PowerShell batch-mode test runner that converts Unity's XML results to JSON.
From solver to auto-player
The first version of playback solved the whole puzzle in memory, then played it back only if the full solution succeeded. On a failure you saw nothing and got no board images, which is exactly when you want to see the most.
6dba7d7 changed the shape: playback now asks the solver for one step at a
time against the real GameManager board, then drives the real cut, nudge,
tick and settle flow on screen. Every round is visible and captured, whatever
the outcome. The same commit also changed the order of checks in the fragment
simulator so that collision with settled material is tested for every cell
before reaching the edge is tested, which stops a fragment from overlapping a
deposit sitting on the edge. The rules file already described that priority;
the code now matched it. The commit history does not say how the mismatch was
noticed, so I won't claim the auto-player found it.
The endless runner
The final commit split the work into two coroutines:
- Pre-solve. Keep a queue of 10 puzzles. Build a random configuration from a preset, run the greedy solver on it (up to 50 rounds), and enqueue the result. This is pure data and never touches the scene.
- Playback. Take the next puzzle, create a fresh
GameManagerunder external control, and replay the solver's steps through it: set the cut, confirm, apply the nudges, advance ticks until everything settles.
The design decision I care most about is how a game is scored. The runner does
not trust the solver's status. It counts a game as solved only if the live
GameManager ends in its Complete phase. The solver and the game share the
same core classes, but the playback path goes through the game's own state
machine, input entry points and spawning. If the two ever disagreed, the
on-screen counter of solved and failed games would show it, and each game
writes its outcome next to its board snapshots and per-tick images.
There are four presets (Checkers, Chess, Go and Full Random) that vary board size, shape generators, color assignment and Start/End placement. An editor tool generates one scene per preset and builds each to WebGL in one step. The demo linked above is the Full Random preset. It draws fresh seeds for each game, so every visit shows different puzzles; within one game, everything follows from its seeds.
Generator guardrails
While the endless runner was coming together, the board generator gained three rules, each commented as a guardrail:
- Start and End must be at least one cell away from the spawn footprint, "otherwise fragments can't move and it's an instant loss".
- Start and End must never be orthogonally adjacent, which would be a puzzle that is already solved.
- The minimum cells per fragment is capped at
(n - 1) / 4for ann-cell shape, so a valid four-way cut always exists.
The commits record the rules, not what prompted them, so I present them as what they are: constraints that make every generated board a real puzzle.
What generalizes
Keep the core pure and the randomness seeded, from day one. Neither rule was written for a solver. Both are what made a solver, a determinism test and a self-playing demo a short follow-up instead of a refactor.
Let the bot play the real game. A solver that only checks its own model can agree with itself forever. Replaying its moves through the real game loop, and scoring by the game's own end state, makes the two check each other.
Make failures the most visible case. The first playback showed only successes. The fix was to solve one round at a time against the live board, so a failing seed produces the most evidence, not the least.