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:
- Structuring a game interface with semantic HTML
- Styling a responsive grid with CSS Grid or Flexbox
- Managing game state with plain JavaScript objects and arrays
- Handling user interactions via event listeners
- Implementing win-detection algorithms
- Updating the DOM dynamically based on state changes
- Writing clean, modular code with separation of concerns
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:
- Each cell carries a
data-indexattribute (0 through 8) that maps to a flat array representation of the board. This makes it trivial to correlate DOM elements with array positions. - The status display is a dedicated element that we will update dynamically to show turns, wins, and draws.
- The reset button sits below the board for easy access.
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:
- The
.boarduses CSS Grid with equal columns and rows, plus anaspect-ratio: 1/1to keep it square. - Cells get a hover effect only when they do not have the
.takenclass, preventing misleading hover states on occupied cells. - The
.winning-cellclass adds a celebratory green background with a subtle pulse animation, which we will apply dynamically via JavaScript when a player wins. - The status display has distinct styles for
.winand.drawstates.
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:
- Guard clauses at the top exit early if the game is over or the cell is already taken. This keeps the main logic un-nested and readable.
- The board state array is updated before we check for a winner, ensuring the check operates on the latest data.
- Winning cells are highlighted by iterating over the returned
comboarray and adding thewinning-cellCSS class. - Turn switching uses a concise ternary expression.
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.