Reading Files, Drawing, and Animations for Gravity in Go

STOP: Each chapter of Programming for Lovers comprises two parts. First, the “core text” presents critical concepts at a high level, avoiding language-specific details. The core text is followed by “code alongs,” where you will apply what you have learned while learning the specifics of the language syntax. We strongly suggest starting with this chapter’s core text (click the button below) to grasp the fundamentals before moving onto code alongs to see the application of these fundamentals. Once you have done so, you are ready to proceed with this code along!

Learning objectives

In this code along, we will discuss reading in a system of heavenly bodies from file, and producing an animation showing a sequence of time steps of this system over time. Once we have done so, we will be prepared to write a physics engine in the next code along to simulate the effects of gravity on our system.

Setup

To complete this code along and the next code along on building a physics engine for our gravity simulator, we will need starter code.

We provide this starter code as a single download gravity.zip. Download this code here, and then expand the folder. Move the resulting gravity directory into your go/src source code directory.

The gravity directory has the following structure:

  • data: a directory containing .txt files that represent different initial systems that we will simulate.
  • output: an initially empty directory that will contain the animated GIFs that we draw.
  • datatypes.go: a file that contains struct type declarations.
  • gravity.go: a file that will contain the physics engine functions we will write in the next code along.
  • io.go: a file that contains code for reading in a system of heavenly bodies from file.
  • drawing.go: a file that contains code for drawing a system to canvas and creating GIF animations over multiple time steps.
  • main.go: a file that contains func main(), where we will call functions to read in systems, run our gravity simulation, and draw the results.

Code along summary

Organizing data

We begin with datatypes.go, which stores all our struct type declarations. As we saw in the core text, we can design objects top-down. We recall the language-neutral type declarations for our gravity simulator below, starting with a Universe object, which contains an array of Body objects as one of its fields.

type Universe
    bodies []Body
    width float
    gravitationalConstant float

In turn, we define a Body object, which includes multiple fields involving OrderedPair objects, as shown below.

type Body
    name string
    mass float
    radius float
    position OrderedPair
    velocity OrderedPair
    acceleration OrderedPair
    red int
    green int
    blue int

type OrderedPair
    x float
    y float

Applying what we learned in the previous code along about struct declarations, we implement these three types in datatypes.go as follows.

package main

type Body struct {
	name                             string
	mass, radius                     float64
	position, velocity, acceleration OrderedPair
	red, green, blue                 uint8 // values between 0 and 255
}

type OrderedPair struct {
	x, y float64
}

type Universe struct {
	bodies                []Body
	width                 float64 // we will draw the universe as a square
	gravitationalConstant float64
}

Notice that the color channels (red, green, blue) are declared as uint8, an unsigned 8-bit integer that holds values from 0 to 255. This matches the range of a standard RGB color channel. Also notice that the bodies field of Universe is a slice of Body structs; when the Universe is initialized with a zero value, this slice will be nil until bodies are appended to it.

Reading the universe from file

Next, we turn to io.go, which contains code for reading an initial state of the universe from a text file. Each file in our data/ directory encodes a universe using the following format: the first line gives the width of the universe, and the second line gives its gravitational constant. After that, each body is described by six lines in order: a name (preceded by >), its RGB color, its mass, its radius, its position (as an x,y pair), and its velocity (as an x,y pair).

We begin by writing two helper functions: ParseOrderedPair(), which parses a comma-separated pair of floats from a string, and ParseRGB(), which parses a comma-separated triple of integers.

func ParseOrderedPair(line string) (OrderedPair, error) {
	// Replace the Unicode minus sign with a standard hyphen-minus
	line = strings.ReplaceAll(line, "−", "-")

	parts := strings.Split(line, ",")
	if len(parts) != 2 {
		return OrderedPair{}, fmt.Errorf("invalid ordered pair")
	}
	x, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
	if err != nil {
		return OrderedPair{}, err
	}
	y, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
	if err != nil {
		return OrderedPair{}, err
	}
	return OrderedPair{x: x, y: y}, nil
}

func ParseRGB(line string) (uint8, uint8, uint8, error) {
	parts := strings.Split(line, ",")
	if len(parts) != 3 {
		return 0, 0, 0, fmt.Errorf("invalid RGB format")
	}
	red, err := strconv.Atoi(strings.TrimSpace(parts[0]))
	if err != nil {
		return 0, 0, 0, err
	}
	green, err := strconv.Atoi(strings.TrimSpace(parts[1]))
	if err != nil {
		return 0, 0, 0, err
	}
	blue, err := strconv.Atoi(strings.TrimSpace(parts[2]))
	if err != nil {
		return 0, 0, 0, err
	}
	return uint8(red), uint8(green), uint8(blue), nil
}

Both functions return their result together with an error value. In Go, functions signal failure by returning an error as a second return value rather than by throwing exceptions. A nil error means success; any other value indicates failure.

