Odin Speaks Odin ยท Appendix G

Appendix G: Extending Muspel

Appendix A's own "What's deliberately absent" section names every feature this book's code/ chose not to build, and ยง21.8 explains why each one was cut. This appendix is the other half of that promise: for a reader who wants to actually add one of them, here is exactly which of muspel/'s thirteen files each kind of extension touches, worked through for a series of concrete cases of increasing (and, later, decreasing) size. G.1 is a complete, buildable change a reader can make; G.2 (%), G.4 (growable lists), G.5 (closures), G.6 (browser module loading), G.7 (multi-binding var), and G.8 (const) are worked walkthroughs of changes that already ship in code/; G.3 is a sketch, honestly labelled as one, for the reasons ยง21.8 already gave for not building it in this book. G.7 and G.8 are the instructive pair at the end: one feature that ripples into every pass, and one that ripples into none of the runtime at all.

Every one of muspel/'s passes (lexer, parser, resolver, compiler, VM) is an exhaustive Odin switch over a closed union or enum. That is the load-bearing property for all three extensions below: forget a case anywhere a new token, AST node, or opcode needs one, and odin check refuses to compile until every switch accounts for it. Extending Muspel is mechanical in exactly the way that matters: the compiler itself tells you everywhere you forgot to look.

G.1 The smallest extension: a for loop

Appendix A already calls this "straightforward sugar over the same whileStmt production," and that is the whole implementation: a for loop needs no new AST node, no new opcode, and no change to the resolver, compiler, VM, or tree-walker at all. It is entirely a parser-level rewrite into Stmt_While and Stmt_Block, the same two node types Chapter 3 already built.

1. token.odin: one new keyword.

Token_Kind :: enum {
	// ...
	While,
	For,          // new
	Return,
	// ...
}
keyword_kind :: proc(lexeme: string) -> (Token_Kind, bool) {
	switch lexeme {
	// ...
	case "for":
		return .For, true
	// ...
	}
}

2. parser.odin: one new grammar production, forStmt ::= "for" "(" (varDecl | exprStmt)? ";" expression? ";" expression? ")" block ;, and one new function alongside parse_while_stmt, dispatched from the same statement switch section 3.4 already built:

parse_for_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
	line := previous(p).line
	consume(p, .Left_Paren, "expected '(' after 'for'")

	init: Stmt
	if !check(p, .Semicolon) {
		init = parse_var_decl(p, allocator) // reuses Chapter 3's own varDecl parser
	} else {
		advance(p) // the bare ';'
	}

	cond: Expr
	if !check(p, .Semicolon) {
		cond = parse_expression(p, allocator)
	} else {
		lit := new(Expr_Literal, allocator)
		lit^ = Expr_Literal{line = line, value = true} // no condition means "loop forever"
		cond = lit
	}
	consume(p, .Semicolon, "expected ';' after loop condition")

	post: Expr
	if !check(p, .Right_Paren) {
		post = parse_expression(p, allocator)
	}
	consume(p, .Right_Paren, "expected ')' after for clauses")

	body := parse_block(p, allocator)
	if post != nil {
		post_stmt := new(Stmt_Expr, allocator)
		post_stmt^ = Stmt_Expr{line = line, expr = post}
		body.stmts = append_elem(body.stmts, Stmt(post_stmt), allocator)
	}

	while_node := new(Stmt_While, allocator)
	while_node^ = Stmt_While{line = line, cond = cond, body = body}

	if init == nil {
		return while_node
	}
	wrapper := new(Stmt_Block, allocator)
	wrapper^ = Stmt_Block{line = line, stmts = {init, while_node}}
	return wrapper
}

That is the entire feature. resolve_program, compile_program, vm_run, and eval_stmt never see a for at all; by the time any of them runs, for (var i = 0; i < 10; i = i + 1) { ...} has already become the exact Stmt_Block{ Stmt_Var, Stmt_While{ ... } } tree a reader would have written out by hand under the old grammar. This is the general shape of the cheapest kind of extension: if a construct's semantics already exist under some other syntax, the extension is a parser rewrite and nothing more.

G.2 A full vertical slice: a new operator (%)

