Chapter 17: Events, callbacks, and many scripts at once
This chapter:
- Let a script register callbacks the host invokes:
on(event_name, fn), andvm_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
musctwo more checkpoints:events, andrun --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_eventwritten for this chapter pushed onlyargs, notcbitself, before computingbase. 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 toframe.base - 1and writes its result there, because ordinary bytecode always leaves the callee sitting in exactly that slot before aCallinstruction runs. Skip pushing that slot, andbase - 1points one slot too low, silently overwriting whateverValuehappened to already be sitting there, in this case, the first callback's own suspendeddeadlinelocal, withnil. 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 ownCallopcode, has to reproduce the entire calling conventionReturnassumes, not just the parts that look necessary. A single suspended callback never exposed this, because nothing was ever sitting just below its ownbase - 1to 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_calldoesn't check which call is actually sitting on top ofvm.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
native_onnever checks thatargs[1]is actually a function value before storing it invm.callbacks. Using section 16.4'sarg_number/arg_stringas a model, add the missing check (there's noarg_functionyet; write one) soon("damage", 5)reports a gracefulruntime_errorinstead of only failing later, confusingly, insidevm_fire_event's ownis_fncheck.- Fire an event with no handler registered (
vm_fire_event(vm, "nonexistent")) and confirmfiredcomes backfalsewithout touchingvm.framesat all. Why does this matter for a host that fires events speculatively, "in case anything's listening," on every entity every tick? - Fire
damagetwice in a row (two separatevm_fire_eventcalls) while the main script is suspended, without draining either in between, and confirm bothCallback_Calltickets finish correctly when drained in the right order, last-fired-first. Then deliberately get the order wrong: callvm_resume_callwith the first ticket while the second call is still suspended above it onvm.frames. Confirm nothing corrupts, then explain, in your own words, what actually got stepped instead of the call you asked for, and whyvm_resume_call's own signature (Callback_Call, not the VM's current top frame) has no way to catch this mistake for you. - Section 17.4's
Max_Instances :: 256is a compile-time cap on a stress test, not a language limit. Runmusc run --instances=257 examples/wave_stress.musand 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.