Project: The Great Refactor
No new powers this time — you'll use every one you've got to tear the snake into clean rooms, hang a door on each, and swap the game's soul with a single line.
You will be able to…
- Split your local snake project into
World/Physics/Render/Inputmodules, each with an.mli - Refactor in small steps that keep the game running after every move
- Define a
RULESsignature and inject a ruleset as a first-class module - Review an interface by asking the killer question: what can this module not do?
The briefing
A refactor has one sacred rule: the game plays exactly the same when
you're done. No new features, no fixed bugs, no “while I'm in
here…” — only structure changes. That constraint is what makes refactoring
safe to do boldly: at every step there's a simple test, does it still
build, does it still play? Work in your snake project from lesson 2.5
(the dune version), and keep a terminal with
opam exec -- dune build within arm's reach. Definition of
done: same snake, new floor plan, and one line of code deciding whether
walls kill or teleport.
Here's the floor plan you're building towards — one room per concern, one file per room:
src/ world.ml world.mli the state: snake, apple, score physics.ml physics.mli the tick: move, eat, collide, die render.ml render.mli World.t -> pixels (the ONLY file that knows Draw) input.ml input.mli keys -> direction changes rules.ml the RULES signature + the rulesets snake.ml main: wires everything, picks the ruleset
Remember the deep-dive from 3.1: in a dune project, every file is a
module. world.ml simply is a module named
World — no module keyword, no
struct/end — and other files call
World.initial as if you'd written the room by hand. The
dependency arrows must point one way: snake.ml knows
everyone; physics.ml knows World and
Rules; world.ml knows nobody. And two house
rules that keep the architecture honest: only render.ml may
mention Draw, and only input.ml may mention
Input. If the compiler ever forces you to break one of those,
a responsibility is sitting in the wrong room.
The .mli: a door, as a file
Each room gets a door: an .mli file with the same name.
It contains a signature's body — bare val and
type lines, no sig/end wrapper —
and dune seals the module with it automatically. Here's a reasonable
world.mli:
(* world.mli — the state, visible in full; the helpers, hidden *)
type dir = Up | Down | Left | Right
type t = {
snake : (int * int) list; (* head first *)
dir : dir;
apple : int * int;
score : int;
alive : bool;
}
val initial : t
Wait — didn't 3.1 preach abstract types, and here's t with
its record laid bare? Deliberate. Physics and
Render genuinely need the state's structure, so this
interface keeps the type transparent and earns its keep by hiding
everything else: spawn helpers, coordinate fiddling, whatever scaffolding
world.ml accumulates stays invisible. Abstraction is a dial,
not a religion — Gold-style opacity where invariants matter,
transparency where sibling rooms genuinely share the data. Your
physics.mli, by contrast, can be brutally small:
(* physics.mli — the whole tick, one function *) val step : (module Rules.RULES) -> World.t -> World.t
Refactor in extractions, smallest room first: create
world.ml, move the state types and initial in,
build, fix the World. prefixes the compiler demands, play one
game. Then render.ml, then input.ml, then
physics.ml; write each .mli right after its
.ml compiles. Green build after every extraction — never two
rooms mid-move at once. After the dust settles:
The RULES seam
Now for the soul-swap. Some decisions aren't code so much as
taste: should walls kill you (classic, tense) or teleport you to
the far side (cozy, zen)? How many steps per second? Bundle the taste into
a module, and make Physics.step take it as a first-class
argument — lesson 3.3, deployed for real. Your rules.ml:
Inside physics.ml, the step unpacks its rules and consults
them at the two places taste matters: the wall check and — via
snake.ml's timing — the speed. And in snake.ml,
the whole decision collapses to one binding. Here's the seam, miniaturised
so you can run it right here:
In the full game that binding is passed to
Physics.step every tick, so changing
Classic_rules to Cozy_rules changes wall
behaviour and speed everywhere at once — the whole game's feel,
one line. Before you wire it locally, drill the seam's trickiest inch
online: the wall arithmetic itself.
Write next_pos : (module RULES) -> int -> int -> int.
The call is next_pos rules w x: w is the
board width in cells (second argument), x a column that
may have stepped out of range (third). Return the corrected column:
if wrap is true — wrap around: −1 comes
back as w − 1, w comes back as
0;
if wrap is false — clamp into
0 .. w − 1 with min/max (the
wall stops you; in the real game this is where alive
would flip).
(module R :
RULES) — then it's one if R.wrap then … else ….((x mod w) + w) mod w — the
+ w rescues negative x from the
mod ambush. Clamp: max 0 (min (w - 1) x).Notice the tests: two rule modules, injected into the same function,
asserting two different physics. When your local Physics.step
takes its (module Rules.RULES), you can test it the same way
— no game loop, no canvas, just crates through the seam.
Victory conditions
This checklist is the project rubric — tick items only when they're honestly true on your machine. The review item is the important one; budget ten unhurried minutes for it.
Deeper: how far does this scale?
Precisely this shape — small modules, thin .mli doors,
behaviour injected at seams — is how large OCaml systems stay sane:
compilers, trading systems, proof assistants. Your
RENDERER-style seam is how a real game ships one logic core
with an SDL backend on desktop and a js_of_ocaml backend in the browser;
your RULES seam is difficulty settings, mod support, and
A/B experiments. And in Phase 4 the pure, sealed step at
the centre becomes the thing you can replay deterministically and
parallelise across cores. Architecture now, superpowers next.
Checkpoint quiz
Loot collected
- In dune projects, files are modules and
.mlifiles are their doors — signature bodies, nosig/end. - Refactor in green-to-green steps: extract one room, build, play, repeat.
- Abstraction is a dial: opaque
twhere invariants matter, transparent records where siblings share state — but always the smallest door that works. - A
RULESmodule injected intostepturns the game's feel into data: one line swaps it. - The review question that finds the design: what can this module NOT do?
Phase 3 complete — your code has architecture now. Phase 4 hands you the OCaml 5.5 exotica: effect handlers, control flow with superpowers.