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

Pong

Building Pong

This chapter builds Pong as a complete game. It has two paddles, a ball, a serve, a score, a match to seven, solo mode and two sounds.

Put the rules in Match. Its Step method changes the match once per tick from the keys held on that tick. The window code reads the match, draws it with Ebitengine and plays sounds when the match reports an event.

Moving two paddles

▣ Build · stage 1 — the match's first two rules, and the window that draws them
// cmd/pong/match.go — create
package main

// The court is the whole picture, with a border two pixels wide just
// inside its edge. Everything that moves stays inside the border.
const (
	courtW = 320
	courtH = 180
	border = 2
)

// A paddle is four pixels wide and twenty-four tall, moves two pixels a
// tick, and never leaves the court.
const (
	paddleW     = 4
	paddleH     = 24
	paddleSpeed = 2
	leftX       = 8                    // the left paddle's column
	rightX      = courtW - 8 - paddleW // the right paddle's column, 308
)

// Paddle is one side's bat: the position of its top-left corner, in pixels.
type Paddle struct {
	X, Y float64
}

// newPaddle returns a paddle on column x, centred on the court.
func newPaddle(x float64) Paddle {
	return Paddle{X: x, Y: (courtH - paddleH) / 2}
}

// Move steps the paddle two pixels up, two pixels down, or not at all, and
// keeps it inside the border.
func (p *Paddle) Move(up, down bool) {
	if up {
		p.Y -= paddleSpeed
	}
	if down {
		p.Y += paddleSpeed
	}
	p.Y = clamp(p.Y, border, courtH-border-paddleH)
}

// clamp returns v held inside [lo, hi].
func clamp(v, lo, hi float64) float64 {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

// The ball is four pixels square. Its position is its top-left corner and
// its velocity is in pixels a tick.
const ballSize = 4

// Ball is the ball: where it is and how far it moves each tick.
type Ball struct {
	X, Y   float64
	VX, VY float64
}

// newBall returns a ball at rest in the centre of the court.
func newBall() Ball {
	return Ball{X: (courtW - ballSize) / 2, Y: (courtH - ballSize) / 2}
}

// Match is the state of one game, advanced one tick at a time by Step.
type Match struct {
	Left  Paddle // W and S
	Right Paddle // Up and Down
	Ball  Ball
}

// newMatch returns a match at tick zero: both paddles centred, the ball at
// rest in the middle of the court.
func newMatch() *Match {
	return &Match{Left: newPaddle(leftX), Right: newPaddle(rightX), Ball: newBall()}
}

// movePaddles moves the left paddle by W and S and the right by Up and
// Down.
func (m *Match) movePaddles(k keys) {
	m.Left.Move(k.w, k.s)
	m.Right.Move(k.up, k.down)
}

// Step advances the match by one tick with the keys held during it.
func (m *Match) Step(k keys) {
	m.movePaddles(k)
}
// cmd/pong/main.go — create
package main

import (
	"image"
	"image/color"
	"image/png"
	"log"
	"os"

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

var (
	courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}
	lineColor  = color.RGBA{R: 232, G: 232, B: 232, A: 255}
)

// keys is what the players are doing this tick: six keys, held or not.
type keys struct {
	w, s, up, down, space, r bool
}

// readKeys asks Ebitengine about the six keys, once a tick.
func readKeys() keys {
	return keys{
		w:     ebiten.IsKeyPressed(ebiten.KeyW),
		s:     ebiten.IsKeyPressed(ebiten.KeyS),
		up:    ebiten.IsKeyPressed(ebiten.KeyArrowUp),
		down:  ebiten.IsKeyPressed(ebiten.KeyArrowDown),
		space: ebiten.IsKeyPressed(ebiten.KeySpace),
		r:     ebiten.IsKeyPressed(ebiten.KeyR),
	}
}

// newCourt paints the court once, into an image the game copies to the
// screen every frame: the fill, a border two pixels wide just inside the
// edge, and a net of dashes down the middle.
func newCourt() *ebiten.Image {
	img := ebiten.NewImage(courtW, courtH)
	img.Fill(courtColor)
	vector.StrokeRect(img, 1, 1, courtW-2, courtH-2, 2, lineColor, false)
	for y := 0; y < courtH; y += 8 {
		vector.FillRect(img, 159, float32(y), 2, 4, lineColor, false)
	}
	return img
}

// sprites is the sheet cut into the pictures the game draws.
type sprites struct {
	left, right *ebiten.Image
	ball        [4]*ebiten.Image
}

// loadSprites reads the sheet and cuts the paddles and the ball's four
// spin frames out of it.
func loadSprites(path string) (*sprites, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	img, err := png.Decode(f)
	if err != nil {
		return nil, err
	}
	sheet := ebiten.NewImageFromImage(img)
	cut := func(x, y, w, h int) *ebiten.Image {
		return sheet.SubImage(image.Rect(x, y, x+w, y+h)).(*ebiten.Image)
	}
	s := &sprites{left: cut(0, 0, 4, 24), right: cut(4, 0, 4, 24)}
	for i := range s.ball {
		s.ball[i] = cut(8+4*i, 0, 4, 4)
	}
	return s, nil
}

// drawSprite draws img with its top-left corner at (x, y), rounded down to
// the pixel the corner is in: the rules keep fractions of a pixel and the
// screen has none.
func drawSprite(dst, img *ebiten.Image, x, y float64) {
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(float64(int(x)), float64(int(y)))
	dst.DrawImage(img, op)
}

// Game is Pong's window: the match, and everything drawn.
type Game struct {
	match *Match
	court *ebiten.Image
	sp    *sprites
}

// newGame builds the match and loads what the window draws.
func newGame() (*Game, error) {
	sp, err := loadSprites("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	return &Game{match: newMatch(), court: newCourt(), sp: sp}, nil
}

// step advances the window's game one tick.
func (g *Game) step(k keys) {
	g.match.Step(k)
}

func (g *Game) Update() error {
	g.step(readKeys())
	return nil
}

// drawMatch draws what the rules say is where: the two paddles and the
// ball.
func (g *Game) drawMatch(screen *ebiten.Image) {
	m := g.match
	screen.DrawImage(g.court, nil)
	drawSprite(screen, g.sp.left, m.Left.X, m.Left.Y)
	drawSprite(screen, g.sp.right, m.Right.X, m.Right.Y)
	drawSprite(screen, g.sp.ball[0], m.Ball.X, m.Ball.Y)
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawMatch(screen)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return courtW, courtH
}

func main() {
	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)
	}
}
go vet ./...
go run ./cmd/pong
The court with the left paddle high up, the right paddle low down, and the ball resting in the exact middle.
The window after stage 1, half a second in, with W held for the first twenty ticks and Down for the first thirty: the left paddle 40 pixels up, the right 60 down, the ball at rest in the middle.

