Coding Conway's Game of Life in Python

Game of Life

Conway's Game of Life is a cellular automaton simulation created by mathematician John Conway. It is a zero-player game, meaning its evolution depends only on the initial state and no further input is needed.

The game creates a set of rules that mimic the evolution of a colony of biological organisms. It takes place in a two-dimensional orthogonal grid world composed by square cells, each of which is in one of two possible states: live or dead. The cells interact with its eight neighbours and evolution takes place in time steps (generations) where the following rules are applied:

The code

We will implement Conway's Game of Life in Python. There are several ways to to do so, and a quick Google search will find many alternatives. I have tried to keep the implementation below very beginner friendly, so we will only use the Python Standard Library. No package installation is required.

We start by importing the necessary packages. We will use the random package to create random initial states for the world and the json package to save the simulation result at the end.

import random
import json

Next, we create a class to represent each cell in the world. Each cell instance starts with a status of Dead and has methods to set it Live and Dead. The is_live() method checks if the cell is alive at any given point. When representing a cell in the terminal we will use the and _ symbols for Live and Dead cells, respectively. The get_print_symbol() method will return the correct symbol depending on the cell status.

class Cell:
    def __init__(self, status='Dead'):
        self.status = status

    def lives(self):
        self.status = 'Live'

    def dies(self):
        self.status = 'Dead'

    def is_live(self):
        if self.status == 'Live':
            return True
        return False

    def get_print_symbol(self):
        if self.is_live():
            return '█'
        return '_'

Now, let's set up a class to represent the game world, which is a 2D grid. There will be two ways to create the game world in this implementation:

  1. By providing the size of the world in terms of rows and columns. For instance, calling world1 = World(10, 20) will create a 10x20 world grid.

  2. By providing a .txt file with an initial state. Like, world1 = World(initial_state='initial_state.txt'). You can find several examples of initial state files here.

In case no initial state is specified, the initial_state_random() method will generate a random weighted distribution of Live and Dead cells in a grid of the provided size.

The print_board() method will print the world state to the terminal when called using the symbolic representation of and _.

The evolve() method implements the evolution of the cells in each time step. When it is called, a new generation is formed. For each cell in the World grid, we will check the number of live neighbours using the get_n_live_neighbours(x, y) method.

class World:
    def __init__(self, rows, columns, initial_state=None):
        self.rows = rows
        self.columns = columns
        if initial_state:
            self.state = initial_state
        else:
            self.state = self.initial_state_random()

    def initial_state_random(self, live_pop=0.4):
        grid = []
        for i in range(self.rows):
            line = random.choices(
                [Cell(status='Dead'), 
                Cell(status='Live')], 
                weights=[1-live_pop, live_pop], 
                k=self.columns)
            grid.append(line)
        return grid

    def print_board(self):
        for line in self.state:
            for cell in line:
                print(cell.get_print_symbol(), end='')
            print('')
        print('\n')

    def evolve(self):
        # Lists of cells that go dead or go alive
        goes_dead = []
        goes_alive = []

        for i in range(self.rows):
            for j in range(self.columns):
                cell = self.state[i][j]
                n_live_neighbours = self.get_n_live_neighbours(i, j)
                if cell.is_live():
                    if (n_live_neighbours < 2) or (n_live_neighbours > 3):
                        goes_dead.append(cell)
                else:
                    if n_live_neighbours == 3:
                        goes_alive.append(cell)

        for cell in goes_dead:
            cell.dies()

        for cell in goes_alive:
            cell.lives()

    def get_n_live_neighbours(self, x, y):
        count = 0
        top_margin, bottom_margin, left_margin, right_margin = False, False, False, False

        if x == 0 and y == 0:
            top_margin, left_margin = True, True
        elif x == 0 and y == (self.columns - 1):
            top_margin, right_margin = True, True
        elif x == (self.rows - 1) and y == 0:
            bottom_margin, left_margin = True, True
        elif x == (self.rows - 1) and y == (self.columns - 1):
            bottom_margin, right_margin = True, True
        elif x == 0:
            top_margin = True
        elif x == (self.rows - 1):
            bottom_margin = True
        elif y == 0:
            left_margin = True
        elif y == (self.columns - 1):
            right_margin = True

        if not top_margin:
            if self.state[x - 1][y].is_live():
                count += 1
        if not bottom_margin:        
            if self.state[x + 1][y].is_live():
                count += 1
        if not left_margin:
            if self.state[x][y - 1].is_live():
                count += 1
        if not right_margin:
            if self.state[x][y + 1].is_live():
                count += 1
        if not top_margin and not left_margin:
            if self.state[x - 1][y - 1].is_live():
                count += 1
        if not bottom_margin and not right_margin:
            if self.state[x + 1][y + 1].is_live():
                count += 1
        if not bottom_margin and not left_margin:
            if self.state[x + 1][y - 1].is_live():
                count += 1
        if not top_margin and not right_margin:
            if self.state[x - 1][y + 1].is_live():
                count += 1

        return count

