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.txtfiles 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 containsfunc 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 statementdefer 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 implementedSimulateGravity()in the next code along, you will be able to run the Jupiter moons simulation withgo 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.