How to build a distributed Game of Life in Elixir

Artur Trzop
We’ve moved to freeCodeCamp.org/news
23 min readJan 17, 2019

I wrote my first game in Elixir. It is a popular thing — Conway’s Game of Life — but it gets quite interesting when you solve it in a functional language, especially when you can see how the actor model works and how actors are distributed across servers in the network.

Game of Life

In this blog post I am going to show:

  • how to write rules for the game of life with tests in Elixir,
  • parallel tasks across lightweight processes (actors) in order to utilize all CPU cores,
  • how to distribute work across nodes so the game can be executed by many servers in the cluster,
  • how to use GenServer behaviour, TaskSupervisor and Agents in Elixir.

This project and the full source code can be found here.

Demo

Let’s start with watching quick demo of how the game works.

As you can see, node1 represents running game and board on the screen. The second node was also started and connected to the first one. From the second node, we added new cells to the board. Both nodes are responsible for processing the game, but only the first node is a master with information about the current state of the game. We can connect more nodes to the cluster so game processing can happen on all of the nodes. You are going to learn in this article how to make it happen.

Game of Life rules

If you already know about the game of life problem just jump to the next header. If not, in this section you can learn the basic concept.

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, alive or dead. Every cell interacts with its eight neighbours, which are the cells that are horizontally, vertically, or diagonally adjacent. At each step in time, the following transitions occur:

  • Any live cell with fewer than two live neighbours dies as if caused by under-population.
  • 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 over-population.
  • Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.

The initial pattern constitutes the seed of the system. The first generation is created by applying the above rules simultaneously to every cell in the seed — births and deaths occur simultaneously, and the discrete moment at which this happens is sometimes called a tick (in other words, each generation is a pure function of the preceding one). The rules continue to be applied repeatedly to create further generations.

Create new application in Elixir

First things first, so we are going to create a new Elixir OTP application with supervision tree. We will use supervisor for our game server, you will learn more about it a bit later.

$ mix new --sup game_of_life

A --sup option is given to generate an OTP application skeleton including a supervision tree. Normally an app is generated without a supervisor and without the app callback.

In lib/game_of_life.ex file you will find an example of how to add child worker to supervisor.

# lib/game_of_life.ex
defmodule GameOfLife do
use Application

# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec, warn: false

children = [
# Define workers and child supervisors to be supervised
# worker(GameOfLife.Worker, [arg1, arg2, arg3]),
]

# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: GameOfLife.Supervisor]
Supervisor.start_link(children, opts)
end
end

Represent the board in Game of Life

We need to represent the alive cells on the board in our game. A single cell can be a tuple {x, y} with coordinates in the 2-dimensional board.

All alive cells on the board will be on the list alive_cells.

alive_cells = [{0, 0}, {1, 0}, {2, 0}, {1, 1}, {-1,-2}]

Here is an example of how this board with alive cells looks like:

Board with alive cells

and here are proper x & y coordinates:

Coordinates of alive cells

Now when we have the idea of how we are going to store our alive cells we can jump to write some code.

Game of Life rules with tests

We can create GameOfLife.Cell module with function keep_alive?/2 responsible for determining if a particular alive cell {x, y} should be still alive on the next generation or not.

Here is the function with expected arguments:

# lib/game_of_life/cell.ex
defmodule GameOfLife.Cell do
def keep_alive?(alive_cells, {x, y} = _alive_cell) do
# TODO
end
end

Let’s write some tests to cover the first of the requirements of the Game of Life:

Any live cell with fewer than two live neighbours dies, as if caused by under-population.

We wrote tests to ensure GameOfLife.Cell.keep_alive?/2 function returns false in a case when the alive cell has no neighbours or has just one.

# test/game_of_life/cell_test.exs
defmodule GameOfLife.CellTest do
use ExUnit.Case, async: true

test "alive cell with no neighbours dies" do
alive_cell = {1, 1}
alive_cells = [alive_cell]
refute GameOfLife.Cell.keep_alive?(alive_cells, alive_cell)
end

test "alive cell with 1 neighbour dies" do
alive_cell = {1, 1}
alive_cells = [alive_cell, {0, 0}]
refute GameOfLife.Cell.keep_alive?(alive_cells, alive_cell)
end
end

GameOfLife.Cell.keep_alive?/2 function needs to return false just to pass our tests so let’s add more tests to cover other requirements.

Any live cell with more than three live neighbours dies, as if by over-population.

# test/game_of_life/cell_test.exs
test "alive cell with more than 3 neighbours dies" do
alive_cell = {1, 1}
alive_cells = [alive_cell, {0, 0}, {1, 0}, {2, 0}, {2, 1}]
refute GameOfLife.Cell.keep_alive?(alive_cells, alive_cell)
end

Any live cell with two or three live neighbours lives on to the next generation.

