A beginner-friendly walkthrough of how the sliding-tile puzzle was built

developmentreactuseStateuseRefuseEffect

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

  1. What is this feature?
  2. Tech stack at a glance
  3. File structure
  4. How the pages are wired up (Next.js routing)
  5. The board: a flat array as a 2-D grid
  6. Shuffling and the solvability problem
  7. Detecting a legal move: Manhattan distance
  8. State management in PuzzleSlider
  9. The hydration-safe board pattern
  10. The timer: setInterval + useRef
  11. Win detection and immutable updates
  12. Internationalisation (i18n)
  13. Styling with Tailwind and clsx
  14. 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

TechnologyRole in this featureLearn more
React 18UI component model, hooks for state and side effectsπŸ“– react.dev
TypeScriptStatic typing for the board array and component propsπŸ“– typescriptlang.org
Next.js 14File-based routing, server components, page metadataπŸ“– nextjs.org/docs
Tailwind CSSUtility-first styling, responsive sizes, dark modeπŸ“– tailwindcss.com/docs
clsxConditionally joining class-name stringsπŸ“– github: lukeed/clsx
MDXWriting 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)
StateTypePurpose
boardBoard | nullThe current tile positions (null before mount)
movesnumberHow many tiles the user has slid
timenumberSeconds elapsed since the first move
timerActivebooleanControls whether the interval timer is running
isWonbooleanTrue 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 timerActive changed), 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 === 0 picks the empty-space style (muted, no cursor pointer) vs the tile style (violet background, hover/active effects).
  • sm:h-20 sm:w-20 uses Tailwind's responsive prefix β€” on small screens tiles are 64px (h-16), on sm breakpoint and wider they are 80px.
  • dark:bg-violet-600 uses Tailwind's dark-mode prefix β€” the class only applies when the OS/browser is in dark mode.
  • active:scale-95 shrinks 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.

πŸ“– MDN β€” <button> element


14. How to replicate this feature from scratch

Follow these steps in order. Each step builds on the previous one.

  1. Model the board as a flat array. Define type Board = number[] and the GOAL constant. Write the row and col conversion helpers mentally (or as inline expressions). This is your foundation.

  2. 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.

  3. Write isGoal. Use Array.every to compare against GOAL. This is the win condition.

  4. 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.

  5. Build the component skeleton. Create PuzzleSlider.tsx with the five state variables and placeholder JSX. Mark it 'use client'. Import useTranslation.

  6. Add the null-board pattern. Start board at null. Add a useEffect with [] dependency that calls setBoard(createShuffledBoard()). Render board ?? GOAL in the JSX.

  7. Wire up handleTileClick. Use guard clauses at the top. Do the immutable array copy, swap, and setBoard. Add setTimerActive(true) on the first move.

  8. Add the timer. Create intervalRef with useRef. Add a useEffect that depends on timerActive. Inside: start or stop the interval, and return a cleanup function. Use the functional update form for setTime.

  9. Add the win check. Call isGoal(newBoard) after the swap. If true, set isWon and setTimerActive(false).

  10. Add handleNewGame. Clear the interval, reset all state, generate a new board.

  11. Style the board. Use a grid grid-cols-4 gap-2 wrapper. Style each tile with clsx β€” empty space gets a muted look, tiles get the violet active style.

  12. Add translations. Define puzzle.* keys in en.ts and de.ts. Replace all hardcoded strings in the component with t('puzzle.*').

  13. Create the page files. Add play/page.tsx (game page), page.tsx (info page), page.en.mdx (English content with the app export), page.de.mdx (German content), and MdxContent.tsx (language switcher). Follow the exact same structure as any other game in the apps/ folder.

  14. 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.