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
// 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
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
// 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 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?
// 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
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.
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.
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 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.
// 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.
// 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
// 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 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
// 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
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
// 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
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
- Keep the rules of a game in a value with a
Stepmethod 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
Stepreports.
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.