Drawing a Game of Life Board in Go

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 contain func 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.

  1. dinnerTable.csv: a period-12 oscillator that we encountered in the main text.
  2. rPentomino.csv: the five-cell R Pentomino pattern that produces surprisingly complex behavior, including the production of six gliders.
  3. 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:

  1. log: for logging errors;
  2. os: for opening files;
  3. 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, setting currentRow[j] equal to false when the current value of lineElements 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.

In array indexing, rows increase downward, and columns increase rightward.
In graphics coordinates, x-values increase rightward, and y-values increase downward.

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.

Our first attempt at drawing the R pentomino.

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.

The R pentomino with cells drawn as circles.

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.

Drawing the R pentomino without adjacent live cells touching by scaling the radius of live cells by a factor of 0.8.

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.
Our drawing of the “dinner table”.
Our drawing of the “Gosper gun”.

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
}


Page Contents
Scroll to Top