Seminar 20 Game of life in NumPy

Today you will be programming the Game of Life created by the British mathematician John Horton Conway. This is not really a game but rather a simulation where you set initial conditions up and observe how the population evolves. The rules, as described on Wikipedia, are very simple:

  1. Any live cell with two or three live neighbours survives.
  2. Any dead cell with three live neighbours becomes a live cell.
  3. All other live cells die in the next generation. Similarly, all other dead cells stay dead.

Despite simplicity they produce remarkable evolution of a cell culture and give rise to many dynamic or static configurations. Here is an example simulation with a random starting state.

20.1 Implementing the rules

To program the game, you need to count live neighbours for every cell and use the rules above to decide whether it will be alive or dead on the next iteration. The latter bit is important, you need to evaluate all cells at their current state, you cannot change their status during that period. Accordingly, on each iteration you need to a new cell culture / grid and then overwrite the original one.

Our cells will live in a rectangular world, so you need to count number of neighbours in the 8 cells around it, as shown in the left hand-side image below (index / location of the dot in question is assumed to be i, j).

However, things become slightly trickier, if a cell lies on the border of the world, as depicted on the right hand-side. The same rule for living or dying still apply but when counting neigbours you need to make sure that we do not try to access cells outside of the world (marked in red). If we mess up our indexing, at best we will get an error, at worst we will access wrong elements. E.g., when our index is negative, Python will not issue a mistake but will simply count elements from the end. We could, of course, add extra range checks for index values but there is a simpler solution. Think, how can you avoid border indexing issues without explicit range checks?

20.2 NumPy

Our cells live on a 2D grid. In principle, we could implement it as a list of lists. However, to compute the total number of neighbouring cells, we need to make a 2D slice, which is impossible with a list of lists. This is not a deal breaker, as we can emulate this via nested loops, one over horizontal, one over vertical coordinates. However, this would be fairly inefficient. Instead, we can use NumPy that provides fast and easy to use implementation of multidimensional arrays, matrix operations, and various functions that re-implement functionality, such as generating random numbers, but for arrays. I will not try to properly cover NumPy here and the material below will contain just the bare minimum that we need for the game. Instead, I encourage you to read the quickstart guide and the the absolute basics for beginners.

The NumPy is not included with the base Python distribution. However, you can easily install it, including versions that utilize special libraries to speed up linear algebra computation. Moreover, PsychoPy Python distribution comes with NumPy, so you should already have it.

As with all libraries, you first need to import it. The traditional way to do this is

import numpy as np

You may remember that I advised you against renaming libraries via as. However, NumPy and a few other popular and widely used libraries, such as pandas (as pd), are exceptions to the rule. This is only because of the tradition / general agreement to consistently rename them to make names shorter. Hence, if you see a code snippet that has np. in it, you can be fairly certain that it uses NumPy library imported as np. However, you should still double-check, just in case!

20.3 Basic PsychoPy code

To begin with, we need our usual boilerplate for PsychoPy. I recommend that you use settings file in combination with the context manager, as you have learned during the previous seminar. This is an overkill for this specific game but it is a good practice. In the settings file, you need just three settings:

  • grid width: I’ve picked a 30
  • grid height: I’ve also picked a 30
  • cell size [pix] : size of an individual cell in pixels. I’ve picked 10 (so, each cell is 10×10 pixels) but pick the number that is suitable for you.

These three numbers determine the size of the PsychoPy window in pixels (cells will cover the entire window).

Implement the boilerplate code, including the main game loop, with the correct window size in

20.4 Matrix of life

As noted, our cells live on a 2D array called matrix. There are different ways to create it using Python lists but also making a simple array filled with zeros via np.zeros(), ones via np.ones()), or random numbers 51.

To create a 3×3 array filled with zeros, you simply write

import numpy as np

x = np.zeros((3, 3))
## array([[0., 0., 0.],
##        [0., 0., 0.],
##        [0., 0., 0.]])

However, we do not want to start our game with no living cells (what would happen as per rules above?). Instead, we want a 2D array where a cell is randomly either alive (1) or dead (0). The function that we used for such cases previously was randint() from random library. NumPy has a function with the same name but which returns an array of random integer numbers instead of a single one. Read documentation for np.random.randint() function and write a code to generate a 2D 5×3 array filled randomly with 0s and 1s. Hint, you will need to specify the named size parameter for this. Experiment with size and the highest number parameters.

Implement the code in a cell in a Jupyter Notebook.

20.5 From 2D to 3D

State of our cells is represented by a 2D NumPy array but how do we visualize this? We could create a Rect stimulus for each cell and draw them in a loop based on a state of the cell. However, there is a better and faster way to achieve same end by turning a NumPy array into an ImageStim. You have used it before by supplying a file name to the file but it also accepts a 3D NumPy array of size [width, height, 3] that defines the image in the RGB or some other color space.

Thus, we can take our NumPy array of cells, turn it into an image that spans the entire window, and draw it. However, for this we need a [grid width, grid height, 3] array but we only have [grid width, grid height]. To get the former, we can just replicate the same array three times and stack it into a 3D representation. The function you need is np.repeat(), you need to repeat you original array three times along the third axis (axis=2, rows are axis=0, columns are axis=1, so stacking 2D arrays is along the third axis). Experiment with the code looking at what happens when you use other axis values or repeat different number of times. Check it visually but also check the .shape of the array.

