Conway's Game of Life is a popular mathematical simulation that illustrates how a system can evolve through time using a simple set of rules.
While the game has practical applications in computer science and biology, it also creates a visualization that is captivating to watch as complex shapes and patterns emerge within its ecosystem.
This project demonstrates how to build a version of Conway's Game of Life on an Adafruit PyPortal. In addition to displaying the game, the project features touch controls that enable users to actively influence the game's evolution.
Game of LifeThe Game of Life is a zero-player game that takes place on a 2D grid composed of cells that are either "live" or "dead".
A new game begins by configuring the grid with an initial layout of cell states, which can be randomly generated or defined by the user.
Once the grid is set, users interact with the game solely by observing how it autonomously progresses according to its predetermined rules.
During each game step, or "generation", the state of each cell is updated based on the states of its "neighbors", the eight cells adjacent to it.
The following rules determine if a cell lives or dies:
- Any live cell with two or three live neighbors remains live.
- Any dead cell with exactly three live neighbors becomes live.
- All other live cells die, and all other dead cells remain dead.
These rules are simultaneously applied to all cells on the game board, updating the entire grid and marking the completion of a single generation.
This process repeats indefinitely, with each new generation building on the last, resulting in a visual representation of a dynamically evolving ecosystem.
Users can observe the continuous growth, contraction, migration, and stabilization of live cells throughout the progression of the game.
Intricate patterns and structures, such as oscillators and spaceships, often emerge as the game unfolds, showcasing the system's emergent behaviors.
See Conway's Game of Life Wikipedia article for an extended explanation of the game's rules and examples of common patterns.
How It WorksThe main hardware component used in this project is the Adafruit PyPortal, which features a 3.2" color TFT display with a resistive touch screen and an Arduino-compatible microcontroller.
An Arduino sketch containing the project code is uploaded to the PyPortal and automatically executed as soon as the board is powered on.
The sketch begins by configuring the game settings, including the board size and display colors, and defines 2D arrays to store the cell states of the grid.
After the initial setup is complete, the sketch enters a repeating loop. During each iteration, it applies the game's logic to the cells, updates the game board display, and handles user touchscreen events.
Users are presented with an 8x8 game board, populated with random cell states, where live cells are represented by black, and dead cells by white.
The game immediately starts autonomously playing through its generations, which the code sets to complete at a rate of ten generations per second.
A wrap-around feature is implemented, allowing cells on the edges of the board to interact with cells on opposite edges as neighbors. This produces a more captivating visualization by concentrating and encouraging cell activity.
Touch controls can be used to change the state of any cell to live by simply touching it on the display. This allows users to directly influence the game, as updated cells are incorporated into the game's live ecosystem.
While a touch is held down, the game pauses, and the user can drag their finger across the board to activate multiple cells to draw lines and shapes. The game resumes playing once the touch is released.
The PyPortal is mounted inside a desktop stand for easy accessibility. With this setup users can watch along as the game plays out endlessly, observing cell patterns naturally emerge and introducing new ones through touch.
Project SetupBefore proceeding with the project build steps below, users should familiarize themselves with the PyPortal and complete the Arduino IDE Setup found in the Adafruit learning guide.
This setup includes adding the PyPortal to the Arduino IDE board manager, installing the required Adafruit Libraries, and running a demo sketch to test the PyPortal's functionality.
The following project instructions assume starting with a BareMinimum
Arduino sketch, which can be found within the IDE's built-in examples.
A completed project sketch can be found on GitHub.
Initialize DisplayThe project requires drawing the game's graphical elements on the PyPortal's display repeatedly over the lifetime of the game. To accomplish this, the display must first be initialized within the sketch.
1. Include the Adafruit_ILI9341.h
library at the top of the project's sketch:
#include "Adafruit_ILI9341.h"
This library provides additional code, developed and released by Adafruit, that is used to interact with the PyPortal's ILI9341 TFT-LCD display.
2. Define the board pins utilized by the display:
// Pins settings for PyPortal tft-lcd display
#define TFT_D0 34 // Data bit 0 pin (MUST be on PORT byte boundary)
#define TFT_WR 26 // Write-strobe pin (CCL-inverted timer output)
#define TFT_DC 10 // Data/command pin
#define TFT_CS 11 // Chip-select pin
#define TFT_RST 24 // Reset pin
#define TFT_RD 9 // Read-strobe pin
#define TFT_BACKLIGHT 25 // Backlight
The TFT display on the PyPortal board is connected using an 8-bit interface. These lines specify the pins used for data and control by the interface, along with a pin dedicated to controlling the display's backlight.
3. Create a display object:
// Pyportal display object
Adafruit_ILI9341 tft = Adafruit_ILI9341(tft8bitbus, TFT_D0, TFT_WR, TFT_DC, TFT_CS, TFT_RST, TFT_RD);
An instance of the Adafruit_ILI9341
object class is created, using the display pins as inputs. This object includes various methods for managing the display's settings and manipulating the screen output.
4. Initialize the display:
// Initialize display
tft.begin();
tft.setRotation(3);
tft.fillScreen(ILI9341_BLACK);
The display object initializes the display and sets its rotation to landscape mode, positioning the origin in the corner opposite the PyPortal's USB socket.
A predefined 16-bit color value representing black is then set to fill the screen, serving as the background for all game graphics to be drawn upon.
5. Turn on the display's backlight:
// Turn on display backlight
pinMode(TFT_BACKLIGHT, OUTPUT);
digitalWrite(TFT_BACKLIGHT, HIGH);
This step is necessary for the PyPortal, as it does not have an automatic backlight power-on feature when running Arduino sketches. Enabling the backlight is essential for viewing any graphics that are added to the display.
Game Board SetupWithin the sketch, the game board is modeled as a 2D array of numbers that stores the state of each cell. Array values of either one or zero are used to represent the live and dead cell states, respectively.
The array is dynamically sized to fit the game board to the display, and its values are randomized to produce the initial layout of cell states for the game.
1. Define the display dimensions by adding the following code at the top of the sketch above the setup()
function:
// Display dimensions
int screen_width = 320;
int screen_height = 240;
In landscape mode, PyPortal's display dimensions are 320x240 pixels.
2. Calculate the game board rows and columns:
// Game board rows and columns
int cell_size = 10;
int num_rows = (screen_height - 1) / cell_size;
int num_cols = (screen_width - 1) / cell_size;
The maximum size of the game board that can be fit on the display is determined by the dimensions of the screen and the size of each cell.
Here, the size of a single cell is set to 10 pixels, which is divided into the screen width and height to calculate the number of rows and columns that the board can accommodate.
Users have the flexibility to edit this cell size, enabling them to increase or decrease the number of cells included in the game board.
3. Initialize the 2D game arrays:
// Declare pointers for 2D arrays
int** game;
int** temp_game;
Two arrays are utilized to process the gameplay. The game
array stores the current state board to be displayed, while the temp_game
array stores a temporary board to which the game rules are applied to each round.
Both arrays are declared as pointers to integer pointers, instead of arrays with predefined dimensions, as the rows and columns are calculated at runtime and not available when the sketch is compiled.
4. Add the following initGame()
function below the setup()
function:
void initGame() {
// Create game and temp_game arrays
// based on number of rows and cols
game = new int*[num_rows];
temp_game = new int*[num_rows];
for(int i = 0; i < num_rows; i++) {
game[i] = new int[num_cols];
temp_game[i] = new int[num_cols];
}
// Randomize initial cell states
for (int i=0; i<num_rows; i++) {
for (int j=0; j<num_cols; j++) {
game[i][j] = random(2);
}
}
}
5. Then call this function from within the setup()
function:
// Initialize game arrays
initGame();
The initGame()
function completes initializing the 2D game arrays by dynamically allocating the memory required by the arrays based on the number of rows and columns calculated for the game board.
Rows exist as arrays of pointers, where each element points to an integer array of column values for that row. Array elements can be accessed and modified through their row and column index, such as in any other 2D array.
The function proceeds to loop through the rows and columns of the game
array and randomly assigns each integer element a value of one or zero. This produces a randomized initial pattern of live and dead cells on the board.
The game board is visually depicted on the display as a grid of squares, separated by grid lines. Each square represents a single cell and is colored black or white to indicate the live or dead state of the cell.
1. Calculate the game board dimensions and offsets by adding the following code above the setup()
function:
// Game board dimensions and offsets
int board_width = cell_size * num_cols;
int board_height = cell_size * num_rows;
int x_offset = (screen_width - board_width) / 2;
int y_offset = (screen_height - board_height) / 2;
The width and height of the game board in pixels are calculated from the defined cell size and the number of rows and columns on the board.
Depending on the cell size, the game board may not have the same dimensions as the display. To improve the visual appearance, the board is centered on the display by calculating and applying offsets in both directions.
2. Define the display colors:
// Game board display colors
int live_color = ILI9341_BLACK;
int dead_color = ILI9341_WHITE;
int grid_color = ILI9341_LIGHTGREY;
Predefined 16-bit color values for black, white, and grey are used to define the display colors of the live cells, dead cells, and grid lines, respectively.
3. Add the drawGame()
function at the end of the sketch:
void drawGame() {
// Draw game grid
for (int i = 0; i < num_rows + 1; i++) {
tft.drawLine(
x_offset,
i * cell_size + y_offset,
x_offset + board_width,
i * cell_size + y_offset,
grid_color
);
}
for (int i = 0; i < num_cols + 1; i++) {
tft.drawLine(
i * cell_size + x_offset,
y_offset,
i * cell_size + x_offset,
y_offset + board_height,
grid_color
);
}
// Draw game cells
for (int i = 0; i < num_rows; i++) {
for (int j = 0; j < num_cols; j++) {
// Get cell color
int color = (game[i][j] == 1) ? live_color : dead_color;
// Draw cell rect
tft.fillRect(
j * cell_size + x_offset + 1,
i * cell_size + y_offset + 1,
cell_size - 1,
cell_size - 1,
color
);
}
}
}
4. Then call this function from within the loop()
function:
// Draw game on display
drawGame();
The drawGame()
function is responsible for drawing the game board and its current game state on the display.
First, the function utilizes the display object's drawLine()
method to individually draw each horizontal and vertical grid line of the board.
Next, it loops through the rows and columns of the game
array and draws a square for each game cell using the display object's fillRect()
method.
A square's display position is calculated from its row and column array index, along with the cell size and offsets, and its color is determined by the corresponding integer value stored in the array for that specific cell.
With this build step complete, users can upload the sketch to the PyPortal, which will display the game board and its initial randomized layout. Resetting the PyPortal will produce a newly randomized game board.
Advance the GameThe Game of Life rules are applied to each cell on the game board, resulting in the creation of a new generation of cell states, which then get displayed.
This process is repeated multiple times per second, generating continuous cell updates and producing an animated game board for the user to observe.
1. Add the following countNeighbors()
function at the end of the sketch.
int countNeighbors(int x, int y) {
// Return number of neighbors for input x,y position,
// calculated using edge-wrapping
int count = 0;
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
if (i == 0 && j == 0) {
continue;
}
int newX = (x + i + num_rows) % num_rows;
int newY = (y + j + num_cols) % num_cols;
count += game[newX][newY];
}
}
return count;
}
Cells are determined by the rules to be live or dead with each new generation based on how many live neighbors surround the cell.
The countNeighbors()
function accepts a grid cell position as input and returns the number of live neighbors for that cell by counting how many adjacent positions have a value of one in the game
array.
When counting, the function implements edge wrapping, meaning that cells on the game board edges consider cells on the opposite edge as neighbors.
2. Add the following stepGame()
function at the end of the sketch:
void stepGame() {
// Reset temp game array
for (int i = 0; i < num_rows; i++) {
for (int j = 0; j < num_cols; j++) {
temp_game[i][j] = 0;
}
}
// Compute temp game updates
for (int i = 0; i < num_rows; i++) {
for (int j = 0; j < num_cols; j++) {
int neighbors = countNeighbors(i, j);
if (game[i][j] == 1) {
if (neighbors < 2 || neighbors > 3) {
temp_game[i][j] = 0;
} else {
temp_game[i][j] = 1;
}
} else {
if (neighbors == 3) {
temp_game[i][j] = 1;
} else {
temp_game[i][j] = 0;
}
}
}
}
// Copy updates to game array
for (int i = 0; i < num_rows; i++) {
for (int j = 0; j < num_cols; j++) {
game[i][j] = temp_game[i][j];
}
}
}
3. Call this function by adding the following at the top of the loop()
function:
// Step game forward
stepGame();
delay(100);
The stepGame()
function applies the game rules to the current cell states stored in the game
array, updating the array values to represent the newly calculated generation.
To achieve this, the temp_game
array is utilized to temporarily store the updated cell states as they are being determined one at a time. This ensures the new generation is calculated from only the current cell state values.
The function begins by first resetting the temp_game
array values to zero. Then, it loops through each row and column position in the game
array and inputs the position into the countNeighbors()
function.
Next, the game's rule logic is applied based on the number of live neighbors for the given cell position. A value indicating its updated state, zero or one, is stored in the temp_game
array at that position.
Once all the updated states have been calculated and stored in the temp_game
array, the values of temp_game
are copied into the game
array.
Uploading the sketch after completing this build step will display the animated game board, which calculates and displays the game's latest generation 10 times per second.
Touchscreen SetupTouch controls allow the user to interact with the game by touching any cell to activate it as live and incorporating this change into the game's environment.
To implement this feature, the sketch must first be set up to access touch events generated by the PyPortal's resistive touchscreen.
1. Include the following library at the top of the sketch:
#include "TouchScreen.h"
The TouchScreen.h
library provides additional code to interface with touchscreens connected to Arduino-compatible boards, such as the PyPortal.
2. Define the pins utilized by the touchscreen before the setup()
function:
// Touchscreen Pins
#define YD A7
#define XR A6
#define YU A5
#define XL A4
Four pins are required to provide a voltage gradient across the two resistive plates that comprise the touchscreen, as well as to read the voltages when touch pressure is applied to the screen.
This pin configuration is specific to the landscape display mode being used.
3. Define a touchscreen object:
// Touchscreen object
TouchScreen ts = TouchScreen(XP, YP, XM, YM, 300);
An instance of the Touchscreen
object class is created using the touchscreen pins as parameters. Additionally, a fifth parameter, specified in ohms, is used to calibrate the touch sensitivity of the screen.
4. In the sketch's setup()
function add the following code:
// Initialize serial communication
Serial.begin(9600);
This line establishes communication between the sketch and the serial console, enabling the printing of messages and values for the user to view.
5. At the top of the loop()
function, collect touch point data:
// Get touch point
TSPoint p = ts.getPoint();
On each iteration of the loop, the touchscreen object's getPoint()
method retrieves a TSPoint
object containing the current touch values obtained from the touchscreen.
6. Process the touch point data:
// Handle touch detected
if (p.z > ts.pressureThreshhold) {
// Print touch coordinate
Serial.print("Touch detected: ");
Serial.print("X = "); Serial.print(p.x);
Serial.print("\tY = "); Serial.println(p.y);
}
// Handle touch not detected
else {
}
The touchpoint object includes X, Y, and Z values measuring the touch forces being applied to the touchscreen.
X and Y values are analog voltage readings that are relative to the position on the screen where the touch is occurring, while the Z value is a reading of the touch pressure on the screen.
A touch event is triggered when the user touches the screen with sufficient force to raise the Z value above a predetermined threshold. The sketch checks for this condition and prints the X and Y values when it occurs.
If no touch event is triggered then the script currently takes no action. This condition will be expanded on in further steps.
After uploading and running the sketch, users can touch anywhere on the PyPortal's touchscreen and view the X and Y values printed to the console.
Touchscreen CalibrationIn order to identify the precise grid cell being touched by the user, it is necessary to first convert the X and Y touch values into display pixel coordinates.
Prior to performing this conversion, a calibration process is conducted to account for the display's unique characteristics and ensure accurate results.
1. Add default calibration values above the setup()
function:
// Touchscreen calibrations
#define TS_MINX 133
#define TS_MINY 840
#define TS_MAXX 927
#define TS_MAXY 140
2. With the sketch uploaded and running from the previous step, touch the top-left corner of the touchscreen and copy and paste the X and Y values from the serial console as the TS_MINX
and TS_MINY
values.
3. Touch the bottom-right corner of the screen and copy and paste the X and Y values from the serial console as the TS_MAXX
and TS_MAXY
values.
The X and Y values of the touch event correspond to analog voltage readings, ranging in value from 0 to 1024, and are directly proportional to the touch position along each axis of the screen.
For example, a touch on the screen's left edge should produce an X value of 0, while a touch on the right edge should produce an X value of 1024.
However, due to manufacturing tolerances and other factors, there are often variations in the actual minimum and maximum voltage readings, which need to be compensated for through calibration.
These calibration values establish the minimum and maximum voltage readings measured by the user's touchscreen, enabling more accurate touch position detection compared to an uncalibrated system.
4. Add the following code inside the touch detected condition within the loop()
function:
// Calculate touch pixel coordinates
int x = map(p.x, TS_MINX, TS_MAXX, 0, screen_width);
int y = map(p.y, TS_MINY, TS_MAXY, 0, screen_height);
// Print touch pixel coordinate
Serial.print("Touch detected: ");
Serial.print("X Pixel = "); Serial.print(x);
Serial.print("\tY Pixel = "); Serial.println(y);
The x
and y
pixel coordinates of the touch are calculated by mapping the X and Y voltage readings within the calibrated range of minimum and maximum voltages, relative to the screen size in each dimension.
Upload and run the sketch after adding this code to see the pixel coordinate printed out to the console with each touch.
With accurate calibration values, the pixel coordinates should range between 0 and 320 (screen width) for X and between 0 and 240 (screen height) for Y.
Activate Game CellThe pixel coordinates of the touch are processed to determine if they fall within the boundaries of the game board. If they do, the grid coordinates of the specific touched cell are calculated.
These grid coordinates are then used to update the corresponding array of cell states, setting the touched cell as live.
1. Add the following code block below where the pixel coordinates are calculated inside the loop()
function :
// Check if touch was on game board
x = x - x_offset;
y = y - y_offset;
if (x >= 0 && x <= board_width && y >= 0 && y <= board_height) {
// Calculate touched cell position
int row = map(y, 0, board_height, 0, num_rows);
int col = map(x, 0, board_width, 0, num_cols);
// Print touched cell position
Serial.print("Touch detected: ");
Serial.print("Row = "); Serial.print(row);
Serial.print("\tCol = "); Serial.println(col);
}
The offset values used to center the game board on the screen are subtracted from the x
and y
pixel coordinates. A check is then performed to see if the coordinates exist inside the game board dimensions.
If a touch event occurs on the game board, the pixel coordinates are mapped to row
and column
grid coordinates, using the board size in each dimension and the number of rows or columns in that dimension.
2. Update the state of the selected cell:
// Activate touched cell
game[row][col] = 1;
The calculated grid coordinates are used as indexes in the game
array, where the indexed value is set to 1, indicating a live state for that cell.
This updated state will be processed along with all the other cell states when the stepGame()
function is called during the current iteration of the loop.
Upload and run the sketch with this latest code to interact with the game. With each touch, users will see the selected cell change to black, indicating it is live, and instantly be incorporated into the ongoing gameplay.
The serial print statements included in the sketch up until this point are no longer required and can be deleted past this step.
Activate Multiple CellsTo enhance gameplay interactions, a touch-and-hold feature is implemented, allowing users to activate multiple cells simultaneously.
While the touch is maintained on the screen, the game remains paused, resuming only when the touch is released.
This feature enables users to draw lines and shapes of live cells by dragging their finger across the display, introducing more complex gameplay dynamics.
1. Define the following variables above the setup()
function:
// Touch tracking
bool touch_active = false;
int release_threshold = 10;
int release_count = 0;
Pausing and resuming the game requires the sketch to track if a touch event is currently active, and how long it has been since the last touch was released.
A threshold defines how many sketch loop iterations must be executed without touch detected to consider a previously initiated touch to be released.
2. Add the following code inside the loop()
function, below where the grid coordinates are calculated:
// Set touch as active
if (touch_active == false) {
touch_active = true;
Serial.println("Touch Started");
}
// Reset release count
release_count = 0;
When a touch event occurs within the game board, the touch_active
variable is set to true to indicate that the display is currently being pressed by the user, but only if it was previously false.
Additionally, the release_count
variable is reset to 0, effectively restarting the count of how many iterations the touch has been released for.
3. Add the following code block inside the loop()
function, within the empty else
conditional used to handle when no touch event occurs:
// Set touch as not active if released
if (touch_active == true) {
if (release_count >= release_threshold) {
touch_active = false;
Serial.println("Touch Ended");
} else {
release_count++;
}
}
This code block is executed when the sketch detects no touch event, indicating the user is not currently touching the display.
The release_count
is examined if the touch_active
variable is true, and is checked to determine if the count has exceeded the release_threshold
.
If the count is above the threshold, it signifies the touch has been released long enough to be considered ended, and touch_active
is set to false
.
Alternatively, if the count is below the threshold, it is incremented by one.
4. In the loop()
function, wrap the setGame()
and delay()
calls with the following conditional:
// Step game forward if no touch detected
if (touch_active == false) {
stepGame();
delay(80);
}
With this update, the game will not step forward if there is an active touch, and only step forward if there is no touch occurring.
This effectively pauses the game for as long as the user touches the display and then resumes it once the touch is released for long enough.
Upload and run the sketch on the PyPortal to use the touch-and-hold feature. Users can now drag their finger on the display to draw patterns of live cells and observe how they evolve once the game resumes.
Wrap UpAfter completing these instructions, the PyPortal can be mounted inside a desktop stand and displayed as an interactive art piece.
Once powered, the game will run indefinitely, providing a captivating and ever-changing visualization. The system of live cells will grow, contract, and migrate across the game board in mesmerizing patterns for the user to watch.
The game may reach a stable state of static or oscillating cell patterns. If this occurs, users can reinvigorate it by using the touch controls to add live cells to the game board, initiating new chains of cellular interaction.
The completed project script can be found on GitHub.
Comments
Please log in or sign up to comment.