Tic tac toe in JavaScript, in a single HTML file

A tic tac toe game is one array of nine cells, a list of eight winning lines and one click handler. Play a working version here, copy the code, and see the mistakes that break it.

A tic tac toe game needs three things: an array of nine cells, a list of the eight winning lines, and a click handler that fills a cell and checks for a result.

Put them in one HTML file with a little CSS and the game runs when you open the file or share a link. Play this version first: two players, X goes first, and the status line announces a win or a draw.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tic tac toe</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; text-align: center; background: #f4f5f7; }
  #msg { font-size: 20px; font-weight: 700; margin-bottom: 12px; }
  #board {
    display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
    width: min(100%, 300px); margin: 0 auto 14px;
  }
  #board button {
    aspect-ratio: 1; font-size: 44px; font-weight: 700; line-height: 1;
    border: 0; border-radius: 10px; background: #fff; cursor: pointer;
    box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
  }
  #reset { font-size: 16px; padding: 8px 16px; border-radius: 8px; border: 1px solid #c8ccd4; background: #fff; cursor: pointer; }
</style>
</head>
<body>
<div id="msg">X's turn</div>
<div id="board"></div>
<button id="reset">New game</button>

<script>
  // The 8 winning lines, as indexes into the 9 cells (0-8, row by row)
  const WINS = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];

  const msg = document.getElementById('msg');
  const board = document.getElementById('board');
  let cells, turn, over;

  // Build the 9 buttons once
  const buttons = [];
  for (let i = 0; i < 9; i++) {
    const b = document.createElement('button');
    b.addEventListener('click', () => play(i));
    board.appendChild(b);
    buttons.push(b);
  }

  function winner() {
    for (const [a, b, c] of WINS) {
      if (cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) return cells[a];
    }
    return null;
  }

  function play(i) {
    if (over || cells[i]) return;     // ignore taken squares and finished games
    cells[i] = turn;
    buttons[i].textContent = turn;

    const w = winner();
    if (w) { over = true; msg.textContent = w + ' wins!'; }
    else if (cells.every(Boolean)) { over = true; msg.textContent = "It's a draw"; }
    else { turn = turn === 'X' ? 'O' : 'X'; msg.textContent = turn + "'s turn"; }
  }

  function reset() {
    cells = Array(9).fill('');        // '' means empty
    turn = 'X';
    over = false;
    msg.textContent = "X's turn";
    buttons.forEach(b => b.textContent = '');
  }

  document.getElementById('reset').addEventListener('click', reset);
  reset();
</script>
</body>
</html>
A complete two-player game. Click a square to place a mark, then press New game to start over.

The whole game in four steps

Every click does the same four things, in the same order.

One click runs four steps. The array is updated first, and the page only displays it.
One click runs four steps. The array is updated first, and the page only displays it.
  1. Click – each button calls play with its own number, 0 to 8.
  2. Update – set cells[i] to the current mark. The array is the game state.
  3. Check – test the eight winning lines, then test whether the board is full.
  4. Show – write the mark into the button and the message into the status line.

Because the rules work on the array and not on the page, the win check is plain JavaScript that never reads the DOM. To see how a script reaches the page at all, read the DOM in HTML and JavaScript.

The board is one array of nine

Keep the board as a flat array, row by row. Cell 0 is the top left, cell 4 is the centre and cell 8 is the bottom right.

let cells = Array(9).fill('');   // '' means empty
const WINS = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];

A player wins by marking all three spaces of a row, a column or a diagonal. On a 3 by 3 grid that gives eight lines, and each one is three cell numbers.

The nine cell numbers, and the eight lines written as triples of those numbers.
The nine cell numbers, and the eight lines written as triples of those numbers.

Pick a line below to see which cells it covers and the matching entry of the WINS list.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The 8 winning lines</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { max-width: 340px; margin: 0 auto; }
  #grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 12px; }
  #grid div {
    aspect-ratio: 1; display: grid; place-items: center;
    border-radius: 10px; background: #fff; color: #9aa3b2;
    font: 700 26px ui-monospace, Consolas, monospace;
    box-shadow: 0 2px 6px rgba(0, 0, 0, .1);
  }
  #grid div.hit { background: #d6f2df; color: #0f5132; box-shadow: 0 0 0 3px #2e9d5b inset; }
  #chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
  #chips button {
    font: 600 14px system-ui, sans-serif; padding: 7px 11px; border-radius: 99px;
    border: 1px solid #c8ccd4; background: #fff; cursor: pointer;
  }
  #chips button[aria-pressed="true"] { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  #out { font: 600 15px ui-monospace, Consolas, monospace; background: #fff; border-radius: 8px; padding: 10px 12px; }
</style>
</head>
<body>
<div class="wrap">
  <div id="grid"></div>
  <div id="chips"></div>
  <div id="out">Pick a line</div>
</div>

<script>
  const LINES = [
    ['Row 1', [0,1,2]], ['Row 2', [3,4,5]], ['Row 3', [6,7,8]],
    ['Col 1', [0,3,6]], ['Col 2', [1,4,7]], ['Col 3', [2,5,8]],
    ['Diagonal \\', [0,4,8]], ['Diagonal /', [2,4,6]]
  ];

  const grid = document.getElementById('grid');
  const chips = document.getElementById('chips');
  const out = document.getElementById('out');

  // Show each cell's index, so the array positions are visible
  const squares = [];
  for (let i = 0; i < 9; i++) {
    const d = document.createElement('div');
    d.textContent = i;
    grid.appendChild(d);
    squares.push(d);
  }

  const chipEls = LINES.map(([name, idx]) => {
    const b = document.createElement('button');
    b.textContent = name;
    b.setAttribute('aria-pressed', 'false');
    b.addEventListener('click', () => select(b, idx));
    chips.appendChild(b);
    return b;
  });

  function select(btn, idx) {
    chipEls.forEach(c => c.setAttribute('aria-pressed', String(c === btn)));
    squares.forEach((s, i) => s.classList.toggle('hit', idx.includes(i)));
    out.textContent = '[' + idx.join(', ') + ']';
  }

  select(chipEls[0], LINES[0][1]);
</script>
</body>
</html>
Choose a line. The cells it covers light up, and the list entry is printed underneath.

Check for a winner, then for a draw

The win check loops over the eight lines and asks whether all three cells hold the same mark.

function winner() {
  for (const [a, b, c] of WINS) {
    if (cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) return cells[a];
  }
  return null;
}

The first test, cells[a], matters. Three empty strings are equal to each other, so without it an empty line would count as a win before anyone has moved.

A draw is a full board with no winner. cells.every(Boolean) is true only when every cell holds a mark.

Order matters here too. The ninth move can complete a line, so test for a winner first. If the full-board test runs first, a game won on the last move is announced as a draw.

Ignore clicks that should not count

The first line of play throws away clicks that must not change the game.

if (over || cells[i]) return;   // taken square, or game already finished
Without the guard, a tap on a taken square overwrites the mark and passes the turn.
Without the guard, a tap on a taken square overwrites the mark and passes the turn.

Without the first half, a player can overwrite the other player's mark. Without the second half, moves keep landing after somebody has won. Set over = true when the game ends and reset sets it back.

Add a computer player

The finished version below adds a vs-computer mode, a score tally, a highlight on the winning line and labels for screen readers.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tic tac toe with a computer player</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; text-align: center; background: #f4f5f7; color: #1d2330; }
  .wrap { max-width: 320px; margin: 0 auto; }
  #modes { display: flex; gap: 6px; justify-content: center; margin-bottom: 12px; }
  #modes button, #reset {
    font: 600 14px system-ui, sans-serif; padding: 7px 13px; border-radius: 99px;
    border: 1px solid #c8ccd4; background: #fff; cursor: pointer;
  }
  #modes button[aria-pressed="true"] { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  #msg { font-size: 20px; font-weight: 700; min-height: 28px; margin-bottom: 10px; }
  #board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 12px; }
  #board button {
    aspect-ratio: 1; font-size: 44px; font-weight: 700; line-height: 1;
    border: 0; border-radius: 10px; background: #fff; cursor: pointer;
    box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
  }
  #board button.x { color: #1d4ed8; }
  #board button.o { color: #c2410c; }
  #board button.win { background: #d6f2df; box-shadow: 0 0 0 3px #2e9d5b inset; }
  #score { display: flex; justify-content: center; gap: 18px; font-size: 15px; margin-bottom: 12px; }
  #score b { font-size: 20px; display: block; }
</style>
</head>
<body>
<div class="wrap">
  <div id="modes">
    <button id="m2" aria-pressed="false">2 players</button>
    <button id="m1" aria-pressed="true">vs computer</button>
  </div>
  <div id="msg" role="status" aria-live="polite"></div>
  <div id="board"></div>
  <div id="score">
    <div><b id="sx">0</b>X wins</div>
    <div><b id="sd">0</b>Draws</div>
    <div><b id="so">0</b>O wins</div>
  </div>
  <button id="reset">New game</button>
</div>

<script>
  const WINS = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];

  const msg = document.getElementById('msg');
  const boardEl = document.getElementById('board');
  const score = { X: 0, O: 0, D: 0 };
  let cells, turn, over, vsComputer = true, timer = null;

  const buttons = [];
  for (let i = 0; i < 9; i++) {
    const b = document.createElement('button');
    b.addEventListener('click', () => humanClick(i));
    boardEl.appendChild(b);
    buttons.push(b);
  }

  function drawCell(i) {
    const v = cells[i];
    const b = buttons[i];
    b.textContent = v;
    b.className = v.toLowerCase();
    b.setAttribute('aria-label', 'Row ' + (Math.floor(i / 3) + 1) + ', column ' + (i % 3 + 1) + ': ' + (v || 'empty'));
  }

  // Returns the winning line (an array of 3 indexes) or null
  function findWin() {
    return WINS.find(([a, b, c]) => cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) || null;
  }

  function play(i) {
    cells[i] = turn;
    drawCell(i);
    const line = findWin();
    if (line) {
      over = true;
      score[turn]++;
      line.forEach(k => buttons[k].classList.add('win'));
      msg.textContent = turn + ' wins!';
    } else if (cells.every(Boolean)) {
      over = true;
      score.D++;
      msg.textContent = "It's a draw";
    } else {
      turn = turn === 'X' ? 'O' : 'X';
      msg.textContent = turn + "'s turn";
    }
    showScore();
  }

  function humanClick(i) {
    if (over || cells[i] || timer) return;          // taken, finished, or computer is thinking
    play(i);
    if (!over && vsComputer && turn === 'O') {
      timer = setTimeout(() => { timer = null; play(computerPick()); }, 400);
    }
  }

  // Computer: win if possible, else block, else centre, else a corner, else anything
  function computerPick() {
    const empty = cells.map((v, i) => v ? -1 : i).filter(i => i >= 0);
    const finish = (p) => {
      for (const line of WINS) {
        const mine = line.filter(i => cells[i] === p).length;
        const gap = line.find(i => !cells[i]);
        if (mine === 2 && gap !== undefined) return gap;
      }
      return -1;
    };
    let move = finish('O');
    if (move < 0) move = finish('X');
    if (move < 0 && !cells[4]) move = 4;
    if (move < 0) {
      const corners = [0, 2, 6, 8].filter(i => !cells[i]);
      if (corners.length) move = corners[Math.floor(Math.random() * corners.length)];
    }
    if (move < 0) move = empty[Math.floor(Math.random() * empty.length)];
    return move;
  }

  function showScore() {
    document.getElementById('sx').textContent = score.X;
    document.getElementById('so').textContent = score.O;
    document.getElementById('sd').textContent = score.D;
  }

  function newGame() {
    clearTimeout(timer);
    timer = null;
    cells = Array(9).fill('');
    turn = 'X';
    over = false;
    msg.textContent = "X's turn";
    for (let i = 0; i < 9; i++) drawCell(i);
  }

  function setMode(computer) {
    vsComputer = computer;
    document.getElementById('m1').setAttribute('aria-pressed', String(computer));
    document.getElementById('m2').setAttribute('aria-pressed', String(!computer));
    score.X = score.O = score.D = 0;
    showScore();
    newGame();
  }

  document.getElementById('m1').addEventListener('click', () => setMode(true));
  document.getElementById('m2').addEventListener('click', () => setMode(false));
  document.getElementById('reset').addEventListener('click', newGame);
  setMode(true);
