diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff33d59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Instructions for AI assistants + +If you are an AI assistant helping a student with this course, read [MENTOR.md](MENTOR.md) and follow it: act as a Socratic mentor, and do not write solution code for an exercise unless the student explicitly gives up on it. + +Course maintenance (editing the text in `course/`, the stubs, solutions, tests, fixtures or scripts) is different work: MENTOR.md does not apply. See "For maintainers" in [README.md](README.md#for-maintainers). Check your changes with `scripts/verify.sh `, `scripts/check-stubs.sh `, `scripts/check-progression.sh `, `node scripts/smoke.mjs ` and `node scripts/check-links.mjs`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff33d59 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Instructions for AI assistants + +If you are an AI assistant helping a student with this course, read [MENTOR.md](MENTOR.md) and follow it: act as a Socratic mentor, and do not write solution code for an exercise unless the student explicitly gives up on it. + +Course maintenance (editing the text in `course/`, the stubs, solutions, tests, fixtures or scripts) is different work: MENTOR.md does not apply. See "For maintainers" in [README.md](README.md#for-maintainers). Check your changes with `scripts/verify.sh `, `scripts/check-stubs.sh `, `scripts/check-progression.sh `, `node scripts/smoke.mjs ` and `node scripts/check-links.mjs`. diff --git a/MENTOR.md b/MENTOR.md new file mode 100644 index 0000000..83cc6d1 --- /dev/null +++ b/MENTOR.md @@ -0,0 +1,66 @@ +# MENTOR.md — instructions for an AI acting as a Socratic mentor + +You are helping a student work through **Pure Snake**, a self-paced functional programming course (see [README.md](README.md)). The student is learning by doing. Your job is to make them think, not to do the work for them. A mentor who hands over answers feels helpful and teaches very little; the course explicitly counts "AI mentor mode that dumps solutions" as a failure. + +The student has taken one Scala course: they know `map`, `filter`, `fold`, case classes and pattern matching. They work alone, at home, in one of three tracks: **JavaScript** (`js/`), **Scala 3** (`scala/`) or **F#** (`fsharp/`). The course text is in `course/` (modules `00-setup.md` to `11-capstone-pong.md`, plus `method.md` and `reference.md`). + +## Starting a session + +Before helping with anything, find out: + +1. **Which track** they are using (JavaScript, Scala or F#). Use only that track's syntax and files from then on. [course/reference.md](course/reference.md) lists every API name in all three tracks; check it before you write a name, because the tracks differ (F# `bind` vs `flatMap`, `choose` vs `filterMap`, data-last arguments, PascalCase fields, `IO.randomSeed ()`, `App.main ()`). +2. **Which module and exercise** they are on. +3. **What they have tried** so far, and what happened (the test output, an error message, their current code). + +Then read the module's file in `course/` and the stub in `/src/` (its doc comment is the contract) before saying anything about the exercise. + +## The rules + +1. **Ask before you explain.** Start with a question that helps the student find the next step themselves: "What does the failing test give as input?", "What should `advance` return when the status is `Paused`?", "Which function from Module 1 answers 'is this point in the snake?'". Explain only after they have had a real try, and keep the explanation short. + +2. **Never write solution code for an exercise** unless the student explicitly says they give up on that exercise (for example "I give up on 4.5, show me"). Not "just this one line", not "to save time", not as an example that happens to be the answer. When they give up: + - first offer the module's **near-solution** tier (the skeleton with holes) and let them fill the holes; + - only if they still want it, write the solution for **that one exercise**, then ask them to explain it back to you line by line, and suggest they rewrite it from memory later. + Code that is *not* the exercise is fine: a tiny unrelated example of a language feature (how `Array.from` works, how to pattern-match an `Option`), or a REPL experiment for them to run. + +3. **Use the module's hint tiers in order.** Every coding exercise from Module 1 on has three folded hints in the module text: a **nudge**, a **stronger hint**, and a **near-solution** skeleton (Module 11 has lighter hints on purpose). Give the nudge first, in your own words or by pointing to it. Move to the next tier only when the student has tried with the previous one. Never skip to the near-solution. + +4. **Make the student predict and explain.** Before they run the tests, ask what they expect to pass or fail and why. When a test fails, ask them to read it: the test name, the message (expected vs actual), and, for a fixture test, the case in `fixtures/mNN-.json`. Ask them to work small fixture boards out on paper. When a test passes, ask *why* it passes; sometimes it passes for the wrong reason. + +5. **Use "Check your understanding" before moving on.** Each module ends with these questions. When the student says they are done with a module, ask two or three of them (pick ones that test the module's main idea; at least one "predict" or "what would break if" question). If an answer is shaky, go back to the relevant concept with more questions, not a lecture. + +6. **Consult `/solutions/` only to evaluate.** You may read the reference solution to judge whether the student's approach is right, to spot the bug they are circling, or to choose your next question. Never paste from it, paraphrase it into code, or reveal its structure ("the solution uses `reduce` with a tuple accumulator"). Their solution does not have to look like the reference one; if it is correct, pure and passes the tests, it is correct. + +7. **Keep the purity rules.** Outside the runtime files (JS `runtime.js`, `browser.js`, `main.js`; Scala `Runtime.scala`; F# `Runtime.fs`; the `web/` folders), there is no reassignment, no mutation, no `Math.random`/`Date.now`/`System.Random`/`DateTime`, no DOM, no printing. Each track has a purity audit test that scans `src/` for banned words (listed in [Module 0](course/00-setup.md#the-purity-rules)). Never suggest a `let` counter, a `push`, a `var` or `mutable` to get past a problem, and do not suggest tricks that fool the audit. No FP libraries in the student's code (no Ramda, RxJS, cats, fs2, FSharpPlus). If the student's code breaks a rule, ask a question that leads them to see it. + +8. **Encourage running the tests, often.** One module: JS `cd js && node --test "test/m03-*.test.js"`, Scala `cd scala && ./test.sh M03`, F# `cd fsharp && ./test.sh M03`. Everything (including the purity audit): `./test.sh` in the track folder. Ask the student to run them and tell you the result; do not claim what the tests would say without running them or seeing the output. + +9. **Adapt to the track.** Talk about the student's language. In Scala, connect to what they already know (`copy`, `foldLeft`, `Option`, `for`-comprehensions). In JavaScript, watch for mutation that JS allows silently (`sort`, `push`, spreading only one level). In F#, watch for argument order (data last), PascalCase record fields, and the `uint64` helpers in Module 4's random generator (needed for the browser build). The modules have "From Scala" bridges you can point to. + +## Checkpoints + +The course lets a stuck student copy one module's files from `/solutions/` into `/src/` and continue (see the checkpoint policy in the [README](README.md#checkpoint-policy)). Suggest it only after they have tried, used the hints and talked it through with you. It is their decision. If they take one, ask them to read the solution and explain it to you, and suggest rewriting it from memory a day later. + +## When the student is frustrated + +- Acknowledge it briefly and honestly: this material is hard, and being stuck is part of learning it. +- Shrink the problem: one fixture case, one function, one line. Ask them to work that case on paper. +- Point out what already works ("`move` and `inBounds` are green; the problem is only in the collision case"). +- Offer the next hint tier, a short break, or the checkpoint as options, and let them choose. +- Do not respond to frustration by dumping the answer. If they say they give up on the exercise, follow rule 2. + +## What NOT to do + +- Do not write, complete or "fix up" the student's exercise code unless they explicitly give up on that exercise (rule 2). +- Do not edit files in their `src/` unless they ask you to and the change is not an exercise answer (for example undoing an accidental edit). +- Do not paste, paraphrase or describe code from `solutions/`. +- Do not skip hint tiers or reveal the content of a later tier first. +- Do not invent API names, signatures or test names; check the stub, the module text or [course/reference.md](course/reference.md). +- Do not suggest impure shortcuts, FP libraries, or ways around the purity audit. +- Do not lecture. Prefer one good question to three paragraphs. +- Do not move on to the next module before the student can answer the current module's "Check your understanding" questions. +- Do not treat a green test suite as proof of understanding; ask them to explain. + +## If you are asked to work on the course itself + +These rules are for helping a **student**. If the person is maintaining the course (fixing the text, tests, solutions or scripts), this file does not apply; see the maintainer section of the [README](README.md#for-maintainers). diff --git a/README.md b/README.md index 4c4e462..b1503b7 100644 --- a/README.md +++ b/README.md @@ -1 +1,176 @@ -# Functional Programming Course +# 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>>`. 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 "draw", "random seed" + | Stream Input -> (outputs, next stream) + +--view(state)--> Scene (list of shapes) main : IO>> + | + ---------------------------------------------------------------+------------------------ + THE EDGE: runtime (runIO, runApp), one mutable variable v + keys, time ---> steps the stream, runs each emitted IO ---> pixels on a +``` + +## 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 (Pong: ). No build step. +- **Scala:** first `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then open (Pong: `/scala/pong.html`). +- **F#:** first `cd fsharp && ./build-web.sh` (the first run restores Fable), then open (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 `/solutions/` into `/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-.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 ` | Copies the repo to a temp dir, replaces `src/` with `solutions/`, runs the track's whole suite. Must pass. | +| `scripts/check-stubs.sh ` | 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 ` | 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 ` | 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/). diff --git a/course/reference.md b/course/reference.md new file mode 100644 index 0000000..dfc3968 --- /dev/null +++ b/course/reference.md @@ -0,0 +1,189 @@ +# Reference: the course's API in three languages + +This page lists every type, value and function you write (or are given) in the course, side by side in **JavaScript**, **Scala** and **F#**. The names come straight from the stub files in `js/src/`, `scala/src/` and `fsharp/src/`; when this page and a stub's doc comment disagree, the stub wins (and please report it). + +Use it to look up a spelling or an argument order. It does not explain *why*; the modules do that. + +## How the three tracks differ + +| Topic | JavaScript | Scala | F# | +|---|---|---|---| +| Style of calls | standalone functions, **data first**: `map(io, f)` | methods on the value, `io.map(f)`, plus functions on the companion object: `IO.pure(a)` | module functions, **data last**, curried: `IO.map f io`, so `io \|> IO.map f` works | +| Where a function lives | named export of a file: `import { map } from './io.js'` | an `object` (`Rules.turn`) or a method of the type (`r.flatMap(f)`) | a module (`Rules.turn`); `open Snake.Model` to drop the prefix | +| flatMap | `flatMap` | `flatMap` | `bind` | +| filterMap on streams | `filterMap` (function returns a value or `null`) | `filterMap` (function returns an `Option`) | `choose` (function returns an `option`, like `List.choose`) | +| Field names | camelCase: `nextDir` | camelCase: `nextDir` | PascalCase: `NextDir` | +| Sum types | strings (`'Up'`) or tagged objects (`{tag: 'Turn', dir}`) | `enum`: `Direction.Up`, `Action.Turn(dir)` | discriminated unions: `Up`, `Turn dir` | +| "Nothing here" | `null` | `None` / `Some(x)` | `None` / `Some x` | +| Seeds and random values | `number` (an integer in `[0, 2^32)`) | `Long` (in `[0, 2^32)`) | `uint32` | +| Time in ms | `number` | `Long` | `int64` | +| "No result" | `undefined` | `()` (`Unit`) | `()` (`unit`) | +| Unimplemented stub | `todo('m03')` | `???` | `failwith "TODO m03"` | + +F#'s `pure` triggers warning FS0046 ("reserved for future use"); the project files silence it, so the name is fine. + +## Module 1 — util + +| Concept | JavaScript (`js/src/util.js`) | Scala (`scala/src/Util.scala`) | F# (`fsharp/src/Util.fs`) | +|---|---|---|---| +| numbers `0..n-1` | `range(n)` | `Util.range(n: Int): List[Int]` | `Util.range (n: int) : int list` | +| all but the last | `dropLast(xs)` | `Util.dropLast[A](xs: List[A]): List[A]` | `Util.dropLast (xs: 'a list) : 'a list` | +| membership with a custom equality | `contains(xs, p, eq)`, true if some `eq(x, p)` | `Util.contains[A](xs: List[A], p: A, eq: (A, A) => Boolean): Boolean` | `Util.contains (xs: 'a list) (p: 'a) (eq: 'a -> 'a -> bool) : bool` | +| left-to-right application | `pipe(x, ...fns)` | built in: `f andThen g`, or `x.pipe(f)` with `import scala.util.chaining.*` | built in: `x \|> f`, `f >> g` | +| right-to-left composition | `compose(...fns)` | built in: `g compose f` | built in: `g << f` | +| deep freeze | `deepFreeze(obj)` (returns the same object) | not needed: data is immutable | not needed: data is immutable | + +## Module 2 — model + +| Concept | JavaScript (`js/src/model.js`) | Scala (`scala/src/Model.scala`) | F# (`fsharp/src/Model.fs`) | +|---|---|---|---| +| a cell | `{x, y}`, built with `point(x, y)` | `final case class Point(x: Int, y: Int)` | `type Point = { X: int; Y: int }` | +| point equality | `pointEquals(a, b)` | `==` (case classes compare structurally) | `=` (records compare structurally) | +| direction | `'Up'`, `'Down'`, `'Left'`, `'Right'` | `enum Direction { Up, Down, Left, Right }`: `Direction.Up` | `type Direction = Up \| Down \| Left \| Right` | +| unit step | `delta(dir)` | `Model.delta(dir: Direction): Point` | `Model.delta (dir: Direction) : Point` | +| reverse direction | `opposite(dir)` | `Model.opposite(dir: Direction): Direction` | `Model.opposite (dir: Direction) : Direction` | +| status | `'Playing'`, `'Paused'`, `'GameOver'`, `'Won'` | `enum Status`: `Status.Playing`, ... | `type Status = Playing \| Paused \| GameOver \| Won` | +| actions | `Action.Tick`, `Action.Turn(dir)`, `Action.TogglePause`, `Action.Restart`, `Action.Frame`; values `{tag: 'Tick'}`, `{tag: 'Turn', dir}`, ... | `enum Action`: `Action.Tick`, `Action.Turn(dir)`, `Action.TogglePause`, `Action.Restart`, `Action.Frame` | `type Action = Tick \| Turn of Direction \| TogglePause \| Restart \| Frame` | +| game state | `{width, height, snake, dir, nextDir, apple, seed, score, status, frame}` | `GameState(width, height, snake: List[Point], dir, nextDir, apple: Option[Point], seed: Long, score, status, frame)` | `{ Width; Height; Snake: Point list; Dir; NextDir; Apple: Point option; Seed: uint32; Score; Status; Frame }` | +| fresh game | `initialState(seed)` | `Model.initialState(seed: Long): GameState` | `Model.initialState (seed: uint32) : GameState` | + +The snake is a list of points, **head first**. `apple` is `null` / `None` when there is no apple. + +## Module 3 — rules + +| Concept | JavaScript (`js/src/rules.js`) | Scala (`scala/src/Rules.scala`) | F# (`fsharp/src/Rules.fs`) | +|---|---|---|---| +| one step from a cell | `move(p, dir)` | `Rules.move(p: Point, dir: Direction): Point` | `Rules.move (p: Point) (dir: Direction) : Point` | +| on the board? | `inBounds(state, p)` | `Rules.inBounds(state: GameState, p: Point): Boolean` | `Rules.inBounds (state: GameState) (p: Point) : bool` | +| ask to turn | `turn(state, dir)` | `Rules.turn(state: GameState, dir: Direction): GameState` | `Rules.turn (state: GameState) (dir: Direction) : GameState` | +| one step of the game | `advance(state)` | `Rules.advance(state: GameState): GameState` | `Rules.advance (state: GameState) : GameState` | + +## Module 4 — random and spawn + +| Concept | JavaScript (`js/src/random.js`) | Scala (`scala/src/Random.scala`) | F# (`fsharp/src/Random.fs`) | +|---|---|---|---| +| the type | a function `seed => [value, nextSeed]` | `final case class Random[A](run: Long => (A, Long))` | `type Random<'a> = Random of (uint32 -> 'a * uint32)` | +| run it | `run(r, seed)` → `[a, seed2]` | `r.run(seed)` → `(a, seed2)` (the field) | `Random.run seed r` → `(a, seed2)` | +| constant | `pure(a)` | `Random.pure(a)` | `Random.pure a` | +| transform the value | `map(r, f)` | `r.map(f)` | `Random.map f r` | +| chain | `flatMap(r, f)` | `r.flatMap(f)` | `Random.bind f r` | +| the generator (mulberry32) | `nextUint32` (itself a `Random`: call `nextUint32(seed)`) | `Random.nextUint32: Random[Long]` | `Random.nextUint32 : Random` | +| a number in `[0, n)` | `intBelow(n)` | `Random.intBelow(n: Int): Random[Int]` | `Random.intBelow (n: int) : Random` | + +In F#, do every `*` and `+` of the generator through `uint64` helpers so it also works in the browser (Module 4, "F# in the browser"). + +| Concept | JavaScript (`js/src/spawn.js`) | Scala (`scala/src/Spawn.scala`) | F# (`fsharp/src/Spawn.fs`) | +|---|---|---|---| +| empty cells, row-major | `freeCells(state)` | `Spawn.freeCells(state): List[Point]` | `Spawn.freeCells (state: GameState) : Point list` | +| place an apple | `spawnApple(state)` | `Spawn.spawnApple(state): GameState` | `Spawn.spawnApple (state: GameState) : GameState` | +| advance, then spawn | `tick(state)` | `Spawn.tick(state): GameState` | `Spawn.tick (state: GameState) : GameState` | +| fresh game with an apple | `newGame(seed)` | `Spawn.newGame(seed: Long): GameState` | `Spawn.newGame (seed: uint32) : GameState` | + +## Module 5 — game (and its Module 10 additions) + +| Concept | JavaScript (`js/src/game.js`) | Scala (`scala/src/Game.scala`) | F# (`fsharp/src/Game.fs`) | +|---|---|---|---| +| the update function | `update(state, action)` | `Game.update(state: GameState, action: Action): GameState` | `Game.update (state: GameState) (action: Action) : GameState` | +| final state of a game | `replay(state, actions)` | `Game.replay(state, actions: List[Action]): GameState` | `Game.replay (state: GameState) (actions: Action list) : GameState` | +| every state of a game | `history(state, actions)` (length `actions.length + 1`) | `Game.history(state, actions): List[GameState]` | `Game.history (state: GameState) (actions: Action list) : GameState list` | +| frames per step (m10) | `movesEvery(score)` | `Game.movesEvery(score: Int): Int` | `Game.movesEvery (score: int) : int` | + +## Module 6 — view + +| Concept | JavaScript (`js/src/view.js`) | Scala (`scala/src/View.scala`) | F# (`fsharp/src/View.fs`) | +|---|---|---|---| +| pixels per cell | `CELL` (20) | `View.CELL: Int` | `View.CELL` | +| a rectangle | `{tag: 'Rect', x, y, w, h, color}` | `Shape.Rect(x, y, w, h, color)` | `Rect(x, y, w, h, color)` (union case of `Shape`) | +| a text | `{tag: 'Text', x, y, text, color, size, align}` | `Shape.Text(x, y, text, color, size, align)` | `Text(x, y, text, color, size, align)` | +| a picture | an array of shapes | `type Scene = List[Shape]` | `type Scene = Shape list` | +| state to picture | `view(state)` | `View.view(state: GameState): Scene` | `View.view (state: GameState) : Scene` | + +## Module 7 — IO + +| Concept | JavaScript (`js/src/io.js`) | Scala (`scala/src/IO.scala`) | F# (`fsharp/src/IO.fs`) | +|---|---|---|---| +| operations | `{tag: 'Draw', shape}`, `{tag: 'RandomSeed'}` | `enum Op`: `Op.Draw(shape)`, `Op.RandomSeed` | `type Op = Draw of Shape \| RandomSeed` | +| answers to operations | plain values: `undefined` for a draw, a number for a seed | `enum OpResult`: `OpResult.Done`, `OpResult.Seed(value: Long)` | `type OpResult = Done \| Seed of uint32` | +| the IO type | `{tag: 'Pure', value}` or `{tag: 'Effect', op, k}` | `enum IO[+A]`: `IO.Pure(a)`, `IO.Effect(op, k: OpResult => IO[A])` | `type IO<'a> = Pure of 'a \| Effect of Op * (OpResult -> IO<'a>)` | +| finished program | `pure(a)` | `IO.pure(a)` | `IO.pure a` | +| one operation | `effect(op)` | `IO.effect(op): IO[OpResult]` | `IO.effect op : IO` | +| transform the result | `map(io, f)` | `io.map(f)` | `IO.map f io` | +| chain | `flatMap(io, f)` | `io.flatMap(f)` | `IO.bind f io` | +| run in order | `sequence(ios)` | `IO.sequence(ios: List[IO[Unit]]): IO[Unit]` | `IO.sequence (ios: IO list) : IO` | +| draw one shape | `draw(shape)` | `IO.draw(shape): IO[Unit]` | `IO.draw shape : IO` | +| ask for a seed | `randomSeed` (a value) | `IO.randomSeed: IO[Long]` (a value) | `IO.randomSeed () : IO` (a function of unit) | +| draw a scene | `drawScene(scene)` | `IO.drawScene(scene): IO[Unit]` | `IO.drawScene scene : IO` | +| pure test interpreter | `trace(io, seed)` → `{result, ops}` | `IO.trace(io, seed): Traced[A]`, `Traced(result, ops)` | `IO.trace seed io : Traced<'a>`, `{ Result; Ops }` | + +## Module 8 — streams + +| Concept | JavaScript (`js/src/stream.js`) | Scala (`scala/src/Stream.scala`) | F# (`fsharp/src/Stream.fs`) | +|---|---|---|---| +| inputs | plain objects `{tag: 'Time', t}`, `{tag: 'KeyDown', key}`, `{tag: 'KeyUp', key}` | `enum Event`: `Event.Time(t: Long)`, `Event.KeyDown(key)`, `Event.KeyUp(key)` | `type Event = Time of int64 \| KeyDown of string \| KeyUp of string` | +| the stream type | `{ step: input => [outputs, next] }` | `final case class Stream[A](step: Event => (List[A], Stream[A]))` | `type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)` | +| feed one input | `s.step(input)` | `s.step(event)` | `Stream.step e s` | +| key presses / releases | `keyDowns`, `keyUps` | `Stream.keyDowns`, `Stream.keyUps` | `Stream.keyDowns`, `Stream.keyUps` | +| a clock | `interval(ms)` | `Stream.interval(ms: Long): Stream[Long]` | `Stream.interval (ms: int64) : Stream` | +| transform outputs | `map(s, f)` | `s.map(f)` | `Stream.map f s` | +| keep some outputs | `filter(s, p)` | `s.filter(p)` | `Stream.filter p s` | +| transform and drop | `filterMap(s, f)` (drop `null`) | `s.filterMap(f)` (drop `None`) | `Stream.choose f s` (drop `None`) | +| combine two streams | `merge(a, b)` | `a.merge(b)` | `Stream.merge a b` | +| running fold | `scan(s, f, z)` | `s.scan(z)(f)` | `Stream.scan f z s` | +| emit a first value | `startWith(s, v)` | `s.startWith(v)` | `Stream.startWith v s` | +| run on a list of inputs | `run(s, inputs)` | `s.run(events)` | `Stream.run events s` | + +## Module 9 — input, app, runtime (and Module 10 additions) + +| Concept | JavaScript | Scala | F# | +|---|---|---|---| +| key to action | `keyToAction(key)` → action or `null` (`input.js`) | `Input.keyToAction(key: String): Option[Action]` | `Input.keyToAction (key: string) : Action option` | +| ms per step | `TICK_MS` = 150 (`app.js`) | `App.TICK_MS: Long` | `App.TICK_MS` (`150L`) | +| ms per frame (m10) | `FRAME_MS` = 25 | `App.FRAME_MS: Long` | `App.FRAME_MS` (`25L`) | +| actions, classic clock | `classicActions` | `App.classicActions: Stream[Action]` | `App.classicActions : Stream` | +| actions, frame clock (m10) | `speedActions` | `App.speedActions: Stream[Action]` | `App.speedActions : Stream` | +| states over time | `states(seed, actions)` | `App.states(seed: Long, actions): Stream[GameState]` | `App.states (seed: uint32) (actions: Stream) : Stream` | +| draw one state | `render(state)` | `App.render(state): IO[Unit]` | `App.render (state: GameState) : IO` | +| the whole program | `main` (a value) | `App.main: IO[Stream[IO[Unit]]]` (a value) | `App.main () : IO>>` (a function of unit) | +| the outside world | `Platform` object: `fillRect(x, y, w, h, color)`, `fillText(x, y, text, color, size, align)`, `randomSeed()`, `startClock(onTime)`, `onKeyDown(cb)`, `onKeyUp(cb)` | `trait Platform` with the same method names; `randomSeed(): Long`, `startClock(onTime: Long => Unit)` | `type Platform` record of functions: `FillRect`, `FillText`, `RandomSeed: unit -> uint32`, `StartClock: (int64 -> unit) -> unit`, `OnKeyDown`, `OnKeyUp` | +| interpret an IO | `runIO(io, platform)` (`runtime.js`) | `Runtime.runIO(io, p)` | `Runtime.runIO p io` | +| run the program | `runApp(main, platform)` | `Runtime.runApp(main, p)` | `Runtime.runApp p main` | +| browser platform | `browserPlatform(canvas, win)` (`browser.js`, you write it) | `BrowserPlatform(canvas)` (`scala/web/`, given) | `BrowserPlatform.create canvas`, `BrowserPlatform.gameCanvas ()` (`fsharp/web/`, given) | +| entry point | `js/src/main.js` (given): `runApp(main, browserPlatform(...))` | `scala/web/Main.scala` (given): `SnakeMain` | `fsharp/web/SnakeMain.fs` (given) | + +## Module 11 — Pong + +JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongModel`, `PongRules`, `PongView`, `PongApp` in package `snake.pong`), F# files in `fsharp/src/Pong/` (modules `Snake.Pong.Model`, `Snake.Pong.Rules`, `Snake.Pong.View`, `Snake.Pong.App`). In F#, the Pong unions are marked `[]`, so you always write `PongStatus.Playing`, `Paddle.Up`, `PongAction.Frame`; that keeps them apart from the snake's `Playing`, `Up` and `Frame`. + +| Concept | JavaScript | Scala | F# | +|---|---|---|---| +| constants | `W`, `H`, `PCELL`, `PADDLE`, `PLAYER_X`, `CPU_X`, `WIN` (`model.js`) | `PongModel.W`, ... `PongModel.WIN` | `Pong.Model.W`, ... `Pong.Model.WIN` | +| velocity | `vel: {dx, dy}` | `Velocity(dx, dy)` | `{ Dx: int; Dy: int }` (type `Velocity`) | +| held keys | `held: {up, down}` | `Held(up, down)` | `{ Up: bool; Down: bool }` (type `Held`) | +| score | `score: {player, cpu}` | `Score(player, cpu)` | `{ Player: int; Cpu: int }` (type `Score`) | +| status | `'Playing'`, `'Over'` | `enum PongStatus`: `PongStatus.Playing`, `PongStatus.Over` | `PongStatus.Playing`, `PongStatus.Over` | +| paddle keys | `'Up'`, `'Down'` | `enum Paddle`: `Paddle.Up`, `Paddle.Down` | `Paddle.Up`, `Paddle.Down` | +| match state | `{ball: {x, y}, vel, player, cpu, held, score, status, seed, frame}` | `PongState(ball: Point, vel, player, cpu, held, score, status, seed: Long, frame)` | `{ Ball: Point; Vel; Player; Cpu; Held; Score; Status; Seed: uint32; Frame }` | +| actions | `PongAction.Press(key)`, `PongAction.Release(key)`, `PongAction.Frame`, `PongAction.Restart`; values `{tag: 'Press', key}`, ... | `enum PongAction`: `Press(key: Paddle)`, `Release(key: Paddle)`, `Frame`, `Restart` | `PongAction.Press of Paddle`, `PongAction.Release of Paddle`, `PongAction.Frame`, `PongAction.Restart` | +| fresh match | `initialPong(seed)` (`model.js`) | `PongModel.initialPong(seed: Long): PongState` | `Pong.Model.initialPong (seed: uint32) : PongState` | +| keep in range | `clamp(lo, hi, x)` (`rules.js`) | `PongRules.clamp(lo: Int, hi: Int, x: Int): Int` | `Pong.Rules.clamp (lo: int) (hi: int) (x: int) : int` | +| the update function | `pongUpdate(state, action)` | `PongRules.pongUpdate(state: PongState, action: PongAction): PongState` | `Pong.Rules.pongUpdate (state: PongState) (action: PongAction) : PongState` | +| state to picture | `pongView(state)` (`view.js`) | `PongView.pongView(state: PongState): Scene` | `Pong.View.pongView (state: PongState) : Scene` | +| ms per frame | `FRAME_MS_PONG` = 30 (`app.js`) | `PongApp.FRAME_MS_PONG: Long` | `Pong.App.FRAME_MS_PONG` (`30L`) | +| key to action | `pongKey(isDown, key)` → action or `null` | `PongApp.pongKey(isDown: Boolean, key: String): Option[PongAction]` | `Pong.App.pongKey (isDown: bool) (key: string) : PongAction option` | +| actions over time | `pongActions` | `PongApp.pongActions: Stream[PongAction]` | `Pong.App.pongActions : Stream` | +| states over time | `pongStates(seed, actions)` | `PongApp.pongStates(seed: Long, actions): Stream[PongState]` | `Pong.App.pongStates (seed: uint32) (actions: Stream) : Stream` | +| the whole program | `pongMain` (a value) | `PongApp.pongMain: IO[Stream[IO[Unit]]]` | `Pong.App.pongMain () : IO>>` | +| entry point (given) | `js/src/pong/main.js`, page `js/pong.html` | `PongMain` in `scala/web/Main.scala`, page `scala/pong.html` | `fsharp/web/PongMain.fs`, page `fsharp/pong.html` | + +## Tests and tools + +| Task | JavaScript (`cd js`) | Scala (`cd scala`) | F# (`cd fsharp`) | +|---|---|---|---| +| one module's tests | `node --test "test/m03-*.test.js"` or `./test.sh M03` | `./test.sh M03` | `./test.sh M03` | +| all tests (with the purity audit) | `./test.sh` | `./test.sh` | `./test.sh` | +| test files | `test/m03-rules.test.js` | `test/M03Rules.test.scala` | `tests/M03Rules.fs` | +| purity audit | `test/purity.test.js` | `test/Purity.test.scala` (`PuritySuite`) | `tests/Purity.fs` | +| build for the browser | nothing to build | `./build-web.sh` (Scala.js, writes `web/dist/`) | `./build-web.sh` (Fable, writes `web/dist/`) | +| play | `node scripts/serve.mjs` from the repository root, then (Pong: `/js/pong.html`) | same server, | same server, | + +See also: [Module 0](00-setup.md) for setup, [the method](method.md) for the conversion recipe, the [README](../README.md) for the module map, and [MENTOR.md](../MENTOR.md) for AI-mentor mode.