Yo Odin · Appendix B

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

FilePurpose
batch/batch.odinCommand buffer, wire format, draw-op encoders
batch/canvas.odinCanvas bootstrap, animation loop, DOM events
web/batch.jsJS glue: Odin runtime hooks + the batched-draw bridge
src/main.odinWiring: bootstrap, per-frame dispatch, keyboard input
src/game.odinState, simulation, collisions
src/draw.odinVector rendering into the command buffer
server/serve.odinStatic-file HTTP dev server (native Odin)
index.htmlThe host page: canvas + app_env (sound, high score, keymap)
build.sh / run.shBuild 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 &amp; 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)