Files
functional-programming-snake/course/00-setup.md
T
2026-10-29 01:43:48 +01:00

23 KiB

Module 0 — Setup and how to use this course

Time: ~1 h · You'll edit: nothing yet · Tests: JS cd js && node --test "test/m00-*.test.js" · Scala cd scala && ./test.sh M00 · F# cd fsharp && ./test.sh M00 Prerequisites: one course in Scala (or similar): you know map, filter, fold, case classes and pattern matching

Why this module

You probably already know how to write Snake. Something like this:

// The usual way. Every line changes something somewhere.
let snake = [{ x: 10, y: 10 }, { x: 9, y: 10 }]
let apple = { x: 3, y: 4 }
setInterval(() => {
  const head = { x: snake[0].x + dx, y: snake[0].y + dy }
  snake.unshift(head)
  if (head.x === apple.x && head.y === apple.y) apple = randomCell()
  else snake.pop()
  draw()
}, 150)
document.addEventListener('keydown', (e) => { /* change dx, dy */ })

It works. But try to answer these questions about it:

  • How would you write a test that checks "the snake grows when it eats"? You would have to set global variables, wait for a timer, and somehow control randomCell().
  • A player reports "the snake died when it shouldn't have". Can you reproduce exactly what happened?
  • What does draw() depend on? What does it change? You have to read the whole program to know.

In this course you write the same game in a way where every one of those questions has an easy answer. The game rules, the randomness, the drawing, the clock, the keyboard and even "the program" itself are ordinary values and pure functions. Only one tiny file at the very edge actually does anything. By the end you will have done the conversion yourself, step by step, and you will be able to explain why each step helps.

This module gets your tools working and shows you how the course is organised. There is nothing to implement yet.

Learning objectives

After this module you can:

  • describe the architecture you are going to build, and which part of it is allowed to have side effects;
  • run the tests of one module, or all of them, in your chosen track;
  • read a red test and find the fixture case it is checking;
  • explain what the purity audit forbids and why;
  • choose between solo mode and AI-mentor mode, and know when to use the checkpoints in solutions/.

Concepts

What you will build

First Snake: a 20 x 20 board, a snake that moves one cell per tick, apples that appear on random free cells, a score, pause, restart, a speed-up as you score. It runs in the browser on a <canvas>.

Then, in the capstone (Module 11), Pong: you convert a second game on your own, applying a recipe you have practised through the whole course.

The big picture

Here is the whole program you are going to build. Keep coming back to this picture; every module fills in one box.

  +-------------------------------- PURE CORE ----------------------------------+
  |                                                                             |
  |   m02 model      m03 rules          m04 random, spawn     m05 game          |
  |   GameState  --> turn, advance  --> spawnApple, tick  --> update(state, a)  |
  |                                                           replay, history   |
  |                                                                 |           |
  |                                                    m06 view     v           |
  |                                         view : State -> Scene (shapes)      |
  |                                                                             |
  |   EFFECTS AS VALUES (descriptions, nothing happens yet)                     |
  |   m07 IO<A>      "draw this rectangle", "give me a random seed"             |
  |   m08 Stream<A>  a pure automaton: Input -> (outputs, next stream)          |
  |   m09 main : IO<Stream<IO<unit>>>   -- the whole program, as ONE value     |
  +-------------------------------------+---------------------------------------+
                                        |  main
                                        v
  +---------------- THE EDGE (m09 runtime, the only impure file) ---------------+
  |  runIO / runApp: interprets IO, feeds clock ticks and key presses into the  |
  |  stream, performs the drawing. One mutable variable, about a page of code.  |
  +-------------------------------------+---------------------------------------+
                         ^ keys, time   |  pixels
                         |              v
                   browser (canvas, keyboard, clock, crypto)

Read it from the top. The pure core is plain data and plain functions: given a state and an action, update computes the next state; given a state, view computes a list of shapes. Below that, effects as values: an IO is a description of something to do (like a recipe), and a Stream is a description of how to react to inputs over time. Building these descriptions changes nothing in the world. Finally, at the edge, a small runtime takes the one value main and actually runs it.

From Scala: if you have heard of cats-effect or ZIO, this is the same idea in miniature. You will build IO and Stream yourself, from scratch, in about a hundred lines each, so there is nothing magic left.

This design is sometimes called functional core, imperative shell (Gary Bernhardt). We push it further than usual: even the shell is mostly pure, and the truly imperative part is one function.

Choosing a track

The course text is shared. Every code listing that you would write appears three times, always in this order: JavaScript, Scala, F#. Pick one track and stay with it; you can read the others for comparison.

Track Why pick it Tests with Runs in the browser via
JavaScript (primary) No compiler, no types: you must hold the discipline yourself, which teaches the most. Zero dependencies. Node's built-in node --test plain ES modules
Scala 3 Closest to what you already know. The compiler helps you (exhaustive matches, immutable by default). munit via scala-cli Scala.js
F# An ML-family language: lighter syntax, data-last functions and pipelines. A good second functional language. Expecto via dotnet Fable

If you are unsure, take JavaScript. It is the primary track, and Module 1 bridges from Scala to JavaScript. The three tracks use the same names where they can. Where they differ, the text tells you the first time it matters, and course/reference.md has the full table. The most common differences:

  • JS functions take the data first: map(io, f). Scala uses methods: io.map(f). F# takes the data last so that pipelines work: io |> IO.map f.
  • flatMap is called bind in F#.
  • "No apple" is null in JS and None (an Option) in Scala and F#.
  • Random seeds are number in JS, Long in Scala, uint32 in F#.

Installing the tools

You need Git and the toolchain for your track. You can install all three if you like.

JavaScript: Node.js 22 or newer (the course is tested on Node 24). Download it from nodejs.org/en/download. There are no npm packages to install.

node --version     # v22.x or higher

Scala: scala-cli. It downloads a JDK, the Scala 3 compiler and the test library by itself the first time you run it, so the first run is slow; later runs are fast.

scala-cli version

F#: the .NET SDK 10 (installation help: learn.microsoft.com/dotnet/core/install). The first test run restores the Expecto package from NuGet.

dotnet --version   # 10.x

