Appendix B: Full source listings
The complete, verified source of the finished project, the state the twelve chapters build up to. These listings are generated from the companion code/ directory, which is the authoritative, compilable copy. Build it with ./run.sh and open http://localhost:9000.
File index
| File | Purpose |
|---|---|
batch/batch.odin | Command buffer, wire format, draw-op encoders |
batch/canvas.odin | Canvas bootstrap, animation loop, DOM events |
web/batch.js | JS glue: Odin runtime hooks + the batched-draw bridge |
src/main.odin | Wiring: bootstrap, per-frame dispatch, keyboard input |
src/game.odin | State, simulation, collisions |
src/draw.odin | Vector rendering into the command buffer |
server/serve.odin | Static-file HTTP dev server (native Odin) |
index.html | The host page: canvas + app_env (sound, high score, keymap) |
build.sh / run.sh | Build and run scripts |
batch/batch.odin
// batch.odin: the batched Canvas2D draw-command buffer.
//
// WHY: driving the browser's Canvas 2D API one method at a time across the WASM<->JS boundary
// (fillRect, arc, fill, ...) means one bridge crossing per call, which is expensive at 60fps.
// This package records an entire frame's worth of draw operations into ONE packed byte buffer,
// then flushes it with a SINGLE foreign call. The JS side (web/batch.js `batch_cmd_flush`) walks
// the buffer and replays each op onto the context. Encoding logic stays in Odin; JS is a
// mechanical dispatcher.
//
// WIRE FORMAT (little-endian):
// command := opcode:u8 followed by that opcode's typed args
// f32 arg := 4 bytes IEEE-754 LE
// str arg := len:u16 LE followed by `len` UTF-8 bytes
// u8 arg := 1 byte
//
// The Buffer is a fixed-capacity, preallocated arena reused every frame (reset() rewinds the
// cursor): no per-frame heap churn, no allocator dependency.
//
// This is a deliberately trimmed cousin of the `Batchiness` library in the `dinn` monorepo: it
// carries only the ops Destroids uses. See Appendix C of the book for how to add gradients,
// sprites/baking, filters and measureText the same way.
package batch
foreign import batch_env "batch_env"
@(default_calling_convention = "contextless")
foreign batch_env {
// batch_cmd_flush replays the packed command buffer onto the 2D context `ctx`.
batch_cmd_flush :: proc(ctx: Handle, buf: []byte) ---
}
// ------------------------------------------------------------------------------------------------
// Opcodes: keep in sync with the switch in web/batch.js `batch_cmd_flush`.
// ------------------------------------------------------------------------------------------------
OP_SET_FILL :: 0x01 // str
OP_SET_STROKE :: 0x02 // str
OP_SET_LINE_WIDTH :: 0x03 // f32
OP_SET_FONT :: 0x04 // str
OP_SET_TEXT_ALIGN :: 0x05 // str
OP_SET_TEXT_BASELINE :: 0x06 // str
OP_SET_GLOBAL_ALPHA :: 0x07 // f32
OP_SET_LINE_CAP :: 0x08 // str
OP_FILL_RECT :: 0x10 // f32 x4
OP_STROKE_RECT :: 0x11 // f32 x4
OP_CLEAR_RECT :: 0x12 // f32 x4
OP_BEGIN_PATH :: 0x20
OP_MOVE_TO :: 0x21 // f32 x2
OP_LINE_TO :: 0x22 // f32 x2
OP_CLOSE_PATH :: 0x23
OP_ARC :: 0x24 // x,y,r,a0,a1 (f32) + ccw (u8)
OP_FILL :: 0x28
OP_STROKE :: 0x29
OP_FILL_TEXT :: 0x30 // str, x, y (f32)
OP_STROKE_TEXT :: 0x31 // str, x, y (f32)
OP_SAVE :: 0x40
OP_RESTORE :: 0x41
OP_TRANSLATE :: 0x42 // f32 x2
OP_SCALE :: 0x43 // f32 x2
OP_ROTATE :: 0x44 // f32
OP_SET_SHADOW :: 0x70 // color(str), blur(f32)
OP_CLEAR_SHADOW :: 0x71
// ------------------------------------------------------------------------------------------------
// Buffer is a fixed-capacity command arena, reused across frames.
// ------------------------------------------------------------------------------------------------
CAPACITY :: 1 << 20 // 1 MiB, comfortably fits a full frame of a large scene.
Buffer :: struct {
data: [CAPACITY]u8,
len: int,
overflowed: bool,
}
// reset rewinds the buffer to empty. Call once at the start of every frame.
reset :: proc "contextless" (b: ^Buffer) {
b.len = 0
b.overflowed = false
}
// flush replays the recorded commands onto ctx, then leaves the buffer as-is (call reset() next
// frame). No-op if nothing was recorded.
flush :: proc "contextless" (ctx: Handle, b: ^Buffer) {
if b.len == 0 {
return
}
batch_cmd_flush(ctx, b.data[:b.len])
}
// ------------------------------------------------------------------------------------------------
// Low-level encoders (private).
// ------------------------------------------------------------------------------------------------
@(private)
put_u8 :: proc "contextless" (b: ^Buffer, v: u8) {
if b.len + 1 > CAPACITY {
b.overflowed = true
return
}
b.data[b.len] = v
b.len += 1
}
@(private)
put_f32 :: proc "contextless" (b: ^Buffer, v: f32) {
if b.len + 4 > CAPACITY {
b.overflowed = true
return
}
bits := transmute(u32)v
b.data[b.len + 0] = u8(bits)
b.data[b.len + 1] = u8(bits >> 8)
b.data[b.len + 2] = u8(bits >> 16)
b.data[b.len + 3] = u8(bits >> 24)
b.len += 4
}
@(private)
put_u16 :: proc "contextless" (b: ^Buffer, v: u16) {
if b.len + 2 > CAPACITY {
b.overflowed = true
return
}
b.data[b.len + 0] = u8(v)
b.data[b.len + 1] = u8(v >> 8)
b.len += 2
}
@(private)
put_str :: proc "contextless" (b: ^Buffer, s: string) {
n := len(s)
if n > 0xFFFF {
n = 0xFFFF
}
if b.len + 2 + n > CAPACITY {
b.overflowed = true
return
}
b.data[b.len + 0] = u8(n)
b.data[b.len + 1] = u8(n >> 8)
b.len += 2
bytes := transmute([]u8)s
for i in 0 ..< n {
b.data[b.len + i] = bytes[i]
}
b.len += n
}
// ------------------------------------------------------------------------------------------------
// State setters.
// ------------------------------------------------------------------------------------------------
set_fill :: proc "contextless" (b: ^Buffer, color: string) {
put_u8(b, OP_SET_FILL);put_str(b, color)
}
set_stroke :: proc "contextless" (b: ^Buffer, color: string) {
put_u8(b, OP_SET_STROKE);put_str(b, color)
}
set_line_width :: proc "contextless" (b: ^Buffer, w: f32) {
put_u8(b, OP_SET_LINE_WIDTH);put_f32(b, w)
}
set_font :: proc "contextless" (b: ^Buffer, font: string) {
put_u8(b, OP_SET_FONT);put_str(b, font)
}
set_text_align :: proc "contextless" (b: ^Buffer, align: string) {
put_u8(b, OP_SET_TEXT_ALIGN);put_str(b, align)
}
set_text_baseline :: proc "contextless" (b: ^Buffer, baseline: string) {
put_u8(b, OP_SET_TEXT_BASELINE);put_str(b, baseline)
}
set_global_alpha :: proc "contextless" (b: ^Buffer, a: f32) {
put_u8(b, OP_SET_GLOBAL_ALPHA);put_f32(b, a)
}
set_line_cap :: proc "contextless" (b: ^Buffer, cap: string) {
put_u8(b, OP_SET_LINE_CAP);put_str(b, cap)
}
// ------------------------------------------------------------------------------------------------
// Rectangles.
// ------------------------------------------------------------------------------------------------
fill_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
put_u8(b, OP_FILL_RECT);put_f32(b, x);put_f32(b, y);put_f32(b, w);put_f32(b, h)
}
stroke_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
put_u8(b, OP_STROKE_RECT);put_f32(b, x);put_f32(b, y);put_f32(b, w);put_f32(b, h)
}
clear_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
put_u8(b, OP_CLEAR_RECT);put_f32(b, x);put_f32(b, y);put_f32(b, w);put_f32(b, h)
}
// ------------------------------------------------------------------------------------------------
// Paths.
// ------------------------------------------------------------------------------------------------
begin_path :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_BEGIN_PATH)}
close_path :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_CLOSE_PATH)}
fill :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_FILL)}
stroke :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_STROKE)}
move_to :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_MOVE_TO);put_f32(b, x);put_f32(b, y)
}
line_to :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_LINE_TO);put_f32(b, x);put_f32(b, y)
}
arc :: proc "contextless" (b: ^Buffer, x, y, r, a0, a1: f32, ccw: bool = false) {
put_u8(b, OP_ARC)
put_f32(b, x);put_f32(b, y);put_f32(b, r);put_f32(b, a0);put_f32(b, a1)
put_u8(b, ccw ? 1 : 0)
}
// ------------------------------------------------------------------------------------------------
// Text.
// ------------------------------------------------------------------------------------------------
fill_text :: proc "contextless" (b: ^Buffer, text: string, x, y: f32) {
put_u8(b, OP_FILL_TEXT);put_str(b, text);put_f32(b, x);put_f32(b, y)
}
stroke_text :: proc "contextless" (b: ^Buffer, text: string, x, y: f32) {
put_u8(b, OP_STROKE_TEXT);put_str(b, text);put_f32(b, x);put_f32(b, y)
}
// ------------------------------------------------------------------------------------------------
// Transforms.
// ------------------------------------------------------------------------------------------------
save :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_SAVE)}
restore :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_RESTORE)}
translate :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_TRANSLATE);put_f32(b, x);put_f32(b, y)
}
scale :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_SCALE);put_f32(b, x);put_f32(b, y)
}
rotate :: proc "contextless" (b: ^Buffer, a: f32) {
put_u8(b, OP_ROTATE);put_f32(b, a)
}
// ------------------------------------------------------------------------------------------------
// Shadows. save/restore captures and restores the shadow state as part of the context's saved
// state, so scoping a glow to one shape is just save() ... set_shadow() ... draw ... restore().
// ------------------------------------------------------------------------------------------------
set_shadow :: proc "contextless" (b: ^Buffer, color: string, blur: f32) {
put_u8(b, OP_SET_SHADOW);put_str(b, color);put_f32(b, blur)
}
clear_shadow :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_CLEAR_SHADOW)}
batch/canvas.odin
// canvas.odin: the tiny bootstrap that gets a canvas + 2D context to draw onto, drives a
// requestAnimationFrame loop, and reads DOM events. Everything else in the package is pure
// drawing (see batch.odin).
//
// NAMING: functions declared inside `foreign batch_env { ... }` are provided BY JavaScript and
// called FROM Odin (JS -> capability). Functions marked `@(export)` go the other way: the JS
// loader (web/batch.js) calls them. The host app must export two of them itself: `batch_main`
// and `batch_invoke_callback`; see the book's Chapter 6/7.
package batch
// ------------------------------------------------------------------------------------------------
// Handle references a live JS object (a canvas element, its 2D context, or a DOM event) stored in
// the JS-side handle table and addressed by integer id. WASM cannot hold a live JS reference, so
// everything crosses the boundary as an opaque u32. 0 is null / invalid.
Handle :: distinct u32
INVALID :: Handle(0)
// ------------------------------------------------------------------------------------------------
// Scratch buffer for receiving strings back from JS (e.g. event property values). A single global
// is safe on the wasm target; everything runs on one thread.
//
// GOTCHA: the string returned by get() points straight at this buffer. It is valid only until the
// NEXT call that touches scratch. Copy it into a local before making another such call.
@(private)
scratch: [4096]u8
// ------------------------------------------------------------------------------------------------
foreign import batch_env "batch_env"
@(default_calling_convention = "contextless")
foreign batch_env {
batch_get_element_by_id :: proc(id: string) -> Handle ---
batch_get_global :: proc(name: string) -> Handle ---
batch_canvas_create :: proc(parent: Handle, width, height: u32) -> Handle ---
batch_canvas_get_context :: proc(canvas: Handle) -> Handle ---
batch_start_animation_loop :: proc(cb_id: u32) ---
batch_add_event_listener :: proc(elem: Handle, event: string, cb_id: u32) ---
batch_log :: proc(msg: string) ---
batch_now :: proc() -> f64 ---
// Generic JS-value property/method bridge: lets event callbacks read fields off the raw
// browser Event object without a bespoke foreign proc per field.
batch_get_property_str :: proc(h: Handle, key: string, buf: []byte) -> int ---
batch_call_method0 :: proc(h: Handle, name: string) ---
}
// ------------------------------------------------------------------------------------------------
is_valid :: proc "contextless" (h: Handle) -> bool {
return h != INVALID
}
get_element_by_id :: proc "contextless" (id: string) -> Handle {
return batch_get_element_by_id(id)
}
// canvas_create creates a <canvas> of the given size, appends it to parent, and returns its handle.
canvas_create :: proc "contextless" (parent: Handle, width, height: int) -> Handle {
return batch_canvas_create(parent, u32(width), u32(height))
}
// canvas_get_context retrieves the "2d" rendering context, the handle you pass to flush().
canvas_get_context :: proc "contextless" (canvas: Handle) -> Handle {
return batch_canvas_get_context(canvas)
}
// start_animation_loop drives a requestAnimationFrame loop, invoking the host app's
// batch_invoke_callback dispatcher with cb_id on every frame.
start_animation_loop :: proc "contextless" (cb_id: u32) {
batch_start_animation_loop(cb_id)
}
// add_event_listener registers a DOM event listener on elem. cb_id identifies the callback to the
// host app's batch_invoke_callback dispatcher.
add_event_listener :: proc "contextless" (elem: Handle, event: string, cb_id: u32) {
batch_add_event_listener(elem, event, cb_id)
}
log :: proc "contextless" (msg: string) {
batch_log(msg)
}
// now returns milliseconds since page load (performance.now()).
now :: proc "contextless" () -> f64 {
return batch_now()
}
// get_window returns the browser's global `window` object, the usual target for keydown
// listeners, since keys aren't delivered to an unfocused canvas.
get_window :: proc "contextless" () -> Handle {
return batch_get_global("window")
}
// get retrieves a property's string form (e.g. a KeyboardEvent's "code").
//
// WARNING: the returned string points at a shared global scratch buffer; copy it if you need to
// retain the value across another call to get.
get :: proc "contextless" (h: Handle, key: string) -> string {
n := batch_get_property_str(h, key, scratch[:])
if n > len(scratch) {
n = len(scratch)
}
return string(scratch[:n])
}
// call_method calls a zero-argument, no-return-value method on the JS value (e.g.
// "preventDefault", "stopPropagation").
call_method :: proc "contextless" (h: Handle, name: string) {
batch_call_method0(h, name)
}
// ------------------------------------------------------------------------------------------------
// last_event holds a handle to the raw JS Event object for the callback currently being
// dispatched. web/batch.js calls batch_set_last_event() immediately before invoking a listener's
// callback, so within batch_invoke_callback you can read event fields via
// batch.get(batch.last_event_handle(), "code") etc.
@(private)
last_event: Handle = INVALID
last_event_handle :: proc "contextless" () -> Handle {
return last_event
}
@(export)
batch_set_last_event :: proc "contextless" (h: Handle) {
last_event = h
}
web/batch.js
// ------------------------------------------------------------------------------------------------
// batch.js: JS glue for the `batch` Odin/WASM batched-Canvas2D library.
//
// Provides two import modules to the WASM instance:
// - "odin_env": the handful of runtime hooks Odin's core library needs on the `js_wasm32`
// target (panic/print output, and float-math intrinsics with no native WASM
// opcode, plus a random seed source).
// - "batch_env": the batched-drawing bridge the `batch` package calls into, a canvas bootstrap
// plus the one-call-per-frame command-buffer flush.
//
// The host app must export these procedures for the loader to call:
// - batch_main :: proc "c" () -- called once after instantiation
// - batch_invoke_callback :: proc "c" (id: u32) -- called for every registered event /
// animation-frame callback
// - batch_set_last_event :: proc "contextless" (h) -- set by the library; optional at call time
//
// Kept intentionally small and unminified so it reads as documentation. It is a trimmed cousin of
// the monorepo's batchiness.js (no gradients/sprites/filters/measureText; see the book's
// Appendix C to add them).
// ------------------------------------------------------------------------------------------------
(() => {
"use strict";
const decoder = new TextDecoder("utf-8");
const encoder = new TextEncoder("utf-8");
// ----------------------------------------------------------------------------------------------
// JS Value Handle Table: stores references to live JS objects (canvas elements, 2D contexts,
// events) indexed by integer handles passed to/from Odin. Index 0 is always null / invalid.
// ----------------------------------------------------------------------------------------------
const jsValues = [null];
let nextHandle = 1;
function getHandle(value) {
if (value === undefined || value === null) return 0;
const id = nextHandle++;
jsValues[id] = value;
return id;
}
// Reserved slot for the "current event" passed to a dispatched listener. Events fire far more
// often than any other handle-producing call, and are only read synchronously inside the
// callback they were dispatched for, so we overwrite this one slot in place rather than leaking
// a fresh, never-freed table entry per event.
const EVENT_HANDLE = nextHandle++;
// ----------------------------------------------------------------------------------------------
globalThis.Batch = {
// extraImports: optional object of additional foreign import modules to merge in, for
// app-specific foreign procs declared outside the shared `batch` package (e.g. the game's
// "app_env" with sound + high-score functions).
instantiate: async function (wasmUrl, extraImports) {
let wasmMemory;
let wasmExports;
function getStr(ptr, len) {
return decoder.decode(new Uint8Array(wasmMemory.buffer, ptr, len));
}
function putStr(ptr, str, maxLen) {
const encoded = encoder.encode(str);
const len = maxLen !== undefined ? Math.min(encoded.length, maxLen) : encoded.length;
new Uint8Array(wasmMemory.buffer, ptr, len).set(encoded.subarray(0, len));
return len;
}
const importObject = {
// ------------------------------------------------------------------------------------
// Odin core-library runtime hooks (js_wasm32 target).
// ------------------------------------------------------------------------------------
odin_env: {
write: (fd, ptr, len) => {
const str = getStr(ptr, len);
if (fd === 1) { console.log(str); return; }
if (fd === 2) { console.error(str); return; }
},
trap: () => { throw new Error("odin: trap"); },
abort: () => { throw new Error("odin: abort"); },
alert: (ptr, len) => { alert(getStr(ptr, len)); },
evaluate: (ptr, len) => { eval.call(null, getStr(ptr, len)); },
open: (urlPtr, urlLen, namePtr, nameLen, specsPtr, specsLen) => {
window.open(getStr(urlPtr, urlLen), getStr(namePtr, nameLen), getStr(specsPtr, specsLen));
},
time_now: () => BigInt(Date.now()),
tick_now: () => performance.now(),
time_sleep: () => {},
sqrt: Math.sqrt,
sin: Math.sin,
cos: Math.cos,
pow: Math.pow,
fmuladd: (x, y, z) => x * y + z,
ln: Math.log,
exp: Math.exp,
ldexp: (x, exp) => x * Math.pow(2, exp),
rand_bytes: (ptr, len) => {
crypto.getRandomValues(new Uint8Array(wasmMemory.buffer, ptr, len));
},
},
// ------------------------------------------------------------------------------------
// batch bridge.
// ------------------------------------------------------------------------------------
batch_env: {
// -- Canvas bootstrap --
batch_get_element_by_id: (ptr, len) => {
const el = document.getElementById(getStr(ptr, len));
return el ? getHandle(el) : 0;
},
batch_get_global: (ptr, len) => getHandle(globalThis[getStr(ptr, len)]),
batch_canvas_create: (parent, width, height) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
jsValues[parent].appendChild(canvas);
return getHandle(canvas);
},
batch_canvas_get_context: (canvas) => getHandle(jsValues[canvas].getContext("2d")),
batch_start_animation_loop: (cbId) => {
const tick = () => {
wasmExports.batch_invoke_callback(cbId);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
},
batch_add_event_listener: (elem, eventPtr, eventLen, cbId) => {
const event = getStr(eventPtr, eventLen);
jsValues[elem].addEventListener(event, (evt) => {
if (wasmExports.batch_set_last_event) {
jsValues[EVENT_HANDLE] = evt;
wasmExports.batch_set_last_event(EVENT_HANDLE);
}
wasmExports.batch_invoke_callback(cbId);
});
},
batch_log: (ptr, len) => { console.log(getStr(ptr, len)); },
batch_now: () => performance.now(),
// -- Generic JS-value property/method bridge (event fields, etc.) --
batch_get_property_str: (h, keyPtr, keyLen, outPtr, outLen) => {
const val = jsValues[h]?.[getStr(keyPtr, keyLen)];
return putStr(outPtr, val === undefined || val === null ? "" : String(val), outLen);
},
batch_call_method0: (h, namePtr, nameLen) => {
const v = jsValues[h];
const name = getStr(namePtr, nameLen);
if (v && typeof v[name] === "function") v[name]();
},
// -- Retained draw-command buffer --
// Walks a packed byte buffer produced by Odin and replays each op onto the 2D context.
// Wire format documented in batch/batch.odin. One foreign call per frame.
batch_cmd_flush: (ctx, bufPtr, bufLen) => {
const buf = wasmMemory.buffer;
const bytes = new Uint8Array(buf, bufPtr, bufLen);
const view = new DataView(buf, bufPtr, bufLen);
let p = 0;
const f = () => { const v = view.getFloat32(p, true); p += 4; return v; };
const u8 = () => bytes[p++];
const str = () => {
const n = view.getUint16(p, true); p += 2;
const s = decoder.decode(new Uint8Array(buf, bufPtr + p, n)); p += n;
return s;
};
const c = jsValues[ctx];
while (p < bufLen) {
switch (bytes[p++]) {
case 0x01: c.fillStyle = str(); break;
case 0x02: c.strokeStyle = str(); break;
case 0x03: c.lineWidth = f(); break;
case 0x04: c.font = str(); break;
case 0x05: c.textAlign = str(); break;
case 0x06: c.textBaseline = str(); break;
case 0x07: c.globalAlpha = f(); break;
case 0x08: c.lineCap = str(); break;
case 0x10: c.fillRect(f(), f(), f(), f()); break;
case 0x11: c.strokeRect(f(), f(), f(), f()); break;
case 0x12: c.clearRect(f(), f(), f(), f()); break;
case 0x20: c.beginPath(); break;
case 0x21: c.moveTo(f(), f()); break;
case 0x22: c.lineTo(f(), f()); break;
case 0x23: c.closePath(); break;
case 0x24: { const x = f(), y = f(), r = f(), a0 = f(), a1 = f(); c.arc(x, y, r, a0, a1, u8() !== 0); break; }
case 0x28: c.fill(); break;
case 0x29: c.stroke(); break;
case 0x30: { const s = str(); c.fillText(s, f(), f()); break; }
case 0x31: { const s = str(); c.strokeText(s, f(), f()); break; }
case 0x40: c.save(); break;
case 0x41: c.restore(); break;
case 0x42: c.translate(f(), f()); break;
case 0x43: c.scale(f(), f()); break;
case 0x44: c.rotate(f()); break;
case 0x70: { c.shadowColor = str(); c.shadowBlur = f(); break; }
case 0x71: { c.shadowColor = "rgba(0,0,0,0)"; c.shadowBlur = 0; break; }
default: return; // unknown opcode, bail rather than desync
}
}
},
},
};
if (extraImports) {
for (const mod of Object.keys(extraImports)) {
importObject[mod] = { ...(importObject[mod] || {}), ...extraImports[mod] };
}
}
let wasm;
if (typeof WebAssembly.instantiateStreaming === "function") {
wasm = await WebAssembly.instantiateStreaming(fetch(wasmUrl), importObject);
} else {
const bytes = await (await fetch(wasmUrl)).arrayBuffer();
wasm = await WebAssembly.instantiate(bytes, importObject);
}
wasmExports = wasm.instance.exports;
wasmMemory = wasmExports.memory;
wasmExports.batch_main();
return wasmExports;
},
};
})();
src/main.odin
// Destroids: a classic Asteroids clone rendered through our own `batch` batched-Canvas2D
// library. Every frame's draw operations (a starfield of hundreds of points, dozens of vector
// asteroids, bullets, and particle debris) are recorded into ONE packed byte buffer and flushed
// to the browser with a single WASM<->JS crossing, so the scene stays "busy" while the frame
// rate stays high.
//
// This file owns the wiring: canvas bootstrap, the per-frame animation callback, keyboard input,
// and the FPS readout pushed to the HTML footer. The game itself lives in game.odin / draw.odin.
package main
import "base:runtime"
import batch "../batch"
// ------------------------------------------------------------------------------------------------
// Foreign bridge, provided by index.html via Batch.instantiate's extraImports as the "app_env"
// module: plain numbers in/out, no string marshalling needed.
foreign import app_env "app_env"
@(default_calling_convention = "contextless")
foreign app_env {
set_fps :: proc(fps: f32) ---
// play_sound triggers a one-shot synthesised effect (see SND_* ids in game.odin); set_thrust
// starts/stops the looping thrust rumble. Both are no-ops while the user has muted audio.
play_sound :: proc(id: u32) ---
set_thrust :: proc(on: u32) ---
// get_best_score / set_best_score persist the high score in the browser's localStorage.
get_best_score :: proc() -> i32 ---
set_best_score :: proc(score: i32) ---
}
// ------------------------------------------------------------------------------------------------
CANVAS_W :: 800
CANVAS_H :: 600
CB_TICK :: 0 // the only animation-loop callback id we register
// Key slots: index order matches the KEYMAP in index.html.
KEY_LEFT :: 0
KEY_RIGHT :: 1
KEY_THRUST :: 2
KEY_FIRE :: 3
KEY_START :: 4
KEY_HYPER :: 5
KEY_PAUSE :: 6
KEY_COUNT :: 7
// ------------------------------------------------------------------------------------------------
// Globals
ctx: batch.Handle // 2D context batch flushes onto
cmd: batch.Buffer // reusable per-frame command buffer
keys: [KEY_COUNT]bool
// just_pressed latches true the instant a keydown arrives and stays true until game logic consumes
// it via consume_pressed. A plain "keys[x] && !x_prev" edge check only samples state once per
// rendered frame, so a keydown+keyup cycle fast enough to complete between two frames would be
// missed entirely; the latch can't miss it since keyup never clears it.
just_pressed: [KEY_COUNT]bool
last_ms: f64 // performance.now() of the previous frame
// FPS accumulator: averaged over a window and pushed to the footer a few times a second.
fps_frames: int
fps_accum: f32
// ------------------------------------------------------------------------------------------------
@(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 tab-out / long stalls
if dt < 0 {dt = 0}
update_game(dt)
draw_game()
// FPS: average instantaneous rate over ~0.4s, then push to the footer.
if dt > 0 {
fps_accum += 1.0 / dt
fps_frames += 1
if fps_frames >= 24 {
set_fps(fps_accum / f32(fps_frames))
fps_accum = 0
fps_frames = 0
}
}
}
}
// ------------------------------------------------------------------------------------------------
// destroids_key is called by index.html on keydown/keyup with the slot index (see KEY_* above)
// and 1/0 for pressed/released.
@(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 reports (and clears) a latched keydown: use this for one-shot actions (restart,
// pause toggle, hyperspace) instead of comparing keys[] against its previous-frame value, which
// can miss a fast enough press/release cycle. Continuous actions (turning, thrust, firing) should
// keep reading keys[] directly.
consume_pressed :: proc(code: u32) -> bool {
if code < KEY_COUNT && just_pressed[code] {
just_pressed[code] = false
return true
}
return false
}
src/game.odin
// game.odin: Destroids game state, simulation, and collision handling.
//
// The rendering lives in draw.odin; this file is pure simulation. Everything is fixed-size arrays
// with an `active` flag (no dynamic allocation, no per-frame heap churn), which is the pattern the
// whole game leans on.
package main
import "core:math"
import "core:math/rand"
// ------------------------------------------------------------------------------------------------
// Tuning
Vec2 :: [2]f32
TAU :: f32(math.TAU)
STAR_COUNT :: 380
MAX_ASTEROIDS :: 256
MAX_BULLETS :: 6
MAX_PARTICLES :: 700
MAX_DEBRIS :: 160
MAX_SHOCKWAVES :: 16
SHAPE_N :: 12 // vertices per asteroid outline
SHIP_TURN :: 3.6 // rad/s
SHIP_THRUST :: 260.0 // px/s^2
SHIP_FRICTION :: 0.6 // velocity retained per second (exponential drag)
SHIP_MAX_SPEED :: 420.0
SHIP_RADIUS :: 11.0
BULLET_SPEED :: 520.0
BULLET_LIFE :: 1.15 // seconds
FIRE_COOLDOWN :: 0.16
INVULN_TIME :: 2.5 // spawn-protection seconds
RESPAWN_DELAY :: 1.2
// Asteroid sizes: 3 = large, 2 = medium, 1 = small.
AST_R_LARGE :: 46.0
AST_R_MED :: 26.0
AST_R_SMALL :: 14.0
// Sound ids: kept in sync with the switch in index.html's playSound().
SND_FIRE :: 0
SND_BANG_LARGE :: 1
SND_BANG_MED :: 2
SND_BANG_SMALL :: 3
SND_SHIP_DEATH :: 4
SND_LEVEL :: 5
SND_HYPER :: 6
SND_THUD :: 7
// ------------------------------------------------------------------------------------------------
// Entities
Star :: struct {
pos: Vec2,
brightness: f32,
twinkle: f32,
}
Ship :: struct {
pos: Vec2,
vel: Vec2,
angle: f32,
alive: bool,
thrust: bool,
invuln: f32,
respawn: f32, // >0 while waiting to respawn after death
cooldown: f32,
}
Bullet :: struct {
pos: Vec2,
vel: Vec2,
life: f32,
active: bool,
}
Asteroid :: struct {
pos: Vec2,
vel: Vec2,
angle: f32,
spin: f32,
radius: f32,
size: int,
shape: [SHAPE_N]f32,
active: bool,
}
Particle :: struct {
pos: Vec2,
vel: Vec2,
life: f32,
max_life: f32,
active: bool,
}
// A spinning line-shard flung out of a destroyed ship/asteroid, denser debris on top of the
// point-particle sparks, for a heavier-feeling explosion.
Debris :: struct {
pos: Vec2,
vel: Vec2,
angle: f32,
spin: f32,
len: f32,
life: f32,
max_life: f32,
active: bool,
}
// An expanding, fading ring drawn at an explosion's origin.
Shockwave :: struct {
pos: Vec2,
radius: f32,
max_radius: f32,
life: f32,
max_life: f32,
active: bool,
}
// ------------------------------------------------------------------------------------------------
// State
stars: [STAR_COUNT]Star
ship: Ship
bullets: [MAX_BULLETS]Bullet
asteroids: [MAX_ASTEROIDS]Asteroid
particles: [MAX_PARTICLES]Particle
debris: [MAX_DEBRIS]Debris
shockwaves: [MAX_SHOCKWAVES]Shockwave
score: int
best_score: int
lives: int
level: int
game_over: bool
thrust_snd_on: bool // mirrors the looping thrust sound so we only toggle it on change
// intro is true from boot until the player's first ENTER/SPACE press, the simulation is fully
// frozen (but still drawn) behind a "desktop + keyboard required" splash. It never resets on
// reset_game(), since replays after game-over shouldn't show the splash again.
intro: bool = true
paused: bool // player-toggled pause via KEY_PAUSE, active gameplay only
// Screen shake: shake_mag decays each frame; shake_x/y are the per-frame random offset drawn from
// it, read by draw.odin to jitter the world transform.
shake_mag: f32
shake_x: f32
shake_y: f32
hyper_cooldown: f32
thud_cooldown: f32 // throttles the asteroid-collision thud so a pile-up doesn't spam audio
thrust_particle_timer: f32 // emits an exhaust spark every so often while thrusting
// ------------------------------------------------------------------------------------------------
// Setup
init_game :: proc() {
for &s in stars {
s.pos = {rand.float32() * CANVAS_W, rand.float32() * CANVAS_H}
s.brightness = rand.float32_range(0.15, 0.9)
s.twinkle = rand.float32_range(0, TAU)
}
best_score = int(get_best_score())
reset_game()
}
reset_game :: proc() {
score = 0
lives = 3
level = 0
game_over = false
for &b in bullets {b.active = false}
for &a in asteroids {a.active = false}
for &p in particles {p.active = false}
for &d in debris {d.active = false}
for &s in shockwaves {s.active = false}
shake_mag = 0
shake_x = 0
shake_y = 0
hyper_cooldown = 0
thud_cooldown = 0
thrust_particle_timer = 0
paused = false
spawn_ship(protected = true)
next_level()
}
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.thrust = false
ship.invuln = protected ? INVULN_TIME : 0
ship.respawn = 0
ship.cooldown = 0
}
next_level :: proc() {
level += 1
if level > 1 {play_sound(SND_LEVEL)} // no jingle for the opening wave
count := 4 + level * 2 // busier each wave
for _ in 0 ..< count {
// Spawn along an edge, away from the ship's centre.
p: Vec2
if rand.float32() < 0.5 {
p = {rand.float32() * CANVAS_W, rand.float32() < 0.5 ? 0 : CANVAS_H}
} else {
p = {rand.float32() < 0.5 ? 0 : CANVAS_W, rand.float32() * CANVAS_H}
}
spawn_asteroid(p, 3)
}
}
spawn_asteroid :: proc(pos: Vec2, size: int) {
for &a in asteroids {
if a.active {continue}
a.active = true
a.pos = pos
a.size = size
switch size {
case 3:
a.radius = AST_R_LARGE
case 2:
a.radius = AST_R_MED
case:
a.radius = AST_R_SMALL
}
speed := rand.float32_range(24, 60) * (1.0 + f32(3 - size) * 0.35)
dir := rand.float32_range(0, TAU)
a.vel = {math.cos(dir) * speed, math.sin(dir) * speed}
a.angle = rand.float32_range(0, TAU)
a.spin = rand.float32_range(-1.6, 1.6)
for i in 0 ..< SHAPE_N {
a.shape[i] = rand.float32_range(0.72, 1.08)
}
return
}
}
// ------------------------------------------------------------------------------------------------
// Helpers
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}
}
spawn_particles :: proc(pos: Vec2, n: int, spread: f32) {
made := 0
for &p in particles {
if made >= n {break}
if p.active {continue}
p.active = true
p.pos = pos
dir := rand.float32_range(0, TAU)
speed := rand.float32_range(30, spread)
p.vel = {math.cos(dir) * speed, math.sin(dir) * speed}
p.max_life = rand.float32_range(0.4, 1.0)
p.life = p.max_life
made += 1
}
}
// spawn_debris flings n spinning line-shards out from pos, heavier-looking companion to the
// point-particle sparks from spawn_particles.
spawn_debris :: proc(pos: Vec2, n: int, spread: f32, size_scale: f32) {
made := 0
for &d in debris {
if made >= n {break}
if d.active {continue}
d.active = true
d.pos = pos
dir := rand.float32_range(0, TAU)
speed := rand.float32_range(20, spread)
d.vel = {math.cos(dir) * speed, math.sin(dir) * speed}
d.angle = rand.float32_range(0, TAU)
d.spin = rand.float32_range(-6, 6)
d.len = rand.float32_range(3, 7) * size_scale
d.max_life = rand.float32_range(0.5, 1.1)
d.life = d.max_life
made += 1
}
}
spawn_shockwave :: proc(pos: Vec2, max_radius: f32) {
for &s in shockwaves {
if s.active {continue}
s.active = true
s.pos = pos
s.radius = 4
s.max_radius = max_radius
s.max_life = 0.4
s.life = s.max_life
return
}
}
// trigger_shake raises the current shake magnitude (never lowers it, a fresh, smaller hit
// shouldn't cut off a bigger shake already in progress).
trigger_shake :: proc(amount: f32) {
if amount > shake_mag {shake_mag = amount}
}
fire_bullet :: proc() {
for &b in bullets {
if b.active {continue}
b.active = true
dir := Vec2{math.cos(ship.angle), math.sin(ship.angle)}
b.pos = ship.pos + dir * SHIP_RADIUS
b.vel = ship.vel + dir * BULLET_SPEED
b.life = BULLET_LIFE
ship.vel -= dir * (BULLET_SPEED * 0.05) // small recoil kick
play_sound(SND_FIRE)
return
}
}
split_asteroid :: proc(a: ^Asteroid) {
switch a.size {
case 3:
score += 20
play_sound(SND_BANG_LARGE)
trigger_shake(7)
case 2:
score += 50
play_sound(SND_BANG_MED)
trigger_shake(4)
case:
score += 100
play_sound(SND_BANG_SMALL)
trigger_shake(2)
}
spawn_particles(a.pos, a.size * 6 + 6, a.radius * 4)
spawn_debris(a.pos, a.size * 2 + 2, a.radius * 3, f32(a.size))
spawn_shockwave(a.pos, a.radius * 1.8)
if a.size > 1 {
spawn_asteroid(a.pos, a.size - 1)
spawn_asteroid(a.pos, a.size - 1)
}
a.active = false
}
count_asteroids :: proc() -> int {
n := 0
for &a in asteroids {
if a.active {n += 1}
}
return n
}
// ------------------------------------------------------------------------------------------------
// Simulation
update_game :: proc(dt: f32) {
// Drain every one-shot key latch exactly once per frame, regardless of which state we're in
// below: if a consume were instead gated behind e.g. "!game_over", a press that arrives while
// gated (say, P during the game-over screen) would stay latched and then fire the instant the
// gate lifts (pausing the next game immediately after a restart). Consuming unconditionally up
// front and only conditionally *acting* on the result avoids that.
just_start := consume_pressed(KEY_START)
just_fire := consume_pressed(KEY_FIRE)
just_pause := consume_pressed(KEY_PAUSE)
just_hyper := consume_pressed(KEY_HYPER)
// Intro gate: nothing simulates until the player's first ENTER/SPACE. Stars still twinkle (that
// happens unconditionally in draw_game) so the splash isn't a completely static image.
if intro {
if just_start || just_fire {intro = false}
return
}
// Pause toggle: only while actively playing, so it can't collide with the game-over screen's
// own ENTER-to-restart flow.
if !game_over && just_pause {paused = !paused}
if !game_over && paused {
// Cut the thrust rumble immediately rather than leaving it looping while frozen.
if thrust_snd_on {
thrust_snd_on = false
set_thrust(0)
}
return
}
if thud_cooldown > 0 {thud_cooldown -= dt}
if game_over {
if just_start {
reset_game()
}
update_particles(dt) // let the last explosion finish
update_debris(dt)
update_shockwaves(dt)
update_shake(dt)
update_thrust_sound()
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()
update_thrust_sound()
if count_asteroids() == 0 {
next_level()
}
}
// update_thrust_sound toggles the looping thrust rumble only when its on/off state actually
// changes, so JS isn't asked to start an already-running sound every frame.
update_thrust_sound :: proc() {
want := ship.alive && ship.thrust && !game_over
if want != thrust_snd_on {
thrust_snd_on = want
set_thrust(want ? 1 : 0)
}
}
update_ship :: proc(dt: f32, just_hyper: bool) {
if ship.respawn > 0 {
ship.respawn -= dt
if ship.respawn <= 0 {
spawn_ship(protected = true)
}
return
}
if !ship.alive {return}
if ship.invuln > 0 {ship.invuln -= dt}
if ship.cooldown > 0 {ship.cooldown -= dt}
if hyper_cooldown > 0 {hyper_cooldown -= dt}
if just_hyper && hyper_cooldown <= 0 {
hyperspace_jump()
hyper_cooldown = 0.5
}
if !ship.alive {return} // hyperspace malfunction may have just killed the ship
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
thrust_particle_timer -= dt
if thrust_particle_timer <= 0 {
back := ship.pos - Vec2{math.cos(ship.angle), math.sin(ship.angle)} * SHIP_RADIUS
spawn_particles(back, 2, 40)
thrust_particle_timer = 0.03
}
}
// Exponential drag toward zero.
drag := math.pow_f32(SHIP_FRICTION, dt)
ship.vel *= drag
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)
if keys[KEY_FIRE] && ship.cooldown <= 0 {
fire_bullet()
ship.cooldown = FIRE_COOLDOWN
}
}
// hyperspace_jump teleports the ship to a random point on the field; classic Asteroids risk: a
// small chance of a "malfunction" that destroys the ship instead of relocating it.
hyperspace_jump :: proc() {
spawn_particles(ship.pos, 14, 220)
play_sound(SND_HYPER)
if rand.float32() < 0.14 {
kill_ship()
return
}
ship.pos = {rand.float32_range(40, CANVAS_W - 40), rand.float32_range(40, CANVAS_H - 40)}
ship.vel *= 0.2 // bleed off most velocity on arrival
if ship.invuln < 0.4 {ship.invuln = 0.4}
spawn_particles(ship.pos, 14, 220)
}
update_bullets :: proc(dt: f32) {
for &b in bullets {
if !b.active {continue}
b.pos += b.vel * dt
wrap(&b.pos)
b.life -= dt
if b.life <= 0 {b.active = false}
}
}
update_asteroids :: proc(dt: f32) {
for &a in asteroids {
if !a.active {continue}
a.pos += a.vel * dt
a.angle += a.spin * dt
wrap(&a.pos)
}
}
update_particles :: proc(dt: f32) {
for &p in particles {
if !p.active {continue}
p.pos += p.vel * dt
p.vel *= math.pow_f32(0.2, dt) // quick decay
p.life -= dt
if p.life <= 0 {p.active = false}
}
}
update_debris :: proc(dt: f32) {
for &d in debris {
if !d.active {continue}
d.pos += d.vel * dt
d.vel *= math.pow_f32(0.25, dt)
d.angle += d.spin * dt
d.life -= dt
if d.life <= 0 {d.active = false}
}
}
update_shockwaves :: proc(dt: f32) {
for &s in shockwaves {
if !s.active {continue}
s.life -= dt
if s.life <= 0 {
s.active = false
continue
}
t := 1 - s.life / s.max_life
s.radius = s.max_radius * t
}
}
// update_shake decays the current shake magnitude and redraws the per-frame random offset from it;
// draw.odin reads shake_x/shake_y to jitter the world transform.
update_shake :: proc(dt: f32) {
if shake_mag > 0.05 {
shake_x = rand.float32_range(-shake_mag, shake_mag)
shake_y = rand.float32_range(-shake_mag, shake_mag)
shake_mag *= math.pow_f32(0.02, dt)
} else {
shake_mag = 0
shake_x = 0
shake_y = 0
}
}
handle_collisions :: proc() {
// Bullets vs asteroids.
for &b in bullets {
if !b.active {continue}
for &a in asteroids {
if !a.active {continue}
if dist2(b.pos, a.pos) <= a.radius * a.radius {
b.active = false
split_asteroid(&a)
break
}
}
}
// Asteroids vs ship.
if ship.alive && ship.invuln <= 0 {
for &a in asteroids {
if !a.active {continue}
r := a.radius + SHIP_RADIUS
if dist2(ship.pos, a.pos) <= r * r {
kill_ship()
break
}
}
}
// Asteroids vs asteroids: an elastic bounce so the field feels like real physics rather than a
// pile of ghosts drifting through each other.
for i in 0 ..< MAX_ASTEROIDS {
a := &asteroids[i]
if !a.active {continue}
for j in i + 1 ..< MAX_ASTEROIDS {
b := &asteroids[j]
if !b.active {continue}
resolve_asteroid_pair(a, b)
}
}
}
// resolve_asteroid_pair separates overlapping asteroids and reflects their velocities along the
// collision normal, with mass proportional to radius^2 (2D area), a standard elastic-collision
// impulse, not a gameplay mechanic borrowed from the original Asteroids.
resolve_asteroid_pair :: proc(a, b: ^Asteroid) {
delta := a.pos - b.pos
d2 := delta.x * delta.x + delta.y * delta.y
min_dist := a.radius + b.radius
if d2 >= min_dist * min_dist || d2 < 0.0001 {return}
dist := math.sqrt(d2)
n := delta / dist
m1 := a.radius * a.radius
m2 := b.radius * b.radius
total := m1 + m2
overlap := min_dist - dist
a.pos += n * (overlap * (m2 / total))
b.pos -= n * (overlap * (m1 / total))
rel := a.vel - b.vel
vn := rel.x * n.x + rel.y * n.y
if vn >= 0 {return} // already separating
imp := (2 * vn) / total
a.vel -= n * (imp * m2)
b.vel += n * (imp * m1)
if thud_cooldown <= 0 {
play_sound(SND_THUD)
thud_cooldown = 0.12
}
}
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))
}
} else {
ship.respawn = RESPAWN_DELAY
}
}
dist2 :: proc(a, b: Vec2) -> f32 {
d := a - b
return d.x * d.x + d.y * d.y
}
src/draw.odin
// draw.odin: vector rendering for Destroids, all recorded into the shared `cmd` buffer and
// flushed once per frame. Classic monochrome-vector Asteroids look: white outlines on black, with
// a dense starfield and glowing particle debris to keep the batch heavy.
package main
import "core:math"
import "core:strconv"
import batch "../batch"
anim_time: f32 // drives star twinkle; advanced each drawn frame
// ------------------------------------------------------------------------------------------------
draw_game :: proc() {
anim_time += 1.0 / 60.0
batch.reset(&cmd)
// Background.
batch.set_fill(&cmd, "#05070d")
batch.fill_rect(&cmd, 0, 0, CANVAS_W, CANVAS_H)
// The world (everything but the HUD) jitters with screen shake; the HUD stays put so the
// score/wave/lives readout never becomes unreadable mid-shake.
batch.save(&cmd)
batch.translate(&cmd, shake_x, shake_y)
draw_stars()
draw_shockwaves()
draw_particles()
draw_debris()
draw_asteroids()
draw_bullets()
if ship.alive {
draw_ship()
}
batch.restore(&cmd)
draw_hud()
if intro {
draw_intro()
} else if paused {
draw_paused()
} else if game_over {
draw_game_over()
}
batch.flush(ctx, &cmd)
}
// ------------------------------------------------------------------------------------------------
// Hundreds of tiny points, the bulk of the per-frame command count.
draw_stars :: proc() {
for &s in stars {
tw := 0.55 + 0.45 * math.sin(anim_time * 2.0 + s.twinkle)
a := s.brightness * tw
batch.set_fill(&cmd, grey(a))
sz: f32 = s.brightness > 0.7 ? 2 : 1
batch.fill_rect(&cmd, s.pos.x, s.pos.y, sz, sz)
}
}
draw_particles :: proc() {
batch.set_shadow(&cmd, "#ffce7a", 4)
batch.set_fill(&cmd, "#ffce7a")
for &p in particles {
if !p.active {continue}
a := p.life / p.max_life
batch.set_global_alpha(&cmd, a)
batch.fill_rect(&cmd, p.pos.x - 1, p.pos.y - 1, 2, 2)
}
batch.set_global_alpha(&cmd, 1)
batch.clear_shadow(&cmd)
}
// draw_debris renders the spinning line-shards flung out of destroyed ships/asteroids.
draw_debris :: proc() {
batch.set_stroke(&cmd, "#e8c79a")
batch.set_line_width(&cmd, 1.4)
for &d in debris {
if !d.active {continue}
a := d.life / d.max_life
batch.set_global_alpha(&cmd, a)
c := math.cos(d.angle)
s := math.sin(d.angle)
hx := c * d.len * 0.5
hy := s * d.len * 0.5
batch.begin_path(&cmd)
batch.move_to(&cmd, d.pos.x - hx, d.pos.y - hy)
batch.line_to(&cmd, d.pos.x + hx, d.pos.y + hy)
batch.stroke(&cmd)
}
batch.set_global_alpha(&cmd, 1)
}
// draw_shockwaves renders the expanding, fading rings spawned at explosion origins.
draw_shockwaves :: proc() {
batch.set_stroke(&cmd, "#8fd8ff")
for &s in shockwaves {
if !s.active {continue}
a := s.life / s.max_life
batch.set_global_alpha(&cmd, a * 0.8)
batch.set_line_width(&cmd, 2.0 + 3.0 * (1 - a))
batch.begin_path(&cmd)
batch.arc(&cmd, s.pos.x, s.pos.y, s.radius, 0, TAU)
batch.stroke(&cmd)
}
batch.set_global_alpha(&cmd, 1)
}
draw_asteroids :: proc() {
batch.set_stroke(&cmd, "#c9d4e6")
batch.set_line_width(&cmd, 1.6)
for &a in asteroids {
if !a.active {continue}
batch.begin_path(&cmd)
for i in 0 ..< SHAPE_N {
ang := a.angle + TAU * f32(i) / f32(SHAPE_N)
r := a.radius * a.shape[i]
x := a.pos.x + math.cos(ang) * r
y := a.pos.y + math.sin(ang) * r
if i == 0 {
batch.move_to(&cmd, x, y)
} else {
batch.line_to(&cmd, x, y)
}
}
batch.close_path(&cmd)
batch.stroke(&cmd)
}
}
// draw_bullets renders each shot as a short glowing streak stretched back along its velocity,
// instead of a static dot, so motion reads clearly even at high bullet speed.
draw_bullets :: proc() {
batch.set_shadow(&cmd, "#ffffff", 6)
batch.set_stroke(&cmd, "#ffffff")
batch.set_line_width(&cmd, 2.2)
batch.set_line_cap(&cmd, "round")
for &b in bullets {
if !b.active {continue}
tail := b.pos - b.vel * 0.02
batch.begin_path(&cmd)
batch.move_to(&cmd, tail.x, tail.y)
batch.line_to(&cmd, b.pos.x, b.pos.y)
batch.stroke(&cmd)
}
batch.clear_shadow(&cmd)
}
draw_ship :: proc() {
// Blink while spawn-protected. Suppressed during intro/pause; invuln doesn't count down while
// frozen, so the blink phase would otherwise be stuck and could hide the ship entirely.
if ship.invuln > 0 && !intro && !paused && int(ship.invuln * 12) % 2 == 0 {
return
}
c := math.cos(ship.angle)
s := math.sin(ship.angle)
// Local-space triangle: nose forward (+x), two tail corners.
nose := Vec2{16, 0}
tail_l := Vec2{-10, -9}
tail_r := Vec2{-10, 9}
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)
// Thrust flame: a flickering triangle out the back.
if ship.thrust && !paused && !intro && (int(anim_time * 30) % 2 == 0) {
fl_l := Vec2{-10, -5}
fl_r := Vec2{-10, 5}
tip := Vec2{-20, 0}
fl := ship.pos + Vec2{fl_l.x * c - fl_l.y * s, fl_l.x * s + fl_l.y * c}
fr := ship.pos + Vec2{fl_r.x * c - fl_r.y * s, fl_r.x * s + fl_r.y * c}
tp := ship.pos + Vec2{tip.x * c - tip.y * s, tip.x * s + tip.y * c}
batch.set_stroke(&cmd, "#ff9a3c")
batch.begin_path(&cmd)
batch.move_to(&cmd, fl.x, fl.y)
batch.line_to(&cmd, tp.x, tp.y)
batch.line_to(&cmd, fr.x, fr.y)
batch.stroke(&cmd)
}
}
// ------------------------------------------------------------------------------------------------
// HUD
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)
// Lives as little ship glyphs, top-centre-ish.
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)
}
}
draw_ship_glyph :: proc(x, y: f32) {
batch.begin_path(&cmd)
batch.move_to(&cmd, x, y - 10)
batch.line_to(&cmd, x - 7, y + 8)
batch.line_to(&cmd, x + 7, y + 8)
batch.close_path(&cmd)
batch.stroke(&cmd)
}
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_fill(&cmd, "#ff5a4c")
batch.set_font(&cmd, "bold 64px monospace")
batch.set_text_align(&cmd, "center")
batch.set_text_baseline(&cmd, "middle")
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)
batch.set_fill(&cmd, "#ffce7a")
batch.set_font(&cmd, "18px monospace")
batch.fill_text(&cmd, str_concat(buf[:], "BEST SCORE ", best_score), CANVAS_W / 2, CANVAS_H / 2 + 46)
batch.set_fill(&cmd, "#9fb2d0")
batch.set_font(&cmd, "18px monospace")
batch.fill_text(&cmd, "PRESS ENTER TO PLAY AGAIN", CANVAS_W / 2, CANVAS_H / 2 + 78)
}
// draw_intro is the one-time splash shown from boot until the first ENTER/SPACE press; this game
// is keyboard-driven and unplayable on touch, so it says so up front rather than confusing anyone.
draw_intro :: proc() {
batch.set_fill(&cmd, "rgba(3,5,10,0.78)")
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, "#e8f0ff")
batch.set_font(&cmd, "bold 40px monospace")
batch.fill_text(&cmd, "DESTROIDS", CANVAS_W / 2, CANVAS_H / 2 - 84)
batch.set_fill(&cmd, "#ffce7a")
batch.set_font(&cmd, "18px monospace")
batch.fill_text(&cmd, "BUILT FOR DESKTOP: KEYBOARD REQUIRED", CANVAS_W / 2, CANVAS_H / 2 - 36)
batch.set_fill(&cmd, "#9fb2d0")
batch.set_font(&cmd, "16px monospace")
batch.fill_text(&cmd, "◀ ▶ TURN ▲ THRUST SPACE FIRE SHIFT HYPERSPACE", CANVAS_W / 2, CANVAS_H / 2)
batch.fill_text(&cmd, "P PAUSE ENTER RESTART", CANVAS_W / 2, CANVAS_H / 2 + 26)
batch.set_fill(&cmd, "#7fe0a0")
batch.set_font(&cmd, "bold 20px monospace")
batch.fill_text(&cmd, "PRESS ENTER OR SPACE TO START", CANVAS_W / 2, CANVAS_H / 2 + 68)
}
// draw_paused is the overlay for a mid-game P toggle, the frozen field stays visible underneath.
draw_paused :: proc() {
batch.set_fill(&cmd, "rgba(3,5,10,0.6)")
batch.fill_rect(&cmd, 0, 0, CANVAS_W, CANVAS_H)
batch.set_fill(&cmd, "#e8f0ff")
batch.set_font(&cmd, "bold 40px monospace")
batch.set_text_align(&cmd, "center")
batch.set_text_baseline(&cmd, "middle")
batch.fill_text(&cmd, "PAUSED", CANVAS_W / 2, CANVAS_H / 2 - 10)
batch.set_fill(&cmd, "#9fb2d0")
batch.set_font(&cmd, "18px monospace")
batch.fill_text(&cmd, "PRESS P TO RESUME", CANVAS_W / 2, CANVAS_H / 2 + 26)
}
// ------------------------------------------------------------------------------------------------
// Small string helpers (no fmt/heap on the wasm target).
// grey builds an "rgb(v,v,v)" string for a 0..1 brightness into a static-ish scratch buffer.
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])
}
// str_concat writes `prefix` followed by the decimal of `n` into dst, returning the slice.
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)])
}
clamp01 :: proc(v: f32) -> f32 {
if v < 0 {return 0}
if v > 1 {return 1}
return v
}
server/serve.odin
// serve.odin: a tiny static-file HTTP/1.1 development server, in pure Odin.
//
// This is the server the book uses to load the game over HTTP. It is DELIBERATELY minimal: a
// development tool, not a production server: single-threaded, one request per connection, no
// keep-alive, no chunking, no compression. That keeps it short enough to read in one sitting.
//
// Why we need it at all: the browser blocks fetch()/instantiateStreaming() of a .wasm file over a
// file:// URL with a CORS error, so the page MUST be served over HTTP. And crucially, the .wasm
// must go out with `Content-Type: application/wasm`, or streaming instantiation refuses it.
//
// Build (native, NOT wasm): odin build serve -out:serve
// Run: ./serve # serves ./dist on http://localhost:9000
package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strings"
ROOT :: "dist" // directory served, relative to where you run the binary
PORT :: 9000
main :: proc() {
endpoint := net.Endpoint {
address = net.IP4_Address{0, 0, 0, 0}, // all interfaces
port = PORT,
}
listen, lerr := net.listen_tcp(endpoint)
if lerr != nil {
fmt.eprintfln("could not listen on port %d: %v", PORT, lerr)
os.exit(1)
}
defer net.close(listen)
fmt.printfln("Serving ./%s at http://localhost:%d (Ctrl-C to stop)", ROOT, PORT)
for {
client, _, aerr := net.accept_tcp(listen)
if aerr != nil {
fmt.eprintfln("accept failed: %v", aerr)
continue
}
handle_connection(client)
net.close(client)
}
}
// handle_connection reads one request, serves one file, and returns. No keep-alive.
handle_connection :: proc(client: net.TCP_Socket) {
buf: [4096]u8
n, rerr := net.recv_tcp(client, buf[:])
if rerr != nil || n == 0 {
return
}
path := request_path(string(buf[:n]))
if path == "" {
send_status(client, 400, "Bad Request")
return
}
// Map "/" to the index, then to a file under ROOT. Reject any path that tries to escape ROOT
// with "..", so a request can never read files outside the served directory.
if path == "/" {
path = "/index.html"
}
if strings.contains(path, "..") {
send_status(client, 403, "Forbidden")
return
}
full := strings.concatenate({ROOT, path})
defer delete(full)
data, derr := os.read_entire_file_from_path(full, context.allocator)
if derr != nil {
send_status(client, 404, "Not Found")
return
}
defer delete(data)
send_file(client, content_type(path), data)
}
// request_path extracts the path from the first line of an HTTP request: "GET /foo.js HTTP/1.1".
request_path :: proc(req: string) -> string {
// First line ends at the first CR or LF.
line := req
if i := strings.index_any(req, "\r\n"); i >= 0 {
line = req[:i]
}
// Split "METHOD SP PATH SP VERSION" and take the middle field.
parts := strings.fields(line)
defer delete(parts)
if len(parts) < 2 {
return ""
}
return parts[1]
}
// content_type picks a MIME type from the file extension. The application/wasm entry is the one
// that actually matters; a mis-typed .wasm won't stream-instantiate in the browser.
content_type :: proc(path: string) -> string {
switch {
case strings.has_suffix(path, ".html"):
return "text/html; charset=utf-8"
case strings.has_suffix(path, ".js"):
return "text/javascript; charset=utf-8"
case strings.has_suffix(path, ".css"):
return "text/css; charset=utf-8"
case strings.has_suffix(path, ".wasm"):
return "application/wasm"
case strings.has_suffix(path, ".json"):
return "application/json; charset=utf-8"
case:
return "application/octet-stream"
}
}
// send_file writes a 200 response with the given content type and body.
send_file :: proc(client: net.TCP_Socket, ctype: string, body: []u8) {
header := fmt.tprintf(
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
ctype,
len(body),
)
net.send_tcp(client, transmute([]u8)header)
net.send_tcp(client, body)
}
// send_status writes a tiny plain-text error response.
send_status :: proc(client: net.TCP_Socket, code: int, text: string) {
body := fmt.tprintf("%d %s\n", code, text)
resp := fmt.tprintf(
"HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
code,
text,
len(body),
body,
)
net.send_tcp(client, transmute([]u8)resp)
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Destroids: built in Odin & WebAssembly</title>
<meta name="theme-color" content="#05070d">
<style>
:root { color-scheme: dark; }
html, body {
margin: 0;
height: 100%;
background: #05070d;
color: #cdd7ea;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#game-hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 100vh;
overflow: hidden;
}
/* The canvas is 800x600 internally; scale it to fill the viewport (leaving room for the footer)
while preserving the 4:3 aspect ratio. */
#app canvas {
display: block;
aspect-ratio: 4 / 3;
width: min(90vw, 116vh);
height: auto;
max-width: 90vw;
max-height: 84vh;
border: 1px solid #1b2436;
box-shadow: 0 0 40px rgba(60, 120, 220, 0.15);
}
footer {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
letter-spacing: 0.06em;
color: #7f8ba6;
}
footer .row {
display: flex;
gap: 20px;
align-items: center;
justify-content: center;
}
footer .fps { color: #7fe0a0; min-width: 92px; }
footer .keys { color: #5c6884; }
footer b { color: #a9b6d2; font-weight: 600; }
footer button {
font: inherit;
font-size: 13px;
color: #a9b6d2;
background: #111826;
border: 1px solid #2a3550;
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
}
footer button:hover { border-color: #3d4d74; color: #cdd7ea; }
footer button[aria-pressed="false"] { color: #6b7690; }
footer .meta { font-size: 12px; color: #4b5670; }
</style>
</head>
<body>
<div id="game-hero">
<!-- The batch library appends its <canvas> inside this element (see batch_main). -->
<div id="app"></div>
<footer>
<div class="row">
<span class="fps" id="fps">FPS: -</span>
<span class="keys">◀ ▶ turn · <b>▲</b> thrust · <b>SPACE</b> fire · <b>SHIFT</b> hyperspace · <b>P</b> pause · <b>ENTER</b> restart · <b>S</b> sound</span>
</div>
<div class="row meta">
<button id="sound" type="button" aria-pressed="true" title="Toggle sound effects (S)">🔊 Sound: On</button>
<span>Destroids v1.0.0</span>
</div>
</footer>
</div>
<!-- The batch runtime + bridge. Defines globalThis.Batch. -->
<script src="./batch.js"></script>
<script>
"use strict";
const fpsEl = document.getElementById("fps");
// ---- Audio: a tiny Web Audio synth for classic-Asteroids blips. No asset files; every effect
// is generated from oscillators and noise on the fly, driven by the game through the
// app_env.play_sound / set_thrust imports below. Everything is a no-op while muted. ----
let audioCtx = null;
let sfxMuted = false;
let thrustSrc = null, thrustGain = null;
// getCtx lazily creates the shared AudioContext and resumes it; browsers start it suspended
// until a user gesture (we unlock it on the first keypress, below).
function getCtx() {
if (!audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") audioCtx.resume();
return audioCtx;
}
function noiseBuffer(ctx, seconds) {
const n = Math.max(1, Math.floor(ctx.sampleRate * seconds));
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return buf;
}
// Filtered noise burst with an exponential decay: the "bang" of an exploding rock.
function explosion(ctx, dur, cutoff, peak) {
const src = ctx.createBufferSource();
src.buffer = noiseBuffer(ctx, dur);
const lp = ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = cutoff;
const g = ctx.createGain();
const t = ctx.currentTime;
g.gain.setValueAtTime(peak, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(lp).connect(g).connect(ctx.destination);
src.start(t); src.stop(t + dur);
}
// A pitch-swept oscillator ping: the firing blip and the wave-clear chime.
function blip(ctx, f0, f1, dur, peak, type) {
const o = ctx.createOscillator();
o.type = type;
const g = ctx.createGain();
const t = ctx.currentTime;
o.frequency.setValueAtTime(f0, t);
o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(peak, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(ctx.destination);
o.start(t); o.stop(t + dur);
}
// Ids must match the SND_* constants in game.odin.
function playSound(id) {
if (sfxMuted) return;
const ctx = getCtx();
if (!ctx) return;
switch (id) {
case 0: blip(ctx, 660, 120, 0.12, 0.05, "square"); break; // fire
case 1: explosion(ctx, 0.55, 380, 0.50); break; // bang: large
case 2: explosion(ctx, 0.38, 700, 0.40); break; // bang: medium
case 3: explosion(ctx, 0.22, 1300, 0.30); break; // bang: small
case 4: explosion(ctx, 0.80, 300, 0.55); // ship death
blip(ctx, 300, 40, 0.70, 0.18, "sawtooth"); break;
case 5: blip(ctx, 440, 720, 0.16, 0.06, "triangle"); break; // wave clear
case 6: blip(ctx, 1400, 180, 0.18, 0.05, "sine"); break; // hyperspace jump
case 7: explosion(ctx, 0.12, 220, 0.22); break; // asteroid-asteroid thud
}
}
// A looping low-passed noise rumble while the thrusters are on. Toggled by the game only when
// the on/off state actually changes (see update_thrust_sound).
function setThrust(on) {
if (on) {
if (sfxMuted) return;
const ctx = getCtx();
if (!ctx || thrustSrc) return;
const src = ctx.createBufferSource();
src.buffer = noiseBuffer(ctx, 1.0);
src.loop = true;
const lp = ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 220;
const g = ctx.createGain();
const t = ctx.currentTime;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.05, t + 0.05);
src.connect(lp).connect(g).connect(ctx.destination);
src.start(t);
thrustSrc = src; thrustGain = g;
} else if (thrustSrc) {
const t = audioCtx.currentTime;
thrustGain.gain.cancelScheduledValues(t);
thrustGain.gain.setValueAtTime(thrustGain.gain.value, t);
thrustGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.08);
thrustSrc.stop(t + 0.12);
thrustSrc = null; thrustGain = null;
}
}
const soundBtn = document.getElementById("sound");
function setSfxMuted(m) {
sfxMuted = m;
if (sfxMuted) setThrust(false);
soundBtn.textContent = sfxMuted ? "🔇 Sound: Off" : "🔊 Sound: On";
soundBtn.setAttribute("aria-pressed", String(!sfxMuted));
}
soundBtn.addEventListener("click", () => { setSfxMuted(!sfxMuted); if (!sfxMuted) getCtx(); });
// Slot indices must match the KEY_* constants in main.odin.
const KEYMAP = {
ArrowLeft: 0, KeyA: 0,
ArrowRight: 1, KeyD: 1,
ArrowUp: 2, KeyW: 2,
Space: 3,
Enter: 4,
ShiftLeft: 5, ShiftRight: 5,
KeyP: 6,
};
Batch.instantiate("destroids.wasm", {
app_env: {
// The game pushes its averaged frame rate here a few times a second.
set_fps: (fps) => { fpsEl.textContent = "FPS: " + Math.round(fps); },
// ...and drives the synthesised sound effects.
play_sound: (id) => playSound(id),
set_thrust: (on) => setThrust(on !== 0),
// Persist the high score across sessions via localStorage.
get_best_score: () => parseInt(localStorage.getItem("destroids_best_score") || "0", 10) || 0,
set_best_score: (score) => localStorage.setItem("destroids_best_score", String(score)),
},
}).then((exports) => {
window.addEventListener("keydown", (e) => {
// Browsers only allow audio to start from a user gesture; unlock on the first keypress.
getCtx();
if (e.code === "KeyS") { e.preventDefault(); if (!e.repeat) setSfxMuted(!sfxMuted); return; }
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); }
});
}).catch((err) => {
console.error("Failed to load Destroids:", err);
});
</script>
</body>
</html>
build.sh
#!/usr/bin/env bash
# build.sh: compile the game to WebAssembly and assemble dist/.
#
# Output is exactly three files, all plain and unminified (this book deliberately skips bundlers
# and minifiers, readable output, zero tooling):
# dist/destroids.wasm the game, compiled from src/
# dist/batch.js the renderer runtime + bridge (copied from web/)
# dist/index.html the page (copied from the project root)
#
# Adapt freely: change the flags, the output names, or the layout to taste.
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
echo "==> Compiling Odin -> WebAssembly ..."
# -target:js_wasm32 build for the browser's WASM target
# -no-entry-point there is no main(); JS drives us through the exported procs
# -o:size optimise for a small binary (use -o:speed if you'd rather)
odin build src -out:dist/destroids.wasm -target:js_wasm32 -no-entry-point -o:size
echo "==> Copying assets ..."
cp web/batch.js dist/batch.js
cp index.html dist/index.html
echo "==> Done. dist/ contains:"
ls -lh dist/
# Soft size gate: warn (don't fail) if the wasm grows past 5 MB.
WASM_BYTES=$(wc -c < dist/destroids.wasm)
if [ "$WASM_BYTES" -gt $((5 * 1024 * 1024)) ]; then
echo "WARNING: destroids.wasm is $((WASM_BYTES / 1024))KB, over the 5MB soft budget"
else
echo "==> destroids.wasm: $((WASM_BYTES / 1024))KB"
fi
run.sh
#!/usr/bin/env bash
# run.sh: build the game, build the dev server, then serve dist/ at http://localhost:9000.
#
# The server is a native Odin binary (server/serve.odin), a second compile target alongside the
# game's WebAssembly. It must serve .wasm as `application/wasm`, which is why we don't just open
# index.html from disk: the browser blocks a file:// fetch of the .wasm with a CORS error.
set -euo pipefail
cd "$(dirname "$0")"
bash build.sh
echo "==> Building the dev server (native) ..."
odin build server -out:serve -o:size
echo ""
echo "=== Starting server ==="
./serve # serves ./dist on http://localhost:9000 (Ctrl-C to stop)