DEV Community

Vladimir Schneider
Vladimir Schneider

Posted on

Writing Conway's Game of Life on JavaScript

Hi there 👋🏼,

In this post I'm going to share with you how I created the Game of Life using JavaScript (again).

Two years ago I wrote my initial version of Game of Life. However, in this new iteration, I aim to introduce improvements based on the knowledge and experience I have gained since then.

To give you a glimpse into my previous work, you can check out my earlier article on Conway's Game of Life.

In addition, I will guide you through the process of recreating this fascinating game and explain how I approached it.

I hope you'll find this article helpful and inspiring. Let's embark on an exciting journey into the captivating realm of the Game of Life!

Conway's Game of Life

Image description

The Game of Life devised British mathematician John Horton Conways in 1970. It is a zero-player game, meaning that its evolution is determined by its initial state, requiring no further input. One interacts with the Game of Life by creating an initial configuration and observing how it evolves

read more on wiki

The game follows four rules that govern its progression. Let's delve into the rules:

  1. Any live cell with fewer than two live neighbours dies, as if by underpopulation.
  2. Any live cell with two or three live neighbours lives on to the next generation.
  3. Any live cell with more than three live neighbours dies, as if by overpopulation.
  4. Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.

These rules dictate the evolution of the game and determine the fate of each cell in each generation.

For this challenge I'm going to use a canvas for drawing board and cells. To process, please ensure that you have a canvas element on your web page.



<canvas id="game-board"></canvas>


Enter fullscreen mode Exit fullscreen mode

If you nothing know about Canvas API for continue reading this article I recommend to study this on MDN. In short:

The Canvas API provides a means for drawing graphics via JavaScript and the HTML <canvas> element

read more on MDN

I decided to use ES6 modules and vanilla JavaScript for this game project. I made a conscious choice to utilize ES6 classes instead of functions this time around.

However, feel free to explore ES6 modules or any existing boilerplate that suits your needs. You could even opt TypeScript for writing the project to gain a different experience or prefer functions over classes.

To use export and import keywords in your JavaScript files as ES6 modules you need to include them in your HTML document just like regular JavaScript files. However, there is one key difference. You should use the type attribute with a value module in the HTML attribute.



<canvas id="game-board"></canvas>

<!-- do this for each module -->
<script src="./main.js" type="module"></script>


Enter fullscreen mode Exit fullscreen mode

As in the previous article, I will begin by writing the code with the definitions of constants. These constants will be used to specify the size of the cell, the width and height of the game board, as well as the colors for the cell and the board.



export const CELL_SIZE = 10

export const BOARD_WIDTH = document.documentElement.offsetWidth
export const BOARD_HEIGHT = document.documentElement.offsetHeight

export const CELL_COLOR = "rgb(0, 255, 0)"
export const BOARD_COLOR = "rgb(0, 0, 0)"


Enter fullscreen mode Exit fullscreen mode

By utilizing constants I can easily configure my game, as you already aware.

In the previous article, I followed a different approach compared to this one. I described the methods of one class and then proceeded to another class to do the same. However, in this article, I will be guiding you through the process of describing and evolving the game together.

The first step is to obtain the canvas element. In the main.js you can retrieve the canvas.



const canvas = document.getElementById("game-board")


Enter fullscreen mode Exit fullscreen mode

Let's consider the game processes. Based on the game rules you need to have an initial state of the game. To achieve this, you will require a game board and cells. In the main.js file you can initialize and launch the game by following these steps: draw background of the game board, initialize cells and finally launch the game.

How could you guess I was referring to three classes and their methods. Let's unveil the details! The following classes are essential for our game:

  • Game class. This class handles the drawing and management of cells and the game board.
  • Board class. With this class, we can receive game parameters and draw the game board
  • Cell class. This class is responsible for drawing current and next generation of cells.

To start the game, all we need is an instance of the Game class, and we can initialize it whenever we're ready.

Since we already have the canvas element, let's take care about of the rest.



import { Game } from "./modules/game.mjs"

const canvas = document.getElementById("game-board")

const game = new Game(canvas)

game.initialize()


Enter fullscreen mode Exit fullscreen mode

This sets the stage for the most challenging part of our adventure. I hope you continue this journey with me to the end.

Intro

For a wile I pondered where to begin I've settled on describing the Board class first. Not only because this class is simpler, but also because it forms the fundamental layer of our game.

