Tic-Tac-Toe Computer Opponent - Feature Documentation
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
- What we built
- Tech stack at a glance
- Files overview — what changed and what's new
- The minimax algorithm
- Difficulty levels — controlled randomness
- The AI module:
tic-tac-toe-ai.ts - Updating the game component:
TicTacToe.tsx - The settings UI:
TicTacToeSettings.tsx - Wiring it together:
TicTacToePlayClient.tsx - i18n — adding translations
- 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
| Technology | Role in this feature | Learn more |
|---|---|---|
| React 19 | Component UI, state management, useEffect for AI moves | 📖 React docs |
| TypeScript | Type-safe props, difficulty/mode types | 📖 TypeScript handbook |
| Next.js 15 | Server/client component split (page.tsx vs client wrapper) | 📖 Next.js docs |
| Tailwind CSS | Styling for settings dropdowns and board | 📖 Tailwind docs |
| i18n (custom) | Translated labels for mode, difficulty, status messages | Project-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:
| Export | Purpose |
|---|---|
Difficulty | Type: 'easy' | 'medium' | 'hard' |
GameMode | Type: '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.
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.
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:
| Key | English | German |
|---|---|---|
ttt.mode | Mode | Modus |
ttt.vsPlayer | vs Player | gegen Spieler |
ttt.vsComputer | vs Computer | gegen Computer |
ttt.difficulty | Difficulty | Schwierigkeit |
ttt.difficulty.easy | Easy | Leicht |
ttt.difficulty.medium | Medium | Mittel |
ttt.difficulty.hard | Hard | Schwer |
ttt.computerThinking | Computer is thinking… | Computer denkt nach… |
ttt.draw | It'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:
-
Extract game logic into a pure module. Move
calculateWinnerand any other helpers out of your component into a separate.tsfile. This gives you a clean foundation for the AI to build on. -
Implement minimax. Start with the base cases (win/loss/draw), then add the recursive evaluation. Test it in isolation — call
getBestMovewith known board states and verify it picks the right square. -
Add difficulty via randomness. Create a
getComputerMovewrapper 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. -
Define types for mode and difficulty.
GameMode = 'local' | 'computer'andDifficulty = 'easy' | 'medium' | 'hard'keep everything type-safe. -
Add props to your game component. Accept
modeanddifficultywith sensible defaults so existing usage doesn't break. -
Add a
useEffectfor the computer's turn. Watch forisComputerTurnbecomingtrue, add a short delay for UX, and callgetComputerMove. Don't forget the cleanup function to cancel pending timeouts. -
Block human input during the computer's turn. A guard clause at the top of your click handler is the simplest approach.
-
Build the settings UI. A mode dropdown and a conditional difficulty dropdown. Use the
keyprop trick on the game component to reset state when settings change. -
Wire up the page. If using Next.js App Router, keep metadata in a server component and create a small client wrapper for state.
-
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.