“The late night tinkering projects #17: Game of Life”

5 min readApr 23, 2023

Developing a “Game of Life” is pretty simple: we get a matrix and check if every cell should be turned on or off following simple rules.

Rules of the “Game of Life” state the following:

• if a cell is off, then it turns on only if it has exactly 3 neighbours lit
• if a cell is on, it remains so only if it has exactly 2 or 3 neighbours lit

To describe the evolution of this cellular automaton, we can develop a Python class with a set of rules:

self.rules = [
[0,0,0,1,0,0,0,0,0,0],
[0,0,1,1,0,0,0,0,0,0],
]

Those rules identify the status of the cell we are examining versus the number of lit neighbours (which are at most 9):

Using this table, updating the (i,j) cell status is easy as:

def next_generation(self, on_grid):
next_grid = [[0 for col in range(self.size_y)] for row in range(self.size_x)]
for (i, j) in [(i, j) for i in range(self.size_y) for j in range(self.size_x)]:
alive_n = self.count_alive_neighbours(on_grid, i, j)
cur_cell = on_grid[i][j]
next_grid[i][j] = self.rules[cur_cell][alive_n]
return next_grid

Neighbours count is the sum of all the cells surrounding the (i,j) cell, no matter if 0 or 1 because 0 will not affect the total sum:

def count_alive_neighbours(self, on_grid, i, j):
neighbours = [
(i-1, j), # n
(i-1, j-1), # nw
(i, j-1), # w
(i+1, j-1), # sw
(i+1, j), # s
(i+1, j+1), # se
(i, j+1), # e
(i-1, j+1) # ne
]
return sum(on_grid[x][y] for x, y in neighbours
if 0 <= x < self.size_x and 0 <= y < self.size_y)

Running the simulation n_gen times allows the creation of a number of “animation frames” (steps):

def generate_steps(self):
steps = []
step = self.grid
steps.append(step)
for _ in range(self.n_gen):
step = self.next_generation(step)
steps.append(step)
return steps

Understanding how a Matrix Led works

Before digging into the code for the microcontroller, let’s see how an 8x8 matrix works.

As you know, a LED is lit because current flows from anode (+) to cathode (-):

A led matrix is just…a matrix of leds(!):

In order to light a led, current must flow as stated before (from anode to cathode) so we have to set to 1 (HIGH) the pin corresponding to a row and to 0 the pin corresponding to the column of the led we want to turn on.

For example, as shown in the image above, we can set the pin connected to row 3 to HIGH and pin connected to column 8 to LOW to light led in (3,8).

Now let’s consider the case of lighting two leds in different columns on the same row, for example led in (3,3): we have no problem here, setting the pin of column 3 to LOW is enough.

But what if we want to turn on only the led on column 3, or maybe row 4?

As we can see from the image above, what happens is that also led on column 8 get lit *and that’s totally unintended*.

This is a consequence of how leds are connected in the matrix and when we set a column to LOW, we are actually preparing all the leds on that column to be turned on.

This implies that we have to be careful about how we try to turn on the lights and we must avoid turning on two rows at once.

Writing a micropy script for a (common cathode) 8x8 led matrix

In the script, we first define pin and row/column relationships:

r5 = Pin(17, Pin.OUT)
r7 = Pin(18, Pin.OUT)
c2 = Pin(5, Pin.OUT)
c3 = Pin(7, Pin.OUT)
r8 = Pin(19, Pin.OUT)
c5 = Pin(4, Pin.OUT)
r6 = Pin(20, Pin.OUT)
r3 = Pin(21, Pin.OUT)

c8 = Pin(13, Pin.OUT)
c7 = Pin(12, Pin.OUT)
r2 = Pin(25, Pin.OUT)
c1 = Pin(29, Pin.OUT)
r4 = Pin(15, Pin.OUT)
c6 = Pin(28, Pin.OUT)
c4 = Pin(27, Pin.OUT)
r1 = Pin(16, Pin.OUT)

ordered_rows_pins = [r1, r2, r3, r4, r5, r6, r7, r8]
ordered_cols_pins = [c1, c2, c3, c4, c5, c6, c7, c8]

Then we define a function to clear the matrix. To turn off a led, we just invert the current flow from LED cathode to anode:

def clear_cols():
for p in ordered_cols_pins:
p.on()
def clear_rows():
for p in ordered_rows_pins:
p.off()

def clear():
clear_cols()
clear_rows()

We first clear the matrix and then draw a glider on a matrix, to be the first frame for Game Of Life:

def clear():
clear_cols()
clear_rows()

def draw_glider(on_grid):
on_grid[3][1] = 1
on_grid[3][2] = 1
on_grid[3][3] = 1
on_grid[2][3] = 1
on_grid[1][2] = 1

clear()
size = 8
n_gen = 50
grid = [[0 for col in range(size)] for row in range(size)]
draw_glider(grid)
gol = GameOfLife(grid, n_gen)
steps = gol.generate_steps()
rows = len(grid)
cols = len(grid[0])

We are going to do some preprocessing to limit the number of writes to PINs: instead of checking every (i,j) combination on the matrix to light the leds, we will use a map to associate every row to a list of columns to turn on.

rows_and_cols_in_step = []

for step in steps:
d = {}
for x in range(rows):
for y in range(cols):
if step[x][y]:
if x in d.keys():
d[x].append(y)
else:
d[x] = [y]
rows_and_cols_in_step.append(d)

Last, we are going to scan only the needed columns for every row and keep them lit for one second:

for row_and_col in rows_and_cols_in_step:
frame_start_time = time.ticks_ms()
while time.ticks_ms()-frame_start_time < 1000:
for x in row_and_col.keys():
clear()
p_row = ordered_rows_pins[x]
p_row.on()
cols = row_and_col[x]
for y in cols:
p_col = ordered_cols_pins[y]
p_col.off()

A note on scanning all rows and columns

Scanning all rows and columns is also possible but, as far as I saw, leds light is dimmer:

# simpler but heavier, leds lightly lit
for step in steps:
frame_start_time = time.ticks_ms()
while time.ticks_ms() - frame_start_time < 1000:
for x in range(rows):
for y in range(cols):
clear()
if step[x][y] == 1:
p_row = ordered_rows_pins[x]
p_col = ordered_cols_pins[y]
p_row.on()
p_col.off()

The result

In the following video we see the result. The MicroPython script is running on an Arduino Nano RP2040 Connect:

--

--

Full-time Human-computer interpreter. Opinions are my own.