Yo Odin ยท Chapter 7

Chapter 7: The game takes shape: loop, state, and the ship

This chapter: start the game proper. Wire the frame loop with a clamped dt, set up the two-view input model and the no-allocation entity pattern, and fly a vector ship with thrust, inertia and screen-wrap.

Where we left off: the batch library is finished and drives a keyboard demo.


7.1 The game's foreign bridge

The game is a package main that imports the renderer and declares one more foreign module, app_env, for things the page provides: the FPS readout now, sound and high-score later:

// src/main.odin
package main

import "base:runtime"
import batch "../batch"

foreign import app_env "app_env"

@(default_calling_convention = "contextless")
foreign app_env {
	set_fps        :: proc(fps: f32) ---
	play_sound     :: proc(id: u32) ---
	set_thrust     :: proc(on: u32) ---
	get_best_score :: proc() -> i32 ---
	set_best_score :: proc(score: i32) ---
}

The page supplies app_env via Batch.instantiate's extraImports (Chapter 6). Plain numbers cross; no string marshalling.

7.2 Constants, globals, and the loop

CANVAS_W :: 800
CANVAS_H :: 600
CB_TICK  :: 0

ctx: batch.Handle
cmd: batch.Buffer
last_ms: f64

@(export)
batch_main :: proc "c" () {
	context = runtime.default_context()
	canvas := batch.canvas_create(batch.get_element_by_id("app"), CANVAS_W, CANVAS_H)
	ctx = batch.canvas_get_context(canvas)
	init_game()
	last_ms = batch.now()
	batch.start_animation_loop(CB_TICK)
}

@(export)
batch_invoke_callback :: proc "c" (id: u32) {
	context = runtime.default_context()
	switch id {
	case CB_TICK:
		now := batch.now()
		dt := f32((now - last_ms) / 1000.0)
		last_ms = now
		if dt > 0.05 { dt = 0.05 }   // clamp after a tab-out / long stall
		if dt < 0    { dt = 0 }
		update_game(dt)
		draw_game()
	}
}

Why clamp dt? dt is real elapsed seconds between frames. Switch tabs for ten seconds and the next dt would be 10.0: every object teleports across the screen and through walls in one step. Capping dt at 0.05 s (20 fps-worth) turns a stall into a brief slow-motion hiccup instead of a broken simulation.

