How to Build Tic-Tac-Toe in JavaScript (With an Unbeatable AI)
If you can build tic-tac-toe in JavaScript, you can build pretty much any small browser game. The skeleton is the same: state, render, input loop, win check, possibly an AI. This article walks through every line, from an empty HTML file to a working game with an opponent that has never lost.
This tutorial assumes you know HTML, CSS, and JavaScript basics — variables, functions, arrays, DOM manipulation, event listeners. You don't need to know anything about game programming or algorithms. We'll build the whole thing from scratch with no framework, no build step, no dependencies. Just three files.
What we're building
A browser tic-tac-toe game with:
- A clickable 3×3 board with X and O marks.
- Two-player mode (two humans share the keyboard/mouse).
- An AI opponent with three difficulty levels (random, smart-rules, unbeatable).
- Score tracking that persists via
localStorage.
The finished game is essentially what's running at /games/classic.html — you can view the source there to see a polished version. This tutorial covers the simpler core.
Step 1: The HTML skeleton
Create a file called index.html. Inside, set up a minimal page structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tic-Tac-Toe</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Tic-Tac-Toe</h1>
<p id="status">X to move</p>
<div id="board">
<button class="cell" data-i="0"></button>
<button class="cell" data-i="1"></button>
<button class="cell" data-i="2"></button>
<button class="cell" data-i="3"></button>
<button class="cell" data-i="4"></button>
<button class="cell" data-i="5"></button>
<button class="cell" data-i="6"></button>
<button class="cell" data-i="7"></button>
<button class="cell" data-i="8"></button>
</div>
<button id="reset">New Game</button>
<script src="game.js"></script>
</body>
</html>
Nine buttons in a div, each with a data-i attribute marking its index. A status paragraph and a reset button complete the structure. Notice we use <button> elements — this makes the board keyboard-accessible automatically.
Step 2: Basic CSS
Create style.css:
body {
font-family: system-ui, sans-serif;
text-align: center;
padding: 2rem;
}
#board {
display: grid;
grid-template-columns: repeat(3, 80px);
gap: 4px;
background: #333;
padding: 4px;
width: max-content;
margin: 1rem auto;
border-radius: 8px;
}
.cell {
background: #fff;
border: none;
font-size: 2.5rem;
font-weight: 900;
width: 80px;
height: 80px;
cursor: pointer;
border-radius: 4px;
}
.cell:hover:not(:disabled) {
background: #f0e8d0;
}
.cell:disabled {
cursor: not-allowed;
}
.cell.x { color: #d94f3a; }
.cell.o { color: #2c5f8d; }
.cell.win { background: #ffe066; }
#status {
font-size: 1.2rem;
font-weight: 700;
min-height: 1.5rem;
}
This is the minimum styling needed. The grid layout gives us the 3×3 arrangement, the cells get a flat-paper look, and X's and O's get distinct colors. You can prettify later.
Step 3: State and the game loop
Create game.js:
const WIN_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
];
let board = Array(9).fill(null);
let currentPlayer = 'X';
let gameOver = false;
const cells = document.querySelectorAll('.cell');
const statusEl = document.getElementById('status');
const resetBtn = document.getElementById('reset');
The state is just three variables: a 9-element array for the board (each entry null, 'X', or 'O'), a string for whose turn it is, and a flag for whether the game is over. The eight winning lines are precomputed as triples of indices.
Step 4: Win detection
function checkWin(board, player) {
for (const line of WIN_LINES) {
if (line.every(i => board[i] === player)) {
return line;
}
}
return null;
}
function isDraw(board) {
return board.every(cell => cell !== null);
}
The win-check returns the winning line if there is one, or null if there isn't. Returning the line is useful because we'll use it to highlight the winning cells later. The draw-check is just "are all cells filled?" — call it after checking for a win.
Step 5: Making moves
function playMove(i) {
if (gameOver || board[i] !== null) return;
board[i] = currentPlayer;
cells[i].textContent = currentPlayer;
cells[i].classList.add(currentPlayer.toLowerCase());
cells[i].disabled = true;
const winLine = checkWin(board, currentPlayer);
if (winLine) {
statusEl.textContent = `${currentPlayer} wins!`;
winLine.forEach(i => cells[i].classList.add('win'));
gameOver = true;
cells.forEach(c => c.disabled = true);
return;
}
if (isDraw(board)) {
statusEl.textContent = "It's a draw!";
gameOver = true;
return;
}
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
statusEl.textContent = `${currentPlayer} to move`;
}
A single move does several things: update the board state, update the DOM (text + class + disabled), check for a win or draw, and either end the game or switch players. This is the core of the entire program.
Step 6: Wiring up clicks and the reset button
cells.forEach(cell => {
cell.addEventListener('click', () => {
const i = parseInt(cell.dataset.i, 10);
playMove(i);
});
});
resetBtn.addEventListener('click', () => {
board = Array(9).fill(null);
currentPlayer = 'X';
gameOver = false;
cells.forEach(c => {
c.textContent = '';
c.classList.remove('x', 'o', 'win');
c.disabled = false;
});
statusEl.textContent = 'X to move';
});
Open index.html in a browser. You now have a working two-player tic-tac-toe game. Try it out, beat your imaginary opponent, hit Reset, repeat.
Step 7: Adding a random AI
Let's add a single-player mode. First, a function that picks a random empty cell:
function randomAIMove(board) {
const empty = [];
for (let i = 0; i < 9; i++) {
if (board[i] === null) empty.push(i);
}
return empty[Math.floor(Math.random() * empty.length)];
}
Then modify your playMove to trigger an AI response when it's the AI's turn. We'll say the AI is always O for simplicity:
const AI_PLAYER = 'O';
const AI_ENABLED = true;
// Inside playMove, after the player switch, add:
if (!gameOver && AI_ENABLED && currentPlayer === AI_PLAYER) {
setTimeout(() => {
const aiMove = randomAIMove(board);
playMove(aiMove);
}, 400);
}
The setTimeout with a small delay makes the AI feel less robotic — it "thinks" for a moment before responding. Try playing against it. The random AI is easy to beat but it's a useful intermediate step.
Step 8: The unbeatable AI (minimax with alpha-beta)
Now the real prize. Here's the full minimax implementation:
function minimax(board, currentTurn, aiPlayer, depth, alpha, beta) {
const opponent = aiPlayer === 'X' ? 'O' : 'X';
if (checkWin(board, aiPlayer)) return { score: 10 - depth };
if (checkWin(board, opponent)) return { score: depth - 10 };
if (isDraw(board)) return { score: 0 };
const moves = [];
for (let i = 0; i < 9; i++) if (board[i] === null) moves.push(i);
let best;
if (currentTurn === aiPlayer) {
best = { score: -Infinity };
for (const m of moves) {
board[m] = currentTurn;
const result = minimax(board, opponent, aiPlayer, depth + 1, alpha, beta);
board[m] = null;
if (result.score > best.score) {
best = { score: result.score, move: m };
}
alpha = Math.max(alpha, result.score);
if (beta <= alpha) break;
}
} else {
best = { score: Infinity };
for (const m of moves) {
board[m] = currentTurn;
const result = minimax(board, aiPlayer, aiPlayer, depth + 1, alpha, beta);
board[m] = null;
if (result.score < best.score) {
best = { score: result.score, move: m };
}
beta = Math.min(beta, result.score);
if (beta <= alpha) break;
}
}
return best;
}
function perfectAIMove(board, aiPlayer) {
const result = minimax(board.slice(), aiPlayer, aiPlayer, 0, -Infinity, Infinity);
return result.move;
}
That's the full unbeatable algorithm with alpha-beta pruning. Replace randomAIMove with perfectAIMove in your game loop and you have an opponent that will never lose. We covered the conceptual basics in our minimax article; the code above is the production-ready version.
Step 9: Difficulty levels
Real games have difficulty selection. The cleanest way to implement this is to mix random and optimal play probabilistically:
function aiMove(board, aiPlayer, difficulty) {
let chooseOptimal;
switch (difficulty) {
case 'easy': chooseOptimal = Math.random() < 0.15; break;
case 'medium': chooseOptimal = Math.random() < 0.55; break;
case 'hard': chooseOptimal = Math.random() < 0.85; break;
case 'impossible': chooseOptimal = true; break;
default: chooseOptimal = true;
}
return chooseOptimal ? perfectAIMove(board, aiPlayer) : randomAIMove(board);
}
On Easy, the AI plays optimally only 15% of the time. The other 85% of moves are random — easy to capitalize on. Medium is closer to a coin flip. Hard is challenging. Impossible never makes a mistake. This is the same logic our production AI uses on the live site.
Add a <select> dropdown in your HTML for the user to pick the difficulty, read its value in your AI-move handler, and you have a properly featured game.
Step 10 (bonus): Score tracking with localStorage
To make scores persist across page reloads:
function loadScores() {
try {
const raw = localStorage.getItem('ttt-scores');
return raw ? JSON.parse(raw) : { X: 0, O: 0, D: 0 };
} catch (e) {
return { X: 0, O: 0, D: 0 };
}
}
function saveScores(scores) {
try {
localStorage.setItem('ttt-scores', JSON.stringify(scores));
} catch (e) {}
}
Call loadScores() at startup, update the scores when a game ends, and call saveScores() to persist. The user's win/loss record now survives reloads.
Where to go from here
You've now built what's essentially the production game on this site. Ideas for going further:
- Bigger boards (4×4, 5×5): Modify the board to be configurable, precompute the win lines dynamically, and use depth-limited minimax with a heuristic for non-terminal positions. Our Big Board game does exactly this.
- Misère mode: Flip the score signs in the terminal return values of minimax. Suddenly your AI plays the reverse-rules variant perfectly. See our Misère strategy article.
- Ultimate Tic-Tac-Toe: The big one. Nine boards in one, with the "your move dictates their next board" rule. Significantly more state and a deeper heuristic, but the same core algorithm. See our implementation.
- Multiplayer over the network: Use WebSockets to let two players in different browsers share a game. This adds significant complexity but is a great learning project.
If you've followed along to here, you've built one of the foundational projects in computer science. The same skeleton — represent state, render, take input, check win conditions, possibly search — underlies almost every game ever made.
Related reading
- How to Build Tic-Tac-Toe in Python — the same project in Python
- How the AI Thinks: Minimax Explained Simply — the algorithm in depth
- How to Never Lose at Tic-Tac-Toe — the human version of what your AI is doing