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…
- Assemble a complete snake game from small, tested pure functions
- Wrap coordinates on a grid — taming
modwith negatives - Fold move / eat / wrap / death into one pure
tick - Ship it: play it on this page, then build it locally with dune
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.)
match s.body with (hx, hy) :: _ -> …, and get (dx, dy) from delta s.dir.drop_last from lesson 2.3: three arms — [], [ _ ], c :: rest.{ 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.
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.
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.
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:
- A dead snake doesn't move: return
sunchanged. - Compute the next head: old head +
delta s.dir, thenwrap grid_w grid_h. - If the next head is on the food, keep the whole old body behind it (feast); otherwise
drop_lastit (slide). Call that remainderrest. - The new body is
next :: rest— and the snake stays alive only ifnextis not inrest.
Write tick : int * int -> snake -> snake —
tick food s plays one full turn following the four
steps above.
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.{ 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
- A whole game assembled from five tested pure functions:
move,eat,wrap,bites_self,tick. ((x mod w) + w) mod w— the negative-proof wrap.- Bite-checks happen against the remaining body — the tail tip vacates in the same tick.
- A fixed timestep (
clock+tick_every) turns messy frame times into clean, replayable turns. - Deterministic food from a pantry list: the no-
Randomrule kept the whole game testable.
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.