First-Class Modules: Hot-Swappable Engines
Today modules stop being furniture and become cargo: pack one into an ordinary value, hand it to a function, and swap your game's whole rendering engine while the program runs.
You will be able to…
- Pack a module into a value with
(module M : S) - Unpack it with
let (module R) = …— even right in a function's parameter list - Write functions that take an engine as an argument — dependency injection, no framework required
- Render one world through two different engines without touching the game logic
The choice that can't wait
Everything module-shaped so far happened at compile time: you sealed
Gold before the program ever ran, and
MakeScoreboard (Loot) was stamped out by the compiler. But
games ask questions that only have answers at runtime: draw
pixels, or print ASCII into a terminal? Play sound, or stay silent because
it's 11 pm? Use the debug renderer because you're replaying a bug report?
An if can't choose between two modules… unless
modules can be values.
Since “modules as arguments” has been on this quest's banner since day
one: this is that milestone. OCaml lets you shrink-wrap any module into an
ordinary value — pass it, store it, return it, pick it with an
if — and unwrap it back into a full module wherever it's
needed. First, the signature every engine in this lesson will satisfy.
One grid cell, at a column and a row, in a colour:
Three new spells, all wearing parentheses:
- The type
(module RENDERER)— a core type likeintorstring list. A value of this type is “some module satisfyingRENDERER”, no more, no less. - Packing
(module Ascii : RENDERER)— freeze the module into a value. Here the annotation lives on thelet, which works just as well. - Unpacking
let (module R) = engine in …— thaw it back into a capital-letter module namedR, dot access and all.
Check the toplevel's reaction — a module, priced like a value:
Engines as arguments
The unpack is so common in function heads that OCaml lets you do it in the parameter list. This is the line to tattoo somewhere — a function that takes an engine:
draw_head has type
(module RENDERER) -> unit: give it any engine, it draws
the snake's head through it. Notice the call site:
draw_head (module Ascii), no annotation needed — the
parameter's type already announces the signature. This pattern has a
grown-up name, dependency injection: instead of the function
hard-wiring which renderer it uses, the caller injects one. Your
turn — write the workhorse of this whole lesson.
Write render : (module RENDERER) -> (int * int) list -> unit.
The list is a world of (col, row) cells; call the packed
engine's cell once for each, in colour
"lime". No peeking at which engine it is — there's no way
to, and that's the design.
List.iter runs a function on every
element — and your function should call R.cell.List.iter (fun (col, row) -> R.cell col row
"lime") world — the tuple pattern splits each cell on
arrival.Peek at how the Check works: the tests build a counting
renderer — a module whose cell just ticks a counter — pack
it, and inject it into your render. A test spy, wearing the
engine uniform. The fact that tests can impersonate a renderer without
touching your code is dependency injection already paying rent.
Choosing an engine at runtime
Now the punchline made literal: a function that returns one
module or another depending on a plain bool — something no
functor could do, because the bool doesn't exist until the
game runs.
Write pick : bool -> (module RENDERER) returning the
two engines given in the starter: Ascii_renderer (noisy —
prints a # per cell) when the flag is true,
Silent_renderer when it's false. Then play
with Run: unpack your pick and call it a few times —
let (module R) = pick true in R.cell 0 0 "x" — and watch
the #s appear… or pointedly not.
if/else where each
branch packs a different module.if loud then (module Ascii_renderer :
RENDERER) else (module Silent_renderer : RENDERER) — both
branches must pack with the same signature, or the two arms wouldn't
share a type.Before moving on, break the door on purpose. A module only fits through a package type if it truly satisfies the signature — one typo and the crate won't close:
Signature mismatch … The value cell is required but not
provided — the same bouncer you met at Map.Make's
gate, now checking crates at runtime's doorstep. Same contract machinery,
end to end.
Two engines, one world
Grand finale. Below are two complete engines: one draws each cell as a 20-pixel square on the canvas, the other prints each cell as a line of text. Your job is a single function that renders a fixed world — a five-cell L-shape — through whichever engine it's handed. The starter then calls it twice: same world, same function, pixels and text.
Write draw_world : (module RENDERER) -> unit: render
every cell of l_shape through the given engine, colour
"orange", using List.iter so the cells go
through in list order. Leave the two let () = draw_world …
lines at the bottom untouched — they're the demo. After the Check,
look up at the canvas, then down at the output pane: one world, two
completely different pictures of it.
render earlier:
List.iter over l_shape, calling
R.cell col row "orange".Now the moral, said plainly: draw_world consulted
R.cell and asked nothing else. It has no idea whether it lit
pixels, printed text, or fed a counter in a test — and it cannot
know, because the signature is all it can see. The game logic never knew
which engine it used. That ignorance is the architecture: next lesson,
the Great Refactor injects physics rulesets and rendering
backends into your snake exactly this way — swap the whole game's feel by
changing which crate you hand over. One line.
Deeper: what's inside the crate?
At runtime, a packed module is roughly a record of its values —
cell here — and packing/unpacking costs about as much as
passing a record. The interesting part is the type-checker's seal: the
package type (module RENDERER) forgets everything about the
original module except the signature, which is why all engines become
interchangeable the moment they're packed. (If a signature contains an
abstract type t, each crate carries its own
private t — OCaml keeps different crates' types apart. Our
RENDERER has no types inside, which keeps today simple.)
Checkpoint quiz
Loot collected
(module RENDERER)is a core type;(module M : S)packs;let (module R) = …unpacks — even in a parameter list.- Functions can take, return and choose engines: dependency injection with the compiler checking the wiring.
- Tests injected a counting spy into your
render— swappable engines make code testable for free. - Functors choose at compile time; first-class modules at run time. Same signature discipline guards both doors.
- One L-shape, three renderers, zero changes to the logic. That's the point.
Next: the Great Refactor — your snake gets rooms, doors, and a hot-swappable soul.