Learning objectives
In this code along, we will build on what we learned in a previous code along about two-dimensional arrays and the preceding code along on graphics to improve on our visualization of a Game of Life board. We will first write some code to read a board from a file to prevent having to manually enter cells. We will then learn how to use some existing code for drawing to store an image corresponding to a Game of Life board.
Visualizing a single board will prepare us to draw a collection of boards in the next code along, when we will implement the Game of Life and visualize a collection of boards over multiple generations as an animated mp4 video.
Code along summary
Setup
To complete this code along and the next code along on implementing the Game of Life, we will need starter code.
We provide this starter code as a single download game_of_life.zip. Download this code here, and then expand the folder. Move the resulting game_of_life directory into your python/src source code directory.
The game_of_life directory has the following structure:
boards: a directory containing.csvfiles that represent different initial Game of Life board states (more on this directory shortly).output: an initially empty directory that will contain images and mp4 videos that we draw.datatypes.py: a file that will contain type declarations (more to come shortly).functions.py: a file that will contain functions that we will write in the next code along for implementing the Game of Life.custom_io.py: a file that will contain code for reading in a Game of Life board from file.drawing.py: a file that will contain code for drawing a Game of Life board to a file.main.py: a file that will containdef main(), where we will call functions to read in Game of Life boards and then draw them.
Similar to the preceding code along, you will use the pygame package to draw the board.
Declaring a GameBoard type
We already know that we conceptualize a Game of Life board as a two-dimensional list of boolean variables, in which True denotes that a cell is alive, and False denotes that it is dead. When implementing the Game of Life in the next code along, we will need to store such a board for each of a number of generations. The result of the simulation will therefore have type list[list[list[bool]]], which is a three-dimensional list of boolean variables.
Do not worry if you find this tricky to conceptualize. Rather, think about the end result of the Game of Life as providing us with a list of game boards, where each game board is a two-dimensional list of boolean variables.
Fortunately, Python provides a way to implement this abstraction. In datatypes.py, we will add the following type declaration that establishes a new GameBoard type as an alias; that is, we can use GameBoard wherever we might otherwise use a two-dimensional list of booleans.
# GameBoard is a two-dimensional list of boolean variables # representing a single generation of a Game of Life board. GameBoard = list[list[bool]]
Once we have added this code, we can use GameBoard in place of list[list[bool]] whenever we are referring to a Game of Life board. As a result, the Game of Life simulation that we will complete in the next code along will produce a variable of type list[GameBoard], a list of GameBoard objects.
We will say much more about the transition toward abstracting variables as objects in our next chapter. For now, let’s learn how to read in a GameBoard from file and then draw it.
Reading in a Game of Life board from file
In the previous code along, we entered the cells of a Game of Life board manually, an approach that will not work for larger boards. Instead, let’s write a function to read a Game of Life board from file. Note that the boards directory contains three comma-separated files.
dinnerTable.csv: the period-12 dinner table oscillator that we encountered in the main text.rPentomino.csv: the five-cell R Pentomino pattern that produces surprisingly complex behavior, including the production of six gliders.gosperGun.csv: the “Gosper gun” that manufactures gliders at a constant rate.
We reproduce the animations of these three initial patterns below according to the above order.



Open and examine one of the files in boards. Each row of the file contains a single line of comma-separated integers representing a row of the board, with alive cells represented by 1 and dead cells represented by 0.

We will write a function read_board_from_file() in custom_io.py that takes as input a string filename, reads in a file from the location specified in filename, and then returns a GameBoard representing the automaton specified in the file according to the above mentioned format.
In custom_io.py, we will introduce and use the following functions and expressions, which we will cover in more detail shortly.
open(filename, 'r'): Opens the file in read mode, giving back a file object.with ... as f: Ensures the file is closed automatically after use (safe practice).f.read(): Reads the entire contents of the file into a single string..strip(): Removes any extra spaces or newlines at the start and end..splitlines(): Splits the file into separate lines, making a list of strings (one string per row of the board).
At the top of the file, you should see:
from datatypes import GameBoard
This allows us to access the GameBoard alias that we defined in datatypes in datatypes.py.
The function read_board_from_file() reads in a CSV file like the ones we have, which represent a Game of Life board. It will return the corresponding GameBoard board.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
# to be implemented...
Inside our function, we need to open the file so we can read its contents. The function call open(filename, 'r') as f opens the file having name filename in read mode ('r'), and it returns a file object called f. We place the word with before the call to open(), which means that we will indent the code immediately following the open() statement as a block. when the block is complete, Python automatically closes the file, even if something goes wrong, which is considered good practice because it prevents resource leaks.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
# to be implemented...
Next, we call the function f.read() to store the entire contents of the file into a string, which we will call giant_string.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
giant_string = f.read()
# to be implemented...
This concludes the with block, which will close the file, since we have the file’s contents stored in giant_string.
We then call the function giant_string.strip() to return a new string with any leading or trailing white space removed and store it in the variable trimmed_giant_string.
Then we apply the function trimmed_giant_string.splitlines() to split trimmed_giant_string into a list of strings lines, where each element of lines is a string corresponding to a single line of the file.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
giant_string = f.read()
# trim any space at the start or end of file
trimmed_giant_string = giant_string.strip()
# split the long string into multiple strings, one for each line
lines = trimmed_giant_string.splitlines()
# to be implemented...
We know that each line in the file corresponds to a single row in our desired GameBoard, and so we can go ahead and declare this GameBoard, which we will initialize as board (which is an empty list). To do so, we use the declaration board: GameBoard = [].
We will also add a return board statement to the bottom of the function, leaving the middle of the function remaining, where we will read through lines and set the elements of board.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
giant_string = f.read()
# trim any space at the start or end of file
trimmed_giant_string = giant_string.strip()
# split the long string into multiple strings, one for each line
lines = trimmed_giant_string.splitlines()
board: GameBoard = [] # declare an empty GameBoard
# fill in...
return board
Next, we loop over the elements of lines, each of which is a list current_line consisting of 0 and 1 values separated by commas. We split current_line by commas using .split(','), which produces a list of strings, where each string is either "0" or "1", representing a dead or alive cell in the Game of Life, respectively.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
# first, convert the whole file to a string
giant_string = f.read()
# trim any space at the start or end of file
trimmed_giant_string = giant_string.strip()
# split the long string into multiple strings, one for each line
lines = trimmed_giant_string.splitlines()
board = []
# we will iterate over each line, parse the data in each line, and build the board
for current_line in lines:
line_elements = current_line.split(',')
# line_elements contains a list of strings, one for each element in the row, i.e., a bunch of "0" and "1" strings
# to fill in
return board
In the spirit of modularity and avoiding unnecessary nested loops, we will set the values of the current row of board by passing line_elements into a subroutine, which we call set_row_values(). This function takes as input a string representing a line of a comma-separated Game of Life, and it returns a list of boolean values corresponding to parsing the line, where "1" is parsed as True and "0" is parsed as False.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
# first, convert the whole file to a string
giant_string = f.read()
# trim any space at the start or end of file
trimmed_giant_string = giant_string.strip()
# split the long string into multiple strings, one for each line
lines = trimmed_giant_string.splitlines()
board = []
# we will iterate over each line, parse the data in each line, and build the board
for current_line in lines:
line_elements = current_line.split(',')
# line_elements contains a list of strings, one for each element in the row, i.e., a bunch of "0" and "1" strings
new_row = set_row_values(line_elements)
board.append(new_row)
return board
Finally, after we read in the board, let’s have a quick sanity check asserting that it is rectangular.
def read_board_from_file(filename: str) -> GameBoard:
"""
Reads a CSV file representing a Game of Life board.
Each element in the file is either "1" (alive) or "0" (dead).
The file is parsed into a GameBoard data structure.
Parameters:
- filename (str): The name of the CSV file to read.
Returns:
- GameBoard: A board where True represents alive and False represents dead.
"""
if not isinstance(filename, str) or len(filename) == 0:
raise ValueError("filename must be a non-empty string.")
with open(filename, 'r') as f:
# first, convert the whole file to a string
giant_string = f.read()
# trim any space at the start or end of file
trimmed_giant_string = giant_string.strip()
# split the long string into multiple strings, one for each line
lines = trimmed_giant_string.splitlines()
board = []
# we will iterate over each line, parse the data in each line, and build the board
for current_line in lines:
line_elements = current_line.split(',')
# line_elements contains a list of strings, one for each element in the row, i.e., a bunch of "0" and "1" strings
new_row = set_row_values(line_elements)
board.append(new_row)
return board
We now write set_row_values(), which first makes a list current_row of boolean variables having length equal to the number of elements in line_elements. It then loops over line_elements, appending the corresponding value of current_row equal to False if the corresponding value of line_elements is "0", and True if the corresponding value of line_elements is "1".
def set_row_values(line_elements: list[str]) -> list[bool]:
"""
Converts a list of "0"/"1" strings into a list of booleans.
Parameters:
- line_elements (list[str]): A list of strings containing "0" or "1".
Returns:
- list[bool]: A row where "0" is False and "1" is True.
"""
if not isinstance(line_elements, list) or len(line_elements) == 0:
raise ValueError("line_elements must be a non-empty list of '0'/'1' strings.")
current_row = []
# range over elements of lineElement and convert them to booleans
for val in line_elements:
if val == '0':
current_row.append(False)
elif val == '1':
current_row.append(True)
else:
raise ValueError("Error: invalid entry in board file.")
return current_row
Drawing a Game of Life board
In drawing.py, let’s write a function draw_game_board() that takes as input a GameBoard object board in addition to an integer cell_width. This function will create a pygame.Surface object named surface, a virtual canvas in which each cell is assigned a square that is cell_width pixels wide and tall. The function then returns surface, which can later be rendered to display the board image.
At the top of drawing.py, we will import pygame and the GameBoard datatype that we created. We will also import two functions count_rows() and count_cols() that we will write in functions.py shortly for determining the dimensions of a board.
import pygame # for drawing on a surface object from datatypes import GameBoard # our GameBoard datatype from functions import count_rows, count_cols
Next, we will set the height of the surface (in pixels) equal to the number of rows in board times cell_width, and the width of the canvas equal to the number of columns in board times cell_width, using the count_rows() and count_cols() subroutines.
def draw_game_board(board: GameBoard, cell_width: int) -> pygame.Surface:
"""
Draw a single GameBoard as a pygame.Surface.
Args:
board (GameBoard): A 2D list of booleans representing the game state
(True = alive cell, False = dead cell).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
pygame.Surface: A surface with the GameBoard visually rendered,
where alive cells are drawn as white squares and dead cells remain background-colored.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
width = count_cols(board) * cell_width
height = count_rows(board) * cell_width
# to fill in
Then, we will call pygame.Surface((width, height)) from the pygame package to create a new pygame.Surface object called surface that is width pixels wide and height pixels tall.
At the end of our function, we will eventually return the pygame.Surface object associated with our canvas.
def draw_game_board(board: GameBoard, cell_width: int) -> pygame.Surface:
"""
Draw a single GameBoard as a pygame.Surface.
Args:
board (GameBoard): A 2D list of booleans representing the game state
(True = alive cell, False = dead cell).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
pygame.Surface: A surface with the GameBoard visually rendered,
where alive cells are drawn as white squares and dead cells remain background-colored.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
width = count_cols(board) * cell_width
height = count_rows(board) * cell_width
# create a drawing surface
surface = pygame.Surface((width, height))
# to fill in
return surface
Next, we will declare two color variables, one for a dark gray color (RGB: (60, 60, 60)) and one for white (RGB: (255, 255, 255)). After doing so, we will call surface.fill() to fill the entire canvas with dark gray.
def draw_game_board(board: GameBoard, cell_width: int) -> pygame.Surface:
"""
Draw a single GameBoard as a pygame.Surface.
Args:
board (GameBoard): A 2D list of booleans representing the game state
(True = alive cell, False = dead cell).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
pygame.Surface: A surface with the GameBoard visually rendered,
where alive cells are drawn as white squares and dead cells remain background-colored.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
width = count_cols(board) * cell_width
height = count_rows(board) * cell_width
# create a drawing surface
surface = pygame.Surface((width, height))
# declare some color variables
dark_gray = (60, 60, 60)
white = (255, 255, 255)
# set the background color of the board to gray
surface.fill(dark_gray)
# to fill in
return surface
We are now ready to range over every cell in board and color a given cell white if it is alive. We don’t need to take any action if the cell is dead, since the color of dead cells will be the same as the background color.
Although we are ardent followers of modularity, in this particular case, we don’t mind using a nested for loop to range over board is in order, since our code will be short. After ranging i over the rows of board and j over the columns of board, we will draw a rectangle at the position on the canvas corresponding to board[i][j]. The question is precisely where on the canvas we should draw this rectangle.
def draw_game_board(board: GameBoard, cell_width: int) -> pygame.Surface:
"""
Draw a single GameBoard as a pygame.Surface.
Args:
board (GameBoard): A 2D list of booleans representing the game state
(True = alive cell, False = dead cell).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
pygame.Surface: A surface with the GameBoard visually rendered,
where alive cells are drawn as white squares and dead cells remain background-colored.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
width = count_cols(board) * cell_width
height = count_rows(board) * cell_width
# create a drawing surface
surface = pygame.Surface((width, height))
# declare some color variables
dark_gray = (60, 60, 60)
white = (255, 255, 255)
# set the background color of the board to gray
surface.fill(dark_gray)
# fill in the colored squares
for i in range(len(board)):
for j in range(len(board[0])):
# only draw the cell if it's alive, since it would equal background color
if board[i][j]:
# draw the cell at the appropriate location. But where?
return surface
Let us recall for a moment how we conceptualize arrays. As shown in the figure below on the left, the rows of an array extend downward from the top left, and the columns of the array extend rightward from the top left. This is similar to the standard computer graphics approach (reproduced below on the right), except that the rows increase in proportion to y-coordinates, and the columns increase in proportion to x-coordinates. Therefore, although we are tempted to think that an element a[r][c] of an array will be drawn at a position (x, y), where x depends on r and y depends on c, the opposite is true: x depends on c, and y depends on r.


