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
1Understand exactly what happens between a user typing x = f(1) + 2 and a Value existing.
2Understand what a token, an AST, an opcode, a frame, a slot, and a handle each are — and which of them exist at runtime.
3Understand why a bytecode VM is faster than a tree walker, and be able to state the reason in terms of memory, not vibes.
4Understand how a closure captures a variable that outlives the stack frame it lived in.
5Understand why reference counting cannot collect cycles, and what a tracing collector does instead.
6Understand how a dynamically-typed field access becomes fast — hidden classes, inline caches, guards.
7Understand what a JIT actually does, why it needs guards, and what deoptimization is for.
8Understand how a Rust host safely owns a garbage-collected object graph without unsafe.
9Understand what a sandbox can and cannot promise, and be able to write the threat model down.
10Be 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 ADD takes no operands and SET_LOCAL takes one. Notice the constant pool holds 10 and 20 separately even though a smarter compiler would fold them to 30 at compile time. Notice SET_LOCAL 0 — the name x is gone; it became a number. Every one of those observations is a chapter.


3. What You Will Build, Section by Section

§SectionMilestonesYou end able to…
1From Characters to TreesM1–M2evaluate 10 + 20 * 3 → 70, with real spans in error messages
2The Reference InterpreterM3–M4run recursive functions, scoped locals, if/while/for/break/return
3Bytecode and the VMM5–M7compile to your own instruction set, disassemble it, execute it, and prove the VM agrees with the tree walker
4Objects, the Heap, and the CollectorM8–M11tables, closures, upvalues, mark-and-sweep GC, string interning, multiple returns, metatables
5The Host BoundaryM12–M13embed Ember in a Rust program, expose host objects, gate capabilities, cap CPU and memory
6ProductionM13ship diagnostics, a REPL, a test matrix, fuzzing, metrics, and an honest limitations document
7Making It FastM14benchmark 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.

ExcludedWhyWhere it returns
CoroutinesThey 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 VMLua 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 boxingIt 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 GCMark-and-sweep first; you cannot reason about write barriers before you can reason about tracing.Capstone project 3, §4
Full Lua compatibilityEmber 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 runtimeOne 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 compilerFun; 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 or dbg!.
  • 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

  1. Read the mental model before writing any code. It is Milestone 0 and it costs you one evening. Everything after it is easier.
  2. 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.
  3. Run every Trace yourself. Reading a trace in this book is worth about a tenth of producing one from your own code.
  4. Write the ADR at the moment of the decision, not later. A reconstructed rationale is fiction.
  5. 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.
  6. 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_score expensive 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 unsafe or 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.