How Bloom Works

When you press Run, your code takes a short journey before it becomes a picture. This page lets you watch every step happen live.

Bloom is a small programming language, and like every language it does not understand your text directly. Instead it transforms your code through a series of stages, each one closer to something a computer can execute. The big picture looks like this:

SourceThe text you type
TokensWords & symbols
ASTA structure tree
BytecodeTiny instructions
OutputThe result

Each box is a real piece of Bloom, living in src/lang/: the lexer, the parser, the bytecode compiler, and one of two runtimes. The rest of this page walks through every box in detail — and lets you drive all of them at once with your own code.

The live pipeline explorer

Edit the program below. Every change re-runs the whole pipeline and updates all six panels. This is not a recording — it calls the exact same lexer, parser, compiler, and runtime that power the real playground.

Your Bloom code

1. Tokens

The lexer scans your text left to right and groups characters into tokens — the smallest meaningful pieces, like the keyword fn, the name double, or the number 2.

2. Abstract syntax tree

The parser arranges those tokens into a tree that captures structure: which expression belongs to which statement, which arguments belong to which call. If your code has a syntax error, you'll see a friendly message here instead.


        

3. Bytecode

The compiler walks the tree and emits bytecode — a flat list of tiny stack instructions. This is Bloom's "assembly language." Each line is one instruction the virtual machine knows how to run.


        

4. Output

The virtual machine executes the bytecode and produces results. Here we capture everything your program prints. (Drawing programs would render to a canvas instead.)


        

5. Execution trace

The debugger re-runs your program and records a snapshot at every statement: which line ran and what every variable held at that moment. Watch loops count and values change, step by step.


        

6. WebAssembly (WAT)

If your code defines a pure-numeric function, Bloom compiles it to WebAssembly text for near-native speed. Otherwise this panel explains why it kept to the VM instead.


        
Try it Change 2 to another number and watch the bytecode constant and the output update. Add a syntax error (delete a }) and watch the AST panel explain what went wrong. Add fn square(n) { return n * n } and the WebAssembly panel lights up.

Each stage, up close

The pipeline above is the whole story in miniature. Now let's slow down and look at what each stage actually does, with a concrete example you can follow all the way through.

Stage 1 — The lexer: text becomes tokens

Your program starts life as one long string of characters. The lexer (also called a scanner) reads it left to right and chops it into tokens — the smallest pieces that carry meaning. The lexer doesn't care about structure or correctness; it just recognises words and symbols, a bit like splitting a sentence into words and punctuation.

letkeyword
xidentifier
=equal
2number
The lexer turns let x = 2 into four tokens. Each token records its kind and the exact text (its "lexeme").

Bloom recognises several kinds of tokens:

Two things the lexer quietly throws away: whitespace (spaces and newlines, except where they separate tokens) and comments (everything after // on a line). They help humans read code but mean nothing to the machine, so they never become tokens. The lexer also adds one invisible token at the very end — EOF, "end of file" — so later stages always know when they have reached the bottom of the program. Edit the explorer above and watch the Tokens panel rebuild this list as you type.

Stage 2 — The parser: tokens become a tree

A flat list of tokens still has no shape. The parser reads the tokens and builds an abstract syntax tree (AST) — a structure that captures how the pieces nest inside each other. The word "abstract" means it drops the noise (parentheses, semicolons) and keeps only the meaning.

The interesting part is precedence: the rule that * binds tighter than +, just like in school maths. So 2 + 3 * 4 is 2 + (3 * 4) = 14, not (2 + 3) * 4 = 20. The parser encodes this by building the multiplication lower in the tree, so it gets evaluated first:

+
  • 2
  • *
    • 3
    • 4
The AST for 2 + 3 * 4. Because * sits beneath +, it is evaluated first — precedence is baked into the shape of the tree, not into any extra rules. Paste print(2 + 3 * 4) into the explorer to see the real tree.

Every construct in Bloom has an AST node type: a Let node for a variable, an If node with a condition and branches, a Call node with a callee and arguments, a Binary node for an operation with a left and a right side. If the tokens don't fit any rule — say you forgot a closing } — the parser stops and reports a friendly error instead of a tree. That's what the AST panel shows you live.

Stage 3 — Two ways to run the tree

Once Bloom has a tree, it has to actually do something with it. There are two strategies, and Bloom ships both.

The tree-walking interpreter

The interpreter is the straightforward approach: walk the tree directly, node by node, doing what each node says. To evaluate the + node above, it evaluates the left child (2), evaluates the right child (which evaluates its children, 3 * 4 = 12), then adds them: 14. It is a recursive walk, and it mirrors the tree exactly.

The interpreter tracks variables in an environment — a lookup table of name to value. Each function call and block gets its own environment that links back to the one around it, forming a chain. When you use a name, the interpreter walks up the chain until it finds it. This is what makes scope work:

inner blocky = 10
functionn = 5
globalprint, sin, …
Environments form a chain. Looking up a name searches the innermost scope first, then walks outward. A closure is a function that remembers its chain even after the outer call has returned — which is why closures need the interpreter.

The bytecode virtual machine

Walking a tree over and over is flexible but not the fastest path. So Bloom also has a compiler that flattens the tree into bytecode — a simple linear list of instructions for a stack-based virtual machine (VM). Instead of re-walking nodes, the VM marches down the list once.

"Stack-based" means the VM has a scratch pile of values — a stack — and most instructions just push a value on top or pop values off. Here's how the VM computes 2 + 3, one instruction at a time:

CONST 2
2
CONST 3
2
3
ADD
5
CONST 2 and CONST 3 push two numbers. ADD pops both, adds them, and pushes the result 5. Every operation is this small. The Bytecode panel shows the real instructions for your code, including LOAD_GLOBAL / STORE_GLOBAL for variables and CALL_NATIVE for built-ins like print.

So let x = 2   print(x + 1) compiles to roughly: push 2, store it in the global x, load x back, push 1, ADD, then CALL_NATIVE print. Each line in panel 3 is one of these steps. The VM is faster precisely because it does this flat, predictable march instead of recursive tree-walking.

The value model: what Bloom can hold

Everything a Bloom program works with is one of a handful of value types:

Truthiness

When a value lands in a condition (if, while, and, or), Bloom asks: is this truthy or falsy? The rule is short and deliberate — only three things are falsy:

ValueIn a conditionWhy
nilfalsynothing is there
falsefalsyit's literally false
0falsyzero counts as "none"
"" (empty string)truthyit's still a string — not nil
[] (empty array)truthyit's still an array — not nil
any other number / string / array / objecttruthya real value exists

The empty-string and empty-array cases trip up newcomers from other languages, so they're worth memorising. To test for emptiness, check the length explicitly:

if 0 { print("never runs") } else { print("0 is falsy") }
if "" { print("empty string is truthy!") }   // this DOES run
if len(myArray) == 0 { print("array is empty") }  // the right way to test

Don't take our word for it — paste any of these into the explorer's editor and watch the Output panel.

Choosing a backend: VM, interpreter, or WebAssembly

Bloom ships with three ways to run code, and it picks for you automatically. You never choose — you just write Bloom — but knowing the rules explains why some programs are blisteringly fast.

your program compiled to bytecode uses closures or import? interpreter tree-walk fallback hot pure-number fn? WebAssembly near-native math bytecode VM the default yes no yes no How Bloom decides. The VM is the workhorse; the interpreter and WebAssembly are picked only when their conditions are met.

The bytecode VM — the default

Most programs run on the bytecode virtual machine: the compiler turns your AST into the flat instruction list from panel 3, and the stack machine runs it. It's quick and it's where your code lands unless something pulls it elsewhere.

The tree-walking interpreter — the flexible fallback

Two features are beyond the VM: closures (functions that capture variables from an enclosing scope) and the module system (import / export). When Bloom spots either, it transparently switches to the tree-walking interpreter, which walks the AST directly using the environment chain shown earlier. Slightly slower, but it never trips over a closure.

WebAssembly — the speed lane

For pure-numeric functions — math on numbers only, no strings, arrays, objects, or drawing calls — Bloom can compile straight to WebAssembly, the low-level format browsers run at near-native speed. Eligible functions are detected automatically and handed off; everything else keeps running on the VM. Same code, big speedup: a recursive fib(30) runs roughly 50× faster on the WebAssembly path than on the VM. Define fn square(n) { return n * n } in the explorer and panel 6 shows you the generated WebAssembly text.

When things go wrong: friendly errors

Bloom is built for beginners, so when your code has a mistake it tries to teach rather than just complain. The lexer and parser report where a problem is, and a layer of suggestions recognises common mix-ups and points you at the fix.

For example, typing a closing brace too few, or using a keyword from another language, produces guidance like:

function greet() { }
// Bloom: Use "fn" instead of "function" to define functions
//        in Bloom. Example: fn myFunction() { }

And if you misspell a built-in, Bloom measures how close your typo is to every name it knows and suggests the nearest one:

prnt("hi")     // did you mean: print
circel(...)    // did you mean: circle

Behind the scenes this uses an "edit distance" (how many single-character changes turn one word into another) to find the closest valid name. The same machinery powers the playground's inline error hints. Try introducing a syntax error in the explorer and watch the AST panel describe it.

The tooling around the language

Here's the quiet payoff of the pipeline: once you have a lexer, a parser, and a compiler, you get a whole toolbox almost for free, because every tool just reuses one of those stages.

ASTshared
Formatterpretty-print
Linterfind smells
Bytecodeshared
Disassemblerread it back
Debuggertrace steps
The tools don't reimplement the language — they hang off the AST and bytecode the pipeline already produces.

Go deeper

Each stage has its own detailed write-up in the internals section: