I wrote previously about coding the backend for Conway’s Game of Life with Python’s standard library. Unsatisfied with the relatively boring console output, I sought a GUI to give this simulation more visual appeal. We’ll be editing a copy of the same file we ended with in my last post.
I’ve never used Pygame
before, but I found this tutorial from Better Programming that seemed to cover exactly what I needed. Much of the Pygame
-specific code in this post is mostly only stylistically changed from that blog post, and I’m grateful to the author Keno Leon for sharing their knowledge on this topic.
Using Numpy
There are a couple of reasons we should pip install numpy
and replace some of our code with it: 1) speed optimizations since so much of its code is calculated in C rather than Python; 2) Pygame
works well with Numpy so it will simplify things down the line.
We can change our grid randomization from this:
SIZE = 17 grid = [[random.choice([0, 1]) for _ in range(SIZE)] for _ in range(SIZE)]
To this:
import numpy as np SIZE = 17 # Make a game grid STARTING_GRID = np.random.randint(2, size=(SIZE, SIZE))
Another benefit is that this is probably more readable as well.
If we just replace our code and run the script, it still works out of the box, which is nice. Probably more “Numpythonic” (if that is indeed a thing) is to change our coordinates from a grid[row][col]
syntax to grid[row, col]
. In our script, we can run a find and replace on ][
to ,
that does this in one fell swoop, replacing the 11 instances of this syntax. Running the script again still works, so we’re on the right track.
Creating the Grid in Pygame
We’ll start with a function adapted from the above-linked post to build a game grid based on the SIZE
constant set at the beginning of the file.
import pygame SCREENSIZE = WIDTH, HEIGHT = 1200, 800 BLACK = (0, 0, 0) GREY = (160, 160, 160) PADDING = PADTOPBOTTOM, PADLEFTRIGHT = 60, 60 # GLOBAL VARS, Using a Dictionary. VARS = { 'surf': False, 'grid_wh': 400, 'grid_origin': (200, 100), 'grid_cells': STARTING_GRID.shape[0], 'line_width': 2, } def draw_grid(origin, grid_wh, cells): CONTAINER_WIDTH_HEIGHT = grid_wh cont_x, cont_y = origin # DRAW Grid Border: # TOP LEFT TO RIGHT pygame.draw.line( VARS['surf'], BLACK, (cont_x, cont_y), (CONTAINER_WIDTH_HEIGHT + cont_x, cont_y), VARS['line_width'] ) # # BOTTOM lEFT TO RIGHT pygame.draw.line( VARS['surf'], BLACK, (cont_x, CONTAINER_WIDTH_HEIGHT + cont_y), (CONTAINER_WIDTH_HEIGHT + cont_x, CONTAINER_WIDTH_HEIGHT + cont_y), VARS['line_width'] ) # # LEFT TOP TO BOTTOM pygame.draw.line( VARS['surf'], BLACK, (cont_x, cont_y), (cont_x, cont_y + CONTAINER_WIDTH_HEIGHT), VARS['line_width'] ) # # RIGHT TOP TO BOTTOM pygame.draw.line( VARS['surf'], BLACK, (CONTAINER_WIDTH_HEIGHT + cont_x, cont_y), (CONTAINER_WIDTH_HEIGHT + cont_x, CONTAINER_WIDTH_HEIGHT + cont_y), VARS['line_width'] ) # Get cell size, just one since its a square grid. cellSize = CONTAINER_WIDTH_HEIGHT/cells # VERTICAL DIVISIONS: (0,1,2) for grid(3) for example for x in range(cells): pygame.draw.line( VARS['surf'], BLACK, (cont_x + (cellSize * x), cont_y), (cont_x + (cellSize * x), CONTAINER_WIDTH_HEIGHT + cont_y), 2 ) # HORIZONTAl DIVISIONS pygame.draw.line( VARS['surf'], BLACK, (cont_x, cont_y + (cellSize*x)), (cont_x + CONTAINER_WIDTH_HEIGHT, cont_y + (cellSize*x)), 2 )
Updating main() to Work with Pygame
To run this, we’ll have to also update our main()
loop to do some Pygame
initialization and rendering. Note also that we’ve moved our code that checks the grid and updates for the next generation into a separate function named process_generation()
.
def process_generation(grid, gen_count): """Copy current grid, iterate over original, make changes to copy""" # Working grid to track next generation for simultaneous changes next_grid = grid.copy() # Iterate over current grid, make changes to working grid for row in range(0, SIZE): for col in range(0, SIZE): alive_next_time = check_rules(grid, row, col) if alive_next_time: next_grid[row, col] = 1 else: next_grid[row, col] = 0 return next_grid, gen_count + 1 def main(): pygame.init() VARS['surf'] = pygame.display.set_mode(SCREENSIZE) grid = STARTING_GRID.copy() # Start the game gen_count = 1 while True: VARS['surf'].fill(GREY) draw_grid(VARS['grid_origin'], VARS['grid_wh'], VARS['grid_cells']) grid, gen_count = process_generation(grid, gen_count) pygame.display.update() sleep(0.25)
With these changes, we get a square grid equal to SIZE
rendered when we run the script:
Adding a Way to Quit
We can quit from the console by pressing ctrl
+C
, but it would be nicer to be able to just type Q
on the keyboard to exit directly from the Pygame
window. Add this function and then insert a call to it at the beginning of our while True
loop in main()
.
import sys from pygame.locals import KEYDOWN, K_q def check_events(): for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() elif event.type == KEYDOWN and event.key == K_q: pygame.quit() def main(): ... while True: check_events() ....
Now we can exit from the Pygame
window by pressing Q
.
Displaying the Population Results on the Grid
We’re going to include two functions. The first populates a cell with a black square:
def draw_square_cell(x, y, dim_x, dim_y): """Draw filled rectangle at coordinates""" pygame.draw.rect( VARS['surf'], BLACK, (x, y, dim_x, dim_y), )
The second function uses the first one to place black squares on each of the cells that need one in the grid:
def place_cells(grid): # GET CELL DIMENSIONS... cell_border = 6 celldim_x = celldim_y = ( (VARS['grid_wh'] / VARS['grid_cells']) - (cell_border*2) ) # DOUBLE LOOP for row in range(grid.shape[0]): for column in range(grid.shape[1]): # Is the grid cell tiled ? if grid[column, row] == 1: draw_square_cell( VARS['grid_origin'][0] + (celldim_y*row) + cell_border + (2*row*cell_border) + VARS['line_width']/2, VARS['grid_origin'][1] + (celldim_x*column) + cell_border + (2*column*cell_border) + VARS['line_width']/2, celldim_x, celldim_y )
Then we can update our main()
function to call this right after drawing the grid:
def main(): ... while True: ... draw_grid(VARS['grid_origin'], VARS['grid_wh'], VARS['grid_cells']) place_cells(grid) ...
At this point we have a working visualization of the algorithm:
Adding the Generation Count
Just for fun, let’s add text that includes the generation count so we can see how long it takes until the grid reaches a repeating loop or constant value.
First we add pygame.font.init()
to our main()
function after the pygame.init()
:
def main(): pygame.init() pygame.font.init() ....
Then we add the following function to our code to write the generation count:
def write_generation_count(gen_count): my_font = pygame.font.SysFont("Verdana", 30) return my_font.render(f"Generation {gen_count}", False, (0, 0, 0))
And finally, we update our main()
function one last time to blit
this to the appropriate place on the screen. The way I figured out the right placement here is good old-fashioned trial-and-error. Perhaps Pygame
veterans have a better way.
def main(): ... while True: check_events() VARS['surf'].fill(GREY) text_surface = write_generation_count(gen_count) VARS['surf'].blit(text_surface, (199,60)) ...
Full Code and Final Output GIF
Here is the full code for this updated algorithm:
""" Conway's Game of Life implemented with a Pygame-based GUI Author: Danny Brown """ import random import os from typing import List, Tuple from time import sleep import sys import numpy as np import pygame from pygame.locals import KEYDOWN, K_q SIZE = 17 # Make a game grid STARTING_GRID = np.random.randint(2, size=(SIZE, SIZE)) SCREENSIZE = WIDTH, HEIGHT = 1200, 800 BLACK = (0, 0, 0) GREY = (160, 160, 160) PADDING = PADTOPBOTTOM, PADLEFTRIGHT = 60, 60 # GLOBAL VARS, Using a Dictionary. VARS = { 'surf': False, 'grid_wh': 400, 'grid_origin': (200, 100), 'grid_cells': STARTING_GRID.shape[0], 'line_width': 2, } def write_generation_count(gen_count: int): my_font = pygame.font.SysFont("Verdana", 30) return my_font.render(f"Generation {gen_count}", False, (0, 0, 0)) def check_events(): for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() elif event.type == KEYDOWN and event.key == K_q: pygame.quit() def draw_grid(origin, grid_wh, cells): CONTAINER_WIDTH_HEIGHT = grid_wh cont_x, cont_y = origin # DRAW Grid Border: # TOP lEFT TO RIGHT pygame.draw.line( VARS['surf'], BLACK, (cont_x, cont_y), (CONTAINER_WIDTH_HEIGHT + cont_x, cont_y), VARS['line_width'] ) # # BOTTOM lEFT TO RIGHT pygame.draw.line( VARS['surf'], BLACK, (cont_x, CONTAINER_WIDTH_HEIGHT + cont_y), (CONTAINER_WIDTH_HEIGHT + cont_x, CONTAINER_WIDTH_HEIGHT + cont_y), VARS['line_width'] ) # # LEFT TOP TO BOTTOM pygame.draw.line( VARS['surf'], BLACK, (cont_x, cont_y), (cont_x, cont_y + CONTAINER_WIDTH_HEIGHT), VARS['line_width'] ) # # RIGHT TOP TO BOTTOM pygame.draw.line( VARS['surf'], BLACK, (CONTAINER_WIDTH_HEIGHT + cont_x, cont_y), (CONTAINER_WIDTH_HEIGHT + cont_x, CONTAINER_WIDTH_HEIGHT + cont_y), VARS['line_width'] ) # Get cell size, just one since its a square grid. cellSize = CONTAINER_WIDTH_HEIGHT/cells # VERTICAL DIVISIONS: (0,1,2) for grid(3) for example for x in range(cells): pygame.draw.line( VARS['surf'], BLACK, (cont_x + (cellSize * x), cont_y), (cont_x + (cellSize * x), CONTAINER_WIDTH_HEIGHT + cont_y), 2 ) # # HORIZONTAl DIVISIONS pygame.draw.line( VARS['surf'], BLACK, (cont_x, cont_y + (cellSize*x)), (cont_x + CONTAINER_WIDTH_HEIGHT, cont_y + (cellSize*x)), 2 ) def draw_square_cell(x, y, dim_x, dim_y): """Draw filled rectangle at coordinates""" pygame.draw.rect( VARS['surf'], BLACK, (x, y, dim_x, dim_y), ) def place_cells(grid: List[List[int]]): # GET CELL DIMENSIONS... cell_border = 6 celldim_x = celldim_y = ( (VARS['grid_wh'] / VARS['grid_cells']) - (cell_border*2) ) # DOUBLE LOOP for row in range(grid.shape[0]): for column in range(grid.shape[1]): # Is the grid cell tiled ? if grid[column, row] == 1: draw_square_cell( VARS['grid_origin'][0] + (celldim_y*row) + cell_border + (2*row*cell_border) + VARS['line_width']/2, VARS['grid_origin'][1] + (celldim_x*column) + cell_border + (2*column*cell_border) + VARS['line_width']/2, celldim_x, celldim_y ) def get_neighbor_count(grid: List[List[int]], row: int, col: int) -> int: """For each of 8 neighbors for cell, check alive status.""" alive = 0 # get upper left rown = row - 1 if row - 1 is not -1 else SIZE - 1 coln = col - 1 if col - 1 is not -1 else SIZE - 1 if grid[rown, coln]: alive += 1 # get above rown = row - 1 if row - 1 is not -1 else SIZE - 1 if grid[rown, col]: alive += 1 # get upper right rown = row - 1 if row - 1 is not -1 else SIZE - 1 coln = col + 1 if col + 1 is not SIZE else 0 if grid[rown, coln]: alive += 1 # get left coln = col - 1 if col - 1 is not -1 else SIZE - 1 if grid[row, coln]: alive += 1 if alive > 3: return alive # get right coln = col + 1 if col + 1 is not SIZE else 0 if grid[row, coln]: alive += 1 if alive > 3: return alive # get lower left rown = row + 1 if row + 1 is not SIZE else 0 coln = col - 1 if col - 1 is not -1 else SIZE - 1 if grid[rown, coln]: alive += 1 if alive > 3: return alive # get below rown = row + 1 if row + 1 is not SIZE else 0 if grid[rown, col]: alive += 1 if alive > 3 or alive is 0: return alive # get bottom right rown = row + 1 if row + 1 is not SIZE else 0 coln = col + 1 if col + 1 is not SIZE else 0 if grid[rown, coln]: alive += 1 return alive def check_rules(grid: List[List[int]], row: int, col: int) -> bool: """Checks rules and returns True if cell should be alive next gen""" live_neighbors = get_neighbor_count(grid, row, col) if live_neighbors is 3: return True elif grid[row, col] and live_neighbors is 2: return True else: return False def process_generation(grid: List[List[int]], gen_count: int) -> Tuple[List[List[int]], int]: """Copy current grid, iterate over original, make changes to copy""" # Working grid to track next generation for simultaneous changes next_grid = grid.copy() # Iterate over current grid, make changes to working grid for row in range(0, SIZE): for col in range(0, SIZE): cell = grid[row, col] alive_next_time = check_rules(grid, row, col) if alive_next_time: next_grid[row, col] = 1 else: next_grid[row, col] = 0 return next_grid, gen_count + 1 def main(): pygame.init() pygame.font.init() VARS['surf'] = pygame.display.set_mode(SCREENSIZE) grid = STARTING_GRID.copy() # Start the game gen_count = 1 while True: check_events() VARS['surf'].fill(GREY) text_surface = write_generation_count(gen_count) VARS['surf'].blit(text_surface, (199,60)) draw_grid(VARS['grid_origin'], VARS['grid_wh'], VARS['grid_cells']) place_cells(grid) grid, gen_count = process_generation(grid, gen_count) pygame.display.update() sleep(.25) if __name__ == "__main__": main()
And here is one more GIF of the final product with generation counter: