Connect Four

Muhammad Saqib Ilyas
15 min readAug 27, 2023

--

In this article, let’s develop a game of Connect Four in JavaScript. The connect four game consists of a board with 7 columns with 6 rows each. It is a two players game. One player has blue disks, while the other has red disks. The players take turns one by one dropping a disk into any of the columns that isn’t already full. The player who manages to have four consecutive disks in a column, a row, or diagonally adjacent wins.

Setting up the board

We’ll start with a simple HTML file for the game:

<!DOCTYPE html>
<html>
<head>
<title>Connect Four</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<h2>Connect Four</h2>
<div id="game">
<div id="turn">Turn: Player <span id="player">1</span></div>
<div id="outcome"><span id="result"></span></div>
<div id="board">

</div>
</div>
<script type="text/javascript" src="app.js"></script>
</body>
</html>

We’ve set up a div element with an id of game, to encompass all other elements. We created a div element with a span element inside it to indicate which player’s turn it is. Player 1 goes first, so that’s what we’re indicating. We created another div with a span inside it to show the game’s outcome. Then, comes the game board itself. There’s nothing inside it because we’ll populate it using JavaScript.

Here’s the super simple CSS with borders for nearly all elements, so that we can see how things are getting laid out.

:root {
--divwidth: 20px;
--divheight: 20px;
}

#game {
border: 1px solid black;
margin: 0 auto;
}

#turn {
border: 1px solid blue;
}

#outcome {
border: 1px solid green;
}
#board {
border: 1px solid red;
width: calc(7*var(--divwidth));
height: calc(6*var(--divheight));
margin: 0 auto;
display: flex;
flex-wrap: wrap;
}

#board div {
height: var(--divheight);
width: var(--divwidth);
}

We’ve defined CSS variables divwidth and divheight in the :root pseudo class. Then, we’ve used the values of these variables to set the height and width of the #board and #board div selectors. The board is supposed to contain 7 columns with 6 rows, so its width is 7 times that of a div element inside it. Rather than hard-coding the widths and heights everywhere, we defined variables and used them. This way, if we want to change the game dimensions, we just have to make changes in one place.

Now, onto setting up the board using JavaScript:

const board = document.getElementById('board')
function initializeBoard() {
for (let i = 0 ; i < 7 ; i++) {
for (let j = 0 ; j < 6 ; j++) {
const div = document.createElement('div')
board.appendChild(div)
}
}
}

initializeBoard()

We obtained a handle to the div element with the id of board. We set up the 7 x 6 board by using nested for loops — the outer one running 7 times, while the inner one runs 6 times. We created div elements inside the inner for loop and appended these new div elements to the div element with the id of board. Now, when you open the page, you should see something like the following:

Initial set up of the connect four game

Keeping track of turns

To keep track of which player’s turn it is, we need a variable. We’ll define one and initialise it to 0. Why not initialise it to 1, since we have “player 1” and “player 2." We could certainly do that. I decided to assign 0 to player 1, and 1 to player 2. That way, I can use the value of this variable as an index into an array to pick values unique to each player, for instance the colour of their disks.

const playerSpan = document.getElementById('player')
addEventListener('click', onClick)

let turn = 0

const player = ['red', 'blue']

function onClick(e) {
turn = 1 - turn
playerSpan.innerHTML = turn + 1
}

Now, every time you click, you’ll see the turn change from “Player 1” to “Player 2” and so on. Notice how we’ve toggled the value of the variable turn. Initially, it was 0, so the first time you click, it becomes 1–0, i.e., 1. The next time you click on it, it becomes 1–1, i.e., 0, and then you repeat the process.

That’s all great, but this click handler gets triggered no matter where you click on the page. We’d just like to respond to clicks on the board. One way to approach that is to add a click handler to all the div elements inside the div element with the id of board. Let’s go ahead and update the JavaScript to the following:

const board = document.getElementById('board')
const playerSpan = document.getElementById('player')
let squares = null

let turn = 0

const player = ['red', 'blue']