class Board

I use private properties for constants within classes that are exclusively used within those classes. You will see this in each of them.

In brief about Bord class:
– it features a method for drawing the game board
– it includes getters for board size and canvas context

We've already defined constants for cells size and board color, utilizing private properties. In the drawBackground method we simply draw the background using the width and height of the context along with the board color from the private property.

For the size getter we return the number of cells at the x and y coordinates and the cell size. This is achieved by dividing the board sizes by cell size from the constant.

Next, for the context getter we should just return canvas context, nothing more.

Now, let's lay our cards on the table!



import {
    CELL_SIZE,
    BOARD_COLOR
} from './constants.mjs'

export class Board {
    #cellSize = CELL_SIZE
    #backgroundColor = BOARD_COLOR

    constructor(canvas) {
        this.canvas = canvas
        this.ctx = this.canvas.getContext("2d")
    }

    drawBackground() {
        const { width, height } = this.canvas

        this.ctx.fillStyle = this.#backgroundColor
        this.ctx.fillRect(0, 0, width, height)
    }

    get size() {
        const { width, height } = this.canvas

        return {
            cellNumberX: Math.ceil(width / this.#cellSize),
            cellNumberY: Math.ceil(height / this.#cellSize),
            cellSize: this.#cellSize,
        }
    }

    get context() {
        return this.ctx
    }
}


Enter fullscreen mode Exit fullscreen mode

class Cell

The Cell class can only draws the cell but also determines whether the cell is alive or death. In fact, this class encapsulates all rules of the game in only one little method!

In brief about Cell class:
– it features a method to decide whether the cell is alive or death
– it features a method to draw the cell depending of its state
– it comprises a private position getter and a public alive getter
– it includes setters to set the alive and neighbor count

The key details is that the x and y coordinates stored in the Game class. The Cell class receives the context and cell size from the Board class instance getter.

Every game iteration we launch nextGeneration method to determine whether the cell alive or dead and the drawCells method to draw the cell.

The position getter just holds an array to destructure all these values for the fillRect context method used in cell drawing.

The setter and getter for the alive simply set and get the private property, just like the neighbors setter.



export class Cell {
    #alive = true
    #neighbors = 0

    constructor(ctx, x, y, cellSize) {
        this.ctx = ctx

        this.x = x
        this.y = y
        this.cellSize = cellSize
    }

    nextGeneration() {
        if (!this.#alive && this.#neighbors === 3) {
            this.#alive = true
        } else {
            this.#alive = this.#alive && (this.#neighbors === 2 || this.#neighbors === 3)
        }
    }

    draw() {
        if (this.#alive) {
            this.ctx.fillStyle = CELL_COLOR
            this.ctx.fillRect(...this.#position)
        }
    }

    get #position() {
        return [
            this.x * this.cellSize,
            this.y * this.cellSize,
            this.cellSize,
            this.cellSize
        ]
    }

    set alive(alive) {
        this.#alive = alive
    }

    get alive() {
        return this.#alive
    }

    set neighbors(neighbors) {
        this.#neighbors = neighbors
    }
}


Enter fullscreen mode Exit fullscreen mode

class Game

So, now, let's bring it all together.

The first step I suggest is to combine all our classes and classes and their methods without writing introducing new logic.

What do we have on our hands?

We already know that knowledge about the cells is stores in the Game class; let's use a private property for it. In fact, it will be the only one private property in this class.

First, initialize everything. We should obtain a board instance to draw it and use the board's getters size and context. We will be use the class constructor for this, also setting the canvas size.



export class Game {
    #cells = []

    constructor(canvas) {
        this.canvas = canvas
        this.board = new Board(this.canvas)

        this.canvas.width = BOARD_WIDTH
        this.canvas.height = BOARD_HEIGHT
    }
}


Enter fullscreen mode Exit fullscreen mode

As a quick remember, the Game class should have the initialize method. Just do it and let's move on.



export class Game {
    // ...

    initialize = () => {}
}


Enter fullscreen mode Exit fullscreen mode

At the point, we've successfully implemented numerous methods for drawing and managing our cells. However, a crucial piece missing – we don't have cells yet! Let's address this gap!

The initializeCells method will iterate over every cell, push it to our #cells, set the cell's alive status randomly and then draw it!

In the board we already have a size getter to obtain x and y dimensions of this board. We will use it for iteration.



export class Game {
    // ...

    initializeCells = () => {
        for (let i = 0; i < this.board.size.cellNumberX; i++) {
            this.#cells[i] = []

            for (let j = 0; j < this.board.size.cellNumberY; j++) {
                this.#cells[i][j] = new Cell(this.board.context, i, j, this.board.size.cellSize)
                this.#cells[i][j].alive = Math.random() > 0.8
                this.#cells[i][j].draw()
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Now that we have the cells and can to operate with them, in the updateCells method, we will iterate over each cell twice. The first iteration is to calculate and set all neighbors of the cell, and the second iteration to draw new state.

Each cell already has a setter neighbors for the first iteration and the methods we need for the second iteration: nextGeneration and draw too.

Calculation how many neighbors a cell has may seem easy, but it's a signification piece of logic, so I've moved it to another method called updateCellNeighbors.



export class Game {
    // ...

    updateCells = () => {
        for (let i = 0; i < this.board.size.cellNumberX; i++) {
            for (let j = 0; j < this.board.size.cellNumberY; j++) {
                this.updateCellNeighbors(i, j);
            }
        }

        for (let i = 0; i < this.board.size.cellNumberX; i++) {
            for (let j = 0; j < this.board.size.cellNumberY; j++) {
                this.#cells[i][j].nextGeneration()
                this.#cells[i][j].draw()
            }
        }
    }

    updateCellNeighbors = (x, y) => {
}


Enter fullscreen mode Exit fullscreen mode

Up until now, we've defined methods, setters and getters but nothing was has been drawn yet. We don't have much left to complete our game. Now I want to describe last piece of our puzzle to be ready to write final method updateCellNeighbors.

The this.board.drawBackground and updateCells are called in another method called launch and calling it again using requestAnimationFrame.



export class Game {
    // ...

    initialize = () => {
        this.initializeCells()
        this.launch()
    }

    // ...

    launch = () => {
        this.board.drawBackground()

        this.updateCells()

        requestAnimationFrame(this.launch)
    }

    // ...
}


Enter fullscreen mode Exit fullscreen mode

updateCellNeighbors

Let's take a moment to summarize and outline what else needs to be written. The last thing we need to be write is updateCellNeighbors.

First, we get a map of all neighbors of the cell be x and y.



export class Game {
    // ...

    updateCellNeighbors = (x, y) => {
        const neighborCoords = [
            [x, y + 1],
            [x, y - 1],
            [x + 1, y],
            [x - 1, y],
            [x + 1, y + 1],
            [x - 1, y - 1],
            [x + 1, y - 1],
            [x - 1, y + 1]
        ]
    }
}


Enter fullscreen mode Exit fullscreen mode

Some coordinates might be out of the game board bounds. We can easily check this by verifying if the x coordinate less than 0 or if the x coordinate is more than this.board.size.cellNumberX. For the y is the same.



export class Game {
    // ...

    updateCellNeighbors = (x, y) => {
        // ...

        for (const coords of neighborCoords) {
            let [xCord, yCord] = coords;

            const xOutOfBounds = xCord < 0 || xCord >= this.board.size.cellNumberX
            const yOutOfBounds = yCord < 0 || yCord >= this.board.size.cellNumberY
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

The last thing we need to do to finish is to choose the neighbor cell, check if the cell is alive or dead, and count it. After this, set neighbors using cell's neighbors setter.



export class Game {
    // ...

    updateCellNeighbors = (x, y) => {
        let aliveNeighborsCount = 0

        // ...

        for (const coords of neighborCoords) {
            // ...

            const wrappedX = xOutOfBounds ? (xCord + this.board.size.cellNumberX) % this.board.size.cellNumberX : xCord
            const wrappedY = yOutOfBounds ? (yCord + this.board.size.cellNumberY) % this.board.size.cellNumberY : yCord

            if (this.#cells[wrappedX]?.[wrappedY]?.alive) {
                aliveNeighborsCount++
            }
        }

        this.#cells[x][y].neighbors = aliveNeighborsCount
    }
}


Enter fullscreen mode Exit fullscreen mode

Congratulations! It's the end of our journey. You can run the game, make changes, improvements – just feel free with it.

Image description

Repo

Demo #1

Demo #2

Thank you 💛 dear reader

Top comments (0)