Odin Speaks Odin ยท Chapter 17

Chapter 17: Events, callbacks, and many scripts at once

This chapter:

  • Let a script register callbacks the host invokes: on(event_name, fn), and vm_fire_event, the host's own side of calling one.
  • Prove a fired callback can suspend and be resumed exactly like the main script can, without ever disturbing whatever frame the main script itself was suspended in.
  • Scale from one VM instance to many, one per scripted entity, sharing a single compiled chunk read-only across all of them.
  • Grow musc two more checkpoints: events, and run --instances=N, a stress-test harness.

Where we left off: Chapter 16 gave a host a way to register its own native builtins and call Muspel from Odin. Nothing yet lets Muspel call back into a host on the host's own schedule, "when this entity dies," rather than the script's; and every chapter so far has run exactly one VM at a time.


17.1 Callback registration and firing

A script says which events it cares about with one builtin, on, and the same section 12.5 hoisting that lets one top-level function call another declared later in the file is what lets on("damage", on_damage) reference on_damage before or after the call reads naturally either way:

// vm.odin
@(private = "file")
native_on :: proc(vm: ^VM, args: []Value) -> Value {
    name, name_ok := arg_string(args, 0)
    if !name_ok || len(args) < 2 {
        runtime_error(vm, "on expects (event_name, function)")
    }
    vm.callbacks[name] = args[1]
    return nil
}

vm.callbacks (a new map[string]Value field on VM) is the entire registration table: native_on does nothing but record args[1], whatever Muspel value was passed as the handler, against name. It doesn't even check args[1] is actually a function; section 16.4's own argument-marshalling helpers could tighten that, and exercise 1 asks for exactly that tightening.

The interesting half is firing one. A host doesn't know an event happened until it happens, so calling the registered handler is entirely the host's job, not something a script triggers on itself:

// vm.odin
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
    proto, is_fn := cb.(^Func_Proto)
    if !is_fn do return {}, .Done, false

    floor := len(vm.frames)
    append(&vm.stack, cb)
    for a in args {
        append(&vm.stack, a)
    }
    base := len(vm.stack) - len(args)
    append(&vm.frames, Frame{chunk = &proto.chunk, ip = 0, base = base, name = proto.name})

    result = vm_call_run(vm, floor)
    return Callback_Call{floor = floor}, result, true
}

This pushes a Frame for the handler function, deliberately shaped exactly the way Call (section 13.3's own dispatch case) leaves the stack before it runs, and drives it with vm_call_run, the same machinery vm_resume itself is built on (section 17.2 below). If the handler itself calls wait, result comes back .Suspended, and call, a Callback_Call{floor: int}, is the ticket a host needs to keep driving that specific call, and only that call, on a later tick:

Gotcha: the callee's own stack slot. The first version of vm_fire_event written for this chapter pushed only args, not cb itself, before computing base. It compiled, ran, and looked correct on every single-callback test. It broke the moment a second callback was fired while the first was still suspended: Return (section 13.3) always resizes the stack back to frame.base - 1 and writes its result there, because ordinary bytecode always leaves the callee sitting in exactly that slot before a Call instruction runs. Skip pushing that slot, and base - 1 points one slot too low, silently overwriting whatever Value happened to already be sitting there, in this case, the first callback's own suspended deadline local, with nil. The fix is the one line above, append(&vm.stack, cb), before the args: any code that builds a call frame by hand, not through the compiler's own Call opcode, has to reproduce the entire calling convention Return assumes, not just the parts that look necessary. A single suspended callback never exposed this, because nothing was ever sitting just below its own base - 1 to overwrite; only a second, concurrently-suspended call made the bug visible at all.

// vm.odin
Callback_Call :: struct {
    floor: int,
}

vm_resume_call :: proc(vm: ^VM, call: Callback_Call) -> Resume_Result {
    return vm_call_run(vm, call.floor)
}

A second call fired while an earlier one is still suspended doesn't need any special handling at all: it stacks on top of vm.frames exactly like any ordinary nested call, its own frame (or frames, if its own handler calls further functions) sitting above the suspended one's, entirely untouched underneath. Firing damage twice in a row, with the first handler still mid-wait when the second fires, is genuinely well-defined: two Callback_Call tickets come back, and each one, drained in the right order, finishes correctly, its own local variables intact throughout.

Gotcha: tickets must be drained last-fired-first. vm_resume_call doesn't check which call is actually sitting on top of vm.frames, only where the frame stack needs to unwind back down to (call.floor). Calling it with an earlier call's own ticket while a later one is still suspended above it doesn't corrupt anything, but it also doesn't resume the call the ticket names: it just keeps stepping whatever's currently on top, the later call, until that one itself finishes or suspends again. This is exactly the discipline ordinary nested function calls already require and get for free (you can't "return early" from a caller while a callee is still mid-call either), so it's a real requirement, not a footgun this chapter is quietly hoping the reader avoids: drain in the reverse of firing order, same as any call stack.

