How We Built Brain Puzzles — DOM, CSS & a BFS Solver
Brain Puzzles is a single HTML file — no build step, no framework, no canvas renderer. The wood aesthetic, the smooth tile slides, the drag-responsive block puzzle, and the hint solver are all vanilla DOM, CSS, and a couple hundred lines of JavaScript. Here is how each piece works.
Wood material in pure CSS
The warm timber look comes entirely from CSS. Each theme defines a set of CSS custom properties — background, board colour, tile face gradient, edge shadow, and ink colour — and switching themes is a single call to document.documentElement.style.setProperty for each variable.
A tile face is rendered as a div with a linear-gradient from the light grain colour to the mid grain colour, plus a faint repeating-linear-gradient streak overlay that mimics wood fibres. Inset box-shadow adds a top highlight and a bottom-edge shadow, creating the raised-plank look. The board itself is a darker recessed well — the same technique in reverse, with an inset top shadow to simulate depth.
This approach was chosen over canvas deliberately. DOM gives you free CSS transitions for tile movement, natural touch event handling via Pointer Events on standard elements, and a simple transform: translate(x, y) model for positioning tiles — all without a custom renderer. The tradeoff is that the DOM is slower than canvas for hundreds of moving objects, but a 5×5 puzzle has at most 25 tiles and the block puzzle has at most ~12 blocks, so DOM performance is never a constraint.
Solvable shuffle for the Number Puzzle
A naive random permutation of tiles produces an unsolvable board roughly half the time — the N-puzzle famously splits into two parity classes, and only one is reachable from the solved state. Rather than implement a parity check, Brain Puzzles uses a simpler and more foolproof approach: generate shuffles by applying random legal moves from the solved state.
// shuffle by legal moves — always solvable function numShuffle(size) { let tiles = solvedState(size); // [1,2,...,n-1, 0] let last = -1; for (let i = 0; i < size * size * 20; i++) { const moves = legalMoves(tiles).filter(m => m !== last); const m = moves[Math.random() * moves.length | 0]; tiles = applyMove(tiles, m); last = inverse(m); } return tiles; }
Filtering out the immediately reversing move avoids back-and-forth oscillation and ensures the shuffle explores the state space broadly. After size² × 20 steps the puzzle is thoroughly mixed, and because every step was a legal slide the result is by definition reachable — and therefore solvable.
Block Puzzle drag with occupancy grid collision
The Block Puzzle uses a 6×6 occupancy grid — a flat array of block IDs (or null) — as the ground truth for collision. When the player starts dragging a block, the engine:
- Removes the dragged block's cells from the occupancy grid to get a "ghost board".
- Scans along the block's axis in both directions, counting free cells until hitting a wall or another block's ID in the grid.
- Clamps the live pointer delta to the resulting
[minOffset, maxOffset]range. - Updates the block's
transform: translate()on everypointermoveevent.
On pointerup, the offset is rounded to the nearest cell, the occupancy grid is updated, and a CSS transition snaps the block into its final grid position with a brief spring animation. This gives perfectly fluid drag behaviour with zero tunnelling — a block can never pass through another because the clamp is computed from the static grid, not from position arithmetic.
pointerdown / pointermove / pointerup), with touch-action: none on the board element to prevent scroll interference. One code path handles both fingers and mice.BFS solver — hints and par
Both the Hint button and the per-level par value are powered by a breadth-first search over the block puzzle's state space. The solver represents each state as a canonical string key — the sorted id:row,col positions of every block — and explores neighbours by trying every legal 1-cell slide of every block in both directions.
function blockSolve(state) { const start = stateKey(state); const queue = [{ key: start, path: [] }]; const seen = new Set([start]); while (queue.length) { const { key, path } = queue.shift(); if (isGoal(key)) return path; // red block at exit for (const move of legalMoves(key)) { const next = applyMove(key, move); if (!seen.has(next)) { seen.add(next); queue.push({ key: next, path: [...path, move] }); } } if (seen.size > 200000) break; // cap for safety } return null; // unsolvable (shouldn't happen) }
The 200,000-state cap exists as a safety valve but is never hit in practice — all 25 shipped levels are verified solvable within roughly twelve thousand states. The solver runs synchronously on demand; on a modern device it completes within a few tens of milliseconds even on the hardest Expert level, so no worker thread is needed.
Par for each level is computed once at startup by calling blockSolve on the initial state and recording the path length. The hint is simply the first move in the returned path applied to the current live state — so it always points toward the shortest remaining solution, not just a locally reasonable next step.
Putting it together
The game is delivered as a single file served by a Cloudflare Worker. The Worker injects a per-request nonce into every inline script and style tag and sets a tight Content Security Policy — no external scripts, no CDN, no inline event handlers. Sound is generated on demand via the Web Audio API's oscillator nodes so there are no audio files to fetch. A small canvas overlay handles particles and confetti; it sits above the DOM game board but does not replace it.
The result is a game that loads in a single round trip, renders crisply at any DPI, and works identically on a phone or a desktop. Read the how-to-play guide if you want the player's view, or jump straight into the game.
See the DOM, CSS, and BFS in action.
Play Brain Puzzles →