Tic-Tac-Toe Computer Opponent - Feature Documentation

developmentreactgameconfigextensibility

Tic-Tac-Toe Computer Opponent — Feature Tutorial

A beginner-friendly walkthrough of how I added a computer opponent with three difficulty levels to the existing Tic-Tac-Toe game using the minimax algorithm.


Table of Contents

  1. What we built
  2. Tech stack at a glance
  3. Files overview — what changed and what's new
  4. The minimax algorithm
  5. Difficulty levels — controlled randomness
  6. The AI module: tic-tac-toe-ai.ts
  7. Updating the game component: TicTacToe.tsx
  8. The settings UI: TicTacToeSettings.tsx
  9. Wiring it together: TicTacToePlayClient.tsx
  10. i18n — adding translations
  11. How to replicate this from scratch

1. What we built

The Tic-Tac-Toe game previously supported two modes: local two-player (taking turns on the same device) and online multiplayer via Supabase. I added a third mode: playing against the computer.

The player chooses between "vs Player" and "vs Computer" in a dropdown. When playing against the computer, a second dropdown appears to select the difficulty: Easy, Medium, or Hard. The human always plays as X (first move); the computer plays as O.

Under the hood, the computer uses the minimax algorithm to evaluate every possible game state and pick the optimal move. Difficulty is controlled by mixing in random moves — easy mode plays randomly 60 % of the time, medium 20 %, and hard never.


2. Tech stack at a glance

TechnologyRole in this featureLearn more
React 19Component UI, state management, useEffect for AI moves📖 React docs
TypeScriptType-safe props, difficulty/mode types📖 TypeScript handbook
Next.js 15Server/client component split (page.tsx vs client wrapper)📖 Next.js docs
Tailwind CSSStyling for settings dropdowns and board📖 Tailwind docs
i18n (custom)Translated labels for mode, difficulty, status messagesProject-specific useTranslation hook

3. Files overview — what changed and what's new

src/
├── lib/
│   └── tic-tac-toe-ai.ts          ← NEW   (AI logic, pure functions)
├── components/games/
│   ├── TicTacToe.tsx               ← MODIFIED (added AI mode + props)
│   └── TicTacToeSettings.tsx       ← NEW   (mode & difficulty UI)
├── app/apps/tic-tac-toe/play/
│   ├── page.tsx                    ← MODIFIED (delegates to client wrapper)
│   └── TicTacToePlayClient.tsx     ← NEW   (state orchestrator)
└── i18n/translations/
    ├── en.ts                       ← MODIFIED (new translation keys)
    └── de.ts                       ← MODIFIED (new translation keys)

The key design decision: all game logic lives in a pure TypeScript module (tic-tac-toe-ai.ts) with zero React imports. This makes it easy to test, reuse, and reason about independently from the UI.


4. The minimax algorithm

Minimax is a decision-making algorithm used in two-player zero-sum games. Think of it like a chess player who mentally plays out every possible future move sequence and picks the path that guarantees the best outcome even if the opponent plays perfectly.

For Tic-Tac-Toe, the game tree is small enough (at most ~5,000 states after pruning) that minimax can evaluate every possible outcome instantly — no shortcuts needed.

How it works

The algorithm alternates between two perspectives:

  • Maximizing player (O / computer): picks the move with the highest score
  • Minimizing player (X / human): picks the move with the lowest score

Scores are assigned to terminal states:

  • O wins → +10 - depth (prefer faster wins)
  • X wins → depth - 10 (prefer slower losses)
  • Draw → 0

The depth penalty is a subtle but important detail: without it, the computer wouldn't distinguish between winning in 1 move vs. winning in 5. With the penalty, it always goes for the fastest win.

// src/lib/tic-tac-toe-ai.ts (simplified)
function minimax(
  squares: (string | null)[],
  isMaximizing: boolean,
  depth: number,
): number {
  const winner = calculateWinner(squares)
  if (winner === 'O') return 10 - depth   // computer wins
  if (winner === 'X') return depth - 10   // human wins
  if (isBoardFull(squares)) return 0      // draw

  const empty = getEmptySquares(squares)

  if (isMaximizing) {
    let best = -Infinity
    for (const idx of empty) {
      const next = squares.slice()        // immutable copy
      next[idx] = 'O'
      best = Math.max(best, minimax(next, false, depth + 1))
    }
    return best
  } else {
    let best = Infinity
    for (const idx of empty) {
      const next = squares.slice()
      next[idx] = 'X'
      best = Math.min(best, minimax(next, true, depth + 1))
    }
    return best
  }
}

