Game of Life 2

I heard about John Conway's death from COVID-19 this weekend and was thinking about his most famous contribution, the Game of Life. I did an implementation for the microbit a while ago modelling explicit population objects as two dimensional arrays of cells.

example

Reconsidering this implementation, it seemed a bit too complicated for what was needed and often seemed to run out of stack space when running. The thought occurred that the display itself could be used as a sort of variable. After all, the original version worked with an array of cells that were manipulated according to the rules of the game then mapped to the 5 x 5 led display to show the current state. If we get rid of the intermediate representation, then the display can be lit with the live cells and we can read the state of any particular cell by using get_pixel.


from microbit import *
import random


leds = [(x, y) for y in range(0, 5) for x in range(0, 5)]

# is this x, y location on the board?
def is_legal_location(x, y):
    return 0 <= x < 5 and 0 <= y < 5


def is_cell_alive(x, y):
    if is_legal_location(x, y):
        return display.get_pixel(x, y) == 9
    return False


def is_cell_dying(x, y):
    brightness = display.get_pixel(x, y)
    return 0 < brightness < 9

    
def is_cell_dead(x, y):
    return display.get_pixel(x, y) == 0   

    
def count_live_neighbours(x, y):
    neighbours = 0

    left = x - 1
    right = x + 1
    above = y - 1
    below = y + 1
    
    if is_cell_alive(left, above):
        neighbours += 1

    if is_cell_alive(left, y):
        neighbours += 1

    if is_cell_alive(left, below):
        neighbours += 1

    if is_cell_alive(x, above):
        neighbours += 1

    if is_cell_alive(x, below):
        neighbours += 1

    if is_cell_alive(right, above):
        neighbours += 1

    if is_cell_alive(right, y):
        neighbours += 1

    if is_cell_alive(right, below):
        neighbours += 1

    return neighbours

  
def apply_rules_to_cells():
    for led in leds:
        x, y = led[0], led[1]
        neighbours = count_live_neighbours(x, y)

        # Any live cell...
        if is_cell_alive(x, y):
            # ... with fewer than two live neighbours 
            # dies, as if caused by under-population.
            # ... with more than three live neighbours 
            # dies, as if by over-population.
            if neighbours < 2 or neighbours > 3:
                display.set_pixel(x, y, 8)
            # ... with two or three live neighbours 
            # lives on to the next generation.
            elif neighbours in [2, 3]:
                display.set_pixel(x, y, 9)
                
        # Any dead cell...
        if is_cell_dead(x, y) and neighbours == 3:
            # ... with exactly three live neighbours 
            # becomes a live cell, as if by reproduction.
            display.set_pixel(led[0], led[1], 9)


def generate_random_population():
    display.clear()
    new_cell_probability = 50

    for led in leds:
        probability = random.randint(0, 100)
        if probability <= new_cell_probability:
            display.set_pixel(led[0], led[1], 9)

    
def are_cells_dying():
    for led in leds:
        if is_cell_dying(led[0], led[1]):
            return True
  
    return False
  
  
def animate_dying_cells():
    for led in leds:
        x, y = led[0], led[1]
        brightness = display.get_pixel(x, y)
        
        if 0 < brightness < 9:
            faded_brightness = max(brightness - 3, 0)
            display.set_pixel(x, y, faded_brightness)


def is_life_extinct():
    for led in leds:
        if is_cell_alive(led[0], led[1]):
            return False
  
    return True


def display_random_animal():
    animals = [
        Image.DUCK, Image.RABBIT, Image.COW, Image.PACMAN, 
        Image.TORTOISE, Image.BUTTERFLY, Image.GIRAFFE, Image.SNAKE
    ]

    display.show(random.choice(animals))
    sleep(2000)
    display.clear()


# Game start...
display_random_animal()
regenerate_population = True

while True:

    if regenerate_population:
        regenerate_population = False
        generate_random_population()
        
    sleep(250)

    if are_cells_dying():
        animate_dying_cells()
    else:
        apply_rules_to_cells()   
    
    if is_life_extinct():
        display.show(Image.SKULL)
        sleep(3000)
        regenerate_population = True

    if button_a.was_pressed():
        regenerate_population = True

Removing the population variables and passing objects around in the code tidies things up a bit at the expense of having a "global" display variable used to hold the internal game state. I think that the code is easier to read and is less complicated plus it comes in at fewer actual lines of code.

Fading

One feature that slightly complicates the main game loop is the fading out of dying pixels. I added this in so that the transition from one population to the other was a bit more obvious and you can hopefully see how the changes are happening because of this feature.

Bug

There is one weakness with this implementation. Because we are updating the display as we go along in applying the rules, it's possible that the new state of the game bleeds into the current state as we proceed top left to bottom right which would have an impact on certain configurations. Better to read the current state and create a blank image object and update that with new state then blt that to the display.

It turns out that is really easy to do. We create a new image in the apply rules function, update it with set_pixel as we work our way through the cells, then use display.show at the end to make this image the new state of the game.


def apply_rules_to_cells():
    
    next_screen = Image()
    
    for led in leds:
        x, y = led[0], led[1]
        neighbours = count_live_neighbours(x, y)

        # Any live cell...
        if is_cell_alive(x, y):
            # ... with fewer than two live neighbours
            # dies, as if caused by under-population.
            # ... with more than three live neighbours
            # dies, as if by over-population.
            if neighbours < 2 or neighbours > 3:
                next_screen.set_pixel(x, y, 8)
            # ... with two or three live neighbours
            # lives on to the next generation.
            elif neighbours in [2, 3]:
                next_screen.set_pixel(x, y, 9)

        # Any dead cell...
        if is_cell_dead(x, y) and neighbours == 3:
            # ... with exactly three live neighbours
            # becomes a live cell, as if by reproduction.
            next_screen.set_pixel(led[0], led[1], 9)
    
    display.show(next_screen)

Once this is addressed, the shapes that appear are subtly different and the stable and unstable end states are different too.