webdevpuneet.com

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

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

CategoryGames
TagsGames

For supporters

Source Code

Want the source code?

Play the game for free. Become a WebDevPuneet supporter to unlock the complete HTML, CSS and JavaScript source code, the exports (HTML, React, Vue, Angular, Tailwind) and Fork.

Play freeSource code: Patreon

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

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.

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.
Related: Tower Stack Timing Game
See the Tower Stack Timing Game for a related games pattern worth pairing with this one.

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.