Files
functional-programming-snake/README.md
T
Lucas WintherandClaude Fable 5.2 5062838604 chore: keep the fixture generator scripts and the replay-sample bot
scripts/fixtures/ holds the scripts that produced the 2026-11-11
fixtures, with a README on running them safely into a copy. Run in
order they reproduce the content of every fixture they touch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:13:03 +01:00

21 KiB
Raw Blame History

Pure Snake — a functional programming course

Build a complete, playable Snake game in which everything is pure: the rules, the randomness, the drawing, the clock, the keyboard, and the program itself. Then convert a second game, Pong, on your own, using the method you practised, and a third, Conway's Game of Life, which you play with the mouse. A bonus module turns your own random generator into a property-based testing tool and uses it to hunt bugs in the game.

You can follow the course in JavaScript (the primary track), Scala 3 or F#. Each track has its own test suites, and all three share the same text and the same test cases.

Who it is for

Bachelor's and master's students in computer science with general programming experience: you can work with variables, loops, functions, classes and objects, collections, callbacks or event handlers, and you have written some unit tests. No prior functional programming is assumed, and neither is any particular language: Module 1 introduces map, filter, reduce, immutable data and functions as values from scratch, and the text connects each new idea to programming you already know. Strong JavaScript is not required.

The course is designed for working alone at home, either solo or with an AI assistant as a Socratic mentor (see How to study).

By the end you can take a game written the usual way (snake.currentPos = ..., apple.pos = ...) and convert it into a purely functional program, and explain each step of how and why, because you have done it twice.

The idea in one paragraph

The game state is one immutable value. Everything that can happen (a clock tick, a key press) is an Action value, and one pure function update(state, action) computes the next state, so a whole game is a fold over its actions. Another pure function view(state) describes the picture as a list of shapes. Effects are values too: an IO describes "draw this rectangle" or "give me a random seed" without doing it, and a Stream describes how to react to inputs over time without running. The whole program is a single value main : IO<Stream<IO<unit>>>. Only one small runtime file at the very edge, with exactly one mutable variable, interprets it against the browser. You write every piece of this yourself, including IO and Stream, with no libraries.

  PURE CORE                                              EFFECTS AS VALUES
  GameState --update(state, action)--> GameState          IO<A>      "draw", "random seed"
      |                                                   Stream<A>  Input -> (outputs, next stream)
      +--view(state)--> Scene (list of shapes)            main : IO<Stream<IO<unit>>>
                                                                 |
  ---------------------------------------------------------------+------------------------
  THE EDGE: runtime (runIO, runApp), one mutable variable        v
      keys, time  --->  steps the stream, runs each emitted IO  --->  pixels on a <canvas>

Prerequisites and installs

You need Git and the toolchain of your track (installing all three is fine).

Track Install Check Notes
JavaScript Node.js 22 or newer (tested on Node 24) node --version No npm packages. Tests use Node's built-in runner.
Scala 3 scala-cli scala-cli version Downloads a JDK, Scala 3.5.2 and munit on first use (slow once, fast afterwards). The browser build uses Scala.js through scala-cli.
F# .NET SDK 10 dotnet --version Restores Expecto from NuGet on the first test run. The browser build uses Fable, a local .NET tool restored by build-web.sh.

To play the game in any track you also need a modern browser. Module 0 walks you through the setup.

Quick start

Run the tests (from the repository root):

(cd js && node --test "test/m03-*.test.js")   # JavaScript, one module   (all: ./test.sh)
(cd scala && ./test.sh M03)                   # Scala, one module        (all: ./test.sh)
(cd fsharp && ./test.sh M03)                  # F#, one module           (all: ./test.sh)

In a fresh checkout, Module 0's tests pass and every other module's tests fail: the files in src/ are stubs for you to fill in.

Run the game (it is playable once you have finished Module 9; the replay viewer after Exercise 10.8, Pong after Module 11, Life after Module 13):

node scripts/serve.mjs                        # from the repository root; serves on http://localhost:8000

Rebuild with build-web.sh after changing Scala or F# code. Arrows or WASD move, Space pauses, R restarts (pause and restart arrive in Module 10). In Life, click cells; Space runs, N steps, R randomizes, C clears.

Module map

# Module Main idea Files you edit (JS · Scala · F#)
0 Setup and how to use this course the architecture, running tests, the purity rules none
1 Purity and immutability referential transparency; build new data instead of changing it util.js · Util.scala · Util.fs
2 The game as data the whole game is one value; algebraic data types model.js · Model.scala · Model.fs
3 Pure game rules rules as total functions State → State rules.js · Rules.scala · Rules.fs
4 Randomness without side effects a seeded generator seed → (value, seed); the State monad random.js, spawn.js · Random.scala, Spawn.scala · Random.fs, Spawn.fs
5 Folding time a game is a fold of update over its actions; replays for free game.js · Game.scala · Game.fs
6 Rendering as data view returns a description of the picture view.js · View.scala · View.fs
7 The IO type effects as values; interpreters; monad laws io.js · IO.scala · IO.fs
7b Stack-safe IO (optional) flatMap as data; re-association; a trampolined interpreter; no later module needs it safeio.js · SafeIO.scala · SafeIO.fs
8 Event streams streams as pure automata (Mealy machines) stream.js · Stream.scala · Stream.fs
9 Wiring it together the program is one value; the runtime is the only impure file input.js, app.js, runtime.js, browser.js · Input.scala, App.scala, Runtime.scala · Input.fs, App.fs, Runtime.fs
10 Extensions, all pure pause, restart, speed-up, surviving a background tab and a turn queue without touching the edge; then a persisted high score, the first new effect; and a replay viewer edits game, input, stream, app; for the high score also io, runtime (and browser.js in JS); new replay.js · Replay.scala · Replay.fs
11 Capstone: Pong, by the method convert a second game on your own pong/ · pong/ · Pong/
12 Testing with properties (bonus) generators are Random values; a failing game is a replay you can shrink check.js · Check.scala · Check.fs
13 Capstone: Conway's Life, with the mouse a third conversion; the next generation from the whole old board; a new kind of input at the edge life/ · life/ · Life/; edits runtime (and browser.js in JS)
— The method the conversion recipe, a worked example, a checklist read after Module 9, before Module 11
— Reference every API in the three languages side by side look things up here

JS files are in js/src/, Scala files in scala/src/, F# files in fsharp/src/.

How to study

Each module explains why before how, then gives exercises with signatures in all three languages, folded hints in three tiers (nudge, stronger hint, near-solution skeleton), and "Check your understanding" questions. Work through the modules in order; each one builds on the previous.

  • Solo. The text, the hints and the tests carry you. Open hints one tier at a time. Before moving on, answer the "Check your understanding" questions out loud or on paper; if you cannot answer one, re-read that part.
  • With an AI mentor. MENTOR.md tells an AI assistant to act as a Socratic mentor: it asks questions before explaining, gives hints in the order of the hint tiers, asks you to predict and explain test results, and does not write your solution code unless you explicitly give up on an exercise. Many coding assistants read it automatically through AGENTS.md / CLAUDE.md. Otherwise, start with: "Read MENTOR.md and be my mentor for Module 3 in the Scala track."

Checkpoint policy

The reference solutions are in js/solutions/, scala/solutions/ and fsharp/solutions/, with the same file names as src/. They are there so that you are never stuck for good, and so that a mentor can evaluate your work. Do not read them before you have tried.

If you are stuck on a module: re-read the stub's doc comment and the module's Concepts, work the failing fixture case on paper, open the hints one tier at a time, ask your mentor. Only then copy that module's files from <track>/solutions/ into <track>/src/, for example:

