JavaScript Game Tutorial
Tic-Tac-Toe in JavaScript with an Unbeatable Minimax AI
Tic-Tac-Toe is small enough that a computer can look at every possible future game before it moves. That makes it the ideal place to learn minimax, the algorithm behind many classic board game AIs.
Production context
This guide studies Tic-Tac-Toe, a published Supagames game. Repository source: games/lvl01/02-tic-tac-toe.html.
Try the Supagames Tic-Tac-Toe in vs AI mode and you will notice you cannot win. The best result is a draw. The code below shows exactly how that AI works, starting from the board and ending with optional difficulty levels.
The board is nine div cells in a CSS grid, a status line and two mode buttons. All game logic lives in a single array.
1. Represent the Board as Nine Cells
A flat array of nine strings is easier to work with than a 3x3 grid. Index 0 is the top left, index 8 the bottom right, and any cell can be found with row times 3 plus column. An empty string means the cell is free.
The DOM is built from the array once per game, with one click listener per cell that passes its index.
let board = ["", "", "", "", "", "", "", "", ""];
let currentPlayer = "X";
let gameActive = true;
let vsAI = false;
function createBoard() {
boardEl.innerHTML = "";
board.forEach((value, i) => {
const cell = document.createElement("div");
cell.className = "cell";
cell.textContent = value;
cell.addEventListener("click", () => handleCellClick(i));
boardEl.appendChild(cell);
});
}
2. Check Wins With a Table of Lines
There are only eight ways to win: three rows, three columns and two diagonals. List them once and a win check becomes a single line: is there any line where all three cells belong to the player?
Returning the winning line instead of true makes highlighting trivial, because the caller already knows which three cells to colour.
const LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
[0, 4, 8], [2, 4, 6], // diagonals
];
function winningLine(b, player) {
return LINES.find((line) => line.every((i) => b[i] === player)) || null;
}
const checkWin = (player) => winningLine(board, player) !== null;
3. Handle Turns, Wins and Draws
makeMove writes the mark, updates the cell, and then decides what happens next. A win ends the game, a full board without a win is a draw, and otherwise the turn passes to the other player.
In vs AI mode the human is always X. After the human moves, the AI is called with a short delay so the move feels like a reply rather than an instant flicker.
function makeMove(index, player) {
board[index] = player;
boardEl.children[index].textContent = player;
const line = winningLine(board, player);
if (line) {
gameActive = false;
line.forEach((i) => boardEl.children[i].classList.add("win"));
statusEl.textContent = player + " wins!";
return;
}
if (board.every((cell) => cell !== "")) {
gameActive = false;
statusEl.textContent = "Draw!";
return;
}
currentPlayer = player === "X" ? "O" : "X";
}
function handleCellClick(index) {
if (!gameActive || board[index] !== "") return;
if (vsAI && currentPlayer !== "X") return;
makeMove(index, currentPlayer);
if (gameActive && vsAI) setTimeout(aiMove, 300);
}
4. Score Every Future With Minimax
Minimax plays the game forward in its head. On the AI's turn it picks the move with the highest score, and on the human's turn it assumes the human picks the move with the lowest score. A finished game scores positive for an AI win, negative for a loss and zero for a draw.
Subtracting the depth is the detail that makes the AI feel smart. A win in one move scores 9, a win in three moves scores 7, so the AI takes the fastest win and delays an unavoidable loss as long as possible.
function minimax(b, depth, isMax) {
if (winningLine(b, "O")) return 10 - depth; // AI wins, sooner is better
if (winningLine(b, "X")) return depth - 10; // human wins, later is better
if (b.every((cell) => cell !== "")) return 0; // draw
let best = isMax ? -Infinity : Infinity;
for (let i = 0; i < 9; i++) {
if (b[i] !== "") continue;
b[i] = isMax ? "O" : "X";
const score = minimax(b, depth + 1, !isMax);
b[i] = "";
best = isMax ? Math.max(best, score) : Math.min(best, score);
}
return best;
}
5. Pick the Best Move
The AI tries each empty cell, scores the resulting position with minimax as if the human moves next, and keeps the highest score. The board is restored after every try, so the search never changes the real game.
The human always moves first, so the AI searches from a board with eight free cells. That full search covers every possible continuation, tens of thousands of positions, and still finishes in a fraction of a second in a modern browser.
function aiMove() {
if (!gameActive) return;
let bestScore = -Infinity;
let bestMove = -1;
for (let i = 0; i < 9; i++) {
if (board[i] !== "") continue;
board[i] = "O";
const score = minimax(board, 0, false);
board[i] = "";
if (score > bestScore) { bestScore = score; bestMove = i; }
}
if (bestMove !== -1) makeMove(bestMove, "O");
}
6. Skip Hopeless Branches With Alpha-Beta Pruning
Alpha-beta pruning returns exactly the same move as minimax but skips branches that cannot change the result. Alpha is the best score the AI is already guaranteed, beta the best the human is guaranteed. When they cross, the rest of that branch is irrelevant.
Tic-Tac-Toe does not need it, but it is the step that makes the same idea work for Connect Four or checkers, where full minimax would be far too slow.
function alphabeta(b, depth, isMax, alpha, beta) {
if (winningLine(b, "O")) return 10 - depth;
if (winningLine(b, "X")) return depth - 10;
if (b.every((cell) => cell !== "")) return 0;
for (let i = 0; i < 9; i++) {
if (b[i] !== "") continue;
b[i] = isMax ? "O" : "X";
const score = alphabeta(b, depth + 1, !isMax, alpha, beta);
b[i] = "";
if (isMax) alpha = Math.max(alpha, score);
else beta = Math.min(beta, score);
if (alpha >= beta) break; // prune
}
return isMax ? alpha : beta;
}
// call: alphabeta(board, 0, false, -Infinity, Infinity)
7. Make the AI Beatable With Difficulty Levels
A perfect opponent is impressive for a minute and frustrating after that. The easiest difficulty system is a mistake rate: sometimes play a random free cell, otherwise play the minimax move.
A 50 percent mistake rate feels like an easy opponent, 20 percent like a decent player, and 0 percent is the unbeatable mode used by the Supagames game.
const MISTAKE_RATE = { easy: 0.5, normal: 0.2, impossible: 0 };
function chooseAiMove(level) {
const free = board.map((cell, i) => (cell === "" ? i : -1)).filter((i) => i !== -1);
if (Math.random() < MISTAKE_RATE[level]) {
return free[Math.floor(Math.random() * free.length)];
}
let best = -Infinity, move = free[0];
for (const i of free) {
board[i] = "O";
const score = minimax(board, 0, false);
board[i] = "";
if (score > best) { best = score; move = i; }
}
return move;
}
8. Build checklist
- Store the board as a flat array of nine cells.
- Check wins against a fixed table of eight lines and return the winning line.
- Decide win, draw or next turn in one makeMove function.
- Score finished games with depth so the AI prefers fast wins.
- Restore the board after every simulated move.
- Add a mistake rate if players should be able to win.
9. FAQ
Is a minimax Tic-Tac-Toe AI really unbeatable?
Yes. Tic-Tac-Toe is a solved game: with perfect play from both sides it always ends in a draw. Minimax plays perfectly, so the best a human can do against it is draw.
How many positions are there in Tic-Tac-Toe?
There are 5,478 distinct legal positions and 255,168 possible games when you count every different move order. That is small enough for minimax to search completely.
Why does the minimax score subtract the depth?
Without depth, a win now and a win in five moves score the same, so the AI may wander. Subtracting depth makes quicker wins worth more and slower losses less bad, which looks much more natural.
Can minimax be used for chess?
The idea is the same, but chess is far too large to search to the end. Chess engines limit the search depth, score unfinished positions with an evaluation function, and rely heavily on alpha-beta pruning and move ordering.