# test/game_of_life/cell_test.exs
test "alive cell with 2 neighbours lives" do
alive_cell = {1, 1}
alive_cells = [alive_cell, {0, 0}, {1, 0}]
assert GameOfLife.Cell.keep_alive?(alive_cells, alive_cell)
end

test "alive cell with 3 neighbours lives" do
alive_cell = {1, 1}
alive_cells = [alive_cell, {0, 0}, {1, 0}, {2, 1}]
assert GameOfLife.Cell.keep_alive?(alive_cells, alive_cell)
end

Now, we can implement our GameOfLife.Cell.keep_alive?/2 function.

# lib/game_of_life/cell.ex
defmodule GameOfLife.Cell do
def keep_alive?(alive_cells, {x, y} = _alive_cell) do
case count_neighbours(alive_cells, x, y, 0) do
2 -> true
3 -> true
_ -> false
end
end

defp count_neighbours([head_cell | tail_cells], x, y, count) do
increment = case head_cell do
{hx, hy} when hx == x - 1 and hy == y - 1 -> 1
{hx, hy} when hx == x and hy == y - 1 -> 1
{hx, hy} when hx == x + 1 and hy == y - 1 -> 1

{hx, hy} when hx == x - 1 and hy == y -> 1
{hx, hy} when hx == x + 1 and hy == y -> 1

{hx, hy} when hx == x - 1 and hy == y + 1 -> 1
{hx, hy} when hx == x and hy == y + 1 -> 1
{hx, hy} when hx == x + 1 and hy == y + 1 -> 1

_not_neighbour -> 0
end
count_neighbours(tail_cells, x, y, count + increment)
end

defp count_neighbours([], _x, _y, count), do: count
end

As you can see, we implemented the private function count_neighbours/4 responsible for counting neighbours. It will be helpful to meet the next rule.

There is one more requirement we forgot about:

Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.

We are going to write a new function GameOfLife.Cell.become_alive?/2 expecting the coordinates of the dead cell and returning if the dead cell should become alive or not.

# lib/game_of_life/cell.ex
defmodule GameOfLife.Cell do
def become_alive?(alive_cells, {x, y} = _dead_cell) do
3 == count_neighbours(alive_cells, x, y, 0)
end
end

And here is the test for that:

# test/game_of_life/cell_test.exs
test "dead cell with three live neighbours becomes a live cell" do
alive_cells = [{2, 2}, {1, 0}, {2, 1}]
dead_cell = {1, 1}
assert GameOfLife.Cell.become_alive?(alive_cells, dead_cell)
end

test "dead cell with two live neighbours stays dead" do
alive_cells = [{2, 2}, {1, 0}]
dead_cell = {1, 1}
refute GameOfLife.Cell.become_alive?(alive_cells, dead_cell)
end

There is one more thing which might be helpful for us. We have the list of alive cells but we don’t know much about the dead cells. The number of dead cells is infinite so we need to cut down the number of dead cells for which we want to check if they should become alive. The simple way would be to check only dead cells with alive neighbours. Hence the GameOfLife.Cell.dead_neighbours/1function.

Let’s write some tests first:

# test/game_of_life/cell_test.exs
test "find dead cells (neighbours of alive cell)" do
alive_cells = [{1, 1}]
dead_neighbours = GameOfLife.Cell.dead_neighbours(alive_cells) |> Enum.sort
expected_dead_neighbours = [
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {2, 1},
{0, 2}, {1, 2}, {2, 2}
] |> Enum.sort
assert dead_neighbours == expected_dead_neighbours
end

test "find dead cells (neighbours of alive cells)" do
alive_cells = [{1, 1}, {2, 1}]
dead_neighbours = GameOfLife.Cell.dead_neighbours(alive_cells) |> Enum.sort
expected_dead_neighbours = [
{0, 0}, {1, 0}, {2, 0}, {3, 0},
{0, 1}, {3, 1},
{0, 2}, {1, 2}, {2, 2}, {3, 2}
] |> Enum.sort
assert dead_neighbours == expected_dead_neighbours
end

and here is the implemented function:

# lib/game_of_life/cell.ex
def dead_neighbours(alive_cells) do
neighbours = neighbours(alive_cells, [])
(neighbours |> Enum.uniq) -- alive_cells
end

defp neighbours([{x, y} | cells], neighbours) do
neighbours(cells, neighbours ++ [
{x - 1, y - 1}, {x , y - 1}, {x + 1, y - 1},
{x - 1, y }, {x + 1, y },
{x - 1, y + 1}, {x , y + 1}, {x + 1, y + 1}
])
end

defp neighbours([], neighbours), do: neighbours

Basically, these are all rules implemented in the single module GameOfLife.Cell. You can see the whole module file with tests on GitHub.

