Chapter 6: Procedural terrain and obstacles
This chapter:
- Generate an endless course: spawn features ahead of the wolf, cull them behind.
- Do it deterministically with one seeded random generator.
- Store entities in fixed-size arrays with an
activeflag: zero per-frame allocation. - Drive obstacle variety from a single source-of-truth table.
Where we left off: an endless scrolling world with nothing in it.
6.1 Determinism first
Before we generate anything, decide where randomness comes from. If we used a different, ambient random source each run, courses would be unrepeatable: impossible to test, and unfair to compare scores on. So the game owns one seeded generator, stored in its state:
// game.odin
import "core:math/rand"
Game :: struct {
// ...
rng: rand.Default_Random_State,
// ...
}
At the start of a run we seed it, and at the top of each update we make it the generator for this scope via context:
context.random_generator = rand.default_random_generator(&g.rng)
Now every rand.float32(), rand.float32_range(lo, hi) and rand.int_max(n) in the update, and in everything it calls, draws from our seeded state, deterministically. (We deliberately don't use raylib's own GetRandomValue for gameplay, precisely so all randomness flows through this one controllable place. The full seeding story, including a fixed seed for testing, is Chapter 11.)
6.2 The entities, and the obstacle table
The world is made of three simple entity types plus the obstacle definitions:
// entities.odin
Obstacle :: struct {
active: bool,
kind: ObstacleKind,
x: f32, // world-space centre; y comes from the ground line and its height
}
Rune :: struct {
active: bool,
pos: Vec2,
bob: f32, // animation phase for the float/spin
}
Gap :: struct {
active: bool,
x0, x1: f32, // a span of ground with nothing under it
}
Obstacle kinds and their stats live in one place: the enumerated-array table from Chapter 2:
ObstacleKind :: enum u8 { Rock, Boulder, Stump }
ObstacleDef :: struct {
w, h: f32,
color: rl.Color,
}
OBSTACLE_DEFS := [ObstacleKind]ObstacleDef {
.Rock = {46, 40, {120, 122, 134, 255}},
.Boulder = {66, 74, { 96, 98, 112, 255}},
.Stump = {42, 58, {112, 80, 50, 255}},
}
Why a single source of truth. To add a fourth obstacle you add one enum value; the compiler then forces you to add its row to
OBSTACLE_DEFS, and the oneswitchindraw_obstacle(Chapter 8's neighbour) is where you add its look. Size, collision and spacing all read from this table, so there's no second place to forget. Its geometry helper:
obstacle_rect :: proc(o: Obstacle) -> rl.Rectangle {
d := OBSTACLE_DEFS[o.kind]
return {o.x - d.w * 0.5, GROUND_Y - d.h, d.w, d.h} // sits on the ground
}
6.3 Fixed-size arrays with an active flag
Here's the allocation-free entity pattern the whole game uses. The world's entities are fixed arrays inside the game struct:
// game.odin
MAX_OBSTACLES :: 64
MAX_RUNES :: 160
MAX_GAPS :: 32
Game :: struct {
// ...
obstacles: [MAX_OBSTACLES]Obstacle,
runes: [MAX_RUNES]Rune,
gaps: [MAX_GAPS]Gap,
// ...
}
Because every struct zero-initialises, all slots start active = false, empty, for free. To spawn, find the first inactive slot and fill it; to despawn, set active = false. No new, no append, no allocator, ever:
// world.odin
add_obstacle :: proc(kind: ObstacleKind, x: f32) {
for &o in g.obstacles {
if !o.active {
o = Obstacle{active = true, kind = kind, x = x}
return
}
}
}
add_rune and add_gap are the same shape. Iterating always skips inactive slots (if !o.active do continue). If every slot is full, add_* simply does nothing: a natural, crash-free cap on how much can exist at once.
Why no per-frame allocation. A single heap allocation in the wrong frame is a stutter. By pre-sizing these arrays we trade a fixed, known amount of memory for a guarantee: the hot loop never touches an allocator. This is the pattern behind particles, too (Chapter 8).
6.4 Spawn ahead, cull behind
Generation is driven by a spawn cursor: a world-x up to which the course has been built. Each frame we generate features until the cursor is comfortably past the right edge of the screen, then deactivate anything that has scrolled off the left.
// world.odin
SPAWN_AHEAD :: 1200.0
CULL_BEHIND :: 320.0
advance_generation :: proc() {
right := g.player.pos.x + (WINDOW_W - CAMERA_ANCHOR) + SPAWN_AHEAD
air: f32 = 2 * JUMP_SPEED / GRAVITY // seconds of hang time in a full jump
for g.spawn_cursor < right {
reach := g.speed * air // how far the wolf travels during one jump
roll := rand.float32()
switch {
case g.distance > 1500 && roll < 0.20:
// A gap to jump, sized to be clearable at the current speed.
gw := reach * rand.float32_range(0.40, 0.62)
x0 := g.spawn_cursor + 70
x1 := x0 + gw
add_gap(x0, x1)
spawn_rune_arc((x0 + x1) * 0.5, gw * 0.5)
// leave at least a full jump's landing room after the gap (fairness)
g.spawn_cursor = x1 + reach * rand.float32_range(1.0, 1.5) + 90
case roll < 0.70:
// A single obstacle, sometimes with a reward hanging above it.
kind := ObstacleKind(rand.int_max(int(max(ObstacleKind)) + 1))
add_obstacle(kind, g.spawn_cursor + 40)
if rand.float32() < 0.45 {
add_rune({g.spawn_cursor + 40, GROUND_Y - 120 - rand.float32_range(0, 60)})
}
// at least a full jump's landing room before the next feature (fairness)
g.spawn_cursor += reach * rand.float32_range(1.05, 1.6) + 60
case:
// An open stretch with a line of runes to collect.
n := 3 + rand.int_max(4)
base := g.spawn_cursor + 40
for i in 0 ..< n {
add_rune({base + f32(i) * 44, GROUND_Y - 80 - rand.float32_range(0, 50)})
}
g.spawn_cursor = base + f32(n) * 44 + reach * rand.float32_range(0.4, 0.8) + 60
}
}
}
The design goals baked in here:
- Fair by construction.
reach = speed ร hang-timeis how far the wolf can jump right now. Gaps are sized as a fraction ofreach(always clearable). Crucially, the space after any airborne feature, a gap or an obstacle, is at least a fullreach(the1.05/1.0lower bounds), so the wolf always lands with room to spare before the next demand, rather than arriving at it still in mid-air. As the speed ramp (Chapter 7) raisesg.speed,reachgrows and the whole course spaces itself out automatically.
Why that full-
reachgap matters (and how we found it). An earlier version used smaller lower bounds, and a feature could spawn closer than a full jump's landing distance, so you'd still be descending from clearing one obstacle when the next arrived, with no chance to jump again. It was technically survivable with frame-perfect short hops, but that's a brutal ask early in a run. We caught it by running the finished game headlessly with a simple auto-player that only ever full-jumps: it died on obstacles around 2,500โ3,000px every time. Raising the post-feature spacing to a fullreachfixed it: the same auto-player then cleared run after run. Determinism (ยง6.1) is what made that test repeatable; a bug you can reproduce is a bug you can fix.
- Variety with weights. A
switchon a randomrollpicks a gap (only once you're warmed up, past
1500px), a single obstacle, or an open rune stretch: roughly 20% / 50% / 30%.
- A
bool && condcase label. Odin'sswitchwith no operand is a clean "if/else-if" ladder; the first matchingcasewins.
spawn_rune_arc strings runes in a tempting arc, e.g. across a gap:
spawn_rune_arc :: proc(cx, half: f32) {
N :: 5
for i in 0 ..< N {
t := f32(i) / f32(N - 1) // 0..1
x := cx + (t - 0.5) * 2 * half
y := GROUND_Y - 70 - math.sin(t * math.PI) * 90 // a shallow arch
add_rune({x, y})
}
}
And culling: deactivate what's well off the left edge, freeing the slot for reuse:
cull_entities :: proc() {
left := g.player.pos.x - CAMERA_ANCHOR - CULL_BEHIND
for &o in g.obstacles do if o.active && o.x < left do o.active = false
for &r in g.runes do if r.active && r.pos.x < left do r.active = false
for &gp in g.gaps do if gp.active && gp.x1 < left do gp.active = false
}
6.5 Ground with holes in it
Gaps aren't a separate surface: they're absence of ground. One query answers "is there ground here?", and the player's landing check from Chapter 4 already calls it:
ground_solid_at :: proc(x: f32) -> bool {
for gp in g.gaps {
if gp.active && x > gp.x0 && x < gp.x1 do return false
}
return true
}
Over a gap, ground_solid_at is false, so the wolf never lands there; it keeps falling until pos.y passes FALL_KILL_Y and (Chapter 7) the run ends. Now replace the Chapter 4 stub of ground_solid_at with this real one.
6.6 Drawing what we generated
Drawing lives in draw_world (world space, inside Mode2D). The ground is one big rectangle with the gaps punched out as dark pits drawn on top:
// draw.odin (excerpt)
cam_l := g.player.pos.x - CAMERA_ANCHOR - 40
width := f32(WINDOW_W) + 80
depth := f32(WINDOW_H) + 200 - GROUND_Y
rl.DrawRectangleRec({cam_l, GROUND_Y, width, depth}, ground)
rl.DrawRectangleRec({cam_l, GROUND_Y, width, 8}, grass) // grass line
for gp in g.gaps {
if !gp.active do continue
rl.DrawRectangleRec({gp.x0, GROUND_Y, gp.x1 - gp.x0, depth}, PIT_COLOR)
}
Runes are the diamonds from Chapter 3, bobbing on r.bob (advanced by update_runes); obstacles are drawn per-kind in draw_obstacle: the switch that pairs with the OBSTACLE_DEFS table. We'll polish both when we add juice in Chapter 8; for now, rects and diamonds are plenty.
Checkpoint
An endless, deterministic obstacle course scrolls past: rocks, boulders and stumps to jump, chasms to leap, and runes strung in tempting arcs, all generated ahead of the wolf and recycled behind it, with zero per-frame allocation. The same seed produces the same course. Nothing collides yet: the wolf runs straight through it all. Chapter 7 fixes that.
Exercises
- Add a fourth
ObstacleKind. Follow the compiler: it will make you add the table row. Then add its draw case (peek at Chapter 8'sdraw_obstacle). - Make gaps more common after a longer distance by scaling the
0.20chance withg.distance. - Temporarily set
SEED_FIXED(Chapter 11) to a constant and confirm the course is identical every run. - Shrink
MAX_RUNESto8and watch rune arcs thin out as slots run dry: the array cap in action.
Further reading
- Odin
core:math/rand(Default_Random_State,float32,float32_range,int_max): https://pkg.odin-lang.org/core/math/rand/ - Odin enumerated arrays and
switch, overview: https://odin-lang.org/docs/overview/ - On chunk-based endless generation: search "endless runner procedural chunk spawning".