17.2 The shared driving loop, generalised

vm_resume (Chapter 15) and vm_fire_event above both need the same shape: run the dispatch loop until a call unwinds back to wherever it started, or the VM suspends. Chapter 15's version only ever asked "did everything finish" (frame count reaches zero); this chapter needed to ask the more general question "did this particular call finish" (frame count reaches wherever it started, which might be above zero, if a suspended main-script frame is still sitting beneath it). One helper answers both:

// vm.odin
@(private = "file")
vm_call_run :: proc(vm: ^VM, floor: int) -> Resume_Result {
    saved := vm.status
    vm.status = .Running
    for vm.status == .Running && len(vm.frames) > floor {
        vm_step(vm)
    }
    if len(vm.frames) <= floor {
        vm.status = saved
        return .Done
    }
    return .Suspended
}

vm_resume is now the floor = 0 case exactly:

// vm.odin
vm_resume :: proc(vm: ^VM) -> Resume_Result {
    if vm.status == .Done do return .Done
    if vm.status == .Error do return .Error

    result := vm_call_run(vm, 0)
    if result == .Done do vm.status = .Done
    return result
}

The detail worth not skipping past is why checking len(vm.frames) > floor from the outside, rather than trusting vm_step's own bool return, is what makes this safe. vm_step's Return case only reports "everything is finished" (return false) when the frame stack drops to completely empty:

// vm.odin, inside vm_step's own Return case
case .Return:
    result := pop(&vm.stack)
    old := pop(&vm.frames)
    if len(vm.frames) == 0 do return false
    resize(&vm.stack, old.base - 1)
    append(&vm.stack, result)

If a callback's own Return pops its frame and vm.frames still has the main script's suspended frame sitting beneath it, that check is false: vm_step returns true, "there's more to do," and would happily keep executing, now on top of whatever frame it just exposed. A driving loop that trusted that bool alone would silently keep running the main script's own suspended code in the same call that was only supposed to run the callback. vm_call_run's len(vm.frames) > floor check, made from outside vm_step after every single step, is what catches this precisely: once the callback's own frame is gone and len(vm.frames) has dropped back to floor, the loop condition goes false immediately, before vm_step ever gets a chance to execute one more instruction of whatever's beneath it.

17.3 Proof: firing a callback while the main script is suspended

examples/events_demo.mus:

func main_loop() {
    print("main: waiting")
    wait(1000)
    print("main: done waiting")
}

func on_damage(amount) {
    print("callback: took damage", amount)
    wait(0.1)
    print("callback: damage handled")
}

on("damage", on_damage)
main_loop()

main_loop's own wait(1000) is deliberately long: the point isn't that it ever finishes here, it's that it's still sitting suspended, three frames deep (<script> calling main_loop calling wait), for as long as this demo needs it to be. musc events resumes it exactly once, fires damage while it's suspended, drains the handler fully, and checks the main script's own state came out unchanged:

./run.sh events examples/events_demo.mus
main: waiting
main script: Suspended, 3 frame(s) on the stack
--- firing 'damage' while the main script is still suspended ---
callback: took damage 5
callback: damage handled
'damage' handler finished: Done
main script untouched: 3 frame(s), Suspended (unchanged: true)

Read the middle three lines against section 17.1/15.2's own machinery. on_damage itself calls wait(0.1), so vm_fire_event's own first call to vm_call_run comes back .Suspended, exactly like the main script's own wait would; the handler needs several vm_resume_call calls (driven by a small manually-advanced fake clock, the same technique section 15.10's --tick harness uses) before its own deadline passes and it finishes. Through all of that, the main script's three frames never move: vm.frames's length and vm.status are read before firing and compared after draining the callback, and they match exactly. This is the chapter's whole thesis made checkable, not just argued: firing and fully resolving an event never touches whatever the main script was doing, suspended or not.

17.4 Running many VM instances

One scripted entity is one VM. Fifty scripted enemies are fifty VMs, not one VM juggling fifty scripts, each with its own stack, frames, and globals, but nothing more exotic than that: no scheduler, no cooperative-multitasking machinery beyond what a single VM already has. tools/musc/instances_cmd.odin builds this the same way the rest of the dinn monorepo's own house style builds anything with a bounded, known-in-advance count, a fixed-capacity array, not a dynamically grown one:

// tools/musc/instances_cmd.odin
Max_Instances :: 256

vms: [Max_Instances]muspel.VM
for i in 0 ..< count {
    vms[i] = muspel.vm_init()
    muspel.vm_push_script(&vms[i], &chunk)
}

Every one of those count instances is driven independently, each on its own simulated clock, each stopping as soon as it's individually .Done:

for {
    still_running := 0
    for i in 0 ..< count {
        if vms[i].status == .Done || vms[i].status == .Error do continue
        muspel.vm_set_fake_clock(&vms[i], clock)
        result := muspel.vm_resume(&vms[i])
        if result == .Error {
            errored += 1
            continue
        }
        if result != .Done {
            still_running += 1
        }
    }
    frames += 1
    clock += instance_tick_dt
    if still_running == 0 do break
}

The .Error branch is Chapter 18's own addition: without it, one instance's own runtime error would leave vms[i].status permanently .Error, never .Done, and still_running would count it forever, exactly the "one bad script hangs the whole harness" outcome fault isolation exists to prevent. Section 18.5 revisits this exact loop once .Error is a reachable outcome at all.

17.5 Shared bytecode, per-instance state

Look again at vm_push_script(&vms[i], &chunk) in section 17.4: every one of the fifty calls passes the same chunk, a single Chunk value compiled exactly once, before the loop that creates any instance even starts. Frame.chunk (vm.odin, since Chapter 13) is a pointer, not a copy, so every instance's own Frame points at the identical bytecode, constant pool, and line table in memory: fifty entities running the same wave script cost one compiled chunk, not fifty. What is per-instance is only ever what a running script actually needs privately: its own stack, its own frames (the call-frame stack, not the chunk each frame points into), its own globals, its own callbacks, its own clock. This is the single biggest lever available for making "many scripted entities" cheap, and it costs nothing extra to get: it falls straight out of Frame already being a pointer-to-chunk plus a handful of small per-call fields, a decision Chapter 12 made for entirely different reasons (so one function's bytecode could be referenced from many call sites) that happens to be exactly the right shape here too.

                     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                     โ”‚   Chunk (compiled    โ”‚
                     โ”‚   once, read-only)    โ”‚
                     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                 โ”‚               โ”‚               โ”‚
          Frame.chunk     Frame.chunk     Frame.chunk    ...
                 โ”‚               โ”‚               โ”‚
          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”
          โ”‚  VM  #0      โ”‚ โ”‚  VM  #1      โ”‚ โ”‚  VM  #2      โ”‚
          โ”‚  stack       โ”‚ โ”‚  stack       โ”‚ โ”‚  stack       โ”‚
          โ”‚  frames      โ”‚ โ”‚  frames      โ”‚ โ”‚  frames      โ”‚
          โ”‚  globals     โ”‚ โ”‚  globals     โ”‚ โ”‚  globals     โ”‚
          โ”‚  callbacks   โ”‚ โ”‚  callbacks   โ”‚ โ”‚  callbacks   โ”‚
          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

examples/wave_stress.mus is deliberately the smallest possible script, one wait, so the stress-test's own output is just the timing, not fifty copies of whatever the script happens to print:

