Chapter 10: HUD, the state machine, and a persistent high score
This chapter: on-screen text without fmt, the intro โ play โ pause โ game-over flow with overlays, lives and death, and a high score that survives a reload.
Where we left off: the game plays and looks alive, but has no score display, no menus, and forgets everything on refresh.
10.1 Text and numbers without fmt
core:fmt is heavy and allocates, not what we want in the wasm game. We format integers into stack buffers with core:strconv instead:
import "core:strconv"
// "SCORE 1200": prefix then a decimal, into a caller-supplied buffer.
str_concat :: proc(dst: []byte, prefix: string, n: int) -> string {
i := copy(dst, prefix)
s := strconv.write_int(dst[i:], i64(n), 10)
return string(dst[:i + len(s)])
}
// "rgb(v,v,v)" for a 0..1 brightness, used by the starfield.
grey_buf: [24]byte
grey :: proc(v: f32) -> string {
iv := i64(clamp01(v) * 255)
n := copy(grey_buf[:], "rgb(")
n += len(strconv.write_int(grey_buf[n:], iv, 10)); grey_buf[n] = ','; n += 1
n += len(strconv.write_int(grey_buf[n:], iv, 10)); grey_buf[n] = ','; n += 1
n += len(strconv.write_int(grey_buf[n:], iv, 10)); grey_buf[n] = ')'; n += 1
return string(grey_buf[:n])
}
Gotcha. These return strings backed by a caller-owned or global buffer, so the same rule as the scratch buffer applies: use the result before the next call that reuses the buffer. In the HUD we give each
str_concatcall its own freshbuf: [32]byte.
10.2 The HUD
Score at top-left, wave at top-right, lives as little ship glyphs in the centre. Drawn after the world's restore, so screen shake never blurs it:
draw_hud :: proc() {
buf: [32]byte
batch.set_fill(&cmd, "#e8f0ff")
batch.set_font(&cmd, "20px monospace")
batch.set_text_align(&cmd, "left"); batch.set_text_baseline(&cmd, "top")
batch.fill_text(&cmd, str_concat(buf[:], "SCORE ", score), 16, 14)
batch.set_text_align(&cmd, "right")
batch.fill_text(&cmd, str_concat(buf[:], "WAVE ", level), CANVAS_W - 16, 14)
batch.set_stroke(&cmd, "#e8f0ff"); batch.set_line_width(&cmd, 2.0)
for i in 0 ..< lives {
x := f32(CANVAS_W) / 2 - f32(lives) * 12 + f32(i) * 24
draw_ship_glyph(x, 26)
}
}
10.3 The state machine
Destroids has four states: intro (a frozen field behind a splash, never shown again after the first start), normal play, paused, and game-over. update_game orchestrates them, and there's one subtle rule that prevents a genuine bug:
update_game :: proc(dt: f32) {
// Drain EVERY one-shot latch once, up front, before branching on state.
just_start := consume_pressed(KEY_START)
just_fire := consume_pressed(KEY_FIRE)
just_pause := consume_pressed(KEY_PAUSE)
just_hyper := consume_pressed(KEY_HYPER)
if intro {
if just_start || just_fire { intro = false }
return
}
if !game_over && just_pause { paused = !paused }
if !game_over && paused {
if thrust_snd_on { thrust_snd_on = false; set_thrust(0) } // cut the rumble while frozen
return
}
if game_over {
if just_start { reset_game() }
update_particles(dt); update_debris(dt); update_shockwaves(dt); update_shake(dt)
return
}
update_ship(dt, just_hyper)
update_bullets(dt); update_asteroids(dt)
update_particles(dt); update_debris(dt); update_shockwaves(dt); update_shake(dt)
handle_collisions()
if count_asteroids() == 0 { next_level() }
}
Why drain the latches unconditionally, then act conditionally? If a
consume_pressed(KEY_PAUSE)were gated behindif !game_over, a P pressed during the game-over screen would stay latched, and fire the instant a fresh game started, pausing it immediately after restart. Consuming every latch up front and only acting on the ones a state cares about closes that leak. It's a small discipline that removes a whole category of "why did it do that?" bugs.
10.4 The overlays
Three translucent overlays drawn on top of the field. The structure is identical (a dim panel, some centred text); here's game-over, with the others in Appendix B:
draw_game_over :: proc() {
batch.set_fill(&cmd, "rgba(3,5,10,0.72)"); batch.fill_rect(&cmd, 0, 0, CANVAS_W, CANVAS_H)
batch.set_text_align(&cmd, "center"); batch.set_text_baseline(&cmd, "middle")
batch.set_fill(&cmd, "#ff5a4c"); batch.set_font(&cmd, "bold 64px monospace")
batch.fill_text(&cmd, "GAME OVER", CANVAS_W/2, CANVAS_H/2 - 40)
buf: [32]byte
batch.set_fill(&cmd, "#e8f0ff"); batch.set_font(&cmd, "24px monospace")
batch.fill_text(&cmd, str_concat(buf[:], "FINAL SCORE ", score), CANVAS_W/2, CANVAS_H/2 + 16)
// ...best score + "PRESS ENTER TO PLAY AGAIN"...
}
draw_game picks one based on state:
if intro { draw_intro() }
else if paused { draw_paused() }
else if game_over { draw_game_over() }
10.5 Lives, death, and the high score
Losing the last life ends the run and banks the score if it's a new best:
kill_ship :: proc() {
play_sound(SND_SHIP_DEATH)
spawn_particles(ship.pos, 28, 260); spawn_debris(ship.pos, 10, 300, 1.4)
spawn_shockwave(ship.pos, 70); trigger_shake(14)
ship.alive = false
lives -= 1
if lives <= 0 {
game_over = true
if score > best_score {
best_score = score
set_best_score(i32(best_score)) // persist via app_env
}
} else {
ship.respawn = RESPAWN_DELAY
}
}
best_score is loaded once at startup:
init_game :: proc() {
// ...seed stars...
best_score = int(get_best_score())
reset_game()
}
The two app_env functions are backed by localStorage on the page: a flat key, string-encoded, tolerant of a first-ever run (it defaults to "0"):
get_best_score: () => parseInt(localStorage.getItem("destroids_best_score") || "0", 10) || 0,
set_best_score: (score) => localStorage.setItem("destroids_best_score", String(score)),
Why so plain? No schema, no versioning: one key, one number. If you ever rename the key, the old value is simply unread and the default (0) stands in. For a single high score that's exactly the right amount of machinery.
Checkpoint
Full game flow: boot splash โ play โ pause/resume โ death โ game-over โ restart, with a live HUD and a best score that persists across reloads. The one-shot-latch discipline keeps state transitions clean.
Exercises
- Add an extra life every 10,000 points (watch for awarding it more than once).
- Show a small "NEW BEST!" line on the game-over screen when
score > best_scoreat death. - Add a
KEY_MUTE-style toggle that pauses and shows a different overlay, reusing the latch pattern.
Further reading
- MDN: localStorage: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
- MDN: fillText: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillText
- Odin
core:strconv: https://pkg.odin-lang.org/core/strconv/