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

The Camera

World points and screen pixels

The player can now move past the right edge of the 320 by 180 screen. A camera fixes that by storing the world point at the top-left of the view. Drawing subtracts that point from every world position before it reaches Ebitengine.

A point at world x 168 and y 136 draws at screen x 48 and y 116 when the camera sits at x 120 and y 20. The subtraction is the whole transform for this chapter.

∑ Math Interlude · subtracting the camera

The camera stores the world position of the screen's top-left corner. Screen x is world x minus camera x. Screen y is world y minus camera y. The run uses camera (120, 20) and world point (168, 136), so the screen point is (168 - 120, 136 - 20), or (48, 116).

worldthe position in the level, measured from the level's top-left corner
camerathe world position shown at the screen's top-left corner
screenworld minus camera, rounded to the integer pixel Ebitengine draws

The camera value

Extend internal/platform/physics.go. Add one field to World. The camera belongs to the rules package because tests need the same numbers the window uses.

▣ Build · stage 1 · extend internal/platform/physics.go
type World struct {
	Player     Player
	Camera     Camera
	Tick       int
	FloorY     float64
	LedgeRight float64
	MaxFall    float64
}

Create internal/platform/camera.go. The file gives the world its size, stores the camera, and converts a world point to a screen point.

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

import (
	"fmt"
	"math"
	"strings"

	"gez/internal/vec"
)

const (
	WorldW       = 800.0
	WorldH       = 256.0
	ScreenW      = 320.0
	ScreenH      = 180.0
	FollowFactor = 0.1
)

type Camera struct {
	X, Y float64
}

func (c Camera) ToScreen(p vec.Vec2) (int, int) {
	return int(math.Round(p.X - c.X)), int(math.Round(p.Y - c.Y))
}

func (c Camera) TargetFor(p Player) vec.Vec2 {
	return vec.Vec2{X: p.X + PlayerW/2 - ScreenW/2, Y: p.Y + PlayerH/2 - ScreenH/2}
}

func (c *Camera) Follow(target vec.Vec2, fraction float64) {
	c.X += (target.X - c.X) * fraction
	c.Y += (target.Y - c.Y) * fraction
}

func (c *Camera) Clamp() {
	c.X = clamp(c.X, 0, WorldW-ScreenW)
	c.Y = clamp(c.Y, 0, WorldH-ScreenH)
}

func (w *World) StepCamera(fraction float64, clamped bool) {
	w.Camera.Follow(w.Camera.TargetFor(w.Player), fraction)
	if clamped {
		w.Camera.Clamp()
	}
}

func CameraTransformReport() string {
	cam := Camera{X: 120, Y: 20}
	sx, sy := cam.ToScreen(vec.Vec2{X: 168, Y: 136})
	return fmt.Sprintf("camera (%.0f, %.0f); world (168, 136) -> screen (%d, %d)", cam.X, cam.Y, sx, sy)
}

func CameraFollowReport(fraction float64) string {
	cam := Camera{}
	target := vec.Vec2{X: 100}
	var parts []string
	for tick := 1; tick <= 5; tick++ {
		cam.Follow(target, fraction)
		parts = append(parts, fmt.Sprintf("tick %d %.4f", tick, cam.X))
	}
	return fmt.Sprintf("follow %.4f toward x 100: %s", fraction, strings.Join(parts, "; "))
}

func CameraTraceReport(fraction float64, clamped bool) string {
	w := NewWorld()
	var lines []string
	for w.Tick < 120 {
		w.Step(Input{Right: true})
		w.StepCamera(fraction, clamped)
		if w.Tick%30 == 0 {
			sx, sy := w.Camera.ToScreen(vec.Vec2{X: w.Player.X, Y: w.Player.Y})
			lines = append(lines, fmt.Sprintf("tick %d player (%.0f, %.0f) camera (%.4f, %.4f) screen (%d, %d)", w.Tick, w.Player.X, w.Player.Y, w.Camera.X, w.Camera.Y, sx, sy))
		}
	}
	return strings.Join(lines, "\n")
}

func CameraClampReport(fraction float64, clamped bool) string {
	w := NewWorld()
	w.Player.X = 780
	w.Player.Y = 240
	w.StepCamera(fraction, clamped)
	maxX, maxY := WorldW-ScreenW, WorldH-ScreenH
	state := "off"
	if clamped {
		state = "on"
	}
	return fmt.Sprintf("clamp %s; target (%.4f, %.4f); camera (%.4f, %.4f); max (%.0f, %.0f)", state, w.Camera.TargetFor(w.Player).X, w.Camera.TargetFor(w.Player).Y, w.Camera.X, w.Camera.Y, maxX, maxY)
}

func CameraRawDrawReport() string {
	cam := Camera{X: 160, Y: 0}
	p := vec.Vec2{X: 200, Y: FloorY - PlayerH}
	sx, _ := cam.ToScreen(p)
	return fmt.Sprintf("camera x %.0f; player world x %.0f; ToScreen draws x %d; raw Draw uses x %.0f", cam.X, p.X, sx, p.X)
}

func clamp(v, lo, hi float64) float64 {
	if hi < lo {
		return lo
	}
	return math.Max(lo, math.Min(hi, v))
}
go run ./cmd/platformer -play camera-transform
$ go run ./cmd/platformer -play camera-transform
camera (120, 20); world (168, 136) -> screen (48, 116)

Camera has no Ebitengine import. The window and the tests can both ask for the same screen point. Rounding happens once at the boundary between physics numbers and pixels.

Following the player by one tenth

A snapped camera copies the target in one tick. This camera closes one tenth of the remaining distance each tick, so a large gap moves more than a small gap. The rule uses the same formula on x and y.

▣ Build · stage 3 · run the follow print
go run ./cmd/platformer -play camera-follow
$ go run ./cmd/platformer -play camera-follow
follow 0.1000 toward x 100: tick 1 10.0000; tick 2 19.0000; tick 3 27.1000; tick 4 34.3900; tick 5 40.9510

The first tick closes 10 pixels because the gap is 100. The second closes 9 pixels because the gap is now 90. The camera keeps easing toward the target without passing it.

The window draws through the camera

Replace cmd/platformer/main.go. The game still reads the same movement and jump input. After physics steps, the camera follows the player, clamps to the world, and the draw helpers convert every world point before drawing.

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

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

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/inpututil"
	"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 physics 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 {
	world *platform.World
}

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),
		JumpPressed:  inpututil.IsKeyJustPressed(ebiten.KeySpace),
		JumpReleased: inpututil.IsKeyJustReleased(ebiten.KeySpace),
	}
	g.world.Step(in)
	g.world.StepCamera(*follow, !*noClamp)
	if *trace && g.world.Tick%30 == 0 {
		p := g.world.Player
		sx, sy := g.world.Camera.ToScreen(platformPlayerPos(p))
		fmt.Printf("tick %d player (%.0f, %.0f) camera (%.4f, %.4f) screen (%d, %d)\n", g.world.Tick, p.X, p.Y, g.world.Camera.X, g.world.Camera.Y, sx, sy)
	}
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawWorld(screen, g.world)
}

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

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 "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{world: platform.NewWorld()}); err != nil {
		log.Fatal(err)
	}
}
go run ./cmd/platformer -play camera-trace
$ go run ./cmd/platformer -play camera-trace
tick 30 player (108, 136) camera (0.0000, 51.7109) screen (108, 84)
tick 60 player (168, 136) camera (4.6093, 53.9030) screen (163, 82)
tick 90 player (228, 136) camera (56.3650, 53.9959) screen (172, 82)
tick 120 player (288, 136) camera (116.0155, 53.9998) screen (172, 82)

The trace prints every thirty ticks. The player moves right at 2 pixels a tick, and the camera begins to move once the player's target is past the left edge. The screen x settles near the centre instead of increasing forever.

A platformer screen with a dark background, grid lines, green ground, and a gold player near the left side.
Figure 23.1 · the camera starts at the world's left edge.
The same platformer after the player has run right, with the camera following and the player near the middle of the screen.
Figure 23.2 · after 120 rightward ticks, the camera follows and keeps the player on screen.

Clamping the camera to the world edge

The world is 800 pixels wide and 256 pixels tall. The screen can show 320 by 180 of it. The largest legal camera position is x 480 and y 76, because those numbers still leave one full screen inside the world.

▣ Build · stage 5 · run the edge clamp
go run ./cmd/platformer -play camera-clamp -follow 1
go run ./cmd/platformer -play camera-clamp -follow 1 -no-clamp
$ go run ./cmd/platformer -play camera-clamp -follow 1
clamp on; target (626.0000, 158.0000); camera (480.0000, 76.0000); max (480, 76)
$ go run ./cmd/platformer -play camera-clamp -follow 1 -no-clamp
clamp off; target (626.0000, 158.0000); camera (626.0000, 158.0000); max (480, 76)

The -follow 1 flag makes the camera move straight to the target in one tick, so the clamp is the only difference between the two runs. With the clamp off, the camera asks to show space past the world's right and bottom edges. With the clamp on, it stops at the last full view.

The platformer view at the far edge of the world, with the ground and rightmost grid line still filling the screen.
Figure 23.3 · the camera can reach the last full view but cannot show outside the world.

Raw world coordinates break drawing

⚠ Worked failure · Draw skips ToScreen

Change one draw call so it uses p.X directly instead of calling ToScreen. Run the failure print with the camera at x 160:

go run ./cmd/platformer -play camera-raw
$ go run ./cmd/platformer -play camera-raw
camera x 160; player world x 200; ToScreen draws x 40; raw Draw uses x 200

The symptom is a player drawn 160 pixels too far right. The camera has moved, but the broken draw call ignores it. The fix is to keep drawWorldRect and drawWorldLine as the only places that turn world points into screen pixels.

What the camera and draw helpers can now answer

✓ Checkpoint · camera transform
  • Convert a world point to a screen pixel by subtracting Camera.X and Camera.Y.
  • Follow the player by closing 0.1 of the remaining distance each tick.
  • Print the player, camera and screen positions every thirty ticks with -trace.
  • Clamp the camera to x 480 and y 76 so a 320 by 180 screen stays inside an 800 by 256 world.
  • Find a draw call that skipped ToScreen by comparing the raw x with the screen x.
⚡ Exercises · try first, then reveal
Exercise 1 · faster follow. Run go run ./cmd/platformer -play camera-follow -follow 0.25.

The first number is 25.0000 because the camera closes one quarter of the 100-pixel gap. Each later tick closes one quarter of the smaller gap.

Exercise 2 · visible debug marker. Extend drawWorld to draw a thin vertical line at the camera's target x, then run the window and hold D.

The marker should stay near the middle while the grid slides. If it moves with the player rectangle, the line used a raw world coordinate.

Exercise 3 · clamp off. Run the window with -no-clamp, hold D until the far edge, then run it again without that flag.

With the flag, the background can slide past the edge of the world. Without it, the view stops at the last legal camera x.