Sliding Number Puzzle (15-Puzzle) — Free JS Snippet

Sliding Number Puzzle (15-Puzzle) · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Guaranteed-solvable shuffle: 250 random legal slides performed from the solved state, never a raw permutation
lastEmpty tracking prevents the shuffle from oscillating the blank between the same two cells repeatedly
Flat 16-element array board representation with indexToRowCol() and areAdjacent() coordinate helpers
GPU-friendly transform: translate(x%, y%) tile positioning instead of top/left layout properties
Array destructuring swap ([tiles[a], tiles[b]] = [tiles[b], tiles[a]]) for concise, bug-resistant move logic
Live move counter incremented on every successful attemptMove()
checkWin() compares live state against the canonical solved array after every move for instant win detection
Solved-state visual feedback: every tile flashes green (.solved-flash) alongside a move-count summary banner

About this UI Snippet

Sliding Number Puzzle (15-Puzzle) — Solvable Shuffling, Transform-Based Tile Sliding & Win Detection

Screenshot of the Sliding Number Puzzle (15-Puzzle) snippet rendered live

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:

text
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

  1. 1
    Start 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.
  2. 2
    Slide 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.
  3. 3
    Watch 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.
  4. 4
    Reach 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.
  5. 5
    Understand 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.
  6. 6
    Export 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

Teaching example for permutation parity and correct shuffle algorithms
This is a strong classroom or interview-prep artifact for explaining why "shuffle by randomizing an array" fails for constrained puzzles. It demonstrates the general fix — shuffle by replaying random legal moves from a known-good state — which applies equally to Rubik's-cube-style puzzles, sudoku generation, and any other combinatorial puzzle with reachability constraints.
Standalone brain-teaser widget for a games or puzzles page
Embed this as a self-contained time-killer alongside a Memory Match Game or Tic-Tac-Toe on a portfolio, waiting-room kiosk, or product changelog page. It needs no backend, no images, and no external state — the whole game lives in one component.
Base for a photo or logo sliding puzzle
Replace the numeric tile labels with 16 equal background-position slices of a single image (using CSS background-image and background-position offset per tile index) to turn this into a "reveal the picture" sliding puzzle — a common onboarding or marketing gimmick. The shuffle, move, and win-detection logic require no changes.
Reference implementation for GPU-composited drag-free tile animation
Because every tile position update goes through a single style.transform assignment rather than DOM reordering or top/left changes, this snippet is a good reference for any grid-rearranging UI (kanban reordering previews, grid-based dashboards) that needs cheap, jank-free repositioning animations.
Speedrun or timed-challenge puzzle mode
Layer a stopwatch on top of the existing move counter to build a timed challenge mode — start the timer on the first attemptMove() call and stop it in checkWin() alongside the existing move-count banner, then persist best times per difficulty with localStorage the same way a Whack-a-Mole Game tracks its best score.
Accessibility and keyboard-navigation extension exercise
The current implementation is click/tap driven; it is a good exercise to extend with arrow-key support that moves whichever tile is adjacent to the blank in the pressed direction, plus aria-live region announcements of the move count and win state for screen reader users.

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.