DEV Community

Borna Šepić
Borna Šepić

Posted on

Pure and Simple - Tic Tac Toe with Javascript

Have you wanted to build something fun and simple to practice your Front End skills but the thought of building another TODO application makes you want to quit this wonderful path you’ve started on?

You can find the completed project on my Github here.

Well look no further, today we’ll be building a simple (drumroll) Tic Tac Toe game. We’ll cover some basics like using CSS grid, query selectors and structuring our game flow and logic.
Let’s first take a look at the end product

The big question then… Where do we start?
Well, usually the best way to start would be to break down the application into smaller, easily digestible pieces.

First, let’s break down the user interface:

  • title
  • 3x3 grid
    • the grid should be clickable
    • the grid cells should have the correct player sign displayed an information display
  • should display a message informing the current player it’s their turn
    • should show us who won the game
    • should show us if the game ended in a draw
  • restart button
    • will restart the entire game

Next, let’s break down the game flow for a cell click:

  • needs to track any clicks that happen on our cells
  • needs to check if a valid move has been made
    • needs to make sure nothing happens if an already played cell has been clicked
  • we should update our game state
  • we should validate the game state
    • check if a player has won
    • check if the game ended in a draw
  • either stop the game or change the active player, depending on the above checks
  • reflect the updates made on the UI
  • rinse and repeat

That’s it, nothing special or overly complicated but still an excellent opportunity to practice and improve.

Let’s get to the fun part and build something!

Folder Structure

We’ll start by building the user interface so we have something to look at while building the game logic.
As I mentioned this is a simple game so there is no need for complicated folder structures.

You should have three files in total:

  1. index.html (will hold our UI structure and import the other files we need)
  2. style.css (to make our game look halfway decent)
  3. script.js (will hold our game logic, and handle everything else we need)

HTML

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Tic Tac Toe</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <section>
        <h1 class="game--title">Tic Tac Toe</h1>
        <div class="game--container">
            <div data-cell-index="0" class="cell"></div>
            <div data-cell-index="1" class="cell"></div>
            <div data-cell-index="2" class="cell"></div>
            <div data-cell-index="3" class="cell"></div>
            <div data-cell-index="4" class="cell"></div>
            <div data-cell-index="5" class="cell"></div>
            <div data-cell-index="6" class="cell"></div>
            <div data-cell-index="7" class="cell"></div>
            <div data-cell-index="8" class="cell"></div>
        </div>
        <h2 class="game--status"></h2>
        <button class="game--restart">Restart Game</button>
    </section>
<script src="script.js"></script>
</body>
</html>

Aside from the usual boilerplate, we have included our style sheet in the <head> element, we do this to make sure the style sheet is always loaded before the actual HTML.
We have also included our script.js file just above the closing </body> tag to make sure that the javascript is always loaded after the HTML.

We will hold the actual game cells in a wrapping div to allow us to make use of the CSS grid. Also, each cell has a “data-cell-index” attribute to allow us to easily track with cell has been clicked.

We also have an <h2> element where we will display the beforementioned game information and a restart button.

CSS

body {
    font-family: "Arial", sans-serif;
}
section {
    text-align: center;
}
.game--container {
    display: grid;
    grid-template-columns: repeat(3, auto);
    width: 306px;
    margin: 50px auto;
}
.cell {
    font-family: "Permanent Marker", cursive;
    width: 100px;
    height: 100px;
    box-shadow: 0 0 0 1px #333333;
    border: 1px solid #333333;
    cursor: pointer;
line-height: 100px;
    font-size: 60px;
}

I wanted to keep the CSS for the application down to a minimum so the only thing I’d draw your attention to are the styles for the “.game — container” since this is where we implement our CSS grid.

Since we want to have a 3x3 grid we make use of the “grid-template-columns” property setting it to repeat(3, auto);

In a nutshell, this splits the contained divs (cells) into three columns and letting the cells automatically decide their width.

JavaScript

Now we get to the fun part!
Let’s kick our JS off by structuring some pseudo-code to break it down into smaller pieces using our before written game logic template

