Skip to main content

Game of life

Created: 2021-04-05. Modified: 2021-05-23.

Introduction#

In this article, we will implement the game of life:

From an implementation point of view, the game of life is simply a grid of square cells where the colours of the cells are controlled by a simulation system. This gives us a 2-step plan for the implementation.

  1. Draw a 2-dimensional grid of squares
  2. Implement the model that simulates the cell dynamics

Step 1 - Draw a 2-dimensional grid of squares#

We will use the following configurations for the application rules = basic_rules(), deparsers = default_2_deparsers()).

i. Load libraries#

First, we load the p5 library and import some R functions from sketch

#! config(debug = F, rules = basic_rules(), deparsers = default_2_deparsers())
#! load_library("p5")
# Import R functions implemented by sketch
seq <- R::seq
runif <- R::runif
round2 <- R::round # avoid name clash with p5::round
matrix <- R::matrix2

ii. Set up relevant variables#

Next, we set up some variables for the canvas and the grid.

  • We need the width and the height for the canvas, cw and ch.
  • For the grid, we need the grid_size, from which we can work out the number of columns and rows of the grid, ncol and nrow.
  • The grid is represented as a matrix of 0s and 1s representing the black and white colours respectively.
#! config(debug = F, rules = basic_rules(), deparsers = default_2_deparsers())
#! load_library("p5")
# Import R functions implemented by sketch.
seq <- R::seq
runif <- R::runif
round2 <- R::round # avoid name clash with p5::round
matrix <- R::matrix2
# Canvas variables
cw <- 600
ch <- 400
# Grid variables
grid_size <- 10 # each cell is 10px by 10px large
ncol <- cw / grid_size # number of columns
nrow <- ch / grid_size # number of rows
data <- round(runif(nrow * ncol)) # each entry is 0 or 1
grid <- matrix(data, nrow, ncol) # a matrix of 0s and 1s

iii. Draw the grid#

Now, we will draw a grid using nested for-loops. The xy-coordinates of the cell is given by the (i,j)-position of the cell multiplied by the grid size. Note that the grid entry is accessed using grid[i][j] rather than grid[i, j]. This is one thing to watch out for when the basic_rules() configuration is used.

#! config(rules = basic_rules(), deparsers = default_2_deparsers())
#! load_library("p5")
# Import R functions implemented by sketch.
seq <- R::seq
runif <- R::runif
round2 <- R::round # avoid name clash with p5::round
matrix <- R::matrix2
# Canvas variables
cw <- 600
ch <- 400
# Grid variables
grid_size <- 10 # each cell is 10px by 10px large
ncol <- cw / grid_size # number of columns
nrow <- ch / grid_size # number of rows
data <- round2(runif(nrow * ncol)) # each entry is 0 or 1
grid <- matrix(data, nrow, ncol) # a matrix of 0s and 1s
# Draw a grid
setup <- function() {
createCanvas(cw, ch)
}
draw <- function() {
background(0) # Black-color background
for (i in seq(0, nrow - 1)) {
for (j in seq(0, ncol - 1)) {
if (grid[i][j] == 1) {
fill(255) # White-color cell
rect(x = j * grid_size,
y = i * grid_size,
w = grid_size,
h = grid_size)
}
}
}
}

Step 2 - Implement the model that simulates the cell dynamics#

The cell evolution follows the following rules (from Wikipedia):

At each step in time, the following transitions occur:

  • 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, which compare the behavior of the automaton to real life, can be condensed into the following:

  • Any live cell with two or three live neighbours survives.
  • Any dead cell with three live neighbours becomes a live cell.
  • All other live cells die in the next generation. Similarly, all other dead cells stay dead.

To implement these rules, we need a function countNeighbours to count the neighbours of a cell and use the count to determine if the cell should be dead or alive (i.e. equal to 0 or 1).

The countNeighbours function#

countNeighbours <- function(m, n) {
total_sum <- - grid[m][n]
s <- seq(-1, 1)
for (i in s) {
for (j in s) {
wi <- (m + i + nrow) %% nrow # wrap around edges
wj <- (n + j + ncol) %% ncol # wrap around edges
total_sum <- total_sum + grid[wi][wj]
}
}
total_sum
}

Notes

  • The count (total_sum) starts with the negative of the centre entry to avoid handling an exception during the loop.
  • We allow wrapping around the edges, so the top edge is the neighbour of the bottom edge (and similarly the left / right edges are neighbours). This is implemented by taking modulus with the number of columns / rows.

Change grid entry state following the evolution rules#

