DEV Community

RitzaCo for Ritza

Posted on

Conway's Game of Life with Kaboom.js

Building Conway's Game of Life with Kaboom.js

Conway's Game of Life was invented back in 1970 by John Conway. He called it a Zero-Player Game, as it was played by the computer. We could call it a sim game as well, as we create the initial state of the game, and then let it evolve according to pre-defined rules.

The Game of Life is played on a grid of cells. Each cell has a state of being either alive or dead. A set of rules is applied on each generation to determine the next state of the cells. These rules are:

  1. If a cell is alive and has less than two live neighbors, it dies.
  2. If a cell is alive and has more than three live neighbors, it dies.
  3. If a cell is alive and has two or three live neighbors, it lives on to the next generation.
  4. If a cell is dead and has exactly three live neighbors, it becomes a live cell.

John Conway spent about 18 months of his coffee breaks tweaking the rules for the game, to come up with the rule set that made the most interesting patterns and properties. He didn't build it for a computer initially. He first played it using a Go board, updating the game manually.

The interesting thing about Game of Life is that, despite its simple rules, it can create amazingly complex and interesting patterns, and even "lifeforms" and machines. It's pretty cool to set some patterns, and then watch how they evolve.

In this tutorial, we'll build the Game of Life using JavaScript and Kaboom.

Getting started on Replit

Head over to Replit and create a new repl, using "Kaboom" as the template. Name it something like "Game of life", and click "Create Repl".

Create repl

After the repl has booted up, you should see a main.js file under the "Code" section. This is where we'll start coding. It already has some code in it, but we'll replace that.

Setting up Kaboom

We need to initialize Kaboom. In the "main" code file, first delete all the example code. Now we can add reference to Kaboom, and initialize it:

import kaboom from "kaboom";

// initialize context
kaboom({
  background: [0, 0, 0],
  width: 1024,
  height: 640,
  scale: 1,
  debug: true,
});
Enter fullscreen mode Exit fullscreen mode

We initialize the Kaboom drawing context with a black background ([0, 0, 0]), a width of 1024 pixels, a height of 640 pixels, and a scale factor of 1. We also set debug to true, so we can access Kaboom diagnostics and info as we are developing. You can bring up the Kaboom debug info in the simulation by pressing F1.

Designing the model

Game of Life is played on a two-dimensional grid, or matrix. Each cell has a state of being either alive or dead. Let's think about how to model this in code.

Since we only need to have two states per cell, we can use a Boolean value to represent the state of these four cells:

small row

let cell1 = false;
let cell2 = true;
let cell3 = false;
let cell4 = true;
Enter fullscreen mode Exit fullscreen mode

Declaring each cell gets tedious really fast, and it's also difficult to loop through the cells when we want to update the model.

JavaScript has a concept of an array. This is a one-dimensional construct, like a list of values. We could use an array to model each row in the grid. Each element of the array would be a Boolean value, true if the cell is alive, and false if it is dead.

So, this row could be represented in a JavaScript array as:

Grid row

let row1 = [false, true, true, true, false, true, false];
Enter fullscreen mode Exit fullscreen mode

If we wanted to represent this grid:

Grid

We could create a new array for each row, like this:

let row1 = [true, true, true, false, true];
let row2 = [true, false, false, false, true];
let row3 = [false, true, false, true, false];
let row4 = [true, false, true, false, true];
Enter fullscreen mode Exit fullscreen mode

This is OK, but it would be nicer to have all the rows in a single construct, so we can easily manipulate and query it.

One solution is to use the array construct again. An array doesn't just need to be a list of single values, it can also be a list of arrays. So we can make an array for the grid, and each of its elements would be the row arrays:

let grid = [
  [true, true, true, false, true],
  [true, false, false, false, true],
  [false, true, false, true, false],
  [true, false, true, false, true],
];
Enter fullscreen mode Exit fullscreen mode

Now this makes it easier to query the grid and manipulate it. For example, if we wanted to find out the value of the cell at row 2, column 3, we could do this:

let value = grid[1][2];
Enter fullscreen mode Exit fullscreen mode

We use [1] and [2] instead of [2] and [3] because arrays are zero-indexed. This means the first row is at index 0, and the first column is at index 0, so the first cell (1) is actually referenced as grid[0][0]

We can use the same notation when setting a cell value:

grid[1][2] = true;
Enter fullscreen mode Exit fullscreen mode

Implementing the model

Now that we've figured out how to model the grid, we can implement some functions to create and manipulate the grid.

