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?dtis real elapsed seconds between frames. Switch tabs for ten seconds and the nextdtwould be10.0: every object teleports across the screen and through walls in one step. Cappingdtat 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_pressedcan't miss it:keydownsets it, onlyconsume_pressedclears 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 thedtpower 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
- Add a reverse thruster on
KEY_HYPER(temporarily) that pushes opposite the heading. - Draw a short line from the ship's nose in its facing direction to visualise
angle. - Halve
SHIP_FRICTIONand then set it to0.99. Feel how drag changes the handling.
Further reading
- Odin
core:math: https://pkg.odin-lang.org/core/math/ (cos,sin,sqrt,pow_f32,TAU) - MDN: KeyboardEvent: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
- Fix Your Timestep! (background on
dt): https://gafferongames.com/post/fix_your_timestep/