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
// 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
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.
# 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 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.
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.
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 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
# 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
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
// 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 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
- 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.
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.