The architecture of distributed Game of Life

The architecture of distributed Game of Life in Elixir

Our main supervisor is GameOfLife.Supervisor which I mentioned at the beginning of the article. Below you can see how we defined its children like Task.Supervisor, workers for BoardServer and GamePrinter.

# lib/game_of_life.ex
defmodule GameOfLife do
use Application

# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec, warn: false

init_alive_cells = []

children = [
# Define workers and child supervisors to be supervised
# worker(GameOfLife.Worker, [arg1, arg2, arg3]),
supervisor(Task.Supervisor, [[name: GameOfLife.TaskSupervisor]]),
worker(GameOfLife.BoardServer, [init_alive_cells]),

# We will uncomment this line later
# worker(GameOfLife.GamePrinter, []),
]

# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: GameOfLife.Supervisor]
Supervisor.start_link(children, opts)
end
end

Let me describe what each component on the image is responsible for.

Task.Supervisor is an Elixir module defining a new supervisor which can be used to dynamically supervise tasks. We are going to use it to spin off tasks like determining if the particular cell should live or die. Those tasks can be run across nodes connected into the cluster.

In the above code, we gave the name GameOfLife.TaskSupervisor for our supervisor. We will use this name to tell the Task.Supervisor.async function which Task Supervisor should handle our task. You can read more about Task.Supervisor here.

GameOfLife.BoardServer is our module implemented as GenServer behaviour. It is responsible for holding the state of the game. By that I mean it keeps the list of alive cells on the board along with generation counter and TRef. TRef is a timer reference returned by the Erlang timer module and the apply_interval function.

We want to start the game and generate a new list of alive cells for the next generation with a specified time interval. With each new generation, we will update the generation counter. The other interesting thing is that GameOfLife.BoardServer is running only on a single node. Once another node is connected to the cluster where is already running GameOfLife.BoardServer then GameOfLife.BoardServer won’t be started just like that on the newly connected node.

Instead on the new node GameOfLife.BoardServer will keep the only reference to the PID of the process existing on the first node. We want to have the single source of truth about the state of our game in one master GameOfLife.BoardServer process existing on the first node started in the cluster.

GameOfLife.GamePrinter is a simple module using Agent in order to keep TRef (time reference) so we can print the board to STDOUT with the specified interval. We will use Erlang timer module to print the board on the screen every second.

You may wonder what’s the difference between GenServer and Agent.

A GenServer is a process like any other Elixir process and it can be used to keep state, execute code asynchronously, and so on. The advantage of using a generic server process (GenServer) is that it will have a standard set of interface functions and include functionality for tracing and error reporting. It also fits into a supervision tree as this is what we did in the GameOfLife module.

On the other hand, Agent is a much simpler solution than GenServer. Agents are a simple abstraction around state. Often in Elixir there is a need to share or store state that must be accessed from different processes or by the same process at different points in time. The Agent module provides a basic server implementation that allows state to be retrieved and updated via a simple API. This is what we are going to do in GameOfLife.GamePrinter as we only need to keep time reference to our timer interval.

Create node manager for task supervisor

Let’s start with something simple just to see if we can distribute work across nodes in the cluster. We assume each new process created by task supervisor will be assigned randomly to one of the connected nodes. Each node should be equally overloaded with the assumption that each task is pretty similar and all nodes are machines with the same configuration and overload.

# lib/game_of_life/node_manager.ex
defmodule GameOfLife.NodeManager do
def all_nodes do
[Node.self | Node.list]
end

def random_node do
all_nodes |> Enum.random
end
end

Our node manager has random_node/0 function which returns the name of a random node connected to the cluster. Basically, that’s it. A simple solution should be enough for now.

Create board helper functions

We need some helper functions for operations we can do on the board like adding and removing cells. Let’s start with tests for the module GameOfLife.Board and function add_cells/2.

# test/game_of_life/board_test.exs
defmodule GameOfLife.BoardTest do
use ExUnit.Case, async: true

test "add new cells to alive cells without duplicates" do
alive_cells = [{1, 1}, {2, 2}]
new_cells = [{0, 0}, {1, 1}]
actual_alive_cells = GameOfLife.Board.add_cells(alive_cells, new_cells)
|> Enum.sort
expected_alive_cells = [{0, 0}, {1, 1}, {2, 2}]
assert actual_alive_cells == expected_alive_cells
end
end

We need to ensure we won’t allow adding the same cell twice to the board so we test that there are no duplicates. Here is the implementation for add_cells/2 function:

# lib/game_of_life/board.ex
defmodule GameOfLife.Board do
def add_cells(alive_cells, new_cells) do
alive_cells ++ new_cells
|> Enum.uniq
end
end

Another thing is removing cells from the list of alive cells:

# test/game_of_life/board_test.exs
test "remove cells which must be killed from alive cells" do
alive_cells = [{1, 1}, {4, -2}, {2, 2}, {2, 1}]
kill_cells = [{1, 1}, {2, 2}]
actual_alive_cells = GameOfLife.Board.remove_cells(alive_cells, kill_cells)
expected_alive_cells = [{4, -2}, {2, 1}]
assert actual_alive_cells == expected_alive_cells
end

Implementation is super simple:

# lib/game_of_life/board.ex
def remove_cells(alive_cells, kill_cells) do
alive_cells -- kill_cells
end

Let’s create something more advanced. We should determine which cells should still live in the next generation after the tick. Here is a test for the GameOfLife.Board.keep_alive_tick/1 function:

# test/game_of_life/board_test.exs
test "alive cell with 2 neighbours lives on to the next generation" do
alive_cells = [{0, 0}, {1, 0}, {2, 0}]
expected_alive_cells = [{1, 0}]
assert GameOfLife.Board.keep_alive_tick(alive_cells) == expected_alive_cells
end

The function keep_alive_tick does a few things like creating a new task with Task.Supervisor for each alive cell. Tasks will be created across available nodes in the cluster. We calculate if alive cells should stay alive or be removed. The keep_alive_or_nilify/2 function returns if the cell should live or nil otherwise.

We wait with Task.await/1 until all tasks across all nodes finished their work. Tasks are working in parallel but we need to wait for results from each task. We remove from the list the nil values so at the end we end up with only alive cells for the next generation.

# lib/game_of_life/board.ex
@doc "Returns cells that should still live on the next generation"
def keep_alive_tick(alive_cells) do
alive_cells
|> Enum.map(&(Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :keep_alive_or_nilify, [alive_cells, &1])))
|> Enum.map(&Task.await/1)
|> remove_nil_cells
end

def keep_alive_or_nilify(alive_cells, cell) do
if GameOfLife.Cell.keep_alive?(alive_cells, cell), do: cell, else: nil
end

defp remove_nil_cells(cells) do
cells
|> Enum.filter(fn cell -> cell != nil end)
end

There is one more case we should handle which is a situation when dead cells should become alive. GameOfLife.Board.become_alive_tick/1 function will be responsible for that.

# test/game_of_life/board_test.exs
test "dead cell with three live neighbours becomes a live cell" do
alive_cells = [{0, 0}, {1, 0}, {2, 0}, {1, 1}]
born_cells = GameOfLife.Board.become_alive_tick(alive_cells)
expected_born_cells = [{1, -1}, {0, 1}, {2, 1}]
assert born_cells == expected_born_cells
end

This is how our function looks like:

# lib/game_of_life/board.ex
@doc "Returns new born cells on the next generation"
def become_alive_tick(alive_cells) do
GameOfLife.Cell.dead_neighbours(alive_cells)
|> Enum.map(&(Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :become_alive_or_nilify, [alive_cells, &1])))
|> Enum.map(&Task.await/1)
|> remove_nil_cells
end

def become_alive_or_nilify(alive_cells, dead_cell) do
if GameOfLife.Cell.become_alive?(alive_cells, dead_cell), do: dead_cell, else: nil
end

It works similarly to GameOfLife.Board.keep_alive_tick/1. First, we are looking for the dead neighbours of alive cells. Then for each dead cell we create a new process across the nodes in the cluster to determine if the dead cell should become alive in the next generation.

You can see the full source code of GameOfLife.Board module and tests on github.

Create BoardServer

Let’s create GameOfLife.BoardServer generic server behaviour. We define a public interface for the server.

# lib/game_of_life/board_server.ex
defmodule GameOfLife.BoardServer do
use GenServer
require Logger

@name {:global, __MODULE__}

@game_speed 1000 # miliseconds

# Client

def start_link(alive_cells) do
case GenServer.start_link(__MODULE__, {alive_cells, nil, 0}, name: @name) do
{:ok, pid} ->
Logger.info "Started #{__MODULE__} master"
{:ok, pid}
{:error, {:already_started, pid}} ->
Logger.info "Started #{__MODULE__} slave"
{:ok, pid}
end
end

def alive_cells do
GenServer.call(@name, :alive_cells)
end

def generation_counter do
GenServer.call(@name, :generation_counter)
end

def state do
GenServer.call(@name, :state)
end

@doc """
Clears board and adds only new cells.
Generation counter is reset.
"""
def set_alive_cells(cells) do
GenServer.call(@name, {:set_alive_cells, cells})
end

def add_cells(cells) do
GenServer.call(@name, {:add_cells, cells})
end

def tick do
GenServer.cast(@name, :tick)
end

def start_game(speed \\ @game_speed) do
GenServer.call(@name, {:start_game, speed})
end

def stop_game do
GenServer.call(@name, :stop_game)
end

