Danny Brown

A Blog on Code and Occasionally Other Things

Creating a GUI for Conway’s Game of Life Using Pygame and Numpy

Danny BrownJuly 10, 2022

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:

Posted In code | Python
Tagged algorithm | pygame

Post navigation

PreviousCoding Conway’s Game of Life with Python’s Standard Library
NextBuild a Simple Microsoft Teams Bot Easily, No SDK Required

Danny Brown

A Dev Blog with Some Tangents

About

Categories

  • code
    • APIs
    • Bash
    • CSS
    • Django
    • HTML
    • JavaScript
    • Python
    • S3
    • Selenium
    • Serverless
    • TypeScript
  • games
  • music
    • concert reviews
    • synthesizers
  • opinion
  • sports
  • tech
    • Bitbucket
    • Git
    • GitHub
    • MS Teams
    • WordPress
  • theater

Recent Posts

  • Open Pull Requests from the Terminal (One of My Favorite Dotfiles Scripts)
  • Dotfiles Script for a New TypeScript/Node Project
  • So I Told You to Go See a Broadway Play? Tips for Theater in New York
  • Build a Simple Microsoft Teams Bot Easily, No SDK Required
  • Creating a GUI for Conway’s Game of Life Using Pygame and Numpy

External Links

  • GitHub
  • LinkedIn

Recent Posts

  • Open Pull Requests from the Terminal (One of My Favorite Dotfiles Scripts)
  • Dotfiles Script for a New TypeScript/Node Project
  • So I Told You to Go See a Broadway Play? Tips for Theater in New York
  • Build a Simple Microsoft Teams Bot Easily, No SDK Required
  • Creating a GUI for Conway’s Game of Life Using Pygame and Numpy

Categories

  • code
    • APIs
    • Bash
    • CSS
    • Django
    • HTML
    • JavaScript
    • Python
    • S3
    • Selenium
    • Serverless
    • TypeScript
  • games
  • music
    • concert reviews
    • synthesizers
  • opinion
  • sports
  • tech
    • Bitbucket
    • Git
    • GitHub
    • MS Teams
    • WordPress
  • theater
Copyright © 2025. Danny Brown
Powered By WordPress and Meritorious