match.go imports nothing from Ebitengine. It holds the court size, the paddles, the ball and the rule that moves the paddles.

main.go handles the window. It reads six booleans into keys, draws the court image, cuts the sheet into sprites and draws them with DrawImage. drawSprite rounds positions to whole pixels so the rules can use float64 positions without choosing a screen row.

The two paddles differ on the sheet by one lit column each, the one facing the net, so the sprites are cut as a left and a right and not one paddle drawn twice. Move is chapter 3's clamp with the paddle's own numbers, and rightX is worked out from the court's width so that the two paddles sit eight pixels in from either edge. Hold W and Up together and both paddles climb; the picture above was made by holding W for a third of a second and Down for half of one.

Bouncing the ball

▣ Build · stage 2 — the ball moves, and spins as it goes, extend both files
// cmd/pong/match.go — extend
// newMatch returns a match at tick zero: both paddles centred, and the
// ball flying from the middle of the court, right and a little down.
func newMatch() *Match {
	m := &Match{Left: newPaddle(leftX), Right: newPaddle(rightX), Ball: newBall()}
	m.Ball.VX, m.Ball.VY = 2, 1
	return m
}

// Step advances the match by one tick with the keys held during it.
func (m *Match) Step(k keys) {
	m.movePaddles(k)
	m.Ball.Move()
}

// Move advances the ball one tick and reflects it off the top and bottom
// borders: whatever part of the step went past the border comes back.
func (b *Ball) Move() {
	b.X += b.VX
	b.Y += b.VY
	const top, bottom = border, courtH - border - ballSize
	if b.Y < top {
		b.Y = 2*top - b.Y
		b.VY = -b.VY
	}
	if b.Y > bottom {
		b.Y = 2*bottom - b.Y
		b.VY = -b.VY
	}
}
// cmd/pong/main.go — extend
// Game is Pong's window: the match, and everything drawn.
type Game struct {
	match *Match
	court *ebiten.Image
	sp    *sprites
	spin  int // ticks the ball has been moving
}

// step advances the window's game one tick: the match, and the ball's spin.
func (g *Game) step(k keys) {
	g.match.Step(k)
	if b := g.match.Ball; b.VX != 0 || b.VY != 0 {
		g.spin++
	}
}

// drawMatch draws what the rules say is where: the two paddles and the
// ball's current spin frame.
func (g *Game) drawMatch(screen *ebiten.Image) {
	m := g.match
	screen.DrawImage(g.court, nil)
	drawSprite(screen, g.sp.left, m.Left.X, m.Left.Y)
	drawSprite(screen, g.sp.right, m.Right.X, m.Right.Y)
	drawSprite(screen, g.sp.ball[ballFrame(g.spin, m.Ball.VX)], m.Ball.X, m.Ball.Y)
}

// The ball turns one frame every six ticks while it moves, the other way
// round when it moves the other way.
const spinEvery = 6

// ballFrame picks the spin frame for a ball that has been moving for spin
// ticks, turning with the direction it travels.
func ballFrame(spin int, vx float64) int {
	f := (spin / spinEvery) % 4
	if vx < 0 {
		f = (4 - f) % 4
	}
	return f
}
go vet ./...
go run ./cmd/pong
The court with both paddles centred and the ball low in the right half, a little above the bottom border, having just bounced.
The window seventy ticks after stage 2 starts: the ball left the middle flying right and down, reached the bottom border on tick 66, bounced off it on tick 67, and is on its way up.

The ball starts in the middle and moves two pixels right and one pixel down each tick. When its top edge would pass the bottom border, Move reflects the extra distance back inside the court and flips the vertical speed.

On tick 67 the ball would be one pixel past the lowest row it may occupy. Reflection puts it one pixel inside instead. Stopping it on the border would lose part of the step and make faster balls hesitate at the wall.

The spin counter counts only while the ball moves, so a resting ball holds its frame. The counter belongs to the window code because it changes only the picture, not the match rules.

Returning from the paddle

A paddle returns the ball. Where the ball struck the paddle decides the angle it leaves at: straight back from the paddle's middle, up to sixty degrees off straight from either end, so that a player can aim by where they meet it. Each return adds a quarter of a pixel a tick to the ball's speed, so a long rally gets faster. And the test for a strike is the one the rectangles of chapter 2 make easy: after the ball has moved, does its four-by-four rectangle overlap the paddle's four-by-twenty-four?

▣ Build · stage 3 — contact, and the angle of the return, extend cmd/pong/match.go
// cmd/pong/match.go — extend
import "math"

// Step advances the match by one tick with the keys held during it.
func (m *Match) Step(k keys) {
	m.movePaddles(k)
	m.fly()
}

// Rect is an axis-aligned rectangle: its top-left corner, width and height.
type Rect struct {
	X, Y, W, H float64
}

// Overlaps reports whether two rectangles share any area. Rectangles that
// only touch along an edge do not overlap.
func (r Rect) Overlaps(o Rect) bool {
	return r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H
}

// Rect is the paddle's rectangle.
func (p Paddle) Rect() Rect {
	return Rect{X: p.X, Y: p.Y, W: paddleW, H: paddleH}
}

// Rect is the ball's rectangle.
func (b Ball) Rect() Rect {
	return Rect{X: b.X, Y: b.Y, W: ballSize, H: ballSize}
}

// A paddle returns the ball at an angle set by where the ball struck it, up
// to sixty degrees from straight, and a quarter of a pixel a tick faster
// than it arrived.
const (
	maxAngle = 60.0 // degrees, at the paddle's ends
	speedUp  = 0.25 // pixels a tick added on every return
)

// Return sends the ball away from paddle p: dir is +1 to send it right,
// off the left paddle, and -1 to send it left. The angle comes from how
// far the ball's centre is from the paddle's centre, the speed grows by
// speedUp, and the ball is placed against the paddle's face.
func (b *Ball) Return(p Paddle, dir float64) {
	offset := ((b.Y + ballSize/2) - (p.Y + paddleH/2)) / (paddleH / 2)
	offset = clamp(offset, -1, 1)
	angle := offset * maxAngle * math.Pi / 180
	speed := math.Hypot(b.VX, b.VY) + speedUp
	b.VX = dir * speed * math.Cos(angle)
	b.VY = speed * math.Sin(angle)
	if dir > 0 {
		b.X = p.X + paddleW
	} else {
		b.X = p.X - ballSize
	}
}

// fly moves the ball one tick and returns it off a paddle it met,
// reporting whether it met one.
func (m *Match) fly() bool {
	m.Ball.Move()
	if m.Ball.Rect().Overlaps(m.Right.Rect()) {
		m.Ball.Return(m.Right, -1)
		return true
	}
	if m.Ball.Rect().Overlaps(m.Left.Rect()) {
		m.Ball.Return(m.Left, +1)
		return true
	}
	return false
}
go vet ./...
go run ./cmd/pong
The court with the right paddle low, near the bottom border, and the ball just to its left, heading away from it up and to the left; the left paddle sits a little below centre.
The window eighty ticks after stage 3 starts, in a rally between two players: the right paddle went down to meet the ball, returned it on tick 74, and six ticks later the ball is on its way back at an angle.

Hold Down as the ball comes and the right paddle meets it, and the ball comes back at an angle that depends on where on the paddle it landed: near the top of the paddle it goes up, near the middle it goes straight, and it is a quarter of a pixel a tick faster than it was. In the picture, the right paddle came down to meet the ball below its middle and sent it back down and to the left. The first file has its first import, math, for the cosine and sine that turn an angle into a velocity and the Hypot that turns a velocity back into a speed. A player who never moves never returns the ball, because the ball was launched at a row the centred paddle does not cover, and it leaves on the right; a serve is stage 5's job.

Return ends by placing the ball against the paddle's face. The overlap test fires when the ball is already partly inside the paddle, and a ball left there would overlap it again on the next tick, be returned again, and rattle. Putting its edge on the face means the next tick's step carries it clear. fly tests the right paddle first and the left second and stops at the first strike; the ball cannot be inside both, so the order never matters.

∑ Math Interlude — where it struck, as an angle

The paddle is 24 pixels tall, so its middle is 12 pixels from either end. A ball whose centre is 6 pixels below the paddle's middle is halfway to the end, and half of sixty degrees is thirty. Half of 12 is 0.5, and 0.5 × 60° = 30°; a ball 12 pixels off is at 1.0, the full sixty; a ball dead centre is at 0 and goes straight back. That fraction is offset, clamped to the range −1 to 1 for a ball that overlaps only the paddle's corner. The speed is the length of the velocity, by Pythagoras: a ball moving 2 across and 1 down is moving √(2² + 1²) = √5 = 2.236 pixels a tick, and after the return it moves 2.486. The new velocity is that speed pointed along the angle: across, the speed times the cosine, and down, the speed times the sine, so at thirty degrees and 2.25 pixels a tick the ball moves 1.949 across and 1.125 down every tick, and dir flips the across part to send it away from the paddle.

offsethow far the ball's centre is from the paddle's centre, as a fraction of half the paddle: −1 at the top, 0 in the middle, 1 at the bottom
angleoffset × 60°, in radians for the library: degrees × π / 180
speedhow far the ball moves a tick, whatever its direction: √(VX² + VY²), which math.Hypot computes
cos, sinthe across and down parts of a unit step along an angle: cos 30° = 0.866, sin 30° = 0.5
dir+1 for a return off the left paddle, which sends the ball right; −1 off the right paddle
The return angle: where the ball's centre meets the paddle sets the direction it leaves in A tall paddle on the right with its middle marked. Three balls sit against its face: one at the middle, leaving straight left; one halfway to the bottom end, leaving down and left at thirty degrees; one at the bottom end, leaving at sixty degrees. Labels give the offset and angle of each, and a note says the speed grows by a quarter each return up to the cap. THE RIGHT PADDLE, AND THREE BALLS THAT MET IT middle offset 0, angle 0° offset 0.5, angle 30° offset 1, angle 60° the ball's centre against the paddle's middle, divided by 12, is the offset; times 60° is the angle every return: speed + 0.25, up to 5
Figure 7.1 — three balls against the right paddle's face, and the direction each leaves in: straight from the middle, thirty degrees from halfway down, sixty from the end.
⚠ Worked failure — no cap on the speed, and a ball that steps through the paddle

Stage 3's Return adds a quarter of a pixel a tick on every return and never stops adding. Change one line in newMatch so that the ball is launched straight along the middle row, m.Ball.VX, m.Ball.VY = 2, 0, and touch nothing: the ball meets the middle of each centred paddle, comes straight back, and speeds up. On the 32nd return it reaches ten pixels a tick, and thirty ticks later, on tick 1911, this is the picture:

The court with both paddles centred and the ball on the far side of the right paddle, touching its outer edge, on its way out of the court.
The stage 3 program with the straight launch, on tick 1911: the ball is past the right paddle, having gone from column 302 to column 312 in one tick.

The ball's left edge was at column 302 on tick 1910 and at 312 on tick 1911, and the paddle occupies columns 308 to 311. For the overlap test to fire, the ball's left edge has to be past 304 and short of 312 at the end of some tick, a window eight pixels wide, and a ball moving ten pixels a tick can step over an eight-pixel window entirely, which it did. The paddle was there; the ball was never inside it at the end of a tick, and the test asks only about the end of a tick. A point follows on tick 1912. The fix this game chooses is the one that keeps the test: cap the speed below the window. The window is the paddle's width plus the ball's, eight pixels, and a ball that never moves more than five a tick cannot cross it in one step. A game whose ball had to outrun its paddles' width would need a different test, one that asks about the path between two ticks and not the position at the end of one.

▣ Build · stage 4 — five pixels a tick, and no more, extend cmd/pong/match.go
// cmd/pong/match.go — extend
// A paddle returns the ball at an angle set by where the ball struck it, up
// to sixty degrees from straight, and a quarter of a pixel a tick faster
// than it arrived, up to five pixels a tick.
const (
	maxAngle = 60.0 // degrees, at the paddle's ends
	speedUp  = 0.25 // pixels a tick added on every return
	maxSpeed = 5.0  // pixels a tick, the cap
)

// Return sends the ball away from paddle p: dir is +1 to send it right,
// off the left paddle, and -1 to send it left. The angle comes from how
// far the ball's centre is from the paddle's centre, the speed grows by
// speedUp up to maxSpeed, and the ball is placed against the paddle's face.
func (b *Ball) Return(p Paddle, dir float64) {
	offset := ((b.Y + ballSize/2) - (p.Y + paddleH/2)) / (paddleH / 2)
	offset = clamp(offset, -1, 1)
	angle := offset * maxAngle * math.Pi / 180
	speed := math.Min(math.Hypot(b.VX, b.VY)+speedUp, maxSpeed)
	b.VX = dir * speed * math.Cos(angle)
	b.VY = speed * math.Sin(angle)
	if dir > 0 {
		b.X = p.X + paddleW
	} else {
		b.X = p.X - ballSize
	}
}
go vet ./...
go run ./cmd/pong

With the cap, the same straight launch rallies for as long as the window is open: the ball reaches five pixels a tick on the twelfth return and stays there, and after six thousand ticks, a hundred seconds, it has been returned 95 times and is still in play. Five is a design choice, and the reasons are the paddle's width, the ball's, and the arithmetic above; a paddle six pixels wide could afford a cap of nine. Put the launch back to 2, 1.

Serving and scoring

A match is in one of three states. In Serve the ball rests in the middle until Space is held, when it is served toward the side that conceded the last point, at two pixels a tick and an angle drawn at random from within thirty degrees of straight. In Play the ball flies and a point can be scored, by the ball leaving through a side; the point puts the match back in Serve, or, when it is a side's seventh, in Over, where R starts another match. Four reasons to change state, one method that does it.

▣ Build · stage 5 — three states, extend both files
// cmd/pong/match.go — extend
import (
	"math"
	"math/rand/v2"
)

// Match is the state of one game, advanced one tick at a time by Step.
type Match struct {
	Left  Paddle // W and S
	Right Paddle // Up and Down
	Ball  Ball
	State State
	Score [2]int  // points for the left side and the right side
	Dir   float64 // which way the next serve goes: +1 right, -1 left

	rng *rand.Rand // the one source of random numbers: the serve's angle
}

// newMatch returns a match at tick zero: both paddles centred, the ball at
// rest in the middle of the court, the first serve toward the right, and
// the serve's angles drawn from a generator seeded with seed.
func newMatch(seed uint64) *Match {
	return &Match{
		Left: newPaddle(leftX), Right: newPaddle(rightX), Ball: newBall(),
		Dir: 1, rng: rand.New(rand.NewPCG(seed, 0)),
	}
}

// Step advances the match by one tick with the keys held during it, and
// reports what happened that a player would hear.
func (m *Match) Step(k keys) event {
	m.movePaddles(k)
	switch m.State {
	case Serve:
		if k.space {
			m.serve()
		}
	case Play:
		if m.fly() {
			return hit
		}
		if side := m.Ball.Out(); side != 0 {
			m.point(side)
			return point
		}
	case Over:
		if k.r {
			m.restart()
		}
	}
	return nothing
}

// Out reports which side the ball has left the court through: +1 past the
// right edge, -1 past the left, 0 while any of it is on the court.
func (b Ball) Out() float64 {
	switch {
	case b.X > courtW:
		return 1
	case b.X+ballSize < 0:
		return -1
	}
	return 0
}

// A match is in one of three states, and moves between them for exactly
// four reasons: Space ends Serve, a point ends Play, the seventh point
// ends the match, and R starts another.
type State uint8

const (
	Serve State = iota // the ball waits at the centre until Space is held
	Play               // the ball is in flight and a point can be scored
	Over               // a side has seven points; R starts a new match
)

// String names the state.
func (s State) String() string {
	switch s {
	case Serve:
		return "serve"
	case Play:
		return "play"
	}
	return "over"
}

// The serve and the match.
const (
	serveSpeed   = 2.0  // pixels a tick
	serveAngle   = 30.0 // degrees: a serve is drawn from [-30, 30]
	winningScore = 7
)

// event is what a tick did that a player would hear: nothing, a paddle
// hit, or a point.
type event uint8

const (
	nothing event = iota
	hit
	point
)

// serve puts the ball in flight from the centre toward the side that
// conceded last, at serveSpeed and an angle drawn from the generator.
func (m *Match) serve() {
	angle := (m.rng.Float64()*2 - 1) * serveAngle * math.Pi / 180
	m.Ball = newBall()
	m.Ball.VX = m.Dir * serveSpeed * math.Cos(angle)
	m.Ball.VY = serveSpeed * math.Sin(angle)
	m.State = Play
}

// point scores for the side the ball did not leave through, puts the ball
// back at the centre, and ends the match on the seventh point.
func (m *Match) point(side float64) {
	if side > 0 {
		m.Score[0]++ // out on the right: the left scores
	} else {
		m.Score[1]++
	}
	m.Dir = side // the next serve goes toward the side that conceded
	m.Ball = newBall()
	m.State = Serve
	if m.Score[0] == winningScore || m.Score[1] == winningScore {
		m.State = Over
	}
}