def change_speed(speed) do
stop_game
start_game(speed)
end
end

As you can see, we use GenServer behaviour in our module. The module requires also Logger as we would like to print some info to the STDOUT.

In the start_link/1 function we start a new GenServer. When our generic server starts, it is as a first process in the cluster. Then it becomes the master process. In the case when there is already a running process with a globally registered name {:global,__MODULE__}, we log info that our process will be a slave process with a reference to the existing PID on another node in the cluster.

We store the global name for our server in the attribute @name. We use another attribute @game_speed for default game speed which is 1000 milliseconds.

In our public interface, we have the alive_cells/1 function which returns the list of alive cells. Basically, it is the current state of the game (alive cells on the board). This function calls GenServer with the registered @name and requests :alive_cells. We need to implement the handle_call/3 function for this type of request (:alive_cells).

There is another public function generation_counter/1 which returns how many generations were already processed by the board server.

The state/1 function returns state that is held by our generic server. The state is represented as the tuple with 3 values like alive cells, TRef (time reference - we want to regenerate board every second) and generation counter. TRef is a very internal thing for the board server so we won’t return this to the outside world. That’s why we will return just alive cells and the generation counter. You will see it later in the implementation for handle_call(:state, _from, state).

You can use the set_alive_cells/1 function in the case when you want to override the current list of alive cells with a new list.

The add_cells/1 function will be very useful as we want to be able to add new cells or figures to the board. For instance, we may want to add a blinker pattern to the existing game. You will learn more about patterns later.

blinker pattern

We can manually force the game to calculate the next generation of cells with the tick/1 function.

The start_game/1 function is responsible for starting a new timer which calls every second a tick/1 function. Thanks to that our game will update the list of alive cells within a specified interval which is @game_speed.

The last 2 functions are stop_game/1 and change_speed/1 which just restart the game and start a new one with the provided speed.

Now you can see how the above functions are working. They are calling server callbacks.

# lib/game_of_life/board_server.ex
defmodule GameOfLife.BoardServer do
use GenServer
# ...

# Server (callbacks)

def handle_call(:alive_cells, _from, {alive_cells, _tref, _generation_counter} = state) do
{:reply, alive_cells, state}
end

def handle_call(:generation_counter, _from, {_alive_cells, _tref, generation_counter} = state) do
{:reply, generation_counter, state}
end

def handle_call(:state, _from, {alive_cells, _tref, generation_counter} = state) do
{:reply, {alive_cells, generation_counter}, state}
end

def handle_call({:set_alive_cells, cells}, _from, {_alive_cells, tref, _generation_counter}) do
{:reply, cells, {cells, tref, 0}}
end

def handle_call({:add_cells, cells}, _from, {alive_cells, tref, generation_counter}) do
alive_cells = GameOfLife.Board.add_cells(alive_cells, cells)
{:reply, alive_cells, {alive_cells, tref, generation_counter}}
end

def handle_call({:start_game, speed}, _from, {alive_cells, nil = _tref, generation_counter}) do
{:ok, tref} = :timer.apply_interval(speed, __MODULE__, :tick, [])
{:reply, :game_started, {alive_cells, tref, generation_counter}}
end

def handle_call({:start_game, _speed}, _from, {_alive_cells, _tref, _generation_counter} = state) do
{:reply, :game_already_running, state}
end

def handle_call(:stop_game, _from, {_alive_cells, nil = _tref, _generation_counter} = state) do
{:reply, :game_not_running, state}
end

def handle_call(:stop_game, _from, {alive_cells, tref, generation_counter}) do
{:ok, :cancel} = :timer.cancel(tref)
{:reply, :game_stoped, {alive_cells, nil, generation_counter}}
end

def handle_cast(:tick, {alive_cells, tref, generation_counter}) do
keep_alive_task = Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :keep_alive_tick, [alive_cells])
become_alive_task = Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :become_alive_tick, [alive_cells])

keep_alive_cells = Task.await(keep_alive_task)
born_cells = Task.await(become_alive_task)

alive_cells = keep_alive_cells ++ born_cells

{:noreply, {alive_cells, tref, generation_counter + 1}}
end
end

Oh, we forgot about tests. In this case, we can use DocTest. It allows us to generate tests from the code examples existing in a module/function/macro’s documentation.

Our test file is super short:

# test/game_of_life/board_server_test.exs
defmodule GameOfLife.BoardServerTest do
use ExUnit.Case
doctest GameOfLife.BoardServer
end

Let’s add @moduledoc to GameOfLife.BoardServer.

# lib/game_of_life/board_server.ex
defmodule GameOfLife.BoardServer do
use GenServer
require Logger

