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

177 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
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 who have taken **one course in Scala** (or a similar functional language): you know `map`, `filter` and `fold`, case classes and pattern matching, maybe `Option`. You have probably never built an *interactive* program functionally. Strong JavaScript is not required; Module 1 bridges from Scala.
The course is designed for working **alone at home**, either solo or with an AI assistant as a Socratic mentor (see [How to study](#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](https://nodejs.org/en/download) 22 or newer (tested on Node 24) | `node --version` | No npm packages. Tests use Node's built-in runner. |
| Scala 3 | [scala-cli](https://scala-cli.virtuslab.org/install) | `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](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) | `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](course/00-setup.md) walks you through the setup.
## Quick start
Run the tests (from the repository root):
```sh
(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; Pong after Module 11):
```sh
node scripts/serve.mjs # from the repository root; serves on http://localhost:8000
```
- **JavaScript:** open <http://localhost:8000/js/> (Pong: <http://localhost:8000/js/pong.html>). No build step.
- **Scala:** first `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then open <http://localhost:8000/scala/> (Pong: `/scala/pong.html`).
- **F#:** first `cd fsharp && ./build-web.sh` (the first run restores Fable), then open <http://localhost:8000/fsharp/> (Pong: `/fsharp/pong.html`).
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).
## Module map
| # | Module | Main idea | Files you edit (JS · Scala · F#) |
|---|---|---|---|
| 0 | [Setup and how to use this course](course/00-setup.md) | the architecture, running tests, the purity rules | none |
| 1 | [Purity and immutability](course/01-purity-and-immutability.md) | referential transparency; build new data instead of changing it | `util.js` · `Util.scala` · `Util.fs` |
| 2 | [The game as data](course/02-the-game-as-data.md) | the whole game is one value; algebraic data types | `model.js` · `Model.scala` · `Model.fs` |
| 3 | [Pure game rules](course/03-pure-game-rules.md) | rules as total functions `State → State` | `rules.js` · `Rules.scala` · `Rules.fs` |
| 4 | [Randomness without side effects](course/04-randomness-without-side-effects.md) | a seeded generator `seed → (value, seed)`; the State monad | `random.js`, `spawn.js` · `Random.scala`, `Spawn.scala` · `Random.fs`, `Spawn.fs` |
| 5 | [Folding time](course/05-folding-time.md) | a game is a fold of `update` over its actions; replays for free | `game.js` · `Game.scala` · `Game.fs` |
| 6 | [Rendering as data](course/06-rendering-as-data.md) | `view` returns a description of the picture | `view.js` · `View.scala` · `View.fs` |
| 7 | [The IO type](course/07-the-io-type.md) | effects as values; interpreters; monad laws | `io.js` · `IO.scala` · `IO.fs` |
| 8 | [Event streams](course/08-event-streams.md) | streams as pure automata (Mealy machines) | `stream.js` · `Stream.scala` · `Stream.fs` |
| 9 | [Wiring it together](course/09-wiring-it-together.md) | 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](course/10-extensions.md) | pause, restart, speed-up, added without touching the edge | edits `game`, `input`, `app` |
| 11 | [Capstone: Pong, by the method](course/11-capstone-pong.md) | convert a second game on your own | `pong/` · `pong/` · `Pong/` |
| — | [The method](course/method.md) | the conversion recipe, a worked example, a checklist | read after Module 9, before Module 11 |
| — | [Reference](course/reference.md) | 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](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:
```sh
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` | `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 already complete in `src/`) | `Input.scala`, `App.scala`, `Runtime.scala` | `Input.fs`, `App.fs`, `Runtime.fs` |
| 10 | no new files: Module 10 extends `game`, `input` and `app` | same | same |
| 11 | the folder `pong/` | the folder `pong/` | the folder `Pong/` |
Two things to know:
- A solution file is the **finished** file. `game`, `input` and `app` already contain the Module 10 extensions, so taking the Module 5 or Module 9 checkpoint shows you those parts early. If you take one, skip reading the Module 10 parts, or take the Module 10 checkpoint later the same way.
- The F# project files (`Snake.fsproj`) and the browser glue (`scala/web/`, `fsharp/web/`, `js/src/main.js`, `js/src/pong/main.js`) 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 ... 11-capstone-pong.md, method.md, reference.md
fixtures/ shared test cases as JSON (mNN-<function>.json), read by all three tracks
js/ src/ your code (stubs), one file per topic; src/pong/ for Module 11
solutions/ reference solutions, same file names
test/ mNN-*.test.js, purity.test.js; test.sh
index.html, pong.html
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 maintainer tools
```
## For maintainers
These scripts check the course itself; students do not need them. Run them from the repository root.
| 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–M11) 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` and `pong.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. |
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:
- John Hughes, [Why Functional Programming Matters](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf).
- [The Elm Architecture](https://guide.elm-lang.org/architecture/), the model–update–view pattern this game follows, and [Elmish](https://elmish.github.io/elmish/) for F#.
- Gary Bernhardt, [Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell).
- Paul Chiusano, Rúnar Bjarnason and Michael Pilquist, [Functional Programming in Scala](https://www.manning.com/books/functional-programming-in-scala-second-edition) (purely functional state, `IO`, streams).
- [cats-effect](https://typelevel.org/cats-effect/) and [fs2](https://fs2.io/), the production versions of the `IO` and `Stream` you build.
- Conal Elliott and Paul Hudak, [Functional Reactive Animation](http://conal.net/papers/icfp97/).
- Gabriel Gonzalez, [You could have invented free monads](https://www.haskellforall.com/2012/06/you-could-have-invented-free-monads.html).
- [Mostly Adequate Guide to Functional Programming](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) (FP in JavaScript).
- The mulberry32 generator, by Tommy Ettinger ([gist](https://gist.github.com/tommyettinger/46a874533244883189143505d203312c)); the course's numbers agree with [bryc's JavaScript reference](https://github.com/bryc/code/blob/master/jshash/PRNGs.md).
- Tools: [Node.js test runner](https://nodejs.org/api/test.html), [munit](https://scalameta.org/munit/), [Expecto](https://github.com/haf/expecto), [Scala.js](https://www.scala-js.org/), [Fable](https://fable.io/).