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

Input

Reading input

This chapter builds a paddle you can move with W and S. Space serves a ball once per press, and a mouse click moves the paddle to the cursor's row.

Ebitengine provides input polling. ebiten.IsKeyPressed(ebiten.KeyW) answers whether W is held on this tick. A serve needs a different answer: whether Space went down on this tick. The program reads those answers once at the top of Update and passes plain booleans to the game rules.

Moving the paddle

▣ Build · stage 1 — a paddle under W and S
// cmd/paddle/main.go — create
package main

import (
	"image/color"
	"log"

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

const (
	screenW = 320
	screenH = 180
	border  = 2 // the court's border, which nothing crosses
)

var (
	courtColor = 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}
)

// drawCourt paints the court: the fill, a border two pixels wide just
// inside the edge, and a net of dashes down the middle.
func drawCourt(screen *ebiten.Image) {
	screen.Fill(courtColor)
	vector.StrokeRect(screen, 1, 1, screenW-2, screenH-2, 2, lineColor, false)
	for y := 0; y < screenH; y += 8 {
		vector.FillRect(screen, 159, float32(y), 2, 4, lineColor, false)
	}
}

// The paddle is four pixels wide and twenty-four tall, on column 8, and
// moves two pixels a tick.
const (
	paddleX     = 8
	paddleW     = 4
	paddleH     = 24
	paddleSpeed = 2
)

// keys is what the player is doing this tick: the keys held.
type keys struct {
	up, down bool // W and S, while held
}

// readKeys asks Ebitengine about the keyboard, once a tick.
func readKeys() keys {
	var k keys
	k.up = ebiten.IsKeyPressed(ebiten.KeyW)
	k.down = ebiten.IsKeyPressed(ebiten.KeyS)
	return k
}

type Game struct {
	paddleY float64 // the paddle's top edge
}

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

// step advances the game one tick with what the player is doing.
func (g *Game) step(k keys) {
	if k.up {
		g.paddleY -= paddleSpeed
	}
	if k.down {
		g.paddleY += paddleSpeed
	}
	g.paddleY = clamp(g.paddleY, border, screenH-border-paddleH)
}

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

func (g *Game) drawPaddle(screen *ebiten.Image) {
	vector.FillRect(screen, paddleX, float32(g.paddleY), paddleW, paddleH, lineColor, false)
}

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

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func main() {
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Paddle")
	ebiten.SetTPS(60)
	g := &Game{paddleY: (screenH - paddleH) / 2}
	if err := ebiten.RunGame(g); err != nil {
		log.Fatal(err)
	}
}
go vet ./...
go run ./cmd/paddle
The court with a white paddle on the left side, high up, near the top border.
The window after stage 1, with W held for half a second: thirty ticks of two pixels have moved the paddle from row 78 to row 18.

Hold W and the paddle moves up two pixels a tick. Hold S and it moves down. Hold both and the moves cancel, so the paddle stays where it is. Let go and the paddle stops on that tick because IsKeyPressed is read again every tick.

clamp keeps the paddle inside the two-pixel border. Its top edge can be as high as row 2 and as low as row 154, which puts the bottom edge on row 177. The clamp runs after movement, so holding W at the top has no visible effect.

Update reads the keys and hands them to step. step receives a keys value, not the keyboard itself. That keeps the physical controls in readKeys and the paddle rules in step.

Reading a press once

A serve should happen once per press. Ebitengine's inpututil package keeps last tick's keyboard state and compares it with this tick. IsKeyJustPressed is true only on the tick a key goes down.

▣ Build · stage 2 — a ball served on Space, extend cmd/paddle/main.go
// cmd/paddle/main.go — extend
import (
	"image/color"
	"log"

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

// keys is what the player is doing this tick: the keys held, and the keys
// that went down this tick.
type keys struct {
	up, down bool // W and S, while held
	serve    bool // Space, on the tick it went down
}

// readKeys asks Ebitengine about the keyboard, once a tick.
func readKeys() keys {
	var k keys
	k.up = ebiten.IsKeyPressed(ebiten.KeyW)
	k.down = ebiten.IsKeyPressed(ebiten.KeyS)
	k.serve = inpututil.IsKeyJustPressed(ebiten.KeySpace)
	return k
}

type Game struct {
	paddleY float64 // the paddle's top edge
	balls   []ball  // every ball in flight, oldest first
}

// step advances the game one tick with what the player is doing.
func (g *Game) step(k keys) {
	if k.up {
		g.paddleY -= paddleSpeed
	}
	if k.down {
		g.paddleY += paddleSpeed
	}
	g.paddleY = clamp(g.paddleY, border, screenH-border-paddleH)
	if k.serve {
		g.balls = append(g.balls, ball{x: paddleX + paddleW, y: g.paddleY + paddleH/2 - 2})
	}
	kept := g.balls[:0]
	for _, b := range g.balls {
		b.x += ballSpeed
		if b.x < screenW {
			kept = append(kept, b)
		}
	}
	g.balls = kept
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawCourt(screen)
	g.drawPaddle(screen)
	g.drawBalls(screen)
}

// A ball served from the paddle flies right at six pixels a tick.
const ballSpeed = 6

type ball struct {
	x, y float64
}

func (g *Game) drawBalls(screen *ebiten.Image) {
	for _, b := range g.balls {
		vector.FillRect(screen, float32(b.x), float32(b.y), 4, 4, lineColor, false)
	}
}
go vet ./...
go run ./cmd/paddle
The court with the paddle at the left in its middle position and two small white squares to its right, level with its centre, one near the middle of the court and one further along.
The window after stage 2, two thirds of a second after the first of two taps of Space a third of a second apart: two balls in flight, 120 pixels apart.

Tap Space and a four-pixel ball leaves the paddle's face. It starts level with the paddle's middle. Hold Space and only one ball leaves, because k.serve is true on one tick.

The balls live in a slice. A serve appends a ball. Each tick moves every ball six pixels and keeps only the balls still on the court. The keepers are appended to g.balls[:0], which reuses the same backing array instead of making a new one.

The extend listing changes the import block, keys, readKeys, Game, step and Draw. The ball declarations are new and go at the end of the file.

One tap of Space across twelve ticks: what IsKeyPressed and IsKeyJustPressed each answer A row of twelve tick boxes numbered 1 to 12. The key is shown down from tick 3 to tick 11. Under the row, a line labelled IsKeyPressed is true on ticks 3 to 11, nine ticks; a second line labelled IsKeyJustPressed is true on tick 3 only. A note says a serve wants the second answer. ONE TAP, NINE TICKS LONG tick 1 2 3 4 5 6 7 8 9 10 11 12 key down IsKeyPressed true on ticks 3 to 11 IsKeyJustPressed true on tick 3 only: the serve fires once
Figure 3.1 — a tap of Space seen tick by tick: held for nine ticks, and down on one. A serve wants the second answer, a paddle the first.
⚠ Worked failure — Space read as a held key

IsKeyPressed reads naturally for Space, but it gives the wrong kind of answer for a serve. Use it for the serve:

func readKeys() keys {
	var k keys
	k.up = ebiten.IsKeyPressed(ebiten.KeyW)
	k.down = ebiten.IsKeyPressed(ebiten.KeyS)
	k.serve = ebiten.IsKeyPressed(ebiten.KeySpace)
	return k
}
The court with the paddle at the left and, to its right, a row of nine small white squares two pixels apart, like a dotted line.
The same program with Space read as a held key, half a second after one tap of Space that lasted nine ticks: nine balls, six pixels apart.

One tap makes nine balls because the tap lasted nine ticks. On each of those ticks, k.serve was true. Each ball is six pixels behind the one before it because each one left the paddle one tick later.

A held Space key serves sixty balls a second. The program cannot tell a tap from a hold because IsKeyPressed answers only about this tick. inpututil remembers the previous tick, so it can answer whether the key went down now. Put IsKeyJustPressed back.

Reading the mouse

▣ Build · stage 3 — the mouse, extend cmd/paddle/main.go
// cmd/paddle/main.go — extend
// keys is what the player is doing this tick: the keys held, the keys
// that went down this tick, and where the mouse is.
type keys struct {
	up, down bool // W and S, while held
	serve    bool // Space, on the tick it went down
	click    bool // the left mouse button, on the tick it went down
	mx, my   int  // the cursor, in the picture's pixels
}

// readKeys asks Ebitengine about the keyboard and the mouse, once a tick.
func readKeys() keys {
	var k keys
	k.up = ebiten.IsKeyPressed(ebiten.KeyW)
	k.down = ebiten.IsKeyPressed(ebiten.KeyS)
	k.serve = inpututil.IsKeyJustPressed(ebiten.KeySpace)
	k.click = inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft)
	k.mx, k.my = ebiten.CursorPosition()
	return k
}

type Game struct {
	paddleY float64 // the paddle's top edge
	balls   []ball  // every ball in flight, oldest first
	mx, my  int     // where the cursor was on the last tick
}

// step advances the game one tick with what the player is doing.
func (g *Game) step(k keys) {
	if k.up {
		g.paddleY -= paddleSpeed
	}
	if k.down {
		g.paddleY += paddleSpeed
	}
	if k.click {
		g.paddleY = float64(k.my) - paddleH/2 // the paddle's middle to the cursor's row
	}
	g.paddleY = clamp(g.paddleY, border, screenH-border-paddleH)
	g.mx, g.my = k.mx, k.my
	if k.serve {
		g.balls = append(g.balls, ball{x: paddleX + paddleW, y: g.paddleY + paddleH/2 - 2})
	}
	kept := g.balls[:0]
	for _, b := range g.balls {
		b.x += ballSpeed
		if b.x < screenW {
			kept = append(kept, b)
		}
	}
	g.balls = kept
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawCourt(screen)
	g.drawPaddle(screen)
	g.drawBalls(screen)
	g.drawCursor(screen)
}

// drawCursor marks the cursor with a small cross.
func (g *Game) drawCursor(screen *ebiten.Image) {
	x, y := float32(g.mx), float32(g.my)
	vector.StrokeLine(screen, x-4, y, x+5, y, 1, dimColor, false)
	vector.StrokeLine(screen, x, y-4, x, y+5, 1, dimColor, false)
}
go vet ./...
go run ./cmd/paddle
The court with a small grey cross in the upper right quarter, and the paddle on the left moved up so that its middle is level with the cross.
The window after stage 3, the tick after a click at column 200, row 60: the cross marks the cursor and the paddle's middle has moved to the cursor's row.

Move the mouse and the grey cross follows it in the picture's own pixels. Drag the window larger and the cross does not speed up. Ebitengine maps the cursor into the 320-by-180 coordinates that Layout chose.

Click and the paddle jumps so its middle is on the cursor's row. The next line clamps it, so a click near the border cannot push it through the wall. The cursor's position is copied into the game in Update and drawn from that copy in Draw.

Before the cursor enters the window, Ebitengine reports the last position it knew. At the start, that is the top-left corner, so the cross sits there until the mouse moves.

⚙ Tool — what the library reports about input

ebiten.IsKeyPressed(key) is true while a key is held; the keys are constants such as ebiten.KeyW, ebiten.KeySpace and ebiten.KeyArrowUp, one per physical key. inpututil.IsKeyJustPressed(key) is true on the tick the key went down, and inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) the same for a button. ebiten.CursorPosition() returns the cursor in the game's own coordinates; it can be outside the picture when the cursor is outside the window. None of these reports events: each reports the state at the moment it is asked, so Update asks once a tick. The reference is pkg.go.dev/github.com/hajimehoshi/ebiten/v2/inpututil.

Keeping input in one value

The operating system sees input as events: a key went down, then a key came up. Ebitengine turns that stream into answers the game can poll once per tick. inpututil adds the comparison with the previous tick.

Reading input at the top of Update gives the whole tick one set of answers. A key cannot be held in step and released in Draw, because Draw does not ask the keyboard. It draws the state that Update already decided.

Checkpoint

✓ Checkpoint — what you can now do
  • Move a paddle two pixels a tick while a key is held and keep it inside a two-pixel border with a clamp, and say which rows its top edge can occupy.
  • Read a key that should act once per press with inpututil, and explain, in ticks, what a tap of Space does when it is read as held instead.
  • Pack the keys, the just-pressed keys and the cursor into one value at the top of Update, and say what that buys the rest of the tick.
  • Read the cursor in the picture's coordinates and move something to its row on a click.
  • Walk a slice of balls, moving each and dropping the ones that have left, without allocating.
⚡ Exercises — try first, then reveal
Exercise 1 — the arrows too. Make Up and Down move the paddle as well as W and S, without touching step.

Two lines in readKeys: k.up = ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeyArrowUp), and the same for down. step sees a boolean and does not know two keys feed it, which is what the keys value is for.

Exercise 2 — a second paddle. Add a paddle on column 308 under the Up and Down arrows, and serve the balls from whichever paddle was moved last.

A second Y field and two more booleans in keys; a last field set to 1 or 2 whenever a paddle's keys are held; the serve reads last to pick the paddle and the direction, and the ball type needs a velocity so that a ball from the right paddle flies left. Both paddles clamp to the same two rows.

Exercise 3 — the count in the title. Count the serves and show the count in the window's title.

A serves field incremented where the ball is appended, and ebiten.SetWindowTitle(fmt.Sprintf("Paddle: %d serves", g.serves)) on the same line, with fmt imported. Tap Space three times and the title bar reads 3; hold it and it still reads one more, which is the chapter's point, made by the window manager.