36
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.
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?
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 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.
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!
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>
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!
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