Appendix F: Attaching Muspel to Destroids
This appendix is a sketch, not a chapter: it demonstrates the reusability claim ยง0 made about this whole book, that muspel/ is a genuinely portable scripting layer and not a one-off toy tied to Embers, by showing what attaching it to yo_odin's own Destroids would actually involve. Nothing here is built or tested; this book's own code/ never imports Destroids, and Destroids' own code/ in yo_odin never imports Muspel, exactly the boundary ยง2's own "muspel/ never imports embers/" rule protects. A reader who has built both books is the intended audience.
What would need scripting
Destroids' own next_level (yo_odin's src/game.odin) hardcodes its wave logic directly in Odin: a fixed asteroid count that grows with the level, spawned at random points around the arena's own edge:
// yo_odin's own src/game.odin, unmodified
next_level :: proc() {
level += 1
if level > 1 {play_sound(SND_LEVEL)}
count := 4 + level * 2 // busier each wave
for _ in 0 ..< count {
p: Vec2
if rand.float32() < 0.5 {
p = {rand.float32() * CANVAS_W, rand.float32() < 0.5 ? 0 : CANVAS_H}
} else {
p = {rand.float32() < 0.5 ? 0 : CANVAS_W, rand.float32() * CANVAS_H}
}
spawn_asteroid(p, 3)
}
}
This is exactly the shape Embers' own wave_01.mus/wave_02.mus replace in this book's own capstone: fixed spawn counts and positions, recompiled into the binary every time a designer wants a different opening wave. Turning next_level into a .mus script means giving a script author a way to say "spawn N asteroids, staggered, along the edges" without touching Odin at all.
What Destroids' own Asteroid needs that Embers' Entity doesn't
Embers' own Entity (embers/src/game.odin, Chapter 21) is deliberately minimal: position, a continuous-drift velocity, an optional move_to target, a colour and radius for drawing. Reading Destroids' own Asteroid struct (yo_odin's src/game.odin) against it, three real differences stand out:
// yo_odin's own src/game.odin, unmodified
Asteroid :: struct {
pos: Vec2,
vel: Vec2,
angle: f32,
spin: f32,
radius: f32,
size: int,
shape: [SHAPE_N]f32,
active: bool,
}
- Rotation. Destroids' own asteroids spin continuously (
angle/spin), independent of their own linear velocity;Entityhas no rotation field at all, since nothing in Embers ever needed one. Aspawn-shaped script API attaching to Destroids would need its ownspin(entity, rate)builtin, orspawnitself would need an extra argument, mirroringvelocity's own shape but for angular rather than linear motion. - A fixed procedural shape, not a colour and radius. Destroids draws each asteroid as a jagged polygon (
shape: [SHAPE_N]f32, a per-vertex radius offset generated once at spawn time), not a filled circle; a script API wouldn't need to expose this at all; it's an Odin-side rendering detail exactly like Embers' ownentity_color/entity_radius(script_api.odin) already are, computed fromkindon the Odin side of the boundary rather than passed in from script. size, a three-level split-on-death mechanic. Destroids' own asteroids split into two smaller ones on destruction (size: int, 3 down to 1), each smaller pair inheriting a fraction of the parent's own velocity. This is the one piece with no equivalent anywhere in Embers at all:on_death(Chapter 21) fires a script callback with no arguments, enough for Ashling's own "print a message" handler, not enough to say "spawn two smaller versions of yourself at your own last position." A Destroids-shapedon_deathwould need to pass the dying entity's own lastx/y(and probably its ownsize) as arguments to the handler, the samearg_number-checked marshalling section 16.4 already established, just with more arguments flowing across the boundary than Ashling's own handler ever needed.
The sketch itself
A destroids_script_api.odin, mirroring Embers' own script_api.odin file-for-file, might look like:
// sketch only, not part of this book's own code/
Script_Builtins := [?]Script_Builtin {
{"spawn_asteroid", native_spawn_asteroid}, // (size, x, y) -> handle
{"spin", native_spin}, // (entity, rate)
{"velocity", native_velocity}, // (entity, dx, dy), unchanged from Embers
{"on_death", native_on_death}, // (entity, fn); fn receives (x, y, size)
}
and next_level's own hardcoded loop becomes a .mus script, wave.mus, exactly the shape wave_01.mus's own run_grunt helper already established:
// sketch only
func spawn_ring(count, size) {
var i = 0
while (i < count) {
var angle = i * 6.2832 / count
spawn_asteroid(size, 400 + 380 * cos(angle), 300 + 380 * sin(angle))
wait(0.1)
i = i + 1
}
}
spawn_ring(4 + level * 2, 3)
level here would need to reach the script somehow, either as a native builtin (current_level(), mirroring host_seconds's own shape exactly) or as an argument main.odin passes when it starts this script, neither requiring anything new from muspel/ itself: both are ordinary applications of Chapter 16's own embedding boundary, not new VM machinery.
What this sketch is actually demonstrating
Nothing above required a single change to muspel/. Every piece, the handle convention (ยง16.3), arg_number-checked marshalling (ยง16.4), vm_register_native's one-call registration (ยง16.2), wait/wait_until built entirely from yield (ยง15.7), even the shape of a Script_Builtins table itself, carries over unchanged from Embers to a completely different host, a different game, in a different book's own code/, that this book's own code/ has never imported and never will. That is the reusability claim ยง0 opened this whole book with, made concrete: a real scripting layer, not a one-off toy tied to one host, verified by sketching its attachment to a second, genuinely independent host and finding that only the host-specific pieces (an asteroid's own rotation, its own split-on-death mechanic) needed anything new at all.