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

Breakout

Building Breakout

This chapter builds Breakout. The game has a paddle along the bottom, a ball, a wall of bricks, three levels, three balls and the same two short sounds.

The new rule is brick collision. An overlap says the ball is inside a brick, but it does not say which side the ball crossed. The game compares the overlap on x with the overlap on y, then reflects only the velocity across the smaller overlap. Reflecting both components sends the ball back the way it came.

Serving from the paddle

▣ Build · stage 1 — the field, the paddle, the ball and the serve
// cmd/breakout/board.go — create
package main

import "math"

// The field is the picture below a twenty-pixel strip for the score. The
// ball bounces off its left, right and top edges and is lost past the
// bottom.
const (
	fieldW = 320
	fieldH = 180
	top    = 20 // the strip above the field, in pixels
)

// The paddle is thirty-two pixels wide and four tall on row 172, moved
// three pixels a tick by the arrows and never off the field. The ball is
// four pixels square.
const (
	paddleW     = 32
	paddleH     = 4
	paddleY     = 172
	paddleSpeed = 3
	ballSize    = 4
)

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

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

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

// paddleRect is the paddle's rectangle, from its left edge.
func paddleRect(x float64) Rect {
	return Rect{X: x, Y: paddleY, W: paddleW, H: 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 serve leaves the paddle at two pixels a tick, three across for every
// four up; the paddle returns the ball at an angle set by where the ball
// struck it, up to sixty degrees from straight up.
const (
	serveSpeed = 2.0
	serveDX    = 0.6 // of the speed, to the right
	serveDY    = 0.8 // of the speed, upward
	maxAngle   = 60.0
)

// A game is in one of two states: waiting to serve, with the ball on the
// paddle, or in play.
type State uint8

const (
	Serve State = iota // the ball rides the paddle until Space is held
	Play               // the ball is in flight
)

// event is what a tick did that a player would hear: nothing, the paddle
// struck, or the ball lost.
type event uint8

const (
	nothing event = iota
	paddle
	lost
)

// Board is the state of one game of Breakout, advanced one tick at a time
// by Step.
type Board struct {
	Paddle float64 // the paddle's left edge; its row never changes
	Ball   Ball
	State  State
}

// newBoard returns a game at tick zero: the paddle centred and the ball on
// it, waiting to serve.
func newBoard() *Board {
	b := &Board{Paddle: (fieldW - paddleW) / 2}
	b.rest()
	return b
}

// rest puts the ball on the middle of the paddle, at rest, and waits.
func (b *Board) rest() {
	b.Ball = Ball{X: b.Paddle + (paddleW-ballSize)/2, Y: paddleY - ballSize}
	b.State = Serve
}

// Step advances the game by one tick with the keys held during it, and
// reports what happened that a player would hear.
func (b *Board) Step(k keys) event {
	b.movePaddle(k.left, k.right)
	switch b.State {
	case Serve:
		b.Ball.X = b.Paddle + (paddleW-ballSize)/2 // the ball rides the paddle
		if k.space {
			b.serve()
		}
	case Play:
		if ev := b.fly(); ev != nothing {
			return ev
		}
		if b.Ball.Y >= fieldH {
			b.rest()
			return lost
		}
	}
	return nothing
}

// movePaddle steps the paddle three pixels left, three right, or not at
// all, and keeps it on the field.
func (b *Board) movePaddle(left, right bool) {
	if left {
		b.Paddle -= paddleSpeed
	}
	if right {
		b.Paddle += paddleSpeed
	}
	b.Paddle = clamp(b.Paddle, 0, fieldW-paddleW)
}

// serve sends the ball up and to the right at the serve speed.
func (b *Board) serve() {
	b.Ball.VX = serveDX * serveSpeed
	b.Ball.VY = -serveDY * serveSpeed
	b.State = Play
}

// fly moves the ball one tick, mirrors it off the three walls, and returns
// it off the paddle, reporting what it met.
func (b *Board) fly() event {
	ball := &b.Ball
	ball.X += ball.VX
	ball.Y += ball.VY
	const right = fieldW - ballSize
	if ball.X < 0 {
		ball.X = -ball.X
		ball.VX = -ball.VX
	}
	if ball.X > right {
		ball.X = 2*right - ball.X
		ball.VX = -ball.VX
	}
	if ball.Y < top {
		ball.Y = 2*top - ball.Y
		ball.VY = -ball.VY
	}
	if ball.VY > 0 && ball.Rect().Overlaps(paddleRect(b.Paddle)) {
		b.returnBall()
		return paddle
	}
	return nothing
}

// returnBall sends the ball back up off the paddle at an angle set by
// where it struck: the ball's centre against the paddle's, as a fraction
// of half the paddle, times sixty degrees. The speed does not change.
func (b *Board) returnBall() {
	ball := &b.Ball
	offset := ((ball.X + ballSize/2) - (b.Paddle + paddleW/2)) / (paddleW / 2)
	offset = clamp(offset, -1, 1)
	angle := offset * maxAngle * math.Pi / 180
	speed := math.Hypot(ball.VX, ball.VY)
	ball.VX = speed * math.Sin(angle)
	ball.VY = -speed * math.Cos(angle)
	ball.Y = paddleY - ballSize
}
// cmd/breakout/main.go — create
package main

import (
	"image/color"
	"log"

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

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

// keys is what the player is doing this tick: the two arrows, Space and
// R, held or not.
type keys struct {
	left, right, space, r bool
}

// readKeys asks Ebitengine about the four keys, once a tick.
func readKeys() keys {
	return keys{
		left:  ebiten.IsKeyPressed(ebiten.KeyArrowLeft),
		right: ebiten.IsKeyPressed(ebiten.KeyArrowRight),
		space: ebiten.IsKeyPressed(ebiten.KeySpace),
		r:     ebiten.IsKeyPressed(ebiten.KeyR),
	}
}

// The ball leaves a trail of its last twelve positions.
const trailLen = 12

// Game is Breakout's window: the board, and everything drawn.
type Game struct {
	board *Board
	trail []Ball // where the ball has been, oldest first
}

// newGame builds the board.
func newGame() *Game {
	return &Game{board: newBoard()}
}

// step advances the board one tick and keeps the trail.
func (g *Game) step(k keys) {
	g.board.Step(k)
	if g.board.State == Play {
		g.trail = append(g.trail, g.board.Ball)
		if len(g.trail) > trailLen {
			g.trail = g.trail[1:]
		}
	} else {
		g.trail = g.trail[:0]
	}
}

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

// fillRect fills a Rect, rounded down to the pixel its corner is in.
func fillRect(dst *ebiten.Image, r Rect, clr color.RGBA) {
	vector.FillRect(dst, float32(int(r.X)), float32(int(r.Y)), float32(r.W), float32(r.H), clr, false)
}

// drawField paints the background and the line under the score strip.
func drawField(screen *ebiten.Image) {
	screen.Fill(fieldColor)
	vector.FillRect(screen, 0, top-1, fieldW, 1, dimColor, false)
}

// drawBall draws the trail, oldest and dimmest first, then the paddle and
// the ball.
func (g *Game) drawBall(screen *ebiten.Image) {
	for i, b := range g.trail {
		c := dimColor
		c.A = uint8(255 * (i + 1) / (trailLen + 1))
		fillRect(screen, Rect{X: b.X + 1, Y: b.Y + 1, W: 2, H: 2}, c)
	}
	fillRect(screen, paddleRect(g.board.Paddle), lineColor)
	fillRect(screen, g.board.Ball.Rect(), lineColor)
}

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

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return fieldW, fieldH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Breakout")
	ebiten.SetTPS(60)
	if err := ebiten.RunGame(newGame()); err != nil {
		log.Fatal(err)
	}
}
go vet ./...
go run ./cmd/breakout
A dark window with a thin grey line near the top and, at the bottom middle, a short white paddle with a small white ball resting on it.
The window after stage 1: the paddle on row 172 and the ball resting on it, waiting for Space.
The same window with the ball in flight above and to the right of the paddle, a short dotted trail behind it fading toward the paddle.
Twenty-five ticks after a serve: the ball has climbed forty pixels and drifted thirty to the right, and its trail points back to where it left the paddle.

The ball sits on the paddle and slides with it as the arrows move it; Space sends it up and to the right, three pixels across for every four up, and it comes off the left, right and top edges of the field and down to the paddle, which returns it at an angle set by where it struck, the way Pong's paddles do turned upright. Miss it, and it drops out of the bottom and comes back to rest on the paddle. Most of board.go is Pong's match.go with the axes swapped: Rect, Overlaps, Ball, clamp, the mirror off a wall, and a returnBall whose offset runs along the paddle and whose angle is measured from straight up, so that the sine goes across and the cosine goes up. A serve of 0.6 across and 0.8 up at two pixels a tick is a 3-4-5 triangle: 1.2 across, 1.6 up, two along the diagonal.

The ball rides the paddle while the game waits, by copying the paddle's position into the ball's on every tick of the Serve state, and rest is what puts it there and sets the state, from three places before the chapter is done. The window adds one thing Pong's did not: a trail, the ball's last twelve positions kept in a slice and drawn as two-by-two dots that fade toward the oldest, so that the ball's path can be seen in a still picture, which is what makes the worked failure below visible. The trail is a fact about the picture and lives in the window; it is emptied whenever the ball is not in flight.

Resolving brick hits

A brick is 28 pixels wide and 8 tall, and ten of them with four-pixel gaps are 316 pixels, two short of the field on either side. Rows start at row 24 with a two-pixel gap, up to eight of them. A wall is a small table of hit points, one number a brick, 0 for a gap or a brick that is gone, and a wall is written as text: ten characters a row, a digit for the hit points, a dot for a gap. The first wall goes in a file, which the game reads when it starts.

▣ Build · stage 2 — the wall, from a file, and which edge the ball came through
# configs/levels.txt — create
# Breakout's levels: one character a brick, a digit for its hit points, a
# dot for a gap, ten characters a row, a blank line between levels.
3333.33333
2222.22222
1111.11111
1111.11111
// cmd/breakout/bricks.go — create
package main

import (
	"fmt"
	"math"
)

// Bricks are twenty-eight pixels wide and eight tall, in ten columns with
// four-pixel gaps: 10 × 28 + 9 × 4 is 316, centred with two pixels each
// side. Rows start at row 24 with two-pixel gaps, up to eight of them. A
// brick has one, two or three hit points.
const (
	cols     = 10
	maxRows  = 8
	brickW   = 28
	brickH   = 8
	gapX     = 4
	gapY     = 2
	margin   = (fieldW - (cols*brickW + (cols-1)*gapX)) / 2 // 2
	firstRow = 24
	maxHP    = 3
)

// Wall is the bricks' hit points by row and column: 1 to 3 for a brick, 0
// for a gap or a brick that is gone.
type Wall [maxRows][cols]int

// brickRect is the rectangle of the brick in column col and row row.
func brickRect(col, row int) Rect {
	return Rect{
		X: margin + float64(col)*(brickW+gapX),
		Y: firstRow + float64(row)*(brickH+gapY),
		W: brickW, H: brickH,
	}
}

// Left counts the bricks still standing.
func (w *Wall) Left() int {
	n := 0
	for _, row := range w {
		for _, hp := range row {
			if hp > 0 {
				n++
			}
		}
	}
	return n
}

// parseRow reads one row of a wall from ten characters: a digit for a
// brick's hit points, a dot for a gap.
func parseRow(row string) ([cols]int, error) {
	var out [cols]int
	if len(row) != cols {
		return out, fmt.Errorf("%d characters, want %d", len(row), cols)
	}
	for c, ch := range row {
		switch {
		case ch == '.':
		case ch >= '1' && ch <= '0'+maxHP:
			out[c] = int(ch - '0')
		default:
			return out, fmt.Errorf("column %d: %q is not a digit 1-%d or a dot", c+1, ch, maxHP)
		}
	}
	return out, nil
}

// parseWall reads a wall from text: one string a row, one character a
// brick. There are at most eight rows.
func parseWall(rows []string) (Wall, error) {
	var w Wall
	if len(rows) > maxRows {
		return w, fmt.Errorf("%d rows, at most %d allowed", len(rows), maxRows)
	}
	for r, row := range rows {
		parsed, err := parseRow(row)
		if err != nil {
			return w, fmt.Errorf("row %d: %w", r+1, err)
		}
		w[r] = parsed
	}
	return w, nil
}

// Centre is the middle of the rectangle.
func (r Rect) Centre() (x, y float64) {
	return r.X + r.W/2, r.Y + r.H/2
}

// Penetration is how far r reaches into o along each axis: the smaller of
// the two overlaps on x, and the smaller of the two on y. Both are
// positive when the rectangles overlap.
func (r Rect) Penetration(o Rect) (px, py float64) {
	px = math.Min(r.X+r.W-o.X, o.X+o.W-r.X)
	py = math.Min(r.Y+r.H-o.Y, o.Y+o.H-r.Y)
	return px, py
}

// hitBrick takes a hit point off the brick the ball overlaps, or, when it
// overlaps two, off the one whose centre is nearer the ball's, and bounces
// the ball off it.
func (b *Board) hitBrick() bool {
	ball := b.Ball.Rect()
	bx, by := ball.Centre()
	struck, best := Rect{}, -1.0
	col, row := 0, 0
	for r := range b.Bricks {
		for c := range b.Bricks[r] {
			br := brickRect(c, r)
			if b.Bricks[r][c] == 0 || !ball.Overlaps(br) {
				continue
			}
			cx, cy := br.Centre()
			if d := math.Hypot(cx-bx, cy-by); best < 0 || d < best {
				struck, best, col, row = br, d, c, r
			}
		}
	}
	if best < 0 {
		return false
	}
	b.Bricks[row][col]--
	b.Score++
	b.bounce(struck)
	return true
}

// bounce reflects the ball off the brick it struck: along x when the ball
// went less far into the brick from a side than from the top or bottom,
// along y otherwise, so a tie, which is a corner, counts as a top or a
// bottom. One component flips and the other is left alone.
func (b *Board) bounce(brick Rect) {
	px, py := b.Ball.Rect().Penetration(brick)
	if px < py {
		b.Ball.VX = -b.Ball.VX
	} else {
		b.Ball.VY = -b.Ball.VY
	}
}
// cmd/breakout/levels.go — create
package main

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

// parseLevels reads levels from text: the rows of a wall, a blank line
// between levels, and lines beginning with # ignored. An error names the
// line it was found on.
func parseLevels(r io.Reader) ([]Wall, error) {
	var levels []Wall
	var rows []string
	line := 0
	flush := func() error {
		if len(rows) == 0 {
			return nil
		}
		w, err := parseWall(rows)
		rows = nil
		if err != nil {
			return fmt.Errorf("line %d: %w", line, err)
		}
		levels = append(levels, w)
		return nil
	}
	sc := bufio.NewScanner(r)
	for sc.Scan() {
		line++
		text := strings.TrimRight(sc.Text(), " \t")
		switch {
		case strings.HasPrefix(text, "#"):
		case text == "":
			if err := flush(); err != nil {
				return nil, err
			}
		default:
			if _, err := parseRow(text); err != nil {
				return nil, fmt.Errorf("line %d: %w", line, err)
			}
			rows = append(rows, text)
		}
	}
	if err := sc.Err(); err != nil {
		return nil, err
	}
	if err := flush(); err != nil {
		return nil, err
	}
	if len(levels) == 0 {
		return nil, fmt.Errorf("no levels")
	}
	return levels, nil
}

// loadLevels reads the levels in the file at path.
func loadLevels(path string) ([]Wall, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	levels, err := parseLevels(f)
	if err != nil {
		return nil, fmt.Errorf("%s: %w", path, err)
	}
	return levels, nil
}
// cmd/breakout/board.go — extend
// event is what a tick did that a player would hear: nothing, a brick
// struck, the paddle struck, or the ball lost.
type event uint8

const (
	nothing event = iota
	brick
	paddle
	lost
)

// Board is the state of one game of Breakout, advanced one tick at a time
// by Step.
type Board struct {
	Paddle float64 // the paddle's left edge; its row never changes
	Ball   Ball
	Bricks Wall // hit points, by row and column
	State  State
	Score  int    // one point a hit
	Levels []Wall // the walls, in order; read at the start and never changed
}

// newBoard returns a game of the levels given, at tick zero: the paddle
// centred, the ball on it, the first wall up, waiting to serve.
func newBoard(levels []Wall) *Board {
	b := &Board{Levels: levels, Bricks: levels[0], Paddle: (fieldW - paddleW) / 2}
	b.rest()
	return b
}

// fly moves the ball one tick, mirrors it off the three walls, returns it
// off the paddle, and takes a hit point off a brick it struck, reporting
// what it met.
func (b *Board) fly() event {
	ball := &b.Ball
	ball.X += ball.VX
	ball.Y += ball.VY
	const right = fieldW - ballSize
	if ball.X < 0 {
		ball.X = -ball.X
		ball.VX = -ball.VX
	}
	if ball.X > right {
		ball.X = 2*right - ball.X
		ball.VX = -ball.VX
	}
	if ball.Y < top {
		ball.Y = 2*top - ball.Y
		ball.VY = -ball.VY
	}
	if ball.VY > 0 && ball.Rect().Overlaps(paddleRect(b.Paddle)) {
		b.returnBall()
		return paddle
	}
	if b.hitBrick() {
		return brick
	}
	return nothing
}
// cmd/breakout/main.go — extend
// newGame reads the levels and builds the board.
func newGame() (*Game, error) {
	levels, err := loadLevels("configs/levels.txt")
	if err != nil {
		return nil, err
	}
	return &Game{board: newBoard(levels)}, nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawField(screen)
	g.drawWall(screen)
	g.drawBall(screen)
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Breakout")
	ebiten.SetTPS(60)
	g, err := newGame()
	if err != nil {
		log.Fatal(err)
	}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}

// brickColors is the colour of a brick by its hit points: index 1 to 3.
var brickColors = [maxHP + 1]color.RGBA{
	{},
	{R: 90, G: 170, B: 220, A: 255},
	{R: 230, G: 180, B: 70, A: 255},
	{R: 220, G: 90, B: 90, A: 255},
}

// drawWall draws every brick still standing, in the colour of its hit
// points.
func (g *Game) drawWall(screen *ebiten.Image) {
	for r, row := range g.board.Bricks {
		for c, hp := range row {
			if hp > 0 {
				fillRect(screen, brickRect(c, r), brickColors[hp])
			}
		}
	}
}
go vet ./...
go run ./cmd/breakout
The field with a wall of coloured bricks across the top, red, yellow and blue by row, with a gap down the fifth column and several bricks missing or turned to lower colours on the left and right; the ball is at the far left edge near the bottom, with the paddle below it.
Fifteen seconds into a game with the first wall up: a red brick has been hit twice on the left, and blue ones have gone in both halves; the paddle has gone to the left edge to meet the ball there.

The wall is up, in three colours for three hit points, and every brick the ball strikes loses a point, turns the next colour, and finally goes; the fifth column is a gap the ball can fly through. The wall came from the file: loadLevels reads it, skips the comment lines, and hands parseWall one paragraph, which turns ten characters a row into ten hit points a row and refuses anything else, naming the line. A file that reads configs/levels.txt: line 5: 11 characters, want 10 in the terminal has a row with a character too many on its fifth line, and the game does not start until it is fixed, which is better than a wall with a brick hanging off its edge.

hitBrick walks the whole wall, eighty bricks at most, for the ones the ball overlaps, and takes the hit off the one whose centre is nearest the ball's: a ball that lands in the gap between two bricks overlaps both and should strike one. Then bounce decides the edge. Penetration is two subtractions an axis: how far the ball's right edge is past the brick's left, or the brick's right past the ball's left, whichever is smaller, and the same for the vertical, and the smaller of the two is the edge the ball came through. The recording the pictures come from strikes its first brick from the side on tick 706: the ball at (29.79, 60.89), moving up and slightly right, reaches 0.21 pixels into the leftmost brick of the fourth row on x and 1.11 on y, so it has come through the brick's right edge, and it leaves moving left, its vertical speed untouched.

∑ Math Interlude — the smaller penetration

The brick struck on tick 706 spans columns 2 to 30 and rows 54 to 62, and the ball, four wide, has its left edge at 29.79 and its top at 60.89. On x, the ball's right edge, 33.79, is 31.79 past the brick's left edge, and the brick's right edge, 30, is 0.21 past the ball's left: the smaller, 0.21, is how far the ball has come in from the right. On y, the ball's bottom, 64.89, is 10.89 past the brick's top and the brick's bottom, 62, is 1.11 past the ball's top: 1.11 from below. A ball that has come 0.21 in from one side and 1.11 in from another came through the side it is least inside, so it struck the right edge, and only the across velocity flips. A tie is a corner, and the rule calls it a top or a bottom.

pxhow far the ball reaches into the brick from a side: the smaller of (ball's right − brick's left) and (brick's right − ball's left)
pythe same from the top or bottom: the smaller of (ball's bottom − brick's top) and (brick's bottom − ball's top)
px < pythe ball came through a side: flip VX and leave VY; otherwise flip VY and leave VX
minthe smaller of two numbers, math.Min in Go
A ball overlapping a brick's corner: the two penetrations, and the edge the smaller one names A wide brick with a small ball overlapping its lower right corner. A short arrow labelled px shows how far the ball reaches in from the right edge, a longer arrow labelled py how far it reaches in from the bottom edge. A note says px is smaller, so the ball came through the right edge and VX flips; beside it, the same picture with the ball under the middle of the brick, where py is smaller and VY flips. FROM THE SIDE: px < py px py 18 in from the right, 20 in from the bottom: it came through the right edge; VX flips FROM BELOW: py < px py 12 in from the bottom, 80 from the right: it came through the bottom; VY flips
Figure 9.1 — the two penetrations of a ball into a brick, and the edge the smaller one names. Only the velocity across that edge is reflected.
⚠ Worked failure — both components flipped, and a ball that goes back the way it came

The rule can be skipped. A ball that struck a brick has to leave it, and flipping both components leaves it every time, in one line:

func (b *Board) bounce(brick Rect) {
	b.Ball.VX, b.Ball.VY = -b.Ball.VX, -b.Ball.VY
}
The field with the first wall up, one blue brick gone from the lower row, and the ball a little below the wall on the right with its trail running straight up from it to the gap and back: the dots of the trail lie on one line.
Eight ticks after the first brick of a game, with both components flipped: the ball is going back down the line it came up, and its trail runs up to the brick and back over itself.

The first brick of the game is struck on tick 82, from below, at 1.2 across and 1.6 up. On tick 81 the ball was at (237.20, 62.40); on tick 82 it struck at (238.40, 60.80) and left moving −1.2 across and 1.6 down; on tick 83 it was at (237.20, 62.40) again, on tick 84 at (236.00, 64.00), and so on down the same line it came up, to the same spot on the paddle it left. The trail in the picture is one line because the ball's last twelve positions include the way up and the way back down, and they coincide to the hundredth of a pixel. A player watching sees the ball come straight back at them off every brick, and a wall with a ball that retraces cannot be cleared from one place: the paddle has to move to change the angle, which it cannot do while the ball is in the air. A brick struck from below should send the ball back up and on, across, the way a ball does; the horizontal velocity was never the brick's to change. Put the penetration test back.

Loading levels

▣ Build · stage 3 — levels, and the wall that is down, a replaced file and an extension of board.go
# configs/levels.txt — replace
# Breakout's levels: one character a brick, a digit for its hit points, a
# dot for a gap, ten characters a row, a blank line between levels.
3333.33333
2222.22222
1111.11111
1111.11111

2222222222
2222222222
1111111111
1111111111
1111111111

33.3333.33
22.2222.22
11.1111.11
11.1111.11
22.2222.22
33.3333.33
// cmd/breakout/board.go — extend
// The serve leaves the paddle at two pixels a tick, three across for every
// four up; the paddle returns the ball at an angle set by where the ball
// struck it, up to sixty degrees from straight up. Each level serves a
// quarter of a pixel a tick faster, up to five.
const (
	serveSpeed = 2.0
	serveDX    = 0.6 // of the speed, to the right
	serveDY    = 0.8 // of the speed, upward
	maxAngle   = 60.0
	levelUp    = 0.25 // added to the serve speed by each level
	maxSpeed   = 5.0
)

// A game is in one of three states: waiting to serve, with the ball on
// the paddle; in play; or between levels, with a wall down and the next
// waiting for Space.
type State uint8

const (
	Serve   State = iota // the ball rides the paddle until Space is held
	Play                 // the ball is in flight
	Cleared              // a wall is down and the next waits for Space
)

// event is what a tick did that a player would hear: nothing, a brick
// struck, the paddle struck, the ball lost, or a wall down.
type event uint8

const (
	nothing event = iota
	brick
	paddle
	lost
	cleared
)

// Board is the state of one game of Breakout, advanced one tick at a time
// by Step.
type Board struct {
	Paddle float64 // the paddle's left edge; its row never changes
	Ball   Ball
	Bricks Wall // hit points, by row and column
	State  State
	Score  int     // one point a hit
	Speed  float64 // the speed the ball is served at on this level
	Level  int     // which of the levels the wall is, from 0
	Levels []Wall  // the walls, in order; read at the start and never changed
}

// newBoard returns a game of the levels given, at tick zero: the paddle
// centred, the ball on it, the first wall up, waiting to serve.
func newBoard(levels []Wall) *Board {
	b := &Board{Levels: levels, Bricks: levels[0], Speed: serveSpeed, Paddle: (fieldW - paddleW) / 2}
	b.rest()
	return b
}

// Step advances the game by one tick with the keys held during it, and
// reports what happened that a player would hear.
func (b *Board) Step(k keys) event {
	b.movePaddle(k.left, k.right)
	switch b.State {
	case Serve:
		b.Ball.X = b.Paddle + (paddleW-ballSize)/2 // the ball rides the paddle
		if k.space {
			b.serve()
		}
	case Play:
		if ev := b.fly(); ev != nothing {
			if ev == brick && b.Bricks.Left() == 0 {
				b.clear()
				return cleared
			}
			return ev
		}
		if b.Ball.Y >= fieldH {
			b.rest()
			return lost
		}
	case Cleared:
		if k.space {
			b.next()
		}
	}
	return nothing
}

// serve sends the ball up and to the right at this level's speed.
func (b *Board) serve() {
	b.Ball.VX = serveDX * b.Speed
	b.Ball.VY = -serveDY * b.Speed
	b.State = Play
}

// clear ends the level: the ball goes back to the paddle and the game
// waits for Space.
func (b *Board) clear() {
	b.rest()
	b.State = Cleared
}

// next puts the next wall up, a quarter of a pixel a tick faster.
func (b *Board) next() {
	b.Level++
	b.Bricks = b.Levels[b.Level]
	b.Speed = math.Min(serveSpeed+levelUp*float64(b.Level), maxSpeed)
	b.rest()
}
go vet ./...
go run ./cmd/breakout
The field with a full wall of five rows, yellow on top and blue below, and the ball just served from the paddle with a short trail.
Forty ticks after the first wall came down and Space brought the second one up: five rows, no gap, and a serve at 2.25 pixels a tick.

Clear the wall and the ball comes back to the paddle and waits; Space brings up the second wall from the file, five rows and no gap, and serves a quarter of a pixel a tick faster; the third wall is the file's last paragraph. The game in the pictures took 8,637 ticks, two minutes and twenty-four seconds, to bring the first wall down. Two things changed in the rules. The wall's fall is checked on the tick of the hit that emptied it, in Step, where Left counts what stands; and the serve speed is a field now, set per level and capped at five for the same reason Pong's is. The file is read once, at the start, and Levels is never written after that: next copies a wall out of it, and the copy is what loses bricks. Go copies an array on assignment, which is what makes Wall an array and not a slice.

Adding lives, messages and sounds

▣ Build · stage 4 — lives, the game's end, the strip and the sounds, a text file and two extensions
// cmd/breakout/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}}
}

// draw draws s in the line colour with its left edge on column x and its
// top on row y.
func (fc *face) draw(dst *ebiten.Image, s string, x, y float64) {
	op := &text.DrawOptions{}
	op.GeoM.Translate(x, y)
	op.ColorScale.ScaleWithColor(lineColor)
	text.Draw(dst, s, fc.f, op)
}

// drawCentred draws s 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)
	fc.draw(dst, s, math.Floor(x-w/2), y)
}
// cmd/breakout/board.go — extend
// The serve leaves the paddle at two pixels a tick, three across for every
// four up; the paddle returns the ball at an angle set by where the ball
// struck it, up to sixty degrees from straight up. Each level serves a
// quarter of a pixel a tick faster, up to five, and a game has three balls.
const (
	serveSpeed = 2.0
	serveDX    = 0.6 // of the speed, to the right
	serveDY    = 0.8 // of the speed, upward
	maxAngle   = 60.0
	levelUp    = 0.25 // added to the serve speed by each level
	maxSpeed   = 5.0
	balls      = 3
)

