Pipe Mania looks like it needs a graph library and a flood-fill. It doesn't. The whole game — laying pipe from a source, racing a burning fuse, then watching water crawl cell by cell until it either reaches a goal or springs a leak — comes down to one idea and one line of code. Here is how it fits together, in pure vanilla JavaScript on a single canvas.
A pipe piece is just a set of open sides
There are no sprites and no per-piece special cases. A piece is which of its four sides are open, packed into four bits: North 1, East 2, South 4, West 8. A horizontal straight has East and West open, so its mask is 2 | 8 = 10. An elbow from North to East is 1 | 2 = 3. The rare cross is all four at once, 15.
const N = 1, E = 2, S = 4, W = 8; // one bit per side
const OPP = { 1: 4, 4: 1, 2: 8, 8: 2 }; // N<->S, E<->W
const PIECES = {
H: E | W, // 10 straight ──
V: N | S, // 5 straight │
NE: N | E, // 3 elbow └
SE: E | S, // 6 elbow ┌
SW: S | W, // 12 elbow ┐
NW: N | W, // 9 elbow ┘
CROSS: N | E | S | W // 15 crossover ┼
};
That is the entire vocabulary. Every rule below is a bit test against these masks. A weighted "bag" hands you the next piece so straights and elbows are common and the cross is rare, and you click empty cells to drop queue[0], overwriting any pipe the water has not yet reached.
Two questions, answered by one line
When the fuse burns down, the water is released as a little walker sitting in the source. Each tick it fills its current piece, then asks the piece where to leave: a straight or elbow has exactly two open sides, so "the other opening" is just the mask with the entry bit cleared. The cross is the one exception — a genuine crossover that sends water straight through.
function pieceExit(mask, from) {
if (mask === PIECES.CROSS) return OPP[from]; // crossover: straight through
return mask & ~from; // the single OTHER open side
}
Then comes the heart of the whole game — the connection test. The walker steps to the neighbour in its exit direction and asks exactly one thing: does that neighbour have the opposite opening facing back at me?
const [dr, dc] = DELTA[flow.exit]; // step to the neighbour
const nr = flow.r + dr, nc = flow.c + dc, entry = OPP[flow.exit];
const nb = inBounds(nr, nc) ? board[nr][nc] : null;
if (nb && nb.kind === "pipe" && (nb.mask & entry)) { // openings line up?
flow = { r: nr, c: nc, from: entry,
exit: pieceExit(nb.mask, entry),
progress: flow.progress, isSource: false };
} else {
return lose(); // a leak — flow stops dead
}
The test neighbour.mask & entry is the connectivity model. If the bit is set, water advances and the distance climbs; reach the goal and you win. If the neighbour is off the board, empty, or its openings don't line up, the flow has sprung a leak and stops dead.
Same flow on any monitor
One more trick keeps it fair. The flow is advanced by a fixed-timestep loop: bank real elapsed time in an accumulator and spend it in constant 1/120 s slices, so the water ticks identically on a 60 Hz or a 144 Hz screen.
const STEP = 1 / 120;
let last = performance.now(), acc = 0;
function frame(now) {
let dt = (now - last) / 1000; last = now;
if (dt > 0.25) dt = 0.25; // clamp a lag spike
acc += dt;
while (acc >= STEP) { update(STEP); acc -= STEP; }
render(); requestAnimationFrame(frame);
}
That is the whole game: pieces are open-side bitmasks, a piece routes water to its other opening, and water advances only where the neighbour's opposite bit is set. Everything else — the overwrite rule, the distance goal, win and lose — is a few lines each.
Play it, step through the logic, and copy the eight build blocks here: https://dev48v.infy.uk/game/day58-pipe-mania.html
Top comments (0)