Game of Life

This program was my attempt recreate the Conway's Game of Life on the RaspberryPi Pico using Prof. Hunter Adam's VGA Library for the RaspberryPi Pico. The program starts with randomly placing alive and dead cells on a board and calculating the next generation of the based on the current generation. For this purpose, I used two boards, one to hold the current generation and the other to hold the next generation. Once the new generation is completely worked out, it is overwritten into the old board. The resources for the project include the C SDK User Guide, the RP2040 Datasheet and Prof. Hunter's website.


The complete code

/*
 * Parth Sarthi Sharma (pss242@cornell.edu)
 * Code based on examples from Raspberry Pi Foundation.
 * This code is an implementation of the Conway's Game of Life 
 * on the Raspberry Pi Pico.
 */
#include <stdio.h> //The standard C library
#include <math.h> //The standard math library
#include "pico/stdlib.h" //Standard library for Pico
#include "hardware/pio.h" //The hardware PIO library
#include "hardware/dma.h" //The hardware DMA library
#include "pico/time.h" //The pico time library
#include "hardware/gpio.h" //The hardware GPIO library
#include "Bitmaps.h" //The bitmaps library
#include "vga_graphics.h" //The graphics library

#define HEIGHT 480 //Height of the VGA screen
#define WIDTH 640 //Width of the VGA screen


volatile char GameBoard[HEIGHT / 4][WIDTH / 4], flag = 0; //The game board and the flag to synchronise changes made for the next generation and drawing the next generation
char NewBoard[HEIGHT / 4][WIDTH / 4]; //A copy of the game board to make changes to
unsigned int generation = 1; //The generation

int checkNeighbours(int x, int y){ //Check to see how many neighbours of a particular cell are alive
    return GameBoard[x - 1][y - 1] + GameBoard[x - 1][y] + GameBoard[x - 1][y + 1] + GameBoard[x][y - 1] + GameBoard[x][y + 1] + GameBoard[x + 1][y - 1] + GameBoard[x + 1][y] + GameBoard[x + 1][y + 1];
}

inline char randomRange(int min, int max){ //Function to generate a random number between min and max
    return (rand() % (max - min)) + min;
}

void core1_entry() { //The program running on core 1
    short i, j; //The iterators
    while(1){ //While eternity
        while(flag); //Wait here while flag is set
        for(i = (HEIGHT / 8); i < (HEIGHT / 4) - 1; i++){ //For the bottom half of the board
            for(j = 1; j < (WIDTH / 4) - 1; j++){ //For all elements from left to right
                if(GameBoard[i][j]){ //If the element is alive
                    fillRect(j << 2, i << 2, 4, 4, WHITE); //Draw the cell with white
                }
                else{ //If the element is dead
                    fillRect(j << 2, i << 2, 4, 4, BLACK); //Draw the cell with black
                }
            }
        }
        flag = 1; //Set the flag once done
    }
}

int main(){ //The program running on core 1
    short i, j; //The iterators
    stdio_init_all(); //Initialize all of the present standard stdio types that are linked into the binary
    initVGA(); //Initialize the VGA screen and functions

    srand(1); //Random seed
    //For all the cells, set the cells randomly to either alive or dead
    for(i = 1; i < (HEIGHT / 4) - 1; i++){
        for(j = 1; j < (WIDTH / 4) - 1; j++){
            GameBoard[i][j] = randomRange(0, 2);
        }
    }

    multicore_launch_core1(core1_entry); //Reset core1 and enter the core1_entry function on core 1 using the default core 1 stack

    while(1){ //While eternity
        while(!flag); //Wait here until flag is set
        printf("Generation: %u\n", generation); //Print the current generation
        generation++; //Increment the generation
        for(i = 1; i < (HEIGHT / 4) - 1; i++){ //For the top half of the board
            for(j = 1; j < (WIDTH / 4) - 1; j++){ //For all elements from left to right
                char neighbours = checkNeighbours(i, j); //Check the number of neighbours alive for the cell
                if(GameBoard[i][j]){ //If the current cell is alive
                    if(neighbours == 2 || neighbours == 3){ //If the number of alive neighbours is 2 or 3
                        NewBoard[i][j] = 1; //The cell lives
                    }
                    else{ //Else
                        NewBoard[i][j] = 0; //The cell dies
                    }
                }
                else{ //If the current cell is dead
                    if(neighbours == 3){ //If the number of alive neighbours is 3
                        NewBoard[i][j] = 1; //The cell comes alive
                    }
                    else{ //Else
                        NewBoard[i][j] = 0; //The cell remains dead
                    }
                }
            }
        }
        memcpy(GameBoard, NewBoard, 19200); //Copy the new board to the gameboard
        flag = 0; //Clear the flag
        for(i = 1; i < (HEIGHT / 8); i++){ //For the top half of the board
            for(j = 1; j < (WIDTH / 4) - 1; j++){ //For all elements from left to right
                if(GameBoard[i][j]){ //If the element is alive
                    fillRect(j << 2, i << 2, 4, 4, WHITE); //Draw the cell with white
                }
                else{ //If the element is dead
                    fillRect(j << 2, i << 2, 4, 4, BLACK); //Draw the cell with black
                }
            }
        }
    }
}


