Conway's Game of Life
The Game of Life is a cellular automaton devised by the British mathematician John Horton Conway in 1970. It is a zero-player game, meaning that its evolution is determined by its initial state, requiring no further input. One only interacts with the Game of Life by creating an initial configuration.
The universe of the Game of Life is an infinite, two-dimensional orthogonal grid of square cells, each of which is in one of two possible states: live or dead. Every cell interacts with its eight neighbours. The game evolves. At each time step, the following transitions occur:
- Any live cell with exactly two or three live neighbours survives.
- Any dead cell with exactly three live neighbours becomes a live cell.
- All other live cells die in the next generation. All other dead cells stay dead.
The first generation must be initialized. Every new generation is created by applying the above rules simultaneously to every cell in the previous generations; births and deaths occur simultaneously. The moment when this happens is called a tick. Since every generation depends only on the previous one, this process is a Markov chain.
The following few exercises will implement the Game of Life. We will consider finite universe with periodic boundary conditions.
Write a function neighbours
that return the number of live neighbours of a cell. The function should accept the world
matrix of boolean values representing the state of all cells (true
if the cell is alive and false
otherwise) and index of the row and column of the cell.
Hint: use the following properties of the mod1
function to implement periodic boundaries.
julia> mod1(1, 4)
1
julia> mod1(4, 4)
4
julia> mod1(5, 4)
1
Bonus: implement a more general function which computes the number of alive cells in a neighbourhood of given size.
Solution:
One way to define the neighbours
function is to check all neighbours manually.
function neighbours(world, row, col)
n, m = size(world)
# this implements periodic boundaries
down = mod1(row + 1, n)
up = mod1(row - 1, n)
left = mod1(col - 1, m)
right = mod1(col + 1, m)
return ( world[up, left] + world[up, col] + world[up, right]
+ world[row, left] + + world[row, right]
+ world[down, left] + world[down, col] + world[down, right])
end
The approach above can not define a general version of the neighbours
function. In this case, we can use nested loops. First, we compute proper row indices by range
combined with the mod1
function.
rows = mod1.(row .+ (-r:r), size(world, 1))
Column indexes can be computed similarly. Then we use nested loops to iterate through both rows and columns. Since the iteration includes the middle cell, we need to subtract its state.
function neighbours(world, row, col; r = 1)
rows = mod1.(row .+ (-r:r), size(world, 1))
cols = mod1.(col .+ (-r:r), size(world, 2))
return sum(world[i, j] for i in rows, j in cols) - world[row, col]
end
Add a new method to the neighbours
function that for the world
matrix returns a matrix containing numbers of living neighbours.
Solution:
We created a function that computes the number of living neighbours in the exercise above. One way how to create a matrix with numbers of living neighbours is:
function neighbours(world)
n, m = size(world)
return [neighbours(world, row, col) for row in 1:n, col in 1:m]
end
This is an example of multiple dispatch. The function neighbours
can have both one and three input arguments.
Write a function willsurvive
that returns true
if the cell will survive based on the conditions described at the beginning of the section and false
otherwise. This function should accept two arguments: state of the cell (true
/false
) and the number of living neighbours.
Solution:
This function can be written using the if-elseif-else
statement. Since cell
is a boolean value, we do not need to compare with one as in cell == 1
.
function willsurvive(cell, k)
if k == 3
return true
elseif k == 2 && cell
return true
else
return false
end
end
We can write this function in a simpler form. We first realize that the short-circuit evaluation can merge the first two conditions. Since the function returns only true
or false
, we can write the function on one line.
willsurvive(cell, k) = k == 3 || k == 2 && cell
Combine these functions to write a function evolve!
that evolves the given world
matrix into a new generation.
Solution:
We first compute the matrix with the numbers of living neighbours. Then we iterate over all elements of the world
matrix and compute new states of all elements with the willsurvive
function. Since we computed the number of living neighbours before iterating, we can rewrite the world
matrix.
function evolve!(world)
ks = neighbours(world)
for i in eachindex(world)
world[i] = willsurvive(world[i], ks[i])
end
return
end
In the four exercises above, we defined functions sufficient to animate the Game of Life. Use the following code to initialize the world
.
world = zeros(Bool, 30, 30)
row, col = 15, 15
world[row, col] = 1
world[row, col + 1] = 1
world[row - 1, col + 6] = 1
world[row + 1, col + 1] = 1
world[row + 1, col + 5] = 1
world[row + 1, col + 6] = 1
world[row + 1, col + 7] = 1
We use the Plots package introduced in the previous lecture to create animations.
using Plots
anim = @animate for i in 1:150
heatmap(world; axis = nothing, border = :none, cbar = false, ratio = :equal)
evolve!(world)
end
gif(anim, "gameoflife.gif"; fps = 10)
Many different types of patterns occur in the Game of Life. For example, the following initialization is called pulsar.
world = zeros(Bool, 17, 17)
line = zeros(17)
line[5:7] .= 1
line[11:13] .= 1
for ind in [3,8,10,15]
world[ind, :] .= line
world[:, ind] .= line
end