@moduledoc """
## Example
iex> GameOfLife.BoardServer.start_game
:game_started
iex> GameOfLife.BoardServer.start_game
:game_already_running
iex> GameOfLife.BoardServer.stop_game
:game_stoped
iex> GameOfLife.BoardServer.stop_game
:game_not_running
iex> GameOfLife.BoardServer.change_speed(500)
:game_started
iex> GameOfLife.BoardServer.stop_game
:game_stoped

iex> GameOfLife.BoardServer.set_alive_cells([{0, 0}])
[{0, 0}]
iex> GameOfLife.BoardServer.alive_cells
[{0, 0}]
iex> GameOfLife.BoardServer.add_cells([{0, 1}])
[{0, 0}, {0, 1}]
iex> GameOfLife.BoardServer.alive_cells
[{0, 0}, {0, 1}]
iex> GameOfLife.BoardServer.state
{[{0, 0}, {0, 1}], 0}

iex> GameOfLife.BoardServer.generation_counter
0
iex> GameOfLife.BoardServer.tick
:ok
iex> GameOfLife.BoardServer.generation_counter
1
iex> GameOfLife.BoardServer.state
{[], 1}
"""
end

As you can see we have grouped 3 examples in the @moduledoc attribute and they are separated by a new line. When you run tests you will see 3 separate tests.

$ mix test test/game_of_life/board_server_test.exs
Compiled lib/game_of_life/board_server.ex

20:54:30.637 [info] Started Elixir.GameOfLife.BoardServer master
...

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)
3 tests, 0 failures

Randomized with seed 791637

In GameOfLife.BoardServer you probably noticed 2 interesting things. First is GameOfLife.Board which is called in:

# lib/game_of_life/board_server.ex
def handle_call({:add_cells, cells}, _from, {alive_cells, tref, generation_counter}) do
alive_cells = GameOfLife.Board.add_cells(alive_cells, cells)
{:reply, alive_cells, {alive_cells, tref, generation_counter}}
end

As you saw before we added some useful functions to GameOfLife.Board module which helps us to do operations on the list of alive cells.

Another interesting thing is how we use Task.Supervisor in:

# lib/game_of_life/board_server.ex
def handle_cast(:tick, {alive_cells, tref, generation_counter}) do
keep_alive_task = Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :keep_alive_tick, [alive_cells])
become_alive_task = Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :become_alive_tick, [alive_cells])

keep_alive_cells = Task.await(keep_alive_task)
born_cells = Task.await(become_alive_task)

alive_cells = keep_alive_cells ++ born_cells

{:noreply, {alive_cells, tref, generation_counter + 1}}
end

What we are doing here is spinning off a new async process to run the GameOfLife.keep_alive_tick/1 function with the argument alive_cells.

# lib/game_of_life/board_server.ex
keep_alive_task = Task.Supervisor.async(
{GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node},
GameOfLife.Board, :keep_alive_tick, [alive_cells])

The tuple {GameOfLife.TaskSupervisor, GameOfLife.NodeManager.random_node} tells Task.Supervisor that we want to use task supervisor with the name GameOfLife.TaskSupervisor and we want to run the process on the node returned by the GameOfLife.NodeManager.random_node function.

Create game printer and console presenter

GameOfLife.GamePrinter module is running as a worker under the supervision of the GameOfLife supervisor. GameOfLife.GamePrinter is using Agent to store TRef for timer reference as we want to print the board to the STDOUT with the specified interval.

You have already seen the example of using Agent so this shouldn’t be new for you. Basically, we wrote the public interface to start and stop printing the board to the screen. For tests we used DocTest.

# lib/game_of_life/game_printer.ex
defmodule GameOfLife.GamePrinter do
@moduledoc """
## Example
iex> GameOfLife.GamePrinter.start_printing_board
:printing_started
iex> GameOfLife.GamePrinter.start_printing_board
:already_printing
iex> GameOfLife.GamePrinter.stop_printing_board
:printing_stopped
iex> GameOfLife.GamePrinter.stop_printing_board
:already_stopped
"""

@print_speed 1000

def start_link do
Agent.start_link(fn -> nil end, name: __MODULE__)
end

def start_printing_board do
Agent.get_and_update(__MODULE__, __MODULE__, :do_start_printing_board, [])
end

def do_start_printing_board(nil = _tref) do
{:ok, tref} = :timer.apply_interval(@print_speed, __MODULE__, :print_board, [])
{:printing_started, tref}
end

def do_start_printing_board(tref), do: {:already_printing, tref}

def print_board do
{alive_cells, generation_counter} = GameOfLife.BoardServer.state
alive_counter = alive_cells |> Enum.count
GameOfLife.Presenters.Console.print(alive_cells, generation_counter, alive_counter)
end

def stop_printing_board do
Agent.get_and_update(__MODULE__, __MODULE__, :do_stop_printing_board, [])
end