Code Organization

The code has a VGA library which has been explained really nicely by Prof. Adams and the main file. Most of the code is self explanatory, however, the main components of the game are explained in the following subsections.

The gameboards

I have initialized two game boards, GameBoard[][] and NewBoard[][]. The GameBoard holds the current state of the life to be drawn while the NewBoard is used to hold the next generation of the game. Once the next generation is figured out, it is overwritten into the old board using memcpy(GameBoard, NewBoard, 19200) where 19200 is the number of bytes to be transferred.

Setting up the board

In order to set up the board, I iterated over through all the cells and randomly set them to be either alive or dead. This is the first generation of the board and signifies the initial state of the system.

Note: The generation of the board is pseudo-random. This means that even though it may seem random, it will always initialize in the same state every time the program is reset.

for(i = 1; i < (HEIGHT / 4) - 1; i++){
    for(j = 1; j < (WIDTH / 4) - 1; j++){
        GameBoard[i][j] = randomRange(0, 2);
    }
}


Rules of Game of Life

According to Wikipedia, the universe of the Game of Life is an infinite, two-dimensional orthogonal grid of square cells, each of which is in one of two possible states, live or dead, (or populated and unpopulated, respectively). Every cell interacts with its eight neighbours, which are the cells that are horizontally, vertically, or diagonally adjacent. At each step in time, the following transitions occur:

The initial pattern constitutes the seed of the system. The first generation is created by applying the above rules simultaneously to every cell in the seed, live or dead; births and deaths occur simultaneously, and the discrete moment at which this happens is sometimes called a tick. Each generation is a pure function of the preceding one. The rules continue to be applied repeatedly to create further generations.

I these exact set of rules to calculate the next generation in my implementation.

for(i = 1; i < (HEIGHT / 4) - 1; i++){
    for(j = 1; j < (WIDTH / 4) - 1; j++){
        char neighbours = checkNeighbours(i, j);
        if(GameBoard[i][j]){
            if(neighbours == 2 || neighbours == 3){
                NewBoard[i][j] = 1;
            }
            else{
                NewBoard[i][j] = 0;
            }
        }
        else{
            if(neighbours == 3){
                NewBoard[i][j] = 1;
            }
            else{
                NewBoard[i][j] = 0;
            }
        }
    }
}


Drawing the boards

Drawing the boards is the most computationally expensive part of the implementation. Drawing the board using just 1 core takes about 1100ms whereas all the other computations take about 81ms. Therefore, I equally divided the task of drawing the board between the two cores.

//Core 0
for(i = 1; i < (HEIGHT / 8); i++){
    for(j = 1; j < (WIDTH / 4) - 1; j++){
        if(GameBoard[i][j]){
            fillRect(j << 2, i << 2, 4, 4, WHITE);
        }
        else{
            fillRect(j << 2, i << 2, 4, 4, BLACK);
        }
    }
}

//Core 1
for(i = (HEIGHT / 8); i < (HEIGHT / 4) - 1; i++){
    for(j = 1; j < (WIDTH / 4) - 1; j++){
        if(GameBoard[i][j]){
            fillRect(j << 2, i << 2, 4, 4, WHITE);
        }
        else{
            fillRect(j << 2, i << 2, 4, 4, BLACK);
        }
    }
}


The output

The video shows the implementation of Conway's Game of Life on the RaspberryPi Pico.

Conway's Game of Life on RaspberryPi Pico

CMakeLists.txt

cmake_minimum_required(VERSION 3.13)

include(pico_sdk_import.cmake)

project(GameOfLife-project)

pico_sdk_init()

add_executable(GameOfLife)

pico_enable_stdio_usb(GameOfLife 1)
pico_enable_stdio_uart(GameOfLife 1)

pico_generate_pio_header(GameOfLife ${CMAKE_CURRENT_LIST_DIR}/hsync.pio)
pico_generate_pio_header(GameOfLife ${CMAKE_CURRENT_LIST_DIR}/vsync.pio)
pico_generate_pio_header(GameOfLife ${CMAKE_CURRENT_LIST_DIR}/rgb.pio)

target_sources(GameOfLife PRIVATE GameOfLife.c vga_graphics.c)

target_link_libraries(GameOfLife PRIVATE pico_stdlib hardware_pio hardware_dma hardware_adc hardware_irq pico_time pico_multicore)

pico_add_extra_outputs(GameOfLife)