Learning objectives
In this code along, we will build on what we learned in a previous code along about two-dimensional arrays and the preceding code along on graphics to improve on our visualization of a Game of Life board. We will first write some code to read a board from a file to prevent having to manually enter cells. We will then learn how to use some existing code for drawing to store an image corresponding to a Game of Life board.
Visualizing a single board will prepare us to draw a collection of boards in the next code along, when we will implement the Game of Life and visualize a collection of boards over multiple generations as an animated GIF.
Setup
To complete this code along and the next code along on implementing the Game of Life, we will need starter code.
We provide this starter code as a single download gameOfLife.zip
. Download this code here, and then expand the folder. Move the resulting gameOfLife
directory into your go/src
source code directory.
The gameOfLife
directory has the following structure:
boards
: a directory containing.csv
files that represent different initial Game of Life board states (more on this directory shortly).output
: an initially empty directory that will contain images and GIFs that we draw.datatypes.go
: a file that will contain type declarations (more to come shortly).functions.go
: a file that will contain functions that we will write in the next code along for implementing the Game of Life.io.go
: a file that will contain code for reading in a Game of Life board from file.drawing.go
: a file that will contain code for drawing a Game of Life board to a file.main.go
: a file that will containfunc main()
, where we will call functions to read in Game of Life boards and then draw them.
You also should ensure that, following up on the preceding code along, you have installed drawing starter code as well as have the canvas
folder contained within go/src
that makes drawing easier. We will use this code in this code along when it comes time to draw a board.
Code along summary
Declaring a GameBoard type
We already know that we conceptualize a Game of Life board as a two-dimensional slice of boolean variables, in which true
denotes that a cell is alive, and false
denotes that it is dead. When implementing the Game of Life in the next code along, we will need to store such a board for each of a number of generations. The result of the simulation will therefore have type []([][]bool)
, which is a three-dimensional slice of boolean variables.
Do not worry if you find concept this tricky to conceptualize. Rather, think about the end result of the Game of Life as providing us with a slice of game boards, where each game board is a two-dimensional slice of boolean variables.
Fortunately, Go provides a way to implement this abstraction. In datatypes.go
, we will add the following type declaration that establishes a new GameBoard
type.
// GameBoard is a two-dimensional slice of boolean variables // representing a single generation of a Game of Life board. type GameBoard [][]bool
Once we have added this code, we can use GameBoard
in place of [][]bool
whenever we are referring to a Game of Life board. As a result, the Game of Life simulation that we will complete in the next code along will produce a variable of type []GameBoard
, a slice of GameBoard
objects.
We will say much more about the transition toward abstracting variables as objects in our next chapter. For now, let’s learn how to read in a GameBoard
from file and then draw it.
Reading in a Game of Life board from file
In the previous code along, we entered the cells of a Game of Life board manually, an approach that will not work for larger boards. Instead, let’s write a function to read a Game of Life board from file. Note that the boards
directory contains three comma-separated files.
dinnerTable.csv
: a period-12 oscillator that we encountered in the main text.rPentomino.csv
: the five-cell R Pentomino pattern that produces surprisingly complex behavior, including the production of six gliders.gospergun.csv
: the “Gosper gun” that manufactures gliders at a constant rate.
We reproduce the animations of these three initial patterns below in order from left to right according to the above order.