function initializeBoard() {
for (let i = 0 ; i < 7 ; i++) {
for (let j = 0 ; j < 6 ; j++) {
const div = document.createElement('div')
board.appendChild(div)
}
}
squares = board.querySelectorAll(":scope > div")
squares.forEach( (square, index) => {
square.onclick = () => {
console.log('You clicked on ', index)
turn = 1 - turn
playerSpan.innerHTML = turn + 1
}
})
}

initializeBoard()

Since we already had a handle to the div element with the id of board, and we needed access to its child div elements, we used the querySelectorAll() function with a selector of :scope > div, which means “direct children that are of type div. Then, we iterate over the array squares and for every element, we do a console.log() just to make sure that we are receiving the right values in the event.

Implementing the disk drop

Let’s try to implement the logic to drop the disk in its right place. We’ll let the player click anywhere in the column they wish to drop the disk at. Let’s begin:

const board = document.getElementById('board')
const playerSpan = document.getElementById('player')
let squares = null

const columns = 7
const rows = 6

let turn = 0

const player = ['red', 'blue']

let countColumns = [0, 0, 0, 0, 0, 0, 0]

function initializeBoard() {
for (let i = 0 ; i < 7 ; i++) {
for (let j = 0 ; j < 6 ; j++) {
const div = document.createElement('div')
board.appendChild(div)
}
}
squares = board.querySelectorAll(":scope > div")
squares.forEach( (square, index) => {
square.onclick = () => {
const column = getColumnTopIndex(index)
if (countColumns[column] < rows) {
// We may place a disk here
countColumns[column] = countColumns[column] + 1
const index = column
squares[index].classList.add(player[turn])
turn = 1 - turn
playerSpan.innerHTML = turn + 1
}
else {
// We can't place a disk in this column
playerSpan.innerHTML = "This column is full"
}
}
})
}

function getColumnTopIndex(divindex) {
return divindex % columns
}

initializeBoard()

We initialised an array named countColumns to hold the number of disks already in a given column. Since there are seven columns, it has a size of seven, too. The initial values are all 0s. We also defined variables for columns and rows to hold the size of the grid.

Inside the click event handler, we obtain the index of the div element at the top of the column in which the user clicked. We defined a helper function getColumnTopIndex() for this. For now, we’ll just drop the disk at the div element at the top of the column.

We’ll define two CSS classes red and blue as follows:

.red {
background-color: red;
border-radius: 10px;
}

.blue {
background-color: blue;
border-radius: 10px;
}

So, now, when we add red or blue to any div element’s classList, it resembles a red or blue disk. Try clicking in different columns, and you’ll see disks appearing at the top of the columns.

Disks can now be placed on clicks

Let’s have the disks drop to the lowest possible row in the respective column. We know the number of disks in the respective column. We know its top index. How do we find the index of the vacant div in the lowest row in that column?

Well, we have the column’s top index in the variable column. What’s the index of the div element right below it? It is column + columns. The one below that? column + 2 * columns, and so on. How many rows deep do we want to go? Since the total number of rows is in the variable rows, we need to go rows — countColumns[column] rows down. With that, we have our answer. Here’s the modified code:

function initializeBoard() {
for (let i = 0 ; i < 7 ; i++) {
for (let j = 0 ; j < 6 ; j++) {
const div = document.createElement('div')
board.appendChild(div)
}
}
squares = board.querySelectorAll(":scope > div")
squares.forEach( (square, index) => {
square.onclick = () => {
const column = getColumnTopIndex(index)
if (countColumns[column] < rows) {
// We may place a disk here
countColumns[column] = countColumns[column] + 1
const index = column + (rows - countColumns[column])*columns
squares[column + (rows - countColumns[column])*columns].classList.add(player[turn])
turn = 1 - turn
playerSpan.innerHTML = turn + 1
}
else {
// We can't place a disk in this column
playerSpan.innerHTML = "This column is full"
}
}
})
}

Now, disks should drop to the lowest row possible, like shown in the following illustration:

Disks now drop to the right place

Do we have a winner?

Now, let’s write code to determine if we have a winner. When do we need to check this? After every disk is successfully placed. So, we’ll define a function and call it from within the click handler. Since we know which column was clicked in, we know its index, and we can pass that to the win declaring function. We’ll also pass the index of the newest added disk to make the “calculation” more convenient.

The player could win in four different ways: four adjacent disks in a row, in a column, in a right slanting diagonal, or in a left slanting diagonal. The following figure shows the board layout and the disk indices. Understanding how adjacent disk indices are related is important in determining a win.

The disk indices

Note that adjacent disk indices in a row are consecutive integers, increasing to the right. Adjacent disk indices in a column differ by 7, increasing from top to bottom. Adjacent disk indices in a right slanting diagonal differ by 6, decreasing upward. Adjacent disk indices in a left slanting diagonal differ by 8, decreasing upward.

Let’s start by checking for a win with four disks in a column. Here’s the modified code:

function initializeBoard() {
for (let i = 0 ; i < 7 ; i++) {
for (let j = 0 ; j < 6 ; j++) {
const div = document.createElement('div')
board.appendChild(div)
}
}
squares = board.querySelectorAll(":scope > div")
squares.forEach( (square, index) => {
square.onclick = () => {
const column = getColumnTopIndex(index)
if (countColumns[column] < rows) {
// We may place a disk here
countColumns[column] = countColumns[column] + 1
const index = column + (rows - countColumns[column])*columns
squares[column + (rows - countColumns[column])*columns].classList.add(player[turn])
if (isWin(column, index)) {
resultSpan.innerHTML = 'Player ' + (turn + 1) + ' wins'
}
turn = 1 - turn
playerSpan.innerHTML = turn + 1
}
else {
// We can't place a disk in this column
playerSpan.innerHTML = "This column is full"
}
}
})
}

function isWin(column, index) {
if (isWinVertical(column, index)) {
return true
}
return false
}

function isWinVertical(column, index) {
if (countColumns[column] < 4) {
return false
}
for (let i = index ; i < index + 4 * columns ; i = i + columns) {
if (!squares[i].classList.contains(player[turn])) {
return false
}
}
return true
}

The isWin() function calls the isWinVertical() function, which starts by checking if the column the player clicked on has at least 4 disks. There’s no point in checking if they won, if there aren’t even a total of 4 disks in the column. If there aren’t enough disks, we immediately return false. Then, we start a for loop at the index where the last disk was placed. We skip columns div elements at a time in the for loop to successively access the div elements below the recently placed disk. As soon as we find a disk that does not have a class that matches the current player’s colour, we return false because this disk broke the streak of a single colour. Our loop runs only 4 times, because there’s no point checking more than 4 disks deep. If our loop is exited normally, it means that we found four consecutive disks of the same colour, and the current player won, so we return true.

Back in the click handler, we call isWin() and if it returns true, we indicate that the current player won.

Next, let’s implement the horizontal win check. The disk indices increases consecutively to the right, and decrease to the left. Once a disk is placed, we need to check both to its left and right to see if there four consecutive disks of the same color. The disk that was just placed could be the left most disk in that set of four disks, or it could be the right most, or one in the middle. So, what we’ll do is to first determine the number of consecutive same color disks to its right. If we find three, the player won. If not, let n be the number of consecutive disks to the right of the new disk that we found. We’ll now look to the new disk’s left for 4-n-1 consecutive disks of the same color. Here’s the code:

function isWinHorizontal(column, index) {
stepsRight = 0
while (((index + stepsRight + 1) % columns !== 0) && (stepsRight < 4) && (squares[index + stepsRight + 1].classList.contains(player[turn])) ) {
stepsRight += 1
}
// There are stepsRight similar disks to the right
// If there are three similar disks to the right, the player won
if (stepsRight === 3) {
return true
}
// Otherwise, we need to see if there are 4 - 1 - stepsRight disks to the left
stepsLeft = 0
while ((index - stepsLeft - 1 > -1) && ((index - stepsLeft - 1) % columns !== columns - 1) && (stepsLeft < 3 - stepsRight) && (squares[index - stepsLeft - 1].classList.contains(player[turn]))) {
stepsLeft += 1
}
if (stepsLeft + stepsRight + 1 === 4) {
return true
}
return false
}