#! config(debug = F, rules = basic_rules(), deparsers = default_2_deparsers())
#! load_library("p5")
# Import R functions implemented by sketch
seq <- R::seq
runif <- R::runif
round2 <- R::round # avoid name clash with p5::round
matrix <- R::matrix2
cw <- 600
ch <- 400
grid_size <- 10
ncol <- cw / grid_size
nrow <- ch / grid_size
data <- round2(runif(nrow * ncol)) # 0 or 1
grid <- matrix(data, nrow, ncol)
countNeighbours <- function(m, n) {
total_sum <- - grid[m][n]
s <- seq(-1, 1)
for (i in s) {
for (j in s) {
wi <- (m + i + nrow) %% nrow # wrap around edges
wj <- (n + j + ncol) %% ncol # wrap around edges
total_sum <- total_sum + grid[wi][wj]
}
}
total_sum
}
setup <- function() {
frameRate(15) # Use lower frame rate for better presentation
createCanvas(cw, ch)
}
draw <- function() {
background(0)
for (i in seq(0, nrow - 1)) {
for (j in seq(0, ncol - 1)) {
if (grid[i][j] == 1) {
fill(255)
rect(j * grid_size, i * grid_size, grid_size, grid_size)
}
}
}
# Create a new grid for the next generation
next_gen <- matrix(0, nrow, ncol)
for (i in seq(0, nrow - 1)) {
for (j in seq(0, ncol - 1)) {
state <- grid[i][j]
neighbors <- countNeighbours(i, j)
# Any dead cell with three live neighbours becomes a live cell.
if (state == 0 && neighbors == 3) {
next_gen[i][j] <- 1
# All live cells with < 2 or > 3 live neighbours die.
} else if (state == 1 && (neighbors < 2 || neighbors > 3)) {
next_gen[i][j] <- 0
# Any live cell with two or three live neighbours survives.
# All other dead cells stay dead.
} else {
next_gen[i][j] <- state
}
}
}
grid <<- next_gen
NULL
}

Extra - add a button to reset the grid#

Given the simulation ends in a fairly short time, it would be convenient to have a button to restart the simulation. We use the createButton function from p5 to create a button, then position it and make it regenerate the grid upon click using the position and mousePressed methods of the button.

#! config(debug = F, rules = basic_rules(), deparsers = default_2_deparsers())
#! load_library("p5")
# Import R functions implemented by sketch
seq <- R::seq
runif <- R::runif
round2 <- R::round # avoid name clash with p5::round
matrix <- R::matrix2
cw <- 600
ch <- 400
grid_size <- 10
ncol <- cw / grid_size
nrow <- ch / grid_size
data <- round2(runif(nrow * ncol)) # 0 or 1
grid <- matrix(data, nrow, ncol)
countNeighbours <- function(m, n) {
total_sum <- - grid[m][n]
s <- seq(-1, 1)
for (i in s) {
for (j in s) {
wi <- (m + i + nrow) %% nrow # wrap around edges
wj <- (n + j + ncol) %% ncol # wrap around edges
total_sum <- total_sum + grid[wi][wj]
}
}
total_sum
}
setup <- function() {
frameRate(15)
createCanvas(cw, ch)
button <- createButton('Reset')
button$position(8, 410)
button$mousePressed(function() {
grid <<- matrix(round2(runif(nrow * ncol)), nrow, ncol)
grid
})
}
draw <- function() {
background(0)
for (i in seq(0, nrow - 1)) {
for (j in seq(0, ncol - 1)) {
if (grid[i][j] == 1) {
fill(255)
rect(j * grid_size, i * grid_size, grid_size, grid_size)
}
}
}
# Create a new grid for the next generation
next_gen <- matrix(0, nrow, ncol)
for (i in seq(0, nrow - 1)) {
for (j in seq(0, ncol - 1)) {
state <- grid[i][j]
neighbors <- countNeighbours(i, j)
# Any dead cell with three live neighbours becomes a live cell.
if (state == 0 && neighbors == 3) {
next_gen[i][j] <- 1
# All live cells with < 2 or > 3 live neighbours die.
} else if (state == 1 && (neighbors < 2 || neighbors > 3)) {
next_gen[i][j] <- 0
# Any live cell with two or three live neighbours survives.
# All other dead cells stay dead.
} else {
next_gen[i][j] <- state
}
}
}
grid <<- next_gen
NULL
}

Credits / Reference#

I learned the above visualisations from Daniel Shiffman. Check out the coding train for many more fun examples with p5.js.