Open and examine one of the files in boards
. Each row of the file contains a single line of comma-separated integers representing a row of the board, with alive cells represented by 1 and dead cells represented by 0.
We will write a function ReadBoardFromFile()
in io.go
that takes as input a string filename
, read in a file from the location specified in filename
, and then return a GameBoard
representing the automaton specified in the file according to the abovementioned format.
At the top of io.go
, we need to import three packages:
log
: for logging errors;os
: for opening files;strings
: for manipulating data parsed from files.
package main import ( "log" // needed for error reporting "os" // needed for reading in from file "strings" // needed for manipulating data parsed from file )
First, we read in the file using os.ReadFile()
, which will return two things: a slice of byte
variables containing all characters in the file, which we will call fileContents
, and an error variable that we call err
. As in previous code alongs on file parsing, we will ensure that there were no issues in reading the file by passing err
into a Check()
subroutine.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // to fill in } // Check takes as input a variable of error type. // If the variable has any value other than nil, it panics. func Check(e error) { if e != nil { log.Fatal(e) } }
Next, we convert the fileContents
slice into a string giantString
representing the entire contents of the file. We then use strings.TrimSpace() to remove any leading or trailing white space from this string, followed by strings.Split()
to split this string into a slice of strings lines
, where each element of lines
is a string corresponding to a single line of the file.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // parse out the file contents as a slice of strings, one line per element giantString := string(fileContents) trimmedGiantString := strings.TrimSpace(giantString) lines := strings.Split(trimmedGiantString, "\n") // to fill in } // Check takes as input a variable of error type. // If the variable has any value other than nil, it panics. func Check(e error) { if e != nil { log.Fatal(e) } }
We now know that len(lines)
, the number of lines in the file, is equal to the number of rows in our GameBoard
, and so we can go ahead and declare this GameBoard
, which we will call board
. Because of our type declaration, instead of declaring board := make([][]bool, len(lines))
, we will replace [][]bool
with GameBoard
.
We will also add a return board
statement to the bottom of the function, leaving the middle of the function to be filled in.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // parse out the file contents as a slice of strings, one line per element giantString := string(fileContents) trimmedGiantString := strings.TrimSpace(giantString) lines := strings.Split(trimmedGiantString, "\n") // we know how many rows the GameBoard will have board := make(GameBoard, len(lines)) // to fill in return board }
We now range over all the lines of the file, separating out elements of each line using strings.Split()
into a slice of strings currentLine
every time we see a comma. The resulting slice of strings lineElements
contains a collection of values that are "0"
or "1"
; these strings correspond to a single element of the current row.
In the spirit of modularity and avoiding unnecessary nested loops, we will set the values of the current row of board
by passing lineElements
into a subroutine, which we call SetRowValues()
.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // parse out the file contents as a slice of strings, one line per element giantString := string(fileContents) trimmedGiantString := strings.TrimSpace(giantString) lines := strings.Split(trimmedGiantString, "\n") // we know how many rows the GameBoard will have board := make(GameBoard, len(lines)) // next, parse the data on each line and add to the GameBoard for i, currentLine := range lines { lineElements := strings.Split(currentLine, ",") // use a subroutine to set the values of the current row board[i] = SetRowValues(lineElements) } return board }
Now that we have written ReadBoardFromFile()
, we will write the SetRowValues()
subroutine. This function takes as input a string representing a line of a comma-separated Game of Life file in boards
, and it returns a slice of boolean values corresponding to parsing the line.
SetRowValues()
first makes a slice currentRow
of boolean variables having length equal to the number of elements in lineElements
. It then ranges over lineElements
, setting the j-th value of currentRow
equal to false
if the corresponding value of lineElements
is "0"
, and true
if the corresponding value of lineElements
is "1"
.
// SetRowValues takes as input a slice of strings, // where each element is either "0" or "1". // It returns a slice of boolean values of the same length, // encoding "0" as false and "1" as true. func SetRowValues(lineElements []string) []bool { currentRow := make([]bool, len(lineElements)) //range through the current line elements and set values for j, val := range lineElements { if val == "0" { currentRow[j] = false // technically this is not necessary } else if val == "1" { currentRow[j] = true } else { log.Fatal("Error: invalid entry in board file.") } } return currentRow }
STOP: Technically, settingcurrentRow[j]
equal tofalse
when the current value oflineElements
is equal to"0"
is not necessary. Why?
Drawing a Game of Life board
In drawing.go
, let’s write a function DrawGameBoard()
that takes as input a GameBoard
object board
in addition to an integer cellWidth
. This function will create a canvas object in which each cell is assigned a square that is cellWidth
pixels wide and tall, and then return an image
object associated with this canvas so that we will be able to render it.
We will first import two packages: canvas
(for drawing shapes), and Go’s built-in image
package (for exporting to image objects).
package main import ( "canvas" // for drawing shapes "image" // for exporting our drawing to an image )
Next, we will set the height of the canvas (in pixels) equal to the number of rows in board
times cellWidth
, and the width of the canvas equal to the number of columns in board
times cellWidth
. We will determine the number of rows and columns in board
using two subroutines, which we will discuss after writing DrawGameBoard()
.
Then, we will call canvas.CreateNewCanvas()
from the canvas
package to create a new canvas object c
that is width
pixels wide and height
pixels tall.
At the end of our function, we will eventually call c.GetImage()
from canvas
to return the image
object associated with our canvas.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) //to fill in return c.GetImage() }
Next, we will declare two color variables, one for a dark gray color (RGB: (60, 60, 60)) and one for white (RGB: (255, 255, 255)). After doing so, we will call c.SetFillColor()
to set the fill color to dark gray, followed by c.Clear()
to fill the entire canvas with this fill color.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) // make two colors: dark gray and white darkGray := canvas.MakeColor(60, 60, 60) white := canvas.MakeColor(255, 255, 255) // set the entire board as dark gray c.SetFillColor(darkGray) c.Clear() // to fill in return c.GetImage() }
We are now ready to range over every cell in board
and color a given cell white if it is alive. We don’t need to take any action if the cell is dead, since the color of dead cells will be the same as the background color.
Although we are ardent followers of modularity, in this particular case, we don’t mind using a nested for loop to range over board is in order, since our code will be short. After ranging i
over the rows of board
and j
over the columns of board
, we will draw a rectangle at the position on the canvas corresponding to board[i][j]
. The question is precisely where on the canvas we should draw this rectangle.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) // make two colors: dark gray and white darkGray := canvas.MakeColor(60, 60, 60) white := canvas.MakeColor(255, 255, 255) // set the entire board as dark gray c.SetFillColor(darkGray) c.Clear() // fill in colored squares for i := range board { for j := range board[i] { //only draw the cell if it's alive, since it would equal background color if board[i][j] == true { c.SetFillColor(white) // draw the cell at the appropriate location. But where? } } } return c.GetImage() }
Let us recall for a moment about how we conceptualize arrays. As shown in the figure below on the left, the rows of an array extend downward from the top left, and the columns of the array extend rightward from the top left. This is similar to the standard graphics approach (reproduced below on the right), except that the rows increase in proportion to y-coordinates, and the columns increase in proportion to x-coordinates. Therefore, although we are tempted to think that an element a[r][c]
of an array will be drawn at a position (x, y) where x depends on r
and y depends on c
, the opposite is true: x depends on c
, and y depends on r
.


