Odin Speaks Odin ยท Chapter 21

Chapter 21: Embers: a game entirely scripted by Muspel

This chapter:

  • Build Embers itself: a small top-down arena, one player ship, reusing yo_odin's batch renderer (copied, not re-derived) exactly as ยง2 promised.
  • Design the script API surface, spawn/move_to/velocity/on_death/emit, as one single-source-of-truth table.
  • Prove muspel/ genuinely needed to stay host-agnostic: getting it to compile for -target:js_wasm32 at all required finishing a refactor this book's earlier chapters had left half-done.
  • Wire wave and boss scripts into a live-reload loop that works from inside a browser, with no native core:os in sight.
  • Close the book on Chapter 1's own promise: edit a running script's source and watch the change land with no rebuild.

Where we left off: every chapter from 11 onward has run Muspel from musc, a native CLI tool. Chapter 19 gave musc run --watch a whole live-reload mechanism, built entirely on core:os. Nothing has yet proven any of this actually works from inside the browser-hosted target this book's own yo_odin companion already established as the shelf's other WASM target.


21.1 Embers itself

Embers is deliberately small: one 480ร—480 arena, one player ship (a triangle, arrow keys or WASD to move, clamped inside the bounds), and an entity table every scripted wave or boss pattern spawns into and drives. embers/src/game.odin is the entire non-scripted core:

// embers/src/game.odin
Entity :: struct {
    id:                 int,
    alive:              bool,
    kind:               int,
    x, y:               f32,
    vx, vy:             f32, // continuous drift, set by the `velocity` builtin
    target_x, target_y: f32,
    speed:              f32, // px/sec toward target_x/target_y while moving == true
    moving:             bool,
    color:              string,
    radius:             f32,
    spark_frames_left:  int, // >0 draws a brief flash ring, set by the `emit` builtin
}

An entity is deliberately plain data, section 16.3's handle rule carried all the way into Embers' own script API: a script only ever holds an entity's own id, an ordinary number, never a pointer to one of these structs. update_game advances every live entity once per frame, continuous drift always applying, a move_to target approached at its own recorded speed and arriving exactly rather than overshooting, and despawns anything that drifts far enough outside the arena. The renderer, embers/src/draw.odin, is a handful of batch calls, copied from yo_odin and unmodified, one filled circle per entity plus a brief white ring while spark_frames_left is counting down:

// embers/src/draw.odin
draw_entities :: proc() {
    for e in game.entities {
        if !e.alive do continue
        batch.set_fill(&cmd, e.color)
        batch.begin_path(&cmd)
        batch.arc(&cmd, e.x, e.y, e.radius, 0, 6.2832)
        batch.fill(&cmd)
        if e.spark_frames_left > 0 {
            batch.set_stroke(&cmd, "#ffffff")
            batch.set_line_width(&cmd, 2)
            batch.begin_path(&cmd)
            batch.arc(&cmd, e.x, e.y, e.radius + 6, 0, 6.2832)
            batch.stroke(&cmd)
        }
    }
}

This chapter is deliberately not a game-design chapter: there is no combat, no scoring, no win condition. Embers exists to prove the embedding, exactly as ยง2 said it would, not to be interesting on its own terms.

21.2 Designing the script API surface

Five builtins are enough to drive every wave and the boss finale: spawn(kind, x, y), move_to(entity, x, y, speed), velocity(entity, dx, dy), on_death(entity, fn), emit(entity, kind). embers/src/script_api.odin collects them as one table, the same TowerDefs-style single-source-of-truth convention the root CLAUDE.md names for every game in this monorepo, credited:

// embers/src/script_api.odin
Script_Builtin :: struct {
    name: string,
    fn:   muspel.Builtin,
}

Script_Builtins := [?]Script_Builtin {
    {"spawn", native_spawn},
    {"move_to", native_move_to},
    {"velocity", native_velocity},
    {"on_death", native_on_death},
    {"emit", native_emit},
}

Every builtin reaches for section 16.4's own arg_number/arg_string and runtime_error, exactly the pattern Chapter 16's stub host already established: a bad call (spawn("north", 4)) is a graceful, traced runtime error, never an Odin panic, the identical guarantee Chapter 18 made for the language's own opcodes now extended to every one of Embers' own builtins too.

on_death needed a genuinely new piece of muspel/ itself. Chapter 17's vm.callbacks is keyed by one VM-wide event name (on("damage", fn)), exactly the wrong shape for "call me back when this specific entity dies": Embers wants many independent per-entity handlers, not one shared table. vm_fire_event's own machinery (looking a name up, then calling the function it maps to) splits cleanly into two procs, vm.odin gaining the lower-level half as a small, genuinely new public API:

// muspel/vm.odin
vm_call_value :: proc(vm: ^VM, fn: Value, args: ..Value) -> (call: Callback_Call, result: Resume_Result, ok: bool) {
    proto, is_fn := fn.(^Func_Proto)
    if !is_fn do return {}, .Done, false
    // ... exactly vm_fire_event's own frame-pushing logic, unchanged ...
}

vm_fire_event :: proc(vm: ^VM, name: string, args: ..Value) -> (call: Callback_Call, result: Resume_Result, fired: bool) {
    cb, ok := vm.callbacks[name]
    if !ok do return {}, .Done, false
    return vm_call_value(vm, cb, ..args)
}

vm_fire_event is now the name -> vm.callbacks[name] case of vm_call_value, not a separate mechanism; Embers' own Death_Handler table (entity_id, fn) is a small, private array inside script_api.odin, and despawn_entity calls vm_call_value directly, with whatever bare function value it stored, the moment an entity leaves the arena.

21.3 Getting muspel/ to compile for js_wasm32 at all

Before any of this could run in a browser, a real, structural problem surfaced: muspel/ didn't compile for -target:js_wasm32 at all.

odin check muspel -target:js_wasm32 -no-entry-point
/opt/homebrew/.../core/os/wasm.odin(4:1) Error: Compile time panic: core:os is unsupported on js/wasm

Three files pulled core:os into a package this book's own ยง2 had always described as host-agnostic ("muspel/ never imports embers/"), but never actually tested against a second host until now. treewalk.odin's Stmt_Yield case called os.exit(1), an easy fix (panic(...) is a language builtin, portable everywhere). disasm.odin's vm_debug_run, Chapter 14's interactive step debugger, read stdin through core:bufio/core:os, a genuinely CLI-only concern with no browser equivalent at all; it moved to tools/musc/debug_cmd.odin, the one place it's ever actually used, leaving disasm.odin itself, disassemble_chunk/disassemble_instruction, genuinely portable. hotreload.odin, Chapter 19's own Watched_Script, polled a real file's mtime through os.modification_time_by_path, native-only by its very nature; it moved to tools/musc/hotreload.odin too, and the pipeline it wrapped, lex/parse/resolve/compile from a plain string, was pulled out as its own portable proc, muspel/stdlib.odin's new compile_source:

// muspel/stdlib.odin
compile_source :: proc(chunk: ^Chunk, src: string, host_builtins: []string = nil, allocator := context.allocator) -> bool {
    stmts, errors := parse_with_stdlib(src, allocator)
    if len(errors) > 0 do return false

    r := resolver_init(allocator)
    for name in host_builtins {
        r.builtins[name] = true
    }
    resolve_program(&r, stmts, allocator)
    if len(r.errors) > 0 do return false

    chunk^ = chunk_init(allocator)
    compile_program(chunk, stmts, allocator)
    return true
}

host_builtins is the one genuinely new idea here: a host's own registered names (Embers' spawn/move_to/...) need to resolve cleanly, and compile_source builds and discards its own Resolver internally, invisible to the caller, so there was previously no way for a host's own names to reach it at all. Passing them in as a plain []string is resolve time's own half of registration; a host still binds the actual callable values onto a real VM instance separately (register_script_api, section 21.2), the same run-time/resolve-time split vm_register_native already drew for Chapter 16's stub host, just with the file-reading core:os half now cleanly severed from the portable compiling half.

odin check muspel -target:js_wasm32 -no-entry-point
(no output: compiles cleanly)

tools/musc's own Chapter 19 checkpoint (musc run --watch) still passes exactly as before, its own Watched_Script now living in tools/musc/hotreload.odin rather than muspel/, calling the identical compile_source Embers now calls too: one compilation pipeline, two entirely different hosts driving it, exactly the reusability claim ยง0 made about this whole book from its very first page.

