Chapter 9
The WASM Compiler
Pure-numeric functions, straight to WebAssembly
Bloom has a third execution path beyond the tree-walking interpreter and the bytecode VM: a WebAssembly compiler. It takes Bloom functions that do nothing but arithmetic and compiles them directly from the AST into a real WASM module the browser runs at near-native speed. It lives in src/lang/wasm-compiler.ts.
The Numeric Model
WebAssembly is typed; Bloom is not. To bridge the gap, the compiler picks one type and uses it everywhere: every value is an f64 (a 64-bit float). Numbers are obviously f64. Booleans and comparisons produce 1.0 for true and 0.0 for false, exactly like C. There are no strings, arrays, objects, or colors inside a compiled function — only floating-point math.
Unlike the VM (which compiles the AST to a flat list of jump-based opcodes), WASM has structured control flow — block, loop, if/else, br. So the compiler walks the AST and emits matching structured instructions directly, rather than translating bytecode.
Eligibility: What Can Be Compiled
A function is compiled only if every construct it uses fits the numeric subset. computeEligible() figures this out in two phases.
First, each function is checked on its own (isLocallyEligible()): it may use arithmetic, comparisons, if/while/for, local lets, return, numeric parameters, and a handful of supported math built-ins (sin, cos, sqrt, abs, floor, min, max, …). Anything outside that — a string, an array, a call to circle(), an object literal — disqualifies it.
Second, eligibility propagates across calls. A fixed-point loop repeatedly removes any function that calls a function that isn't eligible, until nothing changes:
let changed = true;
while (changed) {
changed = false;
for (const name of [...eligible]) {
for (const callee of collectCallees(fn.body)) {
// math built-ins are always allowed
if (callee in WASM_MATH_UNARY || callee in WASM_MATH_BINARY) continue;
if (!eligible.has(callee)) {
eligible.delete(name); // calls something we can't compile
changed = true;
break;
}
}
}
}
This is what lets a recursive function like Fibonacci compile: it only calls itself, and it itself is eligible, so the fixed point keeps it in the set.
From AST to Bytes
The compiler builds a real binary WebAssembly module by hand. Three pieces cooperate:
WasmModuleBuilder
Assembles the binary sections (Type, Function, Export, Code) and encodes integers as LEB128 and constants as little-endian f64. It writes the magic header \0asm and version, then each section.
FunctionCodegen
Walks one FunctionStmt and emits structured WASM opcodes — f64.add, f64.lt, local.get, call, loop/block/br_if — for its body.
WebAssembly.Instance
The finished bytes are handed to new WebAssembly.Module() / Instance(). Each eligible function becomes a callable JS export.
The whole flow is one function, compileProgramToWasm(source), which returns a CompiledWasmProgram (the module bytes, a map of callable functions, and a WAT text view) — or null if nothing is eligible, signalling the caller to fall back.
Booleans as Floats
Because everything is an f64, comparisons need a tiny bit of care. WASM's f64.lt pushes an i32 (0 or 1), so the codegen converts it back to f64 with f64.convert_i32_u. An if condition does the reverse: it compares the f64 against zero to get the i32 that if expects. This keeps the "one type everywhere" invariant intact while still using WASM's native comparison ops.
The WAT View
Binary WASM is unreadable, so toWat(source) renders the eligible functions as WebAssembly text — the human-readable S-expression form. This is exactly what the pipeline explorer shows in its final stage. For a simple square function you'd see something like:
(func $square (param $x f64) (result f64)
local.get $x
local.get $x
f64.mul)
How It Fits the Big Picture
The three backends form a ladder of speed against generality:
In the Source Code
Everything here is in src/lang/wasm-compiler.ts. Key entry points:
compileProgramToWasm(source)— full pipeline, returns module + callable functions or nulltoWat(source)— the readable text view used by the explorercomputeEligible()/isLocallyEligible()— the numeric-subset gatekeepersWasmModuleBuilder/FunctionCodegen— binary emission
See it live in the How Bloom Works explorer — type a numeric function and watch the WAT appear.