Game of Life
Whats the Goal?
Design a Game of life program
It allows you to set a starting state and then run the board through n iterations and print the final state.
The ‘game’ itself runs on an infinite 2-D board with each cell being either on on or off. Each iteration a Cell can change from on to off or stay in its state.
- 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.
Now the Rule have been established, lets look into building the parts
# Define the Cells
defmodule Cell do
defstruct x: 0, y: 0, on_char: "⬛️", off_char: "⬜️", is_active: false
end
# Test Cells struct
1..3
|> Enum.map(fn x -> %Cell{x: x, y: 44} end)
|> IO.inspect()
|> Enum.map(fn cell -> cell.on_char end)
Setup the board
The cells live on a 2-D board so we have a few options for storing the cells. We can only store the live cells for that generation, but that raises issues when trying to implement the 2nd rule Any dead cell with three live neighbours becomes a live cell.
I am going to start down this path and see where I get to
# Defining the board
defmodule Board do
defstruct cells: []
def count_cells(board) do
Enum.count(board.cells)
end
end
# Test Board
cell = %Cell{}
board = %Board{cells: [cell]}
Board.count_cells(board)
So we have the structures in place - I am going to redefine the module to start adding some functions for counting cell neighbours.
I am doing something a bit odd, within the cell module I am adding a function to return an array of all the neightbours. We could do this mathematically, by adding a function that checks if against the {x, y} values, but this allows us to break up that logic and define the grid once.
See diagram.
# Define the Cells
defmodule Cell do
defstruct x: 0, y: 0, on_char: "⬛️", off_char: "⬜️", is_active: false
def neighbour_tuples(cell) do
[
{cell.x - 1, cell.y - 1},
{cell.x, cell.y - 1},
{cell.x + 1, cell.y - 1},
{cell.x - 1, cell.y},
{cell.x + 1, cell.y},
{cell.x - 1, cell.y + 1},
{cell.x, cell.y + 1},
{cell.x + 1, cell.y + 1}
]
end
end
defmodule Board do
defstruct cells: []
def count_neighbours(board, cell) do
board.cells
|> Enum.filter(fn c -> is_neighbour(cell, c) end)
|> Enum.count()
end
def is_neighbour(cell, c) do
Enum.any?(Cell.neighbour_tuples(cell), fn c_tuple -> c_tuple == {c.x, c.y} end)
end
end
cell = %Cell{x: -1, y: -1}
board = %Board{cells: [%Cell{x: 0, y: 0}, cell]}
Board.count_neighbours(board, cell)
We have the basics in place, a board, cell and ability to calculate the number of neighbours. Now time to implement the rules.
One of the stumbling blocks is how to setup and initial state and if the board structure should only have alive cells or all cells with a boolean on / off
I have decided that every cell should be a cell struct, so added a filter on the count neighbours to take it into account.
defmodule Board do
defstruct cells: []
def setup(x, y, active) do
cells =
for i <- 1..x,
j <- 1..y,
do: %Cell{x: i, y: j, is_active: Enum.any?(active, fn t -> t == {i, j} end)}
%Board{cells: cells}
end
def count_neighbours(board, cell) do
board.cells
|> Enum.filter(fn c -> c.is_active end)
|> Enum.filter(fn c -> is_neighbour(cell, c) end)
|> Enum.count()
end
def is_neighbour(cell, c) do
Enum.any?(Cell.neighbour_tuples(cell), fn c_tuple -> c_tuple == {c.x, c.y} end)
end
end
Board.setup(5, 5, [{1, 1}])
We now have a way of setting up the board, now we need to have a way of advancing the board, one step, this means going through each cell, counting the neighbours and changing the state
defmodule Board do
defstruct cells: [], x: 0, y: 0
def setup(x, y, active) do
cells =
for i <- 1..x,
j <- 1..y,
do: %Cell{x: i, y: j, is_active: Enum.any?(active, fn t -> t == {i, j} end)}
%Board{cells: cells, x: x, y: y}
end
def advance(board) do
cells = board.cells |> Enum.map(fn cell -> update_cell(board, cell) end)
Map.replace(board, :cells, cells)
end
def update_cell(board, cell) do
case count_neighbours(board, cell) do
3 -> Map.replace(cell, :is_active, true)
2 -> Map.replace(cell, :is_active, cell.is_active)
_ -> Map.replace(cell, :is_active, false)
end
end
def count_neighbours(board, cell) do
board.cells
|> Enum.filter(fn c -> c.is_active end)
|> Enum.filter(fn c -> is_neighbour(cell, c) end)
|> Enum.count()
end
def is_neighbour(cell, c) do
Enum.any?(Cell.neighbour_tuples(cell), fn c_tuple -> c_tuple == {c.x, c.y} end)
end
def draw(board) do
board.cells
|> Enum.map(fn cell ->
if cell.is_active do
cell.on_char
else
cell.off_char
end
end)
|> Enum.chunk_every(board.x)
end
end
The below example is what is known as a “Blinker” see GameofLife
board =
Board.setup(5, 5, [{3, 2}, {3, 3}, {3, 4}])
|> Board.advance()
|> Board.advance()
|> Board.draw()
Below is an example of a Beacon
Board.setup(6, 6, [{2, 4}, {2, 5}, {3, 4}, {3, 5}, {4, 2}, {4, 3}, {5, 2}, {5, 3}])
|> Board.advance()
|> Board.draw()
Running the game - I want to be able to run the game from an initial state and specify how many iterations, I also want to print the board on each update.
I use a recursive function that takes the number of iterations, runs the game state 1 generation, prints the results then decreases the iteration count. Using pattern matching in the fuction signiture, I exit the recursion when the iteration gets to 0.
defmodule RunGOL do
def run(board, 0) do
board
end
def run(board, iterations) do
new_board = Board.advance(board)
new_board
|> Board.draw()
|> IO.inspect()
run(new_board, iterations - 1)
end
end
Board.setup(5, 5, [{3, 2}, {3, 3}, {3, 4}])
|> RunGOL.run(3)
:ok
Board.setup(6, 6, [{2, 4}, {2, 5}, {3, 4}, {3, 5}, {4, 2}, {4, 3}, {5, 2}, {5, 3}])
|> RunGOL.run(10)
:ok