An editor with language support helps a lot: VS Code with the Metals extension (Scala) or Ionide (F#), or IntelliJ / Rider. For JavaScript any editor is fine.

A tour of the repository

README.md            front page
MENTOR.md            instructions for an AI mentor (see "Solo or with a mentor")
course/              this text: 00-setup.md ... 11-capstone-pong.md, reference.md, method.md
fixtures/            shared test cases as JSON, used by all three tracks
js/      src/        YOUR code: stubs to fill in, one file per topic
         solutions/  reference solutions, same file names (try first!)
         test/       tests, one file per module: m03-rules.test.js, ...
scala/   src/  solutions/  test/    (same idea; files are PascalCase: Rules.scala)
fsharp/  src/  solutions/  tests/   (same idea: Rules.fs)
scripts/serve.mjs    a tiny web server for playing the game in the browser

Four folders matter:

  • src/ is where you work. Every file already exists with the final function names, signatures and a doc comment that says what the function must do. The bodies are placeholders: todo('m03') in JS, ??? in Scala, failwith "TODO m03" in F#. Your job is to replace them.
  • test/ (F#: tests/) holds the tests. Each module has its own test file, so finishing Module 3 does not depend on Module 6.
  • fixtures/ holds most of the pure-logic test cases as language-neutral JSON. For example fixtures/m03-advance.json contains cases named advance-plain-move, advance-into-vacating-tail, and so on, each with args and the expected result. All three tracks read the same files, so the game behaves identically in every language. When a test fails, the fixture tells you exactly which input was given and what was expected.
  • solutions/ holds a reference solution for every file in src/. It is there so you are never stuck for good (see the checkpoint policy below), and so a mentor can check your work. It is not there to read before you try.

Each module tells you, in its header, which files you will edit. The map is the same in every track:

Module JS file(s) in js/src/ Scala (scala/src/) F# (fsharp/src/)
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 Random.scala, Spawn.scala Random.fs, Spawn.fs
05 game.js Game.scala Game.fs
06 view.js View.scala View.fs
07 io.js IO.scala IO.fs
08 stream.js Stream.scala Stream.fs
09 input.js, app.js, runtime.js, browser.js (main.js is given) Input.scala, App.scala, Runtime.scala Input.fs, App.fs, Runtime.fs
10 edits game, input, app same same
11 pong/ pong/ Pong/

Running the tests

From the repository root:

JavaScript

cd js
node --test "test/m03-*.test.js"   # one module
./test.sh                          # everything, including the purity audit

Scala

cd scala
./test.sh M03                      # one module
./test.sh                          # everything

F#

cd fsharp
./test.sh M03                      # one module
./test.sh                          # everything

If scala-cli prints a hint that the munit dependency is outdated, ignore it: the course pins munit on purpose, for reproducibility.

Try it now with Module 0, which has a few sanity tests that already pass. They check that your toolchain works and that the fixtures folder can be read. If you see three green tests (Scala and F#: a short summary with no failures), you are set up.

What a red test means

Run the Module 3 tests in a fresh checkout. Every one of them fails, and at the bottom Node repeats each failure with details:

✖ m03 move: move-up (0.91ms)
✖ m03 move: move-down (0.13ms)
...
✖ failing tests:

test at test/m03-rules.test.js:9:5
✖ m03 move: move-up (0.91ms)
  Error: TODO: implement in m03
      at todo (.../js/src/todo.js:3:9)
      at move (.../js/src/rules.js:4:33)

This is expected, and it is the starting point of every module. In Scala you see scala.NotImplementedError: an implementation is missing (that is what ??? throws), and in F# an exception with TODO m03.

A red test is information, not a verdict. Read it in this order:

  1. The test name. Test names are sentences written for you: dropLast leaves its input untouched. Fixture tests are named after the function and the fixture case: m03 advance: advance-into-vacating-tail. (That is the JavaScript title; JS puts the module in front. Scala and F# print advance: advance-into-vacating-tail, and their other titles sometimes use their own track's vocabulary, for example bind instead of flatMap. The modules quote the JavaScript titles; each module's "About test names" note says how the other tracks differ.)
  2. The message. TODO: implement in m03 means you have not written that function yet. An assertion failure shows expected versus actual: compare them field by field. With game states, the difference is often in one field (dir, seed, status).
  3. The fixture. For a fixture test, open fixtures/<module>-<function>.json and search for the case name. You will see the exact input. Draw it on paper; the boards in fixtures are deliberately tiny (3 x 2, 5 x 5) so that you can.

A green test does not prove your code is right, and a red test never means you are bad at this. The tests are a conversation partner: they tell you which situation you have not thought about yet.

The purity rules

The purity audit. A test in every track (JS test/purity.test.js, Scala PuritySuite in test/Purity.test.scala, F# tests/Purity.fs) scans your src/ files (comments are ignored) and fails if it finds any of these:

Track Banned outside the runtime files
JS let, var, ++, --, .push(, .pop(, .shift(, .unshift(, .splice(, .sort(, .reverse(, Math.random, Date.now, performance.now, document, window, addEventListener
Scala var, mutable, while, scala.util.Random, System., println
F# mutable, <-, System.Random, DateTime, printf, ref

Only the runtime files (JS runtime.js, browser.js, main.js; Scala Runtime.scala; F# Runtime.fs, plus the web/ folders) may use them.

Why so strict? Each banned word is a way to make a result depend on something other than the function's arguments, or to change something other than the function's result:

  • let/var/++/--/<-/mutable: reassignment. A name that means different things at different times.
  • .push(, .sort( and friends: they change an array in place, so everyone else holding that array sees the change. (Use [...xs, x], toSorted(), toReversed() instead; they return new arrays.)
  • Math.random, Date.now, System.Random, DateTime: hidden inputs. The same call gives a different answer each time.
  • document, window, addEventListener, println, printf: talking to the outside world.

Without let or var you cannot write a classic for (let i = 0; ...) loop. That is deliberate: you will use map, filter, reduce, recursion and small helper functions instead, which is what you know from Scala.

The audit is a blunt text scan. It can be fooled, and it sometimes complains about harmless code (for instance a string containing --). It is a guard rail, not a proof. The real rule is the one behind it: outside the edge, a function's result depends only on its arguments, and calling it changes nothing.

Checkpoints: what to do when you are stuck

Each module builds on the previous ones. If you get stuck, work through this list in order:

  1. Re-read the doc comment in the stub and the "Concepts" section of the module.
  2. Open the failing fixture case and work it out on paper.
  3. Open the module's Hints. From Module 1 on, every coding exercise has three tiers: a nudge, a stronger hint, and a near-solution skeleton with holes. Open them one at a time. (Module 0's setup exercises have only the hints they need.)
  4. Ask your mentor (human or AI), explaining what you tried.
  5. Only then: take the checkpoint. Copy that module's files from solutions/ into src/ (for example cp js/solutions/rules.js js/src/rules.js) and continue with the next module. Because tests are organised per module, all earlier tests stay green.

If you take a checkpoint, read the solution carefully and compare it with your attempt. Then close it and write the function again from memory a day later. That is where the learning happens.

Solo, or with an AI mentor

The course works in two modes.

  • Solo. The text, the tiered hints and the tests carry you. Answer the "Check your understanding" questions at the end of each module out loud or on paper before moving on. If you cannot answer one, that is the part to re-read.
  • With an AI as a Socratic mentor. MENTOR.md at the repository root tells an AI assistant how to help you without doing the work for you: it asks questions before it explains, gives hints in the same order as the hint tiers, asks you to predict and explain, and writes solution code only if you explicitly give up on an exercise. Many coding assistants pick it up automatically (through AGENTS.md / CLAUDE.md); with others, start the conversation with "Read MENTOR.md and be my mentor for Module 3."

A mentor that hands you answers feels productive and teaches very little. If your assistant starts writing your functions for you, remind it of MENTOR.md.

How each module is laid out

Every module has the same parts, in this order:

  1. Why this module: the problem, usually what goes wrong in the stateful version.
  2. Learning objectives: what you should be able to do afterwards.
  3. Concepts: the ideas, with Scala bridges (blockquotes starting with From Scala:) and code in all three languages.
  4. Exercises: the functions to write, with signatures in three languages, the tests and fixtures that check them, and pitfalls.
  5. Hints: folded away; from Module 1 on, every coding exercise has a nudge, a stronger hint and a near-solution. Open them one tier at a time.
  6. Check your understanding: questions you should be able to answer before moving on. Several ask you to predict an output or to say what would break if something changed.
  7. In production: how real libraries and frameworks solve the same problem.
  8. Further reading: links to the sources of the ideas.

Playing the game

You can play the game once Module 9 is done. For reference, this is how:

Exercises

Exercise 0.1 — Green lights

Install the toolchain for your track and run the Module 0 tests. They should pass without any changes.

  • Run: JS cd js && node --test "test/m00-*.test.js" · Scala cd scala && ./test.sh M00 · F# cd fsharp && ./test.sh M00

Exercise 0.2 — Read a red test

Run the Module 3 tests (m03 / M03). Pick the failing test whose name ends in advance-into-vacating-tail. Find that case in fixtures/m03-advance.json and draw the board from args on squared paper: mark the head, the body and the tail. Then draw the expected board. Write one sentence describing what happened between the two pictures. (You will meet this exact case again in Module 3.)

Exercise 0.3 — Break the purity audit on purpose

In your track's Module 1 file (js/src/util.js, scala/src/Util.scala, fsharp/src/Util.fs) add a line that uses a banned construct, for example let counter = 0 (JS), var counter = 0 (Scala) or let mutable counter = 0 (F#). Run the full test suite (./test.sh) and find the purity failure. Then remove the line again and check that the failure is gone. Now you know what the audit looks like when it catches you.

Hints

Exercise 0.1 — nudge

If a command is not found, the tool is not on your PATH. Open a new terminal after installing, and check with node --version, scala-cli version or dotnet --version.

Exercise 0.1 — stronger hint

The first Scala and F# runs download a lot (compiler, JDK, NuGet packages). Let them finish; if they fail with a network error, run them again. Make sure you run the command from inside the track folder (js/, scala/ or fsharp/).

Exercise 0.2 — nudge

snake is a list of points, head first. y grows downward. The fixture board is 5 x 5, so coordinates run from 0 to 4.

Check your understanding

  1. In the big-picture diagram, which box is allowed to change things in the world? Why is it useful that this box is small?
  2. Your friend says "an IO value draws a rectangle". Rephrase that sentence so that it is accurate for this course.
  3. Why do the three tracks share their test cases as JSON files instead of each track writing its own?
  4. A test fails with TODO: implement in m04. What does that tell you, and what does it not tell you?
  5. Why does the purity audit ban .sort( in JavaScript but not .map(?
  6. The purity audit can be fooled. Write down one line of JS that mutates something but would pass the scan. What does that tell you about relying on tools versus understanding the rule?
  7. When would taking a checkpoint from solutions/ be the right decision, and what should you do right after taking one?

In production

The architecture of this course is close to how several real systems are built:

  • The Elm Architecture (guide.elm-lang.org/architecture) structures every Elm web app as model, update : Msg -> Model -> Model and view : Model -> Html. The Elm runtime is the only thing that performs effects. F# programmers use the same pattern through Elmish.
  • Redux (redux.js.org) brought the same idea to JavaScript: state changes only through pure reducers (state, action) => state.
  • cats-effect (typelevel.org/cats-effect) and ZIO (zio.dev) in Scala, with fs2 (fs2.io) for streams, make whole server applications out of IO values and run them with one call at the edge. Your hand-built IO and Stream (Modules 7 and 8) are small cousins of these.

Further reading

  • Functional Core, Imperative Shell — Gary Bernhardt's short screencast on splitting a program into a pure core and a thin effectful shell; this course takes the idea further.
  • Why Functional Programming Matters — John Hughes' classic paper on why higher-order functions and laziness make programs modular. Short and still current.
  • Node.js test runner — documentation for node --test, used by the JS track.
  • munit — the Scala test library used by the Scala track.
  • Expecto — the F# test library used by the F# track.
  • Scala.js and Fable — the compilers that turn the Scala and F# tracks into JavaScript for the browser.