Introduction
Conway’s Game of Life was created by John Horton Conway. The idea is simple: you have a grid of cells that can either be alive
or dead
. In every iteration, a new grid is made from the current grid by following a set of rules based on a cell’s neighbors. Since this is a grid, there are 8 possible neighbors when diagonals are included. The rules are the following:
- Any live cell with less than two neighbors dies.
- Any live cell with two or three neighbors stays alive.
- Any live cell with more than three neighbors dies.
- Any dead cell with three live neighbors becomes a live cell.
The rules are simple, but the results can be mesmerizing. However, there are more reasons to care about Conway’s Game of Life than the fact that it is nice to look at. For example, it has been shown that a turing machine can be simulated with it. It is also proof of how seemingly incredibly complex phenomena can appear from straightforward rules.
However, for this blog post, we only care about the results looking nice.
Goal
The goal is to code Conway’s Game of Life in C++ and visualize it with raylib. Why raylib? Great question, but I don’t have a great answer. Any library that allows the programmer to draw rectangles would have worked fine. I chose raylib for this particular project because I wanted to try it out, and I knew that it could be compiled to WASM for a web build with emscripten.
If you want to see the web version of the final product, you can see it here.
Implementing Conway’s Game of Life
To start implementing Conway’s Game of Life (CGoL), randomly initialize a matrix with true or false values.
// matrix
const std::size_t N = 100;
bool curState[N][N];
// set up random number generator
std::default_random_engine generator;
generator.seed(time(NULL));
std::uniform_real_distribution<float> distribution(0.0, 1.0);
// populate the matrix
for (std::size_t y = 0; y < N; ++y) {
for (std::size_t x = 0; x < N; ++x) {
curState[y][x] = distribution(generator) >= 0.5f;
}
}
The state needs to be updated for every iteration. A common mistake in implementations of CGoL is to loop through curState
and update values in curState
. The correct implementation is to update a new matrix. If you update curState
, then the live neighbor counts for each cell are incorrect.
// list of all neighbors
const std::pair<int, int> NEIGHBORS[] = {
{-1,-1},{-1,0},{-1,1},{0,-1},
{0,1},{1,-1},{1,0},{1,1}
};
// matrix to update to
bool nextState[N][N];
// loop through matrix
for(int y = 0; y < N; ++y) {
for (int x = 0; x < N; ++x) {
// count neighbors
int liveNeighbors = 0;
for (auto& n : NEIGHBORS) {
const int nx = x + n.first;
const int ny = y + n.second;
if (nx >= 0 && nx < N && ny >= 0 && ny < N) {
liveNeighbors += curState[ny][nx];
}
}
// rules 1-4 simplified
if (curState[y][x]) {
nextState[y][x] = (liveNeighbors == 2 || liveNeighbors == 3);
} else {
nextState[y][x] = (liveNeighbors == 3);
}
}
}
Here are a few notes for the above implementation that may be helpful:
NEIGHBORS
has the neighbors in all 8 directions.- The new matrix is called as
nextState
. - Counting the neighbors requires a bounds check for the new
x
andy
coordinates. Otherwise, the coordinates can be less than zero or greater than the size of the matrix.
This is all you need to implement one iteration of CGoL. If you want to run more, you need to update curState
to the current value of nextState
. You can do this by running two loops. Or you can be a bit more clever. The full implementation below tries to do the latter. It uses std::swap
to swap the matrices; memory is constant, and the swap is much more efficient.
#pragma once
#include <cstddef>
#include <random>
const std::pair<int, int> NEIGHBORS[] = {
{-1,-1},{-1,0},{-1,1},{0,-1},
{0,1},{1,-1},{1,0},{1,1}
};
template <std::size_t N>
class Conway {
public:
Conway() {
// create random matrix for Conway's game of life
std::default_random_engine generator;
generator.seed(time(NULL));
std::uniform_real_distribution<float> distribution(0.0, 1.0);
for (std::size_t y = 0; y < N; ++y) {
for (std::size_t x = 0; x < N; ++x) {
curState[y][x] = distribution(generator) >= 0.5f;
}
}
};
void step() {
for(int y = 0; y < N; ++y) {
for (int x = 0; x < N; ++x) {
int liveNeighbors = 0;
for (auto& n : NEIGHBORS) {
const int nx = x + n.first;
const int ny = y + n.second;
if (nx >= 0 && nx < N && ny >= 0 && ny < N) {
liveNeighbors += curState[ny][nx];
}
}
if (curState[y][x]) {
nextState[y][x] = (liveNeighbors == 2 || liveNeighbors == 3);
} else {
nextState[y][x] = (liveNeighbors == 3);
}
}
}
std::swap(curState, nextState);
};
const bool cellIsActive(const std::size_t row, const std::size_t col) const {
return curState[row][col];
};
private:
bool curState[N][N];
bool nextState[N][N];
};
All this should go into a header file. The name I went with is conway.hpp
.
Visualizing with Raylib
typedef struct State {
float time;
Conway<GRID_SIZE> conway;
} State;
The State
struct is going to be passed to two functions (update
and render
). Hopefully, Conway
makes sense, but time
may not. time
will be explained in a little bit, so for now, ignore it. Instead, let’s focus on rendering the the alive cells.
void render(const State& state) {
// screen dimensions
const float W = (float) GetScreenWidth();
const float H = (float) GetScreenHeight();
// Figure out where the grid should be drawn based on the dimensions
const float min = std::min(H, W);
const float grid_length = 0.8f* min;
float startX;
float startY;
if (H > W) {
startX = 0.1f*min;
startY = (H - grid_length) / 2;
} else {
startY = 0.1f*min;
startX = (W - grid_length) / 2;
}
// Calculate size of a grid cell
const float cell_dimension = grid_length / GRID_SIZE;
// Draw the grid
ClearBackground(BLACK);
for(std::size_t y = 0; y < GRID_SIZE; ++y) {
float y_pos = startY + y*cell_dimension;
for(std::size_t x = 0; x < GRID_SIZE; ++x) {
if (state.conway.cellIsActive(y, x)) {
DrawRectangle(
startX + x*cell_dimension,
y_pos,
cell_dimension - 1, // we don't want cells connecting
cell_dimension - 1,
RED
);
}
}
}
}
This function is doing two things. At the top, it figures out where to draw a square grid on the screen based on the dimensions. It uses GetScreenWidth
and GetScreenHeight
to get the screen’s dimensions. Then, it finds which is smaller and uses that. The grid should only take 80% of the minimum screen dimension; this way, there is space. I found 80% by playing around with the number until I found something that I thought looked best. After, it defines the start coordinates for x
and y
. Finally, it finds the cell dimension, which results in rendering squares that fit the designated screen space. (I tried allowing for rectangles, but it looked terrible.)
Next, render
draws the grid. It starts by clearing the whole screen. Then, it iterates through the grid, which is curState
. The definition for cellIsActive
is above. If the cell is active, it is drawn. One is subtracted from both dimensions so that the drawn squares don’t connect. Again, this just came down to preference. I thought that the separation made for a better-looking visualization. The same reason is why I went with RED
and not GREEN
or some custom color.
void update(State& state) {
state.time += GetFrameTime();
if (state.time > 0.075f) {
state.time = 0;
state.conway.step();
}
}
Now that we can draw the grid, the next problem is to update
it. This is where time
comes into play. A valid implementation would be to run state.conway.step()
every frame. However, doing this makes for an incredibly busy and hard-to-understand visualization. As a result, I put in a delay before step
could be run. The number I found that worked for me was 0.075
.
The delay works by using GetFrameTime
, which returns the time between frames. When the total time is greater than 0.075
, time
is reset to 0
, and the step
function for CGoL is run.
Conclusion
I have not shown the complete code for running this visualization, nor have I given you any directions on compiling it. To do so, you can go to the GitHub repo. It has a makefile for compiling the code either for local use or for running in your browser. It also has the complete main.cpp
file. You can see one way to structure your code for local and WASM development.
Regardless, this is the end of the post. I have shown one way to implement Conway’s Game of Life in C++ and then how to visualize it with raylib. For anyone out there reading this post, I hope that it helped. If you find any mistakes, please let me know.