Game Engine Zero Vol 1 · Three Arcade Games
ch 10 / 24
Chapter 10

Testing Your Games (optional)

Testing a game

This optional chapter is for checking Pong after you change it. It records the keys held on each tick, replays them with no window, runs that replay in a Go test and writes a PNG for a chosen tick.

Pong's rules learn the outside world through six booleans per tick. A recording is a text file containing those booleans as key names, one line per tick. If the replay uses the same seed and mode, a fresh match reaches the same result because Step receives the same inputs in the same order.

Recording keys

The recording's format is the simplest that can be read by eye: one line per tick, the names of the keys held on that tick separated by spaces, and an empty line for a tick with nothing held. A two-minute match is 7,000 lines and a few kilobytes. Two flags go on the program: -record names a file to write the run's keys to when the window closes, and -replay names a file to play with no window at all.

▣ Build · stage 1 — record, and the file it writes, a new file and an extension of main.go
// cmd/pong/record.go — create
package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
	"strings"
)

// A recording is a text file with one line per tick: the names of the keys
// held on that tick, separated by spaces, or an empty line when none was.

var (
	record = flag.String("record", "", "write the keys held on every tick of this run to the file named")
	replay = flag.String("replay", "", "play the recording in the file named, with no window, and print the final score")
)

// String names the held keys, in a fixed order.
func (k keys) String() string {
	var names []string
	for _, key := range []struct {
		held bool
		name string
	}{{k.w, "w"}, {k.s, "s"}, {k.up, "up"}, {k.down, "down"}, {k.space, "space"}, {k.r, "r"}} {
		if key.held {
			names = append(names, key.name)
		}
	}
	return strings.Join(names, " ")
}

// parseKeys reads one line of a recording back into a keys value.
func parseKeys(line string) (keys, error) {
	var k keys
	for _, name := range strings.Fields(line) {
		switch name {
		case "w":
			k.w = true
		case "s":
			k.s = true
		case "up":
			k.up = true
		case "down":
			k.down = true
		case "space":
			k.space = true
		case "r":
			k.r = true
		default:
			return k, fmt.Errorf("%q is not a key", name)
		}
	}
	return k, nil
}

// writeRecording writes one line per tick to the file at path.
func writeRecording(path string, ticks []keys) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	w := bufio.NewWriter(f)
	for _, k := range ticks {
		fmt.Fprintln(w, k)
	}
	if err := w.Flush(); err != nil {
		f.Close()
		return err
	}
	return f.Close()
}

// readRecording reads the file at path back into one keys value per tick.
func readRecording(path string) ([]keys, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	var ticks []keys
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		k, err := parseKeys(sc.Text())
		if err != nil {
			return nil, fmt.Errorf("%s line %d: %v", path, len(ticks)+1, err)
		}
		ticks = append(ticks, k)
	}
	return ticks, sc.Err()
}

// replayMatch steps a fresh match through every tick of the recording at
// path, with no window, and prints the final score.
func replayMatch(path string) error {
	ticks, err := readRecording(path)
	if err != nil {
		return err
	}
	m := newMatch(*seedFlag, *soloFlag)
	for _, k := range ticks {
		m.Step(k)
	}
	fmt.Printf("%s: %d ticks, score %d-%d, %s\n", path, len(ticks), m.Score[0], m.Score[1], m.State)
	return nil
}
// cmd/pong/main.go — extend
// Game is Pong's window: the match, and everything drawn or heard.
type Game struct {
	match      *Match
	court      *ebiten.Image
	sp         *sprites
	spin       int // ticks the ball has been moving
	font       *digits.Font
	face       *face
	served     bool // has the first serve happened? The title shows until it has.
	hit, point *sound.Sound
	recorded   []keys // the keys held on every tick so far, when recording
}

func (g *Game) Update() error {
	k := readKeys()
	if *record != "" {
		g.recorded = append(g.recorded, k)
	}
	g.step(k)
	return nil
}

func main() {
	flag.Parse()
	if *replay != "" {
		if err := replayMatch(*replay); err != nil {
			log.Fatal(err)
		}
		return
	}
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Pong")
	ebiten.SetTPS(60)
	g, err := newGame()
	if err != nil {
		log.Fatal(err)
	}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
	if *record != "" {
		if err := writeRecording(*record, g.recorded); err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%s: %d ticks recorded\n", *record, len(g.recorded))
	}
}
mkdir -p tapes
go vet ./...
go run ./cmd/pong -solo -record tapes/mine.txt
head -30 tapes/mine.txt

