Drawing a Game of Life Board in Go

Learning objectives

In this code along, we will build on what we learned in the previous code along about two-dimensional arrays 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 following code along, you will need starter code that will help us draw objects and generate images.

First, you will need to install a package called "llgcode/draw2d" that we will use for drawing. To install this code into your Go directory, download this code as a .zip file here, and then expand the folder. You will see an llgcode folder that contains draw2d as a directory. In your go/src directory, if you do not already have a github.com folder, then create one. Then, add llgcode to this folder as a subdirectory.

Next, draw2d requires a couple of “dependencies” of other code projects including functions that it calls.

  1. golang/freetype, which can be downloaded here. Expand the file, and then move the golang folder into go/src/github.com alongside llgcode.
  2. golang.org/x/image, which can be downloaded here. Expand the file, and then move the golang.org folder into go/src.

We provide starter code as a single download gameOfLifeStarterCode.zip. Download this code here, and then expand the folder. You should see three directories. Move each of these folders to your go/src directory.

  1. gameOfLife: will contain the engine for implementing the Game of Life.
  2. canvas: communicates with draw2d to allow us to draw automata and produce images.
  3. gifhelper: code for producing animated GIFs.

We will not need the code contained in gifhelper until the next code along, when we will be generating an animated GIF for multiple generations of the Game of Life. We will explain the contents of canvas later in this code along when it comes time to draw a board.

As for gameOfLife, this 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.

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.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)
	lines := strings.Split(giantString, "\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)
	lines := strings.Split(giantString, "\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)
	lines := strings.Split(giantString, "\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,
// encoring "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?

Understanding canvas.go: our engine for drawing

Now that we can read in a Game of Life board, we would like to draw it. First, we will learn a little bit about how to use programming in order to make an example drawing of a face.

Go provides a comprehensive library for drawing via the draw2d directory that we asked you to install as part of the setup for this code along. This directory is not the easiest for a beginner to use directly, which is why we provided some code in the canvas folder, which contains a collection of functions that call code from draw2d so that we can simply use the functions in canvas.

Note: Special thanks to Carl Kingsford and Hannah Kim for putting together the canvas package.

In particular, the canvas package allows us to conceptualize a “canvas” object. We will say much more about working with “object-oriented” programming in a future chapter, but for now, think of a canvas as a rectangular window with a specified width and height, much like the screen that you are reading this on. The width and height of the canvas are measured in pixels, where a pixel is a single (small) point in the image that will be colored with a single color.

In the RGB color model, every rectangular pixel on a computer screen emits a single color formed as a mixture of differing amounts of the three primary colors of light: red, green, and blue (hence the acronym “RGB”). The intensity of each primary color in a pixel is expressed as an integer between 0 and 255, inclusively, with larger integers corresponding to greater intensities.

A few colors are shown in the figure below along with their RGB equivalents; for example, magenta corresponds to equal parts red and blue. Note that a color like (128, 0, 0) contains only red but appears duskier than (256, 0, 0) because the red in that pixel is less intense.

A collection of colors along with their RGB codes. This table corresponds to mixing colors of light instead of pigment, which causes some non-intuitive effects; for example, yellow is formed by mixing equal parts red and green. The last six colors appear muted because they only receive half of a given color value compared to a color that receives 256 units. If all three colors are mixed in equal proportions, then we obtain a color on the gray scale between white (255, 255, 255) and black (0, 0, 0). Source: Excel at Finance.

The functions from canvas.go that we will need in this code along are listed below. We will say more about these functions as they are needed.

  • CreateNewCanvas(): creates a rectangular canvas object with a given width and height
  • MakeColor(): creates a new color
  • SetFillColor(): sets the fill color
  • Clear(): fill the entire canvas with the fill color
  • Fill(): fill all shapes that have been drawn with the fill color
  • ClearRect(): draw a rectangle at given coordinates and fill it with the current fill color
  • Circle(): draw a circle at given coordinates
  • GetImage(): obtain the image corresponding to the current canvas.

Let’s draw a face

In main.go, instead of putting our code directly into func main(), we will call a function DrawFace(); this function will not take any inputs or return anything, but it will contain code for drawing our image.

func main() {
	fmt.Println("Coding the Game of Life!")

	DrawFace()
}

We will next implement DrawFace() in drawing.go. The first thing that we will do is to create a canvas object, which we will call c. To do so, we will call CreateNewCanvas(), which takes as input two integers w and h and returns a canvas object that is w pixels wide and h pixels tall. In this case, the canvas will be 1000 pixels wide and 2000 pixels tall.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

        // to fill in
}

Next, let’s make the entire canvas black. First, we will declare a variable black (which has type color) by calling canvas.MakeColor(). This function takes three integer inputs between 0 and 255 corresponding to the RGB notation of the color that is returned.

Black is formed by the absence of any light, and so (as the table above illustrates) its RGB format is (0, 0, 0). Therefore, we declare our color variable by using the notation black := canvas.MakeColor(0, 0, 0). We will then set the fill color by calling c.SetFillColor(black), and fill the canvas with this fill color by calling c.Clear().

Note: The notation c.Foo() for a function Foo() involving a canvas object c is new up to this point in the course. This type of function is called a “method”, and we will say much more about methods soon when we discuss object-oriented programming in greater detail.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

        // to fill in
}

Let’s take a look at the image associated with the canvas. To do so, we will call the function c.SaveToPNG() from canvas.go, which takes as input a file name as a string and saves an image associated with c to this file; we will give this function the string "output/fun.png".

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

	// save the image associated with the canvas
	c.SaveToPNG("output/fun.png")
}

