Build Tic-Tac-Toe from Scratch with Vanilla JS
A step-by-step guide to building a complete Tic-Tac-Toe game using only HTML, CSS, and vanilla JavaScript. Covers game state management, win detection, and AI opponent — perfect for frontend interview prep.
Build Tic-Tac-Toe from Scratch with Vanilla JS
Tic-Tac-Toe is one of the most popular frontend interview questions. It tests your ability to manage game state, handle user interaction, detect win conditions, and structure clean code — all without a framework.
By the end of this tutorial, you'll have a fully working game with:
- Two-player mode — X and O alternate turns
- Win and draw detection — highlights winning cells
- AI opponent — a minimax-based unbeatable bot
- Restart functionality — clean state reset
- Keyboard accessibility — fully playable without a mouse
Why Interviewers Ask This
Tic-Tac-Toe seems simple, but it reveals how you:
- Model state — How do you represent the board? An array? A matrix?
- Detect win conditions — Can you enumerate all possible wins efficiently?
- Separate concerns — Is your rendering logic tangled with your game logic?
- Handle edge cases — What about draws? What about clicking an already-filled cell?
Step 1: HTML Structure
Start with a semantic HTML structure. The board is a 3×3 grid of buttons inside a container.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tic-Tac-Toe</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="game">
<h1>Tic-Tac-Toe</h1>
<div class="status" aria-live="polite">X's turn</div>
<div class="board" role="grid" aria-label="Tic-Tac-Toe board">
<button class="cell" data-index="0" role="gridcell" aria-label="Row 1, Column 1"></button>
<button class="cell" data-index="1" role="gridcell" aria-label="Row 1, Column 2"></button>
<button class="cell" data-index="2" role="gridcell" aria-label="Row 1, Column 3"></button>
<button class="cell" data-index="3" role="gridcell" aria-label="Row 2, Column 1"></button>
<button class="cell" data-index="4" role="gridcell" aria-label="Row 2, Column 2"></button>
<button class="cell" data-index="5" role="gridcell" aria-label="Row 2, Column 3"></button>
<button class="cell" data-index="6" role="gridcell" aria-label="Row 3, Column 1"></button>
<button class="cell" data-index="7" role="gridcell" aria-label="Row 3, Column 2"></button>
<button class="cell" data-index="8" role="gridcell" aria-label="Row 3, Column 3"></button>
</div>
<div class="controls">
<button class="btn restart-btn">Restart</button>
<button class="btn ai-btn">Play vs AI</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Key decisions:
- Each cell is a
<button>, not a<div>— buttons are focusable and keyboard-accessible by default. data-indexmaps each cell to a position in our flat array (0–8).aria-live="polite"on the status element means screen readers will announce turn changes and results.role="grid"androle="gridcell"give the board proper semantic structure.
Step 2: CSS Styling
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
}
.game {
text-align: center;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #e94560;
}
.status {
font-size: 1.25rem;
margin-bottom: 1rem;
min-height: 1.5em;
color: #e0e0e0;
}
.board {
display: grid;
grid-template-columns: repeat(3, 100px);
grid-template-rows: repeat(3, 100px);
gap: 4px;
margin: 0 auto 1.5rem;
background: #16213e;
padding: 4px;
border-radius: 8px;
}
.cell {
width: 100px;
height: 100px;
background: #0f3460;
border: none;
border-radius: 4px;
font-size: 2.5rem;
font-weight: bold;
color: #e0e0e0;
cursor: pointer;
transition: background 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.cell:hover:not(:disabled) {
background: #1a4a7a;
}
.cell:focus-visible {
outline: 3px solid #e94560;
outline-offset: -3px;
}
.cell:disabled {
cursor: not-allowed;
}
.cell.x {
color: #e94560;
}
.cell.o {
color: #00d2ff;
}
.cell.winner {
background: #16213e;
animation: pulse 0.6s ease-in-out infinite alternate;
}
@keyframes pulse {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
.controls {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.btn {
padding: 0.6rem 1.5rem;
font-size: 1rem;
border: 2px solid #e94560;
background: transparent;
color: #e94560;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:hover {
background: #e94560;
color: #1a1a2e;
}
.btn.active {
background: #e94560;
color: #1a1a2e;
}The grid uses CSS Grid — three columns and three rows of 100px each with a small gap. The dark theme uses high-contrast colors for X (red) and O (cyan) to ensure readability.
Step 3: JavaScript — Game Logic
Here's the full implementation. We'll break it down section by section afterward.
(function () {
'use strict';
// --- State ---
const EMPTY = '';
const PLAYER_X = 'X';
const PLAYER_O = 'O';
const WIN_COMBOS = [
[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(EMPTY);
let currentPlayer = PLAYER_X;
let gameOver = false;
let aiMode = false;
// --- DOM References ---
const cells = document.querySelectorAll('.cell');
const statusEl = document.querySelector('.status');
const restartBtn = document.querySelector('.restart-btn');
const aiBtn = document.querySelector('.ai-btn');
// --- Event Listeners ---
cells.forEach((cell) => {
cell.addEventListener('click', () => handleMove(cell));
});
restartBtn.addEventListener('click', restart);
aiBtn.addEventListener('click', toggleAI);
// --- Core Game Logic ---
function handleMove(cell) {
const index = Number(cell.dataset.index);
if (board[index] !== EMPTY || gameOver) return;
makeMove(index, currentPlayer);
if (gameOver) return;
if (aiMode && currentPlayer === PLAYER_O) {
// Slight delay so the player can see their move
disableAllCells();
setTimeout(() => {
const aiIndex = getBestMove(board, PLAYER_O);
if (aiIndex !== -1) {
makeMove(aiIndex, PLAYER_O);
}
enableEmptyCells();
}, 300);
}
}
function makeMove(index, player) {
board[index] = player;
render();
const winner = checkWinner(board);
if (winner) {
endGame(winner);
return;
}
if (board.every((cell) => cell !== EMPTY)) {
endGame(null); // draw
return;
}
currentPlayer = currentPlayer === PLAYER_X ? PLAYER_O : PLAYER_X;
statusEl.textContent = `${currentPlayer}'s turn`;
}
function checkWinner(b) {
for (const [a, c, d] of WIN_COMBOS) {
if (b[a] !== EMPTY && b[a] === b[c] && b[a] === b[d]) {
return { player: b[a], combo: [a, c, d] };
}
}
return null;
}
function endGame(result) {
gameOver = true;
if (result) {
statusEl.textContent = `${result.player} wins!`;
result.combo.forEach((i) => cells[i].classList.add('winner'));
} else {
statusEl.textContent = "It's a draw!";
}
disableAllCells();
}
// --- AI: Minimax ---
function getBestMove(b, player) {
let bestScore = -Infinity;
let bestIndex = -1;
for (let i = 0; i < 9; i++) {
if (b[i] !== EMPTY) continue;
b[i] = player;
const score = minimax(b, 0, false);
b[i] = EMPTY;
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
function minimax(b, depth, isMaximizing) {
const winner = checkWinner(b);
if (winner) {
return winner.player === PLAYER_O ? 10 - depth : depth - 10;
}
if (b.every((cell) => cell !== EMPTY)) {
return 0; // draw
}
if (isMaximizing) {
let best = -Infinity;
for (let i = 0; i < 9; i++) {
if (b[i] !== EMPTY) continue;
b[i] = PLAYER_O;
best = Math.max(best, minimax(b, depth + 1, false));
b[i] = EMPTY;
}
return best;
} else {
let best = Infinity;
for (let i = 0; i < 9; i++) {
if (b[i] !== EMPTY) continue;
b[i] = PLAYER_X;
best = Math.min(best, minimax(b, depth + 1, true));
b[i] = EMPTY;
}
return best;
}
}
// --- Rendering ---
function render() {
cells.forEach((cell, i) => {
cell.textContent = board[i];
cell.classList.remove('x', 'o');
cell.disabled = board[i] !== EMPTY || gameOver;
if (board[i] === PLAYER_X) cell.classList.add('x');
if (board[i] === PLAYER_O) cell.classList.add('o');
cell.setAttribute(
'aria-label',
board[i]
? `Row ${Math.floor(i / 3) + 1}, Column ${(i % 3) + 1}: ${board[i]}`
: `Row ${Math.floor(i / 3) + 1}, Column ${(i % 3) + 1}: empty`
);
});
}
function disableAllCells() {
cells.forEach((cell) => (cell.disabled = true));
}
function enableEmptyCells() {
cells.forEach((cell, i) => {
cell.disabled = board[i] !== EMPTY || gameOver;
});
}
// --- Controls ---
function restart() {
board = Array(9).fill(EMPTY);
currentPlayer = PLAYER_X;
gameOver = false;
statusEl.textContent = `${currentPlayer}'s turn`;
cells.forEach((cell) => {
cell.classList.remove('winner');
});
render();
}
function toggleAI() {
aiMode = !aiMode;
aiBtn.classList.toggle('active', aiMode);
aiBtn.textContent = aiMode ? 'Play vs Human' : 'Play vs AI';
restart();
}
})();Step 4: Code Walkthrough
State Representation
let board = Array(9).fill(EMPTY);We use a flat array of 9 elements rather than a 2D array. This is intentional — it maps directly to data-index attributes and makes win checking simple. Each element is '', 'X', or 'O'.
Why a flat array? The board has a fixed, small size. A flat array maps cleanly to DOM indices and keeps win-combo checking straightforward. A 2D array adds unnecessary indexing complexity for a 3×3 board.
Win Detection
const WIN_COMBOS = [
[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
];There are exactly 8 winning combinations in Tic-Tac-Toe: 3 rows, 3 columns, 2 diagonals. We store them as index triples and check whether all three cells in any triple contain the same non-empty value.
This is an exhaustive enumeration approach. For a 3×3 board this is the clearest and most efficient method. For larger boards (like Connect Four), you'd use a scanning approach instead.
The Minimax Algorithm
Minimax is a recursive algorithm that plays out every possible future move and picks the best one. It assumes the opponent also plays optimally.
minimax(board, depth, isMaximizing):
if someone won → return score
if board is full → return 0 (draw)
if isMaximizing (AI's turn):
try every empty cell as AI's move
return the maximum score among children
else (human's turn):
try every empty cell as human's move
return the minimum score among children
The depth parameter is used to prefer winning sooner rather than later (or losing later rather than sooner). A win at depth 2 scores higher than a win at depth 6.
Why minimax works here: Tic-Tac-Toe has at most 9! = 362,880 possible games, but in practice the branching factor shrinks rapidly. Minimax explores the full tree in milliseconds — no alpha-beta pruning needed.
Separation of Concerns
Notice the structure:
| Layer | Responsibility |
|---|---|
State (board, currentPlayer, gameOver) | Pure data, no DOM references |
Logic (makeMove, checkWinner, minimax) | Operates on the board array, returns data |
Rendering (render) | Reads state and updates the DOM |
Events (handleMove, restart, toggleAI) | Translates user actions into state changes |
This separation means you could test the game logic in isolation, swap the rendering layer for canvas, or replace the AI without touching the rest.
Step 5: Interview Variations
"Make the board size configurable (N×N)"
Change WIN_COMBOS from a hardcoded list to a dynamically generated one:
function generateWinCombos(size) {
const combos = [];
// Rows
for (let r = 0; r < size; r++) {
const row = [];
for (let c = 0; c < size; c++) row.push(r * size + c);
combos.push(row);
}
// Columns
for (let c = 0; c < size; c++) {
const col = [];
for (let r = 0; r < size; r++) col.push(r * size + c);
combos.push(col);
}
// Diagonals
const diag1 = [], diag2 = [];
for (let i = 0; i < size; i++) {
diag1.push(i * size + i);
diag2.push(i * size + (size - 1 - i));
}
combos.push(diag1, diag2);
return combos;
}For boards larger than 3×3, minimax becomes too slow. Add alpha-beta pruning:
function minimax(b, depth, isMaximizing, alpha, beta) {
const winner = checkWinner(b);
if (winner) return winner.player === PLAYER_O ? 10 - depth : depth - 10;
if (b.every((c) => c !== EMPTY)) return 0;
if (isMaximizing) {
let best = -Infinity;
for (let i = 0; i < b.length; i++) {
if (b[i] !== EMPTY) continue;
b[i] = PLAYER_O;
best = Math.max(best, minimax(b, depth + 1, false, alpha, beta));
b[i] = EMPTY;
alpha = Math.max(alpha, best);
if (beta <= alpha) break; // prune
}
return best;
} else {
let best = Infinity;
for (let i = 0; i < b.length; i++) {
if (b[i] !== EMPTY) continue;
b[i] = PLAYER_X;
best = Math.min(best, minimax(b, depth + 1, true, alpha, beta));
b[i] = EMPTY;
beta = Math.min(beta, best);
if (beta <= alpha) break; // prune
}
return best;
}
}"Add an undo/redo feature"
Use a move history stack:
let history = [];
let redoStack = [];
function makeMove(index, player) {
history.push({ index, player, board: [...board] });
redoStack = []; // clear redo on new move
board[index] = player;
render();
// ... rest of makeMove
}
function undo() {
if (history.length === 0) return;
const lastMove = history.pop();
redoStack.push(lastMove);
board = [...lastMove.board];
currentPlayer = lastMove.player;
gameOver = false;
render();
}
function redo() {
if (redoStack.length === 0) return;
const move = redoStack.pop();
history.push(move);
board[move.index] = move.player;
currentPlayer = move.player === PLAYER_X ? PLAYER_O : PLAYER_X;
render();
}"Add a score tracker"
let scores = { X: 0, O: 0, draws: 0 };
function endGame(result) {
gameOver = true;
if (result) {
scores[result.player]++;
statusEl.textContent = `${result.player} wins!`;
result.combo.forEach((i) => cells[i].classList.add('winner'));
} else {
scores.draws++;
statusEl.textContent = "It's a draw!";
}
updateScoreboard();
disableAllCells();
}Common Mistakes in Interviews
-
Not handling already-filled cells. Always check
board[index] !== EMPTYbefore placing a mark. -
Forgetting to check for draw. After every move, check if the board is full and no one has won.
-
Mutating state during rendering. Keep
render()as a pure reader of state — it should never modifyboardorcurrentPlayer. -
Not disabling cells after game over. Players can keep clicking after someone wins if you forget to disable cells or check
gameOver. -
Using div instead of button for cells. Buttons are natively focusable and keyboard-operable. Using divs requires manual
tabindexandkeydownhandlers.
Key Takeaway
Tic-Tac-Toe is a state machine: each move transitions the game between states (playing, X wins, O wins, draw). Model it as one. Keep your state minimal (a flat array + whose turn it is), derive everything else (winner, valid moves, display) from that state, and separate your logic from your DOM. That pattern scales to every interactive UI you'll build.
You might also like
Build Connect Four from Scratch with Vanilla JS
A complete guide to building Connect Four using only HTML, CSS, and vanilla JavaScript. Covers gravity-based piece dropping, win detection across rows/columns/diagonals, and AI opponent. Interview-ready implementation.
FrontendBuild Snake from Scratch with Vanilla JS
A complete guide to building the classic Snake game using only HTML, CSS, and vanilla JavaScript. Covers game loops, collision detection, growing mechanics, and responsive canvas rendering. Interview-ready implementation.
FrontendBuild a Hierarchical Checkbox from Scratch with Vanilla JS
A complete guide to building a hierarchical (tri-state) checkbox tree using only HTML, CSS, and vanilla JavaScript. Covers parent-child propagation, indeterminate state, dynamic trees, and accessibility. Interview-ready implementation.