A for loop touched one file end to end. A genuinely new operation, one with no existing opcode to reuse, touches every stage: lexer, parser, opcode table, compiler, both runtimes (Chapter 8's tree-walker and Chapter 13's VM are separate, complete implementations of the same language, and ยง9 never retired the first one), and the disassembler. Walking modulo (%) through all seven is the representative case for "add a new binary operator."

Unlike G.1, % now ships in the core (code/odin-speaks-odin/muspel/): the walkthrough below is the completed change that put it there, file by file, not a proposal. The snippets match the committed source, so you can read this section against the real files.

1. token.odin: Percent alongside Star/Slash.

2. lexer.odin: one new single-character case, next to '*' and '/':

case '%':
	return make_token(l, .Percent, "%")

3. parser.odin: one line in infix_binding_power, at the same precedence as *// (section 4.2's own table is exactly this: one row per operator, not one function per level):

case .Star, .Slash, .Percent:
	return 6, true

Nothing else in the parser changes: the parse_precedence loop that builds Expr_Binary nodes already treats every non-&&/|| operator identically, so % falls out of the same generic path +, -, *, and / already take.

4. opcodes.odin: a new Op_Code case, and the two switches that must stay exhaustive with it, op_name (the compiler enforces this one; miss it and odin check fails) and, because Mod is a bare no-operand instruction exactly like Add, no change at all to op_operand_bytes's default case:

Op_Code :: enum u8 {
	// ...
	Mul,
	Div,
	Mod,          // new
	Negate,
	// ...
}
case .Mod: return "MOD"

5. compiler.odin: one case in compile_expr's ^Expr_Binary branch, next to .Slash:

case .Percent:       chunk_write_op(c.chunk, .Mod, node.line)

