Yo Odin ยท Chapter 10

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_concat call its own fresh buf: [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 behind if !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

  1. Add an extra life every 10,000 points (watch for awarding it more than once).
  2. Show a small "NEW BEST!" line on the game-over screen when score > best_score at death.
  3. Add a KEY_MUTE-style toggle that pauses and shows a different overlay, reusing the latch pattern.

Further reading