Building Conway’s Game of Life in Go with raylib-go
A step-by-step tutorial on building Conway’s Game of Life in Go, using raylib-go for graphics. Learn how to draw grids, apply the rules of life, and simulate evolving patterns.
I recently started to play around with graphics programming and game engine creation. For that, I was using SDL2, OpenGL and C.
However, I wanted to do something in Go, so I started with Conway’s Game of Life. There are various options from C bindings to OpenGL and SDL2 to full game engines such as Ebitengine. I went with raylib-go as it was a good mixture of low-level and abstracted programming.
In this tutorial, we’ll use Go and raylib-go to create Conway’s Game of Life, a simulation where simple rules create endless patterns. By the end, you’ll have gliders drifting, pulsars pulsing, and even a glider gun firing across your screen.
The Game of Life
Conway’s Game of Life is a cellular automaton created by John Conway. It is a zero-player game which evolves. Each generation is built on the previous generation using a set of simple rules.
The game requires no players as you start it off, and it starts to evolve without intervention. It is Turing Complete and can simulate a wide variety of different patterns and Turing Machines.
It starts with an initial game start, then it uses the following rules to determine which cell lives or dies:
Any live cell with fewer than 2 live neighbours dies, as if by underpopulation.
Any live cell with 2 or 3 live neighbours lives on to the next generation.
Any live cell with more than 3 live neighbours dies, as if by overpopulation.
Any dead cell with exactly 3 live neighbours becomes a live cell, as if by reproduction.
Each generation or frame in the context of our game will use these rules to determine the next state.
Demo
In this tutorial, we will be creating a simple cellular automation in Go using raylib-go. It will start with a state we define, and allow us to modify the state to see different evolutions.
Setting up the project
First thing we need to do is create a go package for our project and pull down raylib-go so we can start to build with it.
mkdir go-gameoflife
go mod init gameoflife
go get -v -u github.com/gen2brain/raylib-go/raylib
When learning anything new in programming, we always start with the classic “Hello World”. To do that, we need to import raylib-go, create a window and display some text.
package main
import rl "github.com/gen2brain/raylib-go/raylib"
type Game struct {
Height int
Width int
}
func main() {
// Create the game metadata and state holding object
game := Game{Width: 800, Height: 400}
// Create the Raylib window using the state
rl.InitWindow(int32(game.Width), int32(game.Height), "Game of life")
// Close the window at the end of the program
defer rl.CloseWindow()
// We dont need a high FPS for the game, so 10 should be enough
rl.SetTargetFPS(10)
// Loop until the window needs to close
for !rl.WindowShouldClose() {
// Starting drawing to the canvas
rl.BeginDrawing()
// Create a black background
rl.ClearBackground(rl.Black)
// Draw Hello world
rl.DrawText("Hello world!", 350, 200, 20, rl.RayWhite)
// End the drawing
rl.EndDrawing()
}
}
We have also created a struct to store the game metadata, and we can later use it to store the state of the game. We use that to initialise the window.
To save a little bit of time, we can create a Makefile, which will allow us to simplify the building and running of our code. In this case, we only need one target run, however, this can be expanded to run tests, build more files, clean up build states and more.
.DEFAULT_GOAL := run
run:
go run .
help:
@echo "run - run the game"
.PHONY: run
Now that we have the make file, we can run make to start the program. The same could be done by running. go run .
make
Win! We now have a window with our Hello World.
In the background, raylib is doing all the hard work for us, working with OpenGL to create the window in the system-specific libraries for your operating system. However, we do not need to worry about that.
For each step of this tutorial, I will provide the code so you can compare. The full code for this step is in the GitHub Repo.
Creating a 2D map
The next thing we need to do is to create an initial state for our game, which will consist of a 2D matrix which we use to draw the cells to the screen.
We will need to update our Game struct to store our state and to specify how big we want the cells.
type Game struct {
- Height int
- Width int
+ Height int
+ Width int
+ tileSize int
+ State [][]int
+}
We can then create a function to create the new start for us
func NewGame(width, height, tileSize int) *Game {
g := &Game{Width: width, Height: height, tileSize: tileSize}
g.State = [][]int{
{0, 1, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 1, 0, 0, 0, 0, 1, 0, 0},
{1, 1, 1, 0, 0, 0, 0, 1, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 1, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
return g
}
Next, we need a way to display this on our window. We can simply loop through the array and check if the cell is alive. If it is, we can draw the cell to the screen. We do this with a Draw()
method on the Game struct.
func (g *Game) Draw() {
// Loop through all of the rows
for y := range g.State {
// Loop through all of the columns
for x := 0; x < len(g.State[y]); x++ {
// If we have marked the column as a 1, draw it as white
if g.State[y][x] == 1 {
// We will need to scale our blocks to the size of the window
pixelX := x * g.tileSize
pixelY := y * g.tileSize
// Draw the block to the screen
rl.DrawRectangle(int32(pixelX), int32(pixelY), int32(g.tileSize), int32(g.tileSize), rl.RayWhite)
}
}
}
}
Now we need to update our main()
function to use the new game state, then draw it to the window.
- game := Game{Width: 800, Height: 400}
+ var game = NewGame(800, 400, 80)
...
- // Draw Hello world
- rl.DrawText("Hello world!", 350, 200, 20, rl.RayWhite)
+ // Draw the game state
+ game.Draw()
Now we can run the game again.
make
We now have the starting state for our game. I have used a cell size of 80x80px here to allow us to render it in an 800x400px window, so we can play with the logic and see the results.
Full code for this step is in the GitHub Repo
Getting neighbours
Now we get to the part where we bring our game to life. We can start to create the rule to evolve the state.
To do this, we need a way of counting how many live neighbours a cell has. We can do this by looping through all of the neighbours and checking the surrounding cells.
We can start with the top left cell, the move to the next until we have covered all the cells. We will need to exclude the current cell and account for whether the cell we are calculating is at the edge of the window.
func CountNeighbours(x, y int, gameState [][]int) int {
// Counter for the neighbours
count := 0
// Loop through all the rows
for cellX := x - 1; cellX <= x+1; cellX++ {
// Loop through all the columns
for cellY := y - 1; cellY <= y+1; cellY++ {
// We want to make sure we do not count past the boundary of the board
if cellY < 0 || cellX < 0 || cellY >= len(gameState) || cellX >= len(gameState[0]) {
continue
}
// If current cell, we can skip it
if cellY == y && cellX == x {
continue
}
// Check if cell is alive
if gameState[cellY][cellX] == 1 {
count++
}
}
}
return count
}
Next, we need to codify the rules of the game using the count of the neighbours. We can do that with a switch
statement for each of the rules.
func IsCellAlive(current, neighbours int) int {
switch {
// Any live cell with fewer than two live neighbours dies
// as if by underpopulation.
case neighbours < 2:
return 0
// Any live cell with two or three live neighbours lives
// on to the next generation.
// Any dead cell with two neighbours, remains dead
case neighbours == 2:
return current
// Any dead cell with exactly three live neighbours becomes a
// live cell, as if by reproduction.
case neighbours == 3:
return 1
// Any live cell with more than three live neighbours dies
// as if by overpopulation.
case neighbours > 3:
return 0
}
return 0
}
Now that we have the calculations for our next state implemented, we need to take our existing state and update it based on the rules. We can create an Update()
method on our Game struct to do this.
func (g *Game) Update() {
// We can stat with hardcoded state for now
// However, we would want to update thisbased on the current state
var newState = [][]int{
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
// Loop through each row
for indexY, cellY := range g.State {
// Loop through each column
for indexX, cellX := range cellY {
// Count how many neighbours the current cell has
neighbours := CountNeighbours(indexX, indexY, g.State)
// Update the new state using the rule based on neighbours
newState[indexY][indexX] = IsCellAlive(cellX, neighbours)
}
}
// Set the new state to the updated state
g.State = newState
}
We have used another slicee to build the new state, then we replace the old one. This implementation increases memory usage, however, we could offset and update it in-place with a truth table and two passes of the slice. However, let's keep it simple.
rl.ClearBackground(rl.Black)
+ // Update the game state before drawing
+ game.Update()
+
// Draw the game state
Now we can add the Update function to our main game loop and start the game again to see if it works.
make
Run it and you will see the cells start to evolve!
We can see the patterns start to evolve with each generation, and they will converge into a steady state eventually.
Full code for this step is in the GitHub Repo
Update the map
Now that the core functionality of our game is working, we can start to scale the map size to make more complex patterns.
Let's replace the hard-coded map with a function we can use to generate a game state as big as we want.
func CreateGameState(newWidth, newHeight int) [][]int {
// Create a new game state with the right height
newState := make([][]int, newHeight)
// Create the rows with the right length
for i := range newHeight {
newState[i] = make([]int, newWidth)
}
// Return the new state map
return newState
}
We can use this blank game state in our code where we are manually defining the state. We are defining the state in both the Update()
and NewGame()
functions.
// However, we would want to update this based on the current state
- var newState = [][]int{
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- }
+ newState := CreateGameState(len(g.State[0]), len(g.State))
// Loop through each row
func NewGame(width, height, tileSize int) *Game {
g := &Game{Width: width, Height: height, tileSize: tileSize}
- g.State = [][]int{
- {0, 1, 0, 0, 0, 0, 0, 0, 0, 0},
- {0, 0, 1, 0, 0, 0, 0, 1, 0, 0},
- {1, 1, 1, 0, 0, 0, 0, 1, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 1, 0, 0},
- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
- }
+ g.State = CreateGameState(g.Width/g.tileSize, g.Height/g.tileSize)
return g
}
That looks much cleaner. However, we have now lost our initial state, which triggers the game. We can fix this by creating a function that will add a new pattern. We will start with the Glider pattern.
func CreateGliders(x, y int, gameState *[][]int) {
// Draw the glider patter in the game state
(*gameState)[y][x+1] = 1
(*gameState)[y+1][x+2] = 1
(*gameState)[y+2][x] = 1
(*gameState)[y+2][x+1] = 1
(*gameState)[y+2][x+2] = 1
}
Now that we have the function to create a Glider, we can add them to our game state in the main()
function.
- var game = NewGame(800, 400, 80)
+ var game = NewGame(800, 400, 10)
+ CreateGliders(0, 0, &game.State)
+ CreateGliders(10, 0, &game.State)
+ CreateGliders(20, 0, &game.State)
+ CreateGliders(30, 0, &game.State)
Let's run the game again.
make
Great!
We can see the addition of 4 gliders to a much larger window, and we can see them move across the screen as the generations increase.
Full code for this step is in the GitHub Repo
Setting up different simulations
Now that we have the game working, we can play around with different initial states to see how they evolve.
The other patterns are more complex compared to the glider, so I will create a helper function for adding the pattern, then define the patterns separately. More patterns can be found here.
func addPattern(x, y int, pattern [][]int, gameState *[][]int) {
// Loop through the row
for row := range pattern {
// Loop through the
for col := 0; col < len(pattern[row]); col++ {
// Update the game state if cell alive
if pattern[row][col] == 1 {
(*gameState)[y+row][x+col] = 1
}
}
}
}
func CreateGliderGun(x, y int, gameState *[][]int) {
// Create a slice of the pattern
pattern := [][]int{
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
addPattern(x, y, pattern, gameState)
}
func CreatePulsar(x, y int, gameState *[][]int) {
// Create a slice of the pattern
pattern := [][]int{
{0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1},
{1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1},
{1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1},
{0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0},
{1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1},
{1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1},
{1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1},
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0},
}
addPattern(x, y, pattern, gameState)
}
func CreatePentadecathlon(x, y int, gameState *[][]int) {
// Create a slice of the pattern
pattern := [][]int{
{0, 0, 1, 0, 0, 0, 0, 1, 0, 0},
{1, 1, 0, 1, 1, 1, 1, 0, 1, 1},
{0, 0, 1, 0, 0, 0, 0, 1, 0, 0},
}
addPattern(x, y, pattern, gameState)
}
Now we have our patterns, let's add them to the state.
var game = NewGame(800, 400, 10)
- CreateGliders(0, 0, &game.State)
- CreateGliders(10, 0, &game.State)
- CreateGliders(20, 0, &game.State)
- CreateGliders(30, 0, &game.State)
+ CreateGliderGun(0, 0, &game.State)
+ CreatePentadecathlon(40, 10, &game.State)
+ CreatePulsar(60, 20, &game.State)
// Create the Raylib window using the state
Run our game again.
make
Now our game is busy, with different interactions and patterns.
The full code can be found on the GitHub repo
Testing
Something I have omitted from this tutorial is the testing. When developing this post, I created tests alongside the code to validate that the evolution rules and update function worked as expected. You can find my testing in the main_test.go.
The example, the code to test the IsCellAlive function, looks like:
func TestIsCellAlive(t *testing.T) {
testCases := []struct {
name string
want int
current int
neighbours int
}{
{name: "Live cell should not live with <2", neighbours: 1, current: 1, want: 0},
{name: "Live cell should live with 2", neighbours: 2, current: 1, want: 1},
{name: "Live cells should live with 3", neighbours: 3, current: 1, want: 1},
{name: "Dead cells should live with 3", neighbours: 3, current: 0, want: 1},
{name: "Live cell should not live with >3", neighbours: 4, current: 1, want: 0},
{name: "Dead cells should not live if already dead", neighbours: 0, want: 0},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := IsCellAlive(tt.current, tt.neighbours)
if got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
In future posts, I can cover testing more and potentially walk through how we could have done this with Test Driven Development (TDD)
Conclusion
We have walked through:
Creating a window with raylib-go
Adding shapes to the window
Creating Conway’s Game of Life state
Updating the state with new generations
Adding new patterns to the state
We just built Conway’s Game of Life in Go with raylib-go, starting from a blank window all the way to gliders, pulsars, and even a glider gun. Along the way, we covered rendering, updating state, and adding reusable patterns.
You can find the full source code in the GitHub repo. Try running it, tweak the patterns, or create your own.
If you build something cool with it, I’d love to see it! Share your experiments in the comments, or tag me on LinkedIn.