← Back to DevBytes

How to Build a Tic-Tac-Toe Game with JavaScript

Building a Tic-Tac-Toe Game with JavaScript

Tic-Tac-Toe is one of the most iconic starter projects for any front-end developer. It may seem simple on the surface, but it packs a surprising amount of essential programming concepts into a single, digestible application. In this tutorial, you will build a fully functional, browser-based Tic-Tac-Toe game from scratch using vanilla HTML, CSS, and JavaScript. By the end, you will have a working game that handles player turns, detects wins and draws, and allows for quick resets — all with clean, maintainable code.

What This Project Covers

This project is a two-player game played on a 3x3 grid. Players alternate between placing X and O marks on the board. The game checks for a winner after every move by evaluating all possible winning combinations (rows, columns, and diagonals). If the board fills up without a winner, the game declares a draw. A reset button allows players to start fresh at any time.

From a technical perspective, you will practice:

Why This Project Matters

Building a game like Tic-Tac-Toe forces you to think in terms of state management and event-driven architecture. Unlike static web pages, games require constant synchronization between internal data and the visual representation on screen. This is exactly the mental model used in modern frameworks like React, Vue, and Angular — but understanding it in vanilla JavaScript first gives you a rock-solid foundation.

Additionally, the win-detection logic introduces algorithmic thinking. You must iterate over predefined patterns and compare board values efficiently. This small project is a microcosm of larger application development: you handle user input, maintain state integrity, perform validation, and update the UI — all in real time.

Step 1: Setting Up the HTML Structure

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Start with a minimal but semantic HTML document. The game board will be a container holding nine clickable cells. We also need a status display to show whose turn it is or announce the winner, and a reset button to restart the game.

<!-- index.html -->
<!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="container">
    <h1>Tic-Tac-Toe</h1>
    <div class="status" id="statusDisplay">Player X's turn</div>
    <div class="board" id="board">
      <div class="cell" data-index="0"></div>
      <div class="cell" data-index="1"></div>
      <div class="cell" data-index="2"></div>
      <div class="cell" data-index="3"></div>
      <div class="cell" data-index="4"></div>
      <div class="cell" data-index="5"></div>
      <div class="cell" data-index="6"></div>
      <div class="cell" data-index="7"></div>
      <div class="cell" data-index="8"></div>
    </div>
    <button class="reset-btn" id="resetButton">Reset Game</button>
  </div>
  <script src="script.js"></script>
</body>
</html>

Key structural decisions:

Step 2: Styling with CSS

Good styling makes the game feel tangible. We will use CSS Grid for the 3x3 layout, add hover effects for empty cells, and style the status and reset button for a polished look.

/* style.css */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.container {
  background: #ffffff;
  border-radius: 16px;
  padding: 2.5rem 2rem;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  text-align: center;
  max-width: 400px;
  width: 100%;
}

h1 {
  font-size: 2.5rem;
  color: #333;
  margin-bottom: 1rem;
  letter-spacing: 2px;
}

.status {
  font-size: 1.2rem;
  font-weight: 600;
  color: #555;
  margin-bottom: 1.5rem;
  min-height: 1.5em;
  transition: color 0.3s ease;
}

.status.win {
  color: #2ecc71;
  font-size: 1.4rem;
}

.status.draw {
  color: #e67e22;
}

.board {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, 1fr);
  gap: 8px;
  margin: 0 auto 1.5rem;
  max-width: 300px;
  aspect-ratio: 1 / 1;
}

.cell {
  background: #f0f0f0;
  border-radius: 8px;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 2.5rem;
  font-weight: bold;
  color: #333;
  transition: background 0.2s, transform 0.1s;
  user-select: none;
  aspect-ratio: 1 / 1;
}

.cell:hover:not(.taken) {
  background: #e0e0ff;
  transform: scale(1.03);
}

.cell.taken {
  cursor: not-allowed;
  opacity: 1;
}

.cell.winning-cell {
  background: #2ecc71;
  color: #fff;
  animation: pulse 0.6s ease-in-out;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.1); }
}

.reset-btn {
  background: #667eea;
  color: #fff;
  border: none;
  padding: 0.75rem 2rem;
  border-radius: 30px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.3s, transform 0.1s;
  letter-spacing: 0.5px;
}

.reset-btn:hover {
  background: #5a6fd6;
  transform: translateY(-2px);
}

.reset-btn:active {
  transform: scale(0.96);
}

Styling highlights:

Step 3: JavaScript Game Logic — The Core Engine

This is where the real magic happens. We will structure the JavaScript into clearly defined sections: state initialization, helper functions, the main move handler, win/draw detection, and DOM rendering.

3.1: Initialize Game State

We represent the board as a flat array of 9 strings. Each index corresponds to a cell's data-index. The initial value is an empty string ''. We track the current player, a flag for whether the game is active, and cache DOM references for efficiency.

// script.js

// --- Game State ---
let boardState = ['', '', '', '', '', '', '', '', ''];
let currentPlayer = 'X';
let gameActive = true;

// --- Cached DOM Elements ---
const statusDisplay = document.getElementById('statusDisplay');
const boardElement = document.getElementById('board');
const resetButton = document.getElementById('resetButton');
const cells = document.querySelectorAll('.cell');

3.2: Winning Combinations

Define all eight possible winning lines as arrays of indices. This declarative list makes the win-checking function straightforward and easy to verify.

const winningCombinations = [
  [0, 1, 2], // top row
  [3, 4, 5], // middle row
  [6, 7, 8], // bottom row
  [0, 3, 6], // left column
  [1, 4, 7], // middle column
  [2, 5, 8], // right column
  [0, 4, 8], // diagonal top-left to bottom-right
  [2, 4, 6]  // diagonal top-right to bottom-left
];

3.3: The Win-Checking Function

This function iterates through winningCombinations and checks if all three indices in a combination hold the same non-empty value. If a winner is found, it returns an object with the winner's symbol and the winning indices so we can highlight them. If no winner and the board is full, it returns a draw indicator. Otherwise, it returns null to signal that play continues.

function checkWinner(board) {
  // Check each winning combination
  for (const combo of winningCombinations) {
    const [a, b, c] = combo;
    if (board[a] && board[a] === board[b] && board[a] === board[c]) {
      return { winner: board[a], combo };
    }
  }

  // Check for draw (board full with no winner)
  if (board.every(cell => cell !== '')) {
    return { winner: 'draw' };
  }

  // Game continues
  return null;
}

This function is pure: it takes the board array as input and returns a result without mutating anything. This makes it easy to test and reason about.

3.4: Handling a Player Move

The handleCellClick function is the heart of the game. It validates the move, updates the board state, checks for a result, and either ends the game or switches turns.

function handleCellClick(event) {
  // Get the cell element and its index
  const cell = event.target;
  const index = parseInt(cell.getAttribute('data-index'), 10);

  // Guard clauses: exit early if invalid move
  if (!gameActive) return;
  if (boardState[index] !== '') return;

  // Update board state
  boardState[index] = currentPlayer;

  // Update the cell visually
  cell.textContent = currentPlayer;
  cell.classList.add('taken');

  // Check for a result
  const result = checkWinner(boardState);

  if (result) {
    if (result.winner === 'draw') {
      statusDisplay.textContent = "It's a draw!";
      statusDisplay.className = 'status draw';
      gameActive = false;
    } else {
      statusDisplay.textContent = `Player ${result.winner} wins! 🎉`;
      statusDisplay.className = 'status win';
      gameActive = false;

      // Highlight winning cells
      result.combo.forEach(idx => {
        cells[idx].classList.add('winning-cell');
      });
    }
    return;
  }

  // Switch turns
  currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
  statusDisplay.textContent = `Player ${currentPlayer}'s turn`;
  statusDisplay.className = 'status';
}

Important patterns in this function:

3.5: The Reset Function

Resetting the game means clearing the board state array, resetting the player to X, re-enabling the game, wiping the DOM cells, and restoring the status display.

function resetGame() {
  // Reset state
  boardState = ['', '', '', '', '', '', '', '', ''];
  currentPlayer = 'X';
  gameActive = true;

  // Reset DOM cells
  cells.forEach(cell => {
    cell.textContent = '';
    cell.classList.remove('taken', 'winning-cell');
  });

  // Reset status
  statusDisplay.textContent = "Player X's turn";
  statusDisplay.className = 'status';
}

3.6: Event Binding

We attach event listeners using event delegation on the board element rather than adding a listener to each individual cell. This is more performant and cleaner — one listener handles all nine cells by checking the event.target.

// Event delegation on the board
boardElement.addEventListener('click', (event) => {
  // Only respond to clicks on cell elements
  if (event.target.classList.contains('cell')) {
    handleCellClick(event);
  }
});

// Reset button listener
resetButton.addEventListener('click', resetGame);

Event delegation means if we were to dynamically add or remove cells (which we don't here, but it's a good habit), the listener would still work. It also reduces memory usage by having one listener instead of nine.

Step 4: The Complete JavaScript File

Below is the entire script.js file assembled from all the pieces above. You can copy this directly into your project.

// script.js — Complete Tic-Tac-Toe Game

// --- Game State ---
let boardState = ['', '', '', '', '', '', '', '', ''];
let currentPlayer = 'X';
let gameActive = true;

// --- Cached DOM Elements ---
const statusDisplay = document.getElementById('statusDisplay');
const boardElement = document.getElementById('board');
const resetButton = document.getElementById('resetButton');
const cells = document.querySelectorAll('.cell');

// --- Winning Combinations ---
const winningCombinations = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  [0, 4, 8],
  [2, 4, 6]
];

// --- Check Winner ---
function checkWinner(board) {
  for (const combo of winningCombinations) {
    const [a, b, c] = combo;
    if (board[a] && board[a] === board[b] && board[a] === board[c]) {
      return { winner: board[a], combo };
    }
  }
  if (board.every(cell => cell !== '')) {
    return { winner: 'draw' };
  }
  return null;
}

// --- Handle Cell Click ---
function handleCellClick(event) {
  const cell = event.target;
  const index = parseInt(cell.getAttribute('data-index'), 10);

  if (!gameActive) return;
  if (boardState[index] !== '') return;

  boardState[index] = currentPlayer;
  cell.textContent = currentPlayer;
  cell.classList.add('taken');

  const result = checkWinner(boardState);

  if (result) {
    if (result.winner === 'draw') {
      statusDisplay.textContent = "It's a draw!";
      statusDisplay.className = 'status draw';
      gameActive = false;
    } else {
      statusDisplay.textContent = `Player ${result.winner} wins! 🎉`;
      statusDisplay.className = 'status win';
      gameActive = false;
      result.combo.forEach(idx => {
        cells[idx].classList.add('winning-cell');
      });
    }
    return;
  }

  currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
  statusDisplay.textContent = `Player ${currentPlayer}'s turn`;
  statusDisplay.className = 'status';
}

// --- Reset Game ---
function resetGame() {
  boardState = ['', '', '', '', '', '', '', '', ''];
  currentPlayer = 'X';
  gameActive = true;
  cells.forEach(cell => {
    cell.textContent = '';
    cell.classList.remove('taken', 'winning-cell');
  });
  statusDisplay.textContent = "Player X's turn";
  statusDisplay.className = 'status';
}

// --- Event Binding ---
boardElement.addEventListener('click', (event) => {
  if (event.target.classList.contains('cell')) {
    handleCellClick(event);
  }
});
resetButton.addEventListener('click', resetGame);

Best Practices and Pro Tips

1. Keep State in One Place

The boardState array is the single source of truth. The DOM is a reflection of this state, never the authority. When you need to know what's on the board, read the array — not the cell's textContent. This separation prevents subtle bugs where the UI and data drift apart.

2. Use Pure Functions Where Possible

The checkWinner function takes the board as an argument and returns a result without mutating anything. Pure functions are easier to test, debug, and even reuse if you later add features like an undo stack or move validation.

3. Event Delegation Over Individual Listeners

Attaching one listener to the board container and using event.target to identify the clicked cell is more efficient and scales better. If you ever extend the board (e.g., a 4x4 variant), you don't need to rebind listeners.

4. Guard Clauses for Readability

Early returns for invalid moves keep the happy path logic un-indented and linear. Compare:

// Less readable — deeply nested
function handleMove(index) {
  if (gameActive) {
    if (boardState[index] === '') {
      // actual logic
    }
  }
}

// Better — flat and clear
function handleMove(index) {
  if (!gameActive) return;
  if (boardState[index] !== '') return;
  // actual logic
}

5. Cache DOM References

Querying the DOM repeatedly inside functions is wasteful. Store references to elements you use often (statusDisplay, cells) in variables at the top of your script. This also makes your code more resistant to layout changes.

6. Separate Concerns

Notice how checkWinner only deals with logic, handleCellClick orchestrates state and DOM updates, and resetGame handles cleanup. Each function has a single, clear responsibility. This modularity makes the code maintainable and extensible.

7. Add a Computer Opponent as an Extension

Once the core game works, you can extend it by adding an AI opponent. A simple approach is to implement a function that picks a random empty cell after the human's move. For a more challenging AI, implement the minimax algorithm to make the computer play optimally. Because your architecture keeps state and logic separate, adding an AI player requires only a new function that modifies boardState and triggers the same DOM update path.

Conclusion

You have now built a complete, polished Tic-Tac-Toe game using vanilla JavaScript. More importantly, you have practiced patterns that transcend this single project: maintaining a single source of truth, separating game logic from rendering, using event delegation, writing pure functions, and structuring code with guard clauses. These habits will serve you in every subsequent web application you build, whether you stick with vanilla JS or move to a framework. The game is small, but the lessons are large. Take this foundation and experiment — add score tracking, implement an AI opponent, or restyle it with a unique theme. The code is yours to extend and enjoy.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles