Creating Tic Tac Toe In JavaScript Part 1: The Game

Tic Tac Toe (or noughts and crosses) is a good game to create when learning game development as it has simple rules and a known win state. I have created a version of tic tac toe using PHP before, but I wanted to see if I could re-create the game in JavaScript using the canvas element. This is certainly possible to do as everything we need is built into JavaScript itself, which means we don't need to import any packages to get this working.

In this article I will go through the necessary components needed to create a version of tic tac toe using JavaScript and a canvas element.

Getting Set Up

The first thing we need to do is create the HTML for our canvas element and an indication of the winner of the game. This means creating two HTML elements.

<canvas id="grid" width="250" height="250"></canvas>
<p>Winner: <span id="win"></span></p>

Of course, we also need to include the JavaScript code where we will write our game logic, which is included with a simple script element.

<script src="tictactoe.js"></script>

The variables we need to get the game working are pretty simple. We just need to store information about the game board (including how wide and high it is) as well as information about the current state of the game itself.

// The width of the canvas.
var width;
// The height of the canvas.
var height;

// The state of the game board.
var state = [
  ['', '', '',],
  ['', '', '',],
  ['', '', '',],
];

// The array of canvas elements.
var boxes = [];

// Symbol for player 1.
var player1 = 'x';
// Symbol for player 2.
var player2 = 'o';

// The current player.
var currentPlayer = player1;

To draw the game board itself we use a function called drawGrid(). This function can be run the first time we load the page and it just needs to draw a rectangle for every grid element on the board. To do this we just work out how large that rectangle needs to be before using the strokeRect() function from the 2D canvas context to draw the actual rectangle. One the rectangle is drawn we then send the information about the box we created to the boxes array, which allows us to react to click events later.

function drawGrid() {
  let row = 0;
  let column = 0;

  state.forEach(function (line) {
    column = 0;
    let stateFactorY = state.length;
    let y = row * (width / stateFactorY);

    line.forEach(function (value) {
      let stateFactorX = line.length;

      let x = column * (width / stateFactorX);
      let rectWidth = width / stateFactorX;
      let rectHeight = height / stateFactorY;

      ctx.strokeRect(x, y, rectWidth, rectHeight);

      boxes.push({
        column: column,
        row: row,
        x: x,
        y: y,
        width: rectWidth,
        height: rectHeight
      });

      column++;
    });
    row++;
  });
}

The 2D canvas context is generated in the window.onload function, which is run when the page is first run. What we do here is to find the canvas element and generate the 2D context from this element. We then store the height and width of the canvas before calling the drawGrid() function to draw the boxes in our game board.

window.onload = function () {
  canvas = document.getElementById("grid");
  ctx = canvas.getContext("2d");
  width = canvas.width;
  height = canvas.height;
  drawGrid();
};

If we save this code to the tictactoe.js file and run it we can see the following in our canvas element.

A screenshot of a tic tac toe game board, with a 3 x 3 grid of lines.

We can now use this as the basis of our game.

As an aside, because we have drawn the grid this way there is nothing to stop us from creating a larger game board and using this as the basis of our game.

let state = [
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
    ["", "", "", "",],
  ];

Using this game board generates the following game board in the canvas element.

A game board of tic tac toe, drawn with a 4x9 grid.

This is all still contained within the height and width of the canvas element and so it's still the same size overall as the 3x3 grid of squares.

Listening To Events

A game is pretty dull to play if you can't interact with it, so let's add that functionality now. I normally like to abstract the addition of event listeners into separate functions, just to keep them away from the window.onload function. To that end, let's create a function that will add a "click" event listener to the canvas element.

function addClickListener() {
  // Add event listener for `click` events.
  canvas.addEventListener("click", canvasClick, false);
}

This function only contains a single event listener, but we know that we only need to update this function if we want to add more event listeners to the page. This event will call the canvasClick() function when a user clicks on the canvas element. To register it on the page we just need to add it to the window.onload function, after the grid has been drawn.

