feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)

Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+39 -3
View File
@@ -49,6 +49,10 @@ First **Snake**: a 20 x 20 board, a snake that moves one cell per tick, apples t
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.
A bonus module (Module 12) comes back to testing: you build a small property-based testing tool out of your own random generator and use it to find bugs that example tests miss.
A second capstone (Module 13) converts **Conway's Game of Life**, played with the mouse: a new kind of input, and the first change to the edge of the program since Module 9. Along the way, Module 10 also gives you a **replay viewer** that plays back a recorded game of Snake, forwards and backwards.
### 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.
@@ -126,16 +130,43 @@ 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.
#### If your editor understands types (JavaScript, optional)
Scala and F# check the shape of your data before the program runs: pass a direction where a point is expected and the compiler refuses. Plain JavaScript only finds out when that line runs, often far from the mistake. You can get much of the compiler's help in JavaScript without installing anything and without changing how the course works.
The `js/` folder contains two given files for this: `js/jsconfig.json` switches on type checking for the files in `js/src/`, and `js/src/types.d.ts` writes down the course's data types (`GameState`, `Action`, `IO`, `Stream` and the rest; the model types themselves are the `@typedef` comments at the top of `model.js`). Every function in `src/` carries one extra line in its doc comment that names its type, for example:
```js
/** One step of the game. ...
* @type {(state: GameState) => GameState} */
export const advance = (state) => todo('m03')
```
The `@type` line is a comment: Node ignores it, and the tests do not care whether it is there. An editor with TypeScript support reads it. **VS Code** does this out of the box: open the repository folder and the JS files in `js/src/` are checked as you type. A misspelled field (`state.nextdir`), a forgotten `null` check on `state.apple`, or a `Turn` built without its direction gets a red underline, and hovering over a function shows its type. The untouched stubs show no errors; red only appears where your code disagrees with the types.
From the command line, the same check is one command (it needs the network the first time, because `npx` downloads TypeScript into npm's cache, not into the repository):
```sh
npx -y -p typescript tsc -p js/jsconfig.json # no output means no type errors
```
Helpers you add yourself have no `@type` line, so the checker guesses their types from their bodies, and sometimes it guesses too wide. The usual case: a helper that returns the pair `[outputs, next]` is read as "an array of outputs-or-streams", not as a pair, and the export that uses it gets a red line although the code is right. Give such a helper a `@type` line of its own, written like the ones on the exports, for example `/** @type {(ms: number, last: number) => Stream<number>} */`; the names it uses come from the `@import` line at the top of the file.
This is a convenience, not a requirement. The tests remain the judge of whether your code is right; the type checker only catches a class of mistakes earlier. If you do not use VS Code or do not want the help, ignore both files. Module 2 shows the one trick worth knowing: how to make the checker tell you about a forgotten `case`.
> **From everyday programming:** this is the "linter in the editor" experience: problems are underlined while you type instead of being reported by a failing run. TypeScript projects get the same checks from `.ts` files; here the types live in comments, so the files stay plain JavaScript that Node runs as they are.
### 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
course/ this text: 00-setup.md ... 13-capstone-life.md (plus the optional 07b), 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, ...
jsconfig.json, src/types.d.ts optional type checking (see "If your editor understands types")
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
@@ -155,14 +186,17 @@ Each module tells you, in its header, which files you will edit. The map is the
| 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` |
| 04 | `random.js`, `spawn.js` (optional: `state.js`) | `Random.scala`, `Spawn.scala` (optional: `State.scala`) | `Random.fs`, `Spawn.fs` (optional: `State.fs`) |
| 05 | `game.js` | `Game.scala` | `Game.fs` |
| 06 | `view.js` | `View.scala` | `View.fs` |
| 07 | `io.js` | `IO.scala` | `IO.fs` |
| 07b (optional) | `safeio.js` | `SafeIO.scala` | `SafeIO.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 |
| 10 | edits `game`, `input`, `stream`, `app`, `io`, `runtime` (JS: also `browser.js`); new: `replay.js` (`replay-main.js` is given) | same, without the browser file; new: `Replay.scala` | same, without the browser file; new: `Replay.fs` |
| 11 | `pong/` | `pong/` | `Pong/` |
| 12 (bonus) | `check.js` | `Check.scala` | `Check.fs` |
| 13 | `life/` (`life/main.js` is given); edits `runtime.js`, `browser.js` | `life/`; edits `Runtime.scala` | `Life/`; edits `Runtime.fs` |
### Running the tests
@@ -284,6 +318,8 @@ You can play the game once Module 9 is done. For reference, this is how:
- **Scala:** `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then serve as above and open http://localhost:8000/scala/
- **F#:** `cd fsharp && ./build-web.sh` (the first run restores the Fable compiler), serve, and open http://localhost:8000/fsharp/
The other pages sit next to `index.html` in each track's folder: `pong.html` (after Module 11), `replay.html` (the replay viewer, after Exercise 10.8) and `life.html` (after Module 13).
## Exercises
### Exercise 0.1 — Green lights
+33 -13
View File
@@ -55,8 +55,9 @@ Here is the model the course uses. (Now you may open your stub file: in Scala an
/** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */
/** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */
/** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction,
* apple: Point|null, seed: number, score: number, status: Status, frame: number}} GameState */
// Action: {tag:'Tick'} | {tag:'Turn', dir} | {tag:'TogglePause'} | {tag:'Restart'} | {tag:'Frame'}
* apple: Point|null, seed: number, score: number, status: Status, frame: number,
* queued: Direction|null}} GameState */
/** @typedef {{tag: 'Tick'} | {tag: 'Turn', dir: Direction} | {tag: 'TogglePause'} | {tag: 'Restart'} | {tag: 'Frame'}} Action */
```
**Scala**
@@ -78,7 +79,8 @@ final case class GameState(
dir: Direction, nextDir: Direction,
apple: Option[Point],
seed: Long, // unsigned 32-bit, 0 until 2^32
score: Int, status: Status, frame: Int)
score: Int, status: Status, frame: Int,
queued: Option[Direction])
```
**F#**
@@ -98,10 +100,11 @@ type GameState =
Dir: Direction; NextDir: Direction
Apple: Point option
Seed: uint32
Score: int; Status: Status; Frame: int }
Score: int; Status: Status; Frame: int
Queued: Direction option }
```
A fresh game is 20 x 20, the snake is `(10,10), (9,10), (8,10)` heading `Right` (both `dir` and `nextDir`), there is no apple yet, the seed is whatever you were given, the score is 0, the status `Playing`, and `frame` is 0. The `y` axis grows **downward**, as on a screen: `Up` is `{x: 0, y: -1}`.
A fresh game is 20 x 20, the snake is `(10,10), (9,10), (8,10)` heading `Right` (both `dir` and `nextDir`), there is no apple yet, the seed is whatever you were given, the score is 0, the status `Playing`, `frame` is 0 and nothing is queued (`queued` is none). The `y` axis grows **downward**, as on a screen: `Up` is `{x: 0, y: -1}`.
Now compare with your design, question by question.
@@ -123,7 +126,7 @@ Now compare with your design, question by question.
**8. Events as data.** Everything that can happen to the game is a value of type `Action`: `Tick` (time passes one step), `Turn(dir)` (the player asks to turn), `TogglePause`, `Restart`, and `Frame` (a finer-grained clock you will use in Module 10 for speed-ups). Module 5 shows why this is powerful: a whole game becomes "a start state plus a list of actions".
There is one more field, `frame`. It counts small clock frames so that the snake can speed up later without changing the clock (Module 10). Until then it stays 0.
There are two more fields, both dormant until Module 10. `frame` counts small clock frames since the snake last moved, so that the snake can speed up later without changing the clock; until then it stays 0. `queued` remembers one key press that came too early to be used yet, so that a quick Up-then-Left is not lost (Module 10, Exercise 10.6); until then it stays none. They are in the model from the start so that every fixture, from Module 2 to Module 12, has the same shape: a fixture state always carries `"frame"` and `"queued": null`, even in modules that never look at them.
Did your design differ? Common alternatives and their costs:
@@ -204,7 +207,24 @@ The `match` in Scala and F# is called **pattern matching**: it takes a sum apart
Note the JS lookup-table trick in `nextLight`: an object literal used as a function from a fixed set of strings to values. It is often the clearest way to write a function over a nullary sum type.
The important difference between the languages is **exhaustiveness checking**. If you add `case Triangle` to Scala's `Shape` and forget to handle it in `area`, the compiler warns you. F# does the same. JavaScript will silently return `undefined`. In JS you must be your own exhaustiveness checker; the fixtures, which cover every case, help.
The important difference between the languages is **exhaustiveness checking**. If you add `case Triangle` to Scala's `Shape` and forget to handle it in `area`, the compiler warns you. F# does the same. JavaScript itself will silently return `undefined`. Node never checks; the fixtures, which cover every case, are what catches the mistake when the tests run.
If your editor understands types (Module 0, "If your editor understands types"), you can get the warning in JavaScript too, before running anything. The checker already knows that `Action` is a union of five tagged objects, and inside `case 'Turn':` it knows that `action` has a `dir`. To make it complain about a *forgotten* case, end the `switch` with a `default` that claims nothing is left:
```js
switch (action.tag) {
case 'Tick': return ...
case 'Turn': return ...
// ... the other cases ...
default: {
/** @type {never} */
const unhandled = action // red if some case is missing: "Type '{ tag: "Frame"; }' is not assignable to type 'never'"
return state
}
}
```
`never` is the type with no values. After every handled `case` the checker removes that alternative from the type of `action`; if all five were handled, nothing is left and `action` has type `never` in the `default`, so the assignment is fine. If you forgot `Frame`, `action` can still be `{tag: 'Frame'}` there, and the checker names the missing case. So the precise statement is: in JavaScript the language does not check exhaustiveness, but a type checker reading your comments can, if you ask it to in this way. The stubs do not contain this `default`; add it yourself when you want the check.
### Making illegal states unrepresentable
@@ -330,7 +350,7 @@ def initialState(seed: Long): GameState
let initialState (seed: uint32) : GameState
```
A 20 x 20 board; snake `(10,10), (9,10), (8,10)`; `dir` and `nextDir` both `Right`; no apple; the given seed, stored as given; score 0; status `Playing`; frame 0.
A 20 x 20 board; snake `(10,10), (9,10), (8,10)`; `dir` and `nextDir` both `Right`; no apple; the given seed, stored as given; score 0; status `Playing`; frame 0; nothing queued.
Fixture: `fixtures/m02-initial-state.json` (cases `seed-7` and `seed-is-stored-as-given`, which uses the largest 32-bit seed, 4294967295). Watch out: the order of the snake's points matters (head first), and in JS use your `point` function so that every point has the same shape.
@@ -433,7 +453,7 @@ This is a single record literal. Read the fixture `m02-initial-state.json`: the
<details><summary>Exercise 2.5 (initialState) — stronger hint</summary>
Ten fields: `width`, `height`, `snake`, `dir`, `nextDir`, `apple`, `seed`, `score`, `status`, `frame`. In Scala/F#, "no apple" is `None`; in JS it is `null`. In Scala the enum values are `Direction.Right` and `Status.Playing`.
Eleven fields: `width`, `height`, `snake`, `dir`, `nextDir`, `apple`, `seed`, `score`, `status`, `frame`, `queued`. In Scala/F#, "no apple" and "nothing queued" are `None`; in JS they are `null`. In Scala the enum values are `Direction.Right` and `Status.Playing`.
</details>
@@ -444,19 +464,19 @@ export const initialState = (seed) => ({
width: 20, height: 20,
snake: [point(10, 10), ___, ___],
dir: ___, nextDir: ___,
apple: ___, seed, score: ___, status: ___, frame: ___,
apple: ___, seed, score: ___, status: ___, frame: ___, queued: ___,
})
```
```scala
def initialState(seed: Long): GameState =
GameState(20, 20, List(Point(10, 10), ___, ___), Direction.Right, ___, ___, seed, ___, ___, ___)
GameState(20, 20, List(Point(10, 10), ___, ___), Direction.Right, ___, ___, seed, ___, ___, ___, ___)
```
```fsharp
let initialState (seed: uint32) : GameState =
{ Width = 20; Height = 20
Snake = [ { X = 10; Y = 10 }; ___; ___ ]
Dir = Right; NextDir = ___
Apple = ___; Seed = seed; Score = ___; Status = ___; Frame = ___ }
Apple = ___; Seed = seed; Score = ___; Status = ___; Frame = ___; Queued = ___ }
```
</details>
@@ -475,7 +495,7 @@ let initialState (seed: uint32) : GameState =
## In production
- **Elm** models every application as a single immutable `Model` record plus a `Msg` custom type for everything that can happen, exactly like `GameState` and `Action`. See [custom types in the Elm guide](https://guide.elm-lang.org/types/custom_types.html).
- **TypeScript** checks JavaScript tagged unions at compile time: a `switch` on `tag` can be made exhaustive. See [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions). (We use plain JS in this course, so you are the type checker.)
- **TypeScript** checks JavaScript tagged unions at compile time: a `switch` on `tag` can be made exhaustive. See [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions). (The course stays in plain JS; its optional `@type` comments give you the same check in an editor, see Module 0.)
- **Redux** actions are tagged objects (`{ type: 'todos/added', payload }`), the same idea as our `Action`.
- Domain modelling with types is a whole discipline in the F# and Scala communities; see Scott Wlaschin's work below.
+3 -2
View File
@@ -163,7 +163,7 @@ The check compared against a direction the snake *had not moved in yet*. The sna
That is why the model has two directions (Module 2, question 3): `dir` is a fact about the body (where the neck is), `nextDir` is a wish. Turns are validated against the fact. The fixture `turn-against-last-moved-dir` is exactly this situation: `dir = Right`, `nextDir = Up`, turn `Left`, expected result: unchanged.
Notice the consequences of the rule: several allowed turns within one tick overwrite each other, and "the last allowed one wins" (fixture `turn-replaces-pending-turn`: with `dir = Right` and `nextDir = Up`, pressing `Down` gives `nextDir = Down`, which is allowed because Down is not the opposite of Right). And the player's U-turn intention (Up, then Left) is partly lost: only Up happens. Some games keep a small queue of pending turns to honour both; see the questions at the end.
Notice the consequences of the rule: several allowed turns within one tick overwrite each other, and "the last allowed one wins" (fixture `turn-replaces-pending-turn`: with `dir = Right` and `nextDir = Up`, pressing `Down` gives `nextDir = Down`, which is allowed because Down is not the opposite of Right). And the player's U-turn intention (Up, then Left) is partly lost: only Up happens. Some games keep a small queue of pending turns to honour both; see question 4 at the end, and Module 10, where you build one (Exercise 10.6) without changing `turn`.
</details>
@@ -441,11 +441,12 @@ let advance (state: GameState) : GameState =
1. Why does `turn` return the state unchanged when paused, instead of throwing? Who would have to deal with the exception, and how would that change the rest of the program?
2. Without looking: on a 5 x 5 board, the snake is `(1,1) (1,2) (2,2) (2,1)` heading Up with `nextDir = Right` and an apple at (2,1). What does `advance` return? Now the same with no apple.
3. Explain to a friend, with a drawing, why `turn` must compare against `dir` and not `nextDir`. What sequence of key presses kills the snake in the wrong version?
4. With the correct `turn`, the player presses Up then Left within one tick, intending a quick U-turn, and only Up happens. Sketch a different model that would honour both presses. What would you add to `GameState`, and how would `advance` change?
4. With the correct `turn`, the player presses Up then Left within one tick, intending a quick U-turn, and only Up happens. Sketch a different model that would honour both presses. What would you add to `GameState`, and how would `advance` change? (Keep your sketch: Module 10, Exercise 10.6 builds the course's answer, and you can compare. Note that the course's version leaves `turn` and `advance` exactly as they are here.)
5. After `advance` crashes into a wall, the snake is unchanged but `dir` is updated. What invariant does that keep? Would anything visible break if `dir` were left unchanged?
6. `advance` does not place a new apple after eating. Why is it a good idea to keep that out of `advance`? (Think about what `advance` would need as input.)
7. Predict: `advance(advance(s))` where `s` is a `GameOver` state. And `turn(advance(s), 'Up')` for the same `s`.
8. Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about the decision to store `width` and `height` in the state?
9. Build this state by hand: a two-cell snake `(1,1) (0,1)` with `dir = Right` and `nextDir = Left`, no apple. What does `advance` return, by the rules of this module? Is that a bug in `advance`? Explain why no sequence of `turn` and `advance` calls from a new game can ever produce such a state. (A statement like "`nextDir` is never the opposite of `dir`" is called an *invariant*; Module 12 checks invariants on thousands of random games.)
## In production
+126 -7
View File
@@ -1,6 +1,6 @@
# Module 4 — Randomness without side effects
> **Time:** ~4 h · **You'll edit:** JS `js/src/random.js`, `js/src/spawn.js` · Scala `scala/src/Random.scala`, `scala/src/Spawn.scala` · F# `fsharp/src/Random.fs`, `fsharp/src/Spawn.fs`
> **Time:** ~4 h (+1 h for the optional Exercise 4.7) · **You'll edit:** JS `js/src/random.js`, `js/src/spawn.js` (optional: `js/src/state.js`) · Scala `scala/src/Random.scala`, `scala/src/Spawn.scala` (optional: `scala/src/State.scala`) · F# `fsharp/src/Random.fs`, `fsharp/src/Spawn.fs` (optional: `fsharp/src/State.fs`)
> **Tests:** JS `cd js && node --test "test/m04-*.test.js"` · Scala `cd scala && ./test.sh M04` · F# `cd fsharp && ./test.sh M04`
> **Prerequisites:** Modules 1–3
@@ -30,7 +30,8 @@ After this module you can:
- **describe** at a high level how mulberry32 turns a seed into a number and a new seed, and why 32-bit arithmetic must be handled carefully in each language;
- **implement** `Random<A> = seed → (A, seed)` with `pure`, `map` and `flatMap`, and recognise it as the State monad;
- **use** Scala's for-comprehension and F#'s computation expressions as syntax for the same chaining;
- **implement** apple spawning, including the full-board edge case, and explain why "same seed ⇒ same game".
- **implement** apple spawning, including the full-board edge case, and explain why "same seed ⇒ same game";
- *(optional)* **generalise** `Random` to `State<S, A>` with `get`, `put` and `modify`, and use it for state other than a seed.
## Concepts
@@ -71,7 +72,9 @@ value = t xor (t >>> 14) -- as an unsigned 32-bit number
return (value, s)
```
(`imul` is 32-bit multiplication, keeping only the low 32 bits; `>>>` is an unsigned right shift.) mulberry32 is fine for games and simulations, and **not** suitable for anything security-related: given one output, the next ones can be predicted.
(`imul` is 32-bit multiplication, keeping only the low 32 bits; `>>>` is an unsigned right shift.) mulberry32 is fine for games, and **not** suitable for anything security-related: given one output, the next ones can be predicted.
One more honest detail. The counter visits every 32-bit value once, but the scramble is not one-to-one: different counters can give the same output. Run the generator through its whole period and count (it takes a minute in any of the three languages): only 1 893 145 848 of the 4 294 967 296 possible values ever come out, about 44 %, and those that do come out are not equally frequent. For placing apples this is irrelevant. For a simulation whose results you intend to publish, pick a generator whose output step is a bijection, such as SplitMix or PCG (see "Further reading").
For example, from seed `0` the first draw gives the value `1144304738` and the new seed `1831565813` (which is `0x6D2B79F5`: zero plus the constant). The next draw, from seed `1831565813`, gives `1416247` and seed `3663131626`. These are the first two cases of `fixtures/m04-random.json`.
@@ -260,11 +263,41 @@ JavaScript has no such syntax for user-defined types, so you write the nested `f
> **From everyday programming:** `async`/`await` in JavaScript, C# or Python is the same idea for promises and tasks. `const res = await fetch(url)` reads like an ordinary assignment, but behind the scenes it is a `then` whose callback is the rest of the function. A for-comprehension or a computation expression gives `Random` (or any monad) that same "looks like sequential code" syntax.
### Going further: `State` for any kind of state (optional)
This section and Exercise 4.7 are optional: nothing in the game depends on them. If you want to get to the apples, skip to "Drawing a number below `n`" and come back after Exercise 4.6.
The previous section claimed that `Random<A>` is `State<Seed, A>`. A claim like that is worth checking, and the way to check it is to write `State` itself and then rebuild the dice with it.
**Why bother, if `Random` already works?** Because the seed is not the only thing a program threads through by hand. A counter that hands out fresh labels (`L0`, `L1`, `L2`, ...) has the same problem as the seed: each step needs the number the previous step left behind, and reusing an old number is a bug that nothing catches. So does a parser that reads a string piece by piece, where each step returns a value and "the rest of the input". Every one of these is `S -> (A, S)` for some `S`. Write the plumbing once, for every `S`, and each of them gets `map` and `flatMap` for free.
The code of `pure`, `map`, `flatMap` and `run` does not change at all: look at your `random.js` and rename "seed" to "s". What is new are three small building blocks that talk about the state itself. `Random` never needed them, because only `nextUint32` ever touched the seed directly; for a general state, you need a way to read it and to change it:
```
get : State<S, S> s gives [s, s] the value is the state; the state is unchanged
put(s2) : State<S, unit> s gives [nothing, s2] throw the old state away, store s2
modify(f) : State<S, unit> s gives [nothing, f(s)] store f of the old state
```
("unit" is the type with a single value that carries no information: the value a step gives when only its effect on the state matters. JavaScript uses `undefined` for it, Scala `()` of type `Unit`, F# `()` of type `unit`.)
With these, the label counter is one line, and three labels in a row are a chain of `flatMap`s with no counter in sight:
```js
const fresh = flatMap(get, (n) => map(put(n + 1), () => `L${n}`)) // read n, store n + 1, give "Ln"
const three = flatMap(fresh, (a) => flatMap(fresh, (b) => map(fresh, (c) => [a, b, c])))
run(three, 0) // [['L0', 'L1', 'L2'], 3]
```
And a single draw of the generator can be written with nothing but `get` and `put`: read the seed, compute the value and the next seed, store the next seed. Build the dice of the previous section on top of that draw, and you get exactly the values and seeds that `Random` gives. That is the precise meaning of "`Random` is `State` with the seed as `S`", and it is what the last test of Exercise 4.7 checks.
> **From everyday programming:** the usual way to hand out fresh ids is a field and an increment: `this.nextId++` in a class, or a module-level counter. It works until two parts of the program share the counter by accident, or a test needs it to start at 0 again. `State` makes the counter an explicit input and output of every step, so it can never be shared by accident and every run starts where you say. Redux reducers are the same idea in a different shape: `(state, action) => newState` returns the next state instead of changing the current one.
### Drawing a number below `n`
To pick one of `n` free cells, you need a number in `[0, n)`. `intBelow(n)` is `nextUint32` with its value taken modulo `n`. That is a `map`.
(Strictly, `v % n` is slightly biased when 2^32 is not a multiple of `n`: small results are a tiny bit more likely. For `n` up to 400 the bias is below one in ten million, far below anything a player could notice. Libraries that need exact uniformity reject and redraw; we keep it simple and identical across tracks.)
(Strictly, `v % n` is slightly biased when 2^32 is not a multiple of `n`: even for a perfectly uniform `v`, small results would be a tiny bit more likely. For `n` up to 400 that effect is below one in ten million, much smaller than the unevenness of mulberry32 itself, and both are far below anything a player could notice. Libraries that need exact uniformity reject and redraw, see question 9; we keep it simple and identical across tracks.)
### Spawning an apple
@@ -317,7 +350,7 @@ And now the payoff: `newGame(42)` is the same game every time you call it. Any s
## Exercises
> **About test names.** The test titles quoted under "Test"/"Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m04 intBelow always lands in [0, n)`). The Scala and F# suites test the same things one-to-one with the same titles, except that F# says `bind` where JS and Scala say `flatMap` (`bind threads the seed through both steps`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M04Random.test.scala` and `M04Spawn.test.scala`, or `fsharp/tests/M04Random.fs` and `M04Spawn.fs`. Fixture-based tests have the same case names in every track (JS `m04 nextUint32: seed-0-step-1`, Scala/F# `nextUint32: seed-0-step-1`).
> **About test names.** The test titles quoted under "Test"/"Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m04 intBelow always lands in [0, n)`). The Scala and F# suites test the same things one-to-one with the same titles, except that F# says `bind` where JS and Scala say `flatMap` (`bind threads the seed through both steps`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M04Random.test.scala`, `M04Spawn.test.scala` and `M04State.test.scala`, or `fsharp/tests/M04Random.fs`, `M04Spawn.fs` and `M04State.fs`. Fixture-based tests have the same case names in every track (JS `m04 nextUint32: seed-0-step-1`, Scala/F# `nextUint32: seed-0-step-1`).
### Exercise 4.1 — `nextUint32`
@@ -448,9 +481,52 @@ let newGame (seed: uint32) : GameState
Fixtures: `fixtures/m04-tick.json` (`eat-then-respawn`, `plain-move-no-respawn`, `wall-hit-is-game-over-no-spawn`, `game-over-state-unchanged`) and `fixtures/m04-new-game.json` (`seed-0`, `seed-42`). Tests: `tick is spawnApple after advance`, `newGame is reproducible: same seed, same game`.
### Exercise 4.7 (optional) — `State`
Read "Going further: `State` for any kind of state" first. The file is new: JS `js/src/state.js`, Scala `scala/src/State.scala`, F# `fsharp/src/State.fs`. Nothing else in the course imports it.
**JavaScript**
```js
export const pure = (a) => ... // State<S, A>: s => [a, s]
export const map = (st, f) => ... // State<S, A>, (A -> B) -> State<S, B>
export const flatMap = (st, f) => ... // State<S, A>, (A -> State<S, B>) -> State<S, B>
export const get = ... // State<S, S> (a value, like nextUint32)
export const put = (s) => ... // State<S, undefined>
export const modify = (f) => ... // State<S, undefined>, f : S -> S
export const run = (st, s) => ... // [A, S]
```
**Scala**
```scala
final case class State[S, A](run: S => (A, S)):
def map[B](f: A => B): State[S, B]
def flatMap[B](f: A => State[S, B]): State[S, B]
object State:
def pure[S, A](a: A): State[S, A]
def get[S]: State[S, S]
def put[S](s: S): State[S, Unit]
def modify[S](f: S => S): State[S, Unit]
```
**F#** (in `module Snake.State`, which also defines the type)
```fsharp
type State<'s, 'a> = State of ('s -> 'a * 's)
let run (s: 's) (State f: State<'s, 'a>) : 'a * 's
let pure (a: 'a) : State<'s, 'a>
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b>
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b>
let get () : State<'s, 's> // a function of unit, so it can be generic
let put (s: 's) : State<'s, unit>
let modify (f: 's -> 's) : State<'s, unit>
```
Scala's `get[S]` needs the state type at the call site when nothing else fixes it: `State.get[Int]`. In the tests, the Random comparison turns a `Random` step into a `State` by hand: Scala `State[Long, Long](seed => Random.nextUint32.run(seed))`, F# `State(fun seed -> Random.run seed Random.nextUint32)`. In JS there is nothing to turn: a `Random` already *is* a function `seed => [value, seed]`.
Tests (in `m04-state.test.js` / `M04State`): `State pure gives its value and leaves the state alone`, `State map applies f to the value and keeps the state from the underlying step`, `State flatMap threads the state through both steps`, `State get gives the state as the value and leaves it alone`, `State put replaces the state and gives undefined` (Scala/F#: "gives unit"), `State modify applies f to the state and gives undefined` (Scala/F#: "gives unit"), the three laws `State law: left identity, ...`, `State law: right identity, ...`, `State law: associativity, ...`, `State a counter hands out fresh labels in order`, and `State Random is State with the seed as S: the dice give the same values and seeds`.
Watch out: like `Random`, every function here returns a State (a function, or a value wrapping one) and runs nothing. `put(s)` ignores the incoming state, but it must still *be* a function of it.
### Run
- JS: `cd js && node --test "test/m04-*.test.js"` (runs both `m04-random` and `m04-spawn`)
- JS: `cd js && node --test "test/m04-*.test.js"` (runs `m04-random`, `m04-spawn` and the optional `m04-state`; until you do Exercise 4.7, the `m04 State ...` tests fail; if you decide to skip it, copy the finished file instead, `cp js/solutions/state.js js/src/state.js`, and the same for `State.scala` / `State.fs`, so that your suite stays green)
- Scala: `cd scala && ./test.sh M04`
- F#: `cd fsharp && ./test.sh M04`
@@ -659,6 +735,42 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
</details>
<details><summary>Exercise 4.7 (State, optional) — nudge</summary>
Four of the seven functions are already written: they are in your `random.js` (`Random.scala`, `Random.fs`) under the same names, with "seed" where you now write "s". For the other three, write down what each must return when run with a state `s`, as a pair, and the code follows from the pair.
</details>
<details><summary>Exercise 4.7 (State, optional) — stronger hint</summary>
- `get` run with `s` gives `[s, s]`. In JS, `get` is itself that function (not a function that returns one), just as `nextUint32` is a `Random`.
- `put(s2)` run with any `s` gives `[undefined, s2]`: it ignores `s`, but it is still a function of it.
- `modify(f)` run with `s` gives `[undefined, f(s)]`. (You can also write it with `flatMap`, `get` and `put`: read, then store `f` of what you read.)
- Scala: `get`, `put` and `modify` live in the companion object and wrap their function with `State(...)`; in `put` and `modify` the value is `()`. F#: same shape with `State(fun s -> ...)`; `get` takes `()` so that it can stay generic.
</details>
<details><summary>Exercise 4.7 (State, optional) — near-solution (try the others first)</summary>
```js
export const get = (s) => [___, ___]
export const put = (s) => () => [undefined, ___]
export const modify = (f) => (s) => [___, ___]
```
```scala
def get[S]: State[S, S] = State(s => (___, ___))
def put[S](s: S): State[S, Unit] = State(_ => ((), ___))
def modify[S](f: S => S): State[S, Unit] = State(s => (___, ___))
```
```fsharp
let get () : State<'s, 's> = State(fun s -> (___, ___))
let put (s: 's) : State<'s, unit> = State(fun _ -> ((), ___))
let modify (f: 's -> 's) : State<'s, unit> = State(fun s -> (___, ___))
```
`pure`, `map`, `flatMap`/`bind` and `run` are the near-solution of Exercise 4.2 with `Random` renamed to `State` and the seed renamed to `s`.
</details>
## Check your understanding
1. Why is `const roll = () => Math.floor(Math.random() * 6)` impure, while `d6 = map(nextUint32, v => 1 + v % 6)` is pure? Where did the "randomness" go?
@@ -669,10 +781,14 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
6. What would go wrong, across tracks, if `freeCells` listed cells column-major in JS but row-major in Scala?
7. What does `spawnApple` do on a full board, and why is "status becomes `Won`" better than letting `intBelow(0)` decide?
8. Why does `tick` leave the seed unchanged when the snake did not eat? What would break in replays if every tick consumed a random number anyway? (Hint: would it break anything? Think about determinism versus "which apple appears".)
9. **Challenge: a draw without bias.** `intBelow(n)` is very slightly biased (see "Drawing a number below `n`"). The standard cure is *rejection*: let `limit` be the largest multiple of `n` that is at most 2^32; draw a value; if it is below `limit`, use `v % n`; otherwise draw again. Write `intBelowUniform(n)` on paper with the functions of this module. Why do you need `flatMap` here, where `intBelow` only needed `map`? What does that tell you about the difference between the two? (Do not put it in the course code: the fixtures expect the simple version.)
10. Two generators that start from seeds `s` and `s + 0x6D2B79F5` produce the same sequence, shifted by one draw. Explain why from the structure of mulberry32. Why does that make "seed the second player's generator with `seed + 1`" a reasonable idea and "seed it with the first player's *next seed*" a bad one?
11. *(Exercise 4.7)* **A label counter with `State`.** A game log numbers its lines: `1: ate an apple`, `2: turned left`, ... Write `label(text)` as a `State<number, string>` (Scala `State[Int, String]`, F# `State<int, string>`) using `get` and `put` (or `modify`), so that running the chain of `label('ate an apple')` and then `label('turned left')` from `1` gives both lines and leaves the counter at `3`. What is `S` here, and what bug can no longer happen that `this.line++` would allow? (When you reach Module 11, try the same with the Pong serve: write the choice of `dy` as a `State` over the seed, with `get` and `put`, and compare it with the `Random` version.)
12. *(Exercise 4.7)* In JavaScript, `Random` and `State` are the same plain functions, so `random.js` could import `pure`, `map` and `flatMap` from `state.js`. In Scala and F#, `Random[A]` and `State[Long, A]` are different types, and you had to convert one into the other by hand in the tests. What does the separate `Random` type buy you there, and what does it cost? Which of `get`, `put`, `modify` would you *not* want to offer on `Random`, and why? (Think about what `put(0)` would do to a game's apples.)
## In production
- **Haskell's** [`random`](https://hackage.haskell.org/package/random) package uses pure generators: `uniformR :: g -> (a, g)`, exactly our shape. The **State monad** pattern is described on the [Haskell wiki](https://wiki.haskell.org/State_Monad).
- **Haskell's** [`random`](https://hackage.haskell.org/package/random) package uses pure generators: `uniform :: g -> (a, g)` (leaving out the type-class constraints), exactly our shape. The **State monad** pattern is described on the [Haskell wiki](https://wiki.haskell.org/State_Monad).
- **cats** (Scala) has a general [`State`](https://typelevel.org/cats/datatypes/state.html) data type: `State[S, A]` with `map`, `flatMap`, `get`, `set`. *Functional Programming in Scala* builds a `RNG`/`State` very much like this module in its chapter on purely functional state.
- **Property-based testing** libraries such as [ScalaCheck](https://scalacheck.org/) and [fast-check](https://fast-check.dev/) generate random test inputs from a seed, so that a failing case can be reproduced by re-running with the same seed. Their generators are monads like `Random`.
- **Java's** [`SplittableRandom`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/SplittableRandom.html) is a mutable object, but its algorithm (a counter plus a mixing function) has the same structure as mulberry32.
@@ -688,3 +804,6 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
- [Functional Programming in Scala, 2nd edition](https://www.manning.com/books/functional-programming-in-scala-second-edition) — chapter 6, "Purely functional state", is this module in book form.
- [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F# computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the official docs for the syntax you saw.
- [Mostly Adequate Guide](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — chapters 8 and 9 build `map` and `chain` (flatMap) for containers in JavaScript.
- [Philip Wadler, "Monads for functional programming"](https://homepages.inf.ed.ac.uk/wadler/papers/marktoberdorf/baastad.pdf) — the classic tutorial paper; its section 2 threads a state through an evaluator by hand and then removes the plumbing, exactly the path of this module.
- [Guy Steele, Doug Lea and Christine Flood, "Fast Splittable Pseudorandom Number Generators"](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf) — the design behind "a counter plus a mixing function" (SplitMix), the family mulberry32 belongs to.
- [Daniel Lemire, "Fast Random Integer Generation in an Interval"](https://arxiv.org/abs/1805.10941) — how libraries draw a number below `n` without the modulo bias, and without a division.
+2 -1
View File
@@ -137,7 +137,7 @@ List.scan apply 100 txs // [100; 150; 130; 135]
Because a game is `fold(update, start, actions)` and `update` is pure:
- **Replays.** To store a whole game you do not need a video. You need the start state, which is fully determined by the seed (`newGame(seed)`), and the list of actions. Replaying them through `update` reproduces every state exactly, apples included, because the random seed is in the state (Module 4).
- **Replays.** To store a whole game you do not need a video. You need the start state, which is fully determined by the seed (`newGame(seed)`), and the list of actions. Replaying them through `update` reproduces every state exactly, apples included, because the random seed is in the state (Module 4). In Module 10 (section 9, Exercise 10.8) you build a replay viewer on exactly this: `history` of a recorded game, shown frame by frame, forwards and backwards.
- **Deterministic tests.** A test is "this start, these actions, this expected end". No timers, no mocks, no sleeping. The fixture `twelve-actions-eat-one-apple` is a whole little game in one test case:
```
@@ -361,5 +361,6 @@ let history (state: GameState) (actions: Action list) : GameState list = List.sc
- [Array.prototype.reduce (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce) — JavaScript's fold, including the empty-array pitfall.
- [F# List module](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-listmodule.html) and [Scala List API](https://www.scala-lang.org/api/current/scala/collection/immutable/List.html) — `fold`/`scan` and `foldLeft`/`scanLeft`.
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — model, update, view: the structure this course is building, from Elm's own guide.
- [Martin Kleppmann, "Turning the database inside-out"](https://martin.kleppmann.com/2015/03/04/turning-the-database-inside-out.html) — a talk (with transcript) on treating the log of events as the source of truth and every table as a fold over it.
- [Mealy machine (Wikipedia)](https://en.wikipedia.org/wiki/Mealy_machine) — a state machine whose step produces outputs from the current state and input; a preview of how Module 8 will model streams.
- [Conal Elliott and Paul Hudak, "Functional Reactive Animation"](http://conal.net/papers/icfp97/) — the paper that started treating time-varying values and events as first-class values, the idea behind Modules 8 and 9.
+10 -3
View File
@@ -302,7 +302,7 @@ There are exactly two ways to notice the difference, and you should know them:
- **Exceptions.** `flatMap(pure(1), _ => { throw new Error('boom') })` throws while *building*, whereas `flatMap(randomSeed, _ => { throw ... })` only throws when *interpreted*.
- **Non-termination or very expensive work.** If `f` loops forever or takes a minute, building the program hangs or takes a minute.
Both are, in a sense, impurities in `f`, and our `f`s won't have them. Production libraries do care: cats-effect, ZIO and Haskell's `IO` add a case like `Suspend(() => io)` / `Delay` so that even the first step can be postponed until interpretation. Our tiny `IO` doesn't need it, and it is good to understand *why* it doesn't.
Both are, in a sense, impurities in `f`, and our `f`s won't have them. Production libraries do care: cats-effect and ZIO add a case like `Suspend(() => io)` / `Delay` so that even the first step can be postponed until interpretation. (Haskell does not need such a case: the language is lazy, so `f a` is not evaluated until the runtime asks for it.) Our tiny `IO` doesn't need it, and it is good to understand *why* it doesn't.
### 10. The monad laws
@@ -393,7 +393,7 @@ Our `IO` is made of nested functions, and calling nested functions uses the call
That is the answer to the question from section 8: **build `sequence` right-nested** (fold from the right), so each continuation does a constant amount of work before handing back the next `Effect`. Module 9 has a test that runs `sequence` of 100 000 draws through `runIO`; a left-nested `sequence` will fail it with a stack overflow.
Production libraries solve this once and for all with a technique called **trampolining**: they represent `flatMap` itself as a data constructor (`FlatMap(io, f)`) and the interpreter re-associates left-nested chains on the fly, in a loop, so no shape of program can overflow the stack. Rúnar Bjarnason's paper ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) explains it beautifully; see also [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29). We keep the simpler representation and the rule "fold right".
Production libraries solve this once and for all with a technique called **trampolining**: they represent `flatMap` itself as a data constructor (`FlatMap(io, f)`) and the interpreter re-associates left-nested chains on the fly, in a loop, so no shape of program can overflow the stack. Rúnar Bjarnason's paper ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) explains it beautifully; see also [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29). We keep the simpler representation and the rule "fold right" for the rest of the course. If you want to see the trampoline built, measured and tested, the optional [Module 7b — Stack-safe IO](07b-stack-safe-io.md) does it in this course's terms, right after this module or at any later point.
### 13. What we have built, and its family name
@@ -401,7 +401,7 @@ Step back and look at the type: a program is either *done*, or *one instruction
Consequences you will use in the rest of the course:
- **Adding an effect is adding an `Op`.** Want to play a sound, or save a high score? Add `{tag:'PlaySound', name}` or `{tag:'Save', key, value}` and teach the interpreters what to do with it (you'll think through exactly this in Module 10).
- **Adding an effect is adding an `Op`.** Want to play a sound, or save a high score? Add `{tag:'PlaySound', name}` or `{tag:'SaveBest', score}` and teach the interpreters what to do with it. Module 10 does exactly this for a best score that survives a reload ([section 8, Exercise 10.7](10-extensions.md#8-a-high-score-that-survives-a-reload)): two new `Op`s, one new answer in `trace`, two new cases in `runIO`, and not one change to `pure`, `map`, `flatMap` or `sequence`.
- **Many interpreters.** `trace` for tests, `runIO` for the browser, and you could write a third that logs every op, or one that answers `RandomSeed` from a recorded file to replay a bug — the way you would swap in a different implementation of an interface with dependency injection, except that here nothing needs to be injected into the program at all.
## Exercises
@@ -523,6 +523,8 @@ let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"
Answer every `RandomSeed` with the seed (`Seed(seed)` in Scala/F#), every `Draw` with unit (`Done`), and collect the ops in the order they were performed. Use recursion, not mutation.
(The JS stub already shows a third parameter, `best = 0`. Ignore it for now: Module 10 adds a stored best score as a new operation, and `best` will answer it the way `seed` answers `RandomSeed`.)
**Proved by:** `trace of pure(3) has result 3 and no ops`, and every other test in the module (they all observe programs through `trace`).
**Run (all of Module 7):**
@@ -686,6 +688,8 @@ let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
6. State the associativity law in words. Which everyday refactoring (think "extract function") would be unsafe if it didn't hold?
7. You build `sequence` with a *left* fold and every m07 test passes. What breaks later, and why doesn't the loop in the interpreter save you?
8. To add sound to the game you'd add `{tag:'PlaySound', name}`. List every place in the code that has to change. Which place does *not* have to change at all, and why is that the point of the design?
9. **Challenge: the cost of nesting on the left.** Take `io = sequence` of `n` draws built with a *left* fold. When an interpreter runs it, how many times in total is `flatMap` called on an `Effect`? (Count for `n = 3` by hand, then generalise.) Compare with the right fold. You should find that left nesting is not only a stack problem but also a quadratic amount of work; the papers under "Further reading" on freer monads show how libraries avoid both.
10. **Challenge: laws from laws.** Using only the three monad laws and the definition `map(m, f) = flatMap(m, a => pure(f(a)))`, show that `map(m, x => x) ≡ m` and that `map(map(m, f), g) ≡ map(m, x => g(f(x)))`. (These two are called the *functor laws*; every monad gets them for free.)
## In production
@@ -700,6 +704,9 @@ let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
- [Gabriel Gonzalez, "You Could Have Invented Free Monads"](https://www.haskellforall.com/2012/06/you-could-have-invented-free-monads.html) — the same derivation as this module, in Haskell; read it to see the idea in its native habitat.
- [Rúnar Bjarnason, "Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) — how to make this structure stack-safe for arbitrarily long programs.
- [Haskell wiki: Monad laws](https://wiki.haskell.org/Monad_laws) — the three laws with examples and the intuition behind each.
- [Simon Peyton Jones, "Tackling the Awkward Squad"](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/07/mark.pdf) — how Haskell does input/output, concurrency and exceptions with monadic `IO`, by one of the language's designers; sections 1–2 are the background to this module.
- [Eugenio Moggi, "Notions of computation and monads"](https://www.cs.cmu.edu/~crary/819-f09/Moggi91.pdf) — the 1991 paper that proposed monads as a model of effects; hard going, but worth a look to see where the idea came from.
- [Oleg Kiselyov and Hiromi Ishii, "Freer Monads, More Extensible Effects"](https://okmij.org/ftp/Haskell/extensible/more.pdf) — the "operation plus continuation" representation this module uses, and how to make long chains of `flatMap` cheap.
- [Mostly Adequate Guide to Functional Programming, chapters 8–9](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — functors, `IO` and monads, in JavaScript.
- [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F#: Computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the syntax for writing `flatMap` chains in each language.
- [Referential transparency (Wikipedia)](https://en.wikipedia.org/wiki/Referential_transparency) — the property this whole module protects.
+472
View File
@@ -0,0 +1,472 @@
# Module 7b — Stack-safe IO (optional)
> **Time:** ~3 h · **You'll edit:** JS `js/src/safeio.js` · Scala `scala/src/SafeIO.scala` · F# `fsharp/src/SafeIO.fs`
> **Tests:** JS `cd js && node --test "test/m07b-*.test.js"` · Scala `cd scala && ./test.sh M07b` · F# `cd fsharp && ./test.sh M07b`
> **Prerequisites:** all of Module 7, especially sections 8 (`sequence`), 10 (the monad laws) and 12 (stack safety). No later module needs this one: you can do it straight after Module 7, after the whole course, or never.
## Why this module
Module 7 ended with a rule: **build `sequence` with a right fold.** Fold from the left and every Module 7 test still passes, but the 100 000-draw test of Module 9 dies with a stack overflow. Section 12 explained why in two sentences and moved on.
A rule like that is fragile. Nothing in the type says "nest me to the right", and left nesting is what you get naturally whenever you build a program step by step, adding to "the program so far":
```js
// A perfectly reasonable way to build a program: start from nothing, add one step per item.
const drawAll = (shapes) => shapes.reduce((soFar, s) => flatMap(soFar, () => draw(s)), pure(undefined))
```
Here is what that costs with Module 7's `IO`, measured with Node on the machine this module was written on, with a `while` loop as the interpreter (the loop of Module 9's `runIO`):
| Draws, left-nested | Time | Draws, right-nested | Time |
|---|---|---|---|
| 1 000 | 11 ms | 100 000 | 38 ms |
| 5 000 | 223 ms | | |
| 10 000 | 1 082 ms | | |
| 100 000 | stack overflow | | |
Two different problems hide in that table. Going from 5 000 to 10 000 draws multiplies the time by almost five: the work grows with the *square* of the length. And at 100 000 the stack runs out, even though the interpreter is a loop. On top of that, Module 7's `trace` is recursive, one level per operation, so it overflows at around 10 000 operations whichever way the program is nested.
This module removes both problems by changing the representation, not the rule. You will add a third constructor, `FlatMap`, so that "and then" becomes a piece of data; write the one rewrite rule that makes left nesting harmless; and build an interpreter that never grows the stack, out of tiny steps. At the end, 100 000 left-nested `flatMap`s run in a fraction of a second, and the programs behave exactly as they did under Module 7's `IO`, which the tests check.
## Learning objectives
By the end of this module you can:
- **explain** why a left-nested chain of `flatMap`s is both deep (stack) and quadratic (time) with the `IO` of Module 7;
- **implement** an `IO` whose `flatMap` only builds a `FlatMap` node;
- **state** the re-association rule, draw it as a picture, and **justify** it with the associativity law;
- **implement** `step`, a single rewrite with a constant amount of work, and **explain** how an interpreter built from such steps (a *trampoline*) keeps the stack flat;
- **explain** how a loop can be written without a loop in a language that does not eliminate tail calls, and why the ops must not be collected with `[...ops, op]`;
- **describe** what production effect libraries add on top of this.
## Concepts
### 1. Where the stack goes
Take Module 7's `flatMap` again:
```js
export const flatMap = (io, f) =>
io.tag === 'Pure'
? f(io.value)
: { tag: 'Effect', op: io.op, k: (result) => flatMap(io.k(result), f) }
```
Build a left-nested chain of three steps, `flatMap(flatMap(flatMap(e, f1), f2), f3)`, where `e` is one `Effect(op1, k)`. Work from the inside out:
```
flatMap(e, f1) = Effect(op1, k1) where k1(r) = flatMap(k(r), f1)
flatMap(that, f2) = Effect(op1, k2) where k2(r) = flatMap(k1(r), f2)
flatMap(that, f3) = Effect(op1, k3) where k3(r) = flatMap(k2(r), f3)
```
The outermost program still starts with `op1`, but its continuation is a **tower**: `k3` calls `k2`, which calls `k1`, which calls `k`. When the interpreter answers `op1` and calls `k3(r)`, all of those calls are on the stack at the same time before anything comes back. With `n` steps, the tower is `n` calls high. A `while` loop in the interpreter does not help, because the depth is *inside one call of a continuation*, not between the loop's iterations.
With right nesting, `flatMap(e, (r) => flatMap(e2, (r2) => …))`, the inner `flatMap` sits inside a function that has not been called yet. Each continuation does one small thing and returns the next `Effect`. Nothing piles up.
### 2. Why it is also quadratic
Follow the tower one level further. When `k3(r)` finally returns, what does it return? `k(r)` gave the rest of `e` (say `Pure(r)`); `flatMap(Pure(r), f1)` gave `f1(r)`, which is the program's second step, say `Effect(op2, …)`; then `flatMap(…, f2)` wrapped that in a new continuation, and `flatMap(…, f3)` wrapped it again. So the second operation comes out with a tower of `n − 1` levels, the third with `n − 2`, and so on. The total number of calls is about `n + (n − 1) + … + 1`, which is `n²/2`. That is the answer to the challenge question 9 of Module 7, and the reason the table above grows by a factor of four to five each time `n` doubles.
> **From everyday programming:** this is the same trap as appending to the end of a singly linked list by walking from the head every time: each append is cheap on its own, but the walk gets longer with every element, and building a list of `n` elements costs `n²/2` steps. The classic fixes are the ones this module uses: keep a better structure (a pointer to the end, or here a node that remembers "and then"), or reorganise the work so that you only ever touch the front. String building has the same shape: `s = s + piece` in a loop copies everything so far each time, which is why languages offer string builders and `join`.
### 3. The fix: make "and then" a piece of data
The trouble starts in the `Effect` case of `flatMap`, which immediately builds a new continuation around the old one. What if `flatMap` did nothing clever at all, and just *remembered* what it was asked to do?
```
FlatMap(io, f) "run io; then, with its result, continue with f(result)"
```
A program is now one of three things:
- **`Pure(value)`**: done, the result is `value`;
- **`Effect(op, k)`**: perform `op`, give its answer to `k`, continue with what `k` returns;
- **`FlatMap(io, f)`**: run `io`; when it produces a value `x`, continue with `f(x)`.
`flatMap(io, f)` is now one line: build `FlatMap(io, f)`. It costs the same for any `io`, and it never calls `f`, not even when `io` is a `Pure`. That also quietly removes the eagerness of Module 7 section 9: `flatMap(pure(1), () => { throw … })` no longer throws while building.
All the cleverness moves into the interpreter, which meets `FlatMap` nodes and has to decide what to do with them. That is the subject of the next two sections. First, the type in the three languages.
**JavaScript**
```js
// SafeIO<A> =
// { tag: 'Pure', value } value : A
// { tag: 'Effect', op, k } op : Op, k : (answer) => SafeIO<A>
// { tag: 'FlatMap', io, f } io : SafeIO<X>, f : (x: X) => SafeIO<A> for some type X
```
**Scala**
```scala
// scala/src/SafeIO.scala. Op, OpResult and Traced are the ones from IO.scala.
enum SafeIO[+A]:
case Pure(a: A)
case Effect(op: Op, k: OpResult => SafeIO[A])
case FlatMap[X, B](io: SafeIO[X], f: X => SafeIO[B]) extends SafeIO[B]
```
**F#**
```fsharp
// fsharp/src/SafeIO.fs, module Snake.SafeIO, compiled after IO.fs.
// Op, OpResult and Traced are the ones from Snake.IO: write them IO.Op, IO.OpResult, IO.Traced.
// (Do not `open Snake.IO`: its cases are also called Pure and Effect.)
type SafeIO<'a> =
| Pure of 'a
| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)
```
**Language note: the hidden type `X`.** Look at `FlatMap(io, f)`: the whole program produces an `A`, but the inner `io` produces something else, an `X`, which only `f` cares about. Different `FlatMap` nodes have different `X`s, and nobody outside the node needs to know which. JS has no types to worry about. The Scala `enum` can say it directly: the case has its own type parameters, `FlatMap[X, B]`, and declares that it `extends SafeIO[B]`; `X` appears in no other place, so it stays hidden inside the node (a so-called existential type). An F# union case cannot introduce a type parameter of its own, so the F# track stores the inner program as a `SafeIO<obj>` and the continuation as `obj -> SafeIO<'a>`: the type `X` is *erased* to `obj`. The stub gives you a small helper, `erase : SafeIO<'a> -> SafeIO<obj>`, which views a program as a `SafeIO<obj>` with constant work (it boxes a `Pure`'s value and wraps continuations without calling them). `bind` is then `FlatMap (erase io, fun x -> f (unbox<'a> x))`. The `unbox` cannot fail, because only `bind` builds a `FlatMap`, and it always pairs a program of `'a` with a function expecting `'a`. Keep `erase` and `unbox` inside the module and the rest of the world never sees an `obj`.
### 4. The re-association rule, as a picture
An interpreter can only do something with a `Pure` (finished) or an `Effect` (perform it). So when it meets a `FlatMap`, it rewrites the program until the top is one of those two. Look at what can be inside a `FlatMap(io, f)`; there are three cases, one per constructor of `io`:
**Rule 1.** `FlatMap(Pure(a), f)` becomes `f(a)`. The first part is finished with `a`, so continue with `f(a)`. (This is the left identity law of Module 7.)
**Rule 2.** `FlatMap(Effect(op, k), f)` becomes `Effect(op, r => FlatMap(k(r), f))`. The first thing to do is `op`; afterwards, run the rest of the first part, `k(r)`, and then `f`. Note that the new continuation only *builds* a `FlatMap` node; it does not call `flatMap` recursively the way Module 7 did, so no tower can form.
**Rule 3.** `FlatMap(FlatMap(m, g), f)` becomes `FlatMap(m, x => FlatMap(g(x), f))`. This is the important one. As a picture:
```
FlatMap FlatMap
/ \ / \
FlatMap f ==> m x => FlatMap
/ \ / \
m g g(x) f
```
Read it in words: "(run `m`, then `g`), then `f`" is the same program as "run `m`, then (`g`, then `f`)". It is the associativity law of Module 7 section 10, used as an algorithm: the law says both shapes behave the same, so the interpreter is free to replace the left one by the right one. Each application moves one `FlatMap` from the left spine of the tree to the right side, where it waits inside a function until it is needed. A left-nested chain of `n` `flatMap`s needs `n` applications of rule 3 to expose its first `Effect`, then the continuations unwind one at a time. Every node is rewritten a constant number of times, so the total work is proportional to `n`, not `n²`.
Each rule looks at most two levels into the tree, builds at most two new nodes, and calls at most one function (`f` in rule 1). That is what makes the next idea possible.
### 5. Trampolining: an interpreter made of small moves
Put the three rules into one function, **`step(io)`**: given a `FlatMap`, apply the rule that matches and return the result; given a `Pure` or an `Effect`, return it unchanged. `step` is not recursive. After one `step`, the top of the program may still be a `FlatMap` (rule 3 produces one); that is fine, it will be stepped again.
An interpreter is now a loop of small moves:
- if the program is a `Pure`, stop;
- if it is an `Effect(op, k)`, perform `op` and continue with `k(answer)`;
- if it is a `FlatMap`, continue with `step(program)`.
In a runtime file, where a loop is allowed, that is a few lines (this is an illustration; the course's runtime keeps Module 7's `IO`):
```js
// What a stack-safe runIO would look like in runtime.js, where loops and `let` are allowed.
const runSafeIO = (io, platform) => {
let current = io
while (current.tag !== 'Pure') {
current = current.tag === 'FlatMap' ? step(current) : current.k(perform(current.op, platform))
}
return current.value
}
```
Every move returns to the loop before the next one starts, so the stack is the same height after a million moves as after one. This technique is called a **trampoline**: instead of one function calling the next from inside itself (which piles frames on the stack), each move *bounces back* to a driver, which makes the next call. ([Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29) on Wikipedia; Rúnar Bjarnason's ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) builds exactly this structure.)
> **From everyday programming:** you may have converted a recursive tree walk into a loop with an explicit stack, because a deep tree crashed the recursive version. That is the same move: the work that the call stack used to remember is now held in a data structure that the loop manages. Job runners do it too: instead of each job calling the next job directly, which would make an ever deeper chain of calls, each job returns "what to do next" to a dispatcher that runs it. Here the data structure is the program tree itself, and rule 3 is what keeps its left side shallow.
### 6. A loop without a loop: the pure `trace`
The tests need a *pure* interpreter, `trace(io, seed)`, with the same contract as in Module 7: answer every `RandomSeed` with `seed` and every `Draw` with unit, and return `{result, ops}`. Any other op, such as Module 10's `LoadBest`, is an error: the given `answer` throws rather than inventing a reply. It must survive 100 000 operations. How it achieves that depends on the language.
**Scala and F#** turn a function that calls itself as its very last action (a *tail call*) into a loop: Scala checks it for you with `@tailrec`, F# does it for any `let rec` function that calls itself in tail position ([Tail call](https://en.wikipedia.org/wiki/Tail_call)). So `trace` is an inner function `loop(current, performed)` with one case per constructor, each ending in a call to `loop`: the stack stays flat and nothing is mutated. Collect the ops by putting each one at the *front* of an immutable list (constant work) and reverse the list once at the end.
**JavaScript** is the awkward one. The language standard has asked for proper tail calls since 2015, but only Safari implements them ([WebKit: ECMAScript 6 proper tail calls](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/)); in Node and Chrome every call takes a stack frame, so a recursive `trace` overflows. A `while` loop would work, but this file follows the purity rules: no loops, no `let`. The stub therefore gives you a driver, `drive(start, isDone, next)`, which applies a "move" function `next` until `isDone`, using recursion that is only **logarithmically** deep:
- doing `n` moves is doing `n/2` moves and then `n/2` more; each half is split again, so the recursion is `log2(n)` levels deep (17 levels for 100 000 moves);
- the driver does not know in advance how many moves the program needs, so it tries 1 move, then 2 more, then 4 more, doubling the budget each round until the program is finished (another `log2` levels).
Your part is the move: **`traceStep(io, seed)`** returns `[nextProgram, ops]`, where `ops` is `[]` for a `FlatMap` (rewritten with `step`) and `[op]` for an `Effect` (answered and continued). `trace` then runs `drive` with `traceStep` until the program is a `Pure`.
**Collecting the ops.** The obvious way, `ops = [...ops, op]` at every move, copies the whole array each time: `n²/2` copies, exactly the quadratic cost this module set out to remove. Putting each op at the front of a linked list is cheap, but turning that list back into an array needs either a loop or a recursion `n` levels deep. `drive` instead keeps the outputs in a balanced tree that follows the halving above, and flattens it once at the end: every op is copied once per level of the tree, and the tree is only logarithmically deep (a few dozen levels for 100 000 ops). That is `n log n` work, a little more than linear and nowhere near quadratic, with no loop, no mutation and a shallow stack. You don't have to write `drive`, but read it: it is twenty lines.
### 7. What production libraries add
The three-constructor `IO` with a re-associating interpreter is the core of every serious effect library. Real ones add:
- **A faster run loop.** Instead of rewriting the tree, the interpreter keeps the pending continuations of rule 3 on a stack of its own (a mutable array, safely hidden inside the runtime): meeting `FlatMap(io, f)` pushes `f` and continues with `io`; reaching a `Pure(a)` pops the top `f` and continues with `f(a)`. Same behaviour, fewer allocations. cats-effect's run loop works this way; other libraries use variations of the same idea.
- **Suspended side effects.** A constructor such as `Delay(() => a)` turns any side-effecting code into a description, so a library needs no fixed list of operations like our `Op`.
- **Errors, cancellation and concurrency.** Constructors for raising and handling errors, for running programs on lightweight threads (*fibers*), for waiting on asynchronous callbacks, and for cancelling a fiber safely; a scheduler that interleaves thousands of fibers on a few operating-system threads, letting a busy fiber pause regularly so that others get a turn ([cats-effect: thread model](https://typelevel.org/cats-effect/docs/thread-model)).
- **The same trick elsewhere.** Free monads get a `FlatMapped` constructor and a stack-safe interpreter in the same way ([cats: Free monads](https://typelevel.org/cats/datatypes/freemonad.html)), and Scala's standard library ships a small general-purpose trampoline, [`scala.util.control.TailCalls`](https://www.scala-lang.org/api/current/scala/util/control/TailCalls$.html).
## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones (in `js/test/m07b-safeio.test.js`). The Scala and F# suites (`scala/test/M07bSafeIO.test.scala`, `fsharp/tests/M07bSafeIO.fs`) test the same things in each track's vocabulary. Two differences: `traceStep` and `drive` exist only in JS (section 6), and functions cannot be compared, so where the JS tests check that a node holds the very same `f`, the Scala and F# tests check the node's shape and then observe the program through `trace`.
All exercises are in the one file per track. Write them in order. The purity rules apply as usual (no `let`, no loops, no mutation; Scala/F#: no `var`/`mutable`); the given `drive` follows them too.
### Exercise 7b.1 — `pure`, `effect`, `flatMap`, `map`, `sequence`
**JavaScript**
```js
export const pure = (value) => /* SafeIO<A>: { tag: 'Pure', value } */
export const effect = (op) => /* SafeIO<answer>: { tag: 'Effect', op, k: pure } */
export const flatMap = (io, f) => /* SafeIO<B>: only builds { tag: 'FlatMap', io, f } */
export const map = (io, f) => /* SafeIO<B>, from flatMap and pure */
export const sequence = (ios) => /* SafeIO<undefined>, left to right; either fold is fine now */
```
**Scala**
```scala
enum SafeIO[+A]:
...
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = ???
def map[B](f: A => B): SafeIO[B] = ???
object SafeIO:
def pure[A](a: A): SafeIO[A] = ???
def effect(op: Op): SafeIO[OpResult] = ???
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ???
```
**F#** (data-last; the helper `erase` is given)
```fsharp
let pure (a: 'a) : SafeIO<'a> = failwith "TODO m07b"
let effect (op: IO.Op) : SafeIO<IO.OpResult> = failwith "TODO m07b"
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = failwith "TODO m07b"
```
**Proved by:** `pure builds a Pure`, `effect(op) is an Effect whose continuation is pure`, `flatMap only builds a FlatMap node`, `building flatMap(pure(1), throwingF) does not throw`, `building map(io, throwingF) does not throw`. `sequence` is checked through `trace` in Exercise 7b.3.
**Watch out for:** writing `flatMap` the Module 7 way. If your `flatMap` looks at `io.tag` at all, it is doing the interpreter's job.
### Exercise 7b.2 — `step`, the re-association step
**JavaScript**
```js
export const step = (io) => /* SafeIO<A>: one rewrite (section 4); Pure and Effect come back unchanged */
```
**Scala**
```scala
object SafeIO:
def step[A](io: SafeIO[A]): SafeIO[A] = ???
```
**F#**
```fsharp
let step (io: SafeIO<'a>) : SafeIO<'a> = failwith "TODO m07b"
```
**Proved by:** `step leaves a Pure unchanged`, `step leaves an Effect unchanged`, `step of FlatMap(Pure(a), f) is f(a)`, `step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f)`, `step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f))`, `step does not call the functions it re-associates`, `step of a left-nested chain is one rewrite, not a whole walk`, and `a while loop over step is a complete interpreter for 100 000 left-nested flatMaps` (the loop of section 5, written in the test file, where loops are allowed).
**Watch out for:** recursion. `step` applies *one* rule and returns. If it calls itself, a left-nested chain of 100 000 overflows the stack inside `step`, and the "one rewrite" test sees that the top is no longer a `FlatMap`.
### Exercise 7b.3 — the pure interpreter
**JavaScript** (`drive` is given)
```js
export const traceStep = (io, seed) => /* [nextProgram, ops]: ops is [] or [op] */
export const trace = (io, seed) => /* { result, ops }, using drive and traceStep */
```
**Scala** (a `@tailrec` inner function)
```scala
object SafeIO:
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = ???
```
**F#** (an inner `let rec` that calls itself only in tail position)
```fsharp
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> = failwith "TODO m07b"
```
Answer every `RandomSeed` with the seed (`Seed(seed)` in Scala/F#) and every `Draw` with unit (`Done`), as in Module 7.
**Proved by:** `traceStep answers an Effect and records its op`, `traceStep rewrites a FlatMap and records nothing`, `traceStep leaves a Pure unchanged`, `trace of pure(3) has result 3 and no ops`, `trace answers RandomSeed with the seed and Draw with undefined`, `tracing map(io, throwingF) runs f and throws`, `trace refuses operations of later modules`, `sequence of nothing has no ops`, `sequence runs effects left to right`; the stack-safety tests `100 000 left-nested flatMaps of draws run, in order`, `100 000 right-nested flatMaps of draws run, in order`, `100 000 left-nested maps run without any op`, `sequence of 100 000 draws runs`; the nine monad-law tests (`left identity holds for …`, `right identity holds for …`, `associativity holds for …`, for `a pure value`, `one effect` and `a draw followed by reading the seed`); and `trace equals Module 7's trace for …` on four programs, each written once and built with both `IO`s. (`drive collects every output in order over 200 000 moves` and `drive of a state that is already done makes no move` test the given driver; they pass from the start.)
**Run (all of Module 7b):**
- JS: `cd js && node --test "test/m07b-*.test.js"`
- Scala: `cd scala && ./test.sh M07b`
- F#: `cd fsharp && ./test.sh M07b`
## Hints
<details><summary>Exercise 7b.1 — nudge</summary>
`pure` and `effect` are the same as in Module 7. `flatMap` is shorter than in Module 7: it does not inspect anything. `map` and `sequence` can be written exactly as in Module 7, on top of the new `flatMap` and `pure`.
</details>
<details><summary>Exercise 7b.1 — stronger hint</summary>
`flatMap(io, f)` returns an object with three fields, `tag`, `io` and `f`, and nothing else happens. `map` is "and then finish with `f` of the value". For `sequence`, a left fold that starts from `pure(undefined)` and glues each program on with `flatMap(acc, () => io)` is fine now: that is the shape that broke Module 7, and the tests run 100 000 of them.
</details>
<details><summary>Exercise 7b.1 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const flatMap = (io, f) => ({ tag: ___, io, f })
export const map = (io, f) => flatMap(io, (a) => ___)
export const sequence = (ios) => ios.reduce((acc, io) => flatMap(___, () => ___), pure(undefined))
```
**Scala**
```scala
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = FlatMap(___, f)
def map[B](f: A => B): SafeIO[B] = flatMap(a => ___)
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ios.foldLeft(pure(()))((acc, io) => ___)
```
**F#**
```fsharp
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = FlatMap(erase ___, fun x -> f (unbox<'a> ___))
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = io |> bind (fun a -> ___)
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = List.fold (fun acc io -> ___) (pure ()) ios
```
</details>
<details><summary>Exercise 7b.2 — nudge</summary>
First decide what to do when `io` is not a `FlatMap` (the doc comment says). Otherwise look one level down, at `io.io`: it is a `Pure`, an `Effect` or a `FlatMap`, and section 4 has one rule for each. Write each rule's right-hand side as an object literal or a `flatMap` call.
</details>
<details><summary>Exercise 7b.2 — stronger hint</summary>
Name the parts: the outer node is `FlatMap(inner, f)`. Rule 1 needs `inner.value`; rule 2 needs `inner.op` and `inner.k`; rule 3 needs `inner.io` (the `m` of the picture) and `inner.f` (the `g`). Building a `FlatMap` node is what your `flatMap` does, so you can use it on the right-hand sides. Nothing in `step` calls `step`.
</details>
<details><summary>Exercise 7b.2 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const step = (io) => {
if (io.tag !== 'FlatMap') return ___
const { io: inner, f } = io
if (inner.tag === 'Pure') return ___
if (inner.tag === 'Effect') return { tag: 'Effect', op: inner.op, k: (r) => flatMap(___, f) }
return flatMap(inner.io, (x) => flatMap(___, ___))
}
```
**Scala**
```scala
def step[A](io: SafeIO[A]): SafeIO[A] = io match
case FlatMap(Pure(x), f) => ___
case FlatMap(Effect(op, k), f) => Effect(op, r => FlatMap(___, f))
case FlatMap(FlatMap(m, g), f) => FlatMap(m, x => FlatMap(___, ___))
case other => other
```
**F#**
```fsharp
let step (io: SafeIO<'a>) : SafeIO<'a> =
match io with
| FlatMap(Pure x, f) -> ___
| FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(___, f))
| FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(___, ___))
| other -> other
```
In F# the inner parts already have the erased types (`m : SafeIO<obj>`, `g : obj -> SafeIO<obj>`), so no `erase` or `unbox` is needed here.
</details>
<details><summary>Exercise 7b.3 — nudge</summary>
`traceStep` has three cases, one per constructor; section 6 lists what each returns. For `trace`: what is the start state, when is the driver done, and what is the move? `drive` returns `[last, ops]`; the result is in `last`.
</details>
<details><summary>Exercise 7b.3 — stronger hint</summary>
The `Effect` case of `traceStep` is the body of Module 7's `trace` without the recursion: answer the op, call `k`, and return the new program together with `[op]` instead of tracing it further. The driver is done when the program is a `Pure`. In Scala and F#, the same three cases go into the inner tail-recursive function, with the list of performed ops as an extra argument that grows at the front; the `Pure` case returns the result with that list reversed.
</details>
<details><summary>Exercise 7b.3 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const traceStep = (io, seed) => {
if (io.tag === 'FlatMap') return [___, []]
if (io.tag === 'Effect') return [io.k(answer(io.op, seed)), ___]
return [io, []]
}
export const trace = (io, seed) => {
const [last, ops] = drive(io, (p) => ___, (p) => traceStep(p, seed))
return { result: ___, ops }
}
```
**Scala**
```scala
def trace[A](io: SafeIO[A], seed: Long): Traced[A] =
@tailrec def loop(current: SafeIO[A], performed: List[Op]): Traced[A] = current match
case Pure(a) => Traced(a, ___)
case Effect(op, k) => loop(k(___), op :: performed)
case other => loop(___, performed)
loop(io, Nil)
```
**F#**
```fsharp
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> =
let rec loop current performed =
match current with
| Pure a -> { IO.Result = a; IO.Ops = ___ }
| Effect(op, k) -> loop (k ___) (op :: performed)
| FlatMap _ -> loop ___ performed
loop io []
```
</details>
## Check your understanding
1. With Module 7's `IO`, why does a `while` loop in the interpreter not save a left-nested chain from overflowing the stack? Where exactly is the depth?
2. Count, for Module 7's `IO`, the calls needed to run a left-nested chain of 4 effects, then of 8. How does the count grow? Now count the `step`s that `SafeIO` needs for the same chains.
3. Draw the tree of `flatMap(flatMap(flatMap(e, f1), f2), f3)` and apply rule 3 until the top is `FlatMap(e, …)`. How many rewrites did it take? Then write out the continuation you ended with.
4. Rule 3 replaces one program by another. Which law of Module 7 says that this does not change what the program does? What would go wrong if an interpreter applied rule 3 to an `IO` type that did not obey that law?
5. `step` never calls itself. Why is that essential in JavaScript, and why would it still be a good idea in Scala or F#?
6. Module 7 section 9 said that `flatMap(pure(1), f)` calls `f` while building. Is that still true for `SafeIO`? When does `f` run now?
7. Why doesn't `trace` collect its ops with `[...ops, op]`? Estimate how many element copies that would make for 100 000 ops, and compare with what `drive` does.
8. Rules 1 and 3 never look at an `Op`. Suppose you add a new operation (say `PlaySound`): which parts of `safeio.js` change, and which don't?
9. **Challenge.** If you knew the exact number of moves in advance, you could write a driver with no recursion at all, using `Array.from` and `reduce`. How? Why doesn't that work for `trace`, and how does the doubling budget of `drive` get around it?
## In production
- **cats-effect** (Scala) and **ZIO** (Scala) represent `flatMap` as a data constructor and interpret programs in a run loop that makes any shape of program stack-safe; they add suspended side effects, error handling, fibers, cancellation and a fair scheduler ([cats-effect](https://typelevel.org/cats-effect/), [ZIO](https://zio.dev/)).
- **Free monads** in libraries such as cats get the same treatment: a `FlatMapped` constructor and a stack-safe interpreter ([cats: Free monads](https://typelevel.org/cats/datatypes/freemonad.html)).
- **Haskell** handles left-nested binds of free monads with *type-aligned sequences*, which keep a chain of continuations in a queue that can be extended at either end in constant time ([van der Ploeg and Kiselyov, "Reflection without Remorse"](https://okmij.org/ftp/Haskell/zseq.pdf)).
- **General-purpose trampolines** appear in many standard libraries; Scala's [`scala.util.control.TailCalls`](https://www.scala-lang.org/api/current/scala/util/control/TailCalls$.html) is one, and in JavaScript you meet the same idea whenever a long recursion is turned into a loop that calls "the next step" returned by the previous one.
## Further reading
- [Rúnar Bjarnason, "Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) — trampolines, the `FlatMap` constructor and the re-association rule, step by step.
- [Atze van der Ploeg and Oleg Kiselyov, "Reflection without Remorse"](https://okmij.org/ftp/Haskell/zseq.pdf) — why left-nested binds are quadratic in general, and a data structure that fixes it.
- [Oleg Kiselyov and Hiromi Ishii, "Freer Monads, More Extensible Effects"](https://okmij.org/ftp/Haskell/extensible/more.pdf) — the "operation plus continuation" representation of Module 7, with the continuations kept in such a queue.
- [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29) and [Tail call](https://en.wikipedia.org/wiki/Tail_call) on Wikipedia.
- [WebKit: ECMAScript 6 proper tail calls](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/) — what JavaScript was supposed to get, and why most engines did not ship it.
- [Scala 3: algebraic data types with enums](https://docs.scala-lang.org/scala3/reference/enums/adts.html) and [F#: recursive functions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/functions/recursive-functions-the-rec-keyword) — the language features the Scala and F# tracks rely on here.
+8 -2
View File
@@ -77,6 +77,8 @@ type Event =
**Naming note.** The JS doc comments call this type `Input`; Scala and F# call it `Event`, because their m09 module is already named `Input` and the two names would clash. Same thing.
(A fourth kind, a mouse click on the canvas, arrives in [Module 13](13-capstone-life.md), the second capstone. It is one more case of this type and one more source next to `keyDowns`; nothing in this module changes for it.)
Notice that time arrives as an event too. The program never *asks* for the time (`Date.now()` is banned by the purity audit); the outside world *tells* it what time it is, the same way it tells it about keys. Hidden time becomes an explicit input. Keep that in mind; it's the most important design decision in this module.
### 2. A stream is a machine you feed one event at a time
@@ -169,7 +171,7 @@ Take a minute and work out by hand why the second list is `[10, 30, 130, 160, 20
**`interval(ms)`** is the interesting one. It turns the clock into regular beats: on `Time t`, it emits every multiple of `ms` that has been *crossed* since the last time event — every `k*ms` with `last < k*ms ≤ t` — in ascending order. It starts with `last = 0` and afterwards `last` becomes `t`. The values it emits are those multiples themselves.
Why "every multiple crossed" rather than "one beat per time event"? Because the outside world does not call us on a perfect schedule. The browser reports time about 60 times per second, but if the tab was in the background for two seconds, the next time event might jump from `t = 1000` to `t = 3000`. The game should then catch up on all the ticks it missed, not silently skip them. (This idea, separating game time from the rate at which the outside world calls you, is the classic "fixed timestep" pattern; see [Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html).)
Why "every multiple crossed" rather than "one beat per time event"? Because the outside world does not call us on a perfect schedule. The browser reports time about 60 times per second, but one slow frame can make the next time event jump from `t = 1000` to `t = 1070`. The game should then catch up on the ticks it missed, not silently skip them, so that game time does not drift away from real time. (This idea, separating game time from the rate at which the outside world calls you, is the classic "fixed timestep" pattern; see [Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html).) Catching up has a limit, though: if the tab was in the background for a whole minute, replaying that minute in one go is not what anybody wants. `interval` stays simple and always catches up; Module 10 adds a separate combinator that limits how far the clock may jump.
Here is how `last` threads through a sequence of events for `interval(10)`:
@@ -492,8 +494,10 @@ let rec map (f: 'a -> 'b) (Stream step) : Stream<'b> =
4. Why does `map(s, f)` have to return `map(next, f)` and not just `next`? Write down what `run` would produce with the wrong version on two key presses.
5. The test "running the same stream value twice gives the same outputs" would fail for a typical RxJS pipeline with a `scan`. Explain why in terms of where the accumulator lives.
6. `merge(a, b)` steps *both* streams on *every* event. What would go wrong with an `interval` inside `b` if `merge` only stepped `b` on events that `a` ignored?
7. The browser reports time about 60 times per second, and the snake ticks every 150 ms. Using `interval`, explain what happens if the browser stops calling us for 1 second (say, the tab was hidden) and then resumes. Is that what you want for this game?
7. The browser reports time about 60 times per second, and the snake ticks every 150 ms. Using `interval`, explain what happens if the browser stops calling us for 1 second (say, the tab was hidden) and then resumes. Is that what you want for this game? And after a whole minute? (Module 10 comes back to this.)
8. Our streams never ask what time it is. Why is that important for testing? Where *will* the time come from?
9. **Challenge: laws for streams.** Two streams "behave the same" when `run` gives the same outputs for every list of events. Argue from the definitions: (a) `map(map(s, f), g)` behaves like `map(s, x => g(f(x)))`; (b) `merge(merge(a, b), c)` behaves like `merge(a, merge(b, c))`; (c) `merge(a, b)` does *not* in general behave like `merge(b, a)`: give two streams and one event that show the difference; (d) is `filter(map(s, f), p)` the same as `map(filter(s, p), f)`? When?
10. Every combinator in this module changes a stream's *outputs*. Sketch one that changes its *inputs*: `mapEvents(s, g)`, which behaves like `s` but shows it `g(event)` instead of each event. What could you build with it? (Module 10 needs a stateful version of this idea.)
## In production
@@ -510,3 +514,5 @@ let rec map (f: 'a -> 'b) (Stream step) : Stream<'b> =
- [Functional reactive programming (Wikipedia)](https://en.wikipedia.org/wiki/Functional_reactive_programming) — an overview of the family of ideas and implementations.
- [Robert Nystrom, Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html) — why games decouple game time from the rate at which the outside world calls them; the reason `interval` catches up.
- [RxJS: Overview](https://rxjs.dev/guide/overview) — the push-based alternative, to compare with what you built.
- [Evan Czaplicki, "Elm: Concurrent FRP for Functional GUIs"](https://elm-lang.org/assets/papers/concurrent-frp.pdf) — the thesis behind Elm; its survey of earlier FRP designs explains the trade-offs between signals, events and automata.
- [Conal Elliott, "Push-Pull Functional Reactive Programming"](http://conal.net/papers/push-pull-frp/) — how the two strategies of section 7 can be combined.
+102 -3
View File
@@ -35,7 +35,8 @@ By the end of this module you can:
- **explain** the type `IO<Stream<IO<unit>>>` layer by layer, and why `main` is a value;
- **implement** an interpreter `runIO` as a loop and a driver `runApp` that holds the only mutable variable of the program;
- **explain** how a `Platform` interface lets the runtime be tested with a fake instead of a browser;
- **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels.
- **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels;
- **implement** `play`, which states the behaviour of the whole program, for a list of inputs, as one comparison of plain data.
## Concepts
@@ -124,11 +125,13 @@ And `main` is a **value**. Defining it performs nothing — no seed is asked for
```js
const { ops, result: stream } = trace(main, 42)
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) // main asks for a seed, nothing else
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' }) // main asks for a seed first
const [outs] = stream.step(time(0)) // the first event…
assert.equal(outs.length, 1) // …produces exactly one drawing: the new game
```
(The real test also allows exactly one more operation after the seed, and lets the first drawing carry shapes after the new game's view. Both are for Module 10, where `main` also loads the stored best score and draws it; in this module, `ops` is simply `[{ tag: 'RandomSeed' }]`.)
The whole program, with its randomness, time and keyboard, is tested with the pure interpreter from Module 7 and `step` from Module 8. No browser.
**About `classicActions` and Module 10.** In Module 10, `main` will switch from `classicActions` to `speedActions`. The m09 tests that check the 150 ms behaviour therefore use a copy of `main` built from `classicActions` (`classicMain` in the test helpers), so they keep passing after Module 10.
@@ -181,6 +184,8 @@ type Platform =
OnKeyUp: (string -> unit) -> unit }
```
(Module 10 adds two more methods, `loadBest` and `saveBest`, for a best score that survives a reload.)
The runtime only ever talks to a `Platform`. The browser implementation (`browser.js` in JS, `web/BrowserPlatform.scala`, `fsharp/web/BrowserPlatform.fs`) wraps a canvas, `requestAnimationFrame` and `crypto.getRandomValues`. The tests use a **fake platform** that records every call in a list and lets the test fire events by hand — a test double in the sense of [Martin Fowler's "Test Double"](https://martinfowler.com/bliki/TestDouble.html):
```js
@@ -226,6 +231,8 @@ Remember section 12 of Module 7: the loop only helps if each continuation does a
- store `next` in the variable;
- run each IO in `ios` with `runIO`, in order.
(Module 13 adds a fourth registration, for mouse clicks on the canvas, and a matching `onClick` method to the platform. Write the three now; the tests of this module do not expect the fourth.)
That's the whole runtime. Go back to the stateful code at the start of Module 8, with its `let state` and logic in callbacks, and compare. There, the mutable variable held the *game state*, and game logic lived in the callbacks. Here, the variable holds the *current stream* — the runtime doesn't even know there's a game. It has no idea what a snake, a tick or a score is. All the game logic is in pure values; the runtime is a few lines of plumbing. That is what "pushing effects to the edge" means in practice.
```
@@ -276,6 +283,30 @@ The first run restores Fable (a local .NET tool listed in `fsharp/.config/dotnet
Use the arrow keys or WASD. **You built a game in which every line of logic is pure.** Play a round. Then, before moving on, do the trace exercise in "Check your understanding": follow one key press from the keyboard to the pixels, naming each function it passes through.
### 10. Testing the program end to end without a browser
The tests so far check the pieces: `trace(main, 42)` for the first operation, one `step` for the first drawing, the runtime with a fake platform. Can you state the behaviour of the *whole* program in one line, the way a fixture states the behaviour of `update`?
Everything you need is already a value. `trace` interprets `main` and gives the stream; `run` (Module 8) feeds the stream a list of inputs and collects every IO it emits; `trace` again turns each emitted IO into the list of operations it would perform. Put together:
```
play(main, seed, events) = run(trace(main, seed).result, events).map(io => trace(io, seed).ops)
```
The result is a list with one entry per drawing, and each entry is the list of operations of that drawing. For our program each entry is a scene turned into `Draw` operations, so the whole game, with its seed, clock and keyboard, collapses into one comparison:
```js
const events = [time(150), down('ArrowUp'), down('x'), time(300), time(310)]
play(classicMain, 42, events)
// equals: history(newGame(42), [Tick, Turn('Up'), Tick]).map(state => the Draw ops of view(state))
```
Read that equality slowly. On the left is the program as the browser would run it, event by event. On the right is the game as Module 5 described it, a fold over actions, and the picture of each state as Module 6 described it. The test says they are the same thing. Nothing is mocked: `play` uses the same `main` that `main.js` hands to the runtime, and the only difference from a real session is the interpreter that answers the operations.
A test like this has a name in industry: a **golden** (or snapshot) test. You record the output of a known-good run once, keep it in a file, and from then on check that the program still produces exactly that. Module 10 keeps one in `fixtures/m10-golden.json`: a seed, a scripted list of inputs and the scene of the last drawing of the finished game. When `main` grows in Module 10 (it will also load the stored best score), `play` grows one parameter, the number that answers that load, exactly like `seed` answers `RandomSeed`.
> **From everyday programming:** this is an end-to-end test that needs no browser automation. Tools such as Playwright or Selenium start a real browser, click, wait and take screenshots, which is slow and sometimes flaky because real time passes. Here time is just an input value, so the "recording" of a whole game session is a list of events, the "screenshot" is a list of shapes, and the test runs in milliseconds and gives the same answer every time.
## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", `None` instead of `null`, `()` instead of `undefined`, `bind`/`choose` instead of `flatMap`/`filterMap`, `IO.map throwingF io` instead of `map(io, throwingF)`, and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M09*.test.scala` or `fsharp/tests/M09*.fs`. Fixture-based tests have the same case names in every track.
@@ -379,7 +410,36 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m0
export const browserPlatform = (canvas, win) => /* Platform */
```
Build a `Platform` from a canvas and a window: `fillRect`/`fillText` use `canvas.getContext('2d')` (font `` `${size}px monospace` ``, `textAlign` from `align`, `fillStyle` from `color`); `startClock` calls `onTime(t)` on every `win.requestAnimationFrame`, with `t` = milliseconds since the first frame; `onKeyDown`/`onKeyUp` listen on `win` and call `preventDefault()` for arrow keys and space so the page doesn't scroll; `randomSeed` returns `crypto.getRandomValues(new Uint32Array(1))[0]`. There is no automated test for this file; the test is that the game runs. Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description.
Build a `Platform` from a canvas and a window: `fillRect`/`fillText` use `canvas.getContext('2d')` (font `` `${size}px monospace` ``, `textAlign` from `align`, `fillStyle` from `color`); `startClock` calls `onTime(t)` on every `win.requestAnimationFrame`, with `t` = milliseconds since the first frame; `onKeyDown`/`onKeyUp` listen on `win` and call `preventDefault()` for arrow keys and space so the page doesn't scroll; `randomSeed` returns `crypto.getRandomValues(new Uint32Array(1))[0]`. There is no automated test for this file in this module; the test is that the game runs. (Module 10 adds two methods for a stored best score, and tests for those two with a fake window.) Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description.
### Exercise 9.5 — `play`: the whole program in one equality
**JavaScript** (`js/src/app.js`)
```js
export const play = (main, seed, events) => /* one list of ops per drawing */
// Module 10 adds a fourth parameter: (main, seed, events, best = 0)
```
**Scala** (`scala/src/App.scala`)
```scala
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] = ???
// Module 10 adds a fourth parameter: best: Int = 0
```
**F#** (`fsharp/src/App.fs`)
```fsharp
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m09"
// Module 10 adds playWith seed best events main, and play becomes playWith seed 0 events main
```
`play` is a pure helper: it takes the program as an argument, so it works for `main`, for `classicMain` in the tests, and for Pong's program in Module 11.
**Proved by:** `play on the classic program equals the scenes of the expected states`, `play with no events gives no drawings`, `play uses the seed for the program's RandomSeed`.
**Watch out for:** `trace` returns `{result, ops}`; the stream is the `result` of tracing `main`, but for each emitted IO you want its `ops`. Use the same `seed` for both traces.
**Run:**
- JS: `cd js && node --test "test/m09-*.test.js"`, then `node scripts/serve.mjs` (from the repo root) and open <http://localhost:8000/js/>
@@ -506,6 +566,42 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
</details>
<details><summary>Exercise 9.5 — nudge</summary>
Three steps, each one a function you have: get the stream out of `main`, feed it the events, and turn each IO it emitted into its list of operations. Which function feeds a stream a whole list of inputs and collects the outputs?
</details>
<details><summary>Exercise 9.5 — stronger hint</summary>
`trace(main, seed).result` is the stream. `run(stream, events)` is the list of IOs emitted, in order. `map` over that list with `io => trace(io, seed).ops`.
</details>
<details><summary>Exercise 9.5 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const play = (main, seed, events) => run(trace(main, ___).result, ___).map((io) => ___)
```
**Scala**
```scala
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] =
IO.trace(main, seed).result.run(___).map(io => ___)
```
**F#**
```fsharp
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
(IO.trace seed main).Result |> Stream.run ___ |> List.map (fun io -> ___)
```
</details>
## Check your understanding
1. Read `IO<Stream<IO<unit>>>` aloud and explain each layer. Why can't the outer `IO` be removed? Why isn't the inner one a `Scene`?
@@ -516,6 +612,8 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
6. What makes the runtime testable without a browser? What would you have to do to test it if it called `canvas.getContext('2d')` directly?
7. Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you?
8. `main` is defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what does `trace(main, 1).ops` contain, and when does the platform's `randomSeed` get called?)
9. `play(classicMain, 42, events)` equals the scenes of `history(newGame(42), actions)`. Which part of the program does the left side exercise that the right side does not? Name a bug in `classicActions` or `states` that this equality would catch and the Module 5 fixtures would not.
10. A golden test fails after you change the colour of the apple. Is that a bug in the program or in the test? What should you do, and what does that tell you about when golden tests are useful?
## In production
@@ -528,6 +626,7 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — compare `init`/`update`/`view`/`subscriptions` with `newGame`/`update`/`view`/`classicActions`.
- [Gary Bernhardt, "Functional Core, Imperative Shell"](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) — the architecture you just built, explained in 13 minutes.
- [Gary Bernhardt, "Boundaries"](https://www.destroyallsoftware.com/talks/boundaries) — the longer talk on the same idea: values as the boundaries between parts of a system.
- [MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) — where the browser platform's clock comes from.
- [MDN: `Crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) — where the seed comes from.
- [Martin Fowler, "Test Double"](https://martinfowler.com/bliki/TestDouble.html) — the vocabulary for fakes, stubs and mocks; our fake platform is a "fake".
+806 -36
View File
@@ -1,6 +1,6 @@
# Module 10 — Extensions, all pure
> **Time:** ~2–3 h · **You'll edit:** JS `js/src/game.js`, `js/src/input.js`, `js/src/app.js` · Scala `scala/src/Game.scala`, `scala/src/Input.scala`, `scala/src/App.scala` · F# `fsharp/src/Game.fs`, `fsharp/src/Input.fs`, `fsharp/src/App.fs`
> **Time:** ~5–6 h · **You'll edit:** JS `js/src/game.js`, `js/src/input.js`, `js/src/stream.js`, `js/src/app.js`, for the high score `js/src/io.js`, `js/src/runtime.js`, `js/src/browser.js`, and for the replay viewer the new `js/src/replay.js` · Scala `scala/src/Game.scala`, `scala/src/Input.scala`, `scala/src/Stream.scala`, `scala/src/App.scala`, `scala/src/IO.scala`, `scala/src/Runtime.scala`, `scala/src/Replay.scala` · F# `fsharp/src/Game.fs`, `fsharp/src/Input.fs`, `fsharp/src/Stream.fs`, `fsharp/src/App.fs`, `fsharp/src/IO.fs`, `fsharp/src/Runtime.fs`, `fsharp/src/Replay.fs`
> **Tests:** JS `cd js && node --test "test/m10-*.test.js"` · Scala `cd scala && ./test.sh M10` · F# `cd fsharp && ./test.sh M10` — and afterwards **all** tests: JS `./test.sh` · Scala `./test.sh` · F# `./test.sh`
> **Prerequisites:** Modules 0–9 (a playable game)
@@ -32,15 +32,20 @@ Every feature adds another mutable variable, another `if` in a callback, and ano
In our design, the same three features are **new cases in the `Action` data type, new cases in `update`, two more keys in `keyToAction`, and one different input stream**. The runtime doesn't change at all. The m09 tests keep passing. And every new behaviour is pinned down by fixtures that run without a browser.
Then comes a bug that no fixture so far can see, because it only happens when the player *leaves*: switch to another browser tab for a minute, come back, and the snake is dead. You will fix it without touching the edge, with one new stream combinator. A turn queue makes quick key presses count. Then comes the first feature that needs a *new effect*: a best score that survives reloading the page. You will see exactly how much of the program a new effect touches, and how much it leaves alone. And the module ends by keeping a promise from Module 5: a **replay viewer** that plays back a recorded game, forwards, backwards and on its own, built from nothing but `history` and a second, tiny program.
## Learning objectives
By the end of this module you can:
- **implement** `TogglePause`, `Restart` and `Frame` as pure cases of `update`;
- **explain** why speed-up is done with a frame counter and `movesEvery` instead of changing the clock, and **compute** the move interval for a given score;
- **predict**, using the fixtures, exactly which `Frame` actions move the snake;
- **predict**, using the fixtures, exactly which `Frame` actions move the snake, and **explain** why the counter counts frames since the last move and not frames since the start;
- **implement** `clampTime`, a combinator that changes the *inputs* a stream sees, and **explain** why a long gap in the clock must not be caught up in full;
- **implement** a one-slot turn queue in `update`, and **explain** why the Module 3 rules `turn` and `advance` did not have to change for it;
- **explain** why features added this way cannot break earlier modules' tests, and which tests *would* catch a mistake;
- **design** (on paper) a persisted high score as a new effect, without writing impure code in the game logic.
- **design** a new effect by answering six questions first, and **implement** a persisted high score as two new operations, two interpreter cases and a `Session` value that wraps the game, without writing impure code in the game logic or changing `GameState`;
- **build** a replay viewer as a second program over the same game, and **explain** why stepping *backwards* through a game costs nothing when the game is a fold.
## Concepts
@@ -69,8 +74,10 @@ How do you make the snake move faster as the score grows? The stateful answer is
Here's the alternative that keeps everything pure: **run one fast clock that never changes, and let the game decide on which beats to move.**
- A new action, `Frame`, arrives every `FRAME_MS = 25` ms.
- `GameState` has a `frame` counter (since Module 2, waiting for this moment).
- On `Frame`: if the game isn't `Playing`, nothing happens. Otherwise, `frame` goes up by one, and **if the new frame number is a multiple of `movesEvery(score)`, the game also does a `tick`** (on the state with the incremented frame).
- `GameState` has a `frame` counter (since Module 2, waiting for this moment). It holds **the number of frames since the snake last moved**.
- On `Frame`: if the game isn't `Playing`, nothing happens. Otherwise count the frame: `n = frame + 1`.
- While `n` is below `movesEvery(score)`, the result is the state with `frame: n`.
- When `n` reaches `movesEvery(score)` (write the test as `n >= movesEvery(score)`), the count starts again and the game takes a step: the result is **`tick` of the state with `frame: 0`**.
- `movesEvery(score) = max(2, 8 - floor(score / 3))`.
So the speed is a pure function of the score:
@@ -87,18 +94,47 @@ So the speed is a pure function of the score:
This is a general and very useful trick: **instead of changing the rate of an external effect, observe a fixed-rate effect and decide in pure code.** The clock stays dumb; the decision is in `update`, where it can be tested with fixtures. It's also how many real games work: a fixed simulation step, with the game deciding what happens on each one ([Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html), [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)).
Predict with the fixtures before you code. Each line is "frame before → score → does this `Frame` tick?":
Predict with the fixtures before you code. Each line is "frame before, score → what this `Frame` does":
```
frame 0, score 0 → new frame 1: 1 % 8 ≠ 0 → no tick (frame-below-threshold-only-counts-the-frame)
frame 6, score 0 → new frame 7: 7 % 8 ≠ 0 → no tick (frame-just-before-threshold-…)
frame 7, score 0 → new frame 8: 8 % 8 = 0 → tick (frame-at-threshold-ticks-the-game)
frame 15, score 0 → new frame 16: 16 % 8 = 0 → tick (frame-at-second-multiple-…)
frame 6, score 3 → new frame 7: 7 % 7 = 0 → tick (frame-threshold-shrinks-as-score-grows)
frame 7, score 3 → new frame 8: 8 % 7 ≠ 0 → no tick (frame-after-threshold-shrinks-does-not-tick)
frame 0, score 0 → n = 1 < 8 → only counts: frame 1 (frame-below-threshold-only-counts-the-frame)
frame 6, score 0 → n = 7 < 8 → only counts: frame 7 (frame-just-before-threshold-…)
frame 7, score 0 → n = 8 ≥ 8 → tick, frame 0 (frame-at-threshold-ticks-the-game-and-restarts-the-count)
frame 5, score 3 → n = 6 < 7 → only counts: frame 6 (frame-just-before-the-shrunk-threshold-…)
frame 6, score 3 → n = 7 ≥ 7 → tick, frame 0 (frame-threshold-shrinks-as-score-grows)
frame 0, score 18 → n = 1 < 2 → only counts: frame 1 (frame-at-top-speed-first-frame-does-not-tick)
frame 1, score 18 → n = 2 ≥ 2 → tick, frame 0 (frame-at-top-speed-ticks-every-second-frame)
```
The last two lines show that the multiple is checked against the *current* score's `movesEvery`. When the score crosses a threshold, the next move may come a little earlier or later than a "clean" schedule would suggest; for a game this is invisible, and the rule is simple and testable.
One case shows the order of things inside a single `Frame`: in `frame-tick-that-eats-restarts-the-count` the state has `frame 7, score 2` and the apple is right in front of the head. `n = 8` reaches `movesEvery(2) = 8`, so the game ticks; the tick eats, the score becomes 3, and the result has `frame 0`. The threshold was computed from the score *before* the tick. The new speed, `movesEvery(3) = 7`, applies to the step that starts now.
Why `>=` and not `==`? In a game played from `newGame`, `n` never jumps past the threshold, so both would do. But `update` should be total and sensible for *every* state, including one built by hand in a test. With `==`, a state with `frame: 9` and score 0 would count on forever and never move. The fixture `frame-count-past-the-threshold-still-ticks` pins this down.
### Puzzle 3: the tempting rule
There is a shorter rule, and it is the one most people write first (the first version of this course did too): let `frame` count *all* frames since the start of the game, and step whenever the count is a multiple of the current interval.
```
tempting rule: frame' = frame + 1; tick when frame' % movesEvery(score) == 0
```
It passes the obvious tests: at score 0 the snake moves on frames 8, 16, 24, …, every 200 ms. **Before reading on, trace this situation by hand:** the game is at frame 47 with score 2, and the apple is right in front of the head. Which of the next frames move the snake, and how many milliseconds lie between those moves?
<details><summary>Think about it first, then open the explanation</summary>
- Frame 48: `48 % 8 == 0`, tick. The snake eats; the score is now 3, so the interval is 7.
- Frame 49: `49 % 7 == 0`, tick. **One frame later.**
The snake moves two cells within 25 ms, right after eating, when the player has had no time to react. If there is a wall two cells ahead, the game is over.
The multiples of 8 and the multiples of 7 are two unrelated grids. The moment the score crosses a threshold, the game jumps from one grid to the other, and the distance to the next line of the new grid can be anything from 1 frame to a full interval. It is not a rare corner case either: it happens, with a random gap, at *every* speed-up.
Two lessons are in this bug:
1. **Ask what the state must remember.** The requirement is "every step lasts exactly `movesEvery(score)` frames". To check it you need to know how long the *current* step has lasted, that is, the frames since the last move. The total number of frames since the start of the game is a different quantity; it happens to give the right answer as long as the interval never changes. Choosing the state so that the rule can be stated directly is the same design move as `dir` and `nextDir` in Module 3.
2. **Example-based tests check the cases you thought of.** Six fixtures covered the tempting rule, and all six passed, because nobody wrote the case "the score changes on this very frame". In Module 12 you will state the requirement itself as a *property* ("the gap between two moves always equals `movesEvery` of the score") and let the computer search for a counterexample; it finds this bug in a few milliseconds.
</details>
### 4. The clock stream changes, the runtime doesn't
@@ -112,30 +148,220 @@ speedActions = merge(map(interval(FRAME_MS), _ => Frame), filterMap(keyDowns, ke
and `main` switches from `classicActions` to `speedActions`. Compare it with `classicActions`: the same shape, a different clock and action. The runtime still holds one stream, steps it, runs the IOs. It has no idea that the game can now pause or speed up.
### 5. Why the earlier tests keep passing
### 5. The bug you only find by leaving
Play your game, then switch to another browser tab, wait ten seconds, and come back. (If you have not built the frame clock yet, do it after Exercise 10.4.) The snake has crashed into a wall while you were away. Why?
Browsers stop calling `requestAnimationFrame` for a tab that is not visible ([MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)). When you come back, the next `Time` event carries a `t` that is ten seconds later than the previous one. `interval(25)` does what Module 8 specified: it emits every multiple of 25 that was crossed, which is 400 `Frame`s in one event. `scan` folds all 400 into the state, and the runtime draws 400 scenes in one go. Ten seconds of game have been played in a few milliseconds, with nobody steering.
Catching up is the right behaviour for a *small* hiccup: if one browser frame takes 60 ms instead of 16, the game should take the two or three steps it missed, so that game time and wall-clock time stay together. It is the wrong behaviour for a *long* silence. Every real-time game has to pick a limit, and Glenn Fiedler's ["Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/) names the usual one: never accept more than about a quarter of a second of time at once. Whatever is beyond that is dropped: the game falls behind the wall clock, which is exactly what a player who looked away wants.
Where does this limit go? There are two candidates.
- **At the edge.** The browser glue could clamp the time before it calls the runtime. But that is logic in the one place the tests cannot easily reach, repeated in each track's browser file, and invisible in `main`.
- **In the program.** The limit is a decision about how the game treats time, so it belongs with the other decisions, in pure code, as a value.
We take the second. So far every combinator has transformed the *outputs* of a stream (`map`, `filter`, `scan`). This one transforms the *inputs* that a stream gets to see:
```
clampTime(s, maxGap) : Stream<A> a stream that behaves like s, but shows s a clock that never jumps by more than maxGap
```
`clampTime` gives the inner stream `s` **a clock of its own**, starting at 0. It remembers two numbers: `last`, the real time of the previous `Time` event (0 at the start), and `clock`, the time it has told `s` so far (also 0 at the start). On each event:
- `Time t`: the clock advances by `t - last`, but **by at most `maxGap`**, and by **no less than 0** (if the outside world ever reports an earlier time, the clock stands still; it never runs backwards). Then `s` is stepped with `Time clock`, not with `Time t`. Afterwards `last` is `t`.
- A key event is passed to `s` unchanged, and `last` and `clock` stay as they are.
The outputs are whatever `s` produced, and the next stream is `clampTime` of the next `s` with the new `last` and `clock`: the same "wrap the next stream again" pattern as every combinator in Module 8, with two numbers carried along like `interval` carries one.
Trace `clampTime(interval(10), 25)`:
```
event last clock advance = min(max(t - last, 0), 25) s is stepped with outputs last' clock'
────────── ──── ───── ─────────────────────────────────── ───────────────── ───────── ───── ──────
Time 10 0 0 min(10, 25) = 10 Time 10 [10] 10 10
Time 5000 10 10 min(4990, 25) = 25 Time 35 [20, 30] 5000 35
Time 5010 5000 35 min(10, 25) = 10 Time 45 [40] 5010 45
```
After the long gap the inner clock is simply behind the real one (45 instead of 5010) and continues from there. Nothing inside `s` can tell that time was dropped, so nothing inside needs to change: not `interval`, not `speedActions`, not `update`.
In `main`, wrap the whole stream, with `MAX_GAP_MS = 250`:
```
main = map(randomSeed, seed => clampTime(map(states(seed, speedActions), render), MAX_GAP_MS))
```
After ten seconds in a background tab the game now advances by 250 ms, which is 10 frames, and carries on (test: `main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds`).
> **From everyday programming:** this is the wrapper (or decorator) idea: an object that has the same interface as the thing it wraps and adjusts the calls on their way in, like a rate limiter in front of a service or a `BufferedReader` around a stream of bytes. The wrapped stream is not modified and does not know it is wrapped. A middleware function in a web framework that rewrites a request before the handler sees it does the same job.
Two more things to notice. First, the frame-counting trick of section 3 and this fix are the same idea applied twice: do not ask the outside world to behave differently, decide in pure code what to make of what it tells you. Second, there are other reasonable policies, and each is a small change in pure code: pause the game automatically when a long gap is seen, or show a "welcome back" countdown. Question 9 below asks you to design one.
### 6. Why the earlier tests keep passing
Run all the tests of your track after this module. They should all be green, including Modules 5 and 9. Why?
- `update` still handles `Tick` and `Turn` exactly as before; you only added cases. The m05 fixtures don't contain the new actions.
- `classicActions` is unchanged. The m09 tests that check "a tick every 150 ms" use `classicMain`, a copy of the program built from `classicActions`, defined in the test helpers. The one m09 test that uses the real `main` only checks that "time passing produces more drawings", which is true for both versions.
- The runtime is unchanged, so the runtime tests can't be affected.
- `update` still handles `Tick` and `Turn` as before on every state the m05 fixtures contain; you only added cases. The m05 fixtures don't contain the new actions. (The turn queue of section 7 does change `Tick` and `Turn`, but only when a turn is already pending and another key arrives before the next step, and no m05 fixture does that. Check it: which m05 cases contain a `Turn`, and what is `nextDir` in their start state?)
- `classicActions` is unchanged. The m09 tests that check "a tick every 150 ms" use `classicMain`, a copy of the program built from `classicActions`, defined in the test helpers. The m09 tests that use the real `main` check only what is true of every version: it asks for the seed first (and at most the stored best after it), its first drawing begins with the new game's view, and time passing produces more drawings.
- `clampTime` changes nothing as long as time moves in steps of at most 250 ms, and the earlier tests on `main` never jump further than that.
- The runtime gains two cases for the two new operations of the high score (section 8) and nothing else; the m09 runtime tests use programs that never perform them. `trace` gains a third parameter with a default, so every earlier call means what it meant.
This is the "open for extension" property you get from data + a fold: a new feature is a new case, and old cases don't move. When something does break, it breaks in a fixture that names the exact situation (`restart-from-paused`, `frame-tick-can-end-the-game`), not in a browser session.
**Language note.** In the Scala track, write `update` as a `match` with one case per `Action` and **no `case _`**. Then if you add a case to `Action` and forget it in `update`, the compiler warns you ("match may not be exhaustive"). The stub's doc comment asks for this. F# gives the same warning for incomplete matches (`FS0025`). JS has no such check; the fixtures are your safety net.
### 6. A thought exercise: a high score that survives a reload
### 7. A turn queue: two keys within one step
Suppose you want to keep the best score in the browser's `localStorage` ([MDN: `localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)) so it survives reloading the page. Reading and writing storage are effects. Where would they go? Work through these questions on paper; no code is required (and none is tested).
Module 3 ended with an honest admission. The snake is heading `Right`; the player presses Up and then Left, quickly, within one step, meaning "go up one row and come back the other way". `turn` accepts Up, rejects Left (it is the opposite of `dir`, where the snake last moved), and the snake goes Up and keeps going Up. The rejection is right: accepting Left would have reversed the snake into its own neck (Puzzle 2). But the player's second key is simply lost, and at high speed, where a step lasts 50 ms, that happens often.
1. **New operations.** Which `Op`s do you need? Perhaps `{tag:'LoadHigh'}` (answer: a number) and `{tag:'SaveHigh', score}` (answer: unit). In Scala/F#, which new case does `OpResult` need?
2. **New interpreter cases.** What does `trace` answer to `LoadHigh` — a fixed number passed in, like the seed? What does `runIO` do — and which new `Platform` methods does that need?
3. **Loading.** The high score must be known before the first frame is drawn. Where in `main` does it go? (Hint: `main` already starts with one effect, `randomSeed`. How do you do two effects, the second after the first? Which function from Module 7 combines them?)
4. **State.** Where does the high score live during the game? Is it a field of `GameState`? What does `Restart` do with it?
5. **Saving.** When should it be saved — on every frame, or only when a game ends with a new record? Our stream turns *each state* into an `IO`; to notice "the game just ended", you need to know the previous state too. Which combinator from Module 8 could carry the previous state along? (Hint: `scan` can accumulate pairs.)
6. **The test.** Write down, in words, a test that checks "after a game over with score 7 and a previous best of 5, a `SaveHigh 7` op is performed". Which interpreter would that test use?
The fix is to *remember* the key that came too early and use it one step later. That needs one more piece of state, and `GameState` has carried it since Module 2: `queued`, none until now.
Notice what you did *not* have to do: touch the rules of the game, or let `update` call `localStorage`. The effect enters the program as one more `Op`; the decision of *when* to save is pure; and only the interpreter knows about the browser. That's the method of the whole course, applied to a feature you designed yourself.
**Where does the change go?** Not in `turn` or `advance`. They are the rules of one key press and one step, they are pinned down by the Module 3 fixtures, and they are still right. The queue is a policy about *how key presses are fed to those rules over time*, so it lives in `update`, next to the other "over time" decision of this module, the frame counter. Two places in `update` change:
- **`Turn(dir)`** now goes to a new function `steer(state, dir)` instead of straight to `turn`:
- not `Playing` → unchanged (as always);
- **no turn pending** (`nextDir == dir`, the snake will keep going the way it last moved) → `turn(state, dir)`, the Module 3 rule, unchanged;
- **a turn is pending** (`nextDir != dir`) → if the key is neither `nextDir` nor its opposite, the result is the state with `queued = dir`; a later key replaces an earlier queued one. Otherwise the state is unchanged.
- **After every game step** that `update` takes (the `Tick` case and the stepping branch of `Frame`): if the result is still `Playing` and something is queued, the result gets `nextDir = queued` and `queued = none`. A step that ends the game leaves `queued` alone (nothing will read it again), and `Restart` gives a fresh game with nothing queued.
Trace the motivating example from a fresh game (head at (10,10), heading `Right`):
```
action dir nextDir queued head why
─────────── ───── ─────── ────── ─────── ─────────────────────────────────────────────────────
(start) Right Right none (10,10)
Turn Up Right Up none (10,10) nothing pending: turn accepts Up
Turn Left Right Up Left (10,10) Up is pending; Left is neither Up nor Down: queued
Tick Up Left none (10,9) the step moves Up; then the queued Left becomes nextDir
Tick Left Left none (9,9) the step moves Left
```
Both keys are honoured, one step apart, and the snake never reverses: when Left becomes `nextDir`, the snake's last move was Up, so Left is a legal turn. That is not luck. A queued key is never `nextDir` and never its opposite, so after the step makes `dir` equal to the old `nextDir`, the queued key is exactly perpendicular to the new `dir`. Module 12 states this as an invariant (`queued-not-reversed`) and checks it on random games.
Why only one slot, and not a list of pending keys? Each queued key is played one step later than it was pressed. With a long queue a player who mashes keys would see the snake executing presses from half a second ago, which feels worse than losing one. One slot fixes the common case (two keys within one step) and keeps the snake responsive. Notice also what the rule *refuses*: with Up pending, a Down press is ignored (it would be a U-turn one step later), and pressing Up again is ignored (it is already going to happen). In Module 3, Down would have *replaced* the pending Up; `turn` still does that, but `steer` never asks it to when a turn is pending.
> **From everyday programming:** this is *input buffering*, and many games do it. Fighting games keep a short buffer so that a button pressed a few frames before a move becomes possible still counts; a text field that is busy keeps your keystrokes and applies them in order. The design question is always the same: how long may an input wait before it is more surprising to honour it than to drop it? Here the answer is "one step, one key".
The method is the one from the rest of this module: a new field that the old rules ignore, a new decision in `update`, fixtures that name each branch. The m05 fixtures still pass with the finished `update`, because none of them presses a second key while a turn is pending.
### 8. A high score that survives a reload
Players want to see their best score, and they want it still there after they reload the page. The browser offers `localStorage` for that ([MDN: `localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)): a small store of strings per web site that survives reloads. Reading and writing it are effects, and this time the effect is not drawing. Where do they go?
Before you read the design, work through these six questions on paper. They are the design questions of any new effect, and answering them yourself first is worth more than reading the answers.
1. **New operations.** Which `Op`s do you need, and what does each one answer? In Scala and F#, which new case does `OpResult` need?
2. **New interpreter cases.** What does `trace` answer to the new read: a fixed number passed in, like the seed? What does `runIO` do, and which new `Platform` methods does that need?
3. **Loading.** The best score must be known before the first frame is drawn. Where in `main` does it go? (`main` already starts with one effect, `randomSeed`. How do you do two effects, the second after the first? Which function from Module 7 combines them?)
4. **State.** Where does the best score live during the game? Is it a field of `GameState`? What does `Restart` do with it?
5. **Saving.** When should it be saved: on every frame, only when a game ends with a new record, or at some other moment? Our stream turns *each state* into an `IO`; what must that state know so that its `IO` can decide whether to save?
6. **The test.** Write down, in words, a test that checks "with a stored best of 5, the action that takes the score to 6 saves 6, and the next action saves nothing". Which interpreter does that test use?
Here is the design the course builds. Compare it with yours; where they differ, decide which you prefer and why.
**1. Two new operations.** `{tag:'LoadBest'}` answers a number, the stored best. `{tag:'SaveBest', score}` answers nothing, like a draw. In `io` they become `loadBest`, a value like `randomSeed`, and `saveBest(score)`, a function like `draw`. Scala and F# need one more answer, `OpResult.Best(value)`, because `Seed` carries a seed and the best score is a different kind of number.
**2. Interpreters.** `trace` answers `LoadBest` the way it answers `RandomSeed`: with a number the test passes in. It becomes `trace(io, seed, best = 0)`. The default keeps every existing call valid: no Module 7 test has to change, and the optional `safeio` of Module 7b, whose tests compare its traces with those of `trace` on programs that never load anything, is untouched. `runIO` gets two cases, `LoadBest` → `platform.loadBest()` and `SaveBest` → `platform.saveBest(score)`, so the `Platform` gets two methods. The browser platform keeps the number under the key `pure-snake-best`, treats anything it cannot read as 0 (no entry yet, a value that is not a non-negative whole number, storage blocked by privacy settings) and never throws: a record that cannot be stored is a small loss, a game that crashes on start is a large one.
**3. Loading.** One more effect after the seed, chained with `flatMap`:
```
main = flatMap(randomSeed, seed =>
map(loadBest, best =>
clampTime(map(sessions(seed, best, speedActions), renderSession), MAX_GAP_MS)))
```
`main` now performs two operations before its stream exists, `[RandomSeed, LoadBest]`, and that is the test that pins it.
**4. State: next to the game, not inside it.** The tempting answer is a `best` field in `GameState`. Look at what it would cost. `Restart` is `newGame(state.seed)` (section 2), so it would forget the best unless it learnt to copy it over; every game state in every fixture would grow a field; and `view` and the rules would carry a number that has nothing to do with one game of Snake. The best score belongs to the *player's session*, which spans many games. So it gets its own value, which wraps the game:
```
Session = { game: GameState, best: number, improved: boolean }
sessionUpdate(session, action):
game' = update(session.game, action)
best' = max(session.best, game'.score)
improved = best' > session.best
→ { game: game', best: best', improved }
sessions(seed, best, actions) = startWith(scan(actions, sessionUpdate, s0), s0)
where s0 = { game: newGame(seed), best, improved: false }
```
`GameState`, `update` and `view` do not change at all, and neither does a single fixture of Modules 2–6 or of this module's earlier exercises. `Restart` needs no special case: it replaces `game`, and `best` is outside it. `sessions` is `states` one level up: the same running fold, with `sessionUpdate` in place of `update`.
**5. Saving: on the action that raises the record.** Saving only at game over sounds economical, but a player who closes the tab during a record run loses it, and noticing "the game just ended" needs the previous state as well as the current one. The course saves at the moment the best goes up. That moment is a fact about one step, "the best before" against "the best after", and `sessionUpdate` is the one place that sees both. So it records the answer in the session, as `improved`, and the drawing of each session can decide on its own:
```
renderSession(session) = drawScene(sessionView(session)), followed by saveBest(session.best) when session.improved
```
How often is that? Once per point beyond the old record: a player whose record was 5 and who now scores 8 causes three saves, on the steps that reach 6, 7 and 8. Equalling the record is not an improvement (`best' > best`, strictly), and the action after an improvement has `improved` false again, because it compares the new best with itself.
**Output.** `sessionView(session)` is `view(session.game)` followed by one text, `Best: <best>`, at the top right of the board, level with the score at the top left: `{tag:'Text', x: width * CELL - 6, y: 18, text: 'Best: 3', color: '#ffffff', size: 16, align: 'right'}`. It comes *after* the game's view, so it is drawn on top, and the game's own shapes are exactly what they were.
**6. The tests.** All with the pure interpreter, none with a browser: a traced `renderSession` contains `SaveBest` exactly when `improved`; `main` traced performs `[RandomSeed, LoadBest]`; a scripted game played with `play` (Module 9, section 10) from a stored best saves once per improvement, with the scores in order; and the runtime with a fake platform asks for the best once, at start.
> **From everyday programming:** this is the split between a domain model and a storage adapter, as in the repository pattern. The game code never mentions `localStorage`; it asks for "the best score" through an operation, and one adapter (the browser platform) decides what that means. Swapping it for a server-side leaderboard, a file in a desktop version or an in-memory fake in a test changes the adapter, not the game. Wrapping `GameState` in a `Session` instead of growing it is the same move as keeping a user's preferences next to a document rather than inside it.
Notice what you did *not* have to do: touch the rules of the game, or let `update` call `localStorage`. The effect enters the program as two more `Op`s; the decision of *when* to save is pure and lives in `sessionUpdate`; and only the interpreter knows about the browser. That's the method of the whole course, applied to a feature you designed yourself.
### 9. A replay viewer: a second program over the same game
Module 5 made a claim: a whole game is its seed plus its list of actions, so a replay needs no video. Nothing in the course has shown one yet. Here it is.
A **recording** is a plain value:
```
Recording = { seed: number, actions: Action[] }
```
`fixtures/replay-sample.json` holds one: a game of 226 actions (only `Tick` and `Turn`) in which the snake eats 19 apples and then runs into itself. (It was played by a small program that heads for the apple and avoids dying on the very next step, which is why it eventually traps itself.)
Think first about how a replay viewer is usually written. The game runs forwards; to step *back*, the viewer must either undo each change (every rule needs an inverse, and "an apple was eaten" has none that is easy) or save a copy of the whole game every frame, by hand, before each change. In our design the second option is free and already written: `history(newGame(seed), actions)` from Module 5 *is* the list of every state of the game, and none of them can change afterwards. So:
```
frames(recording) = history(newGame(recording.seed), recording.actions)
```
The viewer never runs the game. It only decides **which frame to show**. Its whole state is three small values:
```
Viewer = { index, last, playing } index: the frame shown (0 = the new game, k = after k actions)
last: the index of the last frame (the number of actions)
playing: is autoplay on?
```
It is a second program over the same game: its own state, its own actions, its own `update`, `view` and `main`, built with exactly the pieces you built for Snake. The viewer's actions are:
| key | action | what `viewerUpdate` does |
|---|---|---|
| `ArrowRight`, `d`, `D` | `Next` | one frame forward, never past `last` |
| `ArrowLeft`, `a`, `A` | `Prev` | one frame back, never below 0 |
| `Home` | `First` | frame 0 |
| `End` | `Last` | frame `last` |
| Space | `TogglePlay` | stop autoplay; or start it (at the last frame: start again from frame 0) |
| (clock, every `REPLAY_MS = 100` ms) | `Beat` | while playing, one frame forward; otherwise nothing |
and one rule after every action: **a viewer at the last frame is not playing**. That single rule makes autoplay stop by itself at the end, and it keeps the state honest: there is no "playing, but nothing left to play" state to think about. `viewerUpdate` is total (every action on every viewer gives a sensible viewer) and clamps at both ends; the fixture `m10-viewer-update.json` names each case.
The picture is the game's own `view` of the current frame, with two texts on top: `step 7 / 226` at the top right and a line of key hints at the bottom.
```
viewerView(frames, viewer) = view(frames[viewer.index]) ++ [step text, hint text]
replayActions = merge(map(interval(REPLAY_MS), _ => Beat), filterMap(keyDowns, viewerKey))
viewerStates(last, actions) = startWith(scan(actions, viewerUpdate, viewerStart(last)), viewerStart(last))
replayMain(recording) = pure(clampTime(map(viewerStates(last, replayActions), v => drawScene(viewerView(fs, v))), MAX_GAP_MS))
where fs = frames(recording), last = fs.length - 1
```
Two things in `replayMain` deserve a look. It starts with `pure`, not with `randomSeed`: the viewer needs nothing from the world, because the recording carries its own seed. And it is a *function* of the recording: the page loads the JSON file and passes it in. That loading is the only new thing at the edge, and it lives in the given entry file `js/src/replay-main.js`, next to `main.js`.
> **From everyday programming:** this is the undo history of an editor, done the easy way. An editor that keeps immutable snapshots of the document can jump to any point in its history by index; one that mutates the document needs a reversible command for every edit. Version control works on the same idea: `git checkout` of an old commit does not "undo" the later commits, it shows a snapshot that never changed.
Notice what the viewer did *not* need: no change to `update`, `view`, the rules, the runtime or the browser glue. It reuses `history`, `view`, `drawScene`, the stream combinators and `clampTime` as they are. And stepping backwards shows **exactly** the earlier picture, pixel for pixel, because it *is* the earlier state (test: `stepping back shows exactly the earlier scene`).
## Exercises
@@ -185,9 +411,9 @@ def update(state: GameState, action: Action): GameState = ???
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05/m10"
```
**Proved by:** `fixtures/m10-update.json`, 22 cases: `toggle-pause-pauses-a-playing-game`, `toggle-pause-resumes-a-paused-game`, `toggle-pause-ignored-when-game-over`, `toggle-pause-ignored-when-won`, `turn-while-paused-is-ignored`, `tick-while-paused-is-ignored`, the four `restart-from-…` cases, the `frame-…` cases from section 3, `frame-at-top-speed-ticks-every-second-frame`, `frame-at-top-speed-odd-frame-does-not-tick`, `frame-tick-can-end-the-game`, and `frame-when-paused-is-unchanged` / `…-game-over-…` / `…-won-…`.
**Proved by:** the first 24 cases of `fixtures/m10-update.json` (the rest belong to Exercise 10.6): `toggle-pause-pauses-a-playing-game`, `toggle-pause-resumes-a-paused-game`, `toggle-pause-ignored-when-game-over`, `toggle-pause-ignored-when-won`, `turn-while-paused-is-ignored`, `tick-while-paused-is-ignored`, the four `restart-from-…` cases, the `frame-…` cases from section 3, `frame-after-a-move-counts-from-zero-again`, `frame-count-past-the-threshold-still-ticks`, `frame-tick-can-end-the-game`, and `frame-when-paused-is-unchanged` / `…-game-over-…` / `…-won-…`.
**Watch out for:** in `Frame`, the tick happens on the state **with the incremented frame**, so the result has the new `frame` *and* the moved snake. And `movesEvery` uses the score *before* the tick (a tick that eats an apple raises the score for the *next* frame, not this one).
**Watch out for:** in `Frame`, the tick happens on the state **with `frame` set back to 0**, so the result has `frame: 0` *and* the moved snake. `movesEvery` uses the score *before* the tick (a tick that eats an apple changes the interval of the *next* step, not of this one). And `frame` counts frames since the last move, not since the start of the game (Puzzle 3).
### Exercise 10.3 — two more keys
@@ -223,12 +449,250 @@ let speedActions : Stream<Action> = (* ... *)
**Proved by:** `the animation clock runs every 25 ms`, `speedActions turns the clock into Frame actions every 25 ms`, `speedActions still turns keys into actions, including pause and restart`, `main (seed 42) draws a moved snake after eight 25 ms frames`.
### Exercise 10.5 — `clampTime`: surviving a background tab
Implement `clampTime` as specified in section 5, in your stream file (it is one more combinator). Then add the constant `MAX_GAP_MS = 250` next to `FRAME_MS` and wrap the stream in `main`.
**JavaScript** (`js/src/stream.js`, then `js/src/app.js`)
```js
export const clampTime = (s, maxGap) => /* Stream<A> */
export const MAX_GAP_MS = 250
// and in main: clampTime(<the stream of drawings>, MAX_GAP_MS)
```
**Scala** (`scala/src/Stream.scala`, a method on `Stream[A]`; then `scala/src/App.scala`)
```scala
def clampTime(maxGap: Long): Stream[A] = ???
val MAX_GAP_MS: Long = 250
```
**F#** (`fsharp/src/Stream.fs`, data-last; then `fsharp/src/App.fs`)
```fsharp
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = failwith "TODO m10"
let MAX_GAP_MS = 250L
```
**Proved by:** `clampTime passes small steps of time through unchanged`, `clampTime lets a long gap count as maxGap, and carries on from there` (the trace in section 5), `clampTime clamps the very first step too (the clock starts at 0)`, `clampTime never moves the clock backwards`, `clampTime passes key inputs through, and they do not disturb the clock`, `running the same clamped stream twice gives the same outputs`, `the game accepts at most 250 ms of time at once`, `main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds`.
**Watch out for:** stepping the inner stream with the *real* time instead of the clamped clock (then nothing is clamped); storing the clamped clock as `last` (then the second event after a long gap sees a huge difference again; `last` is the real time, `clock` the told time, and you need both); and forgetting to wrap the next stream (then only the first event is clamped).
### Exercise 10.6 — a turn queue
Implement `steer` as specified in section 7, in your game file, and change `update`: `Turn` uses `steer`, and every step `update` takes (in `Tick` and in `Frame`) is followed by "take the queued turn". Do not change `turn` or `advance`.
**JavaScript** (`js/src/game.js`)
```js
export const steer = (state, dir) => /* GameState */
// and in update: Turn -> steer; after tick (in Tick and in Frame) take the queued turn
```
**Scala** (`scala/src/Game.scala`)
```scala
def steer(state: GameState, dir: Direction): GameState = ???
```
**F#** (`fsharp/src/Game.fs`)
```fsharp
let steer (state: GameState) (dir: Direction) : GameState = failwith "TODO m10"
```
**Proved by:** the turn-queue cases of `fixtures/m10-update.json`: `turn-with-no-turn-pending-sets-next-dir`, `turn-straight-back-with-no-turn-pending-is-ignored`, `perpendicular-key-while-a-turn-is-pending-is-queued`, `key-in-the-current-direction-while-a-turn-is-pending-is-queued`, `a-later-key-replaces-the-queued-one`, `repeating-the-pending-turn-changes-nothing`, `reversing-the-pending-turn-changes-nothing`, `turn-while-paused-with-a-turn-pending-is-not-queued`, `tick-moves-then-takes-the-queued-turn`, `tick-with-nothing-queued-leaves-next-dir`, `frame-step-takes-the-queued-turn`, `frame-without-a-step-keeps-the-queue`, `tick-that-ends-the-game-keeps-the-queue`, `tick-while-paused-keeps-the-queue`, `toggle-pause-keeps-the-queue`, `restart-clears-the-queue`; and the tests `steer is what update does with a Turn` and `Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after`. All of `m05-update.json`, `m05-replay.json` and `m05-history.json` must still pass.
**Watch out for:** "pending" compares `nextDir` with `dir`, but the queue check compares the key with `nextDir`, not with `dir` (with Up pending on a Right-moving snake, Left *is* allowed into the queue). Take the queued turn *after* the step, never before it (question 12 asks what goes wrong otherwise). Take it in both stepping places; the cleanest way is one helper `step(state)` that both `Tick` and `Frame` call. And a step that ends the game keeps `queued`, so the check is on the status of the *result*.
### Exercise 10.7 — a persisted high score
Build the design of section 8, in this order, running the tests after each step: the operations and `trace`; `runIO` and the browser platform; `sessionUpdate`, `sessions`, `sessionView`, `renderSession`; then `main` and the extra parameter of `play`.
**JavaScript**
```js
// js/src/io.js
export const loadBest = /* IO<number>: effect({tag:'LoadBest'}) */
export const saveBest = (score) => /* IO<undefined>: effect({tag:'SaveBest', score}) */
export const trace = (io, seed, best = 0) => /* {result, ops}; LoadBest is answered with best */
// js/src/runtime.js: two more cases in runIO, LoadBest -> platform.loadBest(), SaveBest -> platform.saveBest(score)
// js/src/browser.js: loadBest() and saveBest(score) on win.localStorage, key 'pure-snake-best'
// js/src/app.js
export const sessionUpdate = (session, action) => /* Session = {game, best, improved} */
export const sessions = (seed, best, actions) => /* Stream<Session> */
export const sessionView = (session) => /* Scene */
export const renderSession = (session) => /* IO<undefined> */
export const main = /* IO<Stream<IO<undefined>>>: seed, then best, then the sessions drawn with renderSession */
export const play = (main, seed, events, best = 0) => /* pass best to both traces */
```
**Scala**
```scala
// scala/src/IO.scala
enum Op:
case Draw(shape: Shape)
case RandomSeed
case LoadBest
case SaveBest(score: Int)
enum OpResult:
case Done
case Seed(value: Long)
case Best(value: Int)
lazy val loadBest: IO[Int] = ??? // in object IO, next to randomSeed
def saveBest(score: Int): IO[Unit] = ???
def trace[A](io: IO[A], seed: Long, best: Int = 0): Traced[A] = ???
// scala/src/Runtime.scala: trait Platform gains
// def loadBest(): Int
// def saveBest(score: Int): Unit
// and runIO performs the two new ops (the browser platform in scala/web/ is given)
// scala/src/App.scala
final case class Session(game: GameState, best: Int, improved: Boolean) // top level in package snake
def sessionUpdate(session: Session, action: Action): Session = ???
def sessions(seed: Long, best: Int, actions: Stream[Action]): Stream[Session] = ???
def sessionView(session: Session): Scene = ???
def renderSession(session: Session): IO[Unit] = ???
lazy val main: IO[Stream[IO[Unit]]] = ???
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event], best: Int = 0): List[List[Op]] = ???
```
**F#**
```fsharp
// fsharp/src/IO.fs
type Op =
| Draw of Shape
| RandomSeed
| LoadBest
| SaveBest of int
type OpResult =
| Done
| Seed of uint32
| Best of int
let loadBest () : IO<int> = failwith "TODO m10" // a function of unit, like randomSeed ()
let saveBest (score: int) : IO<unit> = failwith "TODO m10"
let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> = failwith "TODO m10"
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = traceWith seed 0 io
// fsharp/src/Runtime.fs: the Platform record gains
// LoadBest: unit -> int
// SaveBest: int -> unit
// and runIO performs the two new ops (the browser platform in fsharp/web/ is given)
// fsharp/src/App.fs
type Session = { Game: GameState; Best: int; Improved: bool }
let sessionUpdate (session: Session) (action: Action) : Session = failwith "TODO m10"
let sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session> = failwith "TODO m10"
let sessionView (session: Session) : Scene = failwith "TODO m10"
let renderSession (session: Session) : IO<unit> = failwith "TODO m10"
let main () : IO<Stream<IO<unit>>> = failwith "TODO m10"
let playWith (seed: uint32) (best: int) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m10"
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = playWith seed 0 events main
```
(In F#, `trace` and `play` keep their Module 7 and Module 9 shapes and become the "best is 0" case of `traceWith` and `playWith`, so no earlier call changes.)
**Proved by:** `fixtures/m10-session-update.json` (`a-step-without-eating-keeps-the-best`, `the-first-apple-with-no-record-is-an-improvement`, `eating-past-the-record-raises-the-best`, `eating-below-the-record-keeps-the-best`, `equalling-the-record-is-not-an-improvement`, `the-next-action-clears-improved`, `a-frame-that-steps-and-eats-raises-the-best`, `a-frame-that-only-counts-keeps-the-best-and-clears-improved`, `game-over-keeps-the-best`, `restart-keeps-the-best`, `pausing-keeps-the-best`); `fixtures/m10-session-view.json` (`playing-adds-the-best-at-the-top-right`, `a-best-of-zero-is-shown-too`, `game-over-shows-the-banner-and-then-the-best`); `fixtures/m10-golden.json` (`seed-42-eats-three-apples-with-no-stored-best`, `seed-7-beats-a-stored-best-of-2-and-hits-the-wall`, `seed-42-restart-after-game-over-keeps-the-best`), each checked twice, by `play(main): …` (the last scene) and `play(main) saves once per improvement: …`; and the tests `loadBest records a LoadBest op and is answered with the best given to trace`, `trace answers LoadBest with 0 when no best is given`, `saveBest records a SaveBest op with the score and produces undefined`, `a program can load the best, then save a higher one`, `sessions starts with the new game and the stored best, then yields one session per action`, `renderSession draws the session view and saves nothing when the best did not improve`, `renderSession draws the session view, then saves the best, when the best improved`, `main asks for the seed, then the stored best, and draws the first session`, `play passes the best to the program: the first scene shows it`, `runIO answers LoadBest with platform.loadBest() and continues with it`, `runIO maps SaveBest to platform.saveBest(score)`, `runApp asks for the seed and loads the best once, at start, and draws nothing before the first input`. JS only, for `browser.js`: `browserPlatform loads the best from localStorage key pure-snake-best`, `browserPlatform treats a missing or unreadable best as 0`, `browserPlatform saves the best under pure-snake-best`, `browserPlatform never throws when storage is blocked`. Everything of Modules 7 and 9 must still pass.
**Watch out for:** comparing with `>=` in `improved` (then equalling the record saves, and the fixture `equalling-the-record-is-not-an-improvement` fails); computing `best'` from the score *before* `update` (then the save comes one action late); putting the save before the drawing (the tests expect the scene first, then `SaveBest`); and, in the browser, `Number(null)` and `Number('')` are 0 but `Number('lots')` is `NaN`, so check the parsed number, and wrap *both* storage calls in `try`, because some browsers throw as soon as `localStorage` is touched when storage is blocked.
**Run:**
- JS: `cd js && node --test "test/m10-*.test.js"`, then `./test.sh` for everything
- Scala: `cd scala && ./test.sh M10`, then `./test.sh`
- F#: `cd fsharp && ./test.sh M10`, then `./test.sh`
Then play: JS — reload <http://localhost:8000/js/>; Scala — `./build-web.sh` again and reload <http://localhost:8000/scala/>; F# — `./build-web.sh` again and reload <http://localhost:8000/fsharp/>. Press Space, press R, and eat apples until it gets hard.
Then play: JS — reload <http://localhost:8000/js/>; Scala — `./build-web.sh` again and reload <http://localhost:8000/scala/>; F# — `./build-web.sh` again and reload <http://localhost:8000/fsharp/>. Press Space, press R, and eat apples until it gets hard. Then switch to another tab for ten seconds and come back: the snake should be about where you left it. Finally, beat your best score, reload the page, and check that `Best:` at the top right still shows it.
### Exercise 10.8 — a replay viewer
Build the viewer of section 9 in the new file, in this order, running the tests after each step: `frames` and `viewerStart`; `ViewerAction` and `viewerUpdate` (the fixture first); `viewerKey`; `viewerView`; then `replayActions`, `viewerStates` and `replayMain`. The viewer has no high score: it never loads or saves anything.
**JavaScript** (`js/src/replay.js`; the page is `js/replay.html`, its entry file `js/src/replay-main.js` is given)
```js
export const REPLAY_MS = 100
export const frames = (recording) => /* GameState[]: history(newGame(seed), actions) */
export const viewerStart = (last) => /* {index: 0, last, playing: false} */
export const ViewerAction = { Next: ..., Prev: ..., First: ..., Last: ..., TogglePlay: ..., Beat: ... } // {tag} values
export const viewerUpdate = (viewer, action) => /* Viewer */
export const viewerKey = (key) => /* ViewerAction or null */
export const viewerView = (frames, viewer) => /* Scene */
export const replayActions = /* Stream<ViewerAction> */
export const viewerStates = (last, actions) => /* Stream<Viewer> */
export const replayMain = (recording) => /* IO<Stream<IO<undefined>>> */
```
**Scala** (`scala/src/Replay.scala`, package `snake`)
```scala
final case class Recording(seed: Long, actions: List[Action])
final case class Viewer(index: Int, last: Int, playing: Boolean)
enum ViewerAction:
case Next, Prev, First, Last, TogglePlay, Beat
object Replay:
val REPLAY_MS: Long = 100
def frames(recording: Recording): List[GameState] = ???
def viewerStart(last: Int): Viewer = ???
def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer = ???
def viewerKey(key: String): Option[ViewerAction] = ???
def viewerView(frames: List[GameState], viewer: Viewer): Scene = ???
lazy val replayActions: Stream[ViewerAction] = ???
def viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer] = ???
def replayMain(recording: Recording): IO[Stream[IO[Unit]]] = ???
```
**F#** (`fsharp/src/Replay.fs`, module `Snake.Replay`)
```fsharp
type Recording = { Seed: uint32; Actions: Action list }
type Viewer = { Index: int; Last: int; Playing: bool }
[<RequireQualifiedAccess>]
type ViewerAction =
| Next
| Prev
| First
| Last
| TogglePlay
| Beat
let REPLAY_MS = 100L
let frames (recording: Recording) : GameState list = failwith "TODO m10"
let viewerStart (last: int) : Viewer = failwith "TODO m10"
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer = failwith "TODO m10"
let viewerKey (key: string) : ViewerAction option = failwith "TODO m10"
let viewerView (frames: GameState list) (viewer: Viewer) : Scene = failwith "TODO m10"
let replayActions : Stream<ViewerAction> = (* ... *)
let viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer> = failwith "TODO m10"
let replayMain (recording: Recording) : IO<Stream<IO<unit>>> = failwith "TODO m10"
```
(In F#, `ViewerAction` is `[<RequireQualifiedAccess>]` for the reason Module 11 gives for Pong's unions: `ViewerAction.Last` must never be confused with anything else in scope.)
The picture, exactly (it is fixed by `fixtures/m10-viewer-view.json`): `view(frame)`, then, with `w = width * CELL` of that frame, `{tag:'Text', x: w - 6, y: 18, text: 'step <index> / <last>', color: '#ffffff', size: 16, align: 'right'}` and `{tag:'Text', x: w / 2, y: height * CELL - 8, text: <hint>, color: '#aaaaaa', size: 12, align: 'center'}`, where the hint is `'Space: pause'` while playing and `'Left/Right: step Home/End: jump Space: play'` (three spaces between the parts) otherwise.
**Proved by:** `fixtures/m10-viewer-update.json` (18 cases: `next-moves-one-frame-forward`, `next-at-the-last-frame-stays`, `prev-moves-one-frame-back`, `prev-at-the-first-frame-stays`, `first-goes-to-frame-zero`, `last-goes-to-the-last-frame`, `toggle-play-starts-autoplay`, `toggle-play-stops-autoplay`, `toggle-play-at-the-end-starts-again-from-the-first-frame`, `beat-while-playing-moves-one-frame-forward`, `beat-while-not-playing-is-ignored`, `beat-that-reaches-the-last-frame-stops-autoplay`, `next-while-playing-keeps-playing`, `prev-while-playing-keeps-playing`, `last-while-playing-stops-autoplay`, `first-while-playing-keeps-playing`, `a-recording-with-no-actions-cannot-play`, `a-recording-with-no-actions-stays-at-frame-zero`); `fixtures/m10-viewer-key.json` (14 cases, with the `constructor`/`toString` traps); `fixtures/m10-viewer-view.json` (`first-frame-not-playing`, `middle-frame-while-playing`, `last-frame`); and the tests `the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over`, `frames replays the recording with history, from the new game of its seed`, `viewerStart is the first frame, not playing`, `ViewerAction has the six actions as plain values` (JS only), `viewerUpdate keeps the index in [0, last] whatever the actions`, `the autoplay clock beats every 100 ms`, `replayActions turns the clock into Beats and keys into viewer actions`, `autoplay: Space, then the clock steps one frame per 100 ms and stops at the end`, `without Space nothing plays: the clock alone never moves the viewer`, `replayMain asks for nothing and draws the first frame of the recording first`, `replayMain: End shows the last frame of the game, the game over`, `stepping back shows exactly the earlier scene`, `replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100`.
**Watch out for:** clamping *before* moving (clamp the new index, not the old one); forgetting the end-of-recording rule after `Beat` (then autoplay "plays" at the last frame forever, and `beat-that-reaches-the-last-frame-stops-autoplay` fails); computing `frames` inside the stream for every drawing instead of once in `replayMain` (correct, but it replays the whole game on every key press); and indexing frames from 1 (frame 0 is the new game, before any action).
**Run and watch:** JS `cd js && node --test "test/m10-*.test.js"`, then (from the repository root) `node scripts/serve.mjs` and open <http://localhost:8000/js/replay.html>. Scala: `./test.sh M10`, `./build-web.sh`, then <http://localhost:8000/scala/replay.html>. F#: `./test.sh M10`, `./build-web.sh`, then <http://localhost:8000/fsharp/replay.html>. Press Space and watch the snake eat its nineteen apples; press `End`, then `Left` a few times, and watch it back out of the crash.
## Hints
@@ -240,7 +704,7 @@ Write each new case as its own small function (`togglePause(state)`, `frame(stat
<details><summary>Exercise 10.2 — stronger hint</summary>
For `TogglePause`, match on the *status*: two statuses change, the other two don't. For `Frame`, the guard "not Playing → unchanged" comes first. Then: `counted = { ...state, frame: state.frame + 1 }`, and the result is `counted.frame % movesEvery(state.score) === 0 ? tick(counted) : counted`.
For `TogglePause`, match on the *status*: two statuses change, the other two don't. For `Frame`, the guard "not Playing → unchanged" comes first. Then compute `n = state.frame + 1` and compare it with `movesEvery(state.score)`: below it, the result is the state with `frame: n`; otherwise it is `tick` applied to the state with `frame: 0`.
</details>
@@ -256,8 +720,8 @@ const togglePause = (state) =>
const frame = (state) => {
if (state.status !== 'Playing') return state
const counted = /* ? */
return /* tick(counted) or counted? */
const n = /* ? */
return n >= /* ? */ ? tick({ ...state, frame: /* ? */ }) : { ...state, frame: /* ? */ }
}
export const update = (state, action) => {
@@ -302,6 +766,293 @@ let update (state: GameState) (action: Action) : GameState =
</details>
<details><summary>Exercise 10.5 — nudge</summary>
Look at your `interval`: a private helper that takes the thing to remember as a parameter, and whose `step` returns the helper again with the new value. `clampTime` is built the same way, with two numbers to remember (`last` and `clock`) and an inner stream to step.
</details>
<details><summary>Exercise 10.5 — stronger hint</summary>
Two cases in `step`. A key event: step the inner stream with the same event; return its outputs and the helper around its next stream, with `last` and `clock` unchanged. A `Time t` event: compute `now = clock + min(max(t - last, 0), maxGap)`; step the inner stream with a *new* `Time now` event; return its outputs and the helper around its next stream, with `last = t` and `clock = now`. `clampTime(s, maxGap)` is the helper started with `last = 0` and `clock = 0`.
</details>
<details><summary>Exercise 10.5 — near-solution (try the others first)</summary>
**JavaScript**
```js
const clampFrom = (s, maxGap, last, clock) => ({
step: (i) => {
if (i.tag !== 'Time') {
const [out, next] = s.step(i)
return [out, clampFrom(next, maxGap, /* ? */, /* ? */)]
}
const now = clock + Math.min(Math.max(/* ? */, 0), maxGap)
const [out, next] = s.step({ tag: 'Time', t: /* ? */ })
return [out, clampFrom(next, maxGap, /* ? */, /* ? */)]
},
})
export const clampTime = (s, maxGap) => clampFrom(s, maxGap, 0, 0)
```
**Scala**
```scala
def clampTime(maxGap: Long): Stream[A] =
def from(s: Stream[A], last: Long, clock: Long): Stream[A] = Stream {
case Event.Time(t) =>
val now = clock + math.min(math.max(/* ? */, 0L), maxGap)
val (out, next) = s.step(Event.Time(/* ? */))
(out, from(next, /* ? */, /* ? */))
case key =>
val (out, next) = s.step(key)
(out, from(next, last, clock))
}
from(this, 0L, 0L)
```
**F#**
```fsharp
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> =
let rec from (Stream step) (last: int64) (clock: int64) =
Stream(fun ev ->
match ev with
| Time t ->
let now = clock + min (max ((* ? *)) 0L) maxGap
let (out, next) = step (Time ((* ? *)))
(out, from next ((* ? *)) ((* ? *)))
| key ->
let (out, next) = step key
(out, from next last clock))
from s 0L 0L
```
</details>
<details><summary>Exercise 10.6 — nudge</summary>
Two small pieces. `steer` is three cases, checked in the order of section 7 (status, then "is a turn pending?", then "may this key wait in the queue?"). "Take the queued turn" is a function from state to state that you apply to the result of `tick`. Give "tick, then take the queued turn" a name, and use it in both places that step the game.
</details>
<details><summary>Exercise 10.6 — stronger hint</summary>
`steer`: if not `Playing`, return the state. If `state.nextDir` equals `state.dir`, return `turn(state, dir)`. Otherwise, if `dir` differs from `state.nextDir` and from `opposite(state.nextDir)`, return the state with `queued` set to `dir`; else return the state. `takeQueued(state)`: if the status is `Playing` and `queued` is not none, return the state with `nextDir` set to the queued direction and `queued` set to none; otherwise the state. `step(state) = takeQueued(tick(state))`. In `update`, `Tick` becomes `step(state)`, `Turn` becomes `steer(state, dir)`, and inside `frame` the `tick({ ...state, frame: 0 })` becomes `step(...)` of the same state.
</details>
<details><summary>Exercise 10.6 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const steer = (state, dir) => {
if (state.status !== 'Playing') return state
if (state.nextDir === state.dir) return ___
return dir !== state.nextDir && dir !== ___ ? { ...state, queued: ___ } : state
}
const takeQueued = (state) =>
state.status === 'Playing' && state.queued !== null ? { ...state, nextDir: ___, queued: ___ } : state
const step = (state) => takeQueued(___)
```
**Scala**
```scala
def steer(state: GameState, dir: Direction): GameState =
if state.status != Status.Playing then state
else if state.nextDir == state.dir then ___
else if dir != state.nextDir && dir != ___ then state.copy(queued = ___)
else state
private def takeQueued(state: GameState): GameState = state.queued match
case Some(d) if state.status == Status.Playing => state.copy(nextDir = ___, queued = ___)
case _ => state
private def step(state: GameState): GameState = takeQueued(___)
```
**F#**
```fsharp
let steer (state: GameState) (dir: Direction) : GameState =
if state.Status <> Playing then state
elif state.NextDir = state.Dir then ___
elif dir <> state.NextDir && dir <> ___ then { state with Queued = ___ }
else state
let private takeQueued (state: GameState) : GameState =
match state.Queued with
| Some d when state.Status = Playing -> { state with NextDir = ___; Queued = ___ }
| _ -> state
let private step (state: GameState) : GameState = takeQueued (___)
```
</details>
<details><summary>Exercise 10.7 — nudge</summary>
Most of it is a copy of something you already have. `loadBest` is built like `randomSeed`, `saveBest` like `draw`. The new answer in `trace` sits next to the seed's. The two runtime cases sit next to `RandomSeed`'s. `sessions` is `states` with `sessionUpdate` and a session as the start value. And `main` has two effects in a row before the stream: the first one's result is needed to build the rest, so it is chained with `flatMap`; the second one only needs `map`.
</details>
<details><summary>Exercise 10.7 — stronger hint</summary>
`sessionUpdate`: call `update` on the session's game first; then the new best is the larger of the old best and the *new* game's score; `improved` is "new best greater than old best". `sessionView`: spread the game's view into a new list and add one `Text` at the end, with `x = game.width * CELL - 6`. `renderSession`: two cases on `improved`; the improved one is `sequence` of the scene's drawing and `saveBest(best)`. `play`: the only change is passing `best` as the third argument of both `trace` calls. In the browser: read the entry, convert it with `Number`, and return it only if it is a non-negative whole number (`Number.isSafeInteger(n) && n >= 0`); `catch` returns 0.
</details>
<details><summary>Exercise 10.7 — near-solution (try the others first)</summary>
**JavaScript**
```js
// io.js
export const loadBest = effect({ tag: ___ })
export const saveBest = (score) => effect({ tag: 'SaveBest', ___ })
const answer = (op, seed, best) => op.tag === 'RandomSeed' ? seed : op.tag === 'LoadBest' ? ___ : undefined
// app.js
export const sessionUpdate = (session, action) => {
const game = update(___, action)
const best = Math.max(session.best, ___)
return { game, best, improved: ___ }
}
export const renderSession = (session) =>
session.improved ? sequence([drawScene(sessionView(session)), ___]) : drawScene(sessionView(session))
export const main = flatMapIO(randomSeed, (seed) =>
mapIO(___, (best) => clampTime(map(sessions(seed, best, speedActions), ___), MAX_GAP_MS)))
```
**Scala**
```scala
// IO.scala, in trace's answer
case Op.LoadBest => OpResult.Best(___)
case Op.SaveBest(_) => ___
// App.scala
def sessionUpdate(session: Session, action: Action): Session =
val game = Game.update(___, action)
val best = math.max(session.best, ___)
Session(game, best, improved = ___)
lazy val main: IO[Stream[IO[Unit]]] =
IO.randomSeed.flatMap(seed => IO.loadBest.map(best => sessions(seed, best, speedActions).map(___).clampTime(MAX_GAP_MS)))
```
**F#**
```fsharp
// IO.fs, in traceWith's answer
| LoadBest -> Best ___
| SaveBest _ -> ___
// App.fs
let sessionUpdate (session: Session) (action: Action) : Session =
let game = Game.update ___ action
let best = max session.Best ___
{ Game = game; Best = best; Improved = ___ }
let main () =
IO.randomSeed ()
|> IO.bind (fun seed ->
IO.loadBest () |> IO.map (fun best -> sessions seed best speedActions |> Stream.map ___ |> Stream.clampTime MAX_GAP_MS))
```
</details>
<details><summary>Exercise 10.8 — nudge</summary>
Everything here is a copy of something you have. `frames` is one call. `viewerUpdate` is `update` for a much smaller state: one small case per action, then one rule applied to every result. `viewerKey` is `keyToAction` with other keys. `replayActions`, `viewerStates` and `replayMain` are `speedActions`, `states` and `main` with other names, except that `replayMain` starts with `pure`.
</details>
<details><summary>Exercise 10.8 — stronger hint</summary>
Write `viewerUpdate` in two steps. First a helper `move(viewer, action)` that handles each action on its own, using `clamp(i) = min(last, max(0, i))` for `Next`, `Prev` and `Beat` (`Beat` only when playing), and for `TogglePlay`: playing → not playing; not playing → playing, with `index` set to 0 if it was `last`. Then `viewerUpdate` returns `move(...)` with `playing` replaced by `playing && index < last`. In `replayMain`, compute `fs = frames(recording)` once, before the stream, and use `fs.length - 1` as `last`.
</details>
<details><summary>Exercise 10.8 — near-solution (try the others first)</summary>
**JavaScript**
```js
const move = (viewer, action) => {
const clamp = (i) => Math.min(viewer.last, Math.max(0, i))
switch (action.tag) {
case 'Next': return { ...viewer, index: clamp(___) }
case 'Prev': return { ...viewer, index: clamp(___) }
case 'First': return { ...viewer, index: ___ }
case 'Last': return { ...viewer, index: ___ }
case 'TogglePlay': return viewer.playing ? { ...viewer, playing: false }
: { ...viewer, index: viewer.index === viewer.last ? ___ : viewer.index, playing: true }
case 'Beat': return viewer.playing ? ___ : viewer
default: return viewer
}
}
export const viewerUpdate = (viewer, action) => {
const next = move(viewer, action)
return { ...next, playing: next.playing && ___ }
}
export const replayMain = (recording) => {
const fs = frames(recording)
return pure(clampTime(map(viewerStates(___, replayActions), (v) => drawScene(viewerView(___, v))), MAX_GAP_MS))
}
```
**Scala**
```scala
private def move(v: Viewer, action: ViewerAction): Viewer =
def clamp(i: Int) = math.min(v.last, math.max(0, i))
action match
case ViewerAction.Next => v.copy(index = clamp(___))
case ViewerAction.Prev => v.copy(index = clamp(___))
case ViewerAction.First => v.copy(index = ___)
case ViewerAction.Last => v.copy(index = ___)
case ViewerAction.TogglePlay =>
if v.playing then v.copy(playing = false)
else v.copy(index = if v.index == v.last then ___ else v.index, playing = true)
case ViewerAction.Beat => if v.playing then ___ else v
def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer =
val next = move(viewer, action)
next.copy(playing = next.playing && ___)
```
**F#**
```fsharp
let private move (v: Viewer) (action: ViewerAction) : Viewer =
let clamp i = min v.Last (max 0 i)
match action with
| ViewerAction.Next -> { v with Index = clamp (___) }
| ViewerAction.Prev -> { v with Index = clamp (___) }
| ViewerAction.First -> { v with Index = ___ }
| ViewerAction.Last -> { v with Index = ___ }
| ViewerAction.TogglePlay ->
if v.Playing then { v with Playing = false }
else { v with Index = (if v.Index = v.Last then ___ else v.Index); Playing = true }
| ViewerAction.Beat -> if v.Playing then ___ else v
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer =
let next = move viewer action
{ next with Playing = next.Playing && ___ }
```
</details>
## Check your understanding
1. List every file you changed in this module. Which important file did you *not* change, and why didn't you need to?
@@ -310,18 +1061,37 @@ let update (state: GameState) (action: Action) : GameState =
4. A player presses Space, then ArrowUp, then Space again, all within one move interval. Which way is the snake heading afterwards? Use the rules from Modules 3 and 10 to justify your answer.
5. `Restart` uses `newGame(state.seed)`. What would change if it used the seed of the very first game instead? Which would you prefer for a speedrun leaderboard, and which for casual play?
6. The m09 test "main draws again once time has passed" passes both before and after this module, but "the program built from classicActions draws the new game first, then the ticked game after 150 ms" uses a copy of `main`. Why was the test suite designed this way?
7. For the high-score thought exercise: which parts of your design are pure, which are interpreter code, and how would you test that a new record gets saved without a browser?
7. In the high score of section 8, which parts are pure, which are interpreter code, and which test would fail if `renderSession` saved on *every* frame instead of only when `improved`?
8. With the tempting rule of Puzzle 3, the game is at frame 62 with score 5 and the apple is right in front of the head. On which frames does the snake make its next two moves? What is the shortest gap the tempting rule can produce at the change from interval 7 to interval 6, and at which frame numbers does it happen?
9. `clampTime` drops the time beyond `maxGap`. Design the other policy on paper: when a `Time` event arrives more than 250 ms after the previous one, the game should **pause itself**. Which stream would you write (what does it emit, and what does it remember)? Where does it enter `speedActions`? Why is a plain `TogglePause` action not quite right for it, and which new `Action` would be? Which test would prove it?
10. `clampTime(map(s, f), n)` and `map(clampTime(s, n), f)`: do they behave the same? What about `clampTime(merge(a, b), n)` and `merge(clampTime(a, n), clampTime(b, n))`? Argue from the definitions. Then explain why `clampTime(clampTime(s, 100), 50)` behaves like `clampTime(s, 50)`.
11. With the turn queue, a snake heading `Right` gets Up, Left, Down within one step. Trace `dir`, `nextDir` and `queued` through those three keys and the next two steps. Which keys were honoured, and why is the result still safe?
12. The queue is taken *after* the step. Write down a key sequence for which taking it *before* the step (at the start of `Tick`) would behave differently, and say which of the two the player would prefer.
13. Why does a step that ends the game leave `queued` as it is instead of clearing it? Could any later action observe the difference? (Look at what `steer`, `TogglePause` and `Restart` do in `GameOver`.)
14. Section 7 put the queue in `update` and left `turn` and `advance` alone. Name one benefit for the tests and one for a reader of the code. What would you have had to change in Module 3's fixtures if the queue had gone into `turn`?
15. The best score lives in a `Session` next to the game, not in `GameState`. List everything you would have had to change (functions, fixtures, `Restart`) if it had been a field of `GameState`.
16. With a stored best of 5, a player scores 8, restarts, and scores 3. Which `SaveBest` ops are performed, in which order, and on which actions? Why does the restart itself save nothing?
17. `renderSession` saves on the action that raises the record. Design the other policy, "save once, when a game ends with a new record": what must the session remember for that, and which test of Exercise 10.7 would change?
18. The browser platform turns every failure of `localStorage` into 0 or into nothing. Which failure would a player notice, and why is that still better than letting the exception reach `runIO`?
19. The viewer keeps every frame of the game in memory (`frames` is a list of 227 states). Estimate the memory for a ten-minute game at top speed. Then design the cheaper alternative: keep only every hundredth state and replay from the nearest one when the viewer jumps. Which law of Module 5 makes that correct?
20. Stepping backwards shows exactly the earlier picture. Explain why that is *guaranteed* here, and name two things a stateful game would have to get right to promise the same (think of the apple's position and the random seed).
21. The last frame of the sample shows the game's "Game Over — Press R to restart" banner, but R does nothing in the viewer. Where would you change that: in `view`, in `viewerView`, or somewhere else? What would your choice cost the Snake fixtures?
22. Turn the viewer into a recorder: while you play Snake, the program should keep the recording so that it can be saved at game over. Which value already holds everything you need, and which new `Op` (if any) would saving it need?
## In production
- **Elm** programs add features exactly like this: a new `Msg` variant, a new branch in `update`; the compiler's exhaustiveness check lists every place that must handle it ([The Elm Architecture](https://guide.elm-lang.org/architecture/)).
- **Redux** reducers grow by new action types; the Redux docs recommend keeping reducers pure and putting effects in middleware, the same split as our `update` vs. interpreter ([Redux fundamentals: state, actions and reducers](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers)).
- **Fixed-timestep game loops** separate the rate at which the platform calls you from the rate at which the simulation advances; our `Frame` + `movesEvery` is a minimal version ([Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)).
- **Fixed-timestep game loops** separate the rate at which the platform calls you from the rate at which the simulation advances; our `Frame` + `movesEvery` is a minimal version, and the article's `if (frameTime > 0.25) frameTime = 0.25` is our `clampTime` ([Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)). Nystrom's chapter calls the failure a game that can never catch up (the "spiral of death") and gives the same remedy: bail out after a maximum number of updates.
- **Page visibility.** Browsers throttle or stop timers and animation callbacks in hidden tabs to save battery; games usually listen for `visibilitychange` and pause ([MDN: Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)).
- **Event sourcing** stores the list of events and derives state by folding them — the same "state = fold(update, events)" idea, used for databases and audit logs; it is also why pause/restart cost nothing extra to replay ([Martin Fowler, "Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html)).
## Further reading
- [Robert Nystrom, Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html) — fixed and variable time steps, and why games separate them.
- [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/) — the classic article on decoupling simulation from the clock.
- [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/) — the classic article on decoupling simulation from the clock, including the clamp on long frames.
- [MDN: Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) — what browsers do to hidden tabs, and how a page can find out.
- [Martin Fowler, "Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html) — what happens when you take "a program is a fold over events" seriously in business software.
- [MDN: `Window.localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) — the API you would wrap in a new `Op` for the high-score exercise.
- [MDN: `Window.localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) — the API that the browser platform wraps behind `LoadBest` and `SaveBest`.
- [Evan Czaplicki, "The Perfect Bug Report"](https://elm-lang.org/news/the-perfect-bug-report) — Elm's debugger records a session as messages and replays it on another machine: the replay viewer of section 9, built into a language's tooling.
- Next: [Module 11](11-capstone-pong.md) converts a second game on your own; [Module 13](13-capstone-life.md), the second capstone, adds a new kind of input (the mouse) to the edge.
+8 -6
View File
@@ -79,7 +79,7 @@ The tests are shared fixtures, so the rules must be exact. Here they are in pros
6. **Scoring:** if `nx < 0`, the computer scores; if `nx >= W`, the player scores. A point **serves** a new ball: position `(40, 25)`, `dx` toward the side that conceded (computer scored → `-1`, player scored → `+1`), and `dy = 1` if `nextUint32(seed)` is even, `-1` otherwise — and the seed advances to the one `nextUint32` returned.
7. **End:** when a score reaches `WIN`, the status becomes `Over`.
When the match is `Over`, frames change nothing. Key presses and releases are still recorded (so that a key held across a restart behaves sensibly). **Restart** gives `initialPong(state.seed)` at any time.
When the match is `Over`, frames change nothing. Key presses and releases are still recorded. **Restart** gives `initialPong(state.seed)` at any time, **except that `held` is kept**: the keys are a fact about the player's fingers, not about the match, and a player who restarts while holding a key is still holding it. (A restart that cleared `held` would leave the paddle standing still until the key is released and pressed again; the fixture `restart-keeps-the-held-keys` checks this.)
**The picture** (`pongView`), drawn in order, all text `'#ffffff'` and `'center'`-aligned:
@@ -217,7 +217,7 @@ Question: why are `Press` and `Release` separate actions rather than one `SetHel
### Step 3 — Write `update` as a fold
`pongUpdate(state, action)` is your Pong `update`. `Press`/`Release` set a field of `held`; `Restart` is one call; `Frame` is the seven-step pipeline from [the rules](#the-rules-precisely).
`pongUpdate(state, action)` is your Pong `update`. `Press`/`Release` set a field of `held`; `Restart` is one call plus the kept `held`; `Frame` is the seven-step pipeline from [the rules](#the-rules-precisely).
The `Frame` case is the biggest function you'll write in the course. The way to keep it readable is the way you kept Snake readable: **split it into small, pure, separately-understandable steps**, each taking a state and returning a state, applied in the order of the rules. You'll want a `clamp(lo, hi, x)` helper (it's tested on its own).
@@ -241,11 +241,11 @@ Like Snake's `app`:
- `pongActions`: `Frame` every `FRAME_MS_PONG = 30` ms (from `interval`), merged with `pongKey(true, key)` of every key press (`keyDowns`) and `pongKey(false, key)` of every key release (`keyUps`). That's three streams merged — in which order? (The test `pongActions turns the clock into Frames and keys into Press, Release and Restart` tells you what it expects; read it.)
- `pongStates(seed, actions)`: start from `initialPong(seed)`, fold with `pongUpdate`, emit the initial state first.
- `pongMain`: ask for a seed; turn the stream of states into a stream of drawings of `pongView`.
- `pongMain`: ask for a seed; turn the stream of states into a stream of drawings of `pongView`; and wrap that stream in `clampTime(…, MAX_GAP_MS)` as you did for Snake in Module 10 (a ball that has played on alone for a minute while the tab was hidden is no more fun than a dead snake). `MAX_GAP_MS` is the constant from the Snake app module.
### Step 7 — One runtime at the edge
Nothing to write. `js/src/pong/main.js` is one line: `runApp(pongMain, browserPlatform(document.getElementById('game'), window))`. In Scala, `scala/web/Main.scala` has a `PongMain` object; `build-web.sh` already compiles it to `web/dist/pong.js`. F# has the equivalent in `fsharp/web/PongMain.fs`: `Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ())`. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant.
Nothing to write. `js/src/pong/main.js` is one line: `runApp(pongMain, browserPlatform(document.getElementById('game'), window))`. In Scala, `scala/web/Main.scala` has a `PongMain` object; `build-web.sh` already compiles it to `web/dist/pong.js`. F# has the equivalent in `fsharp/web/PongMain.fs`: `Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ())`. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant. (It will not stay untouched for ever: Module 13 needs a fourth kind of input, the mouse, and adds one registration to it.)
## Exercises
@@ -299,7 +299,7 @@ let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
let pongUpdate (state: PongState) (action: PongAction) : PongState = failwith "TODO m11"
```
**Proved by:** `clamp keeps a number inside [lo, hi]`, `pongUpdate does not modify the state it is given`, `an idle match stays on the court and ends once someone reaches five`, and `fixtures/m11-pong-update.json` — 43 named cases. Read the case names: they're the specification broken into single facts (`player-stops-at-the-top-edge`, `cpu-does-not-move-on-an-odd-frame`, `paddle-bounce-uses-the-player-position-after-it-moved`, `serve-direction-comes-from-the-seed-odd-value-serves-upward`, `cpu-reaching-five-ends-the-match`, …). When one fails, open the fixture, find the case, and work it out by hand.
**Proved by:** `clamp keeps a number inside [lo, hi]`, `pongUpdate does not modify the state it is given`, `an idle match stays on the court and ends once someone reaches five`, and `fixtures/m11-pong-update.json` — 49 named cases. Read the case names: they're the specification broken into single facts (`player-stops-at-the-top-edge`, `cpu-does-not-move-on-an-odd-frame`, `paddle-bounce-uses-the-player-position-after-it-moved`, `serve-direction-comes-from-the-seed-odd-value-serves-upward`, `cpu-reaching-five-ends-the-match`, …). When one fails, open the fixture, find the case, and work it out by hand.
**Strategy:** don't write all seven steps and then run the tests. Write `Press`/`Release`/`Restart` and run; then step 1 and 2 of `Frame` and run (watch the paddle cases go green); then the cpu; then the ball. Let the case names guide you.
@@ -361,7 +361,7 @@ let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
In F#, `main` and `pongMain` are **functions of unit** (`pongMain ()`), not values. F# evaluates a module's top-level values when the module is first used, so a value whose definition fails (like a stub) would break every test that touches the module; a function of unit is only evaluated when called. The value it returns is still the same pure description.
**Proved by:** `fixtures/m11-pong-key.json` (21 cases, including `r-up-does-nothing` and the `constructor`/`toString` traps from Module 9), `the clock runs every 30 ms`, `pongActions turns the clock into Frames and keys into Press, Release and Restart`, `pongStates starts with the new match, then yields one state per action`, `pongMain asks for a seed, draws the new match first, and then the updated one`.
**Proved by:** `fixtures/m11-pong-key.json` (21 cases, including `r-up-does-nothing` and the `constructor`/`toString` traps from Module 9), `the clock runs every 30 ms`, `pongActions turns the clock into Frames and keys into Press, Release and Restart`, `pongStates starts with the new match, then yields one state per action`, `pongMain asks for a seed, draws the new match first, and then the updated one`, `pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds`.
**Run and play:**
- JS: `cd js && node --test "test/m11-*.test.js"`; then, **from the repository root**, `node scripts/serve.mjs` and open <http://localhost:8000/js/pong.html>
@@ -419,3 +419,5 @@ Three sources, one `merge` chain: `merge(merge(frames, presses), releases)`. `pr
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — read it again now; every part should look familiar.
- [Glenn Fiedler, "Deterministic Lockstep"](https://gafferongames.com/post/deterministic_lockstep/) — what pure, deterministic game updates make possible in networked games.
- [John Hughes, "Why Functional Programming Matters"](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf) — reread the conclusion with the whole course behind you.
**Next:** [Module 12](12-property-testing.md) (bonus) turns your random generator into a property-based testing tool. [Module 13](13-capstone-life.md), the second capstone, converts Conway's Game of Life, which needs a kind of input Pong did not: the mouse. It is the first time since Module 9 that the edge itself changes.
+783
View File
@@ -0,0 +1,783 @@
# Module 12 — Testing with properties (bonus)
> **Time:** ~4 h · **You'll edit:** JS `js/src/check.js` · Scala `scala/src/Check.scala` · F# `fsharp/src/Check.fs`
> **Tests:** JS `cd js && node --test "test/m12-*.test.js"` · Scala `cd scala && ./test.sh M12` · F# `cd fsharp && ./test.sh M12`
> **Prerequisites:** Modules 1–5 and Module 10, including the turn queue of Exercise 10.6 (you need `Random`, `update`, `history`, the `Frame` action and `queued`). Modules 6–9 and 11 are not needed.
## Why this module
Every test you have run in this course is an **example**: this state, this action, this expected state. Examples are precise, and they are easy to read. They also have one weakness, which Puzzle 3 of Module 10 showed: an example checks a case that somebody thought of. The first version of this course's speed-up rule had six fixture cases, all green, and still made the snake jump two cells in 25 ms after every third apple. Nobody had written the case "the score changes on this very frame", because nobody had thought of it. That is the kind of bug you are least likely to write an example for.
A **property** is a different kind of test. Instead of one input and its expected output, you state something that must be true for *every* input:
- every state of every game has the snake on the board;
- the snake never overlaps itself;
- the time between two moves is always `movesEvery(score)` frames.
Then you let the computer look for an input that breaks the statement, by trying hundreds of random ones. If it finds one, it hands you a **counterexample**.
Random tests have a bad reputation: they fail on Tuesday, pass on Wednesday, and nobody can reproduce the failure. That reputation comes from *hidden* randomness. You solved that problem in Module 4. A random test input is a value drawn from a `Random<A>` with a seed, so a failing test is reproduced by one number. And because the game is a fold over a list of actions (Module 5), a counterexample is a *replay*: a list you can run again, print, and cut down to the two key presses that matter.
In this module you build a small property-testing library from the parts you already have, about forty lines of code. Then you point it at deliberately broken versions of the game and watch it find each bug and reduce it to its essence. At the end you generate whole game states instead of games, and catch a bug that no statement about a single state can see, by comparing two implementations of the same rule.
## Learning objectives
After this module you can:
- **distinguish** an example-based test from a property, and **state** properties of several kinds (invariants, relations between runs, laws);
- **implement** generators of test data as `Random` values (`elementOf`, `listOf`) and recognise `listOf` as the same pattern as `sequence` from Module 7;
- **implement** a pure test runner `forAll` that returns a report with the counterexample and the seed that reproduces it;
- **implement** shrinking for lists (`removals`, `minimize`) and **explain** what "1-minimal" means and why total rules make shrinking easy;
- **write** an invariant checker for the game state and **explain** how it relates to "making illegal states unrepresentable";
- **judge** whether a property can detect anything at all, using deliberately broken implementations (mutants);
- **implement** a generator of structured data whose parts depend on each other (`genSnake`), and **explain** why the order of the draws is part of its contract;
- **use** a second, independently written implementation as a test oracle (`advanceSpec`), and **explain** which bugs it can and cannot find.
## Concepts
### 1. From examples to properties
Take `dropLast` from Module 1. An example test says:
```
dropLast([1, 2, 3]) is [1, 2]
```
A property says something about every list `xs`:
```
for every list xs: dropLast(xs) has length max(0, length(xs) - 1)
for every list xs: dropLast(xs) is the beginning of xs
```
Neither property mentions a particular list, and together they pin `dropLast` down completely. To *check* a property you need three things: a way to produce many inputs, a way to run the check on each of them and report what happened, and, when it fails, a way to make the failing input small enough to understand. Those are the three parts of this module: **generators**, **`forAll`** and **shrinking**.
> **From everyday programming:** you may know *fuzzing*: throwing large amounts of random input at a parser or a network service to see whether it crashes. A property test is a fuzzer with an opinion. It does not only ask "did it crash?" but "is this statement about the result still true?", and it runs in milliseconds as part of the unit tests.
### 2. A generator is a `Random`
What is "a way to produce test inputs"? A function from a seed to a value and a new seed, so that the next input can be drawn from where the previous one stopped. You built exactly that type in Module 4:
```
Random<A> = seed -> [A, seed]
```
So there is no new type in this module. A generator of numbers below 10 is `intBelow(10)`. Two small combinators give you everything else the game needs:
- **`elementOf(xs)`** picks one element of a non-empty list: draw an index with `intBelow(length)` and look it up. That is a `map`.
- **`listOf(gen, n)`** draws `n` values from `gen`, one after another, and collects them in a list. The first draw uses the incoming seed, the second draw the seed the first one left behind, and so on.
`listOf` is worth a second look. "Run these computations one after another and collect the results" is what `sequence` did for `IO` in Module 7, and it is built the same way: from `flatMap` and `pure`, by recursion.
```
listOf(gen, 0) = pure([])
listOf(gen, n) = flatMap(gen, a => map(listOf(gen, n - 1), rest => [a, ...rest]))
```
Read the second line as: draw one value `a`; then draw the remaining `n - 1` values as a list `rest`; the result is `a` in front of `rest`. Nowhere do you see a seed. `flatMap` threads it, which is the reason you wrote `flatMap` in the first place.
With these two, a generator of random games is one line, and it is given in your stub:
```
genActions(n) = listOf(elementOf([Tick, Tick, Tick, Turn Up, Turn Down, Turn Left, Turn Right]), n)
```
`Tick` appears three times in the list, so about three actions in seven are ticks. Repeating an element is the simplest way to make it more likely.
**Language note.** JS: `listOf(gen, n)`, data first. Scala: `Check.listOf(gen, n)`. F#: `Check.listOf n gen`, data last, so that it reads `gen |> Check.listOf 40`.
### 3. `forAll`: a test runner that is a pure function
`forAll(gen, prop, runs, seed)` draws `runs` values from `gen`, starting from `seed`, and calls the property `prop` (a function from a value to a boolean) on each. It stops at the first value for which `prop` is false.
Most test frameworks report a failure by throwing an exception or printing to the console. Ours **returns a value**:
```
all runs passed: Passed(runs)
a run failed: Failed(run, seed, value)
```
- `run` is the number of the failing draw, counted from 0;
- `value` is the counterexample;
- `seed` is the seed *that draw started from*, so drawing from `gen` with `seed` gives `value` again.
In JS the report is a plain object, `{ ok: true, runs }` or `{ ok: false, run, seed, value }`. In Scala and F# it is a small sum type, `CheckResult`, given in your stub.
`forAll` has no loop counter and no "current seed" variable. It is a recursion that carries the run number and the seed as arguments, the same way `interval` carried `last` in Module 8.
Because `forAll` is pure, the test of a test runner is an ordinary equality: the same generator, property, number of runs and seed give the same report, every time, on every machine. A failure in one track's test suite can be reproduced in another track with the same seed, because all three tracks draw the same numbers (Module 4).
> **From everyday programming:** a *flaky* test is a test whose result depends on something it does not control: the clock, the order of a hash map, a random number. The usual cure is to hunt down each hidden input and pin it. Here there is nothing to hunt down. The only source of variation is the seed, and the seed is an argument.
### 4. Shrinking: from forty actions to two
Suppose `forAll` reports that this list of 40 random actions breaks a property:
```
Tick, Turn Down, Tick, Tick, Turn Right, Turn Up, Tick, Turn Left, Tick, Tick, Turn Up, Tick, ... (28 more)
```
Somewhere in there is a bug, but which of the 40 actions matter? The idea of **shrinking** is to let the computer find out: try smaller inputs, and keep any that still fails.
For a list, the simplest "smaller input" is the list with one element removed:
```
removals([a, b, c]) = [[b, c], [a, c], [a, b]]
```
`minimize(xs, fails)` repeats one step: look through `removals(xs)` for the first list that still fails. If there is one, continue from it. If there is none, stop and return `xs`.
The result is called **1-minimal**: it fails, and removing any *single* element makes it pass. Every element of it is needed. It is not always the smallest failing list that exists. Take the (artificial) failure "the list has exactly one or exactly three elements": `minimize` stops at three elements, because every list of two passes, although a list of one would fail too. For bugs in real programs, 1-minimal is almost always good enough, and it is cheap: at most `n` rounds of at most `n` tries.
Why is this so easy for our game? Because of two decisions made long ago:
- **A game is a list of actions** (Module 5). The test input is plain data, so "a smaller input" has an obvious meaning.
- **The rules are total** (Module 3). *Every* list of actions is a valid game. If `turn` threw an exception for an illegal turn, most of the shortened lists would crash for reasons that have nothing to do with the bug, and the shrinker would be led astray. Because "does not apply" means "unchanged", you can delete any action from any game and still have a game.
> **From everyday programming:** this is what you do by hand with a bug report. A user sends twenty steps to reproduce a crash; you leave out a step, try again, and keep going until every remaining step is needed. `git bisect` automates the same search over a list of commits. The technique has a name, *delta debugging*, and `minimize` is its simplest form.
### 5. What to check: invariants
The most useful properties of a program with state are its **invariants**: statements that hold for every state the program can ever reach.
Module 2 was honest about the model: the types rule out some nonsense (a status that is paused *and* over), but a `GameState` can still be built with a snake outside the board, an apple inside the snake, or a snake whose cells are scattered across the board. Types could not exclude those states, at least not conveniently. The *rules* are supposed to: starting from `newGame(seed)`, no sequence of actions should ever produce one.
An invariant checker turns that claim into a function:
```
violations(state) = the names of the invariants this state breaks (an empty list: the state is sane)
```
| Name | The state is sane when... |
|---|---|
| `snake-not-empty` | the snake has at least one cell |
| `snake-in-bounds` | every cell of the snake is on the board |
| `snake-no-overlap` | no two cells of the snake are equal |
| `snake-connected` | each cell of the snake is exactly one step (up, down, left or right) from the one before it |
| `apple-in-bounds` | the apple, if there is one, is on the board |
| `apple-not-on-snake` | the apple, if there is one, is not on a cell of the snake |
| `turn-not-reversed` | `nextDir` is not the opposite of `dir` |
| `queued-not-reversed` | `queued` is none, or is not the opposite of `nextDir` (the turn queue of Module 10) |
`violations` returns the names in this order. It returns names and not a single boolean, so that a failing test says *what* is wrong. Each row is one small predicate; the function is a `filter` over a table of (name, predicate) pairs.
The property is then one line:
```
for every list of 40 random actions: every state in history(newGame(1), actions) has no violations
```
Think of invariants as the part of "make illegal states unrepresentable" that the type system left over. Where a type can exclude a bad state, use the type: the compiler checks it for all programs, for free. Where it cannot, write the invariant down as a function and check it on thousands of random games.
### 6. What to check: relations and laws
Invariants look at one state at a time. Some requirements are about how states relate to each other.
**A relation between moments of one run.** "Every step of the snake lasts exactly `movesEvery(score)` frames" is a statement about a whole history. To check it, you need the *gaps* between consecutive moves:
```
moveGaps(states) = the distances between the positions in the list where the head moved
```
The snake "moves at index `i`" when the head of `states[i]` differs from the head of `states[i - 1]`. If it moves at indices 2, 5 and 12, the gaps are `[3, 7]`. With `moveGaps` the requirement of Module 10 becomes checkable, and the tests of this module run it against the tempting rule of Puzzle 3.
**Laws.** Some equations hold between whole computations, for example the replay law of Module 5:
```
replay(s, a ++ b) == replay(replay(s, a), b)
```
Here is a question that separates a useful property from a useless one: **could this property ever fail?** Look at the replay law again. It follows from the definition of a fold, for *any* step function whatsoever. A game in which the snake teleports, an `update` that ignores every action, a version full of bugs: all of them satisfy it. So a test of this law tests `replay` (is it really a left fold?) and tells you nothing about the game.
There is a practical way to answer "could it fail?": break the program on purpose and see whether the property notices. A deliberately broken copy is called a **mutant**, and a mutant that fails a test is said to be *killed*. The last tests of this module do exactly that, with these mutants:
| Mutant | The bug | Caught by |
|---|---|---|
| `turn` checks against `nextDir` | Puzzle 2 of Module 3: the quick double turn | `turn-not-reversed` |
| `advance` forgets the walls | the snake walks off the board | `snake-in-bounds` |
| `Tick` forgets the turn queue | Exercise 10.6 done in `Frame` but not in `Tick` | `queued-not-reversed` |
| the queue accepts a reversal | Exercise 10.6 without the "not the opposite of `nextDir`" check | `queued-not-reversed` |
| the tempting frame rule | Puzzle 3 of Module 10: steps of uneven length | the gaps of `moveGaps` |
| `advance` checks the *old* snake | Puzzle 1 of Module 3: death by the tail that is moving away | comparison with `advanceSpec` (section 8) |
For the first four, the tests also shrink the counterexample, and the results are worth predicting. What is the shortest list of actions that makes a snake reverse into itself if `turn` checks the wrong direction? What is the shortest list that walks the snake of `newGame(1)` off a board without walls? (The head starts at `x = 10` on a board 20 wide, heading `Right`.) Decide before you run the tests; the test titles give the answers.
### 7. Generating states, not only games
Every test input so far has been a list of actions, played from `newGame`. That only reaches the states that random play reaches, and random play is a poor explorer: the snake grows only when it eats, and forty random actions rarely eat more than one apple. A long snake coiled up so that its head is right next to its own tail, the situation of Puzzle 1, practically never happens.
When the function under test takes *one state*, as `advance` does, you can skip the game and generate the state directly. The difficulty is that the parts of a state depend on each other. The snake's cells must be neighbours and must not repeat; `dir` must agree with the first two cells; the apple must be on a free cell. A generator that drew each field independently would produce almost nothing but nonsense, and a test of `advance` on nonsense says nothing about the game.
The way out is to **draw the parts in the order in which they depend on each other**, each draw using what the earlier ones produced. That is exactly what `flatMap` does: the next generator is chosen *from the value of the previous one*. For the snake:
```
genSnake(width, height, maxLen):
draw the head: x with intBelow(width), then y with intBelow(height)
then, until the snake has maxLen cells:
the candidates are the neighbours of the LAST cell, in the order Up, Down, Left, Right,
that are on the board and not yet in the snake
if there are none, stop early; otherwise append elementOf(candidates) and go on
```
From seed 0 on a 5 x 5 board with `maxLen` 6, the head is (3,2) and the walk continues up and then left along the top row:
```
x=0 x=1 x=2 x=3 x=4
y=0 5 4 3 2 . snake (head first): (3,2) (3,1) (3,0) (2,0) (1,0) (0,0)
y=1 . . . 1 . the numbers give the order in which the cells were drawn
y=2 . . . H .
y=3 . . . . .
y=4 . . . . .
```
The order of the draws, and the order Up, Down, Left, Right of the candidates, are part of `genSnake`'s contract, written in the stub. They decide which snake a seed gives. Because all three tracks follow them, a counterexample found from a seed in one track is the same counterexample in the others.
`genState(width, height)` is given in your stub and builds on `genSnake`: a length limit between 1 and `width * height`, a snake, `dir` read off the first two cells (`Right` for a one-cell snake), `nextDir` equal to `dir` or a perpendicular direction, an apple on a random free cell or none, and nothing queued. Read it: it is four `flatMap`s, each one choosing the next generator from what came before.
A generator is code, and code has bugs. If `genState` produced a disconnected snake, every later test would complain about states the game can never reach, and you would hunt for bugs that are not there. So the first test of a generator is a property *of the generator*: `every generated state is sane: 500 states of a 5x5 board have no violations`.
> **From everyday programming:** test-data builders and factories (a `UserFactory` that fills in a valid email, a hashed password and a creation date before an account object is saved) solve the same problem: produce objects whose fields satisfy the constraints between them, so that the test can concentrate on what it wants to check. A property-testing generator is a factory that picks *random* valid objects, and can therefore find the valid object nobody thought of.
### 8. A second implementation as an oracle
Generated states make it possible to test `advance` on hundreds of coiled-up snakes. But test it against *what*? Invariants say whether a state is sane, and the bug of Puzzle 1 produces only sane states: it ends the game one step too early, and a finished game is a perfectly valid state. To catch a result that is valid but *wrong*, you need to know the right result. Something that knows it is called a **test oracle**.
For one hand-written example, the oracle is you: you write the expected state into a fixture. For 500 random states, you need a program. The usual answer is **a second implementation**, written independently, and the property
```
for every generated state s: advance(s) == advanceSpec(s)
```
The second implementation is only useful if it does not share the first one's mistakes, so it should be written in a different way. Module 3's `advance` decides about the *new head*: compute the body that remains, then ask whether the head lands on the board and outside that body. `advanceSpec` decides about the *resulting snake*: build the whole snake the step would produce (the new head in front, then the old snake without its last cell unless eating), and ask `violations`, the checker you wrote in Exercise 12.4, whether that snake is on the board and free of overlap. If it is not, the step is deadly, and the result is Module 3's game over. It is the rule stated as a requirement on the outcome: "after a step, the snake must still be a legal snake".
Now look at the wrong check of Puzzle 1 again: "the new head must not be on the *old* snake". It is a statement about the old snake, and it disagrees with "the new snake must not overlap" exactly when the head moves onto the cell the tail is leaving. The comparison test finds such a state in the 22nd draw (run 21) from seed 2024:
```
x=0 x=1 x=2 x=3 x=4
y=0 1 2 . . . snake (head first): (0,1) (0,0) (1,0) (1,1) (1,2) (1,3) (0,3) (0,2)
y=1 H 3 . . . heading Down; the head's next cell (0,2) is the tail T
y=2 T 4 . . . A is the apple
y=3 6 5 A . .
y=4 . . . . .
```
The wrong check says "game over"; `advanceSpec` says the snake moves on, its tail having left (0,2) at the same moment. No invariant could have said which one is right, and the test `no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane` shows that, too.
Two caveats. An oracle written by the same person on the same afternoon can share a *misunderstanding* of the rules with the code it checks; then the two agree and are both wrong. That is what the example fixtures, written from the specification, are for: `advanceSpec` must also pass Module 3's twelve `advance` cases. And an oracle has to be simpler or more obviously right than the code it checks, or you have only doubled the code you must trust. `advanceSpec` is simpler in one sense: it delegates the whole question "is this a legal snake?" to a function that was already tested on its own.
> **From everyday programming:** this is *differential testing*. When a team rewrites a slow service, they often run the old and the new version side by side on the same requests and compare the answers before switching. Compiler writers generate random programs and compare the output of two compilers. A refactoring is safe to the extent that the code after it agrees with the code before it, and keeping the old version around as an oracle turns that into a test.
### 9. What random testing does not find
Be as honest about this tool as Module 2 was about the model.
- **A property only sees what it states.** The invariants of section 5 cannot see the wrong collision check of Puzzle 1: it ends the game too early, and a game that has ended is still a sane state. Catching it took a different kind of property, the comparison with a second implementation of section 8. That one has its own blind spot: it cannot see a mistake that both implementations make.
- **Random inputs reach the likely states.** Forty random actions from a new game almost never eat three apples. So random play from `newGame` would practically never reach the first speed-up, and would not find the bug of Puzzle 3. The tests therefore start from a hand-made state: a long corridor with the third apple right in front of the head, and a random number of frames already counted. Generating states directly (section 7) is the other answer. Choosing where to start and what to generate is the real craft of property testing.
- **Passing is evidence, not proof.** 300 random games that keep every invariant make a bug less likely. They do not show that there is none. A proof needs reasoning (as in question 3 of Module 5) or a tool that explores *every* reachable state.
## Exercises
> **About test names.** The test titles quoted under "Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m12 listOf threads the seed from each draw to the next`). The Scala and F# suites test the same things one-to-one, in each track's vocabulary (`Passed`/`Failed` instead of `ok`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M12Check.test.scala` or `fsharp/tests/M12Check.fs`. Fixture-based tests have the same case names in every track.
All functions are pure: no loops, no reassignment, no mutation. The purity audit checks this file like every other.
### Exercise 12.1 — `elementOf` and `listOf`
**JavaScript**
```js
export const elementOf = (xs) => ... // Random<A>, for a non-empty array xs
export const listOf = (gen, n) => ... // Random<A[]>
```
**Scala**
```scala
def elementOf[A](xs: List[A]): Random[A]
def listOf[A](gen: Random[A], n: Int): Random[List[A]]
```
**F#**
```fsharp
let elementOf (xs: 'a list) : Random<'a>
let listOf (n: int) (gen: Random<'a>) : Random<'a list>
```
Tests: `elementOf picks the element at the index drawn by intBelow`, `elementOf of a one-element array always gives that element, and still uses up a draw`, `listOf(gen, 0) is the empty list and leaves the seed alone`, `listOf threads the seed from each draw to the next`, `listOf of a constant generator repeats the value and leaves the seed alone`, `genActions(n) gives n actions, the same ones for the same seed`.
Watch out: write both with `map`, `flatMap` and `pure` from Module 4. If you find yourself unpacking a `[value, seed]` pair, you are doing by hand what `flatMap` does for you.
### Exercise 12.2 — `forAll`
**JavaScript**
```js
export const forAll = (gen, prop, runs, seed) => ...
// { ok: true, runs } or { ok: false, run, seed, value }
```
**Scala**
```scala
enum CheckResult[+A]: // given
case Passed(runs: Int)
case Failed(run: Int, seed: Long, value: A)
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A]
```
**F#**
```fsharp
type CheckResult<'a> = // given
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a>
```
Tests: `forAll reports success with the number of runs`, `forAll with zero runs succeeds without drawing anything`, `forAll reports the first failing draw, its value and the seed it started from`, `forAll stops at the first failure: the property is not asked about later values`, `forAll is reproducible: same generator, property, runs and seed give the same report`.
Watch out: the `seed` in a failure report is the seed the failing draw *started from*, not the one it left behind. And each draw must continue from the previous draw's leftover seed; if every draw starts from the same seed, you test the same value `runs` times.
### Exercise 12.3 — `removals` and `minimize`
**JavaScript**
```js
export const removals = (xs) => ... // removals([1, 2, 3]) -> [[2, 3], [1, 3], [1, 2]]
export const minimize = (xs, fails) => ... // fails: list -> boolean, and fails(xs) is true
```
**Scala**
```scala
def removals[A](xs: List[A]): List[List[A]]
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A]
```
**F#**
```fsharp
let removals (xs: 'a list) : 'a list list
let minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list
```
Tests: `removals lists every way to drop one element, in order`, `removals of an empty list is empty, and of a single element is one empty list`, `minimize keeps exactly the elements the failure needs`, `minimize keeps the order and the duplicates the failure needs`, `minimize of a list that fails whatever you remove is the empty list`, `minimize returns a list that still fails and has no failing removal (1-minimal)`.
Watch out: continue with the **first** failing removal, in the order of `removals`. The tracks must agree on which minimal list they report.
### Exercise 12.4 — `violations`
**JavaScript**
```js
export const violations = (state) => ... // string[], in the order of the table in section 5
```
**Scala**
```scala
def violations(state: GameState): List[String]
```
**F#**
```fsharp
let violations (state: GameState) : string list
```
Fixture: `fixtures/m12-violations.json`, 21 cases: six sane states (`a-sane-state-has-no-violations`, `no-apple-is-fine`, `a-one-cell-snake-is-fine`, `a-queued-turn-is-fine`, ...), one or two cases per invariant (`head-off-the-right-edge`, `snake-with-a-diagonal-step`, `apple-on-the-tail`, `pending-turn-straight-back`, `queued-turn-straight-back-from-the-pending-turn`, ...), `several-violations-are-listed-in-order` and `reversed-pending-turn-and-reversed-queue-are-both-listed`.
Watch out: `snake-no-overlap` and `snake-connected` are different. A snake with the same cell twice in a row (`doubled-cell-counts-as-overlap-and-as-disconnected`) breaks both: the cells overlap, and they are zero steps apart, not one. Reuse `inBounds` from Module 3 and `contains` from Module 1. `queued-not-reversed` compares with `nextDir`, not with `dir`: in `a-queued-turn-is-fine` the snake moves `Right`, `Up` is pending and `Left` is queued, which is fine.
### Exercise 12.5 — `moveGaps`
**JavaScript**
```js
export const moveGaps = (states) => ... // number[]
```
**Scala**
```scala
def moveGaps(states: List[GameState]): List[Int]
```
**F#**
```fsharp
let moveGaps (states: GameState list) : int list
```
Tests: `moveGaps measures the distance between consecutive moves of the head`, `moveGaps of fewer than two moves is empty`.
### Exercise 12.6 — `genSnake`
**JavaScript**
```js
export const genSnake = (width, height, maxLen) => ... // Random<Point[]>, head first; maxLen >= 1
```
**Scala**
```scala
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]]
```
**F#**
```fsharp
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list>
```
Draw exactly as section 7 and the stub's doc comment say: the head's `x`, then its `y`, then one `elementOf` per further cell among the free in-bounds neighbours of the *last* cell, in the order Up, Down, Left, Right; stop at `maxLen` cells or when there is no candidate.
Tests: `genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)`, `genSnake is pinned for a few more seeds`, `genSnake with maxLen 1 is just the head, drawn with two draws`, `genSnake stops early when the walk is stuck: a 1x1 board holds one cell`, `genSnake always gives a snake on the board, connected, without overlap, at most maxLen long`, and, through the given `genState`, `every generated state is sane: 500 states of a 5x5 board have no violations` and `generated states are Playing, have nothing queued, and vary in length and apple`.
Watch out: the walk extends the snake at its *tail* (the last cell), and the snake is head first, so new cells go at the end of the list. Stopping early must not use up a draw: when there are no candidates, return the snake with `pure`. And do not draw the head as one random cell from a list of all cells: that is a different draw, and the pinned tests will tell you so.
### Exercise 12.7 — `advanceSpec`
**JavaScript**
```js
export const advanceSpec = (state) => ... // GameState; must equal advance(state) on every sane state
```
**Scala**
```scala
def advanceSpec(state: GameState): GameState
```
**F#**
```fsharp
let advanceSpec (state: GameState) : GameState
```
Write it as section 8 describes, without calling `advance`: build the snake the step would produce, then let `violations` decide whether it contains `snake-in-bounds` or `snake-no-overlap`.
Tests: `advanceSpec agrees with Module 3's advance fixture: …` (one test per case of `fixtures/m03-advance.json`), `advance and advanceSpec agree on 500 generated states of a 5x5 board`, `no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane`, `comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail`.
Watch out: ask `violations` about the state *with the new snake*, and look only at the two snake invariants. When the step eats, the new head is on the apple, so `apple-not-on-snake` is in the list too, and that does not make the step deadly. Game over keeps the old snake and records `dir = nextDir`, exactly as in Module 3.
### The payoff
When 12.1–12.5 are green, the remaining tests of the module run your library against the game. They need no code from you; read them in the test file, because they show how the pieces are used together:
- `the real game keeps every invariant on 300 random games of 40 actions`;
- `the quick double turn bug is found and shrunk to its two key presses`;
- `a game without walls is found and shrunk to the shortest way off the board: ten ticks`;
- `a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions`;
- `a queue that accepts a reversal is found and shrunk to its two key presses`;
- `the real game moves exactly every movesEvery(score) frames, also across a speed-up`;
- `counting frames from the start of the game makes some steps too short, and the property finds one`.
If the first of these fails, do not assume that your library is wrong. It may have found a real bug in *your* rules that the fixtures missed. Shrink the counterexample and look at it.
### Run
- JS: `cd js && node --test "test/m12-*.test.js"`
- Scala: `cd scala && ./test.sh M12`
- F#: `cd fsharp && ./test.sh M12`
## Hints
<details><summary>Exercise 12.1 (elementOf, listOf) — nudge</summary>
`elementOf` is "a random index, transformed into the element at that index". `listOf` is recursion on `n`: what is a list of zero draws, and how do you get a list of `n` draws from one draw and a list of `n - 1` draws?
</details>
<details><summary>Exercise 12.1 (elementOf, listOf) — stronger hint</summary>
`elementOf(xs)`: `map` over `intBelow(length of xs)`. `listOf(gen, n)`: if `n` is 0 (or less), `pure` of the empty list. Otherwise `flatMap` over `gen`; inside, you have the first value `a` and need a `Random` of the whole list: `map` over `listOf(gen, n - 1)` and put `a` in front. In F# remember that the function is `bind` and that the data comes last.
</details>
<details><summary>Exercise 12.1 (elementOf, listOf) — near-solution (try the others first)</summary>
```js
export const elementOf = (xs) => map(intBelow(___), (i) => ___)
export const listOf = (gen, n) =>
n <= 0 ? pure(___) : flatMap(gen, (a) => map(listOf(gen, ___), (rest) => [___, ...___]))
```
```scala
def elementOf[A](xs: List[A]): Random[A] = Random.intBelow(___).map(i => ___)
def listOf[A](gen: Random[A], n: Int): Random[List[A]] =
if n <= 0 then Random.pure(___)
else gen.flatMap(a => listOf(gen, ___).map(rest => ___ :: ___))
```
```fsharp
let elementOf (xs: 'a list) : Random<'a> = Random.intBelow ___ |> Random.map (fun i -> List.item i xs)
let rec listOf (n: int) (gen: Random<'a>) : Random<'a list> =
if n <= 0 then Random.pure ___
else gen |> Random.bind (fun a -> listOf ___ gen |> Random.map (fun rest -> ___ :: ___))
```
</details>
<details><summary>Exercise 12.2 (forAll) — nudge</summary>
Write a helper that takes the number of the current run and the current seed. It has three outcomes: all runs are done; this value passes, so go on with the next run and the leftover seed; this value fails, so report.
</details>
<details><summary>Exercise 12.2 (forAll) — stronger hint</summary>
`go(i, s)`: if `i` has reached `runs`, the result is the success report. Otherwise run `gen` with `s` to get the value and the next seed. If `prop(value)` holds, the result is `go(i + 1, nextSeed)`. If not, the result is the failure report with `i`, `s` (not `nextSeed`) and the value. `forAll` is `go(0, seed)`. In Scala, mark the helper `@annotation.tailrec`; in F#, `let rec` with the recursive call in tail position.
</details>
<details><summary>Exercise 12.2 (forAll) — near-solution (try the others first)</summary>
```js
export const forAll = (gen, prop, runs, seed) => {
const go = (i, s) => {
if (i >= runs) return { ok: true, runs }
const [value, next] = run(gen, s)
return prop(value) ? go(___, ___) : { ok: false, run: ___, seed: ___, value }
}
return go(0, seed)
}
```
```scala
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A] =
@annotation.tailrec
def go(i: Int, s: Long): CheckResult[A] =
if i >= runs then CheckResult.Passed(runs)
else
val (value, next) = gen.run(s)
if prop(value) then go(___, ___) else CheckResult.Failed(___, ___, value)
go(0, seed)
```
```fsharp
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> =
let rec go i s =
if i >= runs then Passed runs
else
let (value, next) = Random.run s gen
if prop value then go ___ ___ else Failed(___, ___, value)
go 0 seed
```
</details>
<details><summary>Exercise 12.3 (removals, minimize) — nudge</summary>
`removals`: for each index, the list without the element at that index. `minimize`: find the first removal that still fails; what do you do with it, and what do you do when there is none?
</details>
<details><summary>Exercise 12.3 (removals, minimize) — stronger hint</summary>
`removals` in JS: `map` with the index, and build each list from two `slice`s. In Scala, `xs.indices.toList.map(i => xs.patch(i, Nil, 1))`; in F#, `List.mapi` with `List.removeAt` (or recursion on the list: removing from `x :: rest` is either `rest`, or `x` in front of each removal of `rest`). `minimize`: `find` (`List.tryFind`) the first failing removal. If there is one, the answer is `minimize` of it. If not, the answer is `xs`.
</details>
<details><summary>Exercise 12.3 (removals, minimize) — near-solution (try the others first)</summary>
```js
export const removals = (xs) => xs.map((_, i) => [...xs.slice(0, ___), ...xs.slice(___)])
export const minimize = (xs, fails) => {
const smaller = removals(xs).find(___)
return smaller === undefined ? ___ : minimize(___, fails)
}
```
```scala
def removals[A](xs: List[A]): List[List[A]] = xs.indices.toList.map(i => xs.patch(i, Nil, ___))
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A] =
removals(xs).find(___) match
case Some(smaller) => minimize(___)(fails)
case None => ___
```
```fsharp
let removals (xs: 'a list) : 'a list list = xs |> List.mapi (fun i _ -> List.removeAt ___ xs)
let rec minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list =
match removals xs |> List.tryFind ___ with
| Some smaller -> minimize fails ___
| None -> ___
```
</details>
<details><summary>Exercise 12.4 (violations) — nudge</summary>
Do not write eight `if`s that push onto a list. Write a table: a list of pairs (name, a function that says whether the state is fine). Then the result is "the names of the pairs whose function says no".
</details>
<details><summary>Exercise 12.4 (violations) — stronger hint</summary>
The predicates, on a state `s`: the snake's length is above 0; every snake cell satisfies `inBounds`; for every cell, the cells *after* it do not contain it; every cell from the second on is a neighbour of the cell before it (the sum of the absolute differences of `x` and `y` is exactly 1); the apple is absent or in bounds; the apple is absent or not contained in the snake; `nextDir` differs from `opposite(dir)`; `queued` is absent or differs from `opposite(nextDir)`. For "each cell and the one before it", pair the snake with itself shifted by one: in JS use the index argument of `every`, in Scala `snake.zip(snake.drop(1))`, in F# `List.pairwise`.
</details>
<details><summary>Exercise 12.4 (violations) — near-solution (try the others first)</summary>
```js
const neighbours = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1
const invariants = [
['snake-not-empty', (s) => s.snake.length > 0],
['snake-in-bounds', (s) => s.snake.every((p) => ___)],
['snake-no-overlap', (s) => s.snake.every((p, i) => !contains(s.snake.slice(___), p, pointEquals))],
['snake-connected', (s) => s.snake.slice(1).every((p, i) => neighbours(s.snake[___], p))],
['apple-in-bounds', (s) => s.apple === null || ___],
['apple-not-on-snake', (s) => s.apple === null || ___],
['turn-not-reversed', (s) => ___],
['queued-not-reversed', (s) => s.queued === null || ___],
]
export const violations = (state) => invariants.filter(([, holds]) => ___).map(([name]) => name)
```
The Scala and F# versions have the same shape: a `List` of `(String, GameState => Boolean)` pairs, then `filterNot`/`List.filter` and `map`. With `snake.zip(snake.drop(1)).forall { case (a, b) => ... }` or `List.pairwise snake |> List.forall (fun (a, b) -> ...)` for the connected check, and `apple.forall(p => ...)` / `Option.forall (fun p -> ...) apple` for "absent or fine".
</details>
<details><summary>Exercise 12.5 (moveGaps) — nudge</summary>
Two steps. First the list of indices at which the head moved. Then the differences between neighbours in that list.
</details>
<details><summary>Exercise 12.5 (moveGaps) — stronger hint</summary>
Step 1: pair each state with the one before it (JS: `map` with the index, look at `states[i - 1]`; Scala: `states.zip(states.drop(1)).zipWithIndex`; F#: `List.pairwise` and `List.indexed`), keep the indices where the two heads differ. Mind the off-by-one: the pair of states 0 and 1 is a move *at index 1*. Step 2: pair the list of indices with itself shifted by one and subtract.
</details>
<details><summary>Exercise 12.5 (moveGaps) — near-solution (try the others first)</summary>
```js
export const moveGaps = (states) => {
const moved = states
.map((s, i) => (i > 0 && !pointEquals(s.snake[0], states[___].snake[0]) ? i : -1))
.filter((i) => i >= 0)
return moved.slice(1).map((i, k) => i - moved[___])
}
```
```scala
def moveGaps(states: List[GameState]): List[Int] =
val moved = states.zip(states.drop(1)).zipWithIndex.collect {
case ((before, after), i) if before.snake.headOption != after.snake.headOption => ___
}
moved.zip(moved.drop(1)).map((a, b) => ___)
```
```fsharp
let moveGaps (states: GameState list) : int list =
states
|> List.pairwise
|> List.indexed
|> List.filter (fun (_, (before, after)) -> List.tryHead before.Snake <> List.tryHead after.Snake)
|> List.map (fun (i, _) -> ___)
|> List.pairwise
|> List.map (fun (a, b) -> ___)
```
</details>
<details><summary>Exercise 12.6 (genSnake) — nudge</summary>
Write a helper that takes the snake built so far and the number of cells still allowed, and returns a `Random` of the finished snake. It has three outcomes: no cells left to add; no candidate cell; or pick a candidate and continue. `genSnake` draws the head and then calls the helper.
</details>
<details><summary>Exercise 12.6 (genSnake) — stronger hint</summary>
`grow(snake, left)`: if `left` is 0 (or less), `pure(snake)`. Otherwise take the last cell, `move` it in each of Up, Down, Left, Right (in that order), and keep the cells for which `inBounds` holds (it only reads `width` and `height`, so a small record with those two fields will do) and which the snake does not `contain`. No candidates: `pure(snake)`. Otherwise `flatMap` over `elementOf(candidates)` and continue with the snake plus that cell and `left - 1`. `genSnake` is `flatMap` over `intBelow(width)`, inside it `flatMap` over `intBelow(height)`, and inside that `grow([head], maxLen - 1)`.
</details>
<details><summary>Exercise 12.6 (genSnake) — near-solution (try the others first)</summary>
```js
const directions = ['Up', 'Down', 'Left', 'Right']
const grow = (width, height, snake, left) => {
if (left <= 0) return ___
const last = snake[snake.length - 1]
const options = directions
.map((d) => move(last, d))
.filter((p) => inBounds({ width, height }, p) && !___)
return options.length === 0 ? ___ : flatMap(elementOf(options), (p) => grow(width, height, [___, p], ___))
}
export const genSnake = (width, height, maxLen) =>
flatMap(intBelow(width), (x) => flatMap(intBelow(___), (y) => grow(width, height, [point(x, y)], ___)))
```
```scala
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]] =
def grow(snake: List[Point], left: Int): Random[List[Point]] =
val options = List(Direction.Up, Direction.Down, Direction.Left, Direction.Right)
.map(d => Rules.move(snake.last, d))
.filter(p => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height && ___)
if left <= 0 || options.isEmpty then Random.pure(___)
else elementOf(options).flatMap(p => grow(snake :+ ___, ___))
Random.intBelow(width).flatMap(x => Random.intBelow(___).flatMap(y => grow(List(Point(x, y)), ___)))
```
```fsharp
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> =
let rec grow (snake: Point list) (left: int) : Random<Point list> =
let options =
[ Up; Down; Left; Right ]
|> List.map (fun d -> Rules.move (List.last snake) d)
|> List.filter (fun p -> p.X >= 0 && p.X < width && p.Y >= 0 && p.Y < height && ___)
if left <= 0 || List.isEmpty options then Random.pure ___
else elementOf options |> Random.bind (fun p -> grow (snake @ [ ___ ]) ___)
Random.intBelow width |> Random.bind (fun x -> Random.intBelow ___ |> Random.bind (fun y -> grow [ { X = x; Y = y } ] ___))
```
</details>
<details><summary>Exercise 12.7 (advanceSpec) — nudge</summary>
Follow the Module 3 shape of compute, decide, build. Compute the new head, whether it eats, and the whole new snake. Decide by asking `violations` about the state with that snake. Build one of the two results.
</details>
<details><summary>Exercise 12.7 (advanceSpec) — stronger hint</summary>
Not `Playing`: return the state. `head = move(snake[0], nextDir)`; `eating` = the apple is there and equals `head`; `snake' = [head, ...(eating ? snake : dropLast(snake))]`. `broken` = `violations` of the state with `snake'` contains `'snake-in-bounds'` or `'snake-no-overlap'`. If broken: the state with `dir: nextDir` and `status: 'GameOver'`. Otherwise: the state with `snake'`, `dir: nextDir`, the apple gone if eating, and the score plus one if eating.
</details>
<details><summary>Exercise 12.7 (advanceSpec) — near-solution (try the others first)</summary>
```js
export const advanceSpec = (state) => {
if (state.status !== 'Playing') return state
const head = move(state.snake[0], state.nextDir)
const eating = state.apple !== null && pointEquals(head, state.apple)
const snake = [head, ...(eating ? ___ : ___)]
const broken = violations({ ...state, snake }).some((v) => v === ___ || v === ___)
return broken
? { ...state, dir: state.nextDir, status: ___ }
: { ...state, snake, dir: state.nextDir, apple: ___, score: ___ }
}
```
```scala
def advanceSpec(state: GameState): GameState =
if state.status != Status.Playing then state
else
val head = Rules.move(state.snake.head, state.nextDir)
val eating = state.apple.contains(head)
val snake = head :: (if eating then ___ else ___)
val broken = violations(state.copy(snake = snake)).exists(v => v == ___ || v == ___)
if broken then state.copy(dir = state.nextDir, status = ___)
else state.copy(snake = snake, dir = state.nextDir, apple = ___, score = ___)
```
```fsharp
let advanceSpec (state: GameState) : GameState =
if state.Status <> Playing then state
else
let head = Rules.move (List.head state.Snake) state.NextDir
let eating = state.Apple = Some head
let snake = head :: (if eating then ___ else ___)
let broken = violations { state with Snake = snake } |> List.exists (fun v -> v = ___ || v = ___)
if broken then { state with Dir = state.NextDir; Status = ___ }
else { state with Snake = snake; Dir = state.NextDir; Apple = ___; Score = ___ }
```
</details>
## Check your understanding
1. `forAll` returns a report instead of throwing an exception. Name two things you can do with a report that you could not do if the test runner printed "FAILED" and exited.
2. A colleague's random test fails once a week on the build server and never on their laptop. Which hidden inputs would you look for? Why can that not happen to `forAll(genActions(40), prop, 300, 2024)`?
3. Write `listOf` for `IO` instead of `Random` (on paper): `replicate(io, n) : IO<A[]>`. Which lines change? What does that tell you about `flatMap` and `pure`?
4. Predict, without running anything: `minimize([1, 2, 3, 4], xs => xs.length >= 2)` and `minimize([1, 2, 3], xs => xs.length === 1 || xs.length === 3)`. Which of the two results is the smallest failing list that exists?
5. The replay law `replay(s, a ++ b) == replay(replay(s, a), b)` holds for every `update` function. Explain why, and then decide for each of these whether a *broken game* could fail it: (a) `history(s, as)` ends with `replay(s, as)`; (b) `update(s, Tick)` never changes `width` or `height`; (c) the score never goes down; (d) `update(update(s, TogglePause), TogglePause) == s` for every state `s`. Is (d) even true?
6. The wrong collision check of Puzzle 1 (Module 3), "the new head must not be on the *old* snake", keeps every invariant of this module, and Exercise 12.7 catches it by comparison with `advanceSpec`. Explain why no invariant of a *single* state can catch it. Then design the other kind of property the original version of this question suggested: a sequence of actions from a hand-built start state on a small board that must be survivable. Which of the two would still find the bug if `advanceSpec` had been written with the same mistake?
7. Random play from `newGame` almost never reaches score 3. Describe two different ways to get test games that do: one that changes the *generator*, one that changes the *start state*. Which did the tests of this module choose, and why?
8. `violations` checks eight invariants at run time. Pick one of them and sketch a data model in which a state that breaks it cannot be built at all. What does your model cost in the rest of the code? (Example to start from: a snake stored as a head plus a list of *directions* cannot be disconnected.)
9. In the test `the quick double turn bug is found and shrunk to its two key presses`, the first key is `Up` or `Down` and the second is `Left`. Why can the shrunk list not contain a `Tick`? And why is the second key never `Right`?
10. Trace the counterexample of `a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions` (`Turn Up`, `Turn Right`, `Tick`, `Turn Left`) state by state: `dir`, `nextDir`, `queued` after each action. Which invariant breaks, and after which action? What would the snake have done on the next step?
11. `genState` never produces a state with something queued. Extend it on paper so that it also produces states with a queued turn that the real game could reach. Which constraint must the queued direction satisfy, and which further constraint links it to `dir`?
12. With `maxLen = 25` on a 5 x 5 board, is the snake from `genSnake` always 25 cells long? Why not? Does the answer matter for the comparison test of Exercise 12.7?
13. Why does the comparison test run on generated states rather than on the states of random games from `newGame`? Estimate roughly how often a random game of 40 actions brings the head next to its own tail with `nextDir` pointing at it.
## In production
- **QuickCheck** (Haskell, 1999) is where this style of testing comes from; its generators are a monad over a splittable random seed, as ours are over a seed. Every mainstream language now has a descendant: [fast-check](https://fast-check.dev/) for JavaScript and TypeScript, [ScalaCheck](https://scalacheck.org/) for Scala, [FsCheck](https://fscheck.github.io/FsCheck/) for F# and C#, [Hypothesis](https://hypothesis.readthedocs.io/) for Python.
- **Shrinking** in those libraries is more refined than `minimize`: they also make numbers smaller and strings shorter, and newer designs such as [Hedgehog](https://hedgehog.qa/) and Hypothesis shrink the *random choices* instead of the generated value, so that a shrunk value is always one the generator could have produced.
- **Mutation testing** tools such as [Stryker](https://stryker-mutator.io/) (JavaScript, C#, Scala) change your code automatically, one operator at a time, and report the changes that no test notices. (This course's own test suite was checked that way; the surviving mutants became new fixture cases, for example the Pong cases `ball-in-column-zero-is-still-in-play` and `player-paddle-catches-the-ball-at-its-first-row`.)
- **Deterministic simulation testing** takes "same seed, same run" to the scale of a distributed database: the whole cluster, with its network and disks, runs inside one deterministic simulation driven by a seed, so that a failure found after millions of simulated hours can be replayed exactly. FoundationDB was built this way ([Simulation and Testing](https://apple.github.io/foundationdb/testing.html)). It is the idea of Module 5, applied to a data centre.
- **Model checkers** explore *every* reachable state of a model instead of a random sample, and are the next step when "300 random games passed" is not enough ([TLA+](https://lamport.azurewebsites.net/tla/tla.html)).
## Further reading
- [Koen Claessen and John Hughes, "QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs"](https://www.cs.tufts.edu/~nr/cs257/archive/john-hughes/quick.pdf) — the original paper (ICFP 2000); sections 1–3 are readable with this course behind you, and its `Gen` is your `Random`.
- [Scott Wlaschin, "Choosing properties for property-based testing"](https://fsharpforfunandprofit.com/posts/property-based-testing-2/) — a catalogue of property shapes ("there and back again", "some things never change", "the test oracle") with pictures.
- [Andreas Zeller, "Reducing Failure-Inducing Inputs" (The Debugging Book)](https://www.debuggingbook.org/html/DeltaDebugger.html) — delta debugging, the general form of `minimize`, by its inventor.
- [fast-check documentation](https://fast-check.dev/docs/introduction/) — what a production library adds: many generators, integrated shrinking, replay of a failure from its seed.
- [Mutation testing (Wikipedia)](https://en.wikipedia.org/wiki/Mutation_testing) and [Metamorphic testing (Wikipedia)](https://en.wikipedia.org/wiki/Metamorphic_testing) — testing the tests, and testing relations between runs when no expected output is known.
- [Philip Wadler, "Theorems for free!"](https://people.mpi-sws.org/~dreyer/tor/papers/wadler.pdf) — why some properties (like the replay law) hold for every function of a given type, and so cannot fail.
- [Hillel Wayne, "Learn TLA+"](https://learntla.com/) — an introduction to checking all states of a design instead of sampling them.
+559
View File
@@ -0,0 +1,559 @@
# Module 13 — Capstone: Conway's Life, with the mouse
> **Time:** ~6–8 h (spread it over several sessions) · **You'll edit:** JS `js/src/life/model.js`, `rules.js`, `view.js`, `app.js`, and at the edge `js/src/runtime.js` and `js/src/browser.js` · Scala `scala/src/life/Model.scala`, `Rules.scala`, `View.scala`, `App.scala`, and `scala/src/Runtime.scala` · F# `fsharp/src/Life/Model.fs`, `Rules.fs`, `View.fs`, `App.fs`, and `fsharp/src/Runtime.fs`
> **Tests:** JS `cd js && node --test "test/m13-*.test.js"` · Scala `cd scala && ./test.sh M13` · F# `cd fsharp && ./test.sh M13` — and afterwards **all** tests of your track (`./test.sh`)
> **Prerequisites:** Modules 0–11 and **[the method](method.md)**. Module 12 is not needed.
## Why this module
Pong was a second game, but a close cousin of Snake: a board, a clock, keys. Everything it needed from the outside world was already wired. This capstone is different in two ways.
First, the player uses the **mouse**: clicking a cell switches it on or off. Nothing in the course so far has received a click. The runtime registers three callbacks (keys down, keys up, the clock), the platform offers three ways to listen, and the stream inputs have three cases. A fourth kind of input means changing the edge itself, the one place you have not touched since Module 9 except for the high score. You will see exactly how much that change costs, and why Snake and Pong do not notice it.
Second, the rules of the game look trivial and hide the classic bug of stateful simulations: updating the cells **one by one, in place**, so that later cells see a board that is half old and half new. In this course you cannot write that bug, and the reason why is worth understanding precisely.
The game is **Conway's Game of Life** ([Wikipedia](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)): not really a game, but a "zero-player" world of cells on a grid that live, die and are born by three rules, invented by John Conway in 1970 ([Martin Gardner's column in Scientific American, October 1970](https://web.stanford.edu/class/sts145/Library/life.pdf)). You draw a starting pattern with the mouse, start the clock, and watch.
As in Module 11, the scaffolding is light: the rules and tests are complete, the hints ask questions and stop before a skeleton. Look at your Snake and Pong code first; you have written every pattern you need, except one.
## Learning objectives
By the end of this module you can:
- **apply** the conversion method to a simulation whose state is a whole board;
- **explain** why the next generation must be computed from the whole old board, **demonstrate** the bug of the in-place loop on a blinker, and **explain** why immutable data rules it out;
- **choose** a canonical representation for a set of cells, and **justify** it with the needs of tests in three languages;
- **implement** a neighbour count that is fast enough without mutation;
- **add** a new kind of input to the edge (a stream source, a runtime registration, a platform method) without changing any existing game;
- **reuse** `Random`, `IO`, streams, `clampTime` and the runtime for a third program.
## The game, as you'd find it
Here is a typical stateful implementation sketch. Read it the way you read the Pong sketch: count the hidden things.
```js
// The usual, stateful way (a sketch to convert; do NOT write this in the course)
const W = 40, H = 30, SIZE = 16
let grid = Array.from({ length: H }, () => Array(W).fill(false))
let running = false, generation = 0
canvas.addEventListener('click', (e) => {
const x = Math.floor(e.offsetX / SIZE), y = Math.floor(e.offsetY / SIZE)
grid[y][x] = !grid[y][x]
draw()
})
window.addEventListener('keydown', (e) => {
if (e.key === ' ') running = !running
if (e.key === 'n' && !running) { step(); draw() }
if (e.key === 'c') { grid = grid.map((row) => row.map(() => false)); generation = 0; draw() }
if (e.key === 'r') { grid = grid.map((row) => row.map(() => Math.random() < 0.25)); generation = 0; draw() }
})
setInterval(() => { if (running) { step(); draw() } }, 150)
function step() {
const next = grid.map((row) => row.slice()) // forget this line and see the next section
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const n = countNeighbours(grid, x, y)
next[y][x] = n === 3 || (n === 2 && grid[y][x])
}
grid = next
generation++
}
```
Hidden state: `grid`, `running`, `generation`, and the timer's phase. Hidden randomness: `Math.random`. Hidden effects: `draw()` in four places. And one thing that is new: an event (`click`) that arrives with a *position*, in the coordinates of the page, which the code turns into a cell with `offsetX` (and which breaks as soon as the canvas is scaled by CSS or has a border; we will come back to that at the edge).
## The rules, precisely
The tests are shared fixtures, so the rules must be exact.
**The board** is `LW = 40` cells wide and `LH = 30` cells high; each cell is `LCELL = 16` pixels. Under the board the picture has a status strip `STRIP = 24` pixels high, so the canvas is 640 × 504 pixels. A cell is a point `{x, y}` with `0 <= x < 40` and `0 <= y < 30`.
**The state** is `{cells, running, seed, generation}`: the live cells, whether the clock makes generations, the seed for `Randomize`, and how many generations have been computed since the last `Clear` or `Randomize`. `cells` is **canonical**: sorted row-major (by `y`, then by `x`), each cell at most once, all on the board. A new world (`initialLife(seed)`) has no live cells, is paused, and is at generation 0.
**One generation** (`lifeStep(cells)`), the classic rules, called B3/S23:
- a **live** cell with **2 or 3** live neighbours stays alive;
- a **dead** cell with **exactly 3** live neighbours is born;
- every other cell is dead in the next generation.
The neighbours of a cell are the up to eight cells around it **that are on the board**. The edges are dead: nothing wraps around, so a cell in column 39 is not a neighbour of a cell in column 0. Every cell is judged by the **old** board only. The result is canonical again.
**Actions:**
- `Frame` (every `GEN_MS = 150` ms): when running, the next generation (`lifeStep`) and `generation + 1`; when paused, nothing.
- `Step` (key `n`/`N`): when paused, the next generation and `generation + 1`; when running, nothing.
- `TogglePause` (Space): `running` flips.
- `Toggle(cell)` (a click on that cell): a dead cell becomes alive and a live one dead, **whether the world is running or paused**. `cells` stays canonical; `generation` does not change. A cell off the board changes nothing.
- `Clear` (key `c`/`C`): no live cells, generation 0; `running` and `seed` stay.
- `Randomize` (key `r`/`R`): for every cell of the board in row-major order (row 0 from x = 0 to 39, then row 1, …), draw `intBelow(4)` from Module 4, threading the seed from `state.seed`; a cell is alive when its draw is 0 (so about one cell in four lives). The state gets those cells, the seed after the 1200th draw, and generation 0; `running` stays.
A generation on an empty running board still counts (`frame-on-an-empty-running-board-still-counts-the-generation`): an empty world ages like any other.
**Keys:** Space → `TogglePause`, `n`/`N` → `Step`, `c`/`C` → `Clear`, `r`/`R` → `Randomize`; everything else is ignored. **Clicks:** a click at canvas pixel `(x, y)` is `Toggle` of the cell `(floor(x / 16), floor(y / 16))` when that pixel is on the board; a click left of, above, right of or below the board (the status strip) is ignored.
**The picture** (`lifeView`), drawn in order, all text `'#ffffff'`, size 14:
- the board `Rect(0, 0, 640, 480, '#111111')`;
- one `Rect(x*16 + 1, y*16 + 1, 14, 14, '#7cb342')` per live cell, in the order of `cells`;
- the status strip `Rect(0, 480, 640, 24, '#222222')`;
- `Text(6, 497, 'Generation <n>', …, 'left')`;
- `Text(634, 497, 'Running: Space pauses' when running, else 'Paused: Space runs, N steps', …, 'right')`.
## Why the whole old board: the in-place loop
Look again at the sketch's `step`. Its first line copies the grid into `next`, the loop reads only `grid` and writes only `next`, and the last line swaps them. Here is the version that people write first, because it looks like the same thing with one variable fewer:
```js
// The classic bug: one grid, updated in place
function step() {
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const n = countNeighbours(grid, x, y)
grid[y][x] = n === 3 || (n === 2 && grid[y][x])
}
}
```
**Before reading on, run it by hand** on a horizontal blinker: live cells `(4,5)`, `(5,5)`, `(6,5)`. The right answer is the vertical blinker `(5,4)`, `(5,5)`, `(5,6)` (fixture `a-horizontal-blinker-turns-vertical`). What does the loop produce?
<details><summary>Think about it first, then open the trace</summary>
The loop visits row 4 before row 5, and within a row, left to right.
```
cell live neighbours it counts decision correct?
(5,4) (4,5) (5,5) (6,5) = 3 born yes
(6,4) (5,4)* (5,5) (6,5) = 3 born no: (5,4) was dead in the old board
(4,5) (5,4)* (5,5) = 2 stays alive no: it had 1 neighbour
(5,5) (5,4)* (6,4)* (4,5) (6,5) = 4 dies no: it had 2 and should stay
(6,5) (5,4)* (6,4)* = 2 stays alive no: (5,5) is already dead, and it had 1
(5,6) (4,5) (6,5) = 2 stays dead no: it had 3 and should be born
(* = a value written during this very loop)
```
The result is `(5,4)`, `(6,4)`, `(4,5)`, `(6,5)`: four cells, lopsided, and not a blinker at all. Each cell was judged by a board in which the cells above it and to its left were *already* the next generation and the others were still the old one. The answer even depends on the order of the loops: scan the columns from right to left and you get a different wrong picture.
</details>
The rules say "every cell is judged by the old board". The in-place loop breaks that sentence, and nothing warns you: it runs, it draws something plausible, and still-lifes like the block even come out right, so a quick test passes.
The stateful fix is discipline: keep two grids, read one, write the other, swap. In the pure version there is nothing to remember, because **you cannot write the in-place loop at all**. `cells` is an immutable value; `lifeStep(cells)` can only *read* it and *return* a new list. There is no "half-updated board" anywhere, because the old board never changes while the new one is built. What the stateful programmer must remember to do (copy first, write to the copy) is the only thing the pure version *can* do.
> **From everyday programming:** this is the reason spreadsheets do not recalculate cells in the order you typed them, and why a database transaction reads a consistent snapshot ("snapshot isolation") instead of rows that other writers are changing underneath it. Image filters have the same trap: a blur that writes each blurred pixel back into the image it is still reading smears in one direction. The fix is always the same: read from a version that does not change while you write the next one.
## Applying the method
Open [method.md](method.md) next to this page. Each step names the step of the method, asks you questions to answer **before** reading the course's choice, and then gives the canonical choice (fixed, because the fixtures depend on it). Write your own answer first, then compare.
### Step 1 — Identify the state; make it one value
List every `let` of the sketch. Then ask:
- The sketch stores a 40 × 30 grid of booleans. What else could store "which cells are alive"? Think of at least two other representations and what each makes easy or hard (counting neighbours, toggling one cell, comparing two boards in a test, writing a fixture).
- If two states describe the same live cells, should they be *equal* in a test? What does that demand of your representation?
- Is `generation` state, or could it be computed?
- Where must the randomness live so that `Randomize` is a pure function?
**Canonical choice:** the live cells as a **list of points, sorted row-major, without duplicates**.
**JavaScript** (`js/src/life/model.js`)
```js
// LifeState = { cells: Point[] (canonical), running: boolean, seed: number, generation: number }
// constants: LW = 40, LH = 30, LCELL = 16, STRIP = 24 (given)
```
**Scala** (`scala/src/life/Model.scala`, given; package `snake.life`)
```scala
final case class LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)
object LifeModel:
val LW = 40
val LH = 30
val LCELL = 16
val STRIP = 24
def initialLife(seed: Long): LifeState = ???
```
**F#** (`fsharp/src/Life/Model.fs`, module `Snake.Life.Model`, the record given)
```fsharp
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
// LW = 40, LH = 30, LCELL = 16, STRIP = 24 (given)
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
```
Why a sorted list and not a grid or a set? A grid of 1200 booleans makes every fixture 1200 values long, and a glider would be hard to see in it. A set says exactly what we mean ("these cells are alive"), but sets are printed and compared differently in the three languages, and the order in which you walk through one is not the same in JavaScript, Scala and F#. A list in one agreed order has neither problem: two states with the same live cells are *equal* (`deepStrictEqual`, `==`, `=`) without any special comparison, a fixture is a short readable list, and the three tracks agree on every byte. The price is a rule every function must keep: **whatever goes in canonical comes out canonical.** That rule is a small version of something databases do all the time (a primary key that keeps rows in order), and it is tested: every fixture compares lists, so a result in the wrong order fails.
Nothing stops `lifeStep` from building a set *inside* itself for fast lookups (step 3). Canonical is a promise about the values that cross function boundaries and land in fixtures, not about how a function works inside.
### Step 2 — Identify the events; make them data
What can happen to the world? List the sketch's callbacks. Then decide: should a click be an action `Click(x, y)` in pixels, or something else? Where should "which cell is under the mouse" be worked out?
**Canonical choice:** six actions. A click becomes `Toggle(cell)`, a *meaning*, as keys became `Turn(dir)` in Snake.
**JavaScript**
```js
// LifeAction = {tag:'Frame'} | {tag:'Toggle', cell} | {tag:'TogglePause'} | {tag:'Step'} | {tag:'Clear'} | {tag:'Randomize'}
// constructors: LifeAction.Toggle(cell), and the plain values LifeAction.Frame, .TogglePause, .Step, .Clear, .Randomize
```
**Scala** (given)
```scala
enum LifeAction:
case Frame
case Toggle(cell: Point)
case TogglePause, Step, Clear, Randomize
```
**F#** (given)
```fsharp
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
```
(`[<RequireQualifiedAccess>]` for the reason Module 11 gives: Snake's `Action` also has `Frame` and `TogglePause`.)
The translation from pixels to a cell is a pure function, `lifeClick(x, y)`, next to `lifeKey(key)`. The update never sees a pixel; the input stream never sees a cell. Each side speaks its own language, and one small, fixture-tested function translates.
Question: why is `Toggle` better than separate `Birth(cell)` and `Kill(cell)` actions for a click? When would the second pair be the better design?
### Step 3 — Write `update` as a fold
`lifeUpdate(state, action)` is your Life `update`, and `lifeStep(cells)` is the heart of it. Questions to answer before writing code:
- How will you count the live neighbours of a cell? How many operations does your way take per generation, for an empty board and for a full one? Is that fast enough to stay well under 150 ms?
- How do you produce the next generation **in canonical order** without sorting? (Hint: which list of cells is already in row-major order, before you know anything about the board?)
- `Toggle` must insert a cell at its row-major place, or remove it. How do you do that with `filter` alone?
- `Randomize` needs 1200 draws in a fixed order, each with the seed the previous one left behind. Which tool from Module 4 (or from Module 12, if you did it) threads a seed through a list of draws?
- Should clicking toggle cells only while paused, or always?
**The canonical choices:**
- **Counting.** Two ways are fast enough, and both are pure. The straightforward one: for each of the 1200 board cells, count the live cells in the list that are one step away in both directions. That is 1200 × (number of live cells) comparisons, at most about 1.4 million when the board is full, which every language in the course does in a few milliseconds. The faster one: first build a **lookup set** of the old live cells (a `Set` of numbers `y * LW + x` in JS, because a JS `Set` of objects compares them by identity; a `Set[Point]` in Scala; a `Set<Point>` in F#), then ask it about each of the eight neighbours: 1200 × 8 lookups, however many cells live. The reference solution does the second. The set is built once per generation from the old list and only read afterwards, so it is as immutable as everything else.
- **Order for free.** Make a constant list of all 1200 board cells, row-major, once. The next generation is that list *filtered* by the rules. A filter keeps order, so the result is canonical without a sort.
- **Toggle.** If the cell is in the list, filter it out. Otherwise the new list is "the cells before it", the cell, "the cells after it", where before and after compare `y * LW + x`. Off the board, nothing changes.
- **Randomize.** A fold over the 1200 board cells that carries the seed and collects the draws (or `listOf(intBelow(4), 1200)` if you built it in Module 12), then the board cells whose draw is 0. The order of the draws is part of the contract: it is the only way three languages can produce the same random board from the same seed, and the test `Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0` checks it against Module 4's generator.
- **Clicks while running.** The course's choice is: **always**. A player who clicks a running world and sees nothing happen thinks the program is broken; drawing into a running world is part of the fun of Life; and it is perfectly well defined here, because a click is one action *between* two generations, never in the middle of one (the fold applies one action at a time). In the stateful sketch the same question has a subtler answer, since the click handler and the timer callback both write to the same `grid`. The other policy, "only while paused", is a one-line guard in `update`; question 3 asks you to compare.
### Step 4 — Write the view as data
`lifeView(state)` returns a `Scene`, exactly as specified in [the rules](#the-rules-precisely). Because `cells` is canonical, the order of the cell rectangles is fixed too, and the view fixture can compare the scene as a list.
### Step 5 — Effects as values
Which effects does Life need? Drawing, and a seed at the start, which `randomSeed` already describes; `Randomize` itself is pure. So: **no new `Op`**.
But what about the click? Ask yourself which direction it travels. An `Op` is something the program asks the world to do, or to tell it *when asked* (a seed, the stored best score). A click is the world telling the program something *whenever it likes*, like a key press or the clock. That is not an effect the program performs; it is an **input** the program receives. Inputs do not go into `IO`. They go into the stream, and they enter through the runtime. That is steps 6 and 7.
### Step 6 — Wire the streams, with a fourth kind of input
The stream inputs get a fourth case, and the stream module a fourth source:
**JavaScript**
```js
// input: { tag: 'Click', x, y } x, y: canvas pixels, whole numbers
// source: clicks emits {x, y} for every Click input and ignores everything else
```
**Scala** (`scala/src/Stream.scala`, given)
```scala
enum Event:
case Time(t: Long)
case KeyDown(key: String)
case KeyUp(key: String)
case Click(x: Int, y: Int)
// object Stream:
// val clicks: Stream[(Int, Int)]
```
**F#** (`fsharp/src/Stream.fs`, given)
```fsharp
type Event =
| Time of int64
| KeyDown of string
| KeyUp of string
| Click of int * int
// let clicks : Stream<int * int>
```
`clicks` is **already in your stream file**: it was given there from the start, as one more source next to the two you wrote in Module 8, and it is built exactly like `keyDowns`. Read it. Then answer: why do `keyDowns`, `keyUps`, `interval` and `clampTime` not need a single change to cope with a new kind of input? (Each source looks at the tag of the input and ignores what it does not know; `clampTime` passes everything that is not `Time` straight through. That is why Snake and Pong cannot even tell that clicks exist: test `Snake and Pong ignore clicks: their actions and drawings are the same with and without them`.)
Then wire Life like Snake and Pong:
- `lifeActions`: `Frame` every `GEN_MS = 150` ms (from `interval`), merged with `lifeKey` of every key press, merged with `lifeClick` of every click (from `clicks`), in that order;
- `lifeStates(seed, actions)`: start from `initialLife(seed)`, fold with `lifeUpdate`, emit the initial state first;
- `lifeMain`: ask for a seed, turn the stream of states into a stream of drawings of `lifeView`, and wrap it in `clampTime(…, MAX_GAP_MS)`.
### Step 7 — The edge: this time you change it
In Snake and Pong this step said "nothing to write". Now the edge must learn about the mouse, and it is the first time since Module 9 that you change its *shape* (the high score only added two operations). Two changes, both small:
1. **The runtime** registers a fourth callback. In `runApp`, next to the key and clock registrations:
```js
platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
```
In Scala `p.onClick((x, y) => feed(Event.Click(x, y)))`, in F# `p.OnClick(fun x y -> feed (Click(x, y)))`. The runtime still knows nothing about any game: it turns a click into an input and feeds it, as it does with keys.
2. **The platform** gets a method `onClick(cb)`, which calls `cb(x, y)` with canvas pixels.
- **JavaScript:** `browserPlatform` is yours, from Module 9. Add `onClick` to it: listen for `'click'` on the **canvas** (not on the window, or a click anywhere on the page would toggle a cell), and convert the event's position with `canvasPoint(canvas, e.clientX, e.clientY)`, a helper that is given at the bottom of `js/src/browser.js`. Read it: the page may show the canvas larger or smaller than its `width` and `height` (CSS, zoom) and draws a 2-pixel border round it, so the window coordinates of the mouse must be made relative to the drawing area and then scaled. That is the bug the sketch's `offsetX` had. Add the listener inside `onClick`, not when the platform is built.
- **Scala and F#:** the `Platform` type in your runtime file already declares `onClick` (Scala `def onClick(cb: (Int, Int) => Unit): Unit`) / `OnClick: (int -> int -> unit) -> unit` (F#), given and unused until now, and the browser glue in `scala/web/` and `fsharp/web/` (given) implements it. Your only change at the edge is the registration in `runApp`.
Why do the Snake and Pong pages keep working? Their platform now has one more method, the runtime calls it once at start, and clicks flow into streams that ignore them. A runtime from Module 9 that does not register clicks also keeps working for Snake and Pong; it just never feeds a click, so Life does not react to the mouse until you make this change. The tests for this step say exactly that: `runApp registers a click handler with the platform`, `runApp feeds clicks to the stream`, and (JS) `browserPlatform listens for clicks on the canvas, not before onClick is called` and `browserPlatform converts a click to canvas pixels, through the border and the CSS scaling`.
Compare this with what a click costs in the stateful sketch: there, the click handler is also where the game logic lives (`grid[y][x] = !grid[y][x]; draw()`). Here, the edge gained four lines that know nothing about Life, and Life gained one pure function, `lifeClick`, tested without a browser.
The entry file is given: `js/src/life/main.js` is `runApp(lifeMain, browserPlatform(document.getElementById('game'), window))`, and the page is `js/life.html`. Scala and F# have the same entry points in their web glue, built by `build-web.sh`, with the pages `scala/life.html` and `fsharp/life.html`.
## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, in each track's vocabulary ("events" instead of "inputs", `None` instead of `null`). To read all titles, open `scala/test/M13Life.test.scala` or `fsharp/tests/M13Life.fs`. Fixture-based tests have the same case names in every track.
### Exercise 13.1 — model
**JavaScript** (`js/src/life/model.js`; the constants are given)
```js
export const initialLife = (seed) => /* LifeState */
export const LifeAction = { Toggle: (cell) => ..., Frame: ..., TogglePause: ..., Step: ..., Clear: ..., Randomize: ... }
```
**Scala** (`scala/src/life/Model.scala`)
```scala
object LifeModel:
def initialLife(seed: Long): LifeState = ???
```
**F#** (`fsharp/src/Life/Model.fs`)
```fsharp
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
```
**Proved by:** `the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below`, `initialLife is an empty, paused world at generation 0 with the given seed`, `LifeAction builds Toggle with a cell, and has the other actions as plain values` (JS only).
### Exercise 13.2 — rules
**JavaScript** (`js/src/life/rules.js`)
```js
export const lifeStep = (cells) => /* Point[], canonical */
export const lifeUpdate = (state, action) => /* LifeState */
```
**Scala** (`scala/src/life/Rules.scala`)
```scala
object LifeRules:
def lifeStep(cells: List[Point]): List[Point] = ???
def lifeUpdate(state: LifeState, action: LifeAction): LifeState = ???
```
**F#** (`fsharp/src/Life/Rules.fs`)
```fsharp
let lifeStep (cells: Point list) : Point list = failwith "TODO m13"
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState = failwith "TODO m13"
```
**Proved by:** `fixtures/m13-life-step.json` (`an-empty-board-stays-empty`, `a-lonely-cell-dies`, `two-cells-die-of-loneliness`, `a-block-is-stable`, `a-horizontal-blinker-turns-vertical`, `a-vertical-blinker-turns-horizontal`, `three-cells-in-an-l-give-birth-to-a-block`, `a-cell-with-four-neighbours-dies-of-overcrowding`, `a-glider-moves-and-the-result-is-sorted-row-major`, `a-block-in-the-corner-is-stable`, `a-block-in-the-far-corner-is-stable`, `a-blinker-on-the-top-edge-cannot-grow-beyond-it`, `a-blinker-on-the-right-edge-does-not-wrap-to-the-left-edge`, `cells-on-opposite-edges-are-not-neighbours`, `a-blinker-on-the-bottom-edge-cannot-grow-beyond-it`); `fixtures/m13-life-update.json` (20 cases: `frame-…`, `step-…`, `toggle-pause-…`, the `toggle-…` cases including `toggle-adds-a-cell-between-two-on-the-same-row`, `toggle-works-while-running-too` and `toggle-off-the-board-is-ignored`, `clear-empties-the-board-and-restarts-the-count`, `randomize-fills-the-board-from-the-seed-and-advances-it`, `randomize-replaces-the-cells-restarts-the-count-and-keeps-running`); and the tests `lifeUpdate does not modify the state it is given`, `a glider moves one cell diagonally every four generations`, `the next generation is computed from the whole old board: a blinker blinks forever`, `Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0`, `every generation of a random board stays sorted row-major, on the board and without duplicates`, `lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms`.
**Strategy:** `lifeStep` first, case by case: the empty board, then the lonely cell, then the block, then the blinkers (if the blinker fails while the block passes, read [the in-place section](#why-the-whole-old-board-the-in-place-loop) again), then the edges. Then `lifeUpdate`, one action at a time.
**Watch out for:** a neighbour list that includes the cell itself (then a live cell counts itself, and the block "dies of overcrowding"); off-board neighbours that wrap round (`cells-on-opposite-edges-are-not-neighbours`); a result in the order the cells were *found* instead of row-major; and, in Scala and F#, a seed that changes type on the way through the 1200 draws.
### Exercise 13.3 — view
**JavaScript** (`js/src/life/view.js`)
```js
export const lifeView = (state) => /* Scene */
```
**Scala** (`scala/src/life/View.scala`)
```scala
object LifeView:
def lifeView(state: LifeState): Scene = ???
```
**F#** (`fsharp/src/Life/View.fs`)
```fsharp
let lifeView (state: LifeState) : Scene = failwith "TODO m13"
```
**Proved by:** `fixtures/m13-life-view.json` (`a-new-world-is-an-empty-paused-board`, `live-cells-in-row-major-order-while-running`, `paused-after-some-generations`).
### Exercise 13.4 — keys, clicks and wiring
**JavaScript** (`js/src/life/app.js`)
```js
export const GEN_MS = 150
export const lifeKey = (key) => /* LifeAction or null */
export const lifeClick = (x, y) => /* LifeAction (a Toggle) or null */
export const lifeActions = /* Stream<LifeAction> */
export const lifeStates = (seed, actions) => /* Stream<LifeState> */
export const lifeMain = /* IO<Stream<IO<undefined>>> */
```
**Scala** (`scala/src/life/App.scala`)
```scala
object LifeApp:
val GEN_MS: Long = 150
def lifeKey(key: String): Option[LifeAction] = ???
def lifeClick(x: Int, y: Int): Option[LifeAction] = ???
lazy val lifeActions: Stream[LifeAction] = ???
def lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState] = ???
lazy val lifeMain: IO[Stream[IO[Unit]]] = ???
```
**F#** (`fsharp/src/Life/App.fs`)
```fsharp
let GEN_MS = 150L
let lifeKey (key: string) : LifeAction option = failwith "TODO m13"
let lifeClick (x: int) (y: int) : LifeAction option = failwith "TODO m13"
let lifeActions : Stream<LifeAction> = (* ... *)
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> = failwith "TODO m13"
let lifeMain () : IO<Stream<IO<unit>>> = failwith "TODO m13"
```
**Proved by:** `fixtures/m13-life-key.json` (12 cases, with the `constructor`/`toString` traps); `fixtures/m13-life-click.json` (`the-top-left-pixel-is-cell-0-0`, `the-last-pixel-of-the-first-cell`, `the-next-pixel-is-the-next-cell`, `the-row-below`, `a-pixel-in-the-middle`, `the-bottom-right-pixel-of-the-board`, `right-of-the-board-is-nothing`, `the-status-strip-under-the-board-is-nothing`, `left-of-the-board-is-nothing`, `above-the-board-is-nothing`); `clicks emits the position of every Click input and ignores everything else`, `the other sources ignore clicks`, `a generation every 150 ms`, `lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles`, `lifeStates starts with the new world, then yields one state per action`, `lifeMain asks for a seed, draws the empty world first, and then a click as a live cell`, `lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds`, `Snake and Pong ignore clicks: their actions and drawings are the same with and without them`.
**Watch out for:** in Scala and F#, integer division rounds *towards zero*, so `-1 / 16` is `0`, not `-1`: a click one pixel left of the board would toggle cell 0. Check that the pixel is on the board before you divide (`left-of-the-board-is-nothing`).
### Exercise 13.5 — the click at the edge
Make the two changes of step 7: the fourth registration in `runApp` (all tracks) and, in JS, `onClick` in `browserPlatform`.
**JavaScript** (`js/src/runtime.js`, `js/src/browser.js`)
```js
// runtime.js, in runApp: platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
// browser.js, in the platform object:
// onClick: (cb) => canvas.addEventListener('click', (e) => { /* canvasPoint(canvas, e.clientX, e.clientY), then cb(x, y) */ })
```
**Scala** (`scala/src/Runtime.scala`)
```scala
// trait Platform (given): def onClick(cb: (Int, Int) => Unit): Unit
// in runApp: p.onClick((x, y) => feed(Event.Click(x, y)))
```
**F#** (`fsharp/src/Runtime.fs`)
```fsharp
// type Platform (given): OnClick: (int -> int -> unit) -> unit
// in runApp: p.OnClick(fun x y -> feed (Click(x, y)))
```
**Proved by:** `runApp registers a click handler with the platform`, `runApp feeds clicks to the stream`, `runApp runs Life: a click on the canvas draws the world with that cell alive`; JS only: `browserPlatform listens for clicks on the canvas, not before onClick is called`, `browserPlatform converts a click to canvas pixels, through the border and the CSS scaling`. All tests of Modules 9–12 must still pass.
**Run and play:**
- JS: `cd js && node --test "test/m13-*.test.js"`, then `./test.sh`; from the repository root `node scripts/serve.mjs`, and open <http://localhost:8000/js/life.html>
- Scala: `cd scala && ./test.sh M13`, then `./test.sh`; `./build-web.sh`, serve from the repository root, open <http://localhost:8000/scala/life.html>
- F#: `cd fsharp && ./test.sh M13`, then `./test.sh`; `./build-web.sh`, serve from the repository root, open <http://localhost:8000/fsharp/life.html>
Draw a glider near the top left (three cells in a row, one cell above the right one, and one cell two rows up above the middle one), press Space and watch it walk down to the bottom edge, where it turns into a block. Press R, then Space, and watch a random world settle down. Then open Snake and Pong again and check that they still play.
## Hints
As in Module 11, the hints are deliberately lighter: a nudge and a stronger hint, no skeleton. Look at your Snake and Pong code first.
<details><summary>Exercise 13.2 — nudge</summary>
Build `lifeStep` from three small functions: `onBoard(p)`, `neighbours(p)` (the eight offsets, moved to `p`, filtered by `onBoard`) and `count(p)` (how many neighbours are alive in the *old* list). Then filter a list of all board cells. For `lifeUpdate`, a flat `switch` on the action, one small function per action, as in `pongUpdate`.
</details>
<details><summary>Exercise 13.2 — stronger hint</summary>
- All board cells, row-major: `Array.from({length: LW * LH}, (_, i) => ({x: i % LW, y: Math.floor(i / LW)}))` (Scala: `for y <- 0 until LH; x <- 0 until LW yield Point(x, y)`, F#: `[ for y in 0 .. LH - 1 do for x in 0 .. LW - 1 -> { X = x; Y = y } ]`). Filter it with "`n === 3 || (n === 2 && alive(p))`".
- `alive(p)`: either `cells.some(...)` (fine), or a set built once at the start of `lifeStep` from the old cells (faster). In JS, put numbers `p.y * LW + p.x` in the set, not points.
- `Toggle`: compare cells by that same number `p.y * LW + p.x`: `[...before, cell, ...after]`.
- `Randomize`: a `reduce` over the board cells whose accumulator is `[draws, seed]`; each step runs `intBelow(4)` on the seed it carries. Then keep the board cells whose draw is 0.
</details>
<details><summary>Exercise 13.4 — nudge</summary>
`lifeKey` is `keyToAction` with other keys. `lifeClick`: two divisions and a check. `lifeActions` is `pongActions` with `clicks` in the place of `keyUps`; `lifeMain` is `pongMain` with other names.
</details>
<details><summary>Exercise 13.4 — stronger hint</summary>
`lifeClick(x, y)`: return `null` unless `0 <= x < LW * LCELL` and `0 <= y < LH * LCELL`; otherwise `Toggle({x: floor(x / LCELL), y: floor(y / LCELL)})`. The clicks source emits `{x, y}`, so the third branch of the merge is `filterMap(clicks, (p) => lifeClick(p.x, p.y))`.
</details>
<details><summary>Exercise 13.5 — nudge</summary>
The runtime change is one line, a copy of the `onKeyDown` registration. In `browserPlatform`, the listener goes on `canvas`, and the handler does two things: convert, then call `cb`.
</details>
<details><summary>Exercise 13.5 — stronger hint</summary>
`onClick: (cb) => canvas.addEventListener('click', (e) => { const p = canvasPoint(canvas, e.clientX, e.clientY); cb(p.x, p.y) })`. If the test "not before onClick is called" fails, you added the listener while building the platform object instead of inside `onClick`.
</details>
## Check your understanding
1. Apply steps 0–2 of the method to the stateful sketch: its hidden state (including time and randomness), its events and its effects.
2. Run the in-place loop by hand on a 2 × 2 block. Why does it give the right answer there, and what does that tell you about testing a simulation with still-lifes only?
3. The course lets clicks toggle cells while the world runs. Write the one-line change for the other policy (only while paused). Which fixture would fail, and which new one would you write? Which policy do you prefer, and why?
4. `cells` is a sorted list, not a set. Name two things that would get harder in the tests if each track used its own language's set type in the state, and one thing that would get easier in the code.
5. Your `lifeStep` builds a set inside itself. Is `lifeStep` still pure? Is the set "state"? Argue from what a caller can observe.
6. Estimate the work per generation of the "scan the list for each board cell" method on a quarter-full board, and compare it with the lookup set. At which board size would the first stop being fast enough at 150 ms?
7. Why is a click an *input* (an event in the stream) and not an `Op`? Give an example of a mouse feature that *would* need a new `Op`.
8. List every file the click changed, in your track. Which of them are pure? Why did no Snake or Pong file change, and which test proves that their behaviour did not change either?
9. A runtime from Module 9 never registers `onClick`. Why do the Module 9–12 tests still pass with it, and which Module 13 tests fail?
10. `canvasPoint` subtracts the border and scales by `width / clientWidth`. What goes wrong with `e.offsetX` alone when the page shows the canvas at twice its size? Which test would catch it?
11. `Randomize` fixes the order of 1200 draws. What would go wrong between the three tracks if each one drew in the order its own data structure happened to iterate?
12. Design wrap-around edges (a torus) as an option: which function changes, which fixtures would you add, and does anything outside `lifeStep` need to know?
## In production
- **Double buffering** is the stateful world's fix for the in-place bug: two buffers, read one, write the other, swap. Graphics cards do the same with the picture itself so that the screen never shows a half-drawn frame ([Game Programming Patterns: Double Buffer](https://gameprogrammingpatterns.com/double-buffer.html)). Immutable data gives you the same guarantee without the swap.
- **Hashlife** computes Life on enormous patterns by caching the next generation of every square block it has seen, which only works because the step is a pure function of the old board: the same block always has the same future ([Hashlife on Wikipedia](https://en.wikipedia.org/wiki/Hashlife); the free Life program Golly uses it).
- **Hit testing.** Every UI toolkit has one place that turns a raw pointer position into "what was hit", and the rest of the program only sees the answer: a button, a list row, a cell. `canvasPoint` plus `lifeClick` is that split in miniature: the edge converts window coordinates to canvas pixels, a pure function converts pixels to a cell ([MDN: `MouseEvent.clientX`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX)).
## Further reading
- [The method](method.md) — the recipe, now applied three times.
- [Martin Gardner, "The fantastic combinations of John Conway's new solitaire game 'life'"](https://web.stanford.edu/class/sts145/Library/life.pdf) — the 1970 column that introduced Life.
- [Glider (Wikipedia)](https://en.wikipedia.org/wiki/Glider_%28Conway%27s_Game_of_Life%29) — the pattern you drew, why it moves, and why it matters to the theory of Life.
- [Robert Nystrom, Game Programming Patterns: Double Buffer](https://gameprogrammingpatterns.com/double-buffer.html) — the in-place bug from the stateful side, and the two-buffer discipline that avoids it.
- [MDN: `Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) — what `canvasPoint` measures, and why CSS size and canvas size differ.
+7 -6
View File
@@ -1,6 +1,6 @@
# The method: from a stateful game to a functional one
> **When to read this:** after Module 9 (you need `IO`, streams and the runtime), and before Module 11, which asks you to apply it to Pong on your own. Come back to it whenever you convert something.
> **When to read this:** after Module 9 (you need `IO`, streams and the runtime), and before Module 11, which asks you to apply it to Pong on your own (Module 13 applies it once more, to Conway's Life). Come back to it whenever you convert something.
> **What it is:** the course, compressed into a recipe you can carry to other programs — with a small worked example, a checklist and a list of the mistakes people make most often.
## The recipe in one page
@@ -338,12 +338,16 @@ Use this when you convert anything. Each line is a yes/no question about your re
**Mutation in loops.** `for (…) result.push(…)`, `board[y][x] = …`, `list.sort()` (which sorts in place). It's tempting when the rule is naturally "go through all the bricks and remove the hit ones". *Fix:* `map`/`filter`/`reduce`, building new arrays; `[...xs].sort()` or a non-mutating `toSorted()` if you must sort; for grids, a list of rows rebuilt with `map`.
**Effects in update.** A `console.log` for debugging that stays, a `localStorage.setItem` "just when the game ends", a sound played on eating. Each one makes `update` untestable and order-dependent. *Fix:* return data. For a sound or a save, add an `Op` and produce the `IO` from the state stream (see Module 10's high-score thought exercise). For debugging, use `history` and `trace` instead of logging.
**Effects in update.** A `console.log` for debugging that stays, a `localStorage.setItem` "just when the game ends", a sound played on eating. Each one makes `update` untestable and order-dependent. *Fix:* return data. For a sound or a save, add an `Op` and produce the `IO` from the state stream (Module 10 builds one: the persisted high score of section 8, Exercise 10.7). For debugging, use `history` and `trace` instead of logging.
**State in the runtime.** "I'll just keep `upHeld` in the runtime and pass it in." Then the runtime knows about the game and half the behaviour can't be replayed. *Fix:* the runtime holds only the current stream; held keys are state derived from `Press`/`Release` actions (Module 11).
**Changing the clock instead of counting.** `clearInterval`/`setInterval` to speed up. *Fix:* one fixed clock; decide in `update` which frames matter (Module 10).
**Counting the wrong thing.** "Step when the total frame count is a multiple of the interval" works until the interval changes; then one step is cut short (Module 10, Puzzle 3). *Fix:* store what the rule talks about, here the frames since the last step, and state the requirement as a property you can test (Module 12).
**Trusting the clock to be regular.** A hidden browser tab, a laptop that sleeps or a debugger breakpoint makes the next `Time` event arrive seconds late, and a stream that faithfully catches up plays those seconds in one go. *Fix:* decide how much time the program accepts at once and clamp in pure code (`clampTime`, Module 10).
**Forgetting to wrap the next stream.** A combinator that returns the inner `next` instead of `combinator(next, …)` works for exactly one event. *Fix:* every combinator's next stream is the same combinator around the inner next stream(s) (Module 8).
**Left-nested `flatMap` chains.** Building a long sequence with a left fold overflows the stack in the interpreter's continuations. *Fix:* fold right (Module 7, section 12).
@@ -362,10 +366,7 @@ Each of these is a good exercise in the method. Start from the inventory (step 0
- *Events:* `Press`/`Release` (left/right), `Frame`, `Launch`, `Restart`.
- *Hints:* reuse Pong's held-key pattern and its step-by-step frame pipeline. Represent bricks as a list (or a set) of positions; a collision step returns the remaining bricks and the bounced velocity together. Decide, and write a fixture for, what happens when the ball hits two bricks in one frame.
**Conway's Game of Life** ([Wikipedia](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)).
- *State:* the set of live cells (a list of points, or a set of `"x,y"` strings in JS), generation, paused flag, speed.
- *Events:* `Frame`, `TogglePause`, `Step` (one generation while paused), `Clear`, `Randomize` (needs a seed), and — for editing — clicking a cell.
- *Hints:* `step(cells)` is the purest function you'll ever write: count neighbours with `map`/`filter`, keep cells with 2–3 neighbours, birth cells with exactly 3. Clicking needs a new input type and a new `Platform` method (`onClick(cb: (x, y) => void)`): that's a change to the stream inputs, the runtime and the browser platform, but *not* to the game logic. Doing it will teach you exactly where the edge is.
**Conway's Game of Life** is now built: it is the second capstone, [Module 13](13-capstone-life.md). It adds the one thing the other games did not need, a new kind of input (mouse clicks), and with it a change to the stream inputs, the runtime and the browser platform, but not to any game. If you have done Module 13, try this extension of it: let the player drag to draw several cells in one gesture (`MouseDown`, `MouseMove`, `MouseUp` inputs, and a "drawing" flag in the state, like Pong's held keys).
## Further reading
+111 -9
View File
@@ -44,7 +44,7 @@ F#'s `pure` triggers warning FS0046 ("reserved for future use"); the project fil
| 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 }` |
| game state | `{width, height, snake, dir, nextDir, apple, seed, score, status, frame, queued}` (`queued`: a direction or `null`) | `GameState(width, height, snake: List[Point], dir, nextDir, apple: Option[Point], seed: Long, score, status, frame, queued: Option[Direction])` | `{ Width; Height; Snake: Point list; Dir; NextDir; Apple: Point option; Seed: uint32; Score; Status; Frame; Queued: Direction option }` |
| 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.
@@ -72,6 +72,19 @@ The snake is a list of points, **head first**. `apple` is `null` / `None` when t
In F#, do every `*` and `+` of the generator through `uint64` helpers so it also works in the browser (Module 4, "F# in the browser").
Optional (Exercise 4.7): the same pattern for any state type `S`. Nothing in the game uses it.
| Concept | JavaScript (`js/src/state.js`) | Scala (`scala/src/State.scala`) | F# (`fsharp/src/State.fs`) |
|---|---|---|---|
| the type | a function `s => [value, nextS]` | `final case class State[S, A](run: S => (A, S))` | `type State<'s, 'a> = State of ('s -> 'a * 's)` |
| run it | `run(st, s)` → `[a, s2]` | `st.run(s)` → `(a, s2)` (the field) | `State.run s st` → `(a, s2)` |
| constant | `pure(a)` | `State.pure(a)` | `State.pure a` |
| transform the value | `map(st, f)` | `st.map(f)` | `State.map f st` |
| chain | `flatMap(st, f)` | `st.flatMap(f)` | `State.bind f st` |
| read the state | `get` (a value) | `State.get[S]: State[S, S]` | `State.get () : State<'s, 's>` |
| replace the state | `put(s)` (value `undefined`) | `State.put(s): State[S, Unit]` | `State.put s : State<'s, unit>` |
| change the state | `modify(f)` (value `undefined`) | `State.modify(f: S => S): State[S, Unit]` | `State.modify (f: 's -> 's) : State<'s, unit>` |
| 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` |
@@ -87,6 +100,7 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| 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` |
| turn with the queue (m10) | `steer(state, dir)` | `Game.steer(state: GameState, dir: Direction): GameState` | `Game.steer (state: GameState) (dir: Direction) : GameState` |
## Module 6 — view
@@ -102,8 +116,8 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| 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` |
| operations | `{tag: 'Draw', shape}`, `{tag: 'RandomSeed'}`; m10: `{tag: 'LoadBest'}`, `{tag: 'SaveBest', score}` | `enum Op`: `Op.Draw(shape)`, `Op.RandomSeed`; m10: `Op.LoadBest`, `Op.SaveBest(score: Int)` | `type Op = Draw of Shape \| RandomSeed`; m10: `\| LoadBest \| SaveBest of int` |
| answers to operations | plain values: `undefined` for a draw or a save, a number for a seed or a best score | `enum OpResult`: `OpResult.Done`, `OpResult.Seed(value: Long)`; m10: `OpResult.Best(value: Int)` | `type OpResult = Done \| Seed of uint32`; m10: `\| Best of int` |
| 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<OpResult>` |
@@ -113,16 +127,36 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| draw one shape | `draw(shape)` | `IO.draw(shape): IO[Unit]` | `IO.draw shape : IO<unit>` |
| ask for a seed | `randomSeed` (a value) | `IO.randomSeed: IO[Long]` (a value) | `IO.randomSeed () : IO<uint32>` (a function of unit) |
| draw a scene | `drawScene(scene)` | `IO.drawScene(scene): IO[Unit]` | `IO.drawScene scene : IO<unit>` |
| 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 }` |
| load the best score (m10) | `loadBest` (a value) | `IO.loadBest: IO[Int]` (a value) | `IO.loadBest () : IO<int>` (a function of unit) |
| save the best score (m10) | `saveBest(score)` | `IO.saveBest(score: Int): IO[Unit]` | `IO.saveBest (score: int) : IO<unit>` |
| pure test interpreter | `trace(io, seed)` → `{result, ops}`; m10: `trace(io, seed, best = 0)` answers `LoadBest` with `best` | `IO.trace(io, seed): Traced[A]`, `Traced(result, ops)`; m10: `IO.trace(io, seed, best: Int = 0)` | `IO.trace seed io : Traced<'a>`, `{ Result; Ops }`; m10: `IO.traceWith (seed: uint32) (best: int) io`, with `trace seed io = traceWith seed 0 io` |
## Module 7b — stack-safe IO (optional)
Uses the `Op`, `OpResult` and `Traced` of Module 7; independent of the `IO` type itself.
| Concept | JavaScript (`js/src/safeio.js`) | Scala (`scala/src/SafeIO.scala`) | F# (`fsharp/src/SafeIO.fs`, module `Snake.SafeIO`) |
|---|---|---|---|
| the type | `{tag: 'Pure', value}`, `{tag: 'Effect', op, k}` or `{tag: 'FlatMap', io, f}` | `enum SafeIO[+A]`: `Pure(a: A)`, `Effect(op: Op, k: OpResult => SafeIO[A])`, `FlatMap[X, B](io: SafeIO[X], f: X => SafeIO[B]) extends SafeIO[B]` | `type SafeIO<'a> = Pure of 'a \| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>) \| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)` |
| finished program | `pure(value)` | `SafeIO.pure(a)` | `SafeIO.pure a` |
| one operation | `effect(op)` | `SafeIO.effect(op): SafeIO[OpResult]` | `SafeIO.effect op : SafeIO<IO.OpResult>` |
| chain (only builds a node) | `flatMap(io, f)` | `io.flatMap(f)` | `SafeIO.bind f io` (via the given `erase`, private) |
| transform the result | `map(io, f)` | `io.map(f)` | `SafeIO.map f io` |
| run in order | `sequence(ios)` | `SafeIO.sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit]` | `SafeIO.sequence (ios: SafeIO<unit> list) : SafeIO<unit>` |
| one rewrite (re-association) | `step(io)` | `SafeIO.step(io: SafeIO[A]): SafeIO[A]` | `SafeIO.step (io: SafeIO<'a>) : SafeIO<'a>` |
| loop-free driver (given) | `drive(start, isDone, next)` → `[last, outs]` | none (`@tailrec` instead) | none (tail recursion instead) |
| one interpreter move | `traceStep(io, seed)` → `[next, ops]` | none | none |
| pure test interpreter | `trace(io, seed)` → `{result, ops}` | `SafeIO.trace(io, seed): Traced[A]` | `SafeIO.trace seed io : IO.Traced<'a>` |
## 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` |
| inputs | plain objects `{tag: 'Time', t}`, `{tag: 'KeyDown', key}`, `{tag: 'KeyUp', key}`; m13: `{tag: 'Click', x, y}` (canvas pixels) | `enum Event`: `Event.Time(t: Long)`, `Event.KeyDown(key)`, `Event.KeyUp(key)`; m13: `Event.Click(x: Int, y: Int)` | `type Event = Time of int64 \| KeyDown of string \| KeyUp of string`; m13: `\| Click of int * int` |
| 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` |
| mouse clicks (m13, given) | `clicks` (emits `{x, y}`) | `Stream.clicks: Stream[(Int, Int)]` | `Stream.clicks : Stream<int * int>` |
| a clock | `interval(ms)` | `Stream.interval(ms: Long): Stream[Long]` | `Stream.interval (ms: int64) : Stream<int64>` |
| 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` |
@@ -131,6 +165,7 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| 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` |
| limit clock jumps (m10) | `clampTime(s, maxGap)` | `s.clampTime(maxGap: Long)` | `Stream.clampTime (maxGap: int64) s` |
## Module 9 — input, app, runtime (and Module 10 additions)
@@ -139,17 +174,44 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| 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`) |
| longest accepted time step (m10) | `MAX_GAP_MS` = 250 | `App.MAX_GAP_MS: Long` | `App.MAX_GAP_MS` (`250L`) |
| actions, classic clock | `classicActions` | `App.classicActions: Stream[Action]` | `App.classicActions : Stream<Action>` |
| actions, frame clock (m10) | `speedActions` | `App.speedActions: Stream[Action]` | `App.speedActions : Stream<Action>` |
| states over time | `states(seed, actions)` | `App.states(seed: Long, actions): Stream[GameState]` | `App.states (seed: uint32) (actions: Stream<Action>) : Stream<GameState>` |
| draw one state | `render(state)` | `App.render(state): IO[Unit]` | `App.render (state: GameState) : IO<unit>` |
| a session (m10) | `{game, best, improved}` | `final case class Session(game: GameState, best: Int, improved: Boolean)` | `type Session = { Game: GameState; Best: int; Improved: bool }` |
| one session step (m10) | `sessionUpdate(session, action)` | `App.sessionUpdate(session: Session, action: Action): Session` | `App.sessionUpdate (session: Session) (action: Action) : Session` |
| sessions over time (m10) | `sessions(seed, best, actions)` | `App.sessions(seed: Long, best: Int, actions: Stream[Action]): Stream[Session]` | `App.sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session>` |
| picture of a session (m10) | `sessionView(session)` | `App.sessionView(session: Session): Scene` | `App.sessionView (session: Session) : Scene` |
| draw a session, save a new best (m10) | `renderSession(session)` | `App.renderSession(session: Session): IO[Unit]` | `App.renderSession (session: Session) : IO<unit>` |
| the whole program | `main` (a value) | `App.main: IO[Stream[IO[Unit]]]` (a value) | `App.main () : IO<Stream<IO<unit>>>` (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` |
| the program as data | `play(main, seed, events)` → one list of ops per drawing; m10: `play(main, seed, events, best = 0)` | `App.play(main, seed: Long, events: List[Event]): List[List[Op]]`; m10: `best: Int = 0` added | `App.play (seed: uint32) (events: Event list) main : Op list list`; m10: `App.playWith (seed: uint32) (best: int) events main`, with `play seed events main = playWith seed 0 events main` |
| 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)`; m10: `loadBest()` → number, `saveBest(score)`; m13: `onClick(cb)`, `cb(x, y)` in canvas pixels | `trait Platform` with the same method names; `randomSeed(): Long`, `startClock(onTime: Long => Unit)`; m10: `loadBest(): Int`, `saveBest(score: Int): Unit`; m13: `onClick(cb: (Int, Int) => Unit): Unit` (given) | `type Platform` record of functions: `FillRect`, `FillText`, `RandomSeed: unit -> uint32`, `StartClock: (int64 -> unit) -> unit`, `OnKeyDown`, `OnKeyUp`; m10: `LoadBest: unit -> int`, `SaveBest: int -> unit`; m13: `OnClick: (int -> int -> unit) -> unit` (given) |
| 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) |
| run the program | `runApp(main, platform)`; m13: also registers `onClick` | `Runtime.runApp(main, p)` | `Runtime.runApp p main` |
| browser platform | `browserPlatform(canvas, win)` (`browser.js`, you write it; m10: the best score in `localStorage` key `pure-snake-best`; m13: `onClick`, with the given helper `canvasPoint(canvas, clientX, clientY)` → `{x, y}`) | `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 10 — replay viewer (`replay`)
JS `js/src/replay.js`, Scala `scala/src/Replay.scala` (types at the top level of package `snake`, functions in `object Replay`), F# `fsharp/src/Replay.fs` (module `Snake.Replay`; `ViewerAction` is `[<RequireQualifiedAccess>]`).
| Concept | JavaScript | Scala | F# |
|---|---|---|---|
| a recording | `{seed, actions}` (e.g. `fixtures/replay-sample.json`) | `final case class Recording(seed: Long, actions: List[Action])` | `type Recording = { Seed: uint32; Actions: Action list }` |
| the viewer | `{index, last, playing}` | `final case class Viewer(index: Int, last: Int, playing: Boolean)` | `type Viewer = { Index: int; Last: int; Playing: bool }` |
| viewer actions | `ViewerAction.Next`, `.Prev`, `.First`, `.Last`, `.TogglePlay`, `.Beat`; values `{tag: 'Next'}`, ... | `enum ViewerAction { Next, Prev, First, Last, TogglePlay, Beat }` | `ViewerAction.Next`, ... `ViewerAction.Beat` |
| ms per autoplay frame | `REPLAY_MS` = 100 | `Replay.REPLAY_MS: Long` | `Replay.REPLAY_MS` (`100L`) |
| every state of the game | `frames(recording)` | `Replay.frames(recording: Recording): List[GameState]` | `Replay.frames (recording: Recording) : GameState list` |
| the first viewer | `viewerStart(last)` | `Replay.viewerStart(last: Int): Viewer` | `Replay.viewerStart (last: int) : Viewer` |
| one viewer step | `viewerUpdate(viewer, action)` | `Replay.viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer` | `Replay.viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer` |
| key to action | `viewerKey(key)` → action or `null` | `Replay.viewerKey(key: String): Option[ViewerAction]` | `Replay.viewerKey (key: string) : ViewerAction option` |
| picture | `viewerView(frames, viewer)` | `Replay.viewerView(frames: List[GameState], viewer: Viewer): Scene` | `Replay.viewerView (frames: GameState list) (viewer: Viewer) : Scene` |
| actions over time | `replayActions` | `Replay.replayActions: Stream[ViewerAction]` | `Replay.replayActions : Stream<ViewerAction>` |
| viewers over time | `viewerStates(last, actions)` | `Replay.viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer]` | `Replay.viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer>` |
| the whole viewer | `replayMain(recording)` | `Replay.replayMain(recording: Recording): IO[Stream[IO[Unit]]]` | `Replay.replayMain (recording: Recording) : IO<Stream<IO<unit>>>` |
| entry point (given) | `js/src/replay-main.js` (fetches the recording), page `js/replay.html` | in `scala/web/`, page `scala/replay.html` | in `fsharp/web/`, page `fsharp/replay.html` |
## 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 `[<RequireQualifiedAccess>]`, so you always write `PongStatus.Playing`, `Paddle.Up`, `PongAction.Frame`; that keeps them apart from the snake's `Playing`, `Up` and `Frame`.
@@ -175,6 +237,45 @@ JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongM
| the whole program | `pongMain` (a value) | `PongApp.pongMain: IO[Stream[IO[Unit]]]` | `Pong.App.pongMain () : IO<Stream<IO<unit>>>` |
| 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` |
## Module 12 — property-based testing (bonus)
| Concept | JavaScript (`js/src/check.js`) | Scala (`scala/src/Check.scala`) | F# (`fsharp/src/Check.fs`) |
|---|---|---|---|
| a generator | a `Random<A>` (Module 4) | `Random[A]` | `Random<'a>` |
| pick an element | `elementOf(xs)` | `Check.elementOf(xs: List[A]): Random[A]` | `Check.elementOf (xs: 'a list) : Random<'a>` |
| `n` draws in a row | `listOf(gen, n)` | `Check.listOf(gen, n): Random[List[A]]` | `Check.listOf (n: int) gen : Random<'a list>` |
| random actions (given) | `genActions(n)` | `Check.genActions(n): Random[List[Action]]` | `Check.genActions (n: int) : Random<Action list>` |
| the report | `{ok: true, runs}` or `{ok: false, run, seed, value}` | `CheckResult.Passed(runs)`, `CheckResult.Failed(run, seed, value)` | `Passed runs`, `Failed(run, seed, value)` |
| check a property | `forAll(gen, prop, runs, seed)` | `Check.forAll(gen, runs, seed)(prop)` | `Check.forAll runs seed prop gen` |
| one element removed | `removals(xs)` | `Check.removals(xs): List[List[A]]` | `Check.removals xs : 'a list list` |
| shrink a failing list | `minimize(xs, fails)` | `Check.minimize(xs)(fails)` | `Check.minimize fails xs` |
| broken invariants | `violations(state)` → names | `Check.violations(state): List[String]` | `Check.violations state : string list` |
| frames between moves | `moveGaps(states)` | `Check.moveGaps(states): List[Int]` | `Check.moveGaps states : int list` |
| a random snake | `genSnake(width, height, maxLen)` | `Check.genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]]` | `Check.genSnake (width: int) (height: int) (maxLen: int) : Random<Point list>` |
| a random sane state (given) | `genState(width, height)` | `Check.genState(width: Int, height: Int): Random[GameState]` | `Check.genState (width: int) (height: int) : Random<GameState>` |
| the step, as an oracle | `advanceSpec(state)` | `Check.advanceSpec(state: GameState): GameState` | `Check.advanceSpec (state: GameState) : GameState` |
## Module 13 — Conway's Life
JS files are in `js/src/life/`, Scala files in `scala/src/life/` (objects `LifeModel`, `LifeRules`, `LifeView`, `LifeApp` in package `snake.life`), F# files in `fsharp/src/Life/` (modules `Snake.Life.Model`, `Snake.Life.Rules`, `Snake.Life.View`, `Snake.Life.App`; `LifeAction` is `[<RequireQualifiedAccess>]`). The click at the edge is in the Module 8 and 9 tables above.
| Concept | JavaScript | Scala | F# |
|---|---|---|---|
| constants | `LW` = 40, `LH` = 30, `LCELL` = 16, `STRIP` = 24 (`model.js`) | `LifeModel.LW`, ... `LifeModel.STRIP` | `Life.Model.LW`, ... `Life.Model.STRIP` |
| world state | `{cells, running, seed, generation}`; `cells` sorted row-major, no duplicates | `LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)` | `{ Cells: Point list; Running: bool; Seed: uint32; Generation: int }` |
| actions | `LifeAction.Toggle(cell)`, `LifeAction.Frame`, `.TogglePause`, `.Step`, `.Clear`, `.Randomize`; values `{tag: 'Toggle', cell}`, `{tag: 'Frame'}`, ... | `enum LifeAction`: `Frame`, `Toggle(cell: Point)`, `TogglePause`, `Step`, `Clear`, `Randomize` | `LifeAction.Frame`, `LifeAction.Toggle of Point`, ... |
| fresh world | `initialLife(seed)` | `LifeModel.initialLife(seed: Long): LifeState` | `Life.Model.initialLife (seed: uint32) : LifeState` |
| one generation | `lifeStep(cells)` (`rules.js`) | `LifeRules.lifeStep(cells: List[Point]): List[Point]` | `Life.Rules.lifeStep (cells: Point list) : Point list` |
| the update function | `lifeUpdate(state, action)` | `LifeRules.lifeUpdate(state: LifeState, action: LifeAction): LifeState` | `Life.Rules.lifeUpdate (state: LifeState) (action: LifeAction) : LifeState` |
| state to picture | `lifeView(state)` (`view.js`) | `LifeView.lifeView(state: LifeState): Scene` | `Life.View.lifeView (state: LifeState) : Scene` |
| ms per generation | `GEN_MS` = 150 (`app.js`) | `LifeApp.GEN_MS: Long` | `Life.App.GEN_MS` (`150L`) |
| key to action | `lifeKey(key)` → action or `null` | `LifeApp.lifeKey(key: String): Option[LifeAction]` | `Life.App.lifeKey (key: string) : LifeAction option` |
| click to action | `lifeClick(x, y)` → `Toggle` or `null` | `LifeApp.lifeClick(x: Int, y: Int): Option[LifeAction]` | `Life.App.lifeClick (x: int) (y: int) : LifeAction option` |
| actions over time | `lifeActions` | `LifeApp.lifeActions: Stream[LifeAction]` | `Life.App.lifeActions : Stream<LifeAction>` |
| states over time | `lifeStates(seed, actions)` | `LifeApp.lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState]` | `Life.App.lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState>` |
| the whole program | `lifeMain` (a value) | `LifeApp.lifeMain: IO[Stream[IO[Unit]]]` | `Life.App.lifeMain () : IO<Stream<IO<unit>>>` |
| entry point (given) | `js/src/life/main.js`, page `js/life.html` | in `scala/web/`, page `scala/life.html` | in `fsharp/web/`, page `fsharp/life.html` |
## Tests and tools
| Task | JavaScript (`cd js`) | Scala (`cd scala`) | F# (`cd fsharp`) |
@@ -183,7 +284,8 @@ JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongM
| 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` |
| type checking (optional) | `npx -y -p typescript tsc -p js/jsconfig.json` from the repository root, or VS Code as it is; types in `js/src/types.d.ts` (see [Module 0](00-setup.md#if-your-editor-understands-types-javascript-optional)) | the compiler, on every test run | the compiler, on every test run |
| 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 <http://localhost:8000/js/> (Pong: `/js/pong.html`) | same server, <http://localhost:8000/scala/> | same server, <http://localhost:8000/fsharp/> |
| play | `node scripts/serve.mjs` from the repository root, then <http://localhost:8000/js/> (Pong: `/js/pong.html`, replay viewer: `/js/replay.html`, Life: `/js/life.html`) | same server, <http://localhost:8000/scala/> | same server, <http://localhost:8000/fsharp/> |
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.