def do_stop_printing_board(nil = _tref), do: {:already_stopped, nil}

def do_stop_printing_board(tref) do
{:ok, :cancel} = :timer.cancel(tref)
{:printing_stopped, nil}
end
end

GameOfLife.Presenters.Console is responsible for printing the board nicely with X & Y axes, the number of alive cells, and the generation counter. Let’s start with tests. We are going to capture STDOUT and compare if data printed to the screen are looking as we expect.

# test/game_of_life/presenters/console_test.exs
defmodule GameOfLife.Presenters.ConsoleTest do
use ExUnit.Case

# allows to capture stuff sent to stdout
import ExUnit.CaptureIO

test "print cells on the console output" do
cell_outside_of_board = {-1, -1}
cells = [{0, 0}, {1, 0}, {2, 0}, {1, 1}, {0, 2}, cell_outside_of_board]

result = capture_io fn ->
GameOfLife.Presenters.Console.print(cells, 123, 6, 0, 2, 2, 2)
end

assert result == (
" 2| O,,\n" <>
" 1| ,O,\n" <>
" 0| OOO\n" <>
" | _ _ \n" <>
" / 0 \n" <>
"Generation: 123\n" <>
"Alive cells: 6\n"
)
end
end

Here is the implementation of our print function:

# lib/game_of_life/presenters/console.ex
defmodule GameOfLife.Presenters.Console do
@doc """
Print cells to the console output.
Board is visible only for specified size for x and y.
Start x and y are in top left corner of the board.

`x_padding` Must be a prime number. Every x divided by the prime number
will be visible on x axis.
`y_padding` Any number. Padding for numbers on y axis.
"""
def print(cells, generation_counter, alive_counter, start_x \\ -10, start_y \\ 15, x_size \\ 60,
y_size \\ 20, x_padding \\ 5, y_padding \\ 5) do
end_x = start_x + x_size
end_y = start_y - y_size
x_range = start_x..end_x
y_range = start_y..end_y

for y <- y_range, x <- x_range do
# draw y axis
if x == start_x do
(y
|> Integer.to_string
|> String.rjust(y_padding)) <> "| "
|> IO.write
end

IO.write(if Enum.member?(cells, {x, y}), do: "O", else: ",")
if x == end_x, do: IO.puts ""
end

# draw x axis
IO.write String.rjust("| ", y_padding + 2)
x_length = (round((end_x-start_x)/2))
for x <- 0..x_length, do: IO.write "_ "
IO.puts ""
IO.write String.rjust("/ ", y_padding + 2)
for x <- x_range do
if rem(x, x_padding) == 0 do
x
|> Integer.to_string
|> String.ljust(x_padding)
|> IO.write
end
end
IO.puts ""
IO.puts "Generation: #{generation_counter}"
IO.puts "Alive cells: #{alive_counter}"
end
end

The board with the bigger visible part looks like this:

   15| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
14| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
13| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
12| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
11| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
10| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
9| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
8| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
7| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
6| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
5| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
4| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
3| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
2| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
1| ,,,,,,,,,,OO,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
0| ,,,,,,,,,,OO,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-1| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-2| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-3| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-4| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
-5| ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
| _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/ -10 -5 0 5 10 15 20 25 30 35 40 45 50
Generation: 18
Alive cells: 4

Last step is to uncomment the GameOfLife.GamePrinter worker in:

# lib/game_of_life.ex
defmodule GameOfLife do
use Application

# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec, warn: false

init_alive_cells = []

children = [
# Define workers and child supervisors to be supervised
# worker(GameOfLife.Worker, [arg1, arg2, arg3]),
supervisor(Task.Supervisor, [[name: GameOfLife.TaskSupervisor]]),
worker(GameOfLife.BoardServer, [init_alive_cells]),

# This line is uncommented now
worker(GameOfLife.GamePrinter, []),
]

# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: GameOfLife.Supervisor]
Supervisor.start_link(children, opts)
end
end

Add figure patterns and place them on the board

To play our game of life, it would be great to have an easy way to add figures to the board. There are many commonly known patterns like still lifes, oscillators, and spaceships. You can learn more about them here.

One interesting pattern is the gun. The Gosper Glider Gun is a very popular pattern. Here it is how it looks:

Gosper Glider Gun

When you run game the pattern behaves as you see. The gun is shooting.

Gosper Glider Gun pattern live cycle

Let’s write this pattern down. Imagine you want to put the pattern in a rectangle. Left bottom corner of the rectangle is at {0,0} position.

# lib/game_of_life/patterns/guns.ex
defmodule GameOfLife.Patterns.Guns do
@moduledoc """
https://en.wikipedia.org/wiki/Gun_(cellular_automaton)
"""

@doc """
https://en.wikipedia.org/wiki/File:Game_of_life_glider_gun.svg
"""
def gosper_glider do
[
{24, 8},
{22, 7}, {24, 7},
{12, 6}, {13, 6}, {20, 6}, {21, 6}, {34, 6}, {35, 6},
{11, 5}, {15, 5}, {20, 5}, {21, 5}, {34, 5}, {35, 5},
{0, 4}, {1, 4}, {10, 4}, {16, 4}, {20, 4}, {21, 4},
{0, 3}, {1, 3}, {10, 3}, {14, 3}, {16, 3}, {17, 3}, {22, 3}, {24, 3},
{10, 2}, {16, 2}, {24, 2},
{11, 1}, {15, 1},
{12, 0}, {13, 0},
]
end
end

It would be also useful if we could place the pattern on the board in the position specified by us. Let’s write a pattern converter.

# lib/game_of_life/pattern_converter.ex
defmodule GameOfLife.PatternConverter do
@doc """
## Example
iex> GameOfLife.PatternConverter.transit([{0, 0}, {1, 3}], -1, 2)
[{-1, 2}, {0, 5}]
"""
def transit([{x, y} | cells], x_padding, y_padding) do
[{x + x_padding, y + y_padding} | transit(cells, x_padding, y_padding)]
end

def transit([], _x_padding, _y_padding), do: []
end

This is the way you can add the Gosper glider pattern to the board with the specified position.

GameOfLife.Patterns.Guns.gosper_glider
|> GameOfLife.PatternConverter.transit(-2, -3)
|> GameOfLife.BoardServer.add_cells

You can find more patterns in modules here.

Run game across multiple nodes

Now it is time to run our game. The full source code can be found here.

Let’s run the first node where the GameOfLife.BoardServer will be running.

$ iex --sname node1 -S mix
Erlang/OTP 18 [erts-7.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]
Interactive Elixir (1.2.4) - press Ctrl+C to exit (type h() ENTER for help)

16:54:08.554 [info] Started Elixir.GameOfLife.BoardServer master

iex(node1@Artur)1> GameOfLife.BoardServer.start_game
:game_started

iex(node1@Artur)2> GameOfLife.GamePrinter.start_printing_board
:printing_started

In another terminal window, you can start the second node. We will connect it with the first node.

$ iex --sname node2 -S mix
Erlang/OTP 18 [erts-7.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]
Interactive Elixir (1.2.4) - press Ctrl+C to exit (type h() ENTER for help)

16:55:17.395 [info] Started Elixir.GameOfLife.BoardServer master

iex(node2@Artur)1> Node.connect :node1@Artur
true
16:55:17.691 [info] Started Elixir.GameOfLife.BoardServer slave

iex(node2@Artur)2> Node.list
[:node1@Artur]

iex(node2@Artur)3> Node.self
:node2@Artur

iex(node2@Artur)4> GameOfLife.Patterns.Guns.gosper_glider |> GameOfLife.BoardServer.add_cells
[{24, 8}, {22, 7}, {24, 7}, {12, 6}, {13, 6}, {20, 6}, {21, 6}, {34, 6},
{35, 6}, {11, 5}, {15, 5}, {20, 5}, {21, 5}, {34, 5}, {35, 5}, {0, 4}, {1, 4},
{10, 4}, {16, 4}, {20, 4}, {21, 4}, {0, 3}, {1, 3}, {10, 3}, {14, 3}, {16, 3},
{17, 3}, {22, 3}, {24, 3}, {10, 2}, {16, 2}, {24, 2}, {11, 1}, {15, 1},
{12, 0}, {13, 0}]

Both nodes are executing a calculation to determine a new state for living cells. You can run the game also across different servers in the network like this:

# start node1
$ iex --name node1@192.168.0.101 --cookie "token_for_cluster" -S mix

# start node2 on another server
$ iex --name node2@192.168.0.102 --cookie "token_for_cluster" -S mix
iex> Node.connect :"node1@192.168.0.101"
true

You already saw how the game works in the demo at the beginning of the article. You can try it on your own machine, just clone the repository.

Summary

Finally, we managed to get to the end. It was a pretty long road but we have a working game, distributed across nodes. We learned how to write GenServer, use Agents, split processes across nodes with TaskSupervisor and connect nodes into the cluster. You also saw examples of tests in Elixir and how to use DocTest.

Hope you found something interesting in the article. Please share your thoughts in the comments.

Nowadays I work on CI parallelisation problem to run tests fast on CI servers. I built Knapsack Pro which splits your Ruby and JavaScript tests on parallel CI nodes to save time on testing. You can check one of my articles about Cypress in JavaScript test suite parallelisation or Heroku CI parallelisation. Let me know how slow or fast your tests are in your Elixir projects — just leave a comment. :) I’d like to add support for CI parallelisation testing into Elixir as well.

--

--