Odin't Know That · Chapter 9

Chapter 9: States, menus and the HUD

This chapter:

  • A title → playing → dead state machine, with clean transitions.
  • A HUD (score, best, speed) and title/game-over overlays.
  • Draw text using only raylib's built-in font: no font file shipped.

Where we left off: a lively game with no title, no game-over, and no score on screen.


9.1 The state machine

The game is always in exactly one of three states, and the whole game reads differently in each:

// game.odin
GameState :: enum { Title, Playing, Dead }

Both the update and the draw dispatch on it. Update:

// game.odin, update_game
switch g.state {
case .Playing:
	// the full simulation (Chapter 7)
case .Title:
	g.run_time += dt * 0.3       // let the sky drift
	g.player.anim += dt * 6      // the wolf jogs in place behind the title
case .Dead:
	g.run_time += dt             // world frozen; effects still settle
}

update_particles(dt)             // these run in every state
update_shake(dt)
update_wind(dt)

Note what's shared below the switch: particles, shake and wind keep updating in every state, so the death burst still flies and settles on the game-over screen. Only the simulation itself is gated to .Playing.

9.2 Transitions, and the one-shot rule

Input dispatches on state too. raylib's IsKeyPressed is edge-triggered: true for exactly one frame, which is precisely what one-shot actions (start, restart) want:

// game.odin
handle_input :: proc() {
	jump_pressed := rl.IsKeyPressed(.SPACE) || rl.IsKeyPressed(.UP) || rl.IsKeyPressed(.ENTER)
	switch g.state {
	case .Title:
		if jump_pressed do start_run()
	case .Playing:
		if rl.IsKeyPressed(.SPACE) || rl.IsKeyPressed(.UP) do g.player.jump_buffer = JUMP_BUFFER
		g.player.jump_held = rl.IsKeyDown(.SPACE) || rl.IsKeyDown(.UP)
		if rl.IsKeyPressed(.M) do toggle_mute()
	case .Dead:
		if jump_pressed || rl.IsKeyPressed(.R) do start_run()
	}
}

Why the state-guarded switch matters. Because we branch on g.state, and start_run flips the state, the press that starts a run is handled in the .Title branch and can't also register as a jump: the .Playing branch doesn't run this frame, and next frame IsKeyPressed is already false. raylib's edge detection means we get this for free; the state switch guarantees it stays true even as we add more actions. In Yo Odin (https://nofuss.co.za/yo_odin/) the same guarantee had to be hand-built by draining a latch every frame, a good reminder of what the library is doing for us.

start_run is the clean-slate reset: new seed, entities cleared, timers zeroed, separate from first-time init so it can run any number of times without touching the window or audio:

start_run :: proc() {
	// ... reseed g.rng, clear obstacles/runes/gaps/particles, reset player/speed/score/timers ...
	g.camera = rl.Camera2D{target = {0, 0}, offset = {CAMERA_ANCHOR, 0}, zoom = 1}
	g.state = .Playing
	set_wind_target(1)
	context.random_generator = rand.default_random_generator(&g.rng)
	advance_generation()   // fill the first screen before the player sees it
}

(Its twin, init_title, sets up the idle title scene once at boot. Full listings in Appendix B.)

9.3 Text without a font file

raylib has a built-in default font, so we can draw text with no .ttf shipped, consistent with the zero-assets rule. The two calls:

rl.DrawText(text: cstring, posX, posY, fontSize: c.int, color: rl.Color)
rl.MeasureText(text: cstring, fontSize: c.int) -> c.int   // pixel width, for centring

Dynamic text goes through fmt.ctprintf into a per-frame temp cstring: the safe pattern from Chapter 3, no allocation you manage, no leak.

9.4 The HUD

The HUD is drawn in screen space, outside Mode2D, so scrolling and shake never move it:

// hud.odin
draw_run_hud :: proc() {
	rl.DrawText(fmt.ctprintf("SCORE %d", g.score), 20, 18, 30, rl.WHITE)
	rl.DrawText(fmt.ctprintf("RUNES %d", g.runes_got), 20, 54, 22, RUNE_COLOR)

	best := fmt.ctprintf("BEST %d", g.best)
	bw := rl.MeasureText(best, 24)
	rl.DrawText(best, i32(WINDOW_W) - 20 - bw, 20, 24, rl.Color{200, 205, 220, 255})

	// a thin speed bar along the bottom
	frac := (g.speed - START_SPEED) / (MAX_SPEED - START_SPEED)
	rl.DrawRectangleRec({0, WINDOW_H - 6, f32(WINDOW_W), 6}, rl.Fade(rl.BLACK, 0.3))
	rl.DrawRectangleRec({0, WINDOW_H - 6, f32(WINDOW_W) * frac, 6}, rl.Color{240, 180, 70, 255})
}

MeasureText lets us right-align the best score: measure its width, subtract from the screen edge. The same trick centres overlay text:

draw_center :: proc(text: cstring, y, size: i32, color: rl.Color) {
	w := rl.MeasureText(text, size)
	rl.DrawText(text, i32(WINDOW_W) / 2 - w / 2, y, size, color)
}

Odin note: the i32 casts. raylib's text procedures take c.int (which is i32), and MeasureText returns one. WINDOW_W is an untyped constant, so i32(WINDOW_W) pins it to match, and the arithmetic stays in i32. Being explicit here is Odin keeping you honest across the C boundary.

9.5 The overlays

draw_hud picks the overlay by state; game-over draws the frozen field and the HUD beneath its dim panel:

draw_hud :: proc() {
	switch g.state {
	case .Title:   draw_title()
	case .Playing: draw_run_hud()
	case .Dead:    draw_run_hud(); draw_game_over()
	}
}

draw_title :: proc() {
	rl.DrawRectangle(0, 0, WINDOW_W, WINDOW_H, rl.Fade(rl.BLACK, 0.25))
	draw_center("SKÖLL", 150, 96, rl.WHITE)
	draw_center("the wolf that chases the sun", 250, 24, rl.Color{210, 214, 228, 255})
	draw_center("press SPACE to run", 330, 30, rl.Color{240, 200, 90, 255})
	draw_center("SPACE / UP jump   -   M mute", 386, 20, rl.Color{170, 176, 190, 255})
}

draw_game_over :: proc() {
	rl.DrawRectangle(0, 0, WINDOW_W, WINDOW_H, rl.Fade(rl.BLACK, 0.5))
	draw_center("YOU FELL", 140, 80, rl.WHITE)
	draw_center(fmt.ctprintf("score %d", g.score), 240, 34, rl.Color{220, 224, 236, 255})
	if g.new_best {
		draw_center("NEW BEST!", 292, 30, rl.Color{240, 200, 90, 255})
	} else {
		draw_center(fmt.ctprintf("best %d", g.best), 292, 26, rl.Color{190, 196, 210, 255})
	}
	draw_center("press SPACE to run again", 372, 26, rl.Color{170, 176, 190, 255})
}

Because the title and dead states leave the world drawn behind the dim panel, both overlays sit over a living scene: the wolf jogging on the title, the settling death burst on game-over, rather than a blank screen.

Gotcha: the default font is ASCII-ish. The built-in font covers basic Latin glyphs; fancier characters (and the "ö" in "SKÖLL", depending on your build) may render as a placeholder. For a game that needs full Unicode you'd load a real font with rl.LoadFontEx, which means shipping a font file, a trade the book chooses not to make. Appendix C shows the call for when you want it.

Checkpoint

A complete game flow: a title screen that invites you to run, live play with a readable HUD (score, runes, best, a speed bar), and a game-over screen showing your result and celebrating a new best; then one key restarts cleanly into a fresh course. The only silence left is literal: the game makes no sound yet. Chapter 10 fixes that, from scratch.

Exercises

  1. Add a Paused state toggled by .P in the .Playing branch; freeze the simulation and draw a "PAUSED" overlay. Mind the one-shot rule.
  2. Make the "press SPACE to run" prompt blink by gating its draw on sin(g.run_time * 4) > 0.
  3. Show a small combo indicator on the HUD when runes are collected in quick succession.
  4. Right-align the speed as text (e.g. "SPD 540") above the bar using MeasureText.

Further reading