</script>
</body>
</html>
Play X against the computer, or switch to two players. The winning line turns green and the score counts up.

The computer is a list of rules, tried in this order:

  1. Win now, if two of its marks sit on a line with a free cell.
  2. Block X in the same way.
  3. Take the centre.
  4. Take a random free corner.
  5. Take any free cell.

A random pick uses Math.floor(Math.random() * n). Random numbers in JavaScript covers it in detail.

Three small details keep it tidy:

  • Delay: setTimeout makes the reply appear after 400 milliseconds. The click handler ignores taps while that timer is pending.
  • Cancel: the timer id is saved, so New game can call clearTimeout and stop a reply that has not happened yet.
  • Announce: the status line has role="status" and aria-live="polite", and each button has an aria-label such as "Row 2, column 2: empty".

Keep it in one file

Everything on this page lives in a single HTML file. The CSS sits in a style element, the JavaScript sits in a script element at the end of the body, and nothing else has to be uploaded or built.

The script goes last because a script without async or defer runs as soon as the browser reaches it. At that moment, only the elements above it exist.

If you are adapting code that came as separate files, paste the CSS into a style element and the JavaScript into a script element. More on that format: single HTML file games and HTML game code.

For phones, two lines do the work. The viewport meta tag sets the page width, and width: min(100%, 300px) on the board lets it shrink on a narrow screen.

A CSS grid with repeat(3, 1fr) makes three equal columns, and aspect-ratio: 1 keeps every square square.

When it does not work

What you see Cause Fix
Clicking does nothing The script runs before the buttons exist, so getElementById returns null Move the script block to the end of the body
A mark gets overwritten No check for a taken square `if (over
Moves still count after someone wins No over flag Set over = true on a win or a draw, and test it first
A win on the ninth move shows as a draw The full-board test runs before the win test Call winner() first
The game ends before the first move Three empty cells are equal, so the line test passes Require cells[a] to be non-empty first
Filling one row fills all three Array(3).fill([]) puts the same inner array in every row Use one flat array, or Array.from
The computer moves after New game A pending setTimeout still fires Save the id and call clearTimeout
The board runs off a phone screen Fixed pixel widths and no viewport meta tag width: min(100%, 300px) plus the viewport tag

A game is easier to show than to describe, and a screenshot cannot be played. Game link covers sending a game as a link.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can play a round themselves, without an account. If you change the code later, the same link shows the new version.

Questions people ask

How many winning lines are there in tic tac toe?

Eight: three rows, three columns and two diagonals. In the code each one is a list of three cell numbers, and the win check loops over those eight lists.

Do I need a library or framework to build tic tac toe?

No. Creating buttons with document.createElement, listening with addEventListener and writing text with textContent is enough, as the examples on this page show.

How do I make the computer player unbeatable?

The computer in the live example follows a short list of rules, so a careful player can beat it. An unbeatable player tries every possible continuation and picks the best one, a method called minimax. With best play from both sides, tic tac toe ends in a draw.

Why is the score gone when I reload the page?

The score lives in a JavaScript variable, and the page starts with a fresh variable each time it loads. In an ordinary browser tab, localStorage keeps data after a reload. On a NOS shared page, storage resets on reload, so treat the score as per visit.

Should I store the board as a 2D array or a flat array?

Either works. A flat array of nine fits this game well because every winning line is just three numbers, and the row and column of cell i are Math.floor(i / 3) and i % 3. If you do build a 2D array, avoid Array(3).fill([]), which makes every row the same array.

Keep reading