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>
79 KiB
Module 10 — Extensions, all pure
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 scorejs/src/io.js,js/src/runtime.js,js/src/browser.js, and for the replay viewer the newjs/src/replay.js· Scalascala/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.fsTests: JScd js && node --test "test/m10-*.test.js"· Scalacd scala && ./test.sh M10· F#cd fsharp && ./test.sh M10— and afterwards all tests: JS./test.sh· Scala./test.sh· F#./test.shPrerequisites: Modules 0–9 (a playable game)
Why this module
You have a playable Snake. Now come the features every player asks for: pause, restart, and a game that speeds up as you score. This module is about a question you should ask of any architecture: when I add a feature, how much do I have to touch, and how much can break?
Think about how these features usually get added to a stateful game:
// The usual, stateful way (do NOT write this in the course)
let paused = false
let timer = setInterval(step, 150)
window.addEventListener('keydown', (e) => {
if (e.key === ' ') paused = !paused
if (e.key === 'r') { snake = [...]; apple = ...; score = 0; paused = false } // did we reset everything?
})
function step() {
if (paused) return
// ... move, eat ...
if (ateApple && score % 3 === 0) {
clearInterval(timer)
timer = setInterval(step, Math.max(50, 150 - score * 5)) // change the clock itself
}
}
Every feature adds another mutable variable, another if in a callback, and another way to forget something (does restart reset the timer speed? does pressing an arrow while paused queue a turn?). Speeding up means reaching out and changing the clock, which is an effect hidden in the middle of the game logic.
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,RestartandFrameas pure cases ofupdate; - explain why speed-up is done with a frame counter and
movesEveryinstead of changing the clock, and compute the move interval for a given score; - predict, using the fixtures, exactly which
Frameactions 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 rulesturnandadvancedid 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 a new effect by answering six questions first, and implement a persisted high score as two new operations, two interpreter cases and a
Sessionvalue that wraps the game, without writing impure code in the game logic or changingGameState; - 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
1. Pause is a status, not a flag in a callback
GameState has had a status field since Module 2, with the value Paused waiting for this moment. And since Module 3, turn and advance do nothing unless the status is Playing. So most of "pause" is already done: a paused game ignores ticks and turns, by construction. (This is Review Focus 4 of the course: keys pressed while paused or after game over must do nothing. The fixtures turn-while-paused-is-ignored and tick-while-paused-is-ignored check it.)
What's left is the action that switches it:
TogglePause:Playing→Paused,Paused→Playing, and any other status is unchanged (you can't pause a game that is over, nor "unpause" it intoPlaying).
Compare the two worlds. In the stateful version, "paused" is a separate boolean that every callback must remember to check. In ours, it's one value of a field that the rules already respect — there is no way to forget it, because the rules never run in the wrong status.
2. Restart is "a new game", which is a value you already have
Restart:newGame(state.seed), from any status.
That's it, and it's the shortest bug-free restart you'll ever write. The stateful version had to reset every variable it had created, and forgetting one is a classic bug. Here there is nothing to reset, because there is nothing that persists except the value you return: the new game is a complete state built from scratch by the same function that built the first one.
Notice which seed is used: state.seed, the current seed, which has advanced every time an apple was placed. So a restart is deterministic (the same state always restarts the same way) but it's a different game from the first one. Think about that choice in "Check your understanding".
3. Speed: change the counting, not the clock
How do you make the snake move faster as the score grows? The stateful answer is to change the timer: clearInterval, setInterval with a shorter delay. In our design that would mean the clock stream depends on the game state — the input would depend on the output — and the runtime would have to be told to restart its clock. That's an effect, right in the middle of the game.
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 everyFRAME_MS = 25ms. GameStatehas aframecounter (since Module 2, waiting for this moment). It holds the number of frames since the snake last moved.- On
Frame: if the game isn'tPlaying, nothing happens. Otherwise count the frame:n = frame + 1.- While
nis belowmovesEvery(score), the result is the state withframe: n. - When
nreachesmovesEvery(score)(write the test asn >= movesEvery(score)), the count starts again and the game takes a step: the result istickof the state withframe: 0.
- While
movesEvery(score) = max(2, 8 - floor(score / 3)).
So the speed is a pure function of the score:
| score | movesEvery |
time per move (× 25 ms) |
|---|---|---|
| 0–2 | 8 | 200 ms |
| 3–5 | 7 | 175 ms |
| 6–8 | 6 | 150 ms |
| 9–11 | 5 | 125 ms |
| 12–14 | 4 | 100 ms |
| 15–17 | 3 | 75 ms |
| 18 and up | 2 | 50 ms |
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, Glenn Fiedler, "Fix Your Timestep!").
Predict with the fixtures before you code. Each line is "frame before, score → what this Frame does":
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)
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?
Think about it first, then open the explanation
- 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:
- 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 asdirandnextDirin Module 3. - 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
movesEveryof the score") and let the computer search for a counterexample; it finds this bug in a few milliseconds.
4. The clock stream changes, the runtime doesn't
The input side needs one change. Instead of Tick every 150 ms, the clock now produces Frame every 25 ms; key presses go through keyToAction as before, which now also knows two more keys:
' '(Space) →TogglePause;'r'/'R'→Restart.
speedActions = merge(map(interval(FRAME_MS), _ => Frame), filterMap(keyDowns, keyToAction))
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. 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). 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 Frames 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!" 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 byt - last, but by at mostmaxGap, and by no less than 0 (if the outside world ever reports an earlier time, the clock stands still; it never runs backwards). Thensis stepped withTime clock, not withTime t. Afterwardslastist.- A key event is passed to
sunchanged, andlastandclockstay 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
BufferedReaderaround 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?
updatestill handlesTickandTurnas 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 changeTickandTurn, 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 aTurn, and what isnextDirin their start state?)classicActionsis unchanged. The m09 tests that check "a tick every 150 ms" useclassicMain, a copy of the program built fromclassicActions, defined in the test helpers. The m09 tests that use the realmaincheck 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.clampTimechanges nothing as long as time moves in steps of at most 250 ms, and the earlier tests onmainnever 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.
tracegains 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.
7. A turn queue: two keys within one step
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.
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.
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 functionsteer(state, dir)instead of straight toturn:- 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 neithernextDirnor its opposite, the result is the state withqueued = dir; a later key replaces an earlier queued one. Otherwise the state is unchanged.
- not
- After every game step that
updatetakes (theTickcase and the stepping branch ofFrame): if the result is stillPlayingand something is queued, the result getsnextDir = queuedandqueued = none. A step that ends the game leavesqueuedalone (nothing will read it again), andRestartgives 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): 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.
- New operations. Which
Ops do you need, and what does each one answer? In Scala and F#, which new case doesOpResultneed? - New interpreter cases. What does
traceanswer to the new read: a fixed number passed in, like the seed? What doesrunIOdo, and which newPlatformmethods does that need? - Loading. The best score must be known before the first frame is drawn. Where in
maindoes it go? (mainalready starts with one effect,randomSeed. How do you do two effects, the second after the first? Which function from Module 7 combines them?) - State. Where does the best score live during the game? Is it a field of
GameState? What doesRestartdo with it? - 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 itsIOcan decide whether to save? - 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. WrappingGameStatein aSessioninstead 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 Ops; 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 checkoutof 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
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",
Noneinstead ofnull,()instead ofundefined,bind/chooseinstead offlatMap/filterMap,IO.map throwingF ioinstead ofmap(io, throwingF), and so on. Failing tests are reported by name when you run the module's tests; to read all titles, openscala/test/M10Extensions.test.scalaorfsharp/tests/M10Extensions.fs. Fixture-based tests have the same case names in every track.
Exercise 10.1 — movesEvery
JavaScript (js/src/game.js)
export const movesEvery = (score) => /* max(2, 8 - floor(score / 3)) */
Scala (scala/src/Game.scala)
def movesEvery(score: Int): Int = ???
F# (fsharp/src/Game.fs)
let movesEvery (score: int) : int = failwith "TODO m10"
Proved by: fixtures/m10-moves-every.json (score-0, score-2, score-3, score-17, score-18, score-100).
Exercise 10.2 — update: TogglePause, Restart, Frame
Extend update(state, action) (same file as 10.1) with the three new actions, as specified in sections 1–3. Keep the old Tick and Turn cases as they are.
JavaScript
export const update = (state, action) => /* GameState */
Scala (one case per Action, no wildcard)
def update(state: GameState, action: Action): GameState = ???
F#
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05/m10"
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 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
Extend keyToAction (js/src/input.js, scala/src/Input.scala, fsharp/src/Input.fs): ' ' → TogglePause; 'r', 'R' → Restart. Signature unchanged.
Proved by: fixtures/m10-input.json (space-toggles-pause, r-restarts, R-restarts, ArrowUp-still-turns-up, d-still-turns-right, Enter-is-not-bound, empty-string-is-not-bound), plus all of m09-input.json still passing.
Exercise 10.4 — the frame clock
JavaScript (js/src/app.js)
export const FRAME_MS = 25
export const speedActions = /* Stream<Action> */
// and change `main` to use speedActions
Scala (scala/src/App.scala)
val FRAME_MS: Long = 25
lazy val speedActions: Stream[Action] = ???
// and change `main` to use speedActions
F# (fsharp/src/App.fs)
let FRAME_MS = 25L
let speedActions : Stream<Action> = (* ... *)
// and change `main` to use speedActions
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)
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)
def clampTime(maxGap: Long): Stream[A] = ???
val MAX_GAP_MS: Long = 250
F# (fsharp/src/Stream.fs, data-last; then fsharp/src/App.fs)
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)
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)
def steer(state: GameState, dir: Direction): GameState = ???
F# (fsharp/src/Game.fs)
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/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/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/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.shfor 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 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)
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)
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)
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
Exercise 10.2 — nudge
Write each new case as its own small function (togglePause(state), frame(state)) and keep update a flat dispatch on action.tag. For Frame, do it in two steps: first build the state with frame + 1; then decide whether to tick it.
Exercise 10.2 — stronger hint
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.
Exercise 10.2 — near-solution (try the others first)
JavaScript
const togglePause = (state) =>
state.status === 'Playing' ? { ...state, status: 'Paused' }
: state.status === 'Paused' ? /* ? */
: state
const frame = (state) => {
if (state.status !== 'Playing') return state
const n = /* ? */
return n >= /* ? */ ? tick({ ...state, frame: /* ? */ }) : { ...state, frame: /* ? */ }
}
export const update = (state, action) => {
switch (action.tag) {
case 'Tick': return tick(state)
case 'Turn': return turn(state, action.dir)
case 'TogglePause': return /* ? */
case 'Restart': return /* ? */
case 'Frame': return /* ? */
}
}
Scala
def update(state: GameState, action: Action): GameState = action match
case Action.Tick => Spawn.tick(state)
case Action.Turn(d) => Rules.turn(state, d)
case Action.TogglePause => /* ? */
case Action.Restart => /* ? */
case Action.Frame => /* ? */
F#
let update (state: GameState) (action: Action) : GameState =
match action with
| Tick -> Spawn.tick state
| Turn d -> Rules.turn state d
| TogglePause -> (* ? *)
| Restart -> (* ? *)
| Frame -> (* ? *)
Exercise 10.4 — nudge
speedActions is classicActions with a different interval and a different constant action. Then main changes one word.
Exercise 10.5 — nudge
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.
Exercise 10.5 — stronger hint
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.
Exercise 10.5 — near-solution (try the others first)
JavaScript
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
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#
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
Exercise 10.6 — nudge
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.
Exercise 10.6 — stronger hint
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.
Exercise 10.6 — near-solution (try the others first)
JavaScript
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
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#
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 (___)
Exercise 10.7 — nudge
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.
Exercise 10.7 — stronger hint
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.
Exercise 10.7 — near-solution (try the others first)
JavaScript
// 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
// 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#
// 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))
Exercise 10.8 — nudge
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.
Exercise 10.8 — stronger hint
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.
Exercise 10.8 — near-solution (try the others first)
JavaScript
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
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#
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 && ___ }
Check your understanding
- List every file you changed in this module. Which important file did you not change, and why didn't you need to?
- In the stateful version, speed-up called
clearInterval/setInterval. Why would doing that in our design require the input stream to depend on the game state, and why is that a problem? - Predict: from a fresh game (frame 0, score 0, Playing), how many
Frameactions until the snake first moves? With score 18? Check against the table. - 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.
RestartusesnewGame(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?- 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? - In the high score of section 8, which parts are pure, which are interpreter code, and which test would fail if
renderSessionsaved on every frame instead of only whenimproved? - 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?
clampTimedrops the time beyondmaxGap. Design the other policy on paper: when aTimeevent 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 enterspeedActions? Why is a plainTogglePauseaction not quite right for it, and which newActionwould be? Which test would prove it?clampTime(map(s, f), n)andmap(clampTime(s, n), f): do they behave the same? What aboutclampTime(merge(a, b), n)andmerge(clampTime(a, n), clampTime(b, n))? Argue from the definitions. Then explain whyclampTime(clampTime(s, 100), 50)behaves likeclampTime(s, 50).- With the turn queue, a snake heading
Rightgets Up, Left, Down within one step. Tracedir,nextDirandqueuedthrough those three keys and the next two steps. Which keys were honoured, and why is the result still safe? - 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. - Why does a step that ends the game leave
queuedas it is instead of clearing it? Could any later action observe the difference? (Look at whatsteer,TogglePauseandRestartdo inGameOver.) - Section 7 put the queue in
updateand leftturnandadvancealone. 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 intoturn? - The best score lives in a
Sessionnext to the game, not inGameState. List everything you would have had to change (functions, fixtures,Restart) if it had been a field ofGameState. - With a stored best of 5, a player scores 8, restarts, and scores 3. Which
SaveBestops are performed, in which order, and on which actions? Why does the restart itself save nothing? renderSessionsaves 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?- The browser platform turns every failure of
localStorageinto 0 or into nothing. Which failure would a player notice, and why is that still better than letting the exception reachrunIO? - The viewer keeps every frame of the game in memory (
framesis 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? - 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).
- 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, inviewerView, or somewhere else? What would your choice cost the Snake fixtures? - 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
Msgvariant, a new branch inupdate; the compiler's exhaustiveness check lists every place that must handle it (The Elm 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
updatevs. interpreter (Redux fundamentals: state, actions and reducers). - Fixed-timestep game loops separate the rate at which the platform calls you from the rate at which the simulation advances; our
Frame+movesEveryis a minimal version, and the article'sif (frameTime > 0.25) frameTime = 0.25is ourclampTime(Glenn Fiedler, "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
visibilitychangeand pause (MDN: 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").
Further reading
- Robert Nystrom, Game Programming Patterns: Game Loop — fixed and variable time steps, and why games separate them.
- Glenn Fiedler, "Fix Your Timestep!" — the classic article on decoupling simulation from the clock, including the clamp on long frames.
- MDN: Page Visibility API — what browsers do to hidden tabs, and how a page can find out.
- Martin Fowler, "Event Sourcing" — what happens when you take "a program is a fold over events" seriously in business software.
- MDN:
Window.localStorage— the API that the browser platform wraps behindLoadBestandSaveBest. - Evan Czaplicki, "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 converts a second game on your own; Module 13, the second capstone, adds a new kind of input (the mouse) to the edge.