Chapter 4

The Interpreter

Running your code

The interpreter takes the AST from the parser and executes it. It walks through the tree, evaluating each node and producing results. This is called tree-walking interpretation.

The reference implementation The interpreter is the most flexible engine and Bloom's reference implementation. The faster bytecode VM runs almost every sketch — including closures, which it captures natively (Chapter 6). Bloom re-runs a program here only when it uses modules, which the VM has no system for. Everything the language can do, the interpreter can do — so it remains the source of truth for how Bloom should behave, and the VM is checked against it.

The Evaluation Loop

The interpreter has one main job: given an AST node, compute its value. This is recursive — to evaluate a complex expression, you evaluate its sub-expressions first.

Binary (+) ← evaluate me! ├── left: Literal 10 → returns 10 └── right: Literal 5 → returns 5 Result: 10 + 5 = 15

To evaluate 10 + 5:

  1. Evaluate left child (Literal 10) → returns 10
  2. Evaluate right child (Literal 5) → returns 5
  3. Apply operator (+) → returns 15

Value Types

Bloom values at runtime:

number → 42, 3.14, -7 string → "hello world" boolean → true, false nil → nil (nothing) array → [1, 2, 3] object → { x: 10, y: 20 } function → fn(x) { return x * 2 } vec2 → vec2(100, 50) vec3 → vec3(1, 2, 3) image → loadImage("cat.png") sprite → createSprite(img, 32, 32) particles → fireParticles(100, 100) sound → loadSound("beep.mp3")

Environments and Scope

Variables live in environments. Each scope creates a new environment that links to its parent.

let x = 1          // Global environment: { x: 1 }

fn test() {
  let y = 2        // Function environment: { y: 2 } → parent: global
  if true {
    let z = 3      // Block environment: { z: 3 } → parent: function
    // Can see: z, y, x
  }
  // Can see: y, x (z is gone)
}
// Can see: x (y is gone)
Global x: 1 parent: none
Function y: 2 parent: Global
Block z: 3 parent: Function

When you reference a variable, the interpreter searches up the chain: current environment → parent → parent → ... until it finds the name or reaches the top.

Native Functions

Drawing functions like circle() are native functions — they're implemented in JavaScript, not Bloom. When the interpreter sees a call to circle, it:

  1. Looks up "circle" in the environment → finds a native function
  2. Evaluates all the arguments (x, y, radius)
  3. Calls the JavaScript implementation with those values
  4. The JS code draws on the Canvas
Over 100 native functions Drawing, colors, math, text, transforms, input, arrays, particles, sprites, sound, 3D graphics... all implemented as native functions.

The Animation Loop

Bloom programs typically have setup() and draw() functions. Here's how the runtime manages them:

Start
setup() runs once
draw() 60× per second
↻ repeats

Each frame:

  1. Increment frame counter
  2. Update mouseX, mouseY, keyPressed
  3. Call draw() function
  4. Wait for next animation frame (~16.6ms for 60fps)

Executing a For Loop

Let's trace through this loop:

for i in 0..3 {
  print(i)
}
for i in 0..3
Setup: Evaluate range 0..3 → get iterable [0, 1, 2]
Create new scope for loop body
i = 0
Iteration 1: Bind i = 0 in loop scope
Execute body: print(0) → outputs "0"
i = 1
Iteration 2: Bind i = 1 in loop scope
Execute body: print(1) → outputs "1"
i = 2
Iteration 3: Bind i = 2 in loop scope
Execute body: print(2) → outputs "2"
done
Cleanup: No more items
Discard loop scope, continue after loop

Closures

When you create a function, it captures its surrounding environment. This is a closure.

fn makeCounter() {
  let count = 0
  return fn() {
    count = count + 1
    return count
  }
}

let counter = makeCounter()
print(counter())  // 1
print(counter())  // 2
print(counter())  // 3

The inner function "remembers" the count variable from when it was created, even after makeCounter() has returned. This works because:

  1. When the inner function is created, it stores a reference to its environment
  2. That environment contains count
  3. Even after makeCounter() returns, the environment lives on
  4. Each call to counter() modifies that same count

Closures fall directly out of the environment-chain design: a function value just holds onto the environment it was defined in. The bytecode VM reaches the same behavior by a very different route — it boxes captured variables into heap cells at compile time. That story gets its own chapter; see Chapter 6: Closures.

Control Flow Without Stack Traces

How does a break or continue deep inside nested ifs jump back out to the loop? The interpreter throws a small signal object and catches it at the loop body. return used to work the same way — but it no longer does, and the reason is performance (more below).

src/lang/interpreter.ts
// These deliberately do NOT extend Error.
export class BreakException {}
export class ContinueException {}

// break/continue carry no data, so a single shared
// instance is reused instead of allocating each time.
export const BREAK_SIGNAL = new BreakException()
export const CONTINUE_SIGNAL = new ContinueException()

The crucial detail is that these classes do not extend Error. Constructing a real JavaScript Error captures a stack trace, which is surprisingly expensive when thrown in a tight loop.

Why return stopped throwing return originally unwound the same way, via a thrown ReturnException. But a recursive program does a return on every call, and even a stack-trace-free throw/catch is costly at that volume — benchmarking showed it was roughly 44× slower than the alternative. So return now sets a returning flag plus a returnValue field; statement loops check the flag and stop early, and the function-call boundary consumes it and clears it. No throw at all. break and continue still use the shared signal objects above — they fire far less often, so the simpler exception model is fine for them.

Error Handling

When something goes wrong during execution, the interpreter throws a runtime error:

Runtime Error at line 5 Cannot call undefined as a function

foo()
^^^

Runtime errors stop execution immediately. Unlike syntax errors (caught during parsing), these happen when the code runs.

Timeout Protection

Infinite loops would freeze your browser. The interpreter checks periodically:

// Inside the interpreter loop:
if ((iterations & 1023) === 0) {  // Every 1024 iterations
  if (Date.now() - startTime > timeout) {
    throw new TimeoutError(...)
  }
}

This uses bitwise AND (& 1023) instead of modulo for speed — a micro-optimization that matters in tight loops. Note that TimeoutError and LoopLimitError do extend Error (they're real errors a user should see), unlike the control-flow signals above.

In the Source Code

The interpreter lives in src/lang/interpreter.ts. Key methods: