A Connect 4 Like Game in Vanilla Javascript

#Javascript

In this article we will build a Connect 4 like game with vanilla javascript. In this game two players take turns dropping tokens into a vertical grid of seven columns and six rows. The first player to connect four of his tokens horizontally, vertically, or diagonally wins.

image for A Connect 4 Like Game in Vanilla Javascript

Displaying the Game Board

To keep things simple the two players will be played by humans in the version we will build here. We might revisit this in a future article to add the option to play against the computer. We will first create a very basic HTML layout: a h1 heading, a div with an id of board which will contain the gameboard, built with javascript, and a div with an id of message where we will display which player turn it is and who wins at the end of the game.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Connect 4</title>
    <link rel="stylesheet" href="css/app.css" />
  </head>
  <body>
    <h1>Connect 4</h1>
    <div id="board"></div>
    <div id="message">Player1 turn</div>
    <script src="js/app.js"></script>
  </body>
</html>

We link a app.css stylesheet, with some basic styling. The board will be built with a table element, each cell will be a td element. We make each td a square of 60px, with a blue border which will represent the board and some shadow to give it a little thickness. The classes empty, player1, player2 will update the background of each cell according to its state (empty, owned by player 1, owned by player 2).

body {
    background-color: black;
    background-size: 100%;
    color: lightyellow;
    display: flex;
    flex-direction: column;
    align-items: center;
    flex-wrap: wrap;
}

h1 {
    font-size: 3rem;
    text-align: center;
    margin: 60px auto;
}

td {
    width: 60px;
    height: 60px;
    border: 1px solid blue;
    border-radius: 100%;
    cursor: pointer;
    box-shadow: -1px 2px 10px 3px rgba(0, 0, 0, 0.4) inset;
}

#board {
    min-width: 450px;
    border: 10px solid blue;
    border-radius: 10px;
    background-color: blue;
    box-shadow: -1px 2px 5px 1px rgba(0, 0, 0, 0.7);
    margin-bottom: 60px;
}

.empty {
    background-color: #fff;
}

.player1 {
    background-color: #ff0000;
}

.player2 {
    background-color: #ffff00;
}

#message {
    font-size: 2rem;
    text-align: center;
    text-transform: capitalize;
}

We link a app.js javascript file, which will contain our game logic. We begin by initializing the empty gameboard. The initBoard function creates an HTML table with rows and cells using the DOM API, and returns a two-dimensional array of cells to represent the board. We also add a dataset column info to each cell that will be useful to know which column has been clicked by the player. The game function calls initBoard with the height and width constants, and sets the returned board to a variable called board. Finally, the window.addEventListener function waits for the HTML document to finish loading, and then calls the game function to start the game.

const BOARDHEIGHT = 6
const BOARDWIDTH = 7

const initBoard = (height, width) => {
  const boardElt = document.querySelector('#board')

  const tableElt = document.createElement('table')
  boardElt.appendChild(tableElt)
  const board = Array.from({ length: height }, () => [])
  for (let i = 0; i < height; i++) {
    const rowElt = document.createElement('tr')
    for (let j = 0; j < width; j++) {
      const cellElt = document.createElement('td')
      cellElt.className = 'empty'
      cellElt.dataset.column = j
      rowElt.appendChild(cellElt)
      board[i][j] = cellElt
    }
    tableElt.appendChild(rowElt)
  }
  return board
}

const game = () => {
  const board = initBoard(BOARDHEIGHT, BOARDWIDTH)
}

window.addEventListener('DOMContentLoaded', () => {
  game()
})

If you open index.html in your browser you should now have something like that:

Image of an empty connect 4 gameboard

Handling Player Inputs

When a player clicks on a column, we must drop the token on the lowest empty row of this column. For this we create a function getLowestEmptyRowNumber. If the column is already full this function will return -1. Otherwise, we loop on the cells in the column and check if the cell below has a different class than empty, we stop the loop and return the row number as soon as we find a not empty cell below. If we reached the last row it means the column is empty and we can return the index of the last row.

const getLowestEmptyRowNumber = (board, columnNumber) => {
  if (board[0][columnNumber].className !== 'empty') {
    return -1 // Column is full
  }
  let i = 0
  while (i < BOARDHEIGHT - 1) {
    if (board[i + 1][columnNumber].className !== 'empty') {
      return i
    }
    i++
  }
  return i
}

const game = () => {
  const board = initBoard(BOARDHEIGHT, BOARDWIDTH)
  const maxNumberOfTurns = BOARDHEIGHT * BOARDWIDTH
  let turnCounter = 0
  let player = 'player1'

  document.querySelector('table').addEventListener('click', function (e) {
    if (undefined === e.target.dataset.column) {
      return // Click on table but not td
    }
    const colNumber = parseInt(e.target.dataset.column, 10)
    const rowNumber = getLowestEmptyRowNumber(board, colNumber)
    if (rowNumber < 0) {
      return // Column is full
    }
    turnCounter++
    board[rowNumber][colNumber].className = player
    if (turnCounter >= maxNumberOfTurns) {
      return // Board is full
    }
    player = player === 'player1' ? 'player2' : 'player1'
    document.querySelector('#message').textContent = `${player} turn`
  })
}

At this point each player can take turn placing his tokens but we can fill the whole board without stopping the game when a player wins.

Image of a full connect 4 gameboard

Checking Who Wins

In the game function we add a gameOver variable initialized to false. At each player's turn we check if the player has won thanks to a new function isWinner. If the player has won we set gameOver to true and update the message according to the winning player. Else we check if we have reached the last turn of the game withour a winner. We create the isWinner function which will count the number of adjacent cells that have the same class as the current player, first by column, then by row, and then on diagonals. If one of these number is equal to 4 the function returns true, else it returns false.

const isWinner = (board, player, x, y) => {
  let i = x
  let j = y

  // count column
  let countColumn = 1
  while (j < BOARDHEIGHT - 1 && board[j + 1][x].className === player) {
    countColumn++
    j++
  }
  if (countColumn === 4) {
    return true
  }

  // count row
  let countRow = 1
  let countRowLeft = 0
  let countRowRight = 0
  while (i > 0 && board[y][i - 1].className === player) {
    countRowLeft++
    i--
  }
  i = x
  while (i < BOARDWIDTH - 1 && board[y][i + 1].className === player) {
    countRowRight++
    i++
  }
  countRow += countRowLeft + countRowRight
  if (countRow === 4) {
    return true
  }

  //count diagonal
  let countDiag1 = 1
  let countDiagUpLeft = 0
  i = x
  j = y
  while (j > 0 && i > 0 && board[j - 1][i - 1].className === player) {
    countDiagUpLeft++
    i--
    j--
  }
  let countDiagDownRight = 0
  i = x
  j = y
  while (
    j < BOARDHEIGHT - 1 &&
    i < BOARDWIDTH - 1 &&
    board[j + 1][i + 1].className === player
  ) {
    countDiagDownRight++
    i++
    j++
  }
  countDiag1 += countDiagUpLeft + countDiagDownRight
  if (countDiag1 === 4) {
    return true
  }

  let countDiag2 = 1
  let countDiagDownLeft = 0
  i = x
  j = y
  while (j < BOARDHEIGHT - 1 && i > 0 && board[j + 1][i - 1].className === player) {
    countDiagDownLeft++
    i--
    j++
  }
  let countDiagUpRight = 0
  i = x
  j = y
  while (j > 0 && i < BOARDWIDTH - 1 && board[j - 1][i + 1].className === player) {
    countDiagUpRight++
    i++
    j--
  }
  countDiag2 += countDiagDownLeft + countDiagUpRight
  if (countDiag2 === 4) {
    return true
  }

  return false
}

const game = () => {
  const board = initBoard(BOARDHEIGHT, BOARDWIDTH)
  const maxNumberOfTurns = BOARDHEIGHT * BOARDWIDTH
  let turnCounter = 0
  let player = 'player1'
  let gameOver = false

  document.querySelector('table').addEventListener('click', function (e) {
    if (gameOver) {
      return
    }
    if (undefined === e.target.dataset.column) {
      return // Click on table but not td
    }
    const colNumber = parseInt(e.target.dataset.column, 10)
    const rowNumber = getLowestEmptyRowNumber(board, colNumber)
    if (rowNumber < 0) {
      return // Column is full
    }
    turnCounter++
    board[rowNumber][colNumber].className = player
    if (isWinner(board, player, colNumber, rowNumber)) {
      document.querySelector('#message').textContent = `${player} has won!`
      gameOver = true
      return
    }
    if (turnCounter >= maxNumberOfTurns) {
      document.querySelector('#message').textContent = `It's a draw game!`
      gameOver = true
      return
    }
    player = player === 'player1' ? 'player2' : 'player1'
    document.querySelector('#message').textContent = `${player} turn`
  })
}

Simulating Tokens Fall

The game is now working but it seems a bit weird to have the tokens directly appear on the lowest empty row. We want to add some kind of animation so that it seems that the tokens fall from the highest row downward. We create a new drop function. This function will add the player's class to the highest row, then if it is higher than the targeted row, it will set a timeout to set back the current cell's class to empty and then call itself recursively on the cell below. In the game function we replace the line where we set directly the cell's classname to the current player's by a call to this function.

const drop = (board, player, rowNumber, colNumber, currentRow) => {
  board[currentRow][colNumber].className = player
  if (currentRow < rowNumber) {
    setTimeout(() => {
      board[currentRow][colNumber].className = 'empty'
      drop(board, player, rowNumber, colNumber, currentRow + 1)
    }, 40)
  }
}

