It's alive! Simulate organisms with Conway's game of life on a canvas πŸ§«πŸ”‹

Today, we create living organisms! We'll kind of, at least. It's the next best thing to becoming a 21st century digital "Web Dev Doctor Frankenstein": Conway's game of life.

What?

Excellent question. The best, actually! Let me explain...

John Horton Conway was a British mathematician. He contributed to a lot of different fields in mathematics, such as number theory, algebra, geometry, combinatorial game theory, algorithmics, group theory, and analysis.

He developed a ton of remarkable algorithms, such as the Doomsday algorithm, that lets you find out the weekday of any given date with a only a few steps. I've explained the Doomsday rule in this post some time ago:

Conway developed the "Game of Life" in 1970 as an applied example of abstract computers. It's a 2-dimensional field with X and Y coordinates, where each integer coordinate represents a cell that can be either alive or dead, depending on some rules.

But, since it's a game, how is it played?

The rules of the game

You can think of the Game of Life as a sandbox. Originally, no cell is alive. Alive cells can be either set by the user or sprinkled in randomly. In each game tick, the game determines which cells are alive and which ones are dead in the next generation. This step is then repeated until the user interrupts.

To determine the next generation, the game looks at each cells neighbors and applies a set of rules:

  • If a cell was alive in the current generation:
    • If it has less than 2 (loneliness) or more than 3 (overpopulation) alive neighbors, it dies in the next generation, otherwise it stays alive
  • If a cell was dead in the current generation:
    • If it has exactly 3 alive neighbors, it will become alive in the next generation, otherwise it stays dead