// restart begins a new match on the same court: scores to zero, paddles
// centred, the first serve toward the right. The generator carries on.
func (m *Match) restart() {
	m.Score = [2]int{}
	m.Left, m.Right, m.Ball = newPaddle(leftX), newPaddle(rightX), newBall()
	m.Dir = 1
	m.State = Serve
}
// cmd/pong/main.go — extend
import (
	"flag"
	"image"
	"image/color"
	"image/png"
	"log"
	"os"

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

// newGame builds the match from the flags and loads what the window draws.
func newGame() (*Game, error) {
	sp, err := loadSprites("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	return &Game{match: newMatch(*seedFlag), court: newCourt(), sp: sp}, nil
}

func main() {
	flag.Parse()
	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)
	}
}

var seedFlag = flag.Uint64("seed", 1, "the seed the serves are drawn from")
go vet ./...
go run ./cmd/pong
go run ./cmd/pong -seed 7

The window opens with the ball at rest in the middle, and nothing happens until Space. Then the ball leaves toward the right at some angle, and if the right player misses it the ball goes out, the ball comes back to the middle, and the next Space sends it toward the right again, because the right conceded. Seven points to a side and Space does nothing; R starts over. The score is stored in match.go, where Step is now a switch on the state, and each state reads only the key that matters to it: Space in Serve, R in Over, and the ball's flight and exit in Play.

The serve's angle is the one random thing in the game, and it is drawn from a generator the match owns, seeded from a number on the command line, so that -seed 7 gives the same sequence of serves every time it is run and two runs with the same seed and the same keys are the same match. The default seed is 1. rand.Float64() gives a number from 0 up to but not including 1, and ×2 − 1 moves it to −1 up to 1, so the angle lies from −30 degrees up to 30. Step now returns an event, a hit or a point or nothing. The String method on State lets a state be printed as a word.

main.go changes in two places: the seed is a flag, parsed before anything else, and newGame hands it to the match. A flag declared at package level is registered before main runs, wherever in the file it sits.

Drawing the score and message band

▣ Build · stage 6 — the score and the band, a text file and an extension of main.go
// cmd/pong/text.go — create
package main

import (
	"bytes"
	"log"
	"math"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/text/v2"
	"golang.org/x/image/font/gofont/goregular"
)

// face is the TrueType face the game's words are set in: Go Regular,
// carried inside the program.
type face struct {
	f *text.GoTextFace
}

// newFace parses the font once and returns it at the size asked for.
func newFace(size float64) *face {
	src, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
	if err != nil {
		log.Fatal(err)
	}
	return &face{f: &text.GoTextFace{Source: src, Size: size}}
}

// drawCentred draws s in the line colour with its middle on column x and
// its top on row y.
func (fc *face) drawCentred(dst *ebiten.Image, s string, x, y float64) {
	w, _ := text.Measure(s, fc.f, 0)
	op := &text.DrawOptions{}
	op.GeoM.Translate(math.Floor(x-w/2), y)
	op.ColorScale.ScaleWithColor(lineColor)
	text.Draw(dst, s, fc.f, op)
}
// cmd/pong/main.go — extend
import (
	"flag"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"log"
	"os"

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

	"gez/internal/digits"
)

var (
	courtColor = color.RGBA{R: 16, G: 20, B: 28, A: 255}
	lineColor  = color.RGBA{R: 232, G: 232, B: 232, A: 255}
	bandColor  = color.RGBA{R: 0, G: 0, B: 0, A: 160}
)

// Game is Pong's window: the match, and everything drawn.
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.
}

// newGame builds the match from the flags and loads what the window draws.
func newGame() (*Game, error) {
	sp, err := loadSprites("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	return &Game{
		match: newMatch(*seedFlag), court: newCourt(), sp: sp,
		font: font, face: newFace(12),
	}, nil
}

// step advances the window's game one tick: the match, and the ball's spin.
func (g *Game) step(k keys) {
	g.match.Step(k)
	if b := g.match.Ball; b.VX != 0 || b.VY != 0 {
		g.spin++
	}
	if g.match.State == Play {
		g.served = true
	}
}

func (g *Game) Draw(screen *ebiten.Image) {
	g.drawMatch(screen)
	g.drawHUD(screen)
}

// The score is drawn with the sheet's digits at four times, and the band
// is a dark strip behind the line that says what to do.
const (
	digitScale = 4
	scoreY     = 8
	scoreX     = 120 // the left score's middle; the right's is mirrored
	bandY      = 120
	bandH      = 22
	lineY      = 124
	titleY     = 40
)