window.onload = function () {
  canvas = document.getElementById("grid");
  ctx = canvas.getContext("2d");
  width = canvas.width;
  height = canvas.height;
  drawGrid();
  addClickListener();
};

The event listener function itself is a little complex. As a user as clicked on the canvas element we therefore need to detect where on the canvas the user clicked before we can do anything with the click. This is where the "boxes" variable comes in. When we generated the grid we passed the exact coordinates of that grid to the boxes array. This means that we can easily detect which box was clicked on by looking through the boxes array and comparing the click coordinates with the box coordinates. If the coordinates match then we can initiate the code that runs the state of play.

The play() function takes care of the change of the state of play, but it will essentially change one of the elements in the state array to be a "X" or a "O", depending on who's turn it is. We send over the row and column values of the box that match the click coordinates, which essentially means we have translated the click into a box in the state array.

Once that function is complete we then run a function to find out if we have a winner (or if the game is a draw) as this will cause the game to be over. The win condition is detected in the isWin() function, but the result will wither be a draw or one of the players. If the winner has been detected then we remove the event listener from the canvas element, which prevent any further interaction.

function canvasClick(event) {
  const rect = canvas.getBoundingClientRect();
  var x = event.clientX - rect.left,
    y = event.clientY - rect.top;

  // Collision detection between clicked offset and element.
  boxes.forEach(function (element) {
    if (
      y > element.y &&
      y < element.y + element.height &&
      x > element.x &&
      x < element.x + element.width
    ) {
      if (state[element.row][element.column] !== "") {
        // Box has already had a play, so don't react.
        return;
      }

      // Record the player's play event.
      play(element.row, element.column);

      // See if there is a winner.
      let winner = isWin();

      // Detect the winner.
      if (winner === "draw") {
        document.getElementById("win").innerHTML = "draw!";
      } else if (winner === player1 || winner === player2) {
        document.getElementById("win").innerHTML = winner;
      }

      if (winner !== false) {
        // If there was a winner then don't allow any more plays.
        canvas.removeEventListener("click", canvasClick);
      }
    }
  });
}

The play() function is simple, especially since we have already determined the current coordinates of the click event in the state array. If the array element in the state array that matches the row and column coordinates is blank then we fill it in the the current players symbol, draw the new state of the board, and then swap the players over.

function play(row, column) {
  if (state[row][column] === "") {
    // Assign the letter to the square.
    state[row][column] = currentPlayer;

    // Draw the new state of the board.
    drawState();

    // Swap the current player.
    if (currentPlayer == player1) {
      currentPlayer = player2;
    } else {
      currentPlayer = player1;
    }
  }
}

The drawState() function does get a little complex since we need to introduce some maths to draw the "O" or "X" symbols in the correct place in the canvas. To do this we loop through the state array and draw the symbols that match to the player symbols in the array.

function drawState() {
  let row = 0;
  let column = 0;

  state.forEach(function (line) {
    column = 0;
    let stateFactorY = state.length;
    let y = row * (width / stateFactorY);

    line.forEach(function (value) {
      let stateFactorX = line.length;

      let x = column * (width / stateFactorX);
      let rectWidth = width / stateFactorX;
      let rectHeight = height / stateFactorY;

      if (value == player1) {
        // Draw a "X".
        ctx.beginPath();
        ctx.moveTo(x + rectWidth * 0.15, y + rectHeight * 0.15);
        ctx.lineTo(
          x + rectWidth - rectWidth * 0.15,
          y + rectHeight - rectHeight * 0.15
        );
        ctx.moveTo(x + rectWidth - rectWidth * 0.15, y + rectHeight * 0.15);
        ctx.lineTo(x + rectWidth * 0.15, y + rectHeight - rectHeight * 0.15);
        ctx.stroke();
      } else if (value == player2) {
        // Draw a "O".
        ctx.beginPath();
        ctx.arc(
          x + rectWidth / 2,
          y + rectHeight / 2,
          rectHeight / 3,
          0,
          Math.PI * 2,
          true
        );
        ctx.stroke();
      }
      column++;
    });
    row++;
  });
}