Finally, lets create a class to handle the simulation. It will take the initial state file or grid size as inputs and print the initial World state. With run(generations=100) we specify the number of generation we wish to simulate (with a default value of 100). The load_initial_state_file(filepath) reads the initial state from the file. When needed the save_run() saves the simulation to a .json file.

class Simulation:
    def __init__(self, initial_state_file=None, size=[10, 10]):
        if initial_state_file:
            initial_state = self.load_initial_state_file(initial_state_file)
            self.rows = len(initial_state)
            self.columns = len(initial_state[0])
            self.world = World(self.rows, self.columns, initial_state)

        else:
            self.rows = size[0]
            self.columns = size[1]
            self.world = World(self.rows, self.columns)

        print('Initial World state')
        self.world.print_board()
        self.progress_int = [self.world.get_integer_state()]

    def run(self, generations=100):
        for i in range(generations):
            self.world.evolve()
            self.progress_int.append(self.world.get_integer_state())
            print(f'Generation {i+1}')
            self.world.print_board()

    def load_initial_state_file(self, filepath):
        initial_state = []
        with open(filepath, 'r') as f:
            for line in f:
                line_list = [Cell(status='Dead') if x.strip() == '_' else Cell(status='Live') for x in line.replace('\n', '')]
                initial_state.append(line_list)
        return initial_state

    def save_run(self, filepath='data/result.json'):
        with open(filepath, 'w') as f:
            json.dump(self.progress_int, f)
        print(f'Saved simulation to {filepath}.')

To run a simulation for 100 generations from an initial 20 by 20 random state grid we run the following.

sim = Simulation(size=[20, 20])
sim.run(generations=100)
sim.save_run()

The result

Since the result of the simulation depends only on its initial state, the code above will generate a different output every time. Alternatively if we provide a specific initial state, we will always get the same result.

sim = Simulation(initial_state_file='data/known.txt')
sim.run(generations=100)

I created a random initial state and saved it as data/known.txt. Running the simulation for 100 generations using this initial state we get the following.

Random simulation

Some interesting patterns

Several interesting patterns have been identified in these simulations. The most simple are called Still Lifes and are patterns that do not change between generations and thus appear static. Here are three cases (named Block, Bee-hive and Tub) in a simulation.

Still Lifes simulation

Oscillators are patters that return to their initial state after a finite number of generations. Below you can see the Blinker, Toad, and Beacon, three oscillators with a period of two (meaning they change between two states).

Period 2 oscillators simulation

Higher period oscillators are also known, such as the Pulsar (period 3), and the Penta-decathlon (period 15). Here things start to get interesting.

Pulsar Penta-decathlon
Pulsar simulation Penta-decathlon simulation

Other know patterns move across the World grid, these are called Spaceships. One of the simplest is the Glider.

Glider simulation

The Gosper Glider Gun below, named for its discoverer mathemathician Bill Gosper, was the first case of unbounded growth found in the Game. This structure produces its first Glider on the 15th generation, and another Glider every 30th generation from then on.

Gosper Glider Gun

To conclude I will leave the following animation from Wikipedia. The Breeder below leaves Glider Guns in its path, which in turn create Gliders.

Breeder

Fascinating how such simple rules and simple code can create such complex (and beautiful) systems and patterns.

Source code

References