We initialize a variable stepsRight to 0, and then run a while loop with a compound condition. The first condition bound checks that we don’t increment the index beyond the current row. The second condition causes a stop if we’ve located three consecutive disks of the same color. The last condition checks that the next disk to the right has the same color as the new disk. If all of these conditions are true, we increment stepsRight. At the end of the loop, this variable will hold the number of consecutive disks of the same color to the right of the new disk. After the first while loop, we check if we found three consecutive disks of the same color to the right. If so, we return true. If not, we don’t lose hope and run another while loop, looking for consecutive disks of the same color to the left of the new disk. The first condition in the while loop makes sure that we don’t look beyond the first disk. The second condition checks that we don’t look to the left of the leftmost disk in any column. The next condition checks if we found enough consecutive disks to the left and right to constitute a win. The final condition checks that the next disk to the left has the same color as the new disk. If all of these conditions are true, we increment stepsLeft and continue. Finally, outside this loop, we check if we were able to find 4 consecutive disks of the same color in the row. If so, we return true. If not, we return false.

Up next are two functions to check for a win on a right slanting, or left slanting diagonal:

function isWinDiagonal1(column, index) {
// check for win on right slanting diagonal
stepsRight = 0
while ( (index - ( stepsRight + 1 ) * (columns - 1) > 0) && (stepsRight < 4) && (squares[index - ( stepsRight + 1 ) * (columns - 1)].classList.contains(player[turn])) ) {
stepsRight += 1
}
if (stepsRight === 3) {
return true
}
stepsLeft = 0
while ( (index + (stepsLeft + 1) * (columns - 1) < columns * rows) && ( stepsLeft + stepsRight < 3) && (squares[index + (stepsLeft + 1) * (columns - 1)].classList.contains(player[turn])) ) {
stepsLeft += 1
}
if (stepsLeft + stepsRight === 3) {
return true
}
return false
}

function isWinDiagonal2(column, index) {
// check for win on left slanting diagonal
stepsLeft = 0
while ( (index - ( stepsLeft + 1 ) * (columns + 1) > 0) && (stepsLeft < 4) && (squares[index - ( stepsLeft + 1 ) * (columns + 1)].classList.contains(player[turn])) ) {
stepsLeft += 1
}
if (stepsLeft === 3) {
return true
}
stepsRight = 0
while ( (index + (stepsRight + 1) * (columns + 1) < columns * rows) && ( stepsLeft + stepsRight < 3) && (squares[index + (stepsRight + 1) * (columns + 1)].classList.contains(player[turn])) ) {
stepsRight += 1
}
if (stepsLeft + stepsRight === 3) {
return true
}
return false
}

Note that when moving in a diagonal, on every step, we move a multiple of either 6, or 8, depending on which diagonal we are exploring. That is why you see columns — 1 being multiplied by stepsRight + 1, for example in isWinDiagonal1. The structure of these functions is very similar to that of isWinHorizontal so I wouldn’t explain it in as much detail.

We’ll need to update the isWin() function to the following:

function isWin(column, index) {
if (isWinVertical(column, index)) {
return true
}
else if (isWinHorizontal(column, index)) {
return true
}
else if (isWinDiagonal1(column, index)) {
return true
}
else if (isWinDiagonal2(column, index)) {
return true
}
return false
}

Please make it stop

You can keep playing our game even after a player has won. So, once a winner is declared we need to make the game stop, by removing all the click handlers. We can achieve that by setting the onclick property of all squares to null.

Also, if all disks have been placed and no one won, a tie should be declared and the game should stop. Here’s the modified code:

const board = document.getElementById('board')
const playerSpan = document.getElementById('player')
const resultSpan = document.getElementById('result')
let squares = null

const columns = 7
const rows = 6

let turn = 0

const player = ['red', 'blue']

let countColumns = [0, 0, 0, 0, 0, 0, 0]

