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

+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.