cp js/solutions/rules.js js/src/rules.js
cp scala/solutions/Rules.scala scala/src/Rules.scala
cp fsharp/solutions/Rules.fs fsharp/src/Rules.fs

Tests are organised per module, so everything earlier stays green and you can continue with the next module. Afterwards, read the solution carefully, compare it with your attempt, close it, and write the function again from memory a day later.

The files of each module (this mapping is also what scripts/check-progression.sh uses):

Module js/solutions/ scala/solutions/ fsharp/solutions/
01 util.js Util.scala Util.fs
02 model.js Model.scala Model.fs
03 rules.js Rules.scala Rules.fs
04 random.js, spawn.js, state.js (optional Exercise 4.7) Random.scala, Spawn.scala, State.scala Random.fs, Spawn.fs, State.fs
05 game.js Game.scala Game.fs
06 view.js View.scala View.fs
07 io.js IO.scala IO.fs
07b safeio.js (optional module) SafeIO.scala SafeIO.fs
08 stream.js Stream.scala Stream.fs
09 input.js, app.js, runtime.js, browser.js (main.js is already complete in src/) Input.scala, App.scala, Runtime.scala Input.fs, App.fs, Runtime.fs
10 replay.js (replay-main.js is already complete in src/); Module 10 also extends game, input, stream, app, io, runtime and (JS) browser.js Replay.scala Replay.fs
11 the folder pong/ the folder pong/ the folder Pong/
12 check.js Check.scala Check.fs
13 the folder life/; Module 13 also adds the click to runtime.js and browser.js the folder life/ (and the click in Runtime.scala) the folder Life/ (and the click in Runtime.fs)

Two things to know:

  • A solution file is the finished file. game, input, stream, app, io, runtime and browser.js already contain the Module 10 extensions (and runtime and browser.js the Module 13 click), so taking the Module 5, 7, 8 or 9 checkpoint shows you those parts early. If you take one, skip reading the Module 10 and 13 parts, or take the later checkpoint the same way. Copy runtime.js and browser.js together: the finished runtime.js asks the platform for onClick, which a Module 9 browser.js does not have yet.
  • The F# project files (Snake.fsproj), the browser glue (scala/web/, fsharp/web/, js/src/main.js, js/src/pong/main.js, js/src/replay-main.js, js/src/life/main.js) and the JS type declarations (js/src/types.d.ts, the same in both trees) are not exercises; never copy them.

Repository layout

README.md                 this page
MENTOR.md                 instructions for an AI acting as a Socratic mentor
AGENTS.md, CLAUDE.md      pointers to MENTOR.md for coding assistants
course/                   the text: 00-setup.md ... 13-capstone-life.md, method.md, reference.md
fixtures/                 shared test cases as JSON (mNN-<function>.json), read by all three tracks; replay-sample.json, the recording the replay viewer plays
js/       src/            your code (stubs), one file per topic; src/pong/ for Module 11, src/life/ for Module 13
          solutions/      reference solutions, same file names
          test/           mNN-*.test.js, purity.test.js; test.sh
          index.html, pong.html, replay.html, life.html
          jsconfig.json, src/types.d.ts   optional type checking in the editor (given, not exercises)
scala/    src/, solutions/, test/ (MNN*.test.scala, Purity.test.scala), test.sh, project.scala
          web/            browser glue (given) and build output; build-web.sh; index.html, pong.html
fsharp/   src/, solutions/ (with Snake.fsproj), tests/ (Snake.Tests.fsproj, MNN*.fs, Purity.fs), test.sh
          web/            Fable project and browser glue (given), build output; build-web.sh; index.html, pong.html
scripts/  serve.mjs       a tiny static web server (port 8000) for playing the game
          verify.sh, check-stubs.sh, check-progression.sh, smoke.mjs, check-links.mjs,
          mutate.mjs (+ mutate-allow.txt), typecheck-js.sh, fixtures/ (generators), e2e.sh                    maintainer tools