// A game is in one of four states: waiting to serve, with the ball on the
// paddle; in play; between levels, with a wall down and the next waiting
// for Space; or over, with the last ball lost or the last wall down.
type State uint8

const (
	Serve   State = iota // the ball rides the paddle until Space is held
	Play                 // the ball is in flight
	Cleared              // a wall is down and the next waits for Space
	Over                 // the third ball is lost, or the last wall is down; R restarts
)

// Board is the state of one game of Breakout, advanced one tick at a time
// by Step.
type Board struct {
	Paddle float64 // the paddle's left edge; its row never changes
	Ball   Ball
	Bricks Wall // hit points, by row and column
	State  State
	Score  int     // one point a hit
	Speed  float64 // the speed the ball is served at on this level
	Level  int     // which of the levels the wall is, from 0
	Balls  int     // balls left, counting the one in play or on the paddle
	Levels []Wall  // the walls, in order; read at the start and never changed
}

// newBoard returns a game of the levels given: the paddle centred, the
// ball on it, the first wall up, three balls, waiting to serve.
func newBoard(levels []Wall) *Board {
	b := &Board{Levels: levels}
	b.restart()
	return b
}

// Step advances the game by one tick with the keys held during it, and
// reports what happened that a player would hear.
func (b *Board) Step(k keys) event {
	b.movePaddle(k.left, k.right)
	switch b.State {
	case Serve:
		b.Ball.X = b.Paddle + (paddleW-ballSize)/2 // the ball rides the paddle
		if k.space {
			b.serve()
		}
	case Play:
		if ev := b.fly(); ev != nothing {
			if ev == brick && b.Bricks.Left() == 0 {
				b.clear()
				return cleared
			}
			return ev
		}
		if b.Ball.Y >= fieldH {
			b.lose()
			return lost
		}
	case Cleared:
		if k.space {
			b.next()
		}
	case Over:
		if k.r {
			b.restart()
		}
	}
	return nothing
}