Play a few points against the follower and close the window, and the terminal prints tapes/mine.txt: and the number of ticks the window was open, one line of the file for each; the lines are empty until the first key and then read space, w and s as the keys went down. The window's Update gained one line: before the keys go to step, they go into a slice, one entry a tick, when a recording was asked for. The keys are recorded where they are read and nowhere else, so what is in the file is what the match was given. String writes a keys value as words and parseKeys reads the words back, refusing a word that is not a key with the line it was on, and the rest of the file is the two functions that read and write a file of lines.

replayMatch is the whole of a headless run: read the file, make a match from the same flags the window would, step it once per line, and print how it ended. No window is opened; the program returns before ebiten.RunGame is reached. The recording holds the keys and nothing else, so the seed and the mode have to be given again on the command line, the same as they were when it was made; the worked failure below is what happens when they are not.

▣ Build · stage 2 — a recording replayed with no window

The three recordings below are the ones this volume's pictures of Pong were made from: a rally between two program paddles, one serve with nobody at the keys, and a whole match in solo mode. Put them in tapes/.

go run ./cmd/pong -replay tapes/ch07-match.txt -solo
$ go run ./cmd/pong -replay tapes/ch07-match.txt -solo
tapes/ch07-match.txt: 7456 ticks, score 7-0, over

The match that took two minutes to play replays without opening a window, and it ends 7–0 to the left, over. The 7,456 lines of the file, plus the seed and solo mode on the command line, are enough to reproduce the match.

The window, sprites, sounds and display clock do not affect the score. Change a rule in match.go and this printed line may change, which is why the replay belongs in a test.

⚠ Worked failure — the same recording without -solo
$ go run ./cmd/pong -replay tapes/ch07-match.txt
tapes/ch07-match.txt: 7456 ticks, score 5-2, serve

The same file, and a different match: 5–2, and not over. The keys in the file are the left player's, recorded against a right paddle that followed the ball; played against a right paddle that reads Up and Down, which nobody in the recording pressed, the right paddle never moves, the ball goes past it or does not on different ticks, and from the first point that differs every later line of the file is a key pressed for a match that is no longer happening. A recording replays a match only with the match's other inputs, the seed and the mode, given exactly as they were; the file does not carry them, so the command line has to.

Checking a replay in a test

▣ Build · stage 3 — the test
// cmd/pong/pong_test.go — create
package main

import (
	"testing"

	"gez/internal/shot"
)

// TestReplay plays the solo match recorded in tapes/ch07-match.txt through
// a fresh match, with no window, and checks how it ended. go test runs in
// the package's directory, and the recordings live at the module's root.
func TestReplay(t *testing.T) {
	shot.AtRoot()
	ticks, err := readRecording("tapes/ch07-match.txt")
	if err != nil {
		t.Fatal(err)
	}
	m := newMatch(1, true)
	for _, k := range ticks {
		m.Step(k)
	}
	if m.State != Over || m.Score != [2]int{7, 0} {
		t.Fatalf("after %d ticks: score %d-%d, %s; want 7-0, over", len(ticks), m.Score[0], m.Score[1], m.State)
	}
	t.Logf("%d ticks: score %d-%d, %s", len(ticks), m.Score[0], m.Score[1], m.State)
}
go test ./cmd/pong -run TestReplay -v
$ go test ./cmd/pong -run TestReplay -v
=== RUN   TestReplay
    pong_test.go:25: 7456 ticks: score 7-0, over
--- PASS: TestReplay (0.00s)
PASS
ok  	gez/cmd/pong	0.00s

A Go test is a function in a file ending in _test.go, named Test and something, taking a *testing.T; go test compiles the package with its test files and runs every such function -run selects, and -v prints each test's name and its t.Logf lines instead of only the verdict. The two times on the last lines are yours and differ from run to run; everything else is the same on every machine. The test is in package main, beside the game, so it can call newMatch and readRecording directly and needs nothing exported. It steps the recording through a fresh solo match with seed 1, the flags written as arguments, and fails with both scores if the match did not end 7–0 and over.

Now change a rule. Make the cap six instead of five, or the paddle two pixels taller, run the test again, and it fails, with the score the changed rules produce in its message; the recording's left player was aiming at a paddle that is no longer there. That is what the test is for: a change to the rules that was meant to leave the game alone is caught by a match that ends differently, and a change that was meant to alter the game gets a recording of its own. The os.Chdir at the top is because go test runs a test in the package's own directory, cmd/pong, while the recordings live at the module's root beside assets/.

