Game Engine Zero Vol 3 · The Platformer
ch 24 / 24
Chapter 24

The Game State Machine

One state runs at a time

The platformer now has a menu, a playing screen, a pause screen and an over screen. One of them owns input on each tick. A state machine stores that one state and lets it return the state that should run next.

The game already has a world and a camera. The machine wraps that world instead of replacing it. Pause keeps the live play state, so resuming returns to the same tick and the same player position.

The input edges

Extend internal/platform/physics.go. The held keys stay in Input. The machine compares the current input with the previous tick and fills the pressed fields for state changes and jumping.

▣ Build · stage 1 · extend internal/platform/physics.go
type Input struct {
	Left, Right  bool
	JumpHeld     bool
	JumpPressed  bool
	JumpReleased bool
	Start        bool
	Pause        bool
	Quit         bool
	Lose         bool
}

type World struct {
	Player     Player
	Camera     Camera
	Tick       int
	FloorY     float64
	LedgeRight float64
	MaxFall    float64
}

Space is both Start and JumpHeld in the window. The machine records the previous tick, so holding Space from the menu into play does not count as a fresh jump press.

The state types

Create internal/platform/state.go. This package still imports no Ebitengine code. A state updates rules, names itself for tests, and exposes its world when drawing needs one.

▣ Build · stage 2 · create internal/platform/state.go
package platform

import (
	"fmt"
	"strings"
)

type State interface {
	Update(in, pressed Input) State
	Name() string
	World() *World
}

type Machine struct {
	current State
	last    Input
}

func NewMachine() *Machine {
	m := &Machine{}
	m.Switch(&Menu{})
	return m
}

func (m *Machine) State() State {
	return m.current
}

func (m *Machine) Name() string {
	if m.current == nil {
		return "none"
	}
	return m.current.Name()
}

func (m *Machine) Switch(next State) {
	m.current = next
}

func (m *Machine) Update(in Input) {
	pressed := in.Pressed(m.last)
	if m.current != nil {
		m.current = m.current.Update(in, pressed)
	}
	m.last = in
}

type Menu struct{}

func (s *Menu) Update(in, pressed Input) State {
	if pressed.Start {
		return NewPlay()
	}
	return s
}

func (s *Menu) Name() string  { return "menu" }
func (s *Menu) World() *World { return nil }

type Play struct {
	W *World
}

func NewPlay() *Play {
	return &Play{W: NewWorld()}
}

func (s *Play) Update(in, pressed Input) State {
	if pressed.Pause {
		return &Pause{Play: s}
	}
	if pressed.Lose {
		return &Over{}
	}
	move := in
	move.JumpPressed = pressed.JumpPressed
	move.JumpReleased = pressed.JumpReleased
	s.W.Step(move)
	s.W.StepCamera(FollowFactor, true)
	return s
}

func (s *Play) Name() string  { return "play" }
func (s *Play) World() *World { return s.W }

type Pause struct {
	Play *Play
}

func (s *Pause) Update(in, pressed Input) State {
	if pressed.Pause {
		return s.Play
	}
	if pressed.Quit {
		return &Menu{}
	}
	return s
}

func (s *Pause) Name() string  { return "pause" }
func (s *Pause) World() *World { return s.Play.World() }

type Over struct{}

func (s *Over) Update(in, pressed Input) State {
	if pressed.Start {
		return &Menu{}
	}
	return s
}

func (s *Over) Name() string  { return "over" }
func (s *Over) World() *World { return nil }

func (in Input) Pressed(last Input) Input {
	pressed := Input{}
	pressed.JumpPressed = in.JumpHeld && !last.JumpHeld
	pressed.JumpReleased = !in.JumpHeld && last.JumpHeld
	pressed.Start = in.Start && !last.Start
	pressed.Pause = in.Pause && !last.Pause
	pressed.Quit = in.Quit && !last.Quit
	pressed.Lose = in.Lose && !last.Lose
	return pressed
}

func StateTransitionReport() string {
	m := NewMachine()
	var names []string
	names = append(names, m.Name())
	tap(m, Input{Start: true, JumpHeld: true})
	names = append(names, m.Name())
	tap(m, Input{Pause: true})
	names = append(names, m.Name())
	tap(m, Input{Pause: true})
	names = append(names, m.Name())
	tap(m, Input{Lose: true})
	names = append(names, m.Name())
	tap(m, Input{Start: true})
	names = append(names, m.Name())
	return "states: " + strings.Join(names, " -> ")
}