// clear ends the level, or the game when it was the last level.
func (b *Board) clear() {
	b.rest()
	b.State = Cleared
	if b.Level == len(b.Levels)-1 {
		b.State = Over
	}
}

// lose takes a ball away, and ends the game when it was the last.
func (b *Board) lose() {
	b.Balls--
	b.rest()
	if b.Balls == 0 {
		b.State = Over
	}
}

// restart begins again at the first level with three balls and no score.
func (b *Board) restart() {
	b.Score, b.Level, b.Balls = 0, 0, balls
	b.Bricks, b.Speed = b.Levels[0], serveSpeed
	b.Paddle = (fieldW - paddleW) / 2
	b.rest()
}
// cmd/breakout/main.go — extend
import (
	"fmt"
	"image/color"
	"log"

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

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

// Game is Breakout's window: the board, and everything drawn or heard.
type Game struct {
	board    *Board
	trail    []Ball // where the ball has been, oldest first
	font     *digits.Font
	face     *face
	hit, die *sound.Sound
}

// newGame reads the levels and loads what the window draws and plays.
func newGame() (*Game, error) {
	levels, err := loadLevels("configs/levels.txt")
	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
	}
	die, err := sound.Load("assets/point.wav")
	if err != nil {
		return nil, err
	}
	return &Game{board: newBoard(levels), font: font, face: newFace(12), hit: hit, die: die}, nil
}

// step advances the board one tick, keeps the trail, and plays what the
// board reports.
func (g *Game) step(k keys) {
	switch g.board.Step(k) {
	case brick, paddle:
		g.hit.Play()
	case lost, cleared:
		g.die.Play()
	}
	if g.board.State == Play {
		g.trail = append(g.trail, g.board.Ball)
		if len(g.trail) > trailLen {
			g.trail = g.trail[1:]
		}
	} else {
		g.trail = g.trail[:0]
	}
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawField(screen)
	g.drawWall(screen)
	g.drawBall(screen)
	g.drawHUD(screen)
}

// drawHUD draws the level, the balls left and the score in the strip,
// and a band across the field while the game waits between levels or at
// its end.
func (g *Game) drawHUD(screen *ebiten.Image) {
	b := g.board
	g.face.draw(screen, "LEVEL", 6, 3)
	g.font.Draw(screen, b.Level+1, 50, 5, 2)
	g.face.draw(screen, "BALLS", 100, 3)
	g.font.Draw(screen, b.Balls, 146, 5, 2)
	g.face.draw(screen, "SCORE", 210, 3)
	g.font.Draw(screen, b.Score, 256, 5, 2)
	if b.State != Cleared && b.State != Over {
		return
	}
	vector.FillRect(screen, 0, 100, fieldW, 22, bandColor, false)
	switch {
	case b.State == Cleared:
		g.face.drawCentred(screen, fmt.Sprintf("CLEARED  -  SPACE FOR LEVEL %d", b.Level+2), fieldW/2, 104)
	case b.Bricks.Left() == 0:
		g.face.drawCentred(screen, "EVERY WALL DOWN  -  R FOR ANOTHER", fieldW/2, 104)
	default:
		g.face.drawCentred(screen, "GAME OVER  -  R FOR ANOTHER", fieldW/2, 104)
	}
}
go vet ./...
go run ./cmd/breakout
The field with LEVEL 1, BALLS 3 and SCORE 63 in the strip, no bricks, the ball back on the paddle, and a band reading CLEARED - SPACE FOR LEVEL 2.
The tick after the first wall came down: 63 points for its 63 hit points, three balls still, and the band waiting for Space.
The field with LEVEL 3, BALLS 1 and SCORE 229 in the strip, no bricks, and a band reading EVERY WALL DOWN - R FOR ANOTHER.
The end of the same game, 23,995 ticks in: every wall down, 229 points, one ball left.
The field with LEVEL 1, BALLS 0 and SCORE 5 in the strip, most of the first wall standing, and a band reading GAME OVER - R FOR ANOTHER.
Twenty seconds into a game in which the paddle never moved: three serves, three balls lost, five bricks struck on the way.

The strip shows the level, the balls left and the score, in the two fonts of chapter 5, and a band says what to do between levels and at the end; a brick or the paddle beeps, a lost ball or a fallen wall sounds the lower tone. A game has three balls, counting the one in play, and losing the third is the end; so is the last wall coming down, and the band says which. R begins again at the first level, from restart, which newBoard now calls too, as Snake's does. The three walls in the pictures took 229 hits, which is the sum of the hit points in the file: 63 in the first wall, 70 in the second and 96 in the third, one point each, so a score is also a count of how much wall has gone.

Breakout is three games' worth of pieces put together in a new order: Pong's ball and return, Snake's strip and states, and a wall that is data in a file. The file is the part to change first. A wall with a hole in the middle, a wall of single bricks, a wall eight rows deep, each is a paragraph of ten-character rows and no code, and a mistake in the paragraph is reported by line before the window opens.

Choosing the collision axis

An overlap test answers whether two rectangles share area. A bounce also needs to know which edge was crossed.

The two penetrations answer that question without storing the ball's previous position. If the ball's step is smaller than the brick, the axis with the smaller penetration is the axis it crossed. The cap of five pixels a tick keeps the step smaller than an eight-pixel brick.

Checkpoint

✓ Checkpoint — what you can now do
  • Turn Pong's paddle and ball upright, serve on a 3-4-5 triangle, and return the ball from a paddle along the bottom at an angle from straight up.
  • Lay out a wall of bricks from ten-character rows in a file, name the line of a bad row, and draw each brick in the colour of its hit points.
  • Compute the two penetrations of a ball into a brick by hand and say which edge it came through, for the strike on tick 706.
  • Explain from three ticks' positions why flipping both components sends the ball back along its own trail.
  • Move a game through Serve, Play, Cleared and Over with three balls and three walls, and bring the next wall up faster.
⚡ Exercises — try first, then reveal
Exercise 1 — a wall of your own. Add a fourth paragraph to configs/levels.txt with a hole in its middle and a row of threes at the bottom, and play to it.

Six rows such as 1111111111, 1111111111, 111....111, 111....111, 2222222222, 3333333333, after a blank line. The game reads four levels, the band after the third wall says SPACE FOR LEVEL 4, and the fourth serves at 2.75 pixels a tick. The hard bricks at the bottom take three hits each before the ball can reach the easy ones.

Exercise 2 — the corner rule, reversed. Change px < py to px <= py and play a level. When does it matter?

Only on an exact tie, a ball that has come the same distance in from a side and from the top or bottom, which is a corner struck dead on. With <= a corner flips the horizontal velocity instead of the vertical; either choice is a rule, and the game plays the same nearly all the time, so a tie is the case to decide once and write down.

Exercise 3 — the trail as a ruler. Make the trail twenty-four positions long and count, from a still picture, how many pixels the ball moves a tick at each level.

trailLen = 24, and the dots are one tick apart, so the distance between two dots is the speed: two pixels on the first wall, 2.25 on the second, 2.5 on the third, measured along the diagonal. A trail is a cheap way to see a velocity, and a debugging aid many games ship with a key to turn it on.