We now know that to draw the rectangle associated with board[i][j], the minimum x-value will be the product of j, the column index, and cell_width; the minimum y-value will be the product of i, the row index, and cell_width. We define the rectangle’s left and top coordinates to be x and y, and the added width and height are both cell_width because we are drawing a square. We are now ready to finish draw_game_board().
def draw_game_board(board: GameBoard, cell_width: int) -> pygame.Surface:
"""
Draw a single GameBoard as a pygame.Surface.
Args:
board (GameBoard): A 2D list of booleans representing the game state
(True = alive cell, False = dead cell).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
pygame.Surface: A surface with the GameBoard visually rendered,
where alive cells are drawn as white squares and dead cells remain background-colored.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
width = count_cols(board) * cell_width
height = count_rows(board) * cell_width
# create a drawing surface
surface = pygame.Surface((width, height))
# declare some color variables
dark_gray = (60, 60, 60)
white = (255, 255, 255)
# set the background color of the board to gray
surface.fill(dark_gray)
# fill in the alive squares
for i in range(len(board)):
for j in range(len(board[0])):
if board[i][j]:
x = j * cell_width
y = i * cell_width
pygame.draw.rect(surface, white, (int(x), int(y), cell_width, cell_width))
# return the surface with the board drawn on it
return surface
Two seemingly simple subroutines, and an introduction to assertion functions
We now just need to write the subroutines count_rows() and count_cols(), which we will place in functions.py because they are general purpose functions not specific to drawing. Then, we will be ready to write some code in def main() to read in a Game of Life board using read_board_from_file() and draw it using draw_game_board().
We can count the number of rows in board by accessing len(board), and we can count the number of columns in board by accessing the length of the first row, len(board[0]). In count_cols(), we make sure to check that board doesn’t have zero rows, so that when we access board[0], we know that it exists.
STOP: What issue do you see that might arise with these two functions?
def count_rows(board: GameBoard) -> int:
"""
Count the number of rows in a GameBoard.
Args:
board (GameBoard): A 2D list of booleans representing the game state.
Returns:
int: Number of rows in the board.
"""
if not isinstance(board, list):
raise ValueError("board must be a list.")
return len(board)
def count_cols(board: GameBoard) -> int:
"""
Count the number of columns in a GameBoard.
Args:
board (GameBoard): A 2D list of booleans representing the game state.
Returns:
int: Number of columns in the board.
Raises:
ValueError: If the board is not rectangular.
"""
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
if len(board) == 0:
raise ValueError("Error: no rows in GameBoard.")
return len(board[0])
Although count_rows() always correctly the number of rows in a two-dimensional list, recall from our creation of a triangular list in the previous code along that a two-dimensional list does not necessarily have the same number of elements in each row.
We could make our code more robust in one of two ways. First, we could return the maximum length of any row of board. However, the strategy that we will take is to revise count_cols() so that it only returns a value if board is rectangular. To do so, at the beginning of count_cols(), we will call a subroutine assert_rectangular(), which takes board as input. This assertion function does not return anything, but it raises an exception if board has no rows, or if when we range over the rows of board, any row has length that is not equal to the length of the initial row of board.
def count_cols(board: GameBoard) -> int:
"""
Count the number of columns in a GameBoard.
Args:
board (GameBoard): A 2D list of booleans representing the game state.
Returns:
int: Number of columns in the board.
Raises:
ValueError: If the board is not rectangular.
"""
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
assert_rectangular(board)
return len(board[0])
def assert_rectangular(board: GameBoard) -> None:
"""
Ensure that a GameBoard is rectangular.
Args:
board (GameBoard): A 2D list of booleans representing the game state.
Raises:
ValueError: If the board has no rows or if its rows are not of equal length.
"""
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
# range over rows and ensure that every row has same length (i.e., every row has the length of the first row).
first_row_length = len(board[0])
for row in range(1, len(board)):
if len(board[row]) != first_row_length:
raise ValueError("Error: GameBoard is not rectangular.")
Note: Halting the program is a strict decision to make for a non-rectangular board. In practice, we might make different design choices, such as providing a message to the user.
Running our code, and improving our automaton visualization
In main.py, we will add the following import statements. In the next code along, we will add some more import statements that will allow us to create a video out of pygame surfaces.
import pygame from custom_io import read_board_from_file from drawing import draw_game_board # returns a pygame.Surface # in the next codealong we will add more imports
In def main(), we will first read a board from file. Let’s read the simplest board, the R pentomino, which is stored in boards/rPentomino.csv. We will then set our cell_width parameter to 20 pixels, and call draw_game_board() on rPentomino and cell_width.
At the top, we will initialize all pygame modules using pygame.init(), and below the rest of the code we will shut down all the modules with pygame.quit().
def main():
"""Read a board CSV, render it, and save to PNG."""
pygame.init()
# read the board from file
r_pentomino = read_board_from_file("boards/rPentomino.csv")
cell_width = 20
surface = draw_game_board(r_pentomino, cell_width)
pygame.quit()
The reference to the pygame.Surface object that we generated is stored in a variable called surface, which we want to write to a file to view. First, we declare a string filename, which we will set to be equal to "output/rPentomino.png", so that our image will be written into the output directory.
We then call pygame.image.save(surface, filename), which ensures that the image is saved to that file.
def main():
pygame.init()
# read the board from file
r_pentomino = read_board_from_file("boards/rPentomino.csv")
cell_width = 20
surface = draw_game_board(r_pentomino, cell_width)
# Save surface as PNG
filename = "output/rPentomino.png"
pygame.image.save(surface, filename)
pygame.quit()
STOP: In a new terminal window, navigate into our directory usingcd python/src/game_of_life. Then run your code by executingpython3 main.py(macOS/Linux) orpython main.py(Windows).
As a result of running our code, you should see a file named rPentomino.png appear in the output folder, shown in the figure below.

Improving our automaton visualization
Let’s add a little bit of flavor to our automaton drawing, by drawing the cells as circles. First, recall the innermost block of draw_game_board(), reproduced below.
# only draw the cell if it's alive
if board[i][j]:
x = j * cell_width
y = i * cell_width
rect = pygame.Rect(int(x), int(y), cell_width, cell_width)
pygame.draw.rect(surface, white, rect)
If board[i][j] is alive, then this code draws a white circle centered in the cell. The top-left corner of the cell is at (j * cell_width, i * cell_width), so to center the circle inside the cell, we add cell_width / 2 to both the x and y coordinates. The radius of the circle is cell_width / 2 so that it exactly fits within the cell.
We are now ready to replace the innermost if statement in draw_game_board() with the following code:
if board[i][j]:
# set coordinates of the cell's center
x = j * cell_width + cell_width / 2
y = i * cell_width + cell_width / 2
pygame.draw.circle(surface, white, (int(x), int(y)), int(cell_width/2))
After running our code once again, we obtain the illustration below. The circles are nice, but the cells touch, which will make the cells tricky to view when we transition to animations.

Let’s reduce the radius of the circle by a bit by multiplying it by a scaling factor, and defining these variables outside of the for-loops to avoid recomputing the values. We adjust draw_game_board() as follows.
def draw_game_board(board: GameBoard, cell_width: int) -> pygame.Surface:
"""
Draw a single GameBoard as a pygame.Surface.
Args:
board (GameBoard): A 2D list of booleans representing the game state
(True = alive cell, False = dead cell).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
pygame.Surface: A surface with the GameBoard visually rendered,
where alive cells are drawn as white circles and dead cells remain background-colored.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(board, list) or len(board) == 0:
raise ValueError("board must be a non-empty 2D list.")
width = count_cols(board) * cell_width
height = count_rows(board) * cell_width
# create a drawing surface
surface = pygame.Surface((width, height))
# declare some color variables
dark_gray = (60, 60, 60)
white = (255, 255, 255)
# set the background color of the board to gray
surface.fill(dark_gray)
scaling_factor = 0.8
radius = scaling_factor * cell_width / 2
# fill in the alive squares
for i in range(len(board)):
for j in range(len(board[0])):
if board[i][j]:
x = j * cell_width + cell_width / 2
y = i * cell_width + cell_width / 2
pygame.draw.circle(surface, white, (int(x), int(y)), int(radius))
# return the surface with the board drawn on it
return surface
Running our main.py code this time produces the following image.

Now that draw_game_board() is improved, we are ready to draw more complicated boards. For example, run your code after changing def main() as follows to draw the dinner table oscillator, which is shown in the figure below on the left.
def main():
pygame.init()
# read the board from file
dinner_table = read_board_from_file("boards/dinnerTable.csv")
cell_width = 20
surface = draw_game_board(dinner_table, cell_width)
# Save surface as PNG
filename = "output/dinnerTable.png"
pygame.image.save(surface, filename)
pygame.quit()
STOP: Run your code once again after reading in the Gosper gun (gosperGun.csv) to draw the Gosper gun shown in the figure below on the right.


Drawing multiple Game of Life boards
Now that we have written a function to draw a single board, we will also write a draw_game_boards() function in drawing.py that takes a list of GameBoard objects boards and an integer cell_width as input and that returns a list of pygame.Surface objects corresponding to calling draw_game_board() on each GameBoard in boards. This function will come in handy in the next code along, when we will write a function returning a list of GameBoard variables as a result of simulating the Game of Life over multiple generations, and after calling draw_game_boards() on this list, we will generate an animated video showing the changes in the automaton over time.
def draw_game_boards(boards: list[GameBoard], cell_width: int) -> list[pygame.Surface]:
"""
Draw multiple GameBoard objects as pygame.Surface images.
Args:
boards (list[GameBoard]): A list of GameBoard objects,
where each board is a 2D grid of booleans (True = alive, False = dead).
cell_width (int): Pixel width of each cell in the drawing.
Returns:
list[pygame.Surface]: A list of pygame.Surface objects,
each corresponding to the rendering of one GameBoard.
"""
if not isinstance(cell_width, int) or cell_width <= 0:
raise ValueError("cell_width must be a positive integer.")
if not isinstance(boards, list) or len(boards) == 0:
raise ValueError("boards must be a non-empty list of GameBoard objects.")
result = []
for board in boards:
result.append(draw_game_board(board, cell_width))
return result
Looking ahead
Now that we can visualize the Game of Life, we are ready to implement the automaton, so that we can form multiple generations for any initial game board. Join us to do so in the next code along!