6. vm.odin: one case in the dispatch loop, next to .Div, reusing as_number_pair exactly as Add/Sub/Mul/Div already do (section 13.3's own helper, built precisely so every arithmetic opcode shares one error path for "one of these wasn't a number"):

case .Mod:
	b := pop(&vm.stack); a := pop(&vm.stack)
	if an, bn, ok := as_number_pair(vm, a, b); ok {
		if bn == 0 {
			runtime_error(vm, "modulo by zero")
		} else {
			append(&vm.stack, Value(math.mod(an, bn)))
		}
	}

(math.mod, from core:math, gives floating-point modulo the same way Div already handles non-integer operands; a new import "core:math" line joins vm.odin's existing core:fmt/ core:strings/core:time imports.)

7. treewalk.odin: the tree-walker is a second, independent implementation of the same semantics (Chapter 8, kept deliberately alive past Chapter 9 as the VM's own oracle, section 7.5), so it needs the identical case in eval_binary, next to its own .Slash:

case .Percent:
	return math.mod(left.(f64), right.(f64))

Skipping this file is the one mistake odin check will not catch: eval_binary switches on Token_Kind, not a closed union, and Odin's #partial switch (used here because the tree-walker never needs to handle every token kind, only the operator ones) does not force exhaustiveness the way opcodes.odin's op_name does. A % that works in tools/musc (which runs scripts through the VM) but silently returns the wrong thing wherever code still runs through the tree-walker is exactly the class of bug section 14.5's own disassembler exists to catch early: disasm_chunk printing MOD at the instruction you expect is the fast way to confirm the compiler side landed correctly, before ever suspecting the tree-walker was missed.

Seven files, no new AST node (Expr_Binary already carries an arbitrary Token_Kind in op), no new Value variant. This is the shape every other missing arithmetic or comparison operator (bitwise &/|, integer division, exponentiation) would take: same seven files, same pattern, different opcode name.

G.3 The one that's a bigger commitment: a map, and the heap it needs

The original candidate for this section was field and index access, obj.field and list[i]. That is no longer the example, because the book itself built it: Chapter 5 added struct field access and Chapter 6 added array indexing, which is why Appendix A's grammar now shows a full postfix ::= primary ( call | fieldAccess | index )* rule rather than a bare call loop. Concretely, that meant the Expr_Field_Access/Expr_Field_Assign and Expr_Index/Expr_Index_Assign AST nodes (ast.odin), the Get_Field/Set_Field and Get_Index/Set_Index opcodes (opcodes.odin), and two new Value variants, ^Struct_Instance and ^Array_Instance, so the union is now:

Value :: union {
	f64,
	string,
	bool,
	^Func_Proto,
	Builtin,
	^Struct_Instance,
	^Array_Instance,
}

Those two container types could be added without a garbage collector because of the arena (Chapter 10): every struct and array a VM instance builds lives in one bump-allocated buffer, reclaimed whole between runs, never per object. That works precisely because Muspel's containers are acyclic and short-lived relative to a run.

What is still a genuinely bigger commitment is the value type that breaks both of those assumptions: a map (dictionary) with dynamic string keys, or any long-lived mutable object that must be created and freed repeatedly over a single long run, not merely reset between runs. This is the same honest cut ยง21.8 named for garbage collection, and G.4's growable list already walks up to its edge: append grows a length, but the struct each new element points at is arena-allocated and never freed, so an unbounded stream of them exhausts the arena, which is why sparks pools its particles instead.

The parser side of a map is small, the same postfix/literal work G.2 and Chapters 5 and 6 already show. What it needs underneath is the real design work:

  • A backing store the arena cannot be. A map whose keys are added and removed across a long run needs individual reclamation, which a bump allocator has no concept of. That is a general-purpose heap, and the moment one holds mutable, shared references the question ยง21.8 raised returns in full: whether two Script_Runner instances (Chapter 17) could end up holding the same map by reference, and whether reachability now has to be tracked at all, that is, whether Muspel finally needs the garbage collector it was designed to avoid.
  • Hashing and equality on Value. values_equal (value.odin) already defines structural equality; a map keyed by arbitrary Values needs a matching hash, and a decision about whether keys compare by value (like the struct/array equality Chapter 13 chose) or by identity.

None of this is unsolvable, and it is exactly the size of decision Lua's own table type or Wren's map/list types represent: real design work, not the mechanical file-by-file walk G.1, G.2, and G.4 were. That is precisely why ยง21.8 named it a deliberate cut. A reader who wants to build it has the lay of the land here, which files change and which question is the hard one, and the same generous pointer ยง21.8 gives toward Crafting Interpreters and Lua's own reference manual for a complete, production version.

Engine era (PLAN.md M5): a map that skips the hard question. The engine ships a map in code/, and it does so precisely by declining the general-purpose heap this section says a map "needs." A Map_Instance (value.odin, the third new arena-backed Value variant after ^Struct_Instance and ^Array_Instance) is a fixed-capacity, open-addressed table that map(cap) sizes once, exactly as list(cap) reserves an array (G.4): m[key] reuses the existing Get_Index/Set_Index opcodes, has/keys/len/remove are builtins, keys are strings or numbers, and a removed slot becomes a tombstone that a later insert reuses. Because the capacity is fixed and slots are recycled, a map whose keys churn every frame never allocates again after it is created, so it needs no individual reclamation and therefore no heap and no GC, the bump arena holds it exactly like every other value. That is the whole trick, and its cost is honest: the capacity is chosen up front and a map that truly needs to grow without bound still wants the heap above. Hashing reuses values_equal's structural equality for lookup but hashes only strings and numbers (the practical key types); the by-value-vs-identity question this section raised is settled by keying on those primitives, where the two coincide. The arena itself grew a companion answer: it is larger by default and raisable per game via _config().arena_kb, a bigger ceiling rather than reclamation, the deliberate "configurable arena, keep pooling" trade M5 chose over the collector. --walk does not run maps: Map_Instance is a bytecode-VM Value, and the tree-walker keeps its own Walk_Value closure-and-container world, the same engine-era split G.5 notes for closures.

Engine era GC (muspel/gc.odin): accurate root-tracing copying garbage collection. In addition to the configurable arena and tombstone-recycling map above, code/ includes a fully functional, precise copying garbage collector pass (vm_collect_garbage in muspel/gc.odin). When gc: true is passed in _config() (or when calling the gc() builtin), Muspel automatically tracks all active root references across the execution stack, global variables, registered host callbacks, and active stack frames/upvalues. During collection, surviving heap objects (Struct_Instance, Array_Instance, Map_Instance, and Closure) are recursively copied into a freshly allocated Value_Arena, updating forwarded pointers to preserve reference identity and handle cycles. Unreachable allocations are reclaimed in a single pass without manual object pooling.

G.4 Growable lists: a live length on the fixed array

Chapter 6 built arrays deliberately fixed-capacity, and ยง6.1 gives the reasons: no per-frame reallocation, no hidden allocation, a size fixed once at construction. That is the right default, and it stays the default. But a game engine's scripts eventually want a collection whose length changes, a bullet list that grows and shrinks, without giving up the arena discipline that makes the fixed array cheap. The core now carries exactly that, as a small, self-contained extension of the Chapter 6 array, and it is worth walking because it shows the other shape an extension can take: not a new syntax or opcode, but a new capability bolted onto an existing runtime value plus a handful of builtins.

The whole idea is to separate an array's backing capacity from its live length:

Array_Instance :: struct {
	values: []Value, // backing capacity, sized once, never reallocated
	count:  int,     // live length, always <= len(values)
}

An array literal or list_new fills the whole backing, so count == len(values) and it behaves exactly like Chapter 6's fixed array, unchanged. The new list(cap) builtin instead reserves cap slots with count 0, and three more builtins move count within that backing without ever allocating again: append(xs, v) writes at count and increments it, pop(xs) returns the last element and decrements, and remove(xs, i) swap-removes (the last element drops into slot i). The one discipline is that every reader of an array, len, indexing, for-in, equality, printing, must read count, never len(values), so the reserved-but-unused tail stays invisible; for-in gets this for free, because it already bounds its loop with a runtime len() call.

Two honest limits, both consistent with what this book already taught:

  • These builtins are VM-only, registered in vm_init/resolver_init but not the tree-walker, exactly the status wait/yield have had since Chapter 15. Chapter 8's Walk_Array_Instance is a separate type and stays fixed-length; growth is a bytecode-VM capability.
  • append grows a length, not a heap. The value arena (Chapter 10) is still never reclaimed mid-run, so append(xs, make_thing()) on every frame still exhausts it: the struct each call allocates is never freed. The pattern that stays inside the arena is to pool the objects, allocate a fixed set once, and let the growable list shuffle references between a free list and a live list. The sparks game (code/'s own engine/games/sparks/) does exactly this, which is the honest use of growable lists until the reclaimable heap ยง21.8 names actually exists.

G.5 The one the book said it would not do: closures (engine era)

Chapter 4 drew a firm line: functions are declared at the top level only, and they capture nothing. The parser enforced it (functions can only be declared at the top level), Chapter 12 leaned on it (a compiled prototype already *is* a complete, callable value, with nothing left to attach), and examples/nested_func.mus exists precisely to show the parse error. That line was the right one for teaching the language: closures are the single largest piece of runtime machinery a bytecode VM adds, and deferring them kept the book's VM small enough to read.

The engine era (the engine/ host and its PLAN's milestone M6) reverses that cut, because a game engine really does want an on_death handler that closes over the entity it belongs to. This is the one extension in this appendix that changes muspel/ beyond a mechanical switch-case: it touches the parser (nested func declarations and anonymous func(...) expressions), the AST (Expr_Func), the resolver (binding a nested function's name, resolving free variables across function boundaries), the compiler (upvalue resolution and a Make_Closure emission), the VM (a ^Closure value, the Make_Closure/Get_Upvalue/Set_Upvalue opcodes, and a closure field on Frame), and the tree-walker (whose environment-chain model already supported closures, needing only the new Expr_Func node). It is deliberately by-value capture, not the full open/closed upvalue cells Lua uses: a closure snapshots the values of the variables it reads at the moment it is created. Because Muspel structs and arrays are references, capturing an entity by value still shares its mutable state, which is the whole point; only a captured primitive later reassigned in the enclosing scope behaves differently (the closure keeps its snapshot). That single simplification is what keeps closures compatible with the resumable VM (Chapter 15) with no open-upvalue-into-a-suspended-stack hazard to reason about.

Two honest consequences to record, since this appendix is about extending the real code and not a hypothetical:

  • examples/nested_func.mus now succeeds. Chapter 4's shown output (a functions can only be declared at the top level syntax error) no longer matches what the current code/ prints; the file runs and prints 1. The book's narrative still stands as the history of the language's design; the engine era simply moved the line Chapter 4 drew. This is the accepted muspel/-exceeds- the-prose divergence the engine work runs under, called out here rather than left as a surprise.
  • Make_Closure allocates in the value arena, exactly like a struct or array literal, and the arena is never reclaimed mid-run. So building a closure every frame (inside _process/_draw) exhausts it just as append(xs, make_thing()) does (ยงG.4's own warning). Create closures in _ready, or once per entity when the entity is spawned, and store them; a handler captured once and called many times costs nothing further.

G.6 Browser module loading: the host half of Chapter 20 (engine era)

Chapter 20 built Muspel's module system: import "path" as alias, an import graph walked in dependency order, cycles caught, each module resolved in its own resolver and compiled once and shared. Almost all of that lives in one host-agnostic file, muspel/modules.odin, which only ever consumes an already-parsed graph. The one piece Chapter 20 deliberately left outside muspel/ was reading an imported .mus file off disk: that needs core:os, and core:os does not exist on -target:js_wasm32, the same constraint ยง21 and disasm.odin already live under. Native musc supplies it in tools/musc/module_loader.odin; the browser could not, so before the engine era a browser game was a single file. This section is the browser's own answer, the host half that lets engine/games/<name>/main.mus write import "star.mus" and have it work (PLAN.md 3.7's "a main.mus plus imported modules"). It is an engine-era extension in the exact sense G.3-G.5 are: almost entirely host code, with muspel/ itself barely touched.

The two small muspel/ additions. The graph logic was already portable, so only two things had to change to fit a host that fetches rather than reads. First, resolve_module_graph gained a host_builtins parameter: each module gets a fresh resolver that knows only the language's own builtins, so a module calling an engine native (fill_poly, draw_text) would otherwise be told the native is undefined. This is the same list compile_source_reporting already takes for the single-file path (Chapter 16's registration, resolve-time half); native musc passes nil, unaffected. Second, a new run_dependency_modules runs every module in the order except the entry, leaving the entry for the host to compile and drive through its own lifecycle. musc run uses run_module_graph, which runs the entry too and forgets it; an engine cannot, because the entry defines _config/_ready/_process/_draw it must call every frame for the life of the game. Splitting the two apart is the whole difference between a one-shot CLI and a long-lived host.

The protocol, because a fetch is async. WASM cannot block on a fetch, so the loader cannot be one call the way engine_load_game was. It is a short conversation JS drives (engine/src/modules.odin):

  1. engine_module_reset() starts a fresh graph.
  2. For each file, JS writes its key and source into shared buffers and calls engine_module_add, which lexes and parses it into a store and returns how many imports it declares (or a sentinel on a syntax error, already drawn on the canvas). The entry is parsed with the standard library folded in (parse_with_stdlib, so wait/wait_until resolve inside it); an imported module is not, exactly the split the native loader makes.
  3. engine_module_import_path(i) hands each of those import paths back so JS can fetch any it has not seen, looping to step 2 until the graph is closed.
  4. engine_run_modules(entry) orders the graph, resolves it (host builtins included), compiles the entry, and makes it the running game through the same activate_game a single-file load uses, only with the dependency modules run first.

JS walks the graph breadth-first from main.mus; the fetch and the filesystem-shaped recursion the native loader does with core:os become a queue in the page. Keys are import paths exactly as written ("star.mus"), which for the engine's flat one-directory games are also their filenames. The importer-relative path cleaning tools/musc/module_loader.odin does is a deliberate scope cut here, matching how every shipped game keeps its modules side by side.

The gotcha, recorded because it was a real bug. Running the dependency modules to completion leaves the VM's status at .Done, and vm_resume is a deliberate no-op once the status is .Done or .Error (section 15.5's own suspend/resume contract). The first version pushed the entry chunk straight after the dependencies and called vm_resume, which returned immediately without running the entry's top level: none of its functions or globals were defined, the game loaded with no error and drew nothing. The fix is one line, vm.status = .Running before the entry, the very reset run_module_graph already makes before each of its own scripts. A single-file load never hit it, because a fresh VM is already .Running; only running dependencies first exposed it.

Two limits, both inherited honestly. An import exposes a module's functions, structs, and enums, never its top-level vars (Chapter 20's qualified-imports design, resolve_module_graph copies exactly those three tables). That is why the worked example, engine/games/embers/palette.mus, split out of Embers' main.mus, is three pure lookups (color_for, kind_name, radius_for) keyed only on a kind number: a module that reached back into the game's pooled entity list or director state could not be one. And a load failure JS detects on its own side, a module that will not fetch or one over the byte budget, is surfaced on the canvas by engine_report_load_error, so a broken import is as visible as the syntax, cycle, and resolve errors the engine already overlays, rather than only reaching the console. The smallest end-to-end demonstration is engine/games/modtest/ (a main.mus importing a star.mus); Embers' palette.mus is the same mechanism used in earnest.

G.7 Several bindings per var: a new statement node, and the switches that find you

The extensions so far have added a token (G.2), a builtin (G.4), or a whole host protocol (G.6). This one adds a new AST statement node, which is the first time the exhaustive-switch property does real work for you rather than just being satisfied. The feature is small and purely syntactic: let one var keyword introduce several comma-separated bindings, so

var x = 80, y = 80, vx = 190, vy = 140, size = 64

means exactly what the five separate var lines mean. It ships in code/.

The whole feature is a parser-side desugaring: var a = 1, b = 2 is nothing more than var a = 1 followed by var b = 2, run in order in the same scope. The only question is how one call to parse_var_decl, which must return a single Stmt, hands back two. The answer is a new statement node that carries a list.

Design decision: a multi-binding var desugars to a new Stmt_Multi node, not to a Stmt_Block. Alternatives considered: wrap the bindings in the Stmt_Block the language already has, since it too holds a []Stmt. Trade-off: a Stmt_Block opens a new lexical scope (begin_scope/end_scope in the resolver, a depth bump in the compiler), so var a = 1, b = 2 would declare a and b inside a nested scope that closes immediately, and neither name would survive to the next line: the exact opposite of what the syntax promises. Stmt_Multi is deliberately the scope-free sibling of Stmt_Block: same shape, { line, stmts }, but every pass processes its children in the current scope, adding no frame.

1. ast.odin: the node, and one line in the Stmt union.

Stmt_Multi :: struct {
	line:  int,
	stmts: []Stmt,
}
Stmt :: union {
	// ...
	^Stmt_Import,
	^Stmt_Multi,   // new
}

The moment that union line lands, odin check stops compiling and starts naming every switch node in s that now has an unhandled case. There are five, and working through the list is the implementation: the compiler is a to-do list that cannot be forgotten.

2. parser.odin: parse_var_decl loops over the comma-separated bindings instead of parsing one.

parse_var_decl :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
	line := previous(p).line

	decls := make([dynamic]Stmt, allocator)
	for {
		name_tok := consume(p, .Ident, "expected a variable name after 'var'")
		init: Expr
		if match(p, .Assign) do init = parse_expression(p, allocator)
		node := new(Stmt_Var, allocator)
		node^ = Stmt_Var{line = name_tok.line, name = name_tok.lexeme, init = init, depth = -1, slot = -1}
		append(&decls, node)
		if !match(p, .Comma) do break
	}
	consume_statement_end(p, "expected ';' after variable declaration")

	if len(decls) == 1 do return decls[0]      // the common case is unchanged
	multi := new(Stmt_Multi, allocator)
	multi^ = Stmt_Multi{line = line, stmts = decls[:]}
	return multi
}

The subtle correctness point is why the loop's match(p, .Comma) is unambiguous. An initialiser can itself contain commas, var a = f(1, 2), b = [3, 4], but every one of those commas is consumed inside parse_expression (by the call-argument, array-literal, and struct-literal parsers) before it returns. Any comma still sitting there when parse_expression hands control back is therefore a binding separator, never part of the expression. Note too that a single binding still returns a bare Stmt_Var: the new node exists only when it is actually needed, so nothing downstream sees a Stmt_Multi for the overwhelmingly common one-binding var.

3. resolver.odin: two touches. The hoisting pass's "nothing to hoist" case gains ^Stmt_Multi (a multi-binding var hoists nothing, exactly as a single one doesn't), and resolve_stmt gains a case that resolves each child in the current scope:

case ^Stmt_Multi:
	for child in node.stmts {
		resolve_stmt(r, child, allocator)
	}

Because the children are resolved in order in the same scope, a later binding may refer to an earlier one: var a = 1, b = a + 1 resolves a before it resolves b's initialiser, so b sees it.

4. compiler.odin: compile_stmt gains the mirror case, emitting each child's bytecode in turn.

case ^Stmt_Multi:
	for child in node.stmts {
		compile_stmt(c, child)
	}

There is nothing multi-binding-specific in the emitted code: each child Stmt_Var compiles to the same Define_Global (top level) or local slot (inside a block) it would have on its own line. The desugaring is complete by the time the compiler runs; it just walks the list.

5. treewalk.odin: exec_stmt gains the same shape, threading the return signal through for uniformity even though a var list can never contain a return:

case ^Stmt_Multi:
	for child in node.stmts {
		sig, val := exec_stmt(interp, env, child)
		if sig == .Return do return sig, val
	}

6. tools/musc/parse_cmd.odin: the case you would have forgotten. The four muspel/ passes are where a language author's attention naturally goes, but musc parse's AST pretty-printer is a fifth exhaustive switch node in s, and odin check refuses to build until it too handles the node:

case ^muspel.Stmt_Multi:
	fmt.println("Multi")
	for stmt in node.stmts do print_stmt(stmt, depth + 1)
}

That last one is the whole point of the exercise. Nothing about Stmt_Multi is conceptually hard, it is five near-identical "process each child" cases, but finding all five by hand, across muspel/ and tools/, is exactly the bookkeeping a #partial switch or a default case would have let you skip, and skipping it is how a printer silently emits nothing for a new node three refactors later. The exhaustive switch turned a design decision (no #partial anywhere a new node must be handled) into a compiler that hands you the checklist. ./run.sh run examples and ./run.sh run --walk examples agree on a multi-binding var, and ./run.sh parse shows the Multi node wrapping its Var children, precisely because none of the five switches could be left half-done.

G.8 A const keyword: a whole feature the runtime never hears about

G.7 added a node that every pass had to handle. This one is the mirror image: a language feature the compiler, the VM, and the tree-walker are all completely unaware of. const bg = "#0b0e17" declares a binding exactly like var, except the resolver refuses any later assignment to it:

const bg = "#0b0e17"
bg = "#ffffff"        // resolve error: cannot assign to constant 'bg'

Design decision: const is enforced statically, at resolve time, and has no runtime representation at all. Alternatives considered: a distinct runtime value or a "read-only" flag the VM checks on every store, so an assignment to a constant fails when it executes. Trade-off: a runtime check costs a branch on every write and only catches the violation when (and if) that line runs, exactly the "wherever in a running script the bug happens to execute" failure mode Chapter 7 built the resolver to escape. Enforcing it in the resolver catches every constant violation before the program runs, for free, and leaves the compiler and both interpreters literally unchanged: a constant compiles, stores, and loads identically to a variable. Immutability here is a promise the resolver keeps, not a thing the running program carries. Note the scope of the promise: const fixes the binding, not the value it refers to, so const xs = [1, 2, 3] still allows xs[0] = 9, the same line JavaScript's const and Java's final draw.