First, let's create a function to create a new grid. We'll call the grid a matrix in the code, as this is the mathematical term for it. Therefore our function is called createMatrix. It uses a global constant MATRIX_SIZE to determine the number of rows and columns of the matrix, and returns an array of arrays of the specified size, with all cells set to dead, or false. Add the code below to the main.js file:

const MATRIX_SIZE = 64;

function createMatrix() {
  const matrix = new Array(MATRIX_SIZE);

  for (var i = 0; i < matrix.length; i++) {
    matrix[i] = new Array(MATRIX_SIZE).fill(false);
  }
  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Note that we use the array constructor by calling new Array(MATRIX_SIZE) to create each array. The first call to the constructor creates the "outer" array, and then we use a for loop to repeatedly create the "inner", or row, arrays. To set the value of each cell in the row, we use the fill method on the row arrays. This method takes a single value, and sets all the values in the array to that value. We set all the values to false, or dead, to start.

In the Game of Life rules, there are multiple references to "neighbor" cells. Neighbors are any cells that touch a particular cell. For example, for the blue cell below, all the red cells are neighbors.

neighbors

In particular, the rules refer to the number of "live" neighbors a cell has. A handy function to have would be one that finds all the neighbors of a particular cell, and counts how many of them are alive.

Notice how each neighbor cell is one row or column away from the cell we are looking at. So if we create a function that looks at each cell one position away from the target cell and counts how many of those cells are alive, we can use this to find the number of neighbors a cell has.

Recall that we can reference any cell in our matrix structure using the notation matrix[row_number][column_number]. So, noting that every neighbor is one position away, we can add or subtract one from the row and column numbers to find the neighbors. A few examples:

  • The immediate left neighbor would be: matrix[row_number][column_number - 1]
  • The immediate right neighbor would be: matrix[row_number][column_number + 1]
  • The immediate top neighbor would be: matrix[row_number - 1][column_number]
  • The immediate bottom neighbor would be: matrix[row_number + 1][column_number]
  • The immediate top left neighbor would be: matrix[row_number - 1][column_number - 1]

So, if we have the target cell coordinates, x and y, we can use the following code to find the number of neighbors:

function neighbors(matrix, x, y) {
  let count = 0;
  for (var i = -1; i <= 1; i++) {
    for (var j = -1; j <= 1; j++) {
      if (i === 0 && j === 0) {
        // this is the cell itself, do nothing
        continue;
      }
      let currentX = x + i;
      let currentY = y + j;
      if (
        currentX < 0 ||
        currentX >= MATRIX_SIZE ||
        currentY < 0 ||
        currentY >= MATRIX_SIZE
      ) {
        // this is an edge cell, do nothing
        continue;
      } else if (matrix[currentX][currentY] === true) {
        // the neighbor is alive, count it
        count++;
      }
    }
  }
  return count;
}
Enter fullscreen mode Exit fullscreen mode

Notice that we use for loops to sweep from -1 to 1, which represents left to right and up to down. We use a conditional to check if the current cell is the target cell. We know it's the target cell if both of the sweep values are 0. If it is the target cell, we don't count it as a neighbor. We also check if the current cell is outside the grid (<0 || >=sMATRIX_SIZE), in which case we don't count it as a neighbor. Finally, if we have a valid neighbor cell, we check if it is alive, by testing if its value is true. If it is, we increment the count of "living" neighbors.

Implementing the rules

Now that we have a representation, and a way to query the model for the number of neighbors a cell has, we can implement the rules of the Game of Life.

Recall that the rules of the game of life are:

  1. If a cell is alive and has less than two live neighbors, it dies.
  2. If a cell is alive and has more than three live neighbors, it dies.
  3. If a cell is alive and has two or three live neighbors, it lives on to the next generation.
  4. If a cell is dead and has exactly three live neighbors, it becomes a live cell.

The rules are applied across all cells in the matrix with each generation. To avoid having a partially updated matrix, with cells in the next generation that are not yet updated, we can create a new, blank matrix. Then we can iterate over each cell in the current generation's matrix, apply the rules to each cell, and set the value of the cell in the next generation's matrix according to the result of the rules.

Let's start with some pseudo-code to find the outline of this strategy:


create nextMatrix

for each row in matrix
  for each column in row
    get alive neighbors of cell at matrix[row][column]
    if cell is alive
      if cell  has less than two live neighbors
        set cell to dead in nextMatrix[row][column]
      if cell has more than three live neighbors
        set cell to dead in nextMatrix[row][column]
      if cell has two or three live neighbors
        set cell to alive in nextMatrix[row][column]
    if cell is dead
      if cell has exactly three live neighbors
        set cell to alive in nextMatrix[row][column]

return nextMatrix
Enter fullscreen mode Exit fullscreen mode

Translating to JavaScript, and using our matrix functions, we will write the following function:

function nextGeneration(matrix) {
  const nextMatrix = createMatrix();

  for (var i = 0; i < matrix.length; i++) {
    for (var j = 0; j < matrix[i].length; j++) {
      const cellNeighbors = neighbors(matrix, i, j);

      if (matrix[i][j] === true) {
        if (cellNeighbors === 2 || cellNeighbors === 3) {
          nextMatrix[i][j] = true;
        } else {
          nextMatrix[i][j] = false;
        }
      }

      if (matrix[i][j] === false) {
        if (cellNeighbors === 3) {
          nextMatrix[i][j] = true;
        } else {
          nextMatrix[i][j] = false;
        }
      }
    }
  }
  return nextMatrix;
}
Enter fullscreen mode Exit fullscreen mode

Updating each generation

We have a model, and we have the rules. We now need a way to regularly update the model with the rules to create one generation of the simulation after the other.

Kaboom has two key events to help us with this:

These two event handlers are called on every frame of the game. The onUpdate event is called first, and the onDraw event second. Drawing to the screen can only happen in the onDraw event handler.

This allows us to create a new generation using onUpdate, and then draw this update to the screen using onDraw.

A new frame is typically created 60 times per second, normally expressed as 60 frames per second (FPS). This means that we can create a new generation every 16.67 milliseconds (1s/60fps = 16.67 milliseconds). At times, we might want to slow this down so that we can see the patterns evolving while we watch. To control the interval between each generation, we can measure the time between updates and only create a new generation if the time between updates is greater than a preset threshold.

With Kaboom, all drawing, controls, and event handlers must be contained within a scene. We only need one scene for our game. Let's create a scene called game, containing the core onUpdate function, and some of the variables the game will need:

scene("game", () => {

  let pause = true;
  let updateInterval = 0.5;
  let generation = 0;
  let timeFromLastUpdate = 0;
  let matrix = createMatrix();

  onUpdate(() => {
    if (pause) return;
    timeFromLastUpdate += dt();
    if (timeFromLastUpdate < updateInterval) return;
    timeFromLastUpdate = 0;

    generation++;
    matrix = nextGeneration(matrix);
  });

  onDraw(()=> {
    // todo: draw the world
  });

});

go("game");
Enter fullscreen mode Exit fullscreen mode

We've created a new scene called "game" here. In the code for the scene, we have a few variables which control various parameters of the simulation:

  • pause, a Boolean which indicates if the simulation should be paused, that is, not updated.
  • updateInterval, the time in seconds to wait between each generation update.
  • generation, a counter to track how many generations have been run.
  • timeFromLastUpdate, an accumulator tracking the time in seconds since the last generation was updated.
  • matrix, the model of the current generation.

Following these variables, we have a handler for the onUpdate event. Kaboom calls this handler up to 60 times per second.

First up in our handler function, we check if the game is paused. If so, we return immediately without making any changes.

Then we add the time from the last update handler call to our timeFromLastUpdate accumulator. Kaboom has a helpful function dt, which returns the time since the onUpdate method was last called. We then check this accumulated time against our set updateInterval time to see if we should update the game and create a new generation. If the accumulated time in timeFromLastUpdate is less than this updateInterval, we leave early again.

If enough time has elapsed from the last generation update and it is time to update to the next generation, we first reset the timeFromLastUpdate accumulator to 0. Then we update our generation counter, and replace the current generation matrix with next generation calculated by the nextGeneration function we created earlier.

We have put a placeholder handler for the onDraw event for now. We'll get to that in the next section.

To start the whole game off, we use the go function, which switches between scenes.

Creating the UI

We now need to create a UI to visualize and interact with the game.

Some things that would be useful are:

  • Visualizing the game
  • Setting or clearing a cell
  • Running and pausing the game
  • Setting the speed of the game
  • Resetting the game

Visualization

To visualize the game, we can use some of lower level Kaboom draw functions. These allow us to draw shapes directly to the canvas. The shapes are not rich game objects like those created through Kaboom's add function, they are merely bitmaps on the drawing canvas. For this game, we don't need the advanced capabilities of Kaboom game objects, like gravity, collision detection, moving, and so on. That would just slow down our renders.

Let's start off by adding in labels for the game state, speed, and generation number. Add the following code to the game scene:

  const pauseText = add([
    text("Paused", { size: 16, font: "sink" }),
    pos(650, 40),
    origin("left"),
    layer("ui"),
  ]);

  const speedText = add([
    text("dt: 50ms", { size: 16, font: "sink" }),
    pos(650, 60),
    origin("left"),
    layer("ui"),
  ]);

  const generationText = add([
    text("Generation: 0", { size: 16, font: "sink" }),
    pos(650, 80),
    origin("left"),
    layer("ui"),
  ]);
Enter fullscreen mode Exit fullscreen mode

We've added some default text in here - it will soon be updated to real values in code we will add to the onDraw handler:

onDraw(()=>{
    speedText.text = `dt: ${(updateInterval * 100).toFixed(0)}ms`;
    pauseText.text = pause ? "Paused" : "Running";
    generationText.text = `Generation: ${generation}`;

})
Enter fullscreen mode Exit fullscreen mode

This update to the onDraw handler sets the text of the text labels to the variable values at that frame. Notice we use the JavaScript template literal for strings. This enables us to insert calculation and code directly into the strings using the ${} placeholder notation.

Now let's draw each of the cells in the matrix. We'll create a helper function for this. Add the following code to the game scene:

const CELL_SIZE = 10;

function drawCell(row, col) {
  drawRect({
    width: CELL_SIZE,
    height: CELL_SIZE,
    pos: vec2(row * CELL_SIZE, col * CELL_SIZE),
    color: rgb(100, 149, 237),
    fill: true,
  });
}
Enter fullscreen mode Exit fullscreen mode

This function draws the cell at the given row and col of our matrix model. The function drawRect is a Kaboom function that draws a rectangle to the canvas. We use the constant CELL_SIZE to determine the width and height of each cell in pixels. The position on the canvas is set by multiplying the row and height by the cell size, using the vec2 structure. A vec2 is Kaboom's two-dimensional vector. The color is set to a classic bluish color (Google "cornflower blue"). We use the fill property to instruct Kaboom to fill the whole rectangle with the color.

We need one other helper function to draw the grid over the cells, so we can more easily see individual cells. Add the following code to the game scene:

function drawGridLines() {
  for (var i = 0; i <= MATRIX_SIZE; i++) {
    drawLine({
      p1: vec2(i * CELL_SIZE, 0),
      p2: vec2(i * CELL_SIZE, MATRIX_SIZE * CELL_SIZE),
      width: 1,
      color: rgb(218, 165, 32),
    });

    drawLine({
      p1: vec2(0, i * CELL_SIZE),
      p2: vec2(MATRIX_SIZE * CELL_SIZE, i * CELL_SIZE),
      width: 1,
      color: rgb(218, 165, 32),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

This uses the Kaboom drawLine function to draw the grid lines. We set up a loop to draw MATRIX_SIZE number of lines vertically and horizontally. The first drawLine call draws the vertical lines, and the second the horizontal lines. The start and end points for each line,p1 and p2, are expressed as two-dimensional vectors, vec2. The width property sets the width of the line, and the color property sets the color of the line.

Now we can place and draw a cell on the screen. Let's go back to the onDraw handler to loop through the matrix and call out to the drawCell and drawGridLines functions. Update the onDraw handler like this:

  onDraw(() => {
    speedText.text = `dt: ${(updateInterval * 100).toFixed(0)}ms`;
    pauseText.text = pause ? "Paused" : "Running";
    generationText.text = `Generation: ${generation}`;

    // run through the matrix and draw the cells that are alive
    for (var x = 0; x < MATRIX_SIZE; x++) {
      for (var y = 0; y < MATRIX_SIZE; y++) {
        if (matrix[x][y] === true) {
          drawCell(x, y);
        }
      }
    }
    drawGridLines();
  });
Enter fullscreen mode Exit fullscreen mode

Here we add looping through all rows and columns to get each cell. If the cell value is true (the cell is alive), we draw it to the canvas. Then, once we are done with the cell, we draw the grid lines to help us see each individual cell.

Setting or clearing a cell

We can draw the game, but we need some way to set the starting patterns. We can use the mouse to click on cells to set them as alive or dead. Kaboom has the function onMousePress that lets us attach a handler whenever the mouse buttons are clicked. We can also filter depending on if the left or right button is clicked. Add the following code to the game scene:

  onMousePress("left", (pos) => {
    const row = Math.floor(pos.x / CELL_SIZE);
    const col = Math.floor(pos.y / CELL_SIZE);
    if (row < 0 || col < 0 || row >= MATRIX_SIZE || col >= MATRIX_SIZE) return; 
    matrix[row][col] = true;
  });

  onMousePress("right", (pos) => {
    const row = Math.floor(pos.x / CELL_SIZE);
    const col = Math.floor(pos.y / CELL_SIZE);
    if (row < 0 || col < 0 || row >= MATRIX_SIZE || col >= MATRIX_SIZE) return; 
    matrix[row][col] = false;
  });
Enter fullscreen mode Exit fullscreen mode

Our onMousePress function takes the mouse button to filter by as a first parameter. The second parameter is the event handler function. In the event handler, we convert the screen pos from pixels to rows and columns in our matrix by dividing the screen pixel position by the CELL_SIZE in pixels.

We do a check to make sure the row and column is not outside the bounds of the matrix, if the player clicked outside of the grid for example.

Then, for the left click handler, we update the state of the clicked cell to true, or alive. For the right click handler, we update the state of the clicked cell to false, or dead.

Great, now we can set cells!

Running and pausing the game

Now we can model the game, see it, and set states. Let's add a control to start and pause the simulation.

We can use Kaboom's onKeyPress function to attach a handler whenever a key is pressed. Add the following code to the game scene:

  onKeyPress("space", () => {
    pause = !pause;
  });
Enter fullscreen mode Exit fullscreen mode

This fires whenever the spacebar is pressed. It toggles the pause variable using the Boolean NOT ! operator to the opposite of its current value. Recall the pause flag is used in the onUpdate handler we added earlier.

Setting the speed of the game

We might want to speed up or slow down the simulation. We'll use the up and down arrow keys to change the updateInterval value that is checked in the onUpdate handler to determine if it is time to create the next generation. Add the following code to the game scene:

 onKeyDown("down", () => {
    updateInterval += 0.01;
  });

  onKeyDown("up", () => {
    updateInterval -= 0.01;
    updateInterval = Math.max(0.0, updateInterval);
  });
Enter fullscreen mode Exit fullscreen mode

Here we either add or subtract 0.01 seconds to the interval. Note that in the up key handler, which makes the interval between updates shorter, therefore increasing the speed of the simulation, we make sure that our interval cannot go negative. A negative time interval would make no sense, unless we accidentally invent time travel.

Resetting the game

The last control we need to add in is one to completely reset the simulation, clearing out all cells if we want to start fresh. We'll listen for the "r" key being pressed. If the "r" is pressed, we'll create a new blank matrix, and reset the generation counter. Add the following to the game scene:

  onKeyPress("r", () => {
    matrix = createMatrix();
    updateInterval = 0.5;
    generation = 0;
  });
Enter fullscreen mode Exit fullscreen mode

Running the game

Now that we've finished building the game, let's give it a go!

We'll start off with some basic patterns that oscillate between two or more states. Using the left mouse button, click on cells to fill them with the following starting patterns.

oscillating starting patterns

After you enter them, press the space bar to start the simulation. You should see something like this:

Oscillators

Try using the up and down arrow keys to speed up or slow down the simulation.

Now let's try some patterns that move and are a bit more lifelike. This one is called a glider:

Glider starting pattern

Create it somewhere near the top left of your grid (you can press space to stop the previous simulation, and r to reset the game).

After entering the glider pattern, press space to start the simulation. You should see it move across the screen like this:

![Glider moving](https://replit-docs-images.bardia.repl.co/images/tutorials/45-game-of-life/glider.gif"

Pretty cool! Let's try some spaceships now:

Space ship starting pattern

This should start flying across the screen:

Space ship flying

Here's a more random one. It's called "die hard", and goes through 130 generations with random patterns before dying out. Create it near the center of the grid, as it needs a bit of space:

Die hard starting pattern

It looks a bit like a wild fireworks show when it runs:

die hard show

There are also patterns that can create other patterns. These type of patterns are known as guns. Here is Gosper's glider gun, the first that was discovered. It creates gliders. Try this pattern out:

glider gun stating pattern

When you run it, you should see it emit gliders! Guns are some of the coolest patterns you can create.

glider gun

Next steps

There are many, many patterns that have been discovered for Conway's Game of Life, and many more still being discovered today. Perhaps you could discover some! Take at look the Wikipedia article for more info on Conway's Game of Life and some patterns. The Game of Life even has its own wiki.

Also try Google searching for "Conway Game of Life patterns". There is a myriad of sites out there listing patterns to try.

An interesting interview with John Conway was done a few years back. Sadly, John Conway died in 2020, but his game will last forever.

You can find the code for this tutorial here:
https://replit.com/@ritza/Game-of-life

Top comments (0)