Chapter 10

The Runtime

Where the pieces become a running sketch

The lexer, parser, interpreter, VM, and WASM compiler are all components. Something has to glue them together, decide which backend to use, hook up the mouse and keyboard, and run draw() sixty times a second. That something is createRuntime() in src/lang/index.ts.

The Entry Point

Every sketch starts the same way. The playground hands your source to createRuntime() with a canvas and some callbacks, then calls .start():

Calling the runtime
const runtime = createRuntime(source, {
  canvas,
  onPrint: (msg) => console.log(msg),
  onError: (err) => showError(err),
  onWarning: (msg, hint) => showHint(msg, hint),
});
runtime.start();

Whatever backend ends up running, you get back the same BloomRuntime shape — start, stop, pause, resume, step, plus getters for the current frame, user variables, and performance metrics. The caller never has to know which engine is underneath.

Choosing a Backend

This is the heart of the runtime. Bloom defaults to the bytecode VM for speed, but transparently falls back to the interpreter for the one thing the VM can't handle — a program that uses import:

src/lang/index.ts — createRuntime()
if (options.useBytecode !== false) {
  try {
    const bytecodeRuntime = createBytecodeRuntimeFromSource(source, { ... });
    return wrapBytecodeRuntime(bytecodeRuntime);
  } catch (error) {
    if (!(error instanceof ModulesNotSupportedError)) {
      throw error; // Real parse/compile/setup error — do not swallow.
    }
    // import detected — fall through to the interpreter path.
  }
}
// ... interpreter setup below ...

The logic is deliberately narrow. Just one exception triggers a fallback:

ModulesNotSupportedError
The program uses import. The VM has no module system, so the interpreter — which resolves and links modules — takes over.

Closures used to be the other fallback case, but they no longer are: the VM captures them natively with boxed-cell upvalues, so a closure-using sketch stays on the fast path (the full story is in Chapter 6). Any other error — a real syntax mistake, an undefined variable in setup() — is re-thrown, not swallowed. That matters: a fallback should never hide a genuine bug. Both paths funnel checkCommonMistakes() warnings through onWarning, so the friendly nudges from the error system appear no matter which engine runs.

Why try-the-VM-first works Both backends consume the identical AST from the shared front-end and share the same value semantics. That equivalence is what makes it safe to attempt the VM, bail on the one known-incompatible feature, and re-run on the interpreter with the same observable behavior. See the overview for the two-backend contract.

Wiring Up Input

When a canvas is supplied, the runtime attaches the event listeners that make a sketch interactive. Mouse, touch, and keyboard all feed plain fields on the interpreter (or VM):

Touch events call preventDefault() so dragging on a sketch doesn't scroll the page, and they also populate a touches array for multi-touch sketches. The Mouse & Keyboard chapter shows these variables in action.

The Draw Loop

If the program defines a draw() function, the runtime starts an animation loop with requestAnimationFrame. The loop is frame-rate aware — it only runs a frame once enough time has elapsed for the target FPS (60 by default), so a fast monitor doesn't run your sketch too quickly:

src/lang/index.ts — the loop
function loop(currentTime) {
  if (!running || paused) return;
  const elapsed = currentTime - lastFrameTime;
  if (elapsed >= frameDelay) {           // frameDelay = 1000 / targetFps
    lastFrameTime = currentTime - (elapsed % frameDelay);
    if (!executeFrame()) return;         // runs draw(), catches errors
    // ... record frameTime, update rolling FPS average ...
  }
  animationId = requestAnimationFrame(loop);
}

executeFrame() calls draw() inside a try/catch. If your code throws, the runtime stops the loop and routes the error to onError rather than spamming the console every frame. It also records the frame time into a 60-sample rolling history to compute the average frame time and FPS you see in the playground's metrics.

Lifecycle Controls

The returned object exposes the controls the playground's run/pause/step buttons use:

Method
What it does
start()
Runs top-level code, calls setup(), then begins the loop if draw() exists
pause() / resume()
Cancel / restart the rAF loop without losing state
step()
Pauses, then runs exactly one frame — the basis of the debugger
stop()
Halts the loop and calls interpreter.cleanup() to release camera/audio resources

The Full Journey, End to End

Source your code
tokenize lexer.ts
parse parser.ts → AST
createRuntime pick backend
rAF loop draw() → canvas

In the Source Code

Want to watch every stage interactively? The How Bloom Works explorer runs this exact pipeline and lets you step through it.