Ember: Build a Lua-Like Scripting Runtime in Rust
Welcome to the Language Runtime Curriculum — a project-based engineering apprenticeship in which you build Ember, a small Lua-like dynamic language and its runtime, in Rust, from the first character of source text to native machine code.
The primary goal is not a polished language. The primary goal is that you can open
lua/lvm.c, cpython/Python/ceval.c, v8/src/interpreter/, openjdk/src/hotspot/share/interpreter/,
or wasmtime/crates/cranelift/ and read them without hitting a layer you do not understand. You
get there by implementing every important layer yourself, incrementally, with instrumentation you
can switch on at each one.
The secondary goal is a crate you would actually embed in a Rust service. By the end you will have one.
What This Curriculum Is
This is a build-it curriculum. Apache Tez, OpenSearch, and Firecracker teach you to contribute to an existing project. This one — like Terminals, PTYs & Multiplexers — teaches you a domain by making you build a simplified system, inspired by:
- Lua 5.4 — the language, the register VM, the incremental collector, the C API
- LuaJIT — NaN boxing, trace compilation, and what "fast dynamic language" actually costs
- CPython — a stack VM in production, reference counting, and the specializing interpreter
- V8 — inline caches, hidden classes, tiered compilation, deoptimization
- HotSpot — the template interpreter, C1/C2, and the profile-guided philosophy
- Cranelift and Wasmtime — a modern, embeddable code generator
- SQLite's VDBE — the most-shipped bytecode VM on earth, and a masterclass in small, auditable systems software
- Crafting Interpreters — the pedagogical ancestor of this curriculum's first half
You will finish with a Rust crate called ember containing a lexer, a Pratt parser, an AST, a
tree-walking reference interpreter, a bytecode compiler, a disassembler, a stack virtual machine,
tables, closures, upvalues, a tracing garbage collector, metatables, a capability-gated standard
library, a host-controlled module system, an instruction-budget sandbox, a diagnostic engine with
source spans and stack traces, a CLI with a REPL, a benchmark suite, a fuzz harness, and an
experimental Cranelift JIT.
Every piece is small enough to hold in your head.
We do not hide important behavior behind large frameworks. You will write a hand-rolled lexer
before you are allowed to look at logos. You will write a Pratt parser before you are allowed to
look at chumsky or lalrpop. You will build a tracing collector over your own heap before you
are allowed to reach for gc-arena. That ordering is the entire pedagogy.
This curriculum will not hold your hand. Where it names a struct, a function, or an opcode in a
real implementation, it also gives you the command that shows it to you on your machine — because
upstream code moves, and an engineer who quotes a remembered line number instead of running rg is
already wrong.
Learning Priorities
These are the outcomes, in priority order. Everything in the curriculum serves one of them. When you must choose between finishing a feature and understanding a layer, choose understanding.
UNDERSTANDING → CORRECTNESS → READABILITY → USABILITY → OBSERVABILITY → PERFORMANCE
Performance is last on that list, and it is last on purpose. It is not permission to write a slow runtime — Section 7 is entirely about making Ember fast, and you will measure every change. It is a statement that a fast runtime you cannot explain has failed this assignment. Optimization that arrives before understanding is indistinguishable from superstition.
| # | Outcome |
|---|---|
| 1 | Understand exactly what happens between a user typing x = f(1) + 2 and a Value existing. |
| 2 | Understand what a token, an AST, an opcode, a frame, a slot, and a handle each are — and which of them exist at runtime. |
| 3 | Understand why a bytecode VM is faster than a tree walker, and be able to state the reason in terms of memory, not vibes. |
| 4 | Understand how a closure captures a variable that outlives the stack frame it lived in. |
| 5 | Understand why reference counting cannot collect cycles, and what a tracing collector does instead. |
| 6 | Understand how a dynamically-typed field access becomes fast — hidden classes, inline caches, guards. |
| 7 | Understand what a JIT actually does, why it needs guards, and what deoptimization is for. |
| 8 | Understand how a Rust host safely owns a garbage-collected object graph without unsafe. |
| 9 | Understand what a sandbox can and cannot promise, and be able to write the threat model down. |
| 10 | Be able to justify every design decision you made, in writing, against the alternatives you rejected. |
Outcome 10 is enforced. You will maintain docs/adr/ — one short Architecture Decision Record per
significant choice — and docs/learning/, a running journal. They are graded at the capstone.
1. The Complete Runtime Stack
Here is the whole system. Read it top to bottom; every box is a chapter of this curriculum. The double line is the compile time / run time boundary — the single most important line on the diagram, because most confusion about language implementations comes from not knowing which side of it a behavior lives on.
┌──────────────────────────────────────────────────────────────────────────┐
│ SOURCE TEXT │
│ "local x = 10 + 20" bytes in a file, or a line in the REPL │
└───────────────────────────────┬──────────────────────────────────────────┘
│ &str + SourceId
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ LEXER (lexer.rs) §1 ─ chars → tokens │
│ a hand-written scanner. Emits Token { kind, span }. │
│ Owns: keywords, numbers, string escapes, comments, EOF. │
└───────────────────────────────┬──────────────────────────────────────────┘
│ Vec<Token>
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ PARSER (parser.rs) §1 ─ tokens → tree │
│ recursive descent for STATEMENTS, Pratt for EXPRESSIONS. │
│ Owns: precedence, associativity, syntax errors, recovery. │
└───────────────────────────────┬──────────────────────────────────────────┘
│ Ast (Block of Stmt, Expr trees + spans)
▼
┌────────────────────┴────────────────────┐
│ │
▼ ▼
┌────────────────────────────┐ ┌────────────────────────────────────┐
│ REFERENCE INTERPRETER │ │ COMPILER (compiler.rs) §3 │
│ (interp/) §2 │ │ AST → bytecode. Resolves names to │
│ walks the tree directly. │ │ SLOT NUMBERS. Patches jumps. │
│ Slow. Obviously correct. │ │ Builds the constant pool. │
│ NEVER DELETED — it is the │ └───────────────┬────────────────────┘
│ oracle for differential │ │ Chunk { code, consts,
│ testing against the VM. │ │ lines, protos }
└───────────┬────────────────┘ ▼
│ ┌──────────────────────────────────────┐
│ │ DISASSEMBLER (bytecode.rs) §3 │
│ │ Chunk → human-readable listing. │
│ │ `ember disassemble script.ember` │
│ └──────────────────┬───────────────────┘
│ │
══════════════╪══════════════════════════════════════════╪═══ COMPILE TIME ═══
│ │
══════════════╪══════════════════════════════════════════╪═══ RUN TIME ════
│ ▼
│ ┌──────────────────────────────────────┐
│ │ VIRTUAL MACHINE (vm.rs) §3 │
│ │ ┌────────────────────────────────┐ │
│ │ │ loop { fetch; decode; execute }│ │
│ │ └────────────────────────────────┘ │
│ │ ip ── instruction pointer │
│ │ stack ─ Vec<Value>, the operand │
│ │ stack AND the local slots │
│ │ frames ─ Vec<CallFrame> │
│ │ budgets ─ instructions, depth │
│ └──────────────────┬───────────────────┘
│ │
└────────────┬─────────────────────────────┘
│ both produce the SAME Value. That is Lab 12.
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ VALUES (value.rs) §2 │
│ Nil │ Boolean(bool) │ Integer(i64) │ Float(f64) │ GcRef<…> │
│ 16 bytes. Copy. Exhaustively matched. │
└───────────────────────────────┬──────────────────────────────────────────┘
│ handles point into…
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ THE HEAP (heap.rs) §4 │
│ slot table: Vec<Option<HeapObject>> + generations + free list │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │ Table │ │ Closure │ │ Upvalue │ │ EmberStr│ │ UserData │ │
│ │ array + │ │ proto + │ │ open ─▶ │ │interned │ │ host obj │ │
│ │ hash │ │ upvals │ │ closed │ │ │ │ + vtable │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
│ │
│ GARBAGE COLLECTOR: mark from roots → sweep unmarked → account bytes │
│ roots = value stack ∪ frames ∪ globals ∪ open upvalues ∪ host handles │
└───────────────────────────────┬──────────────────────────────────────────┘
│
══════════════════════════════════╪══════════════ THE HOST BOUNDARY ═════════
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ ENGINE (engine.rs) §5 │
│ Engine::new() · execute() · call() · set_global() · get_global() │
│ register_function() · limits() · stats() │
│ marshaling: Rust types ⟷ Value capabilities: what scripts may do│
└───────────────────────────────┬──────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ YOUR RUST APPLICATION capstone │
│ the recommendation policy engine: Rust owns the data and the budget, │
│ Ember owns the business rules. │
└──────────────────────────────────────────────────────────────────────────┘
Read that diagram again in a week. You will not understand all of it now, and you are not supposed to. Its job is to give every later chapter a place to attach.
2. The Signature Move: Trace Everything
The core promise of this curriculum is that the runtime is observable rather than magical.
Every lab contains a section called ## The Trace. It takes one concrete program and shows it in
every representation that exists at that point in the curriculum. Here is what the Trace looks like
by the end of Section 3, for a single line of Ember:
local x = 10 + 20
Characters — what the lexer sees, with byte offsets:
0 9 13
│ │ │
l o c a l x = 1 0 + 2 0
0 1 2 3 4 5 6 7 8 9 …
Tokens — ember tokens x.ember:
# span kind text
0 0..5 Local "local"
1 6..7 Ident "x"
2 8..9 Equal "="
3 10..12 Integer(10) "10"
4 13..14 Plus "+"
5 15..17 Integer(20) "20"
6 17..17 Eof ""
AST — ember ast x.ember:
Block
└── LocalDecl span 0..17
├── name: "x"
└── init: Binary(Add) span 10..17
├── lhs: Literal(Int 10) span 10..12
└── rhs: Literal(Int 20) span 15..17
Bytecode — ember disassemble x.ember:
== chunk: x.ember ==
constants:
[0] 10
[1] 20
offs line op operands comment
0000 1 LOAD_CONST 0 ; 10
0002 1 LOAD_CONST 1 ; 20
0004 1 ADD
0005 1 SET_LOCAL 0 ; x
0007 1 RETURN_NIL
VM stack — ember trace x.ember, one line per instruction:
ip op stack before stack after slots
0000 LOAD_CONST 0 [] [10] [_]
0002 LOAD_CONST 1 [10] [10, 20] [_]
0004 ADD [10, 20] [30] [_]
0005 SET_LOCAL 0 [30] [] [30]
0007 RETURN_NIL [] [] [30]
Runtime value — slot 0 of the current frame holds Value::Integer(30).
Six representations of one line of code. By Lab 11 your own tools print all six, and you will use
them to debug everything that follows. When something goes wrong in Section 4 — a closure captures
the wrong variable, a table drops a key, the collector frees a live object — you will not guess.
You will run ember trace and look.
Note: Notice
ADDtakes no operands andSET_LOCALtakes one. Notice the constant pool holds10and20separately even though a smarter compiler would fold them to30at compile time. NoticeSET_LOCAL 0— the namexis gone; it became a number. Every one of those observations is a chapter.
3. What You Will Build, Section by Section
| § | Section | Milestones | You end able to… |
|---|---|---|---|
| 1 | From Characters to Trees | M1–M2 | evaluate 10 + 20 * 3 → 70, with real spans in error messages |
| 2 | The Reference Interpreter | M3–M4 | run recursive functions, scoped locals, if/while/for/break/return |
| 3 | Bytecode and the VM | M5–M7 | compile to your own instruction set, disassemble it, execute it, and prove the VM agrees with the tree walker |
| 4 | Objects, the Heap, and the Collector | M8–M11 | tables, closures, upvalues, mark-and-sweep GC, string interning, multiple returns, metatables |
| 5 | The Host Boundary | M12–M13 | embed Ember in a Rust program, expose host objects, gate capabilities, cap CPU and memory |
| 6 | Production | M13 | ship diagnostics, a REPL, a test matrix, fuzzing, metrics, and an honest limitations document |
| 7 | Making It Fast | M14 | benchmark honestly, add inline caches and specialized opcodes, and compile one function with Cranelift |
| — | Capstone | — | build the recommendation policy engine, and trace one non-trivial script through every layer |
Thirty labs. Fifteen milestones. Details in the roadmap.
4. What You Will Not Build
Scope discipline is a design skill, so the exclusions are explicit and each has a reason. Several reappear as capstone projects, because "we skipped it" and "you cannot do it" are different claims.
| Excluded | Why | Where it returns |
|---|---|---|
| Coroutines | They turn one call stack into many and touch every layer at once. Adding them after the VM is stable is a far better exercise than designing around them from the start. | Capstone project 4 |
| A register VM | Lua is register-based and it is genuinely better; a stack VM is a much better first VM. You will port and benchmark. | Capstone project 1 |
| NaN boxing | It is a real technique with a real cost in readability and unsafe. You will implement it once you can measure whether it helped. | Capstone project 2 |
| An incremental or generational GC | Mark-and-sweep first; you cannot reason about write barriers before you can reason about tracing. | Capstone project 3, §4 |
| Full Lua compatibility | Ember is Lua-like, not Lua. Goto, __gc, weak tables, _ENV semantics, and the full string-pattern library are out. Every divergence is documented. | appendix/lua-differences.md |
| Threads inside the runtime | One VM per thread, no shared mutable object graph. This is a decision, not an oversight, and it is why Engine is not Sync. | ADR-011 |
| A self-hosting compiler | Fun; teaches you nothing you do not already learn here. | — |
5. Prerequisites
Required:
- Intermediate Rust. You have written a program with lifetimes in it and understood the borrow checker's complaint. You do not need to be expert — this curriculum will teach you Rust, because object graphs are exactly where Rust is hardest, and every fight is annotated.
- Comfort with
cargo,cargo test, and a debugger ordbg!. - Basic familiarity with any dynamic language. If you have never written Lua, that is fine — the warm-up is one evening.
- Willingness to write things down. Predictions, ADRs, journal entries. Non-negotiable.
Not required:
- Compiler theory. No dragon book, no formal grammars beyond what Section 1 teaches you.
- Assembly. Section 7 uses Cranelift precisely so that you learn JIT architecture rather than x86 encoding. You will read the generated code, not write it.
- Prior Lua implementation knowledge. You will read Lua's source after you have written your own version of the thing it does, which is the only order in which that reading is pleasant.
Tooling:
rustc --version # 1.75+ (edition 2021 is what the curriculum assumes)
cargo --version
cargo install cargo-fuzz cargo-criterion cargo-audit # §6 and §7
lua -v # 5.4.x — for the warm-up and differential comparison; brew/apt has it
Time budget: 14–20 weeks at 8–12 hours per week, per the weekly plan. Faster if you have implemented a language before; slower if Section 4 grips you, which it should.
6. The Production Profile
Here is an honest statement of what this runtime is for. Read it now; you will write the expanded version yourself in the threat model.
Ember's intended production profile: embedded policy scripting for trusted to partially-trusted scripts inside a Rust service.
"Partially trusted" means: written by your own engineers, your customers' engineers, or an internal tool — people who may write an infinite loop or allocate a gigabyte by accident, and whom the runtime should stop. It does not mean anonymous internet-submitted code.
Ember is not suitable for hostile multi-tenant execution. It has no memory isolation beyond process boundaries, no side-channel mitigations, no fair scheduling between scripts, and a host function surface whose safety is the host author's responsibility. If you need that, you need a process boundary, a WASM runtime with a real sandbox, or a microVM — not an in-process interpreter.
Being able to write that paragraph, and to know which sentence in it is doing the load-bearing work, is one of the outcomes of this curriculum. "Production-ready" is not a feeling. It is a checklist plus a scope statement, and Section 6 builds both.
7. How to Work Through This
- Read the mental model before writing any code. It is Milestone 0 and it costs you one evening. Everything after it is easier.
- Never skip the Predict-First questions. Write the prediction down, with a confidence level. The gap between prediction and observation is where learning happens; a prediction kept in your head silently rewrites itself the moment you see the answer.
- Run every Trace yourself. Reading a trace in this book is worth about a tenth of producing one from your own code.
- Write the ADR at the moment of the decision, not later. A reconstructed rationale is fiction.
- Do not delete the tree-walking interpreter. You will want to, around Lab 11, when the VM works and the tree walker looks like dead weight. It is not dead weight; it is the oracle. Lab 12 explains why, and it is the most valuable lab in Section 3.
- Benchmark before you optimize, and after. Section 7 refuses to accept an optimization without a baseline measurement, a hypothesis, a post-change measurement, and a stated tradeoff.
8. Questions You Will Be Able to Answer
Print this list. Come back to it at the capstone and answer each one out loud, without notes. If you can, the curriculum worked.
- How does a programming language execute? Name every representation between text and result.
- What is bytecode, and why is it faster than walking a tree? Answer in terms of cache lines and pointer chasing, not "it's compiled."
- What is a call frame? What is in it, who allocates it, and when does it die?
- How does a closure keep a variable alive after the function that declared it has returned?
- What is an upvalue, and what does "closing" one mean?
- Why can reference counting not collect a cycle, and what does a tracing collector do instead?
- What are the roots of a garbage collector, and what happens if you forget one?
- How does a hash table become an object system? What does Lua's array part buy?
- Why is
article.semantic_scoreexpensive in a dynamic language, and how does an inline cache fix it? - What is a guard? What is deoptimization, and why does a JIT need both?
- Why must a garbage collector and a JIT know about each other?
- How does a Rust host own a cyclic, garbage-collected object graph without
unsafeor leaks? - What can a sandbox promise, and what can it not?
- When should you embed a scripting language, and when is TOML enough — or plain Rust better?
That last question is the one your future colleagues will actually ask you, and the capstone answers it honestly, including the cases where the answer is "don't."
Next: Overview & Prerequisites — then Milestone 0: The Runtime Mental Model, which is where the work starts.