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…

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).

Unpack in the parameter list — (module R : RULES) — then it's one if R.wrap then … else ….
Wrap: ((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

Phase 3 complete — your code has architecture now. Phase 4 hands you the OCaml 5.5 exotica: effect handlers, control flow with superpowers.