Pipe puzzles: guarantee the map is solvable
Random pipe orientations mostly produce unsolvable boards. Generate a spanning tree first, then rotate every cell — solvability comes from the construction, not from a check.
The rules fit in one sentence: every cell is a pipe segment, tap to rotate it ninety degrees, and feed every cell from the source in the top-left corner.
The hard part is generating boards. Assign random orientations and the vast majority are unsolvable — the player rotates until their fingers hurt and the water never flows. That is not difficulty, that is a bug.
Guarantee solvability by construction
I stopped trying to decide whether a random board is solvable, and instead built a board that is definitely solvable, then scrambled it.
The construction is a random spanning tree. Start at the top-left, pick an unvisited neighbour at random, open both sides, and recurse until every cell is visited. The resulting graph is a tree by definition — no cycles, and every cell connects back to the source.

Figure: connect a tree first, scramble the rotations second. The player only has to undo the rotations.
const stack: number[] = [0];
visited[0] = true;
while (stack.length) {
const current = stack[stack.length - 1];
const neighbours = freeNeighbours(current);
if (!neighbours.length) {
stack.pop();
continue;
}
const pick = neighbours[Math.floor(random() * neighbours.length)];
masks[current] |= pick.from;
masks[pick.index] |= pick.to;
visited[pick.index] = true;
stack.push(pick.index);
}
A tree also means there are no spare segments: every piece has to be used, so the player never wonders whether a segment is decoration.
Scrambling: rotate, do not rearrange
After generation, rotate each cell a random 0–3 times. Rotation is reversible, so a solution always exists: rotate each cell back.
One detail matters: store how many times a cell was rotated, not just its final orientation. Reset has to return to the scrambled state, and if only the orientation is stored, reset degrades into “generate a new board”.
Connectivity: both sides must open
One BFS decides whether water reaches every cell. The essential part is that adjacent cells must open toward each other:
if (!(mask & need)) continue; // my side faces it
if (!(masks[next] & back)) continue; // its side faces me
Skip the second check and you get a board that claims to be connected while the pipe visibly stops halfway. My first version missed it, and the test reported success on a broken picture.
Orientation is a four-bit mask: north 1, east 2, south 4, west 8. Rotating ninety degrees is a circular left shift:
export function rotate(mask: number): number {
return ((mask << 1) | (mask >> 3)) & 15;
}
North, east, south, west, back to north — four taps for a full turn, exactly as it feels.
Colour is the state
The UI needs no extra state field: BFS returns a boolean array, fed cells draw in the accent colour, unfed ones in grey. The player sees where the water stops at a glance.
It also answers “when is it solved”: when every entry is true. No counters to keep in sync with the picture.
A little feel
- Rotate on click immediately, without waiting for animation — this is a high-frequency action and latency ruins the rhythm
- Lock rotation after solving so a finished board cannot be broken
- “New board” regenerates without keeping progress: a round of this should last minutes
Construction guarantees solvability, the check guarantees honest feedback, and all that is left is turning.

Comments
…