⚙ Tool — go test

go test ./cmd/pong runs every test in the package and prints one line, ok or FAIL; -run Name selects tests whose names match; -v shows each test; -count=1 makes it run again instead of printing a cached result. A test passes unless it calls t.Fatal, t.Fatalf or t.Errorf. The reference is go help test and pkg.go.dev/testing.

Writing a frame image

A frame is the window's Draw called once after the match has been stepped to the tick wanted, and the pixels it painted read back and written as a PNG. Ebitengine only calls Draw inside a running window, so the program that makes a picture has to open one, run the ticks, draw, read the pixels, and close it; the package below does exactly that and nothing else, and every picture in this volume came out of it.

▣ Build · stage 4 — the pictures, two new files
// internal/shot/shot.go — create
// Package shot makes pictures of a game without anyone at the keyboard: it
// opens a window, advances a game a given number of ticks by calling a
// function once per tick, draws one picture, and writes it to a PNG file.
// Every picture in this volume was made with it.
package shot

import (
	"fmt"
	"image"
	"image/png"
	"os"
	"path/filepath"

	"github.com/hajimehoshi/ebiten/v2"
)

// The pictures are the size every game in this volume draws.
const (
	ScreenW = 320
	ScreenH = 180
)

// Frame is one picture to make: Step is called once for each of Ticks
// ticks, then Draw paints the picture, and the picture is written to Path.
type Frame struct {
	Path  string
	Ticks int
	Step  func()
	Draw  func(screen *ebiten.Image)
}

// runner is the game the window runs: it works through the frames in
// order, all of one frame's ticks in a single Update, then takes the
// picture in the Draw that follows.
type runner struct {
	frames []Frame
	i      int  // the frame being made
	ready  bool // the frame's ticks have all been stepped
	err    error
}

func (r *runner) Update() error {
	if r.err != nil || r.i >= len(r.frames) {
		return ebiten.Termination
	}
	if r.ready {
		return nil // stepped; wait for Draw to take the picture
	}
	f := r.frames[r.i]
	for t := 0; t < f.Ticks; t++ {
		f.Step()
	}
	r.ready = true
	return nil
}

func (r *runner) Draw(screen *ebiten.Image) {
	if !r.ready || r.i >= len(r.frames) {
		return
	}
	f := r.frames[r.i]
	f.Draw(screen)
	if err := save(screen, f.Path); err != nil {
		r.err = err
		return
	}
	fmt.Printf("%s: tick %d, %dx%d\n", f.Path, f.Ticks, screen.Bounds().Dx(), screen.Bounds().Dy())
	r.i++
	r.ready = false
}

func (r *runner) Layout(outsideWidth, outsideHeight int) (int, int) {
	return ScreenW, ScreenH
}

// save writes the picture screen holds to path as a PNG: every pixel, four
// bytes each, exactly what was drawn.
func save(screen *ebiten.Image, path string) error {
	w, h := screen.Bounds().Dx(), screen.Bounds().Dy()
	img := image.NewRGBA(image.Rect(0, 0, w, h))
	screen.ReadPixels(img.Pix)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	if err := png.Encode(f, img); err != nil {
		f.Close()
		return err
	}
	return f.Close()
}

// Run makes every frame in turn in one window, then closes it. Ebitengine
// runs one window per process, so a test makes all of its pictures in one
// call.
func Run(frames []Frame) error {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("shot")
	ebiten.SetTPS(60)
	r := &runner{frames: frames}
	if err := ebiten.RunGame(r); err != nil {
		return err
	}
	return r.err
}

// AtRoot moves the process to the module's root, where go.mod is, so that
// a test reads assets/ and tapes/ the way the programs do. go test starts a
// test in its package's directory; a second test in the same process finds
// the process already there.
func AtRoot() {
	for i := 0; i < 4; i++ {
		if _, err := os.Stat("go.mod"); err == nil {
			return
		}
		os.Chdir("..")
	}
}
// cmd/pong/shot_test.go — create
package main

import (
	"testing"

	"gez/internal/shot"
)

// newTestGame builds the window's game the way main does, from the flags'
// defaults or the seed and mode given.
func newTestGame(t *testing.T, seed uint64, solo bool) *Game {
	*seedFlag, *soloFlag = seed, solo
	g, err := newGame()
	if err != nil {
		t.Fatal(err)
	}
	return g
}

// play returns a step function that feeds the game one tick of the
// recording at a time, and nothing held once the recording runs out.
func play(t *testing.T, g *Game, path string) func() {
	ticks, err := readRecording(path)
	if err != nil {
		t.Fatal(err)
	}
	i := 0
	return func() {
		var k keys
		if i < len(ticks) {
			k = ticks[i]
		}
		i++
		g.step(k)
	}
}

// launch puts the ball in flight the way chapter 7's stage 2 does, with no
// serve: flying right and a little down.
func launch(g *Game) {
	g.match.State = Play
	g.match.Ball.VX, g.match.Ball.VY = 2, 1
}

// firstEvent steps a fresh match through a recording and returns the tick
// on which the event first happens, or -1.
func firstEvent(t *testing.T, m *Match, path string, want event) int {
	ticks, err := readRecording(path)
	if err != nil {
		t.Fatal(err)
	}
	for i, k := range ticks {
		if m.Step(k) == want {
			return i + 1
		}
	}
	return -1
}

// TestCh07Frames makes chapter 7's pictures.
func TestCh07Frames(t *testing.T) {
	shot.AtRoot()
	// Stage 1: the paddles under the keys, W held twenty ticks and Down thirty.
	paddles := newTestGame(t, 1, false)
	tick := 0
	movePaddles := func() {
		tick++
		paddles.step(keys{w: tick <= 20, down: tick <= 30})
	}
	// Stage 2: the launched ball, after its first bounce off the bottom.
	bounce := newTestGame(t, 1, false)
	launch(bounce)
	// Stage 3: the rally between two program paddles, six ticks after the
	// first return.
	rallyTick := firstEvent(t, launchedMatch(), "tapes/ch07-rally.txt", hit) + 6
	t.Logf("first return in tapes/ch07-rally.txt: tick %d", rallyTick-6)
	returned := newTestGame(t, 1, false)
	launch(returned)
	// Stage 6: the title before the first serve, and the band after the
	// first point of the serve recording.
	title := newTestGame(t, 1, false)
	pointTick := firstEvent(t, newMatch(1, false), "tapes/ch07-serve.txt", point)
	t.Logf("first point in tapes/ch07-serve.txt: tick %d", pointTick)
	served := newTestGame(t, 1, false)
	// Stage 7: the solo match, mid-rally and over.
	solo := newTestGame(t, 1, true)
	over := newTestGame(t, 1, true)
	matchTicks, err := readRecording("tapes/ch07-match.txt")
	if err != nil {
		t.Fatal(err)
	}
	err = shot.Run([]shot.Frame{
		{Path: "assets/frames/ch07-paddles.png", Ticks: 30, Step: movePaddles, Draw: paddles.drawMatch},
		{Path: "assets/frames/ch07-bounce.png", Ticks: 70, Step: func() { bounce.step(keys{}) }, Draw: bounce.drawMatch},
		{Path: "assets/frames/ch07-return.png", Ticks: rallyTick, Step: play(t, returned, "tapes/ch07-rally.txt"), Draw: returned.drawMatch},
		{Path: "assets/frames/ch07-title.png", Ticks: 0, Step: func() {}, Draw: title.Draw},
		{Path: "assets/frames/ch07-point.png", Ticks: pointTick + 1, Step: play(t, served, "tapes/ch07-serve.txt"), Draw: served.Draw},
		{Path: "assets/frames/ch07-solo.png", Ticks: 600, Step: play(t, solo, "tapes/ch07-match.txt"), Draw: solo.Draw},
		{Path: "assets/frames/ch07-over.png", Ticks: len(matchTicks), Step: play(t, over, "tapes/ch07-match.txt"), Draw: over.Draw},
	})
	if err != nil {
		t.Fatal(err)
	}
}

// launchedMatch is a fresh match with the ball launched as stage 2 does.
func launchedMatch() *Match {
	m := newMatch(1, false)
	m.State = Play
	m.Ball.VX, m.Ball.VY = 2, 1
	return m
}
go vet ./...
go test ./cmd/pong -run TestCh07Frames -v
$ go test ./cmd/pong -run TestCh07Frames -v
=== RUN   TestCh07Frames
    shot_test.go:76: first return in tapes/ch07-rally.txt: tick 74
    shot_test.go:83: first point in tapes/ch07-serve.txt: tick 87