func HeldStartReport() string {
	m := NewMachine()
	m.Update(Input{Start: true, JumpHeld: true})
	play, _ := m.State().(*Play)
	m.Update(Input{Start: true, JumpHeld: true})
	return fmt.Sprintf("held Space across menu: state %s; play tick %d; jumps %d", m.Name(), play.W.Tick, play.W.Player.Jumps)
}

func PauseFreezeReport() string {
	m := NewMachine()
	tap(m, Input{Start: true})
	for i := 0; i < 30; i++ {
		m.Update(Input{Right: true})
	}
	play := m.State().(*Play)
	before := play.W.Tick
	tap(m, Input{Pause: true})
	for i := 0; i < 30; i++ {
		m.Update(Input{Right: true})
	}
	after := m.State().World().Tick
	tap(m, Input{Pause: true})
	return fmt.Sprintf("pause at tick %d; after 30 paused updates tick %d; resumed %s", before, after, m.Name())
}

func PauseStepsFailureReport() string {
	good := NewMachine()
	tap(good, Input{Start: true})
	for i := 0; i < 30; i++ {
		good.Update(Input{Right: true})
	}
	tap(good, Input{Pause: true})
	for i := 0; i < 30; i++ {
		good.Update(Input{Right: true})
	}
	goodTick := good.State().World().Tick

	bad := NewWorld()
	for i := 0; i < 30; i++ {
		bad.Step(Input{Right: true})
	}
	for i := 0; i < 30; i++ {
		bad.Step(Input{Right: true})
	}
	return fmt.Sprintf("correct pause tick %d; broken pause tick %d", goodTick, bad.Tick)
}

func tap(m *Machine, in Input) {
	m.Update(in)
	m.Update(Input{})
}
go run ./cmd/platformer -play state-flow
$ go run ./cmd/platformer -play state-flow
states: menu -> play -> pause -> play -> over -> menu

Update returns the next state. Most ticks return the same pointer. Menu returns a new Play. Pause returns the same Play pointer it stored. Over returns Menu when Space is pressed again.

Held keys do not cross states

A held key can outlive the state that first read it. The machine prevents one Space hold from starting the game and jumping on the first play tick. It derives pressed edges from the previous tick after the state update finishes.

▣ Build · stage 3 · run the held-Space check
go run ./cmd/platformer -play held-start
$ go run ./cmd/platformer -play held-start
held Space across menu: state play; play tick 1; jumps 0

The play state advances one tick because the game has started. It does not jump. JumpHeld is true, but JumpPressed is false because Space was already down on the menu tick.

Pause keeps the play world frozen

Pause stores *Play, not a copy of the world. Its update reads Escape and Q only. It does not step physics, so the player and camera stay exactly where play left them.

▣ Build · stage 4 · run the pause freeze
go run ./cmd/platformer -play pause-freeze
$ go run ./cmd/platformer -play pause-freeze
pause at tick 31; after 30 paused updates tick 31; resumed play

The run gives the paused machine thirty updates with Right held. The world's tick is still 31. Escape returns to the saved play state, so the same world resumes instead of a new one starting.

The window draws each state

Replace cmd/platformer/main.go. Ebitengine still calls one Game. That Game forwards input to the machine, then draws by checking the current state type.

▣ Build · stage 5 · replace cmd/platformer/main.go
package main

import (
	"flag"
	"fmt"
	"image/color"
	"log"

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

	"gez/internal/platform"
	"gez/internal/vec"
)

const (
	screenW = 320
	screenH = 180
)

var play = flag.String("play", "", "print one platformer run and exit")
var trace = flag.Bool("trace", false, "print the camera position every thirty ticks")
var follow = flag.Float64("follow", platform.FollowFactor, "fraction of the remaining distance the camera closes each tick")
var noClamp = flag.Bool("no-clamp", false, "let the camera show outside the world rectangle")

type Game struct {
	machine *platform.Machine
}

