Chapter 5: A scrolling world: Camera2D and parallax
This chapter:
- Understand world space vs screen space, and why "endless" is easy once you separate them.
- Use raylib's
Camera2Dto follow the wolf. - Paint a parallax backdrop of hills and a banded sky, all from primitives.
Where we left off: the wolf runs and jumps on a fixed flat ground.
5.1 The insight: never move the player back
In Chapter 4 the wolf's pos.x grows forever; after a minute it might be 20,000. You might expect we have to wrap it back to zero to keep it on screen. We don't, and that's the whole trick.
We keep two spaces in our heads:
- World space: where things are. The wolf at x = 20,000; an obstacle at x = 20,300. Unbounded, ever-growing.
- Screen space: where things are drawn. Always 0…960 across, 0…540 down.
A camera is just the mapping between them. If we point the camera at the wolf, then whatever the wolf's world-x, it's drawn in the same place on screen, and the world slides past behind it. Nothing ever needs wrapping. "Endless" isn't a feature we implement: it's what you get for free when you draw a moving world through a following camera.
Why this beats scrolling the world. The alternative, keeping the wolf at a fixed x and subtracting a scroll offset from everything else, works, but you end up subtracting that offset by hand in every draw call and every collision check. A camera does the subtraction once, for everything, and keeps your world coordinates honest.
5.2 Camera2D
raylib's 2D camera is a small struct:
Camera2D :: struct {
offset: Vector2, // where `target` lands on the SCREEN
target: Vector2, // the WORLD point the camera is centred on
rotation: f32,
zoom: f32,
}
The mapping it applies is: screen = (world - target) * zoom + offset. So if we set target to the wolf's world position and offset to a fixed screen anchor, the wolf is nailed to that screen spot and the world flows past.
We anchor the wolf about a third of the way in from the left, the classic runner framing, and only scroll horizontally:
// game.odin
CAMERA_ANCHOR :: 320.0 // the wolf's fixed screen-x
g.camera = rl.Camera2D{
target = {g.player.pos.x, 0},
offset = {CAMERA_ANCHOR, 0},
zoom = 1,
}
Each frame, update the camera to follow:
g.camera.target.x = g.player.pos.x
(We leave target.y and offset.y at 0, so vertical world coordinates map straight to the screen: the ground at world-y 400 is drawn at screen-y 400. The camera scrolls sideways only.)
To draw through the camera, wrap your world drawing in BeginMode2D/EndMode2D:
rl.BeginMode2D(g.camera)
// ... draw the world in WORLD coordinates ...
rl.EndMode2D()
Anything drawn between those two is transformed by the camera. Anything drawn outside them is plain screen space, which is exactly where the HUD belongs, so it stays put while the world scrolls (and, in Chapter 8, while the world shakes). That separation is deliberate and we rely on it repeatedly.
5.3 The frame, reorganised
draw_game() now has a clear three-part shape: screen-space backdrop, then the world through the camera, then the screen-space HUD.
// draw.odin
draw_game :: proc() {
f := day_factor() // 0..1, night..noon (Chapter 8)
draw_sky(f) // screen space
draw_parallax(f) // screen space
rl.BeginMode2D(g.camera)
draw_world(f) // world space
draw_player()
draw_particles()
rl.EndMode2D()
draw_hud() // screen space
}
The backdrop is drawn in screen space on purpose: parallax layers move at their own pace, not the camera's, so it's simpler to place them ourselves than to fight the camera. The world, ground, obstacles, runes, the wolf, is drawn in honest world coordinates inside Mode2D.
5.4 A banded sky
A gradient sky, built from horizontal bands lerped between a top and bottom colour (the day→night colours come in Chapter 8; for now imagine two fixed colours):
draw_sky :: proc(f: f32) {
top := rl.ColorLerp(SKY_NIGHT_TOP, SKY_DAY_TOP, f)
bot := rl.ColorLerp(SKY_NIGHT_BOT, SKY_DAY_BOT, f)
BANDS :: 24
bh := f32(WINDOW_H) / BANDS
for i in 0 ..< BANDS {
t := f32(i) / f32(BANDS - 1)
rl.DrawRectangleRec({0, f32(i) * bh, f32(WINDOW_W), bh + 1}, rl.ColorLerp(top, bot, t))
}
}
rl.ColorLerp(a, b, t) blends two colours: t = 0 gives a, t = 1 gives b. Twenty-four bands reads as a smooth gradient. (A sun/moon disc gets added here in Chapter 8.)
5.5 Parallax hills
Distant things drift slower than near things; that's parallax, and it's what sells depth. We draw two layers of rounded hills, each scrolled by a fraction of the camera's world-x: the far layer at 0.20, the near layer at 0.45. Slower fraction = feels further away.
Each layer is a row of overlapping circles sitting above a filled base: cheap, and pleasingly rounded:
draw_hill_layer :: proc(factor, base_y, radius, spacing: f32, color: rl.Color) {
scroll := g.camera.target.x * factor // this layer's own scroll amount
start := math.floor((scroll - radius) / spacing) * spacing
x := start
for x < scroll + f32(WINDOW_W) + radius {
rl.DrawCircleV({x - scroll, base_y}, radius, color) // screen-x = worldish-x - scroll
x += spacing
}
rl.DrawRectangleRec({0, base_y, f32(WINDOW_W), f32(WINDOW_H) - base_y}, color) // fill below
}
draw_parallax :: proc(f: f32) {
draw_hill_layer(0.20, 300, 150, 210, rl.ColorLerp(HILL_FAR_NIGHT, HILL_FAR_DAY, f))
draw_hill_layer(0.45, 350, 120, 170, rl.ColorLerp(HILL_NEAR_NIGHT, HILL_NEAR_DAY, f))
}
The floor(...) * spacing line finds the first hill position at or before the visible edge, so we draw exactly the bumps in view and no more, a tiny, deliberate cull. Subtracting scroll converts each hill's position into a screen position.
Checkpoint
The wolf runs rightward forever across a scrolling landscape: two layers of hills drift past at different speeds under a banded sky, and the camera tracks the wolf smoothly with it anchored a third of the way in. The world is drawn in real world coordinates through Camera2D; the backdrop and (soon) the HUD sit outside it. There's nothing in the world to dodge yet; that's the next chapter.
Exercises
- Add a third, furthest hill layer at factor
0.10. Where in the draw order must it go? - Change
CAMERA_ANCHORto480(centre) and back to320. Which framing suits a runner, and why? - Set both parallax factors to the same value. Watch the depth illusion collapse; that's why they must differ.
- Add
g.camera.zoomwobble (e.g.1 + 0.02*sin(t)) and see the whole world breathe. (We won't keep it, but it shows what the camera can do.)
Further reading
- raylib cheatsheet (Camera2D,
BeginMode2D/EndMode2D): https://www.raylib.com/cheatsheet/cheatsheet.html - raylib 2D camera example: https://www.raylib.com/examples.html
- Odin
core:math(floor): https://pkg.odin-lang.org/core/math/