wait(0.12)
./run.sh run --instances=50 examples/wave_stress.mus
50 instance(s), 1 shared chunk, 4 simulated frame(s), 0 error(s), finished in 43ยตs

Fifty independently-suspending, independently-resuming VM instances, one shared chunk, driven through simulated ticks, timed, in microseconds. Section 21.6 revisits this benchmark once Embers exists to give it a real scripted arena to measure inside of.

17.6 Tearing down a VM instance cleanly, and closing Chapter 5's own open question

An entity that dies mid-script (an enemy destroyed while its own wave logic is still mid-wait, say) needs nothing special done to its VM. There's no destructor to run, no open resource a Muspel script can be holding (section 15.9 already established this for the suspended-cancellation case, and it's the identical fact doing the identical work here): a host simply stops calling vm_resume on that instance and lets it be reclaimed, exactly the discard-and-restart pattern Chapter 15 already committed to for a script that needs interrupting. vms[i].status never needs checking again once a host has decided an entity is gone; nothing about a VM sitting unresumed forever is unsafe, only, as section 15.9 already said, occasionally wasteful of whatever partial progress it had made.

This is also, finally, the answer to the question Chapter 5 named and deliberately couldn't implement yet: where does a struct instance go when nothing needs it any more? Every struct and array a dying instance's script ever built lives in that one instance's value_arena (Chapter 10), never shared with any other instance, never referenced from outside it (Chapter 16's handles-not-pointers rule sees to that). Reclaiming the whole instance, value_arena_reset (or simply letting the VM struct itself, arena buffer included, be freed with whatever allocator the host used to create it) is every struct and array that instance ever allocated, reclaimed in one step: no per-object trace, no reference count, no cycle check, because nothing outlives the instance that made it and nothing was ever freed individually to begin with. Chapter 10 named the cost honestly at the time: a struct or array can never be freed early to reclaim memory mid-script. Here, at last, is the payoff that cost was buying: an entity's entire scripted memory footprint, however many structs and arrays it built up over however many ticks, disappears in exactly the same single step as everything else about it.

Checkpoint

musc events examples/events_demo.mus fires a damage event against a script whose main function is suspended mid-wait, drains a handler that itself waits, and confirms the main script's own frame count and status are unchanged afterward. musc run --instances=50 examples/wave_stress.mus drives fifty independent VM instances, one shared compiled chunk, through simulated ticks to completion, and reports how long the whole run took.

Exercises

  1. native_on never checks that args[1] is actually a function value before storing it in vm.callbacks. Using section 16.4's arg_number/arg_string as a model, add the missing check (there's no arg_function yet; write one) so on("damage", 5) reports a graceful runtime_error instead of only failing later, confusingly, inside vm_fire_event's own is_fn check.
  2. Fire an event with no handler registered (vm_fire_event(vm, "nonexistent")) and confirm fired comes back false without touching vm.frames at all. Why does this matter for a host that fires events speculatively, "in case anything's listening," on every entity every tick?
  3. Fire damage twice in a row (two separate vm_fire_event calls) while the main script is suspended, without draining either in between, and confirm both Callback_Call tickets finish correctly when drained in the right order, last-fired-first. Then deliberately get the order wrong: call vm_resume_call with the first ticket while the second call is still suspended above it on vm.frames. Confirm nothing corrupts, then explain, in your own words, what actually got stepped instead of the call you asked for, and why vm_resume_call's own signature (Callback_Call, not the VM's current top frame) has no way to catch this mistake for you.
  4. Section 17.4's Max_Instances :: 256 is a compile-time cap on a stress test, not a language limit. Run musc run --instances=257 examples/wave_stress.mus and read what happens. Where would a real host (Embers, Chapter 21) put its own, probably much smaller, equivalent cap, and why might that number be a design decision worth its own callout there, unlike here?

Further reading

  • Unity's ECS-adjacent "one script asset, many entity instances" pattern, and Unreal's compiled Blueprint-graph-plus-per-instance-state model: named generally, not claimed as a direct source, as real-world engines that split shared compiled logic from per-instance state the same way section 17.5 does.
  • Crafting Interpreters has no direct equivalent to this chapter: clox is neither resumable nor built to run many instances of one program at once, both being this book's own additions to the shape that book established.