1. token.odin: one keyword, exactly as G.1's for and G.2's %.

Var,
Const,   // new
Func,
case "const":
	return .Const, true

2. ast.odin: no new node. A constant is a Stmt_Var with one extra field, because at runtime it is a variable; only the resolver treats it differently.

Stmt_Var :: struct {
	line:     int,
	name:     string,
	init:     Expr,
	depth:    int,
	slot:     int,
	is_const: bool,   // new
}

3. parser.odin: parse_var_decl gains an is_const parameter, defaulted so no existing caller changes, and the two declaration dispatchers claim const the same way they claim var:

if match(p, .Var)   do return parse_var_decl(p, allocator)
if match(p, .Const) do return parse_var_decl(p, allocator, true)

Inside parse_var_decl, const differs from var in exactly one rule: an initialiser is mandatory, because a name that can never be assigned is useless without a value.

if match(p, .Assign) {
	init = parse_expression(p, allocator)
} else if is_const {
	parse_error(p, name_tok, "a constant must be initialised, e.g. `const name = value`")
}
// ... Stmt_Var{ ..., is_const = is_const }

Because the parameter defaults to false and the comma loop from G.7 is reused untouched, const a = 1, b = 2 works for free: it is the multi-binding path with is_const threaded through.

4. resolver.odin: this is where the whole feature actually lives, in three small touches.

