Back to Frontend
Evergreen··11 min read

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.

javascriptvanilla-jsinterviewsgamehtmlcss
Share

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:

  1. Model state — How do you represent the board? An array? A matrix?
  2. Detect win conditions — Can you enumerate all possible wins efficiently?
  3. Separate concerns — Is your rendering logic tangled with your game logic?
  4. 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-index maps 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" and role="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:

LayerResponsibility
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

  1. Not handling already-filled cells. Always check board[index] !== EMPTY before placing a mark.

  2. Forgetting to check for draw. After every move, check if the board is full and no one has won.

  3. Mutating state during rendering. Keep render() as a pure reader of state — it should never modify board or currentPlayer.

  4. Not disabling cells after game over. Players can keep clicking after someone wins if you forget to disable cells or check gameOver.

  5. Using div instead of button for cells. Buttons are natively focusable and keyboard-operable. Using divs requires manual tabindex and keydown handlers.


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