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
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
The game follows four rules that govern its progression. Let's delve into the rules:
- Any live cell with fewer than two live neighbours dies, as if by underpopulation.
- Any live cell with two or three live neighbours lives on to the next generation.
- Any live cell with more than three live neighbours dies, as if by overpopulation.
- 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>
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
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>
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)"
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")
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()
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
}
}
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
}
}
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
}
}
As a quick remember, the Game
class should have the initialize
method. Just do it and let's move on.
export class Game {
// ...
initialize = () => {}
}
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()
}
}
}
}
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) => {
}
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)
}
// ...
}
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]
]
}
}
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
}
}
}
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
}
}
Congratulations! It's the end of our journey. You can run the game, make changes, improvements β just feel free with it.
Thank you π dear reader
Top comments (0)