Making a simple Tic-Tac-Toe game in JavaScript

Beginner's Guide

Making a simple Tic-Tac-Toe game in JavaScript

For a while now, I've been playing with JavaScript and I even made a tic tac toe game a while back. I watched a YouTube tutorial and then went on to make my own version of. I'm happy to share it with everyone. In this game, a human player plays against AI Player.

You can find the complete code here and the demo here

I'm asssuming all files will be on the same directory level

SPOILER ALERT: The AI Player simply makes random moves and wins by chance. It's still fun though

  • HTML

    Since our focus is on JavaScript, you can just copy & paste this HTML code in a file called index.html
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css" />
   <link rel="stylesheet" href="style.css">
   <title>Tic Tac Toe</title>
</head>
<body>

   <div class="container">

      <h1 id="title">Tic Tac Toe</h1>

      <div id="option">
         <p>Who do you want to play as?</p>
         <div>
            <input type="radio" name="choice" id="x">X
            <input type="radio" name="choice" id="o">O
            <button class="btn" id="startBtn">Start</button>
         </div>
      </div>

      <!-- show whose turn to move is-->
      <p id="playerMessage"></p>

      <a href="#" class="btn" id="reset">Reset</a>

       <!-- create empty tic-tac-toe board -->
      <div id="board">
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
         <div class="cell"></div>
      </div>

      <a href="https://github.com/RabsonJ/tictactoe" target="_blank" rel="noopener noreferrer"><i class="fab fa-github repo"></i></a>

   </div>

<!-- show win, draw or lose status -->   
<div id="resultContainer">
      <h3 id="gameResult"></h3>
      <p>Restarting game...</p>
   </div>

<script src="app.js"></script>
</body>
</html>
  • CSS

All we're doing here is giving some styling to our small game. Put the styles in a file called styles.css

*,
*::before,
*::after {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --cell-size: 100px;
    --mark-size: calc(var(--cell-size) * .9);
}

body {
    background-color: #525558;
    color: #f0ecf0;
    font-family: 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
}

a {
    text-decoration: none;
}

.container {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-top: 2rem;
}

#title {
    font-size: 4rem;
    margin-bottom: 3rem;
    color: #d6b5aa;
}

#playerMessage {
   font-size: 1.5rem;
    margin-bottom: 1rem;
}

#option > * {
   margin-bottom: 1rem;
   text-align: center;
}

#reset {
    background-color: rgb(214, 29, 29);
    color: #fff;
    margin-bottom: 1rem;
}

#board {
    display: grid;
    justify-content: center;
    align-content: center;
    justify-items: center;
    align-items: center;
    grid-template-columns: repeat(3, auto);
}

.cell {
    height: var(--cell-size);
    width: var(--cell-size);
    background-color: #f0ecf0;
    border: 1px solid #525558;
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
}

.cell.x {
    background-color: #e0c7be;
}

.cell.circle {
    background-color: #e3cd6d;
}

.cell.x,
.cell.circle {
    cursor: not-allowed;
}


- 
.cell.x::before,
.cell.x::after {
    content: '';
    position: absolute;
    width: calc(var(--mark-size) * .15);
    height: var(--mark-size);
    background-color: #525558;
}

.cell.x::before {
    transform: rotate(45deg);
}

.cell.x::after {
    transform: rotate(-45deg);
}

.cell.circle::before,
.cell.circle::after {
    content: '';
    position: absolute;
    border-radius: 50%;
}

.cell.circle::before {
    width: var(--mark-size);
    height: var(--mark-size);
    background-color: #525558;
}

.cell.circle::after {
    width: calc(var(--mark-size) * .7);
    height: calc(var(--mark-size) * .7);
    background-color: #e3cd6d;
}

// Overlay to display result status

#resultContainer {
    display: none;
    position: fixed;
    width: 100vw;
    height: 100vh;
    opacity: .95;
    background-color: #000;
    top: 0;
    left: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}

#gameResult {
    font-size: 2rem;
    margin-bottom: 1rem;
}

.btn {
    display: inline-block;
    padding: .5rem 1rem;
    text-transform: uppercase;
    font-weight: 700;
}

// Link to GitHub repo
.repo {
    margin-top: 2rem;
    margin-bottom: 1rem;
    color: #000;
    font-size: 3rem;
}

We're using .cell.x::before, .cell.x::after, .cell.circle::before, .cell.circle::after to style our X and O so that they look the same in every browser.

  • JavaScript

Create a file called app.js to hold all of your JavaScript code.

Let's begin by "fetching" our HTML elements.

const gameBoard = document.querySelector('#board');
const cells = document.querySelectorAll('.cell');
const playerMessage = document.querySelector('#playerMessage');
const resultContainer = document.querySelector('#resultContainer');
const gameResult = document.querySelector('#gameResult');
const resetGame = document.querySelector('#reset');
const option = document.querySelector('#option');

Next, let's declare and initialise our X and O constants, as well as an array of all possible winning combinations

const X_CLASS = 'x';
const CIRCLE_CLASS = 'circle';

const winCombos = [
    [ 0, 1, 2 ],
    [ 3, 4, 5 ],
    [ 6, 7, 8 ],
    [ 0, 3, 6 ],
    [ 1, 4, 7 ],
    [ 2, 5, 8 ],
    [ 0, 4, 8 ],
    [ 2, 4, 6 ]
];

Now, on to the fun part. When a user clicks the "Reset Game" button, reset the game to it's initial state by calling a function called initGame, and start the game when the "Start" button is clicked by calling the function startGame. We also call initGame() when our game loads

