Chapter 7: Collisions, runes and the speed ramp
This chapter:
- Detect the wolf hitting an obstacle (death) or falling into a gap.
- Collect runes for points and track a distance score.
- Ramp the speed so the game tightens the longer you last.
Where we left off: a full course scrolls past, but the wolf runs straight through everything.
7.1 Hitboxes
Collision works on axis-aligned bounding boxes: upright rectangles. Each entity gives one. The wolf's is deliberately a little smaller than its drawing:
// player.odin
player_rect :: proc() -> rl.Rectangle {
INSET :: 8.0
p := g.player
return {
p.pos.x - PLAYER_W * 0.5 + INSET,
p.pos.y - PLAYER_H + INSET,
PLAYER_W - 2 * INSET,
PLAYER_H - 2 * INSET,
}
}
Why a generous hitbox. A hitbox slightly inside the sprite means near-misses read as misses: the wolf's ear can clip the edge of a rock and survive. Players never complain that a game is too fair; they rage at deaths that "shouldn't have counted". Shrinking the player's box (not the obstacles') is the standard, invisible kindness.
Obstacles hand back their box from the OBSTACLE_DEFS table (Chapter 6's obstacle_rect); runes give a small box around their centre:
// entities.odin
rune_rect :: proc(r: Rune) -> rl.Rectangle {
return {r.pos.x - RUNE_R, r.pos.y - RUNE_R, RUNE_R * 2, RUNE_R * 2}
}
7.2 Rectangle overlap
raylib tests two rectangles for overlap for us:
rl.CheckCollisionRecs(a, b) -> bool
So the whole collision step is two loops: obstacles end the run, runes get collected:
// world.odin
check_collisions :: proc() {
pr := player_rect()
for &o in g.obstacles {
if !o.active do continue
if rl.CheckCollisionRecs(pr, obstacle_rect(o)) {
kill_player()
return
}
}
for &r in g.runes {
if !r.active do continue
if rl.CheckCollisionRecs(pr, rune_rect(r)) {
r.active = false
g.runes_got += 1
play_sound(.Rune) // Chapter 10
spawn_sparkle(r.pos) // Chapter 8
}
}
}
Collecting a rune is just deactivating its slot (freeing it for reuse) and bumping a counter. AABB overlap is cheap and exactly right for boxes that don't rotate, which none of ours do.
7.3 Two ways to die
There are two failure modes, and they arrive by different routes:
- Hitting an obstacle: caught above, in
check_collisions, which callskill_player. - Falling into a gap: no collision at all. Over a gap,
ground_solid_atis false (Chapter 6), so the wolf never lands; it falls untilpos.y > FALL_KILL_Y, and the player update callskill_player(Chapter 4's last line).
Both funnel into one place:
// game.odin
kill_player :: proc() {
if g.state != .Playing do return // ignore repeat calls
g.state = .Dead
trigger_shake(18) // Chapter 8
spawn_burst(player_center(), 42) // Chapter 8
play_sound(.Hit) // Chapter 10
set_wind_target(0) // Chapter 10
if g.score > g.best {
g.best = g.score
g.new_best = true
save_best(g.best) // Chapter 11
}
}
(player_center() is a one-liner in game.odin returning the wolf's mid-point, {g.player.pos.x, g.player.pos.y - PLAYER_H * 0.5}, the natural origin for the burst.)
The if g.state != .Playing do return guard matters: an obstacle hit and a fall could both fire on the same frame, and we want the run to end exactly once. Replace the Chapter 4 stub of kill_player with this (the effect/sound/save calls are still stubs until their chapters).
7.4 Scoring
Score is distance travelled plus a bonus per rune:
// game.odin, in the Playing branch of update_game
g.distance = g.player.pos.x
g.score = int(g.distance / 10) + g.runes_got * RUNE_SCORE
RUNE_SCORE :: 25. Distance is the wolf's world-x (it started at 0), divided down to a sensible number. Simple, legible, and always climbing: exactly what an endless runner's score should be.
7.5 The speed ramp: one knob that tightens everything
The single most important number for how the game plays is the run speed, and it rises with distance:
// game.odin: tuning
START_SPEED :: 320.0
MAX_SPEED :: 760.0
RAMP_DIST :: 14000.0
SPEED_K :: (MAX_SPEED - START_SPEED) / RAMP_DIST // speed added per pixel travelled
// in update_game's Playing branch:
g.speed = min(MAX_SPEED, START_SPEED + g.distance * SPEED_K)
That one value feeds back through the whole game:
- The wolf runs faster (
p.pos.x += g.speed * dt). - The camera scrolls faster (it follows the wolf).
- The course spaces itself out:
reach = g.speed ร hang-timegrows, so gaps get wider and the landing room after each feature grows too (Chapter 6). - The wind rumble rises in pitch and volume (Chapter 10).
Why one knob. Rather than separately ratcheting up spacing, gap size, camera speed and audio, we raise speed and let everything else read from it. Difficulty stays coherent: the game can't drift into "too fast to see but too sparse to challenge", because the spacing is derived from the speed. And clamping at
MAX_SPEEDkeeps it humanly possible however long you survive.
7.6 Wiring it into the update
The Playing branch of update_game now runs the full loop in order:
case .Playing:
g.run_time += dt
g.distance = g.player.pos.x
g.speed = min(MAX_SPEED, START_SPEED + g.distance * SPEED_K)
update_player(dt)
advance_generation()
cull_entities()
update_runes(dt)
check_collisions()
g.score = int(g.distance / 10) + g.runes_got * RUNE_SCORE
// (wind target, Chapter 10)
Order matters: advance the wolf, extend and trim the world around its new position, then test collisions against where it actually is.
Checkpoint
It's a game now. Run, jump, clear rocks and chasms, snatch runes for points, and feel the pace climb the longer you survive, until you clip an obstacle or misjudge a gap and it's over, with your score frozen. The HUD showing that score, and the screen that lets you start and restart, are Chapter 9; first, Chapter 8 makes every hit and landing feel like something.
Exercises
- Widen
INSETto14and then to0. Feel the difference between forgiving and brutal hitboxes. - Double
RUNE_SCOREand see how it shifts play toward risk-taking for runes over pure survival. - Raise
MAX_SPEEDto1000. Is the auto-spaced course still fair? Where does it break down? - Add a small streak bonus: track consecutive runes collected without a miss and award extra.
Further reading
- raylib cheatsheet (
CheckCollisionRecs,Rectangle): https://www.raylib.com/cheatsheet/cheatsheet.html - On AABB vs swept collision (we use AABB, fine for non-rotating boxes): search "AABB collision detection".