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 nomain. 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 withcontext = 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 underfile://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
- Add a
spike_stroke_rectforeign proc and an outlined rectangle to the scene. - 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. - Read the export names in your
spike.wasmwithwasm-objdump -x spike.wasm(if you have the WABT tools) or your browser's dev tools, and findwasm_mainandmemory.
Further reading
- MDN: WebAssembly: https://developer.mozilla.org/en-US/docs/WebAssembly
- MDN:
instantiateStreaming: https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/instantiateStreaming_static - Odin:
foreignand@(export)in the Overview: https://odin-lang.org/docs/overview/