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.
// 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.
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.
$ 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
// 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/.
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.
// 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.
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
- 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
mainthat 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.
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.