(These rules allow for some pretty complex structures, but we'll come to that later!)

Let's make an example or two

Let's consider a 3 by 3 grid. We're going to see how the rules work by applying them to the center cell. All other cells are the center cell's neighbors.

Here we can see what happens if less than 2 neighboring cells are alive.

The filled cell in the middle is alive in this generation, but dies the next generation.

In the following picture, we can see how it could look like if a cell is being born:

One thing is important, though: The next generation needs to be calculated all at once. Meaning: If the game sets cell 1 as "alive" that was dead before and starts applying the rules to its immediate neighbor cell 2, it should not consider the new state of cell 1 (alive) but the old one (dead) for the calculation of cell 2.

But this begs a question: What does it do at the border of the field?

There's two possibilities: Either we consider the border as always dead (they are neighbors, but the rules are never applied to them) or the world is actually formed like a donut.

Tasty torus

When the field is shaped like a donut, it behaves like this: A square field with arrows indicating that what leaves the top enters again in the bottom and vice versa and what leaves the left side enters again on the right and vice versa.

Whatever leaves either side will reenter on the opposite side. When you connect those sides, the shape will actually look like a donut. Or in mathematics speech: A torus.

So, that's all the info we need. Let's start implementing this!

Coding out the game of life

Let's start with the field. I will create the field as a nested array of 100 by 100 boolean variables:

const field = []
for (let y = 0; y < 100; y++) {
  field[y] = []
  for (let x = 0; x < 100; x++) {
    field[y][x] = false
  }
}

By setting everything false, the code will consider all cells as dead. True, on the other hand, would mean that a cell is alive.

Next, I need a function to get any cell's neighbors. A cell is identified by its X and Y values, so I can add and subtract 1 to to those values to get all neighbors:

const getNeighbors = (x, y, field) => {
  let prevX = x - 1
  let nextX = x + 1
  let prevY = y - 1
  let nextY = y + 1

  return [
    field[prevY][prevX],
    field[prevY][x],
    field[prevY][nextX],
    field[y][prevX],
    // field[y][x], That's the cell itself - we don't need this.
    field[y][nextX],
    field[nextY][prevX],
    field[nextY][x],
    field[nextY][nextX],
  ]
}

But wait - the field is a donut. So I need to catch the border cases as well:

const getNeighbors = (x, y, field) => {
  let prevX = x - 1
  if (prevX < 0) {
    prevX = field[0].length - 1
  }

  let nextX = x + 1
  if (nextX === field[0].length) {
    nextX = 0
  }

  let prevY = y - 1
  if (prevY < 0) {
    prevY = field.length - 1
  }

  let nextY = y + 1
  if (nextY === field.length) {
    nextY = 0
  }

  // ...
}

So this function now returns an array of boolean values. The game's rules don't care about which neighbors are alive or dead, only how many of them are.

The next step is to actually implement the rules. Ideally, I've got a function that takes X and Y values as well as the field and returns the state of the cell for the next generation:

const getDeadOrAlive = (x, y, field) => {
  const neighbors = getNeighbors(x, y, field)
  const numberOfAliveNeighbors = neighbors.filter(Boolean).length

  // Cell is alive
  if (field[y][x]) {
    if (numberOfAliveNeighbors < 2 || numberOfAliveNeighbors > 3) {
      // Cell dies
      return false
    }

    // Cell stays alive
    return true
  }

  // Cell is dead
  if (numberOfAliveNeighbors === 3) {
    // Cell becomes alive
    return true
  }

  // Cell stays dead
  return false
}

And that's pretty much it for the game rules!

Now I create a function to draw the entire field on a square canvas:

const scaleFactor = 8

const drawField = field => {
  const canvas = document.querySelector('canvas')
  const context = canvas.getContext('2d')

  // Fill entire field
  context.fillStyle = '#fff'
  context.fillRect(0, 0, 100 * scaleFactor, 100 * scaleFactor);

  context.fillStyle = '#008000'

  // Fill alive cells as small rectangles
  field.forEach((row, y) => row.forEach((cell, x) => {
    if (cell) {
      context.fillRect(
        x * scaleFactor, 
        y * scaleFactor, 
        scaleFactor, 
        scaleFactor
      )
    }
  }))
}

Now let's add some control buttons to let the game automatically calculate and draw new generations each 80ms:

let nextField = field

drawField(field)

const step = () => {
  nextField = nextField.map((row, y) => row.map((_, x) => {
    return getDeadOrAlive(x, y, nextField)
  }))

  drawField(nextField)
}

let interval = null

document.querySelector('#step').addEventListener('click', step)

document.querySelector('#start').addEventListener('click', () => {
  interval = setInterval(step, 80)
})

document.querySelector('#stop').addEventListener('click', () => {
  clearInterval(interval)
})

And some more controls for defaults, random, reset, etc.:

document.querySelector('#reset').addEventListener('click', () => {
  for (let y = 0; y < 100; y++) {
    for (let x = 0; x < 100; x++) {
      field[y][x] = false
    }
  }

  nextField = field

  drawField(field)
})

document.querySelector('#glider').addEventListener('click', () => {
  for (let y = 0; y < 100; y++) {
    for (let x = 0; x < 100; x++) {
      field[y][x] = false
    }
  }

  field[20][20] = true
  field[20][21] = true
  field[20][22] = true
  field[19][22] = true
  field[18][21] = true

  nextField = field

  drawField(field)
})

document.querySelector('#random').addEventListener('click', () => {
  for (let y = 0; y < 100; y++) {
    for (let x = 0; x < 100; x++) {
      field[y][x] = Math.random() * 100 > 65
    }
  }

  nextField = field

  drawField(field)
})

document.querySelector('canvas').addEventListener('click', event => {
  const x = Math.floor(event.offsetX / scaleFactor)
  const y = Math.floor(event.offsetY / scaleFactor)

  field[y][x] = !field[y][x]

  nextField = field

  drawField(field)
})

Of course this needs some HTML, too:

<!DOCTYPE html>
<html>
  <head>
    <style>
      canvas {
        box-sizing: border-box;
        border: 1px solid #000;
        width: 800px;
        height: 800px;
      }

      .container {
        box-sizing: border-box;
        width: 800px;
        border: 1px solid #000;
        margin-top: 10px;
        padding: 10px;
      }
    </style>
  </head>
  <body>
    <h1>Conway's game of life on a canvas</h1>
    <canvas id="canvas" width="800" height="800"></canvas>

    <div class="container">
      <button id="start">Start</button>
      <button id="stop">Stop</button>
      <button id="step">Step</button>
    </div>

    <div class="container">
      <button id="reset">Reset to empty</button>
      <button id="glider">Set single glider</button>
      <button id="random">Random (35% alive)</button>
    </div>

    <script src="./index.js"></script>
  </body>
</html>

The final result

And here's a codepen where you can play around with it:

(Because of the size of the canvas and the non-responsive nature of the example, I recommend running it in 0.5 scale)

Have fun exploring!

Some remarkable structures

There's some cell structures that are worth mentioning. A rather simple one is called a "glider":

As you can see, this thing actually moves in a straight line by one unit on the X and Y axis every 5 generations.

Since it's going back to its original state again, this structure is able to move indefinitely!

But there's more: Some structures are static (for example a 2 by 2 alive square), flip between two states (one example being a straight line along either the X or Y axis consisting of 3 alive cells), others are capable of moving and even producing gliders at intervals!

You see, this really is the closest thing to creating living organisms as you can get with around 200 lines of JS and a canvas!

I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❀️ or a πŸ¦„! I write tech articles in my free time and like to drink coffee every once in a while.

If you want to support my efforts, buy me a coffee β˜• or follow me on Twitter 🐦! You can also support me directly via Paypal!

36