resetGame.addEventListener('click', initGame);
document.querySelector('#startBtn').addEventListener('click', startGame);

initGame();

// "extract" array from the HTML cells we selected (because at the moment it's an array-like Object)
const cellsArray = Array.from(cells);

// Add only one event listener on each cell
cellsArray.forEach((cell) => cell.addEventListener('click', handleClick, { once: true }));

initGame:

// Original Game State
function initGame() {
    option.style.display = 'block';
    gameBoard.style.display = 'none';
    resetGame.style.display = 'none';

// Remove any "X" or "O" from the cells
    cells.forEach((cell) => (cell.classList.contains(X_CLASS) ? cell.classList.remove(X_CLASS) : cell.classList.remove(CIRCLE_CLASS)));
    playerMessage.innerHTML = "Let's have some fun";

    resultContainer.style.display = 'none';
}

startGame: Outside of the function, create two variables, humanPlayer and aiPlayer

// Start Game
let humanPlayer, aiPlayer;
function startGame() {
    const xChoice = document.querySelector('#x').checked;
    const oChoice = document.querySelector('#o').checked;

// Only show the game board after the user picks either "X" or "O"
    if (xChoice || oChoice) {
        gameBoard.style.display = 'grid';
        resetGame.style.display = 'block';
        option.style.display = 'none';
    }

    humanPlayer = xChoice ? X_CLASS : CIRCLE_CLASS;

// If the user chose to play as "X", then our aiPlayer will be "O", otherwise, it'll be "X"
    aiPlayer = humanPlayer === X_CLASS ? CIRCLE_CLASS : X_CLASS;
}

handleClick: Let's specify what happens when a user clicks on a cell

function handleClick(e) {
  // If the clicked cell already has "X" or "O", remove the event listener so that it cannot be clicked again
    if (e.target.classList.contains(X_CLASS) || e.target.classList.contains(CIRCLE_CLASS)) {
        e.target.removeEventListener('click', handleClick);
    } else {
        // If a click occured, it means it's the human player that moved, so show that on the clicked cell 
        e.target.classList.add(humanPlayer);

        // Now, call checkWinner() - returns true/false - and pass in the cells array and the class of the human player to check if they've won
        if (checkWinner(cells, humanPlayer)) {
            endGame('Congrats!!! You won');
        } else {
            // The human player hasn't won, take turns
            takeTurns();
        }
    }

    // The function emptyCells return an array of all empty cells. If there are no longer any empty cells and neither the humanPlayer nor the aiPlayer has won, it means we have a draw
    if (emptyCells().length === 0 && !checkWinner(cells, X_CLASS) && !checkWinner(cells, CIRCLE_CLASS)) {
        endGame("It's a draw");
        return;
    }
}

checkWinner: This is the most interesting part, checking for the winner. It may take some time to wrap your head around, but you'll get there. Remember, our winningCombos variable is an array of arrays. Each of it's arrays is a winning combination, so we'll iterate through winningCombos with the some method, since we need at least one array from it to have a winning combination of three cells. However, each of the cells (indexes) in those arrays must contain the same class (X_CLASS or CIRCLE_CLASS), and we use the every method to check this.

// Has any winning condition been met?
function checkWinner(cells, currentClass) {
    return winCombos.some((possible) => {
        return possible.every((index) => {
            return cells[index].classList.contains(currentClass);
        });
    });
}

endGame:

function endGame(winMessage) {
    gameResult.innerHTML = winMessage;
    resultContainer.style.display = 'flex';

    // set a timer for the results message to "disappear"
    setTimeout(() => {
        initGame();
        resultContainer.style.display = 'none';
    }, 2500);
    return;
}

emptyCells: Here, we use the filter method to filter out cells not having either an "X" or "O". We then return the array returned by the filter method, which is a array of empty cells, if any

// Util function to give us empty cells
function emptyCells() {
    return cellsArray.filter((cell) => !cell.classList.contains(X_CLASS) && !cell.classList.contains(CIRCLE_CLASS));
}

takeTurns:

// Switch to aiPlayer
function takeTurns() {
    // At this point, the human player has made a move. Remove all event listeners to prevent the human player from clicking a cell before our aiPlayer makes a move
    cellsArray.forEach((cell) => cell.removeEventListener('click', handleClick));
    if (emptyCells().length !== 0) {
        // wait for 1 second before moving to make the game feel more natural
        setTimeout(() => {
            const randEmptyCell = emptyCells()[Math.floor(Math.random() * emptyCells().length)];
            // Add back eventListeners once aiPlayer has moved
            cellsArray.forEach((cell) => cell.addEventListener('click', handleClick, { once: true }));

            // Mark an "X" or "O" on a random cell picked from the empty cells
            randEmptyCell.classList.add(aiPlayer);
            emptyCells();

            // check if aiPlayer has won after making move
            if (checkWinner(cells, aiPlayer)) {
                endGame('Sorry! You lost');
                return;
            }
            playerMessage.innerHTML = 'Your turn!';
        }, 1000);

        playerMessage.innerHTML = 'My turn!';
    }
}

Conclusion

Well, that was quite a lot to take in wasn't it! Making a Tic Tac Toe game may sound easy but you sure do learn some great concepts along the way. I hope you found this insightful.

Suggestions to improve the game

  1. Implement the Minimax Algorithm to make our aiPlayer unbeatable
  2. Make different game modes, for exemple...Classic Mode, Zombie mode, etc that changes the look and feel of the game
  3. Show some confetti or any other animation when the human / ai player wins