We now can say that to draw the rectangle associated with board[i][j], the minimum x-value will be the product of j
, the column index, and cellWidth
; the minimum y-value will be the product of i
, the row index, and cellWidth
. Because we are drawing a square, the maximum x- and y-values will be equal to the minimum such values plus cellWidth
. We are now ready to finish DrawGameBoard()
.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) // make two colors: dark gray and white darkGray := canvas.MakeColor(60, 60, 60) white := canvas.MakeColor(255, 255, 255) // set the entire board as dark gray c.SetFillColor(darkGray) c.Clear() // fill in colored squares for i := range board { for j := range board[i] { //only draw the cell if it's alive, since it would equal background color if board[i][j] == true { c.SetFillColor(white) x := j * cellWidth y := i * cellWidth c.ClearRect(x, y, x+cellWidth, y+cellWidth) } } } return c.GetImage() }
Two seemingly simple subroutines, and an introduction to assertion functions
We now just need to write the subroutines CountRows()
and CountColumns()
in functions.go
, and then we will be ready to write some code in func main()
to read in a Game of Life board using ReadBoardFromFile()
and draw it using DrawGameBoard()
.
We can count the number of rows in board
by accessing len(board)
, and we can count the number of columns in board by accessing the length of the first row, len(board[0])
. In CountColumns()
, we make sure to check that board doesn’t have zero rows, so that when we access board[0]
, we know that it exists.
STOP: What issue do you see that might arise with these two functions?
// CountRows takes as input a GameBoard // and returns the number of rows in the board func CountRows(board GameBoard) int { return len(board) } // CountColumns takes as input a rectangular GameBoard and // returns the number of columns in the board. func CountColumns(board GameBoard) int { if len(board) == 0 { panic("Error: no rows in GameBoard.") } // give # of elements in 0-th row return len(board[0]) }
Although CountRows()
always correctly the number of rows in a two-dimensional slice, recall from our creation of a triangular slice in the previous code along that a two-dimensional slice does not necessarily have the same number of elements in each row.
We could make our code more robust in two ways. We could return the maximum length of any row of board
. However, the strategy that we will take is to revise CountColumns()
so that it only returns a value if board
is rectangular. To do so, at the beginning of CountColumns()
, we will call a subroutine AssertRectangular()
, which takes board
as input. This assertion function does not return anything, but it panics if board
has no rows, or if when we range over the rows of board
, any row has length that is not equal to the length of the initial row of board
.
Note: Halting the program is a strict decision to make for a non-rectangular board. In practice, we might make different design choices, such as providing a message to the user.
// CountColumns takes as input a GameBoard and returns the number of columns // in the board, assuming that the board is rectangular. func CountColumns(board GameBoard) int { // assert that we have a rectangular board AssertRectangular(board) // we can assume board is rectangular now // number of columns is the number of elements in 0-th row return len(board[0]) } // AssertRectangular takes as input a GameBoard. // If returns nothing. If the board has no rows, or is not rectangular, it panics. func AssertRectangular(board GameBoard) { if len(board) == 0 { panic("Error: no rows in GameBoard.") } firstRowLength := len(board[0]) //range over rows and make sure that they have the same length as first row for row := 1; row < len(board); row++ { if len(board[row]) != firstRowLength { panic("Error: GameBoard is not rectangular.") } } }
Running our code, and improving our automaton visualization
In main.go
, we will add the following import statements:
import ( "image/png" // for drawing an image to PNG "os" // for opening a file that will hold the image )
In func main()
, we will first read a board from file. Let’s read the simplest board, the R pentomino, which is stored in boards/rPentomino.csv
. We will then set our cellWidth
parameter to 20 pixels, and call DrawGameBoard()
on rPentomino
and cellWidth
.
func main() { rPentomino := ReadBoardFromFile("boards/rPentomino.csv") cellWidth := 20 img := DrawGameBoard(rPentomino, cellWidth) }
The image of the canvas that we generated is stored in an image
object called img
, which we want to write to a file to view. First, we declare a string filename
, which we will set to be equal to "output/rPentomino.png"
, so that our image will be written into the output
directory.
We then create a file f
in this location, check if there was an error, and defer closing the file to the end of func main()
.
func main() { rPentomino := ReadBoardFromFile("boards/rPentomino.csv") cellWidth := 20 img := DrawGameBoard(rPentomino, cellWidth) filename := "output/rPentomino.png" f, err := os.Create(filename) Check(err) defer f.Close() }
Finally, we call the Encode()
function from the png
package with inputs f
and img
to encode the image into the file, checking once again whether this process produced an error.
func main() { rPentomino := ReadBoardFromFile("boards/rPentomino.csv") cellWidth := 20 img := DrawGameBoard(rPentomino, cellWidth) filename := "output/rPentomino.png" f, err := os.Create(filename) Check(err) defer f.Close() err = png.Encode(f, img) Check(err) }
Save your code, compile, and run. You should see an rPentomino.png
file appear in the output
folder, shown in the figure below.

Let’s add a little bit of flavor to our automaton drawing. First, let’s draw the cells as circles by calling c.Circle()
. To do so, recall the innermost block of DrawGameBoard()
, reproduced below.
//only draw the cell if it's alive if board[i][j] == true { c.SetFillColor(white) x := j * cellWidth y := i * cellWidth c.ClearRect(x, y, x+cellWidth, y+cellWidth) }
If board[i][j]
is alive, then this code draws a white square whose top-left corner is (x
, y
) and whose bottom-right corner is (x+cellWidth
, y+cellWidth
). If we are going to draw a circle in this same location, then the center’s circle should move to the center of the cell by adding float64(cellWidth)/2
to each of x
and y
. We are now ready to replace the innermost if statement in DrawGameBoard()
with the following code.
//only draw the cell if it's alive if board[i][j] == true { c.SetFillColor(white) // set coordinates of the cell's top left corner x := float64(j*cellWidth) + float64(cellWidth)/2 y := float64(i*cellWidth) + float64(cellWidth)/2 c.Circle(x, y, float64(cellWidth)/2) c.Fill() }
After compiling and running our code once again, we obtain the illustration below. The circles are nice, but the cells touch, which will make the cells tricky to view when we transition to animations.

Let’s reduce the radius of the circle by a bit by setting a scaling factor that we can multiply by the radius of the circle, adjusting the central if block of DrawGameBoard()
as follows.
//only draw the cell if it's alive if board[i][j] == true { c.SetFillColor(white) x := float64(j*cellWidth) + float64(cellWidth)/2 y := float64(i*cellWidth) + float64(cellWidth)/2 scalingFactor := 0.8 // to make circle smaller c.Circle(x, y, scalingFactor*float64(cellWidth)/2) c.Fill() }
Compiling and running our code this time produces the following image.

Now that DrawGameBoard()
is improved, we are ready to draw more complicated boards. For example, compile and run your code after changing func main()
as follows to draw the dinner table oscillator, which is shown in the figure below on the left.
func main() { dinnerTable := ReadBoardFromFile("boards/dinnerTable.csv") cellWidth := 20 img := DrawGameBoard(dinnerTable, cellWidth) filename := "output/dinnerTable.png" f, err := os.Create(filename) Check(err) defer f.Close() err = png.Encode(f, img) Check(err) }
STOP: Compile and run your code once again after reading in the Gosper gun (gospergun.csv
) to draw the Gosper gun shown in the figure below on the right.


Drawing multiple Game of Life boards
Now that we have written a function to draw a single board, we will also write a DrawGameBoards()
function in drawing.go
that takes a slice of GameBoard
objects boards
and an integer cellWidth
as input and that returns a slice of image.Image
objects corresponding to calling DrawGameBoard()
on each GameBoard
in boards
. This function will come in handy in the next code along, when we will write a function returning a slice of GameBoard
variables as a result of simulating the Game of Life over multiple generations, and after calling DrawGameBoards()
on this slice, we will generate an animated GIF showing the changes in the automaton over time.
// DrawGameBoards takes as input a slice of (rectangular) GameBoards and an integer cellWidth. // It returns a slice of Image objects corresponding to each of these GameBoards, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoards(boards []GameBoard, cellWidth int) []image.Image { numGenerations := len(boards) imageList := make([]image.Image, numGenerations) for i := range boards { imageList[i] = DrawGameBoard(boards[i], cellWidth) } return imageList }