The binding record and the global table both learn to carry the flag. The global table already mapped a name to a bool; that bool now means is_const (it was previously always true and read only for membership), so the one place that read it as a value, declare's redeclaration check, switches to the comma-ok membership form:

Local :: struct { slot: int, defined: bool, is_const: bool }   // + is_const

declare :: proc(r: ^Resolver, line: int, name: string, is_const := false) -> int {
	if len(r.scopes) == 0 {
		if _, exists := r.globals[name]; exists {              // was: if r.globals[name]
			resolve_error(r, line, fmt_undeclared(name, "is already declared"))
		}
		r.globals[name] = is_const                             // was: = true
		return -1
	}
	// ...
	scope[name] = Local{slot = slot, defined = false, is_const = is_const}
	return slot
}

resolve_stmt's Stmt_Var case passes the flag through (declare(r, node.line, node.name, node.is_const)), and the actual enforcement is four lines added to the Expr_Assign case, backed by one helper that walks scopes the way resolve_name does so the two agree on which binding is meant:

name_is_const :: proc(r: ^Resolver, name: string) -> bool {
	for i := len(r.scopes) - 1; i >= 0; i -= 1 {
		if local, ok := r.scopes[i][name]; ok do return local.is_const
	}
	if is_const, ok := r.globals[name]; ok do return is_const
	return false
}
case ^Expr_Assign:
	resolve_expr(r, node.value, allocator)
	if name_is_const(r, node.name) {
		resolve_error(r, node.line, fmt.tprintf("cannot assign to constant '%s'", node.name))
	}
	node.depth, node.slot = resolve_name(r, node.line, node.name)