func (g *Game) Update() error {
	in := platform.Input{
		Left:     ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA),
		Right:    ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD),
		JumpHeld: ebiten.IsKeyPressed(ebiten.KeySpace),
		Start:    ebiten.IsKeyPressed(ebiten.KeySpace),
		Pause:    ebiten.IsKeyPressed(ebiten.KeyEscape),
		Quit:     ebiten.IsKeyPressed(ebiten.KeyQ),
		Lose:     ebiten.IsKeyPressed(ebiten.KeyK),
	}
	g.machine.Update(in)
	if *trace {
		if w := g.machine.State().World(); w != nil && w.Tick%30 == 0 {
			p := w.Player
			sx, sy := w.Camera.ToScreen(platformPlayerPos(p))
			fmt.Printf("tick %d player (%.0f, %.0f) camera (%.4f, %.4f) screen (%d, %d)\n", w.Tick, p.X, p.Y, w.Camera.X, w.Camera.Y, sx, sy)
		}
	}
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawState(screen, g.machine.State())
}

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

func drawState(screen *ebiten.Image, state platform.State) {
	switch s := state.(type) {
	case *platform.Menu:
		drawMenu(screen)
	case *platform.Play:
		drawWorld(screen, s.World())
	case *platform.Pause:
		drawWorld(screen, s.World())
		vector.FillRect(screen, 0, 0, screenW, screenH, color.RGBA{R: 0, G: 0, B: 0, A: 140}, false)
		vector.FillRect(screen, 108, 76, 104, 28, color.RGBA{R: 230, G: 180, B: 70, A: 255}, false)
	case *platform.Over:
		drawOver(screen)
	default:
		screen.Fill(color.RGBA{R: 16, G: 20, B: 28, A: 255})
	}
}

func drawMenu(screen *ebiten.Image) {
	screen.Fill(color.RGBA{R: 16, G: 20, B: 28, A: 255})
	vector.FillRect(screen, 96, 64, 128, 32, color.RGBA{R: 82, G: 96, B: 80, A: 255}, false)
	vector.FillRect(screen, 124, 108, 72, 8, color.RGBA{R: 230, G: 180, B: 70, A: 255}, false)
}

func drawOver(screen *ebiten.Image) {
	screen.Fill(color.RGBA{R: 16, G: 20, B: 28, A: 255})
	vector.FillRect(screen, 124, 68, 72, 44, color.RGBA{R: 160, G: 64, B: 64, A: 255}, false)
	vector.FillRect(screen, 132, 120, 56, 8, color.RGBA{R: 230, G: 180, B: 70, A: 255}, false)
}

func drawWorld(screen *ebiten.Image, w *platform.World) {
	screen.Fill(color.RGBA{R: 16, G: 20, B: 28, A: 255})
	for x := 0.0; x <= platform.WorldW; x += 80 {
		drawWorldLine(screen, w.Camera, x, 0, x, platform.WorldH, color.RGBA{R: 45, G: 52, B: 64, A: 255})
	}
	drawWorldRect(screen, w.Camera, 0, platform.FloorY, platform.WorldW, platform.WorldH-platform.FloorY, color.RGBA{R: 82, G: 96, B: 80, A: 255})
	p := w.Player
	drawWorldRect(screen, w.Camera, p.X, p.Y, platform.PlayerW, platform.PlayerH, color.RGBA{R: 230, G: 180, B: 70, A: 255})
	drawWorldLine(screen, w.Camera, 0, platform.FloorY, platform.WorldW, platform.FloorY, color.RGBA{R: 232, G: 232, B: 232, A: 255})
}

func platformPlayerPos(p platform.Player) vec.Vec2 {
	return vec.Vec2{X: p.X, Y: p.Y}
}

func drawWorldRect(screen *ebiten.Image, cam platform.Camera, x, y, w, h float64, c color.Color) {
	sx, sy := cam.ToScreen(vec.Vec2{X: x, Y: y})
	vector.FillRect(screen, float32(sx), float32(sy), float32(w), float32(h), c, false)
}

func drawWorldLine(screen *ebiten.Image, cam platform.Camera, x0, y0, x1, y1 float64, c color.Color) {
	sx0, sy0 := cam.ToScreen(vec.Vec2{X: x0, Y: y0})
	sx1, sy1 := cam.ToScreen(vec.Vec2{X: x1, Y: y1})
	vector.StrokeLine(screen, float32(sx0), float32(sy0), float32(sx1), float32(sy1), 1, c, false)
}

func printPlay(name string) error {
	switch name {
	case "state-flow":
		fmt.Println(platform.StateTransitionReport())
	case "held-start":
		fmt.Println(platform.HeldStartReport())
	case "pause-freeze":
		fmt.Println(platform.PauseFreezeReport())
	case "pause-steps":
		fmt.Println(platform.PauseStepsFailureReport())
	case "camera-transform":
		fmt.Println(platform.CameraTransformReport())
	case "camera-follow":
		fmt.Println(platform.CameraFollowReport(*follow))
	case "camera-trace":
		fmt.Println(platform.CameraTraceReport(*follow, !*noClamp))
	case "camera-clamp":
		fmt.Println(platform.CameraClampReport(*follow, !*noClamp))
	case "camera-raw":
		fmt.Println(platform.CameraRawDrawReport())
	case "apex":
		fmt.Println(platform.ApexReport())
	case "jump-cut":
		fmt.Println(platform.JumpCutReport())
	case "coyote":
		fmt.Println(platform.CoyoteReport(0))
	case "buffer":
		fmt.Println(platform.BufferReport(4))
	case "cap":
		fmt.Println(platform.FallCapReport())
	case "bool":
		fmt.Println(platform.BoolFailureReport())
	case "":
		return nil
	default:
		return fmt.Errorf("no play run called %q", name)
	}
	return ebiten.Termination
}

func main() {
	flag.Parse()
	if *play != "" {
		if err := printPlay(*play); err != nil && err != ebiten.Termination {
			log.Fatal(err)
		}
		return
	}
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Platformer")
	ebiten.SetTPS(60)
	if err := ebiten.RunGame(&Game{machine: platform.NewMachine()}); err != nil {
		log.Fatal(err)
	}
}

Drawing stays outside internal/platform because drawing uses Ebitengine. The platform package keeps the rules. Pause draws the play world first, then places a dark layer and a small marker over it.

A dark platformer menu screen with a green title panel and a gold start marker.
Figure 24.1 · Menu is the first current state.
A platformer play screen with a dark background, grid lines, green ground, and the gold player on the floor.
Figure 24.2 · Play draws the world through the camera.
The platformer play screen dimmed by a pause overlay with a gold marker near the middle.
Figure 24.3 · Pause draws Play underneath a dim overlay.
A dark over screen with a red block and a small gold return marker.
Figure 24.4 · Over is separate from Menu and Play.

Pause must not step the world

⚠ Worked failure · Pause still calls world.Step

Put the pause flag around drawing only, and keep stepping the world in the shared update path. Run the failure print after thirty play ticks and thirty paused ticks:

go run ./cmd/platformer -play pause-steps
$ go run ./cmd/platformer -play pause-steps
correct pause tick 31; broken pause tick 60

The symptom is a moving world under a paused picture. The broken version still calls World.Step while the pause screen is active. The fix is the state machine: only Play.Update steps physics, and Pause.Update never calls it.

What the machine and states can now answer

✓ Checkpoint · game state machine
  • Store exactly one current state in Machine.
  • Return the next state from Update instead of setting several mode booleans.
  • Keep one Space hold from becoming both a menu start and a play jump.
  • Pause and resume the same Play pointer without advancing its world.
  • Draw Menu, Play, Pause and Over from a type switch in cmd/platformer.
⚡ Exercises · try first, then reveal
Exercise 1 · start on Enter. Add an Enter field to Input and let Menu start from Space or Enter.

Read ebiten.KeyEnter in main.go. Add the pressed-edge line in Input.Pressed. Run go run ./cmd/platformer and start the game with Enter.

Exercise 2 · pause counter. Add an integer to Pause that counts paused updates, and draw a wider overlay marker after sixty paused ticks.

The counter belongs to Pause, not Play. Run the window, press Escape, wait a second, and check that the world is still frozen while the pause marker changes.

Exercise 3 · restart from Over. Change Over.Update so Space returns directly to a fresh Play.

Return NewPlay() instead of &Menu{{}}. Run go run ./cmd/platformer, press Space, press K, then press Space again. The world starts at the beginning of a new play state.