The recursion bottoms out when someone wins or the board is full. Each level tries every empty square, creating a tree of all possible games. The scores "bubble up" to the root, telling the computer which first move leads to the best guaranteed outcome.

📖 Minimax on Wikipedia 📖 Minimax for Tic-Tac-Toe (GeeksforGeeks)


5. Difficulty levels — controlled randomness

Instead of building three separate AI strategies, we use a single elegant approach: the computer always knows the optimal move, but sometimes chooses a random one instead.

const DIFFICULTY_RANDOM_CHANCE: Record<Difficulty, number> = {
  easy: 0.6,    // 60% random, 40% optimal
  medium: 0.2,  // 20% random, 80% optimal
  hard: 0.0,    // 0% random, always optimal (unbeatable)
}

This is like a chess grandmaster who knows the best move but intentionally plays casual moves to give you a chance — except on hard mode, where they play to win every time.

export function getComputerMove(
  squares: (string | null)[],
  difficulty: Difficulty,
): number {
  const empty = getEmptySquares(squares)
  const randomChance = DIFFICULTY_RANDOM_CHANCE[difficulty]

  if (Math.random() < randomChance) {
    // Pick a random empty square
    return empty[Math.floor(Math.random() * empty.length)]
  }

  // Otherwise, play the optimal move
  return getBestMove(squares)
}

The beauty of this design: one algorithm, one code path, three difficulty levels controlled by a single number.


6. The AI module: tic-tac-toe-ai.ts

File: src/lib/tic-tac-toe-ai.ts

This module exports everything the game needs:

ExportPurpose
DifficultyType: 'easy' | 'medium' | 'hard'
GameModeType: 'local' | 'computer'
calculateWinner(squares)Returns 'X', 'O', or null
isBoardFull(squares)Returns true when no empty cells remain
getComputerMove(squares, difficulty)The main entry point — returns the index of the computer's chosen square

Note that calculateWinner was extracted from the original TicTacToe.tsx. It previously lived at the bottom of the component file. Moving it here means both the UI and the AI can import the same function — no duplication.

The internal helpers (getEmptySquares, minimax, getBestMove) are not exported. They're implementation details that callers don't need to know about.

📖 TypeScript Record utility type


7. Updating the game component: TicTacToe.tsx

File: src/components/games/TicTacToe.tsx

What changed

The component gained two optional props:

interface TicTacToeProps {
  mode?: GameMode       // default: 'local'
  difficulty?: Difficulty // default: 'medium'
}

Because both default to their original behavior, existing usage without props still works identically — no breaking changes.

The isComputerTurn flag

Rather than scattering mode === 'computer' checks everywhere, we compute a single boolean:

const isComputerTurn =
  mode === 'computer' &&
  !xIsNext &&
  !calculateWinner(currentSquares) &&
  !isBoardFull(currentSquares)

This flag is true only when all four conditions hold: we're in computer mode, it's O's turn, nobody has won, and the board isn't full. It drives both the useEffect and the click-blocking logic.

Triggering the computer's move with useEffect

useEffect(() => {
  if (!isComputerTurn) return

  const timeout = setTimeout(() => {
    const moveIndex = getComputerMove(currentSquares, difficulty)
    const nextSquares = currentSquares.slice()
    nextSquares[moveIndex] = 'O'
    handlePlay(nextSquares)
  }, 400)

  return () => clearTimeout(timeout)
}, [isComputerTurn, currentSquares, difficulty])

Why a 400 ms delay? Without it, the computer's move appears instantly after the human clicks — it feels jarring, like the board just jumped ahead. The small delay gives the player a moment to register their own move and creates the impression that the computer is "thinking".

The cleanup function (clearTimeout) prevents stale moves if the user navigates the move history rapidly — each re-render cancels the previous pending move.

📖 React useEffect

Blocking clicks during the computer's turn

The Board component now receives isComputerTurn as a prop. The click handler adds an early guard clause:

function handleClick(i: number): void {
  if (isComputerTurn || squares[i] || calculateWinner(squares)) {
    return  // guard clause: ignore clicks
  }
  // ... handle the move
}

Guard clauses exit early when a precondition isn't met, keeping the main logic unindented and easy to read.

Draw detection

The original code had no draw message — it just kept showing "Next player: X" on a full board. Now the status logic handles four states:

if (winner) {
  status = t('ttt.winner', { mark: winner })
} else if (isBoardFull(squares)) {
  status = t('ttt.draw')
} else if (isComputerTurn) {
  status = t('ttt.computerThinking')
} else {
  status = t('ttt.nextPlayer', { mark: xIsNext ? 'X' : 'O' })
}

8. The settings UI: TicTacToeSettings.tsx

File: src/components/games/TicTacToeSettings.tsx

This component follows the same pattern as the existing MemorySettings.tsx (used by the Memory game for grid size and theme selection). It uses native <select> elements with the same Tailwind classes for visual consistency.

The difficulty dropdown is conditionally rendered — it only appears when mode === 'computer':

{mode === 'computer' && (
  <label className="...">
    {t('ttt.difficulty')}
    <select value={difficulty} onChange={...}>
      <option value="easy">{t('ttt.difficulty.easy')}</option>
      <option value="medium">{t('ttt.difficulty.medium')}</option>
      <option value="hard">{t('ttt.difficulty.hard')}</option>
    </select>
  </label>
)}

This keeps the UI clean — when playing "vs Player", the user only sees one dropdown. Switching to "vs Computer" reveals the difficulty picker.

📖 React conditional rendering


9. Wiring it together: TicTacToePlayClient.tsx

File: src/app/apps/tic-tac-toe/play/TicTacToePlayClient.tsx

The server/client split problem

The play page (page.tsx) is a server component — it exports metadata for SEO. But our settings need useState, which only works in client components. The solution: extract a thin client wrapper.

page.tsx stays as a server component and renders <TicTacToePlayClient />. The client component holds the state:

'use client'

export function TicTacToePlayClient() {
  const [mode, setMode] = useState<GameMode>('local')
  const [difficulty, setDifficulty] = useState<Difficulty>('medium')
  const [gameKey, setGameKey] = useState(0)
  // ...
}

The key trick for game reset

When the user changes mode or difficulty, the game should reset. We could add a reset() function that clears history, resets currentMove, etc. But there's a simpler React pattern:

<TicTacToe key={gameKey} mode={mode} difficulty={difficulty} />

Changing the key prop tells React to unmount the old component and mount a fresh one. All internal state (history, currentMove) starts from scratch. This is like closing a document and opening a new blank one instead of manually erasing every line.

Each settings change increments gameKey:

function handleModeChange(newMode: GameMode) {
  setMode(newMode)
  setGameKey((k) => k + 1)
}

📖 React key for resetting state 📖 Next.js server vs client components


10. i18n — adding translations

Files: src/i18n/translations/en.ts and de.ts

We added nine new translation keys under the ttt. prefix:

KeyEnglishGerman
ttt.modeModeModus
ttt.vsPlayervs Playergegen Spieler
ttt.vsComputervs Computergegen Computer
ttt.difficultyDifficultySchwierigkeit
ttt.difficulty.easyEasyLeicht
ttt.difficulty.mediumMediumMittel
ttt.difficulty.hardHardSchwer
ttt.computerThinkingComputer is thinking…Computer denkt nach…
ttt.drawIt's a draw!Unentschieden!

The German translation file (de.ts) is typed as Record<keyof typeof en, string>, which means TypeScript will report an error if any key from en.ts is missing in de.ts. This guarantees both languages stay in sync.


11. How to replicate this from scratch

If you want to add a computer opponent to your own Tic-Tac-Toe (or any simple board game), here's the recommended order:

  1. Extract game logic into a pure module. Move calculateWinner and any other helpers out of your component into a separate .ts file. This gives you a clean foundation for the AI to build on.

  2. Implement minimax. Start with the base cases (win/loss/draw), then add the recursive evaluation. Test it in isolation — call getBestMove with known board states and verify it picks the right square.

  3. Add difficulty via randomness. Create a getComputerMove wrapper that rolls a random number and sometimes picks a random move instead of the optimal one. Tune the thresholds until each difficulty level feels right.

  4. Define types for mode and difficulty. GameMode = 'local' | 'computer' and Difficulty = 'easy' | 'medium' | 'hard' keep everything type-safe.

  5. Add props to your game component. Accept mode and difficulty with sensible defaults so existing usage doesn't break.

  6. Add a useEffect for the computer's turn. Watch for isComputerTurn becoming true, add a short delay for UX, and call getComputerMove. Don't forget the cleanup function to cancel pending timeouts.

  7. Block human input during the computer's turn. A guard clause at the top of your click handler is the simplest approach.

  8. Build the settings UI. A mode dropdown and a conditional difficulty dropdown. Use the key prop trick on the game component to reset state when settings change.

  9. Wire up the page. If using Next.js App Router, keep metadata in a server component and create a small client wrapper for state.

  10. Add translations (if your project supports i18n). Add keys for all new UI strings in every language file.


Generated for the react-playground project — covering the Tic-Tac-Toe computer opponent feature with minimax AI and difficulty levels.