// drawHUD draws the score, and, while the match waits on a key, a band
// with the line that says which key.
func (g *Game) drawHUD(screen *ebiten.Image) {
	m := g.match
	g.font.DrawCentred(screen, m.Score[0], scoreX, scoreY, digitScale)
	g.font.DrawCentred(screen, m.Score[1], courtW-scoreX, scoreY, digitScale)
	if m.State == Play {
		return
	}
	vector.FillRect(screen, 0, bandY, courtW, bandH, bandColor, false)
	switch m.State {
	case Serve:
		if !g.served {
			g.face.drawCentred(screen, "PONG", courtW/2, titleY)
		}
		g.face.drawCentred(screen, "SPACE TO SERVE", courtW/2, lineY)
	case Over:
		side := "LEFT"
		if m.Score[1] > m.Score[0] {
			side = "RIGHT"
		}
		g.face.drawCentred(screen, fmt.Sprintf("%s WINS  -  R FOR ANOTHER", side), courtW/2, lineY)
	}
}
go vet ./...
go run ./cmd/pong
The court with 0 and 0 in big digits at the top of each half, PONG in small letters above the middle, both paddles centred, the ball at rest in the middle, and a dark band across the lower part reading SPACE TO SERVE.
The window after stage 6, before the first serve: the score, the title, and the band.
The court with 1 and 0 at the top, the paddles centred, the ball back in the middle, and the band reading SPACE TO SERVE; the title is gone.
The tick after the first point of a match in which nobody moved: the serve went right, the centred paddle was not on its row, and the left has a point.

The score is the digit font from chapter 5 at four times, each side's number centred on the middle of its half, and the band is chapter 5's shade cut down to a strip, drawn only while the match waits on a key, with the line that says which key. The title shows until the first serve and then never again; a served flag in the window, set the first time the match is seen in Play, is enough to know that. Both are drawn after the match, which puts the band over the ball, and the band is drawn before its words, which is chapter 5's lesson kept.

text.go is chapter 5's two functions folded into a type, so that g.face.drawCentred reads as one thing; Snake and Breakout each get a copy of this file with the same twenty lines. The line colour it draws in is main.go's, because the two files are one package.

Adding solo mode

▣ Build · stage 7 — solo mode, extend both files
// cmd/pong/match.go — extend
// Match is the state of one game, advanced one tick at a time by Step.
type Match struct {
	Left  Paddle // W and S
	Right Paddle // Up and Down, or the program in solo mode
	Ball  Ball
	State State
	Score [2]int  // points for the left side and the right side
	Dir   float64 // which way the next serve goes: +1 right, -1 left
	Solo  bool    // the right paddle follows the ball

	rng *rand.Rand // the one source of random numbers: the serve's angle
}

// newMatch returns a match at tick zero: both paddles centred, the ball at
// rest in the middle of the court, the first serve toward the right, and
// the serve's angles drawn from a generator seeded with seed. The right
// paddle is played by the program when solo is set.
func newMatch(seed uint64, solo bool) *Match {
	return &Match{
		Left: newPaddle(leftX), Right: newPaddle(rightX), Ball: newBall(),
		Dir: 1, Solo: solo, rng: rand.New(rand.NewPCG(seed, 0)),
	}
}

// movePaddles moves the left paddle by W and S and the right by Up and
// Down, or, in solo mode, after the ball.
func (m *Match) movePaddles(k keys) {
	m.Left.Move(k.w, k.s)
	if m.Solo {
		m.Right.Follow(m.Ball)
	} else {
		m.Right.Move(k.up, k.down)
	}
}

// Follow moves the paddle toward the ball's row at the paddle's speed and
// never faster: the program's opponent in solo mode.
func (p *Paddle) Follow(b Ball) {
	target := b.Y + ballSize/2 - paddleH/2
	dy := clamp(target-p.Y, -paddleSpeed, paddleSpeed)
	p.Y = clamp(p.Y+dy, border, courtH-border-paddleH)
}
// cmd/pong/main.go — extend
// newGame builds the match from the flags and loads what the window draws.
func newGame() (*Game, error) {
	sp, err := loadSprites("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	return &Game{
		match: newMatch(*seedFlag, *soloFlag), court: newCourt(), sp: sp,
		font: font, face: newFace(12),
	}, nil
}

var (
	seedFlag = flag.Uint64("seed", 1, "the seed the serves are drawn from")
	soloFlag = flag.Bool("solo", false, "play against the program")
)
go vet ./...
go run ./cmd/pong -solo
The court with 0 and 0 at the top, the ball high in the right half heading for the right paddle, which has climbed to meet it, and the left paddle a little above centre.
Ten seconds into a solo match: the right paddle has followed the ball up to its row while the left player, who served, waits below.

With -solo the right paddle plays itself: on every tick it moves toward the row that would put its middle on the ball's middle, two pixels at most, and stops when it is there. That is a paddle that never misses a slow ball and cannot keep up with a fast one at a steep angle, because the ball's vertical speed at sixty degrees and five pixels a tick is 4.3 and the paddle's is 2. The way to beat it is the way to beat a person who only follows: meet the ball near the end of your paddle, so that it leaves steeply, and do it again. The follower reads the ball's position from the match, in the match, so it is a rule and not a piece of the window; a second flag hands the choice to newMatch, and the two flags now share one var block.

Playing hit and point sounds

▣ Build · stage 8 — a hit and a point, heard, extend cmd/pong/main.go
// cmd/pong/main.go — extend
import (
	"flag"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"log"
	"os"

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

	"gez/internal/digits"
	"gez/internal/sound"
)

// 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
}

// newGame builds the match from the flags and loads what the window
// draws and plays.
func newGame() (*Game, error) {
	sp, err := loadSprites("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	font, err := digits.Load("assets/pong-sheet.png")
	if err != nil {
		return nil, err
	}
	hit, err := sound.Load("assets/hit.wav")
	if err != nil {
		return nil, err
	}
	point, err := sound.Load("assets/point.wav")
	if err != nil {
		return nil, err
	}
	return &Game{
		match: newMatch(*seedFlag, *soloFlag), court: newCourt(), sp: sp,
		font: font, face: newFace(12), hit: hit, point: point,
	}, nil
}

// step advances the window's game one tick: the match, the ball's spin,
// and a sound for what the match reports.
func (g *Game) step(k keys) {
	switch g.match.Step(k) {
	case hit:
		g.hit.Play()
	case point:
		g.point.Play()
	}
	if b := g.match.Ball; b.VX != 0 || b.VY != 0 {
		g.spin++
	}
	if g.match.State == Play {
		g.served = true
	}
}
go vet ./...
go run ./cmd/pong -solo
The court with 7 and 0 at the top, both paddles centred, the ball in the middle, and the band reading LEFT WINS - R FOR ANOTHER.
The end of a solo match, 7,456 ticks in: the left player beat the following paddle seven points to none, and the band waits for R.

A return beeps and a point sounds the lower tone. The match reports both as an event. The window plays the sound in step, and the match rules never import or call the sound package.

Pong now has two files for rules and window code, plus one file for text drawing. The picture above is a solo match that took 7,456 ticks. The follower lost every point to a player who aimed for the ends of the paddle.

Keeping rules separate from drawing

Every fact about a match is in one value. One method changes it, once per tick, from the keys for that tick. That made each stage a change to the rules first, with the window drawing the result.

This also makes the match reproducible. Two matches with the same seed and the same keys on the same ticks are the same match, to the pixel, because the rules do not read a wall clock, a mouse or a draw count. The spin counter lives outside the rules because it changes only the picture.

Checkpoint

✓ Checkpoint — what you can now do
  • Keep the rules of a game in a value with a Step method and the window in another, and say what each may touch.
  • Reflect a ball off a border without losing any of its step, and say what stopping it at the border would look like at five pixels a tick.
  • Turn where a ball struck a paddle into an angle and a velocity, by hand, for a ball six pixels off the middle at 2.25 pixels a tick.
  • Explain, in columns, how a ball at ten pixels a tick steps over a four-pixel paddle, and why a cap of five cannot.
  • Write a three-state match with a seeded serve, and say what the seed buys.
  • Draw a score and a state band over the match, in the right order, and play a sound on what Step reports.
⚡ Exercises — try first, then reveal
Exercise 1 — to eleven, by two. Make the match end at eleven points, but only when the leader is two points clear.

winningScore = 11, and the test in point becomes lead >= winningScore && lead-trail >= 2 with lead and trail the larger and smaller score. At 10–10 the match goes on to 12–10. The band needs no change; it reads the scores when the state is Over.

Exercise 2 — a wider paddle, a higher cap. Make the paddles six pixels wide, drawn with vector.FillRect instead of the sprites, raise the cap to nine, and run the straight launch of the worked failure. Then take the cap off and find the speed the ball goes through at.

paddleW = 6, two FillRect calls in drawMatch with the paddles' rectangles, and maxSpeed = 9. The overlap window is now ten pixels wide, and a ball at nine a tick cannot step over it, so the straight rally holds at nine for as long as you watch. With the cap removed the rally speeds up past ten and the ball goes through on the first return whose step happens to land either side of the window; count the returns to find the speed, and compare it with the chapter's 32.

Exercise 3 — the machine against itself. Make the left paddle follow the ball too when -solo is given, and watch a match nobody plays.

In movePaddles, m.Left.Follow(m.Ball) under the same if m.Solo. Both paddles arrive centred on the ball, every return is straight, and the rally runs at five pixels a tick until you close the window: two followers never miss each other's returns, which is why the game gives the follower a human to lose to.