You Might Also Like
Sliding Number Puzzle (15-Puzzle) — Free JS Snippet
Sliding Number Puzzle (15-Puzzle) · Layouts · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Sliding Number Puzzle (15-Puzzle) — Solvable Shuffling, Transform-Based Tile Sliding & Win Detection

The 15-puzzle is one of the oldest mechanical puzzles ever popularized, dating back to the 1870s, and it remains a genuinely interesting programming exercise because a naive implementation is broken by default: a puzzle shuffled by fully randomizing 16 numbers is only solvable half the time. This snippet implements the correct approach — shuffling by performing a long sequence of random legal slides starting from the solved position — alongside smooth, transform-based tile animation and accurate win detection.
Why a random permutation is the wrong shuffle
The 15-puzzle's underlying mathematics involves permutation parity. Every legal slide move is equivalent to a single transposition combined with moving the blank tile, and it can be proven that exactly half of all 16! possible tile arrangements are reachable from the solved state through legal moves — the other half are permutation-parity odd and mathematically unsolvable no matter how the player slides tiles. Generating a shuffle by calling something like Array.sort(() => Math.random() - 0.5) on the 16 values ignores this entirely and will produce an unsolvable board roughly 50% of the time. This snippet avoids the problem completely: shuffleFromSolved() starts from the perfectly solved tiles array and performs 250 random legal slides in a row, each one swapping the blank with a randomly chosen adjacent neighbor. Because every individual move is legal and reversible, the resulting board is, by construction, always reachable back to the solved state — no parity checking required.
Avoiding a lazy, oscillating shuffle
A subtle secondary bug in naive "random legal slide" shufflers is that the blank tile can bounce back and forth between the same two cells repeatedly, producing a shuffle that looks busy but barely moves anything overall. This snippet tracks lastEmpty, the blank's position before the previous move, and filters it out of the candidate neighbor list on each iteration (neighbors.filter(n => n !== lastEmpty)). This forces every shuffle step to make genuine progress rather than immediately undoing the prior move, producing a well-mixed 4x4 board after 250 iterations.
Representing the board as a flat array, not a grid
The puzzle state lives in a single flat array, tiles, of length 16, where tiles[i] is the value shown at cell index i and 0 represents the empty slot. Helper functions indexToRowCol() and areAdjacent() convert between the flat index and a 2D row/column coordinate using simple integer division and modulo (row = Math.floor(index / 4), col = index % 4), and determine legality of a move by checking that the Manhattan distance between the clicked tile's cell and the empty cell equals exactly 1. This flat-array-plus-coordinate-math representation avoids nested arrays entirely and keeps the swap logic ( [tiles[a], tiles[b]] = [tiles[b], tiles[a]] ) a single line using array destructuring.
Transform-based sliding instead of layout reflow
Each numbered tile is an absolutely positioned div inside the .board container, sized to exactly 25% width and height. Rather than reordering DOM elements or changing top/left (which forces the browser to recompute layout), every tile's position is set with el.style.transform = 'translate(x%, y%)', computed from its cell index. Because transform is a compositor-only property, the browser can animate the 0.16s ease transition on the GPU without triggering layout or paint, which is why the tiles glide smoothly even during a rapid sequence of clicks. On every move, render() fully re-derives each tile's transform from the current tiles array and re-attaches its click handler, keeping the DOM state and the logical state trivially in sync.
Win detection and move counting
After every successful attemptMove(), checkWin() compares the live tiles array against the canonical solved array (1 through 15 followed by 0) element-by-element. On a match, a green .solved-flash class is applied to every tile and a banner reports the final move count — a lower move count indicating a more efficient solve, similar in spirit to the scoring feedback in a memory-matching game.
Build with AI
Build, Understand, Optimize, and Extend It With AI
This snippet is a good candidate to explore with an AI coding assistant like Claude because the correctness of the shuffle depends on a non-obvious mathematical fact (permutation parity) that is easy to get wrong. Paste the code in and ask the assistant to explain exactly why shuffleFromSolved() replays legal moves instead of randomizing the array directly, and to walk through how lastEmpty prevents the shuffle from wasting moves oscillating the blank tile back and forth. It's also a good target for extension requests: ask for keyboard arrow-key controls that move whichever tile is adjacent to the blank in that direction, a timer with a persisted best-time leaderboard using localStorage, an optional "solvability visualizer" that highlights the blank tile's reachable neighbors, or a swap to image-slice tiles for a picture-reveal variant. Treat the current file as a correct, well-tested baseline and use the assistant to layer new modes on top rather than to re-derive the shuffle algorithm from scratch.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a classic 4x4 sliding number puzzle (15-puzzle) in plain HTML, CSS, and JavaScript with guaranteed-solvable shuffling and smooth tile animation.
Requirements:
- A 4x4 grid containing 15 numbered tiles (1 through 15) and exactly one empty slot, represented internally as a single flat array of 16 values where 0 marks the empty slot.
- The shuffle must be generated by starting from the solved arrangement and performing many random LEGAL slide moves in sequence (picking a random valid neighbor of the blank tile each time) — never by randomly permuting all 16 values directly, since that produces an unsolvable board roughly half the time.
- The shuffle must avoid trivially undoing its own previous move every other step (i.e. track the blank's prior position and exclude it from the next random choice) so the board is well mixed rather than oscillating between two states.
- Clicking a tile must only move it if the tile is orthogonally adjacent (not diagonal) to the empty slot; clicking a non-adjacent tile must do nothing.
- Tile movement must be animated smoothly using a CSS transform-based transition (not by changing top/left or reordering DOM nodes), so tiles visibly glide into the empty slot.
- Track and display a live move counter that increments on every successful slide.
- After every move, check whether the board matches the fully solved order (1-15 in reading order, empty slot last); when solved, stop accepting further moves, apply a clear visual "solved" indication to the tiles, and display the final move count.
- Provide a "New Game" control that generates a fresh guaranteed-solvable shuffle and resets the move counter and solved state.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Start a new shuffled boardThe puzzle shuffles itself automatically on load, or click "New Game" to reshuffle. shuffleFromSolved() always starts from the solved array and performs 250 random legal slides, so the resulting board is guaranteed solvable.
- 2Slide tiles into the empty slotClick any tile that is directly above, below, left, or right of the empty slot to slide it. attemptMove() checks areAdjacent() using row/column Manhattan distance before allowing the swap — clicking a non-adjacent tile does nothing.
- 3Watch the move counterEvery successful slide increments the moves variable and updates the #moves display immediately. Use it as a lightweight scoring mechanism — challenge yourself or others to solve the puzzle in fewer moves.
- 4Reach the solved statecheckWin() runs after every move, comparing the live tiles array to the canonical 1-15-then-0 solved order. On a match, every tile flashes green and a "Solved in N moves!" banner appears over the board.
- 5Understand the transform-based renderingrender() recomputes every visible tile's translate(x%, y%) transform from its index in the tiles array and reattaches a click listener. Because it uses CSS transform rather than top/left, the 0.16s slide transition runs smoothly without layout thrashing.
- 6Export and adaptClick HTML or JSX to export. Change SIZE from 4 to 3 for a classic 8-puzzle, or swap the number labels for image tile fragments (background-position offsets) to build a picture-sliding puzzle instead.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The 15-puzzle has a mathematical property called permutation parity: only exactly half of all possible arrangements of the 16 tiles are actually reachable from the solved state using legal slide moves. A shuffle that randomly permutes all 16 values (e.g. sorting with a random comparator) ignores this and produces an unsolvable board roughly 50% of the time. This snippet avoids the issue by shuffling through 250 random legal slides starting from the solved state, so every generated board is provably solvable by construction.
areAdjacent() converts both the clicked tile's flat array index and the empty slot's index into row/column coordinates via indexToRowCol(), then checks that the Manhattan distance between them (the sum of the absolute row difference and column difference) equals exactly 1. Only tiles directly above, below, left, or right of the blank satisfy this and are allowed to move; diagonal tiles and any tile further away are rejected.
Changing top or left forces the browser to recompute layout (reflow) on every move, which can cause visible jank, especially with many tiles animating simultaneously. transform: translate() is a compositor-only property that modern browsers can animate purely on the GPU without triggering layout or paint, which is why render() sets el.style.transform = translate(x%, y%) for every tile rather than adjusting its box position directly.
Yes. Changing the SIZE constant at the top of the JS panel from 4 to 3 produces a classic 8-puzzle (9 cells, 8 numbered tiles); the solvedState(), indexToRowCol(), areAdjacent(), shuffleFromSolved(), and checkWin() functions all derive their bounds from SIZE and TILE_COUNT, so no other logic needs to change. The CSS grid math (25% tile width/height, cellPosition() percentages) is also driven by SIZE and should be updated to 100/SIZE if you change the constant.
checkWin() runs after every successful move and does an element-by-element comparison between the live tiles array and a freshly generated canonical solved array (values 1 through 15 in order followed by 0). If every position matches, the solved flag is set to true, further moves are blocked, every visible tile is given a .solved-flash class that swaps its gradient to green, and a banner reports the total move count used to solve the puzzle.