function initializeBoard() {
for (let i = 0 ; i < 7 ; i++) {
for (let j = 0 ; j < 6 ; j++) {
const div = document.createElement('div')
board.appendChild(div)
}
}
squares = board.querySelectorAll(":scope > div")
squares.forEach( (square, index) => {
square.onclick = () => {
const column = getColumnTopIndex(index)
if (countColumns[column] < rows) {
// We may place a disk here
countColumns[column] = countColumns[column] + 1
const index = column + (rows - countColumns[column])*columns
squares[column + (rows - countColumns[column])*columns].classList.add(player[turn])
if (isWin(column, index)) {
resultSpan.innerHTML = 'Player ' + (turn + 1) + ' wins'
squares.forEach( (square, index) => square.onclick = null)
}
else if (isTie()) {
resultSpan.innerHTML = 'It is a tie!'
squares.forEach( (square, index) => square.onclick = null)
}
turn = 1 - turn
playerSpan.innerHTML = turn + 1
}
else {
// We can't place a disk in this column
playerSpan.innerHTML = "This column is full"
}
}
})
}

function isTie() {
if (countColumns.reduce( (acc, value) => acc + value) === columns * rows) {
return true
}
return false
}

function isWin(column, index) {
if (isWinVertical(column, index)) {
return true
}
else if (isWinHorizontal(column, index)) {
return true
}
else if (isWinDiagonal1(column, index)) {
return true
}
else if (isWinDiagonal2(column, index)) {
return true
}
return false
}

function isWinVertical(column, index) {
if (countColumns[column] < 4) {
return false
}
for (let i = index ; i < index + 4 * columns ; i = i + columns) {
if (!squares[i].classList.contains(player[turn])) {
return false
}
}
return true
}

function isWinHorizontal(column, index) {
stepsRight = 0
while (((index + stepsRight + 1) % columns !== 0) && (stepsRight < 4) && (squares[index + stepsRight + 1].classList.contains(player[turn])) ) {
stepsRight += 1
}
// There are steps similar disks to the right
// If there are three similar disks to the right, the player won
if (stepsRight === 3) {
return true
}
// Otherwise, we need to see if there are 4 - 1 - stepsRight disks to the left
stepsLeft = 0
while ((index - stepsLeft - 1 > -1) && ((index - stepsLeft - 1) % columns !== columns - 1) && (stepsLeft < 3 - stepsRight) && (squares[index - stepsLeft - 1].classList.contains(player[turn]))) {
stepsLeft += 1
}
if (stepsLeft + stepsRight + 1 === 4) {
return true
}
return false
}

function isWinDiagonal1(column, index) {
// check for win on right slanting diagonal
stepsRight = 0
while ( (index - ( stepsRight + 1 ) * (columns - 1) > 0) && (stepsRight < 4) && (squares[index - ( stepsRight + 1 ) * (columns - 1)].classList.contains(player[turn])) ) {
stepsRight += 1
}
if (stepsRight === 3) {
return true
}
stepsLeft = 0
while ( (index + (stepsLeft + 1) * (columns - 1) < columns * rows) && ( stepsLeft + stepsRight < 3) && (squares[index + (stepsLeft + 1) * (columns - 1)].classList.contains(player[turn])) ) {
stepsLeft += 1
}
if (stepsLeft + stepsRight === 3) {
return true
}
return false
}

function isWinDiagonal2(column, index) {
// check for win on left slanting diagonal
stepsLeft = 0
while ( (index - ( stepsLeft + 1 ) * (columns + 1) > 0) && (stepsLeft < 4) && (squares[index - ( stepsLeft + 1 ) * (columns + 1)].classList.contains(player[turn])) ) {
stepsLeft += 1
}
if (stepsLeft === 3) {
return true
}
stepsRight = 0
while ( (index + (stepsRight + 1) * (columns + 1) < columns * rows) && ( stepsLeft + stepsRight < 3) && (squares[index + (stepsRight + 1) * (columns + 1)].classList.contains(player[turn])) ) {
stepsRight += 1
}
if (stepsLeft + stepsRight === 3) {
return true
}
return false
}

function getColumnTopIndex(divindex) {
return divindex % columns
}

initializeBoard()

In addition to removing the click event listeners to null in case of a win, we also define a function isTie() that counts the total number disks in all columns using the Array.reduce() method. If the sum is 42, we declare a tie.

That’s a wrap! If you want, you can download the entire source code from this repository.

--

--

Muhammad Saqib Ilyas

A computer science teacher by profession. I love teaching and learning programming. I like to write about frontend development, and coding interview preparation