Odin't Know That ยท Chapter 7

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 calls kill_player.
  • Falling into a gap: no collision at all. Over a gap, ground_solid_at is false (Chapter 6), so the wolf never lands; it falls until pos.y > FALL_KILL_Y, and the player update calls kill_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-time grows, 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_SPEED keeps 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

  1. Widen INSET to 14 and then to 0. Feel the difference between forgiving and brutal hitboxes.
  2. Double RUNE_SCORE and see how it shifts play toward risk-taking for runes over pure survival.
  3. Raise MAX_SPEED to 1000. Is the auto-spaced course still fair? Where does it break down?
  4. Add a small streak bonus: track consecutive runes collected without a miss and award extra.

Further reading