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, andstart_runflips the state, the press that starts a run is handled in the.Titlebranch and can't also register as a jump: the.Playingbranch doesn't run this frame, and next frameIsKeyPressedis 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
i32casts. raylib's text procedures takec.int(which isi32), andMeasureTextreturns one.WINDOW_Wis an untyped constant, soi32(WINDOW_W)pins it to match, and the arithmetic stays ini32. 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
- Add a
Pausedstate toggled by.Pin the.Playingbranch; freeze the simulation and draw a "PAUSED" overlay. Mind the one-shot rule. - Make the "press SPACE to run" prompt blink by gating its draw on
sin(g.run_time * 4) > 0. - Show a small combo indicator on the HUD when runes are collected in quick succession.
- Right-align the speed as text (e.g. "SPD 540") above the bar using
MeasureText.
Further reading
- raylib cheatsheet (text,
MeasureText, default font): https://www.raylib.com/cheatsheet/cheatsheet.html - Odin
core:fmt(ctprintf): https://pkg.odin-lang.org/core/fmt/ - Sibling volume's latch-draining approach for comparison: https://nofuss.co.za/yo_odin/