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:
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.
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.
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.
let x = 2 into four tokens. Each token records its kind and the exact text (its "lexeme").
Bloom recognises several kinds of tokens:
- Keywords — reserved words the language knows:
let,fn,if,else,for,while,return,and,or,not,true,false,nil. - Identifiers — names you invent, like
xordouble. - Numbers — digits, with an optional decimal point, like
2or3.14. - Strings — text in quotes, like
"hello". The lexer reads characters until the closing quote, so it knows the spaces inside belong to the string. - Operators & punctuation —
+,*,==,(,{, and so on.
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
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:
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 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:
- number —
42,3.14,-1. There's just one number type; no separate ints and floats. - string — text,
"hello". - boolean —
trueorfalse. - nil — "nothing", the absence of a value.
- array — an ordered list,
[1, 2, 3]. - object — named fields,
{ x: 1, y: 2 }. - function — yes, functions are values too; you can pass them around and store them in variables.
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:
| Value | In a condition | Why |
|---|---|---|
nil | falsy | nothing is there |
false | falsy | it's literally false |
0 | falsy | zero counts as "none" |
"" (empty string) | truthy | it's still a string — not nil |
[] (empty array) | truthy | it's still an array — not nil |
| any other number / string / array / object | truthy | a 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.
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.
- Formatter — pretty-prints your code straight from the AST, so layout is always consistent no matter how you typed it.
- Linter — walks the AST looking for likely mistakes and suspicious patterns before you even run anything.
- Disassembler — turns compiled bytecode back into the readable listing you see in panel 3 (learn more).
- Debugger — records a step-by-step execution trace so you can inspect every variable at every line. That's exactly what powers panel 5 above.
Go deeper
Each stage has its own detailed write-up in the internals section:
- The Lexer — how text becomes tokens
- The Parser — how tokens become an AST
- The Bytecode VM — how the AST is compiled and run fast
- The Interpreter — the tree-walking fallback
- Disassembly — reading the bytecode listing
- The Rendering Pipeline — drawing 100k+ shapes per frame
- Why Bloom over p5.js — the benchmark and the reasons behind it
- Modules — splitting code with import & export