e2e/                      maintainer tooling: Playwright end-to-end tests of every track's pages against the JS model

For maintainers

These scripts check the course itself; students do not need them. Run them from the repository root. The rules the course code must follow are in docs/contract.md; the current state and working method are in docs/HANDOFF.md.

Script What it checks
scripts/verify.sh <js|scala|fsharp> Copies the repo to a temp dir, replaces src/ with solutions/, runs the track's whole suite. Must pass.
scripts/check-stubs.sh <track> Runs each module's tests (M01–M13 and the optional M07b) against the stubs in src/. Every module must fail; tooling or build errors exit 2 so they cannot pass for "fails".
scripts/check-progression.sh <track> For each module N, puts the solutions of modules ≤ N into src/ (the rest stay stubs) and runs the tests of M01..MN. Proves that each checkpoint is self-contained.
node scripts/smoke.mjs <url> Opens a page in headless Chrome (DevTools protocol, no dependencies) and checks that it throws no errors and paints the canvas. Serve a copy of the repo whose src/ holds the solutions (Scala/F#: after build-web.sh) and check js/, scala/, fsharp/ index.html, pong.html, replay.html and life.html. Set CHROME if Chrome is not at /usr/bin/google-chrome.
node scripts/check-links.mjs Checks every http(s) link in the Markdown files (HEAD, then GET, following redirects) and every relative link and anchor. --offline checks only the relative ones.
scripts/typecheck-js.sh Type-checks the JS track with TypeScript (js/jsconfig.json, types in js/src/types.d.ts, the @type lines in the doc comments): once the untouched stubs in js/src (a student's starting point shows no errors), once a temp copy with the solutions as src. Both must be clean. Nothing is installed in the repo; it runs npx -y -p typescript tsc, so it needs the network the first time and exits 2 with a message when it cannot run. TS_PACKAGE=typescript@5.9 pins a version.
scripts/e2e.sh [--tracks js,scala,fsharp] [-- <playwright args>] End-to-end tests with Playwright (e2e/, maintainer tooling; the course tracks stay free of dependencies). Copies the repo to a temp dir with the solutions as src/, runs build-web.sh for Scala and F#, serves the copy on a free port and runs every track's four pages in Chromium and WebKit (Firefox too if a build is installed; browsers are never downloaded, a missing one is skipped with a message). Each page gets a fixed seed, a paused fake clock and a recorder of every fillRect/fillText; the pure JS solutions, run in Node on the same seed and inputs, say what must be drawn, and the page must match them frame by frame and shape by shape. Snake: first frame, step timing, a scripted game past the third apple, the turn queue, pause, game over and restart, the 250 ms time clamp, the best score (saved, reloaded, garbage or throwing storage); Pong: held keys, a whole match, restart; replay viewer: keys and autoplay; Life: clicks on a CSS-scaled canvas, N, Space, C, R; no page or console errors anywhere. The first run installs @playwright/test into e2e/node_modules (network once). --base-url <url> tests a copy you serve yourself. Exit code is Playwright's; a failure prints the first frame and shape where page and model differ. It does not replace a person playing.
node scripts/mutate.mjs [file...] Mutation analysis of the JS suite: makes single-token mutants of the js/solutions files (default: all but the browser glue; or the files named, relative to js/solutions), runs the whole JS suite against each in a temp copy, and prints killed/survived with every survivor's file, line, original and mutated text. Exits 1 if a survivor is not in scripts/mutate-allow.txt, the list of known equivalent mutants with a reason per line; exits 2 if the suite already fails without mutants (--skip-failing skips those tests). --jobs N sets the parallelism.

Conventions: student code uses no FP libraries; src/ and solutions/ contain the same file names; the purity audit test of each track bans mutation, reassignment, randomness, clocks and I/O outside the runtime files; pure-logic test cases live in fixtures/ so the three tracks cannot drift apart.

Credits and sources

The ideas in this course are not new; each module ends with links to where they come from. The main ones: