Odin Speaks Odin ยท Appendix F

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,
}
  1. Rotation. Destroids' own asteroids spin continuously (angle/spin), independent of their own linear velocity; Entity has no rotation field at all, since nothing in Embers ever needed one. A spawn-shaped script API attaching to Destroids would need its own spin(entity, rate) builtin, or spawn itself would need an extra argument, mirroring velocity's own shape but for angular rather than linear motion.
  2. 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' own entity_color/entity_radius (script_api.odin) already are, computed from kind on the Odin side of the boundary rather than passed in from script.
  3. 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-shaped on_death would need to pass the dying entity's own last x/y (and probably its own size) as arguments to the handler, the same arg_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.