To draw an "X" we use the beginPath(), moveTo(), lineTo() and stroke() functions of the 2D canvas context. We also offset the lines by 15% in order to keep them from touching the edges of the game grid. This generates a nice looking "X" that doesn't just fill the box.

To draw a "O" we use the beginPath(), arc() and stroke() functions of the 2D canvas context. This places a circle (using the arc() function) in the centre of the grid, the radius of which is 30% the height of the box itself. This means that the "O" is drawn within the confines of the grid, not touching the edges at all.

Winning

Finally, we need to take care of the win condition of the game by adding an isWin() function. This function is the longest in the game since there are quite a few states to take into account. The win states of a game of tic tac toe are:

  • Player has 3 symbols in a row horizontally.
  • Player has 3 symbols in a row vertically.
  • Player has 3 symbols in a row diagonally.

If none of these states are detected then we check to see if there are any spaces left in the game board. If we find one then the game is still in a playable state so we return false.

Finally, if we get to the end of the function then the game is in a drawn state as no player has won and there are no more moves to make.

function isWin() {
  let winner = null;

  let row = 0;
  let column = 0;

  // horizontal
  for (let i = 0; i < state.length; i++) {
    for (let j = 0; j < state[i].length - 2; j++) {
      if (
        (state[i][j] == player1 && state[i][j + 1]) == player1 &&
        state[i][j + 2] == player1
      ) {
        return player1;
      }
      if (
        (state[i][j] == player2 && state[i][j + 1]) == player2 &&
        state[i][j + 2] == player2
      ) {
        return player2;
      }
    }
  }

  // vertical
  for (let i = 0; i < state.length - 2; i++) {
    for (let j = 0; j < state[i].length; j++) {
      if (
        (state[i][j] == player1 && state[i + 1][j]) == player1 &&
        state[i + 2][j] == player1
      ) {
        return player1;
      }
      if (
        (state[i][j] == player2 && state[i + 1][j]) == player2 &&
        state[i + 2][j] == player2
      ) {
        return player2;
      }
    }
  }

  // diagonal
  for (let i = 0; i < state.length - 2; i++) {
    for (let j = 0; j < state[i].length - 2; j++) {
      if (
        (state[i][j] == player1 && state[i + 1][j + 1]) == player1 &&
        state[i + 2][j + 2] == player1
      ) {
        return player1;
      }
      if (
        (state[i + 2][j] == player1 && state[i + 1][j + 1]) == player1 &&
        state[i][j + 2] == player1
      ) {
        return player1;
      }
      if (
        (state[i][j] == player2 && state[i + 1][j + 1]) == player2 &&
        state[i + 2][j + 2] == player2
      ) {
        return player2;
      }
      if (
        (state[i + 2][j] == player2 && state[i + 1][j + 1]) == player2 &&
        state[i][j + 2] == player2
      ) {
        return player2;
      }
    }
  }

  // if there are any gaps left then return false
  for (let i = 0; i < state.length; i++) {
    for (let j = 0; j < state[i].length; j++) {
      if (state[i][j] == "") {
        return false;
      }
    }
  }

  return "draw";
}

With all of this in place we can now have a game of tic tac toe. Each player takes it in turn to place their symbol onto the game grid and if a win state is detected we show this to on the screen.

A screenshot of a game of tic tac toe, where the o player has won the game.

This is now a fully complete game of tic tac toe. There are some improvements to be made to this game (like a reset button) but I'll leave that as an exercise for the user.

By creating the winning check function using the relative positions of the grid coordiantes we also allow for different sizes of game boards to be created and allow them to play in the normal way. With a 6x6 grid the game will still follow the same rules as before, with each player needing to get a line of 3 symbols, 

If you are interested in having a go of this tic tac toe game yourself and reviewing all of the code in action then I have created a codepen containing everything you need to play the game.

In the next part of this article I will look at adding an AI controlled player to the game using a min-max algorithm.

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
8 + 3 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.