(The real loop also averages an FPS number and pushes it to the footer with set_fps; that's in the Appendix B listing.)

7.3 Two views of the keyboard

The page maps physical keys to slot indices and calls one exported proc; the order must match these constants:

KEY_LEFT :: 0
KEY_RIGHT :: 1
KEY_THRUST :: 2
KEY_FIRE :: 3
KEY_START :: 4
KEY_HYPER :: 5
KEY_PAUSE :: 6
KEY_COUNT :: 7

keys:         [KEY_COUNT]bool   // currently held, for continuous actions
just_pressed: [KEY_COUNT]bool   // latched on keydown, for one-shot actions

@(export)
destroids_key :: proc "c" (code: u32, down: u32) {
	if code < KEY_COUNT {
		keys[code] = down != 0
		if down != 0 { just_pressed[code] = true }
	}
}

consume_pressed :: proc(code: u32) -> bool {
	if code < KEY_COUNT && just_pressed[code] {
		just_pressed[code] = false
		return true
	}
	return false
}

Why two arrays? Continuous actions (turn, thrust, fire) read keys[]: "is it held right now?". One-shot actions (start, pause, hyperspace) need "was it pressed since I last looked?". A naive edge check (keys[x] && !prev) samples once per frame and can miss a key that's pressed and released between two frames. just_pressed can't miss it: keydown sets it, only consume_pressed clears it.

On the page (index.html):

const KEYMAP = { ArrowLeft:0, KeyA:0, ArrowRight:1, KeyD:1, ArrowUp:2, KeyW:2,
                 Space:3, Enter:4, ShiftLeft:5, ShiftRight:5, KeyP:6 };
window.addEventListener("keydown", (e) => {
  const code = KEYMAP[e.code];
  if (code !== undefined) { e.preventDefault(); if (!e.repeat) exports.destroids_key(code, 1); }
});
window.addEventListener("keyup", (e) => {
  const code = KEYMAP[e.code];
  if (code !== undefined) { e.preventDefault(); exports.destroids_key(code, 0); }
});

Destroids reads the keyboard on the page side (the KEYMAP) rather than through batch's event bridge from Chapter 6; both work, but this keeps the wasm's job to a single destroids_key call.

7.4 The entity pattern: fixed arrays, active flags

Here's the memory model for the whole game. Every kind of thing lives in a fixed-size array; an active flag says whether a slot is in use. No dynamic arrays, no allocator, no per-frame allocation:

Vec2 :: [2]f32
TAU  :: f32(math.TAU)

Ship :: struct {
	pos:      Vec2,
	vel:      Vec2,
	angle:    f32,
	alive:    bool,
	thrust:   bool,
	invuln:   f32,   // spawn-protection countdown
	respawn:  f32,   // >0 while waiting to respawn
	cooldown: f32,   // fire cooldown
}

ship: Ship

Why no allocation? A 60 fps loop that allocates every frame invites fragmentation and stalls. Pre-sizing every pool up front (six bullets, hundreds of particles) makes memory boringly predictable: the single best thing you can do for a smooth game loop.

7.5 Spawning and tuning the ship

Tuning constants live together at the top of game.odin:

SHIP_TURN      :: 3.6    // rad/s
SHIP_THRUST    :: 260.0  // px/s^2
SHIP_FRICTION  :: 0.6    // fraction of velocity kept per second (exponential drag)
SHIP_MAX_SPEED :: 420.0
SHIP_RADIUS    :: 11.0

spawn_ship :: proc(protected: bool) {
	ship.pos   = {CANVAS_W / 2, CANVAS_H / 2}
	ship.vel   = {}
	ship.angle = -TAU / 4         // pointing up
	ship.alive = true
	ship.invuln = protected ? INVULN_TIME : 0
}

7.6 Flying it

The ship update turns, thrusts along its heading, applies exponential drag, clamps speed, integrates, and wraps at the edges:

wrap :: proc(p: ^Vec2) {
	if p.x < 0 { p.x += CANVAS_W }
	if p.x >= CANVAS_W { p.x -= CANVAS_W }
	if p.y < 0 { p.y += CANVAS_H }
	if p.y >= CANVAS_H { p.y -= CANVAS_H }
}

update_ship :: proc(dt: f32) {
	if keys[KEY_LEFT]  { ship.angle -= SHIP_TURN * dt }
	if keys[KEY_RIGHT] { ship.angle += SHIP_TURN * dt }

	ship.thrust = keys[KEY_THRUST]
	if ship.thrust {
		ship.vel += Vec2{math.cos(ship.angle), math.sin(ship.angle)} * SHIP_THRUST * dt
	}

	ship.vel *= math.pow_f32(SHIP_FRICTION, dt)     // exponential drag toward zero

	speed := math.sqrt(ship.vel.x * ship.vel.x + ship.vel.y * ship.vel.y)
	if speed > SHIP_MAX_SPEED { ship.vel *= SHIP_MAX_SPEED / speed }

	ship.pos += ship.vel * dt
	wrap(&ship.pos)
}

Notice how the Vec2 array programming makes the physics read like physics: `ship.pos += ship.vel

  • dt`.

Why pow_f32(FRICTION, dt) for drag? Multiplying velocity by a constant each frame would make drag depend on frame rate. Raising the per-second retention (0.6) to the dt power makes it frame-rate independent: the ship loses the same fraction of speed per real second whether you run at 30 or 144 fps.

7.7 Drawing the ship

The ship is a triangle defined in local space (nose at +x), rotated by angle with a 2D rotation, and stroked into the buffer:

// src/draw.odin (excerpt)
draw_ship :: proc() {
	c := math.cos(ship.angle)
	s := math.sin(ship.angle)
	nose   := Vec2{16, 0}
	tail_l := Vec2{-10, -9}
	tail_r := Vec2{-10, 9}

	// rotate each local point by `angle`, translate to ship.pos
	p0 := ship.pos + Vec2{nose.x*c - nose.y*s,     nose.x*s + nose.y*c}
	p1 := ship.pos + Vec2{tail_l.x*c - tail_l.y*s, tail_l.x*s + tail_l.y*c}
	p2 := ship.pos + Vec2{tail_r.x*c - tail_r.y*s, tail_r.x*s + tail_r.y*c}

	batch.set_stroke(&cmd, "#e8f0ff")
	batch.set_line_width(&cmd, 2.0)
	batch.begin_path(&cmd)
	batch.move_to(&cmd, p0.x, p0.y)
	batch.line_to(&cmd, p1.x, p1.y)
	batch.line_to(&cmd, p2.x, p2.y)
	batch.close_path(&cmd)
	batch.stroke(&cmd)
}

draw_game clears to near-black, calls draw_ship, and flushes:

draw_game :: proc() {
	batch.reset(&cmd)
	batch.set_fill(&cmd, "#05070d")
	batch.fill_rect(&cmd, 0, 0, CANVAS_W, CANVAS_H)
	if ship.alive { draw_ship() }
	batch.flush(ctx, &cmd)
}

Wire init_game to seed the ship, have update_game(dt) call update_ship(dt), ./run.sh, and you can fly.

Checkpoint

The game boots, runs a clamped fixed-ish-step loop, reads the keyboard through the two-view model, stores state in no-allocation fixed arrays, and flies a vector ship with thrust, inertia and wrap.

Exercises

  1. Add a reverse thruster on KEY_HYPER (temporarily) that pushes opposite the heading.
  2. Draw a short line from the ship's nose in its facing direction to visualise angle.
  3. Halve SHIP_FRICTION and then set it to 0.99. Feel how drag changes the handling.

Further reading