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 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

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 Error("Execution timeout")
  }
}

This uses bitwise AND (& 1023) instead of modulo for speed — a micro-optimization that matters in tight loops.

In the Source Code

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