Tic-Tac-Toe
Tic-Tac-Toe
Overview
Tic-Tac-Toe is the perfect introduction to game-playing AI. With only 255,168 possible game states, we can compute the perfect strategy using the minimax algorithm.
Rules
Two players take turns marking spaces on a 3x3 grid. The first player to get three marks in a row (horizontally, vertically, or diagonally) wins. If all nine spaces are filled with no winner, the game is a draw.
The Minimax Algorithm
The minimax algorithm evaluates game positions by:
- Recursively exploring all possible future moves
- Assigning scores to terminal positions (1 for win, -1 for loss, 0 for draw)
- Working backward to the current position, with each player choosing the move that maximizes their score
minimax(position, depth, isMaximizing):
if position is terminal:
return score
if isMaximizing:
maxScore = -∞
for each move in position:
score = minimax(nextPosition, depth+1, false)
maxScore = max(maxScore, score)
return maxScore
else:
minScore = +∞
for each move in position:
score = minimax(nextPosition, depth+1, true)
minScore = min(minScore, score)
return minScore
Key Properties
- Perfect Play: The minimax algorithm computes the optimal move for any position
- Game Outcome: With perfect play from both sides, the game always ends in a draw
- Computation: The AI must evaluate approximately 5,478 positions on average
Interactive Demo
Play against the unbeatable AI:
Click to place your mark (X). The AI will play as O.
The Negamax Algorithm
Negamax is an elegant variant of minimax that simplifies code by:
- Always maximizing from the current player’s perspective (not just one player)
- Negating child scores instead of alternating between min/max branches
- Results in 50% less code while maintaining identical behavior
Traditional Minimax: Negamax:
- if isMaximizing: - Always: maxScore = max(score)
maxScore = max(...) - Negate child: -negamax(child)
- else:
minScore = min(...)
Implementation Notes
The provided implementation uses the clean negamax algorithm:
function negamax(board, depth) {
const score = evaluateBoard(board);
// Terminal states
if (score !== 0) {
return score - depth * (score > 0 ? 1 : -1); // Prefer faster wins
}
if (isBoardFull(board)) {
return 0; // Draw
}
// Always maximize from current player's view
let maxScore = -Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] === 0) {
board[i] = -1; // Current player moves
let score = -negamax(board, depth + 1); // Negate child score!
board[i] = 0;
maxScore = Math.max(maxScore, score);
}
}
return maxScore;
}
function findBestMove(board) {
let bestScore = -Infinity;
let bestMove = 0;
for (let i = 0; i < 9; i++) {
if (board[i] === 0) {
board[i] = -1;
let score = -negamax(board, 0); // Negate at root level
board[i] = 0;
if (score > bestScore) {
bestScore = score;
bestMove = i;
}
}
}
return bestMove;
}
Why Negamax is Better
| Aspect | Minimax | Negamax |
|---|---|---|
| Code Complexity | 2 branches (max/min) | 1 branch (max only) |
| Lines of Code | ~40 | ~20 |
| Readability | Separate logic | Unified logic |
| Performance | Identical | Identical |
| Scores | Sign alternates | Relative to player |
Key Insight: Score Negation
When evaluating a position from your opponent’s perspective, their gain is your loss. So:
- My best move scores +10
- From opponent’s view, it scores -10
- When I get their score back, I negate it: -(-10) = +10 ✓
Board Representation:
- 1D array of 9 numbers: indices 0-8
- Values: 0 = empty, 1 = your X, -1 = AI’s O
Scoring System:
- +10: Current player wins
- -10: Current player loses
- 0: Draw
- Depth adjustment: Prefers faster wins, delays losses
Where to find it: /assets/js/games.js (lines 5-40)
Code on GitHub
Check out the complete implementation on GitHub with detailed comments and performance notes.
Next Steps
Ready for more complexity? Check out Checkers to learn about alpha-beta pruning, an optimization that reduces computation by 100-1000x.