Yo Odin ยท Chapter 3

Chapter 3: Odin meets the browser: WebAssembly foundations

This chapter: compile Odin to WebAssembly, cross the boundary to JavaScript both ways, and draw one rectangle from a hand-wired spike. Everything the batch library (Chapters 5โ€“6) is built on.

Where we left off: you can read and write the Odin the game needs.


3.1 The problem WebAssembly solves, and the one it doesn't

WebAssembly (WASM) is a compiled bytecode that runs at near-native speed inside a web page. odin build -target:js_wasm32 turns our Odin into a .wasm module the browser can load.

But WASM is sandboxed. It has linear memory and functions, and nothing else. No DOM, no canvas, no audio, no clock. Every capability comes from JavaScript, handed in when the module is instantiated. So the whole game is a conversation across a boundary:

  • Odin โ†’ JS (Odin asks JS to do something): declared with foreign import.
  • JS โ†’ Odin (JS calls into our code): marked @(export).

Getting that conversation right is the work of this chapter.

3.2 The js_wasm32 target and -no-entry-point

odin build spike -out:spike.wasm -target:js_wasm32 -no-entry-point -o:size
  • -target:js_wasm32: emit WebAssembly for the browser.
  • -no-entry-point: there is no main. JavaScript drives us by calling exported procedures, so the usual entry point doesn't apply. (Type-check the same way: odin check spike -target:js_wasm32 -no-entry-point.)

3.3 @(export): letting JavaScript call in

Mark a procedure @(export) and give it the C calling convention, and its name becomes a WASM export JavaScript can call:

@(export)
wasm_main :: proc "c" () {
	// ...
}

proc "c" matters: it uses the C ABI (so JS can call it) and carries no implicit context.

3.4 foreign import: calling out to JavaScript

Declare the JS-provided functions in a foreign block. The string names the import module JS will supply:

foreign import spike "spike_env"

@(default_calling_convention = "contextless")
foreign spike {
	spike_init      :: proc(w, h: u32) ---
	spike_fill_rect :: proc(x, y, w, h, r, g, b: f32) ---
}

These have no body; JavaScript provides it. The --- is Odin's "declaration only" marker.

3.5 The rule you must never forget: re-establish context

Because proc "c" drops the implicit context, the first line of every exported procedure must put it back:

import "base:runtime"

@(export)
wasm_main :: proc "c" () {
	context = runtime.default_context()   // <-- always first
	// ...now allocation, logging, etc. work normally
}

Gotcha. Forget this and anything that touches the context (an allocation, a core: call that allocates or logs) reads uninitialised state and misbehaves in ways that are maddening to debug (crashes, garbage, silent no-ops), often far from the real cause. Every @(export) proc "c" in this book starts with context = runtime.default_context(). Make it a reflex.

3.6 The handle table: WASM can't hold a JS object

A WASM module's memory is just bytes; it cannot hold a live reference to a JavaScript object like a canvas or an event. The standard fix, and the one we use everywhere, is a handle table on the JS side:

  • JS keeps an array of live objects.
  • When Odin needs one, JS stores it and returns its integer index.
  • Odin holds only that integer (an opaque u32), and passes it back whenever it wants that object.

We'll give the integer a distinct type, Handle, in Chapter 5 so it can't be confused with any other number. For now, just hold the idea: every browser object Odin touches is really a small integer into a JS-side array.

3.7 Strings across the boundary

An Odin string is a pointer + length into WASM linear memory. JavaScript can read it by viewing that memory:

const bytes = new Uint8Array(wasmMemory.buffer, ptr, len);
const text  = new TextDecoder().decode(bytes);

and write into it with a TextEncoder. We don't need strings for the spike below (it passes only numbers), but this is exactly the mechanism the renderer's put_str and the event bridge use later.

3.8 The spike: one rectangle, hand-wired

Here's a complete, minimal round trip. The Odin side (spike/main.odin):

package main

import "base:runtime"

foreign import spike "spike_env"

@(default_calling_convention = "contextless")
foreign spike {
	spike_init      :: proc(w, h: u32) ---
	spike_fill_rect :: proc(x, y, w, h, r, g, b: f32) ---
}

@(export)
wasm_main :: proc "c" () {
	context = runtime.default_context()
	spike_init(320, 200)
	spike_fill_rect(40, 40, 120, 80, 0.25, 0.56, 0.77)
}

Build it:

odin build spike -out:spike.wasm -target:js_wasm32 -no-entry-point -o:size

The JavaScript side supplies two import modules. odin_env is the handful of runtime hooks Odin's core library needs on this target (output, a trap handler, some float-math intrinsics, a random seed); spike_env is our two functions:

// spike.html (the <script> body)
let mem;
const importObject = {
  odin_env: {
    write: (fd, ptr, len) =>
      console.log(new TextDecoder().decode(new Uint8Array(mem.buffer, ptr, len))),
    trap:  () => { throw new Error("odin trap"); },
    abort: () => { throw new Error("odin abort"); },
    alert: () => {}, evaluate: () => {}, open: () => {},
    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, e) => x * Math.pow(2, e),
    rand_bytes: (ptr, len) =>
      crypto.getRandomValues(new Uint8Array(mem.buffer, ptr, len)),
  },
  spike_env: {
    spike_init: (w, h) => {
      const cv = document.getElementById("app").appendChild(document.createElement("canvas"));
      cv.width = w; cv.height = h;
      window._ctx = cv.getContext("2d");
    },
    spike_fill_rect: (x, y, w, h, r, g, b) => {
      window._ctx.fillStyle = `rgb(${r*255|0},${g*255|0},${b*255|0})`;
      window._ctx.fillRect(x, y, w, h);
    },
  },
};

WebAssembly.instantiateStreaming(fetch("spike.wasm"), importObject).then((wasm) => {
  mem = wasm.instance.exports.memory;
  wasm.instance.exports.wasm_main();   // JS -> Odin
});

With a <div id="app"></div> on the page, wasm_main runs, calls spike_init (JS creates a canvas) and spike_fill_rect (JS paints a blue rectangle). One round trip out to instantiate, one call in to wasm_main, two calls back out to draw.

There's just one thing stopping you seeing it: you can't open this HTML file yet.

Gotcha. Opening the page as a file:// URL fails. instantiateStreaming(fetch("spike.wasm")) makes an HTTP request for the .wasm, and browsers block that under file:// with a CORS error. The game must be served over HTTP, which is exactly what Chapter 4 builds.

3.9 Odin ships WASM helpers, but we're building our own

Odin includes vendor:wasm/js and core:sys/wasm/js, which wrap a lot of this plumbing for you. We don't use them, not because they're bad, but because wiring the boundary by hand once is the fastest way to actually understand it. Everything we build uses only the Odin compiler and the browser; nothing here is third-party.

Checkpoint

You can compile Odin to WASM, expose procedures to JS with @(export) proc "c", call JS from Odin with foreign import, and you know the context = runtime.default_context() rule and the handle-table idea. The spike builds; it just needs a server to load it.

Exercises

  1. Add a spike_stroke_rect foreign proc and an outlined rectangle to the scene.
  2. Deliberately omit context = runtime.default_context(), rebuild, and note that a numbers-only program like this one may seem fine, then recall why it won't stay fine once you allocate.
  3. Read the export names in your spike.wasm with wasm-objdump -x spike.wasm (if you have the WABT tools) or your browser's dev tools, and find wasm_main and memory.

Further reading