After saving your files, navigate into go/src/gameOfLife from the command line, compile your code by executing the command go build, and run your code by executing either ./gameOfLife (Mac) or gameOfLife.exe (Windows). You should see the black rectangle shown below appear as fun.png in your go/src/gameOfLife/output directory.

A thrilling black rectangle.

We now are ready to add the head, but we first need to understand the canvas coordinate system. In the Cartesian plane that we work with in mathematics, increasing x-coordinates extend to the right, and increasing y-coordinates extend upward (see figure below, left). However, graphics uses a different standard that dates to the foundation of computing. Early computers drew an image to the screen starting in the top left corner of the screen and extending right and down. As a result, many graphics packages (draw2d included) still use the standard of viewing the top left corner of a window as an “origin”, with increasing x-coordinates extending to the right, and increasing y-coordinates extending downward (see figure below, right).

In the classic Cartesian plane, x-coordinates increase to the right, and y-coordinates increase upward. Three example points are shown according to this representation.
In graphics, x-coordinates increase to the right, and y-coordinates increase downward. Three example points are shown according to this representation; note that the horizontal positioning of these points are the same as the figure on the left, but their vertical position is inverted.

We will draw the head as a white circle. We will first declare a color called white; just as combining red, green, and blue light yields white light, our color variable will constitute the maximum amount of red, green, and blue. We then will set the fill color of c to our newly declared color.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

	white := canvas.MakeColor(255, 255, 255)
	c.SetFillColor(white)

	// save the image associated with the canvas
	c.SaveToPNG("output/fun.png")
}

We now will call the Circle() function from the canvas package. Remember when your geometry teacher told you that a circle is defined by its center and its radius? I know that you don’t, but it’s true nevertheless. When it comes to programming, Circle() takes three float64 arguments: the x- and y- coordinates of the center, followed by the radius.

We want the head to lie in the top part of the rectangle, halfway across from left to right. Because the canvas is 1000 pixels wide, we know that the x-coordinate of the circle’s center should be 500. In order to have the same amount of space on the top, left, and right, let’s make its y-coordinate 500 as well; that is, the circle’s center will be 500 pixels from the top. As for the radius, let’s set it to be 200 pixels.

After calling c.Circle(500, 500, 200), we will call c.Fill() to fill the circle that we have drawn with the current fill color, white.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

	white := canvas.MakeColor(255, 255, 255)
	c.SetFillColor(white)

	// make face
	c.Circle(500, 500, 200)
	c.Fill()

	// save the image associated with the canvas
	c.SaveToPNG("output/fun.png")
}