In addition, for the default RGB color space in PsychoPy values go from -1 (black) to 1 (white). However, in our array we have zeros and ones. Thusm while repeating array, you also need to rescale it, so values go from -1 to 1.

Implement the code in a cell in a Jupyter Notebook.

20.6 Drawing cells

Create a new class (I called it CellCulture) that will take care of cells. It should be a descendant of the ImageStim. We need to implement two methods. First, the constructor. It should have a parameter for PsychoPy window (something you definitely need to pass to the ancestor’s constructor) and the grid size (either as a tuple or as two width and height parameters, your choice). It should generate a random 2D array (as you already implemented in Jupyter Notebook) and store it in an attribute cells. Then, you need to call the ancestor’s constructor, passing PsychoPy window and setting size so that the image spans the entire window (what should it size be, given "normal" units?). This way, although each cell is a single pixel in the image, we stretch it and turn pixels into squares. Note that you do not need to specify the actual image in the constructor, as we will make a function for that!

The second function should take .cells 2D array, turn it into 3D RGB array (you just implemented that code in the Jupyter notebook) and assign it to the .image attribute of the class. Call this function array2img (or something along these lines) and call it in the constructor after the ancestor constructor was called.

In the main code, create an object of class CellCulture and draw it in the main game loop. Currently, it should be a static random patter of black and white squares.

Create CellCulture class and use it in the main code, drawing the cells. Put code in

20.7 Slicing a 2D array

Slicing for Numpy arrays is more powerful than for lists, see Indexing and slicing. However, for our intents and purposes, we only need to supply two arrays of indexes, one per each dimension to get the elements that we want.

You can use Python lists

x = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
## [[1 2 3]
##  [4 5 6]
##  [7 8 9]]
print(x[ (0, 1) , (0, 1)])
## [1 5]

or you can use NumPy arrays.

x = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
## [[1 2 3]
##  [4 5 6]
##  [7 8 9]]
print(x[ np.array((0, 1)) , np.array((0, 1))])
## [1 5]

However, the latter have an advantage that you can apply algebra operations to them such as addition, note the 1 + np.array().

x = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
## [[1 2 3]
##  [4 5 6]
##  [7 8 9]]
print(x[ 1 + np.array((0, 1)) , np.array((0, 1))])
## [4 8]

To count the neighbours, we need to sum over eight cells relative to the current one (see the image above). Create two index NumPy arrays that will have correct relative indexes in them, i.e., values should be -1 and -1 for the top-left corner, 0 and -1 for the top-middle cell, etc. Once you have them, you know indexes of all neighbours of a cell i, j, as you can simple add them to a corresponding index and center it on that cell.

In Jupyter notebook, create the relative index of eight neighbours and test it for cells in the 2D array that you created previously. For a moment, use only valid values of i and j which are not on the border and check that you do extract correct cells and that the total number of neighbours that you compute via sum is also correct.

Implement relative slicing code in a cell in a Jupyter Notebook.

20.8 Dealing with borders

So, what was your solution to deal with the border problem that I have described above? Mine is to simply pad the cell culture with an extra rows/columns of dead cells. Then, we count neighbours and modify state only of the inner cells (so starting with cells in the second row and column) and keep the outer cells permanently dead.

To this I create a 2D array of size [grid width + 2, grid height + 2] with the outer rows and columns set to zero. One way to do this, is to

  1. Create a grid width + 2 by grid height + 2 array of zeros,
  2. Generate a random array of size grid width by grid height
  3. Put the random (smaller) array into the inner part of the zeros (larger) array using slicing. I.e., just as with Python lists, you can write 1:-1 to slice through each dimension.

In the Jupyter notebook, write the new code that will generate the random cell culture padded with zeros.

Once the code is working, copy it into the constructor of the CellCulture class, so that .cells array is zero-padded. However, we do not want to show the padding, so also modify the array2img() method, so that it repeats not the entire .cells array but only the inner part (same slicing!).

Update CellCulture class by creating a zero-padded initial population and correct array-2-image conversion.

Test the code, use breakpoints if necessary.

20.9 Evolution

Now, let us make cells get born, live, and die. For this, you need to create an update() method for the CellCulture class. Inside, create a new temporary array grid width by grid height size, call it next_gen. Then, loop through all the cells of the current cell culture excluding the padding, count number of neighbours for each cell, and use rules above to determine whether a cell should be dead or alive on the next frame. Put the result into the corresponding in next_gen. Once you went through all the cells via nested for loops, copy the next_gen into into .cells (the same way you copied the initial random state) overwriting the current generation and update the .image. In the main experimental code, call .update() method on every frame and you should see your cell culture change.

Create update method in CellCulture and use it in the main loop, put it into

20.10 Pause

Add an ability to pause / continue the process via space button. Think how you can implement via a state variable or an attribute of the class.

Put the code into

20.11 Drawing

Add an ability to draw/erase cell via mouse. Left mouse button (index 0) should add a living cell at the location, whereas the right button (index 2) should put a dead cell. First, create a new method (I called it set) that takes x and y index of the cell and its new value (0 or 1) and puts it into the correct position (mind the zero-padding!) in the .cells. Next, compute the index of the cell based on the mouse position with the window and the size of the square. Use these coordinates and a button-based value to modify the cell.

Create set method in CellCulture. Use it together with the mouse control in the main loop, put it into

  1. See this guide as well↩︎