assets/frames/ch07-paddles.png: tick 30, 320x180
assets/frames/ch07-bounce.png: tick 70, 320x180
assets/frames/ch07-return.png: tick 80, 320x180
assets/frames/ch07-title.png: tick 0, 320x180
assets/frames/ch07-point.png: tick 88, 320x180
assets/frames/ch07-solo.png: tick 600, 320x180
assets/frames/ch07-over.png: tick 7456, 320x180
--- PASS: TestCh07Frames (0.00s)
PASS
ok  	gez/cmd/pong	0.00s

A window opens for a moment and closes, and assets/frames/ holds seven pictures: the seven on chapter 7's page, made from the recordings above and the ticks the test names, in the order the page shows them. Open ch07-over.png beside the page's last picture; they are the same picture, and on most machines the same bytes, the soft edges of the band's words being the one place two graphics cards may differ by a shade.

shot.Run is a game in Ebitengine's sense with three methods, and it makes every frame of a list in one window, because Ebitengine runs one window per process. Its Update runs all of a frame's ticks in one call, by calling the frame's Step function that many times, and then holds still; the Draw that follows calls the frame's Draw function, reads the screen's pixels into a standard-library image with ReadPixels, encodes it as a PNG, and moves to the next frame. Stepping every tick inside one Update is what makes the picture a picture of a tick and not of a moment: the display's rate has nothing to say about which tick gets drawn.

The test file is the list. For each picture it builds the window's game the way main does, from the flags, and hands shot.Run a step function and a draw function. The step function is what stands in for the keyboard: play feeds the game one line of a recording a tick, and the paddles picture's function holds W and Down for a set number of ticks, since that picture needed no recording. The draw function is whichever of the window's drawing methods the picture wanted: drawMatch alone for the pictures of chapter 7's first stages, which had no score yet, and Draw for the rest. Two pictures are placed by an event rather than a tick, the first return and the first point; firstEvent steps a spare match through the recording to find the tick, and the test prints it, which is where chapter 7's 74 and 87 came from.

The recording, the rules, and the two things made from them: a score to check and a picture to look at A box labelled recording, one line a tick, with arrows to a box labelled Step, one call a line, and from Step two arrows: one to a box labelled the score, checked by the test, and one to a box labelled Draw once at tick N, then ReadPixels, then a PNG. A note says the window, the spin and the sounds are not on the path. FROM A FILE OF KEYS TO A SCORE AND A PICTURE tapes/…txt one line a tick m.Step(k) once per line the score: 7-0, over TestReplay checks it Draw once at tick N ReadPixels, then a PNG the seed and the mode go in beside the file; the window and the sounds are off the path
Figure 10.1 — a recording stepped through the rules, and the two things made from it: the final score a test can check, and any tick's picture.

Keeping tests on the rules

A recording replays because the match has no other input. The seed is fixed, the keys come from the file, and nothing in match.go reads a clock, a draw count, the mouse or the window.

The spin counter and the sounds live outside the rules, so they cannot change a score. A test needs a recording and an expected score. A frame image needs a recording, a tick and one call to Draw.

Checkpoint

✓ Checkpoint — what you can now do
  • Record the keys of a match, one line a tick, where they are read, and say what a recording does and does not contain.
  • Replay a recording with no window and get the score the match ended with, and explain from the failure box why the flags have to match.
  • Write a Go test in package main that plays a recording and fails with the scores when a rule change alters the match.
  • Make a picture of any tick of any of the three games with shot.Run, and say why the display's rate cannot change which tick is drawn.
  • Name the one property of the rules that makes all three possible.
⚡ Exercises — try first, then reveal
Exercise 1 — your own match, checked. Record a solo match of your own to tapes/mine.txt, replay it, and add a test that plays it and checks the score you got.

go run ./cmd/pong -solo -record tapes/mine.txt, play to seven, close the window; go run ./cmd/pong -replay tapes/mine.txt -solo prints your score; a second test in pong_test.go is TestReplay with the file name and the scores changed. Run both tests with -run Replay.

Exercise 2 — a rule change, caught. Make the paddles move three pixels a tick and run TestReplay.

paddleSpeed = 3 in match.go, and the test fails: the left paddle arrives at every row sooner than the recording's player expected, the returns come off different parts of the paddle, and the match ends 0–7, which the message prints. Put the speed back and it passes again.

Exercise 3 — a picture of Snake. Write cmd/snake/shot_test.go with one frame: the snake left to itself for two hundred ticks.

shot.AtRoot(), a game from newGame(), and one shot.Frame with Ticks: 200, Step: func() { g.step(keys{}) } and Draw: g.Draw, written to assets/frames/snake-200.png. The picture is the game-over band over a snake that ran into the right border on its twentieth move, at tick 160, and waited.