Project: Snake, the Functional Way

Records, variants, lists, the tick — this is where the whole phase snaps together into a game you can play, test, and (rarest of all) trust.

You will be able to…

The quest log

Here is the entire game state. Read it slowly — it's the phase in four lines:

The board is a 20 × 15 grid of cells (each drawn 20 pixels square, filling our 400 × 300 canvas exactly). body lists grid cells head first, like lesson 2.3. dir is where the snake is heading; alive is self-explanatory and non-negotiable. Five milestones stand between you and a playable game — each one a pure function, each worth 30 XP, each tested to the bone: slide, feast, wrap, bite, and the tick that runs them all. Every starter redefines the types, so each milestone stands alone.

Milestone 1 — slide

A snake slithers the lesson-2.3 way: a new head appears one cell along dir, and the tail tip vanishes. Length constant. We provide delta from lesson 2.2 in the starter — and yes, on this grid y still grows down, so Up is (0, -1). You'll want drop_last again: rebuild it from memory (that's the muscle this milestone trains).

Write move : snake -> snake: new head = old head shifted by delta s.dir; then drop the last body cell. dir and alive ride along unchanged. (No walls yet — heads may leave the grid; wrapping is milestone 3.)

Fish out the head with match s.body with (hx, hy) :: _ -> …, and get (dx, dy) from delta s.dir.
Rebuild drop_last from lesson 2.3: three arms — [], [ _ ], c :: rest.
Assemble with one update: { s with body = (hx + dx, hy + dy) :: drop_last s.body }.

Milestone 2 — feast

Eating is moving without the haircut: the new head appears, and the whole old body stays. Length grows by one — the only way a snake ever grows.

Write eat : snake -> snake: new head from delta s.dir, old body kept whole behind it. No drop_last anywhere near this one.

It's move minus the drop_last — the old body becomes the new tail, whole.
{ s with body = (hx + dx, hy + dy) :: s.body }.

Milestone 3 — the world is a doughnut

Our snake doesn't crash into walls; it wraps around and reappears on the far side. "Bring this coordinate back into 0…19" smells like mod — but OCaml's mod keeps the sign of its left operand, and a snake exiting the top edge has y = −1. Watch the trap, and its disarm:

Write wrap : int -> int -> int * int -> int * int: wrap w h (x, y) brings the cell into the w × h grid — x into 0…w−1 and y into 0…h−1 — using exactly the idiom above on each coordinate.

Same recipe twice: once with w for x, once with h for y; return the pair.
(((x mod w) + w) mod w, ((y mod h) + h) mod h).

Milestone 4 — don't bite yourself

The one honourable death: the head lands on a cell the rest of the body occupies. Note the phrasing — the rest. The head is always a member of the whole body (it's the first cell!), so compare it against the tail only. Lesson 2.3's contains — or List.mem — finishes this in one line.

Write bites_self : snake -> bool: true exactly when the head cell also appears somewhere in the tail (everything after the head). Empty and one-cell snakes can't bite themselves.

Split with match s.body with | [] -> … | head :: tail -> ….
List.mem head tail — membership in the tail, not the whole body.

Milestone 5 — the tick that runs it all

Final assembly: one pure function that plays a whole turn, in the style of lesson 2.4. The starter hands you everything you've built — delta, wrap, drop_last, the grid constants — and you compose. Given the food's position, one tick means:

  1. A dead snake doesn't move: return s unchanged.
  2. Compute the next head: old head + delta s.dir, then wrap grid_w grid_h.
  3. If the next head is on the food, keep the whole old body behind it (feast); otherwise drop_last it (slide). Call that remainder rest.
  4. The new body is next :: rest — and the snake stays alive only if next is not in rest.

Write tick : int * int -> snake -> snake — tick food s plays one full turn following the four steps above.

Skeleton: if not s.alive then s else match s.body with … — the guard first, then the head.
let rest = if next = food then s.body else drop_last s.body in — the whole feast/slide decision in one line.
Finish with { s with body = next :: rest; alive = not (List.mem next rest) }.

Play it

Deep breath. Below is the complete game: your five milestones plus a steering function reading the arrow keys (Input.is_down, with 180°-turns forbidden — no instant self-bite), a score, and a clock that accumulates dt and fires a tick every 0.14 s — the fixed timestep the coach trailed last lesson. Food comes from a pantry: a plain list of upcoming positions, cycled forever. No Random anywhere — the same game replays identically every Run. That's not a limitation; that's your determinism superpower wearing its work clothes. Click the canvas so it hears the keyboard, and press Run.

Read tick closely — it's your milestone 5 with score and pantry folded in. (List.iter in draw is List.map's sibling for actions — draw each cell, keep nothing.) One last break-on-purpose before shipping: misspell a field in an update and see how gently OCaml catches you falling:

There is no field alve within type snake… Hint: Did you mean alive? The compiler proofreads your game.

Take it home

A game this good deserves to live outside a lesson page. Port it into your canvas-starter template from lesson 1.3 — every Draw/Input/Game call works the same there, so it's a copy-paste of the sample plus your curiosity. The exact template paths and the serve command are in Setup; the ritual is:

Then open the served page, and bring in the family HUD: Maks's scoreboard from lesson 1.5 belongs on top of this canvas — an HTML overlay showing the score, and a proper GAME OVER banner with actual CSS dignity. Finish by changing one rule and rebuilding: that edit-build-play loop is the whole game-dev life, in miniature.

Checkpoint quiz

Loot collected

Phase 2 complete — you can build a world that never mutates and still dances. Phase 3 teaches your code some architecture: modules, interfaces, and engines you can hot-swap.