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.

Left: a random spanning tree covering every cell. Right: the same board after rotating each cell zero to three times

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.

← Back to all posts

Comments

…