Now we can write the main ReadUniverse() function. It opens the file, reads the first two lines for the universe’s width and gravitational constant, and then reads subsequent lines in groups of six to build each Body. We use a lineType counter to keep track of which field we expect next for the current body.

func ReadUniverse(filename string) (Universe, error) {
	file, err := os.Open(filename)
	if err != nil {
		return Universe{}, err
	}
	defer file.Close()

	var universe Universe
	scanner := bufio.NewScanner(file)

	// Read the first line: universe width
	if scanner.Scan() {
		width, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
		if err != nil {
			return Universe{}, fmt.Errorf("invalid universe width: %v", err)
		}
		universe.width = width
	}

	// Read the second line: gravitational constant
	if scanner.Scan() {
		g, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
		if err != nil {
			return Universe{}, fmt.Errorf("invalid gravitational constant: %v", err)
		}
		universe.gravitationalConstant = g
	}

	var currentBody Body
	lineType := 0 // tracks which field we expect next

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		switch lineType {
		case 0: // body name, starting with '>'
			if strings.HasPrefix(line, ">") {
				if currentBody.name != "" {
					universe.bodies = append(universe.bodies, currentBody)
				}
				currentBody = Body{}
				currentBody.name = strings.TrimSpace(line[1:])
				lineType = 1
			}
		case 1: // RGB color
			red, green, blue, err := ParseRGB(line)
			if err != nil {
				return Universe{}, fmt.Errorf("invalid RGB values: %v", err)
			}
			currentBody.red, currentBody.green, currentBody.blue = red, green, blue
			lineType = 2
		case 2: // mass
			mass, err := strconv.ParseFloat(line, 64)
			if err != nil {
				return Universe{}, fmt.Errorf("invalid mass: %v", err)
			}
			currentBody.mass = mass
			lineType = 3
		case 3: // radius
			radius, err := strconv.ParseFloat(line, 64)
			if err != nil {
				return Universe{}, fmt.Errorf("invalid radius: %v", err)
			}
			currentBody.radius = radius
			lineType = 4
		case 4: // position
			position, err := ParseOrderedPair(line)
			if err != nil {
				return Universe{}, fmt.Errorf("invalid position: %v", err)
			}
			currentBody.position = position
			lineType = 5
		case 5: // velocity
			velocity, err := ParseOrderedPair(line)
			if err != nil {
				return Universe{}, fmt.Errorf("invalid velocity: %v", err)
			}
			currentBody.velocity = velocity
			lineType = 0
		}
	}

	// Add the last body
	if currentBody.name != "" {
		universe.bodies = append(universe.bodies, currentBody)
	}

	return universe, scanner.Err()
}
Note: The statement defer file.Close() schedules the file to be closed when the surrounding function returns, no matter how it returns. This is idiomatic Go for ensuring resources are released.

Drawing to canvas

Now we turn to drawing.go, which handles converting a Universe into a visual image. We will use a custom canvas package (included with the starter code) that provides functions for drawing circles and lines onto an image. Our DrawToCanvas() function takes a Universe, a canvas width in pixels, and a map of body trails, and returns an image.Image.

func DrawToCanvas(u Universe, canvasWidth int, trails map[int][]OrderedPair, frameCounter int) image.Image {
	c := canvas.CreateNewCanvas(canvasWidth, canvasWidth)

	// Set canvas to white
	c.SetFillColor(canvas.MakeColor(255, 255, 255))
	c.ClearRect(0, 0, canvasWidth, canvasWidth)

	// Draw trails for all bodies
	DrawTrails(&c, trails, frameCounter, u.width, float64(canvasWidth), u.bodies)

	// Draw the bodies themselves
	for _, b := range u.bodies {
		c.SetFillColor(canvas.MakeColor(b.red, b.green, b.blue))
		centerX := (b.position.x / u.width) * float64(canvasWidth)
		centerY := (b.position.y / u.width) * float64(canvasWidth)
		r := (b.radius / u.width) * float64(canvasWidth)

		// Jupiter's moons are very small relative to the universe width;
		// multiply their drawn radius so they are visible.
		if b.name == "Io" || b.name == "Ganymede" || b.name == "Callisto" || b.name == "Europa" {
			c.Circle(centerX, centerY, jupiterMoonMultiplier*r)
		} else {
			c.Circle(centerX, centerY, r)
		}
		c.Fill()
	}

	return c.GetImage()
}

Each body’s position is given in universe coordinates (values between 0 and u.width). To place the body on the canvas, we scale by dividing by u.width and multiplying by canvasWidth. We apply the same scaling to the body’s radius.

Trails

To make our animation more beautiful, we will draw fading trails behind each body as it moves. We store trails as a map from body index to a slice of past positions. The DrawTrails() function iterates over each body’s trail, drawing line segments between consecutive positions and fading the color from white (oldest) to the body’s own color (most recent).

const (
	trailFrequency        = 10
	numberOfTrailFrames   = 100
	jupiterMoonMultiplier = 10.0
	trailThicknessFactor  = 0.2
)

func DrawTrails(c *canvas.Canvas, trails map[int][]OrderedPair, frameCounter int, uWidth, canvasWidth float64, bodies []Body) {
	for bodyIndex, b := range bodies {
		trail := trails[bodyIndex]
		numTrails := len(trail)

		lineWidth := (b.radius / uWidth) * canvasWidth * trailThicknessFactor
		if b.name == "Ganymede" || b.name == "Io" || b.name == "Callisto" || b.name == "Europa" {
			lineWidth *= jupiterMoonMultiplier
		}
		c.SetLineWidth(lineWidth)

		for j := 0; j < numTrails-1; j++ {
			alpha := 255.0 * float64(j) / float64(numTrails)
			red := uint8((1-alpha/255.0)*255.0 + (alpha/255.0)*float64(b.red))
			green := uint8((1-alpha/255.0)*255 + (alpha/255.0)*float64(b.green))
			blue := uint8((1-alpha/255.0)*255 + (alpha/255.0)*float64(b.blue))

			c.SetStrokeColor(canvas.MakeColor(red, green, blue))

			startX := (trail[j].x / uWidth) * canvasWidth
			startY := (trail[j].y / uWidth) * canvasWidth
			endX := (trail[j+1].x / uWidth) * canvasWidth
			endY := (trail[j+1].y / uWidth) * canvasWidth

			c.MoveTo(startX, startY)
			c.LineTo(endX, endY)
			c.Stroke()
		}
	}
}

Animating and creating a GIF

The AnimateSystem() function takes a slice of Universe objects (one per time step), a canvas width, and a drawing frequency. It returns a slice of image.Image objects — one image for every drawingFrequency time steps. Sampling every k-th frame instead of every frame keeps the output file size manageable.

func AnimateSystem(timePoints []Universe, canvasWidth, drawingFrequency int) []image.Image {
	images := make([]image.Image, 0)
	trails := make(map[int][]OrderedPair)

	for i, u := range timePoints {
		// Update trails periodically
		if (i*trailFrequency)%drawingFrequency == 0 {
			for bodyIndex, body := range u.bodies {
				trails[bodyIndex] = append(trails[bodyIndex], body.position)
				if len(trails[bodyIndex]) > numberOfTrailFrames*trailFrequency {
					trails[bodyIndex] = trails[bodyIndex][1:]
				}
			}
		}
		// Draw a frame every drawingFrequency steps
		if i%drawingFrequency == 0 {
			images = append(images, DrawToCanvas(u, canvasWidth, trails, i))
		}
	}

	return images
}

Once we have a slice of images, we pass it to the gifhelper package’s ImagesToGIF() function to write an animated GIF file to the output/ directory.

The main function

Finally, we look at main.go, which ties everything together. It reads five command-line arguments from os.Args: the name of the scenario (used to locate the input file), the number of time steps to simulate, the time interval per step, the canvas width in pixels, and the drawing frequency.

func main() {
	fmt.Println("Let's simulate gravity!")

	if len(os.Args) != 6 {
		panic("Error: incorrect number of command line arguments.")
	}

	inputFile := "data/" + os.Args[1] + ".txt"
	outputFile := "output/" + os.Args[1]

	initialUniverse, err := ReadUniverse(inputFile)
	Check(err)

	numGens, err2 := strconv.Atoi(os.Args[2])
	Check(err2)

	time, err3 := strconv.ParseFloat(os.Args[3], 64)
	Check(err3)

	canvasWidth, err4 := strconv.Atoi(os.Args[4])
	Check(err4)

	drawingFrequency, err5 := strconv.Atoi(os.Args[5])
	Check(err5)

	if drawingFrequency <= 0 {
		panic("Error: nonpositive number given as drawingFrequency.")
	}

	fmt.Println("Command line arguments read!")

	// SimulateGravity will be implemented in the next code along.
	timePoints := SimulateGravity(initialUniverse, numGens, time)

	images := AnimateSystem(timePoints, canvasWidth, drawingFrequency)

	gifhelper.ImagesToGIF(images, outputFile)

	fmt.Println("Simulation complete.")
}

func Check(err error) {
	if err != nil {
		panic(err)
	}
}

The Check() helper function is a concise way to panic on any error. In a production program we might handle errors more gracefully, but for a simulation script, panicking immediately with the error message is a reasonable approach.

STOP: Once you have implemented SimulateGravity() in the next code along, you will be able to run the Jupiter moons simulation with go run *.go jupiterMoons 1000 60 1500 10. For now, read through the starter code and make sure you understand how the data flows from file through to the animation.

In the next code along, we will implement the SimulateGravity() function and all the physics needed to bring our solar system models to life.

Scroll to Top
Programming for Lovers banner no background
programming for lovers logo cropped

Join our community!

programming for lovers logo cropped
Programming for Lovers banner no background

Join our community!