A beginner-friendly walkthrough of how the sliding-tile puzzle was built
15 Puzzle β Feature Tutorial
A beginner-friendly walkthrough of how the sliding-tile puzzle was built, why each decision was made, and how you could replicate it yourself.
Table of Contents
- What is this feature?
- Tech stack at a glance
- File structure
- How the pages are wired up (Next.js routing)
- The board: a flat array as a 2-D grid
- Shuffling and the solvability problem
- Detecting a legal move: Manhattan distance
- State management in PuzzleSlider
- The hydration-safe board pattern
- The timer: setInterval + useRef
- Win detection and immutable updates
- Internationalisation (i18n)
- Styling with Tailwind and clsx
- How to replicate this feature from scratch
1. What is this feature?
The 15 Puzzle (also called the sliding puzzle) is a classic game invented in the 1870s. You have a 4Γ4 tray with 15 numbered tiles and one empty space. The goal is to slide tiles one at a time into the empty space until they are ordered 1 through 15, left-to-right, top-to-bottom, with the empty space in the bottom-right corner.
This implementation is a fully playable React component that lives inside the existing Playground app. It tracks the number of moves and elapsed time, detects the win condition automatically, and ships with English and German translations. It was built as a learning exercise to practice game-state management, a classic combinatorics problem (solvability), and a leak-free interval timer.
2. Tech stack at a glance
| Technology | Role in this feature | Learn more |
|---|---|---|
| React 18 | UI component model, hooks for state and side effects | π react.dev |
| TypeScript | Static typing for the board array and component props | π typescriptlang.org |
| Next.js 14 | File-based routing, server components, page metadata | π nextjs.org/docs |
| Tailwind CSS | Utility-first styling, responsive sizes, dark mode | π tailwindcss.com/docs |
| clsx | Conditionally joining class-name strings | π github: lukeed/clsx |
| MDX | Writing app descriptions in Markdown with JS exports | π mdxjs.com |
3. File structure
src/
βββ app/
β βββ apps/
β βββ puzzle-slider/ β URL: /apps/puzzle-slider/
β βββ page.tsx β Info/detail page (server component)
β βββ page.en.mdx β App metadata + English description
β βββ page.de.mdx β German description
β βββ MdxContent.tsx β Language-switcher for MDX
β βββ play/
β βββ page.tsx β Game page (server component)
β
βββ components/
β βββ games/
β βββ PuzzleSlider.tsx β The entire game logic + UI
β
βββ i18n/
βββ translations/
βββ en.ts β English strings (puzzle.* keys)
βββ de.ts β German strings (puzzle.* keys)
Think of the app/ folder as the URL map of the site: every folder
becomes a URL segment. apps/puzzle-slider/ maps to /apps/puzzle-slider/
and apps/puzzle-slider/play/ maps to /apps/puzzle-slider/play/. The
actual game logic lives separately in components/games/ β the page files
are thin shells that import and render it.
4. How the pages are wired up
There are two distinct pages for this feature.
The info page (page.tsx)
// src/app/apps/puzzle-slider/page.tsx
import { AppDetailLayout } from '@/components/AppDetailLayout'
import { MdxContent } from './MdxContent'
import { app as baseApp } from './page.en.mdx'
const app = { ...baseApp, slug: 'puzzle-slider' }
export const metadata = { title: app.title, description: app.description }
export default async function PuzzleSliderPage() {
return (
<AppDetailLayout app={app}>
<MdxContent />
</AppDetailLayout>
)
}
page.en.mdx is not just a text file β it also exports a JavaScript
object called app containing the title, description, tech tags, and date.
The page.tsx imports that object and passes it to AppDetailLayout, which
handles the card layout, the "Go to app" button, and related journal entries.
MdxContent is a small client component that picks English or German MDX
based on the user's language preference.
π Next.js β file-based routing
The play page (play/page.tsx)
// src/app/apps/puzzle-slider/play/page.tsx
export default function PuzzleSliderPlayPage() {
return (
<Container className="mt-16 sm:mt-32">
<GamePageHeader
titleKey="puzzle.title"
descriptionKey="puzzle.description"
/>
<div className="mt-16 sm:mt-20">
<PuzzleSlider />
</div>
</Container>
)
}
This is a server component β it runs on the server, generates HTML, and
sends it to the browser. It contains no game logic; it just composes shared
layout components around <PuzzleSlider />. GamePageHeader reads the
translated title and description from the i18n system. All the interactivity
lives inside PuzzleSlider, which is a client component (marked with
'use client').
π Next.js β Server vs Client Components
5. The board: a flat array as a 2-D grid
The board is represented as a flat array of 16 numbers, not a 2-D array of arrays. This is a common and practical choice.
type Board = number[]
const GOAL: Board = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0]
0 represents the empty space. The goal state has tiles 1β15 in order with
the empty space last.
Think of it like a cinema ticket roll: even though the seats are arranged in a 2-D auditorium (rows and columns), every seat has a single sequential number printed on it. You can always convert between the two representations with simple arithmetic:
row = Math.floor(index / 4) // which row (0β3)
col = index % 4 // which column (0β3)
For example, index 6 β row 1, col 2 (second row, third column).
Using a flat array makes React state updates simple β you just copy the array,
swap two elements, and call setBoard. There is no need to spread nested
arrays or worry about deep cloning.
π MDN β Array
6. Shuffling and the solvability problem
This is the trickiest part of the whole feature, and it is worth understanding deeply.
The problem: not every shuffle is solvable
Roughly half of all random arrangements of the 15-puzzle are mathematically impossible to solve β no sequence of moves can lead to the goal state. If you ship a puzzle that is occasionally unsolvable, users will think it is a bug.
π Wikipedia β 15-puzzle solvability
Fisher-Yates shuffle
The code starts with a fair random shuffle using the Fisher-Yates algorithm:
function createShuffledBoard(): Board {
const board = [...GOAL] // start from goal state
for (let i = board.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[board[i], board[j]] = [board[j], board[i]] // destructure swap
}
// ... solvability fix below
}
Fisher-Yates works by walking backwards through the array. At each step
it picks a random position from 0 to the current index and swaps. The result
is a perfectly uniform random permutation β every arrangement is equally
likely. Think of it like shuffling a deck of cards by repeatedly pulling a
card from a random position and moving it to the end.
π Wikipedia β Fisher-Yates shuffle
Solvability check: inversions + blank row parity
A 15-puzzle on a 4-wide grid is solvable if and only if this rule holds:
Count the inversions in the tile sequence (ignoring the blank). Count the row of the blank from the bottom (1-indexed). If the blank's row from the bottom is even, the puzzle is solvable iff inversions is odd. If the row is odd, the puzzle is solvable iff inversions is even.
An inversion is any pair of tiles (a, b) where a appears before b
in the flat array but a > b. In the goal state there are zero inversions.
function countInversions(board: Board): number {
const tiles = board.filter((n) => n !== 0) // ignore blank
let inv = 0
for (let i = 0; i < tiles.length; i++) {
for (let j = i + 1; j < tiles.length; j++) {
if (tiles[i] > tiles[j]) inv++ // found an inversion
}
}
return inv
}
function isSolvable(board: Board): boolean {
const blankRow = Math.floor(board.indexOf(0) / GRID) // 0-indexed from top
const blankRowFromBottom = GRID - blankRow // 1-indexed from bottom
const inv = countInversions(board)
return blankRowFromBottom % 2 === 0
? inv % 2 === 1 // even row from bottom β inversions must be odd
: inv % 2 === 0 // odd row from bottom β inversions must be even
}
The parity fix
If the shuffle lands on an unsolvable configuration, the fix is to swap any two non-blank tiles. Swapping two tiles changes the inversion count by exactly Β±1, which flips the parity β and because the blank doesn't move, the blank-row parity stays the same. One swap is always enough.
if (!isSolvable(board)) {
// Collect all indices that are not the blank
const nonZero = board.reduce<number[]>(
(acc, v, i) => (v !== 0 ? [...acc, i] : acc),
[],
)
// Swap the first two non-blank tiles to flip parity
;[board[nonZero[0]], board[nonZero[1]]] =
[board[nonZero[1]], board[nonZero[0]]]
}
After this fix, isSolvable is guaranteed to return true.
7. Detecting a legal move: Manhattan distance
A tile can only move if it is directly adjacent to the blank space β up, down, left, or right. No diagonals. The code checks this using Manhattan distance (also called "taxicab distance"):
function isAdjacentToBlank(tileIdx: number, blankIdx: number): boolean {
const tr = Math.floor(tileIdx / GRID) // tile row
const tc = tileIdx % GRID // tile column
const br = Math.floor(blankIdx / GRID) // blank row
const bc = blankIdx % GRID // blank column
return Math.abs(tr - br) + Math.abs(tc - bc) === 1
}
Manhattan distance is the sum of the absolute differences in row and column.
A value of exactly 1 means the two cells share an edge. A value of 0 means
they are the same cell. 2 means they are a knight's move apart. By requiring
exactly 1, diagonal and non-adjacent moves are rejected.
Analogy: Imagine navigating a city grid where you can only walk along streets (not cut through buildings). The Manhattan distance is how many blocks you walk. Adjacent cells are exactly 1 block away.
π Wikipedia β Manhattan distance
8. State management in PuzzleSlider
The component holds five pieces of state:
const [board, setBoard] = useState<Board | null>(null)
const [moves, setMoves] = useState(0)
const [time, setTime] = useState(0)
const [timerActive, setTimerActive] = useState(false)
const [isWon, setIsWon] = useState(false)
| State | Type | Purpose |
|---|---|---|
board | Board | null | The current tile positions (null before mount) |
moves | number | How many tiles the user has slid |
time | number | Seconds elapsed since the first move |
timerActive | boolean | Controls whether the interval timer is running |
isWon | boolean | True once the board matches the goal state |
The board starts as null β this is intentional and explained in the next
section.
The click handler
function handleTileClick(idx: number) {
if (!board || isWon) return // guard clauses β exit early
const blankIdx = board.indexOf(0)
if (!isAdjacentToBlank(idx, blankIdx)) return
const newBoard = [...board] // immutable update β copy first
;[newBoard[idx], newBoard[blankIdx]] = [newBoard[blankIdx], newBoard[idx]]
const newMoves = moves + 1
setBoard(newBoard)
setMoves(newMoves)
if (newMoves === 1) setTimerActive(true) // start timer on first move
if (isGoal(newBoard)) {
setIsWon(true)
setTimerActive(false) // stop timer on win
}
}
The first two return statements are guard clauses β they exit early if
a precondition fails. This keeps the main logic unindented and easy to read.
The [...board] spread creates a shallow copy before the swap, which is
an immutable update β React requires state to be replaced, not mutated.
π React β Updating arrays in state
9. The hydration-safe board pattern
// Start with null to avoid SSR / hydration mismatch
const [board, setBoard] = useState<Board | null>(null)
// Initialise board client-side only
useEffect(() => {
setBoard(createShuffledBoard())
}, [])
// Show goal state as placeholder during SSR
const displayBoard: Board = board ?? GOAL
This is the null-board-on-SSR pattern. Here is why it is needed:
Next.js renders React components twice: once on the server (to generate
HTML), and once in the browser (to "hydrate" the static HTML into an
interactive app). If the server and browser produce different output β for
example because Math.random() produces different numbers each time β React
throws a hydration mismatch error.
The fix is to make the server render a deterministic placeholder (the
goal state) and delay the random shuffle until the browser's first paint.
The useEffect with an empty dependency array [] runs only in the browser,
never on the server. Once it runs, board is set to the shuffled array and
React re-renders with the real puzzle.
The ?? (nullish coalescing) operator picks GOAL when board is null
and picks board otherwise β a clean one-liner for the fallback.
π React β useEffect π MDN β Nullish coalescing (??)
10. The timer: setInterval + useRef
The timer increments time by 1 every second. It starts on the first move
and stops when the player wins.
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (timerActive) {
intervalRef.current = setInterval(() => {
setTime((prev) => prev + 1)
}, 1000)
} else {
if (intervalRef.current) clearInterval(intervalRef.current)
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current) // cleanup
}
}, [timerActive])
There are two important patterns here.
Why useRef for the interval ID?
setInterval returns an ID number that you need later to call clearInterval.
This ID must be remembered between renders, but storing it in useState would
trigger a re-render every time it changes β which would break the timer logic.
useRef is the right tool: it stores a value that persists across renders
without causing a re-render. Think of it as a sticky note taped to the side
of the component β invisible to the UI, but accessible whenever you need it.
π React β useRef
Why the cleanup function?
The return () => { clearInterval(...) } at the end is the effect cleanup
function. React calls it before:
- running the effect again (because
timerActivechanged), or - unmounting the component (user navigates away).
Without cleanup, stale intervals keep ticking in the background and try to update state in an unmounted component β a memory leak. The cleanup function prevents this.
Why setTime((prev) => prev + 1)?
When you update state inside an interval, you should use the functional
update form β passing a function instead of a value. React guarantees the
function receives the most recent state value, even if several updates are
batched. Using setTime(time + 1) inside an interval can "capture" a stale
value of time in a closure, causing the timer to stop incrementing after
the first tick.
π React β Functional updates with useState
11. Win detection and immutable updates
Win detection is a single comparison:
function isGoal(board: Board): boolean {
return board.every((v, i) => v === GOAL[i])
}
Array.every returns true only if the predicate is true for every element.
This compares each position in the current board to the goal state. Because
GOAL is defined once at the top of the file as a constant, it is reused
for both the win check and the SSR placeholder.
The win check is called on newBoard β the array after the move β before
setBoard is called. This is important: board is still the old value
inside the click handler (React state updates are asynchronous). By checking
newBoard instead, the win is detected immediately on the move that completes
the puzzle.
12. Internationalisation (i18n)
All user-visible strings are stored in translation files rather than hardcoded in the component. This allows the UI to switch between English and German without any component changes.
Translation keys
// src/i18n/translations/en.ts
'puzzle.title': '15 Puzzle',
'puzzle.description': 'Slide tiles into order. β¦',
'puzzle.moves': 'Moves: {{count}}',
'puzzle.time': 'Time: {{time}}',
'puzzle.newGame': 'New Game',
'puzzle.youWon': 'Solved in {{moves}} moves β {{time}}! π',
The {{count}}, {{time}}, and {{moves}} placeholders are replaced at
runtime by the t() function:
t('puzzle.moves', { count: moves }) // β "Moves: 7"
t('puzzle.youWon', { moves, time: formatTime(time) }) // β "Solved in 7 moves β 00:42! π"
Using translations in the component
const { t } = useTranslation() // hook from the project's i18n system
useTranslation is a custom React context hook. It reads the current locale
from localStorage (defaulting to the browser's language), and exposes t
to look up strings. The app detail page and the play page both use this same
system β GamePageHeader also calls t(titleKey) to render its heading.
The MDX language switcher
// src/app/apps/puzzle-slider/MdxContent.tsx
'use client'
export function MdxContent() {
const { locale } = useTranslation()
return locale === 'de' ? <ContentDe /> : <ContentEn />
}
This client component renders the German MDX file when the locale is 'de'
and the English one otherwise. It must be a client component because it uses
useTranslation, which reads localStorage β a browser-only API.
13. Styling with Tailwind and clsx
Tailwind provides utility classes applied directly to JSX elements. The
clsx library lets you apply them conditionally without messy string
concatenation.
className={clsx(
// always applied
'flex h-16 w-16 items-center justify-center rounded-xl text-xl font-bold',
'transition-all duration-150 sm:h-20 sm:w-20 sm:text-2xl',
// conditionally applied based on tile value
value === 0
? 'cursor-default bg-zinc-100 dark:bg-zinc-800/50'
: [
'cursor-pointer shadow-md',
'bg-violet-500 text-white',
'hover:bg-violet-400 active:scale-95',
'dark:bg-violet-600 dark:hover:bg-violet-500',
isWon && 'ring-2 ring-green-400 dark:ring-green-500',
],
)}
value === 0picks the empty-space style (muted, no cursor pointer) vs the tile style (violet background, hover/active effects).sm:h-20 sm:w-20uses Tailwind's responsive prefix β on small screens tiles are 64px (h-16), onsmbreakpoint and wider they are 80px.dark:bg-violet-600uses Tailwind's dark-mode prefix β the class only applies when the OS/browser is in dark mode.active:scale-95shrinks the tile slightly on click β a tactile press effect achieved entirely with CSS.
π Tailwind CSS β Responsive design π Tailwind CSS β Dark mode
Why <button> instead of <div>?
The tiles use <button> elements rather than <div>. Buttons are
natively focusable β keyboard users can Tab to them and press Space or
Enter to activate them. Screen readers announce them as interactive. Divs
with onClick are invisible to accessibility tools. This is a small change
with a big impact on usability.
14. How to replicate this feature from scratch
Follow these steps in order. Each step builds on the previous one.
-
Model the board as a flat array. Define
type Board = number[]and theGOALconstant. Write therowandcolconversion helpers mentally (or as inline expressions). This is your foundation. -
Write
isAdjacentToBlank. Implement the Manhattan distance check. Test it manually: index 5 and index 9 should be adjacent (same column, adjacent rows); index 5 and index 6 should be adjacent (same row, adjacent columns); index 0 and index 5 should not be adjacent. -
Write
isGoal. UseArray.everyto compare againstGOAL. This is the win condition. -
Write the shuffle. Implement Fisher-Yates first, then add the solvability check. Read the Wikipedia article on the 15-puzzle to understand the math before coding it.
-
Build the component skeleton. Create
PuzzleSlider.tsxwith the five state variables and placeholder JSX. Mark it'use client'. ImportuseTranslation. -
Add the null-board pattern. Start
boardatnull. Add auseEffectwith[]dependency that callssetBoard(createShuffledBoard()). Renderboard ?? GOALin the JSX. -
Wire up
handleTileClick. Use guard clauses at the top. Do the immutable array copy, swap, andsetBoard. AddsetTimerActive(true)on the first move. -
Add the timer. Create
intervalRefwithuseRef. Add auseEffectthat depends ontimerActive. Inside: start or stop the interval, and return a cleanup function. Use the functional update form forsetTime. -
Add the win check. Call
isGoal(newBoard)after the swap. If true, setisWonandsetTimerActive(false). -
Add
handleNewGame. Clear the interval, reset all state, generate a new board. -
Style the board. Use a
grid grid-cols-4 gap-2wrapper. Style each tile withclsxβ empty space gets a muted look, tiles get the violet active style. -
Add translations. Define
puzzle.*keys inen.tsandde.ts. Replace all hardcoded strings in the component witht('puzzle.*'). -
Create the page files. Add
play/page.tsx(game page),page.tsx(info page),page.en.mdx(English content with theappexport),page.de.mdx(German content), andMdxContent.tsx(language switcher). Follow the exact same structure as any other game in theapps/folder. -
Test edge cases. Try clicking a non-adjacent tile (nothing should happen). Try clicking the empty space (nothing should happen). Navigate away and back β the timer should stop cleanly. Verify the win banner appears and tiles get the green ring on completion.
Made with React, TypeScript, Next.js, and Tailwind CSS. Ask for any section to be expanded or a specific function to be explained in more detail.