When we compile and run our code, we obtain the white circle below.

Adding a big round head to our drawing.

Let’s next add a nose, which we will draw as a small black circle, whose center will be slightly below the center of the head. First, we will set the fill color to black. The nose’s x-coordinate will therefore be the same as that of the head, but the y-coordinate will be a little bit larger at 550 pixels. As for the radius, let’s make it ten pixels.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

	white := canvas.MakeColor(255, 255, 255)
	c.SetFillColor(white)

	// make face
	c.Circle(500, 500, 200)
	c.Fill()

	// make nose
	c.SetFillColor(black)
	c.Circle(500, 550, 10)
	c.Fill()

	// save the image associated with the canvas
	c.SaveToPNG("output/fun.png")
}

When we compile and run our code, we see the figure shown below.

Do you think my nose is too small?

Next, we will add two eyes, which are once again circles. The eyes will fall slightly above the center of the face, and so we will make their y-coordinates equal to 475. Let’s move them to 75 pixels left and right of the center line, so that their x-coordinates will be 425 and 575. We will set their radii equal to 15 to make the eyes a little bigger than the nose.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

	white := canvas.MakeColor(255, 255, 255)
	c.SetFillColor(white)

	// make face
	c.Circle(500, 500, 200)
	c.Fill()

	// make nose
	c.SetFillColor(black)
	c.Circle(500, 550, 10)
	c.Fill()

	// make eyes
	c.Circle(425, 475, 15)
	c.Circle(575, 475, 15)
	c.Fill()

	// save the image associated with the canvas
	c.SaveToPNG("output/fun.png")
}

Our updated face is shown in the figure below and is starting to take shape as a landmark achievement of Western art.

Starting to take, uh, shape.

Finally, we will make a mouth, which gives us the opportunity to make a new color, red. This color will be formed by taking the maximum amount of the red variable and no green or blue, and so we define our red variable using MakeColor(255, 0, 0).

We will also make the mouth rectangular, which will allow us to use the function c.ClearRect(). This function takes four float64 parameters; the first two correspond to the minimum x- and y-coordinates of the rectangle, and the last two correspond to the maximum x- and y-coordinates of the rectangle. We will make the rectangle 200 pixels wide and 20 pixels tall, and so the x-coordinates will range from 400 to 600, and the y-coordinates will range from 600 to 620. We do not need to call c.Fill() after calling c.ClearRect(), since this function will fill the rectangle automatically.

// DrawFace takes no inputs and returns nothing.
// It uses the canvas package to draw a face to a file
// in the output folder.
func DrawFace() {
	// create the canvas
	c := canvas.CreateNewCanvas(1000, 2000)

	// fill canvas as black
	black := canvas.MakeColor(0, 0, 0)
	c.SetFillColor(black)
	c.Clear()

	white := canvas.MakeColor(255, 255, 255)
	c.SetFillColor(white)

	// make face
	c.Circle(500, 500, 200)
	c.Fill()

	// make nose
	c.SetFillColor(black)
	c.Circle(500, 550, 10)
	c.Fill()

	// make eyes
	c.Circle(425, 475, 15)
	c.Circle(575, 475, 15)
	c.Fill()

	// make mouth
	red := canvas.MakeColor(255, 0, 0)
	c.SetFillColor(red)
	c.ClearRect(400, 600, 600, 620)

	// save the image associated with the canvas
	c.SaveToPNG("output/fun.png")
}

Compiling and running our code produces our final face as shown below.

I am smiling.
Note: Feel free to continue editing our face. Add a body, some arms, or change colors however you like to get the hang of working with canvas.go. And feel free to post your drawings in our Discord!

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 ranging 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 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 := j * cellWidth
				y := i * cellWidth

				scalingFactor := 0.8 // to make circle smaller

				c.Circle(float64(x), float64(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
}


close

Love P4❤️? Join us and help share our journey!

Page Contents
Scroll to Top