21.4 One scripted director, not fifty concurrent instances

Chapter 17 built the machinery for running many independent VM instances at once, each an entity's own script. Embers deliberately doesn't use it that way. Script_Runner is a single VM instance, resumed once per frame, currently driving whichever .mus file is next in a fixed playlist:

// embers/src/main.odin
Script_Runner :: struct {
    vm:     muspel.VM,
    active: bool,
}
runner: Script_Runner

One scripted director, orchestrating many entities through spawn/move_to calls, proves the exact same embedding end to end without needing fifty concurrently-suspended VMs to do it: a wave script spawns three grunts and waits between them; the boss script spawns one entity and drives it through four phases. tick_script (embers/src/scripts.odin) is the whole sequencing:

// embers/src/scripts.odin
tick_script :: proc() {
    if !runner.active do return
    result := muspel.vm_resume(&runner.vm)
    if result == .Suspended do return
    if result == .Error {
        batch.log("script error, advancing to next entry")
    }
    playlist_index = (playlist_index + 1) % len(Playlist)
    start_script(Playlist[playlist_index])
}

Playlist is wave_01.mus, wave_02.mus, boss_ashling.mus, looping. A script that ends in .Error (Chapter 18's own graceful failure) is logged and the playlist simply advances, the identical fault-isolation guarantee Chapter 18 built, now visible as "one broken script doesn't freeze the arena" rather than an abstract VM-level property.

wave_01.mus, the simplest example, three grunts crossing the arena one after another, each spawn point a struct in a small array (Chapter 5, Chapter 6) rather than three hand-written calls:

struct Spawn_Point { x, gap }

var spawns = [
    Spawn_Point{x: 40, gap: 0.5},
    Spawn_Point{x: 240, gap: 0.5},
    Spawn_Point{x: 340, gap: 1.5},
]

for sp in spawns {
    var e = spawn(0, sp.x, -20)
    move_to(e, sp.x, 500, 90)
    wait(1.2)
    wait(sp.gap)
}

Adding a fourth grunt, or changing one's own gap, is a one-line edit to the spawns array, no new function call to write and no risk of a copy-pasted run_grunt call drifting out of sync with its own neighbours the way three hand-written calls could.

wave_02.mus favours velocity over move_to, a small swarm each given continuous downward drift once launched, no per-frame Odin code needed beyond update_game's own existing drift handling. boss_ashling.mus, the finale example, ties every builtin together: an on_death handler, a Boss_Phase enum (Entering/Sweeping/Leaving) tracking the boss's own state, and a sweep pattern held as an array of Sweep structs (x, y, speed), iterated with for, two emit calls per sweep standing in for an attack:

struct Sweep { x, y, speed }
enum Boss_Phase { Entering, Sweeping, Leaving }

// ... spawn, on_death, and the Entering-phase move_to/wait unchanged ...

phase = Boss_Phase.Sweeping
var sweeps = [
    Sweep{x: 100, y: 90, speed: 120},
    Sweep{x: 380, y: 90, speed: 120},
    Sweep{x: 100, y: 90, speed: 120},
    Sweep{x: 380, y: 90, speed: 120},
]
for s in sweeps {
    move_to(ashling, s.x, s.y, s.speed)
    wait(1.0)
    emit(ashling, 0)
}

Neither script imports the other, or a shared common.mus: compile_source (section 21.3) compiles one bundled string at a time, with no module loader wired in behind it the way musc run gained one in Chapter 20. That's a real, honestly-named gap, not an oversight: Embers' own scripts are #load-embedded into the WASM binary at compile time (there is no file system to read an import's path from at run time the way tools/musc/module_loader.odin does), so wiring imports in here would mean teaching scripts.odin to assemble its own small, statically-known Loaded_Module graph by hand and drive it through muspel/modules.odin's already-portable, host-agnostic graph functions, real, bounded, buildable work this book leaves as a named extension rather than a claim about code that doesn't exist yet. What every script does share, right now, is the language's own struct/enum/array vocabulary, exercised here exactly as it would be through an eventual common.mus, just declared locally in each file until that extension lands.

21.5 Live reload, from inside a browser

Chapter 19's Watched_Script polls a real file's mtime through core:os, which section 21.3 already established doesn't exist on -target:js_wasm32 at all. A browser host needs its own mechanism, built on the one thing it does have: fetch(). index.html polls every script every 1.5 seconds, writes the fetched bytes straight into the WASM module's own linear memory, and calls an exported recompile proc:

// embers/index.html
async function pollScripts(exports) {
  const bufPtr = exports.embers_script_buffer_ptr();
  // ... fetch each SCRIPTS[slot], write into memory.buffer at bufPtr ...
  const ok = exports.embers_reload_script(slot, bytes.length);
}
// embers/src/scripts.odin
@(export)
embers_reload_script :: proc "c" (slot: u32, length: u32) -> u32 {
    context = runtime.default_context()
    if int(slot) >= len(Playlist) do return 0
    if int(length) > len(script_recv_buf) do return 0

    src := strings.clone(string(script_recv_buf[:length]))

    new_chunk := new(muspel.Chunk)
    if !muspel.compile_source(new_chunk, src, script_builtin_names()) do return 0

    Playlist[slot].chunk = new_chunk
    return 1
}

strings.clone here is the same lesson Chapter 19 already learned once, applied a second time in a new setting: script_recv_buf is one shared, reused buffer, overwritten on every future poll, and a compiled chunk's own string constants slice directly into whatever buffer produced them (section 12's own const-value machinery, unchanged since Chapter 10). Skipping the clone would mean every earlier script's own string constants started reading whatever the next poll happened to write into that same memory, exactly the use-after-free Chapter 19's own Gotcha already named once, in a different shape. Playlist[slot].chunk = new_chunk is the identical pointer-swap Watched_Script.chunk already used natively: an already-running VM instance keeps executing whatever ^Chunk it was pushed with at the start of its own current run, completely unaffected, and only the next time that playlist slot starts does it pick up the new bytecode.

Getting this working end to end, actually loading Embers in a browser rather than trusting that odin check and a clean build meant it worked, surfaced two real bugs neither odin check nor any of musc's own commands could ever have caught, because neither exercises the exact path a browser actually takes.

Gotcha: @(init) procs silently never run under -no-entry-point. The very first version of script_api.odin filled in Script_Builtin_Names (the array compile_source's host_builtins parameter needs) with an @(init) proc, the ordinary Odin idiom for start-up code that runs before anything else, no explicit call needed anywhere. It compiled cleanly. It passed musc embers-check, a small native harness this chapter used early on to test compile_source against the three bundled scripts before the WASM build even existed. It failed the instant Embers actually loaded in a browser: every one of spawn/move_to/... came back 'log' is undefined-shaped errors (compile_or_panic's own assert firing on batch_main's very first frame, an outright RuntimeError: unreachable crash visible in the browser's own console, not a quiet failure). The root cause: @(init) procs are invoked by runtime._startup_runtime, itself only called from the WASM _start entry point (base:runtime/entry_wasm.odin), the exact entry point every game in this monorepo (Yo Odin's Destroids included) deliberately compiles without, via -no-entry-point: JS drives the module through batch_main/batch_invoke_callback instead, never through a WASM-native _start. No _start means no _startup_runtime, means no @(init) procs ever run, full stop, silent on native (where the ordinary entry point still exists) and an outright failure the moment the identical source compiles for -target:js_wasm32 -no-entry-point instead. The fix, init_script_api, is called explicitly from batch_main, before init_scripts, the same "the host calls it, nothing calls itself" discipline batch_main already uses for everything else it sets up. Nothing about odin check, a native build, or a native test harness would ever have surfaced this: it is specific to the combination of @(init) and -no-entry-point, and the only way to find it was loading the actual game in an actual browser and reading the actual console.

Gotcha: a live-reload build script that copies scripts into dist/ defeats its own purpose. build.sh's first version copied scripts/*.mus into dist/scripts/ at build time, exactly the way it already copies batch.js and index.html. This is correct for those two files, and wrong for the scripts: the whole point of section 21.5's own polling loop is to notice an edit to scripts/wave_01.mus without a rebuild, but the server only ever serves dist/, so an edit to the real scripts/ file changed nothing the running game could ever see. The fetch loop kept polling and recompiling the same frozen, one-build-old copy forever, no error, no visible sign anything was wrong short of a wave that stubbornly never changed no matter what was edited and saved. The first fix reached for was a symlink, dist/scripts -> ../scripts, made once by build.sh if it didn't already exist, so dist/scripts/wave_01.mus and the project's own scripts/wave_01.mus were the identical file on disk. It worked, locally, and broke the moment dist/ was deployed anywhere else: the symlink's target, ../scripts, lives outside dist/, so shipping dist/ alone to a real server (the entire point of dist/ being the build's output) leaves every /scripts/*.mus request 404ing against a dangling link nothing on the far end can resolve. Fixing that for good meant moving the special case out of build.sh and into embers/server/serve.odin instead: build.sh now cp -r's scripts/ into dist/scripts/ as ordinary files, no symlink anywhere, so dist/ is self-contained and deployable as-is. The dev server is what needs the live view, not the build output, so it serves /scripts/* from the project's own scripts/ directory directly (a small SCRIPTS_ROOT constant, checked before falling back to ROOT), leaving every other path served from dist/ exactly as before. Editing scripts/wave_01.mus is visible on the very next poll because the running dev server reads the real file every time, not because any copy of it happens to alias the original. A second, smaller bug turned up in the same session: the dev server's own request_path (embers/server/serve.odin, copied unmodified from Yo Odin) never stripped a query string, and the polling loop's own cache-busting ?t=<timestamp> suffix meant every single fetch 404'd, since wave_01.mus?t=1785332624744 is never a real file on disk. Neither of these bugs has any equivalent on the native side, where musc run --watch reads the file directly with core:os and no HTTP server, no browser cache, no deploy step, and no query string ever enters the picture at all: they are specific to what a browser host actually needs, and specific to what only running the real thing, end to end, in a real browser, with a real edit, and eventually a real deploy, ever surfaces.

Both fixes are small; the process of finding them is the point worth carrying forward. Every earlier chapter's own checkpoint, musc compile, musc run, musc debug, was verified the same way this book insists on throughout: by actually running it and reading the real output, never by trusting that a clean type-check meant a program was correct. A browser host is simply the first place in this book where "actually running it" means something a terminal alone can't show: odin check and a successful odin build -target:js_wasm32 both said everything was fine, twice, right up until the moment the game actually loaded.

21.6 The live demo

With both bugs fixed: ./run.sh builds Embers, starts the dev server, and http://localhost:9000 shows the arena, the player ship (arrow keys or WASD), and the playlist cycling through wave_01.mus, wave_02.mus, and boss_ashling.mus in order, a pink boss sweeping side to side with a white flash ring on each emit. Editing any of the three files under scripts/ and saving, while the game keeps running, updates the footer's own reload log within 1.5 seconds and the next playlist pass uses the new source, exactly the demo Chapter 1 opened the whole book by promising: a boss pattern edited in a text editor, reloaded live, with no recompilation of the host game at all.

21.7 Performance check-in

embers.wasm is 280KB, comfortably under the 5MB soft budget every game in this monorepo warns against; the whole compiled binary, VM included, is smaller than a single uncompressed sprite sheet would have been. Section 17.4's own "fifty instances in microseconds" benchmark already established that the VM's own overhead is not where any real cost in a scripted game would come from; Embers' own three-entity-at-a-time waves and one-entity boss are nowhere near that number, and the frame rate (the footer's own live FPS readout) never dips below the browser's own vsync cap during normal play. Closing the thread Chapter 9 opened and Chapter 13 revisited: a bytecode VM was never meant to be "as fast as native code," only "fast enough for game-logic scripting," and a real, rendered, live-reloadable arcade arena is the concrete proof that promise held.

21.8 Where to go next

Muspel's own scope was cut deliberately and often, and every cut is worth naming here, together, now that the whole system is built and running. Closures capture by value at creation time only (section 12.4); full upvalue-style closures, capturing an outer local by reference, are real, useful extra complexity Lua's own design solves and this book didn't need to. A general-purpose garbage collector was unnecessary for Embers because structs and arrays, the only heap-shaped data Muspel has in the core chapters, live in a per-VM-instance bump arena (Chapter 10), freed wholesale when the instance is torn down (Chapter 17), never traced or reference-counted individually (an optional precise copying collector pass muspel/gc.odin is detailed as an engine extension in Appendix G for scripts with continuous object churn). The cost, named honestly back in Chapter 10 and paid for real in Chapter 18, is that nothing can be freed early; a general heap trades that limit for real, ongoing bookkeeping this book's own scale never needed. Growable collections and a general heap allocator for them are the natural next step if that arena limit ever actually bites: Muspel arrays are fixed-capacity by design (Chapter 6), and lifting that would mean solving reallocation inside a bump allocator, or reaching for the general heap the arena was chosen specifically to avoid. A type system beyond runtime checks (section 18.6's own graceful as_number/as_number_pair) was never attempted; static type checking ahead of running anything, enough to prove enemy.pos.x always means "index 0 of a Vec2" without a runtime scan (section 10.6's own named limit), is a real, large feature of its own. JIT compilation is the natural next step after a bytecode VM, and this book stops deliberately short of it, exactly the honest boundary section 9.4 already drew. Every one of these is a real, well-documented feature of production languages this book's own scope chose not to build; Crafting Interpreters and Wren are both, again, the generous, credited pointer for a reader who wants to keep going past where this book stops. Appendix G walks a reader through what actually changes, file by file, to add a new operator or a new opcode, and honestly names why a static field-index type system is a bigger commitment than either.

For a reader who's also built yo_odin's Destroids: Appendix F sketches, without depending on it, exactly how this same script_api.odin surface would attach to Destroids' own wave/enemy system instead, an optional "now go do it to your other game" send-off, the reusability claim ยง0 made about this whole book, demonstrated a second time on a game this book's own code/ never needed to import.

Checkpoint

./run.sh (from embers/) builds and serves Embers at http://localhost:9000: a playable arena with a movable player ship, three scripted entities crossing the field in wave_01.mus, a swarm in wave_02.mus, and a four-phase scripted boss in boss_ashling.mus, cycling in order. Editing any script under scripts/ and saving updates the game within 1.5 seconds, with no rebuild. muspel/ compiles cleanly for -target:js_wasm32 -no-entry-point, proving the "muspel/ never imports embers/, and stays genuinely reusable" claim from ยง2 was true in practice, not just in principle.

Exercises

  1. Add a sixth script builtin, shake(amount, duration), that does nothing in game.odin yet beyond recording a screen-shake request; wire it through Script_Builtins and call it from boss_ashling.mus on each emit. What's the smallest change to draw_game that would apply a small random offset to every draw call while the shake is active?
  2. Section 21.5's second Gotcha found a real bug in request_path's own query-string handling. embers/server/serve.odin is otherwise identical to yo_odin's own copy. Check whether yo_odin's Destroids ever sends a request with a query string; if it doesn't, explain why this exact bug could sit undiscovered in that book's own server indefinitely, and whether that's a real latent bug there too or a genuinely Embers-specific one.
  3. Script_Runner is a single VM instance; Chapter 17's own multi-instance machinery (vm_resume's own contract, unchanged) would let Embers instead give each entity its own script, rather than one scripted director orchestrating all of them. Sketch, in prose, what wave_01.mus would need to look like rewritten that way (one script per grunt, not one script spawning three), and name one concrete thing that becomes harder to coordinate (the timing between grunts, say) once there's no longer a single script holding all three wait calls in one place.
  4. Appendix F sketches attaching script_api.odin to Destroids without this book's own code/ depending on it. Read yo_odin's own game.odin and draw.odin and name the two or three Destroids-specific pieces of state (asteroid position/velocity, say) a spawn/move_to-shaped script API would need its own equivalent of, mirroring Entity's own shape in this chapter's game.odin.

Further reading

  • yo_odin's own batch renderer and native dev server (server/serve.odin), reused, credited, not re-derived, for both Embers' visuals and its own http://localhost:9000 hosting.
  • Crafting Interpreters and Wren (Robert Nystrom), credited generously, one final time, as where a reader goes next for full closures, a garbage collector, a static type system, or a JIT: every feature this book's own scope named and declined to build, at production quality, in a language built for exactly this purpose.
  • Lua's own reference manual, for the closure/upvalue design this book's own section 12.4 already named as the "full" version of Muspel's own deliberately-simplified closures.
  • The dinn monorepo's own TowerDefs/CarDefs single-source-of-truth convention (root CLAUDE.md), credited as the direct model for Script_Builtins, this chapter's own version of the same idea.