/*
We store our game status element here to allow us to more easily 
use it later on 
*/
const statusDisplay = document.querySelector('.game--status');
/*
Here we declare some variables that we will use to track the 
game state throught the game. 
*/
/*
We will use gameActive to pause the game in case of an end scenario
*/
let gameActive = true;
/*
We will store our current player here, so we know whos turn 
*/
let currentPlayer = "X";
/*
We will store our current game state here, the form of empty strings in an array
 will allow us to easily track played cells and validate the game state later on
*/
let gameState = ["", "", "", "", "", "", "", "", ""];
/*
Here we have declared some messages we will display to the user during the game.
Since we have some dynamic factors in those messages, namely the current player,
we have declared them as functions, so that the actual message gets created with 
current data every time we need it.
*/
const winningMessage = () => `Player ${currentPlayer} has won!`;
const drawMessage = () => `Game ended in a draw!`;
const currentPlayerTurn = () => `It's ${currentPlayer}'s turn`;
/*
We set the inital message to let the players know whose turn it is
*/
statusDisplay.innerHTML = currentPlayerTurn();
function handleCellPlayed() {

}
function handlePlayerChange() {

}
function handleResultValidation() {

}
function handleCellClick() {

}
function handleRestartGame() {

}
/*
And finally we add our event listeners to the actual game cells, as well as our 
restart button
*/
document.querySelectorAll('.cell').forEach(cell => cell.addEventListener('click', handleCellClick));
document.querySelector('.game--restart').addEventListener('click', handleRestartGame);

We have also outlined all the functionalities we will need to handle our game logic, so let’s go and write our logic!

handleCellClick

In our cell click handler, we’ll handle two things.
First off we need to check if the clicked cell has already been clicked and if it hasn’t we need to continue our game flow from there.
Let’s see how this looks in action:

function handleCellClick(clickedCellEvent) {
/*
We will save the clicked html element in a variable for easier further use
*/    
    const clickedCell = clickedCellEvent.target;
/*
Here we will grab the 'data-cell-index' attribute from the clicked cell to identify where that cell is in our grid. 
Please note that the getAttribute will return a string value. Since we need an actual number we will parse it to an 
integer(number)
*/
    const clickedCellIndex = parseInt(
      clickedCell.getAttribute('data-cell-index')
    );
/* 
Next up we need to check whether the call has already been played, 
or if the game is paused. If either of those is true we will simply ignore the click.
*/
    if (gameState[clickedCellIndex] !== "" || !gameActive) {
        return;
    }
/* 
If everything if in order we will proceed with the game flow
*/    
    handleCellPlayed(clickedCell, clickedCellIndex);
    handleResultValidation();
}

We will accept a ClickEvent from our cell event listener. That will allow us to track which cell has been clicked and get its’ index attribute more easily.

handleCellPlayed

In this handler, we’ll need to handle two things. We’ll update our internal game state, and update our UI.

function handleCellPlayed(clickedCell, clickedCellIndex) {
/*
We update our internal game state to reflect the played move, 
as well as update the user interface to reflect the played move
*/
    gameState[clickedCellIndex] = currentPlayer;
    clickedCell.innerHTML = currentPlayer;
}

We accept the currently clicked cell (the .target of our click event), and the index of the cell that has been clicked.

handleResultValidation

Here comes the core of our Tic Tac Toe game, the result validation. Here we will check whether the game ended in a win, draw, or if there are still moves to be played.
Let’s start by checking if the current player won the game.

const winningConditions = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
];
function handleResultValidation() {
    let roundWon = false;
    for (let i = 0; i <= 7; i++) {
        const winCondition = winningConditions[i];
        let a = gameState[winCondition[0]];
        let b = gameState[winCondition[1]];
        let c = gameState[winCondition[2]];
        if (a === '' || b === '' || c === '') {
            continue;
        }
        if (a === b && b === c) {
            roundWon = true;
            break
        }
    }
if (roundWon) {
        statusDisplay.innerHTML = winningMessage();
        gameActive = false;
        return;
    }
}

Take a minute to break this down before continuing as an exercise.

Values in arrays for our winningConditions are indexes for cells that need to be populated by the same player for them to be considered a victor.

In our for-loop, we go through each one and check whether the elements of our game state array under those indexes match. If they do match we move on to declare the current player as victorious and ending the game.

Of course, we need to handle the other two cases as well. Let’s first check if the game has ended in a draw. The only way the game can end in a draw would be if all the fields have been filled in.

const winningConditions = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
];
function handleResultValidation() {
    let roundWon = false;
    for (let i = 0; i <= 7; i++) {
        const winCondition = winningConditions[i];
        let a = gameState[winCondition[0]];
        let b = gameState[winCondition[1]];
        let c = gameState[winCondition[2]];
        if (a === '' || b === '' || c === '') {
            continue;
        }
        if (a === b && b === c) {
            roundWon = true;
            break
        }
    }
if (roundWon) {
        statusDisplay.innerHTML = winningMessage();
        gameActive = false;
        return;
    }
/* 
We will check weather there are any values in our game state array 
that are still not populated with a player sign
*/
    let roundDraw = !gameState.includes("");
    if (roundDraw) {
        statusDisplay.innerHTML = drawMessage();
        gameActive = false;
        return;
    }
/*
If we get to here we know that the no one won the game yet, 
and that there are still moves to be played, so we continue by changing the current player.
*/
    handlePlayerChange();
}

Since we have a return statement in our roundWon check we know that, if a player has won that round, our script will stop there. This allows us to avoid using else conditions and to keep our code nice and compact.

handlePlayerChange

Here we will simply change the current player and update the game status message to reflect the change.

function handlePlayerChange() {
    currentPlayer = currentPlayer === "X" ? "O" : "X";
    statusDisplay.innerHTML = currentPlayerTurn();
}

We are using a ternary operator here to assign a new player, you can learn more about it here. It really is awesome!

The only thing left to do would be to connect our game restart functionality.

handleRestartGame

Here we will set all our game tracking variables back to their defaults, clear the game board by removing all the signs, as well as updating the game status back to the current player message.

function handleRestartGame() {
    gameActive = true;
    currentPlayer = "X";
    gameState = ["", "", "", "", "", "", "", "", ""];
    statusDisplay.innerHTML = currentPlayerTurn();
    document.querySelectorAll('.cell')
               .forEach(cell => cell.innerHTML = "");
}

Conclusion

Basically, that’s it!
You have a functioning playable Tic Tac Toe game (* self-high five*)

Of course, there are a lot more things we could do here, like make the game actually multiplayer, so you can play with a friend that’s on the other side of the world. Or why not write an algorithm that will play the game with you? Maybe try writing the app in a framework of your choice to see how it compares to vanilla JavaScript?

There are a lot of possibilities to explore and grow here, let me know which one you’d like the most and I’d be more than happy to make another one of these guides!

As always you can find the completed project on my Github here.

Discussion (25)

Collapse
cnorman19 profile image
Cameron Norman

Hello Sir, modifying this solution to make it work for a 4x4 tic tac toe game but I am having some trouble getting it to work and I'm not quite understanding why. Any help would be highly appreciated. I will list the steps I have taken in order to accomplish that and maybe you can tell me where I went wrong.

  1. Adding more cells to our board by modifying both the HTML and CSS.
  2. Modifying the win conditions in order to accommodate for the change in board size
for (let i = 0; i <= 15; i++) {
        const winCondition = winningConditions[i];
        let a = gameState[winCondition[0]];
        let b = gameState[winCondition[1]];
        let c = gameState[winCondition[2]];
        let d = gameState[winCondition[3]];

        if (a === '' || b === '' || c === '' || d === '') {
            continue;
        }
        if (a === b && b === c && c === d) {
            roundWon = true;
            break; 
        }
    }
Enter fullscreen mode Exit fullscreen mode
  1. Lastly I modified the gamestate to reflect the additional cells.

Like I said any help would be greatly appreciated! If you would like to look at the full code let me know! Thank you for your time!

Collapse
cnorman19 profile image
Cameron Norman

Sorry, realized immediately after posting this that my for-loop should look like this. Got a little confused on what exactly was taking place.

for (let i = 0; i <= 9; i++)

Everything works perfect now. Thank you.

Collapse
bornasepic profile image
Borna Šepić Author

Ah the rubber duck debugging at it's finest :)

Glad you sorted it out!

Collapse
jeffmx profile image
jeff-mx

Great code, I'am just starting with JS so I find it very usefull. I add the next lines in order to set a color to every player.

function handleCellPlayed(clickedCell, clickedCellIndex) {
/*
We update our internal game state to reflect the played move,
as well as update the user interface to reflect the played move
*/
gameState[clickedCellIndex] = currentPlayer;
clickedCell.innerHTML = currentPlayer;

    if ( currentPlayer == "X" ) { 
        document.querySelectorAll('.cell')[clickedCellIndex].style.color = "blue";
    }else{
        document.querySelectorAll('.cell')[clickedCellIndex].style.color = "red";
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
bornasepic profile image
Borna Šepić Author

Hey Jeff, that's a great idea!
Always happy to see someone taking the tutorial a step further :)

Collapse
starrysamurai profile image
StarrySamurai

I keep recieving this error- Uncaught TypeError: Cannot set property 'innerHTML' of null. Anyone know how to fix this?

Collapse
linehammer profile image
linehammer

In JavaScript almost everything is an object, null and undefined are exception. if a variable has been declared, but has not been assigned a value, is automatically assigned the value undefined . Therefore, if you try to access the value of such variable, it will throw Uncaught TypeError cannot set property ‘0’ of undefined/null .

JavaScript null and undefined is one of the main reasons to produce a runtime errors . This happens because you don’t check the value of unknown return variables before using it. If you are not sure a variable that will always have some value, the best practice is to check the value of variables for null or undefined before using them. The standard way to catch null and undefined simultaneously is this:

if (variable == null) {
// your code here.
}

Because null == undefined is true, the above code will catch both null and undefined.

Also you can write equivalent to more explicit but less concise:

if (variable === undefined variable === null) {
// your code here.
}

This should work for any variable that is either undeclared or declared and explicitly set to null or undefined.

Collapse
bornasepic profile image
Borna Šepić Author

Hey, sorry to hear your having trouble with this tutorial. Could you post your code on codesandbox.io/ or some other similar platform? I'd be more than happy to take a look and help you out

Collapse
justkundai profile image
Just-Kundai

Actually having the same error message. Did you guys resolve this?

Thread Thread
truepiotrek profile image
truepiotrek

Can you post your repo/code?

Collapse
truepiotrek profile image
truepiotrek • Edited on

And one thing from my side :) When you display winning/draw message you use:

statusDisplay.innerHTML = someMessage();

I think it should be used as:

statusDisplay.innerHTML = someMessage;

If I do it your way IDE tells me it's not of function type which is true since it's a const with a string inside. Apart from that it's great (:

EDIT: I've just noticed that you have functions there... This explains a lot :D

Collapse
cukekwe profile image
CUkekwe

For some reason the grid is appearing as a 1x9 column instead of 3x3. I think that it has something to do with the "grid-template-columns" part of the CSS but I'm not entirely sure. I made sure there were no typos in my CSS so I know it isn't that.

Collapse
httpsolly profile image
HttpsOlly

I have my styling set to:
display: grid;
width: 90vw;
grid-template-columns: 30vw 30vw 30vw;
grid-template-rows: 30vw 30vw 30vw;
margin: 0 5vw 0 5vw;

This ensures all cells are square. By listing 3 values for both columns and rows, it ensures the grid is 3x3.

If you want the grid to be as wide as the viewport, set the "width" to 100vw and the columns to "auto auto auto" to create 3 columns - the width of the columns will be 1/3 of the viewport. Though you would have to resize the rows also :)

Collapse
giftedgeek profile image
Rajesh

What is data-cell-index btw?

Collapse
bornasepic profile image
Borna Šepić Author

The data-cell-index is a custom attribute on an HTML element, you can read more about them here: developer.mozilla.org/en-US/docs/L...

We are using it to more cleanly track the position of the cell that has been clicked and to update the game state array at its position.

Collapse
baziotabeans profile image
fabioBaziota

amazing, simple, practical and super clean tutorial...

Collapse
elav252 profile image
ElAv252

Wow you so helped me with a problem I had.
thanks.

Collapse
cforsyth68 profile image
Charles Forsyth • Edited on

Wouldn't this be better? (event delegation, so there is only only event listener)

document
  .querySelector(".game--container")
  .addEventListener("click", handleCellClick);
Enter fullscreen mode Exit fullscreen mode
Collapse
jrubino22 profile image
jrubino22

Hello Borna,
I am about a month into learning JS, so there is a lot of this code I do not understand.
Can you please explain what the purpose of this in the handleResultValidation() function is:

if (a === b && b === c)

Collapse
truepiotrek profile image
truepiotrek

Hey there, I hope I can help :)

First you take the winConditions which is the table with smaller tables marking all possible.. well, win conditions :D And with the code you are asking about you are checking if any of them is created using the same symbol (X or O). If those two conditions are met, the game ends with a win.

Collapse
adamirenee profile image
Taylor Renee Adami

Hey, I'm having issues getting the code to switch between users. I am able to get the current player to be X, but it won't switch to O

Collapse
yash06112002 profile image
Yash Kansara • Edited on

From where handleCellClick function's parameter clickedCellEvent will get value ?

Collapse
jose_guerra_wd profile image
JoseGH

Hey Borna, amazing post.

Just a quick one, in handleResultValidation(), the for loop I believe should be 'i <= 8' since there is 8 possible combinations in total.
I'm assuming that the index it's 7, but if you don't put 8 then playing diagonally from top right corner to bottom left won match as a win.

Other than that, great post, very easy to follow and the explanations are top notch!
Cheers!

Collapse
bornasepic profile image
Borna Šepić Author

Hey Jose, thanks for the feedback, really glad to see you like it!

Ah, I see what you mean. Well while there are 8 items in the array, array indexes start at 0, not 1 (it's a bit confusing at first but that's just how it is across all languages).

So to get the first item we need winningConditions[0], the second one is at winningConditions[1], etc.

What this means is that we only need the numbers 0 - 7 (8 numbers in total).

If you were to add the 'i <= 8' you'd actually get an error because you would be trying to access an element at index 0 of undefined a line under.

Collapse
jose_guerra_wd profile image
JoseGH

Damn! I was wrong being right, but you are most deffo right. I added an extra winning move, so my array.length was longer than yours.
My bad, sorry.
Thanks for the article!