const game = () => {
  const board = initBoard(BOARDHEIGHT, BOARDWIDTH)
  const maxNumberOfTurns = BOARDHEIGHT * BOARDWIDTH
  let turnCounter = 0
  let player = 'player1'
  let gameOver = false

  document.querySelector('table').addEventListener('click', function (e) {
    if (gameOver) {
      return
    }
    if (undefined === e.target.dataset.column) {
      return // Click on table but not td
    }
    const colNumber = parseInt(e.target.dataset.column, 10)
    const rowNumber = getLowestEmptyRowNumber(board, colNumber)
    if (rowNumber < 0) {
      return // Column is full
    }
    turnCounter++
    drop(board, player, rowNumber, colNumber, 0)
    if (isWinner(board, player, colNumber, rowNumber)) {
      document.querySelector('#message').textContent = `${player} has won!`
      gameOver = true
      return
    }
    if (turnCounter >= maxNumberOfTurns) {
      document.querySelector('#message').textContent = `It's a draw game!`
      gameOver = true
      return
    }
    player = player === 'player1' ? 'player2' : 'player1'
    document.querySelector('#message').textContent = `${player} turn`
  })
}

Some Refactoring

I want to fix two things that have been bothering me.

First the isWinner function is very long, let's try to shorten it a bit.
We create a countCells helper function. The function counts the number of game pieces of a player in a specified direction from a given position on the game board. It iterates in the specified direction and increments a count variable for each cell that has the same className as the player parameter.
The column and row indices are updated based on the dj and di parameters.
The function stops when it encounters a cell that does not have the same className as the player and returns the count variable.

const countCells = (board, player, j, i, dj, di) => {
  let count = 0

  while (
    j >= 0 &&
    j < BOARDHEIGHT &&
    i >= 0 &&
    i < BOARDWIDTH &&
    board[j][i].className === player
  ) {
    count++
    j += dj
    i += di
  }

  return count
}

const isWinner = (board, player, x, y) => {
  const countColumn = countCells(board, player, y + 1, x, 1, 0) + 1
  const countRow =
    countCells(board, player, y, x - 1, 0, -1) +
    countCells(board, player, y, x + 1, 0, 1) +
    1
  const countDiagonal1 =
    countCells(board, player, y - 1, x - 1, -1, -1) +
    countCells(board, player, y + 1, x + 1, 1, 1) +
    1
  const countDiagonal2 =
    countCells(board, player, y + 1, x - 1, 1, -1) +
    countCells(board, player, y - 1, x + 1, -1, 1) +
    1

  return (
    countColumn >= 4 ||
    countRow >= 4 ||
    countDiagonal1 >= 4 ||
    countDiagonal2 >= 4
  )
}

Then, the drop method is taking some time to run because of the setTimeout. In the meantime, if a user repeatedly fast clicks on the board, it can completely mess up the game board. We want to make the drop function async so we can await it in our game function.

const drop = async (board, player, rowNumber, colNumber, currentRow) => {
  board[currentRow][colNumber].className = player
  if (currentRow < rowNumber) {
    await new Promise((resolve) => {
      setTimeout(() => {
        board[currentRow][colNumber].className = 'empty'
        drop(board, player, rowNumber, colNumber, currentRow + 1).then(resolve)
      }, 40)
    })
  }
}

Then in our "game" function we introduce a blockedInput variable, initialized to false.
We make the event listener callback async, if blockedInput is true we do not handle the player click but simply return, else we set it to true and await the drop function, at the end of the turn we set blockedInput back to false.
We also made a bit of cleanup by regrouping the reasons to return early at the begining and creating a messageElt variable instead of repeatedly calling document.querySelector('#message').

const game = () => {
  const board = initBoard(BOARDHEIGHT, BOARDWIDTH)
  const maxNumberOfTurns = BOARDHEIGHT * BOARDWIDTH
  const messageElt = document.querySelector('#message')
  let turnCounter = 0
  let player = 'player1'
  let gameOver = false
  let blockedInput = false

  document.querySelector('table').addEventListener('click', async function (e) {
    if (gameOver || undefined === e.target.dataset.column || blockedInput) {
      return
    }
    const colNumber = parseInt(e.target.dataset.column, 10)
    const rowNumber = getLowestEmptyRowNumber(board, colNumber)
    if (rowNumber < 0) {
      return // Column is full
    }
    blockedInput = true
    turnCounter++
    await drop(board, player, rowNumber, colNumber, 0)
    if (isWinner(board, player, colNumber, rowNumber)) {
      messageElt.textContent = `${player} has won!`
      gameOver = true
      return
    }
    if (turnCounter >= maxNumberOfTurns) {
      messageElt.textContent = `It's a draw game!`
      gameOver = true
      return
    }
    player = player === 'player1' ? 'player2' : 'player1'
    messageElt.textContent = `${player} turn`
    blockedInput = false
  })
}

And there you have it a fully working Connect 4 game built with javascript!

You can find the code in this Github repository.

And you can try a demo here.

Last updated 1 year ago