And that is the entire feature. compiler.odin, treewalk.odin, and vm.odin are not touched, and neither is tools/musc (the parse_cmd printer already prints a Stmt_Var, and a constant is one). That is the striking contrast with G.7: multi-binding var added a node and so rippled into five switches; const adds a static rule and ripples into none of the runtime, because a promise the resolver checks before running costs the runtime nothing. ./run.sh check reports cannot assign to constant 'bg' on the reassignment above without ever executing a line, and ./run.sh run and ./run.sh run --walk behave identically to the var version for any program that keeps its promise.

What this appendix is actually demonstrating

G.1, G.2, and G.4 are not hypothetical: G.1 compiles cleanly against this book's code/ as a change a reader can make, and G.2 (%) and G.4 (growable lists) already ship in code/, each changing only the files named above, with no other part of muspel/, embers/, or tools/musc needing to know an extension happened at all, the same "the host never needs to change for a language-level extension" property Chapter 16's embedding boundary was built to protect. G.3 draws the line honestly at the point where extending Muspel stops being a mechanical exercise in exhaustive-switch bookkeeping and starts being real language design, exactly the line ยง21.8 already drew for closures, garbage collection, and a static type system. G.5 then crosses one of those lines on purpose: closures ship in code/ too now, the engine era's one genuinely cross-cutting extension, with by-value capture the deliberate simplification that keeps it tractable. G.6 is the other engine-era shape entirely: not a language feature at all but a host one, browser module loading, which reuses Chapter 20's already-portable graph code untouched and adds only the fetch-driven protocol a filesystem-less host needs, the clearest proof that the muspel/-does-no-I/O boundary Chapter 16 drew keeps paying off. G.7 is the smallest of the shipped language extensions and the best illustration of the appendix's own thesis: a multi-binding var is one new AST node and five near-identical switch cases, and its whole value as a worked example is that the fifth case lives in tools/musc, where odin check, not your memory, is what finds it. G.8 is the deliberate opposite of G.7 and the appendix's other bookend: const is a whole language feature that touches the lexer, the AST, the parser, and the resolver but not one line of the compiler, the VM, or the tree-walker, because it is a promise the resolver keeps before the program runs rather than anything the runtime carries, the same static-checks-earn-their-keep argument Chapter 7 made for the resolver's existence in the first place. Muspel is complete and usable for what this book built it to do; it was also, deliberately, left with clear seams for a reader who wants to keep going.