Files
functional-programming-snake/course/10-extensions.md
T
2026-10-29 01:06:09 +01:00

20 KiB
Raw Blame History

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 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)

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.

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;
  • 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.

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 into Playing).

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 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).
  • 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 → does this Frame tick?":

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)

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.

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

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.

From Scala: in Scala, 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

Suppose you want to keep the best score in the browser's localStorage (MDN: 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).

  1. New operations. Which Ops 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?

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.

Exercises

About test names. The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", None instead of null, () instead of undefined, bind/choose instead of flatMap/filterMap, IO.map throwingF io instead of map(io, throwingF), and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open scala/test/M10Extensions.test.scala or fsharp/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: 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-….

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

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.

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.

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: counted = { ...state, frame: state.frame + 1 }, and the result is counted.frame % movesEvery(state.score) === 0 ? tick(counted) : counted.

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 counted = /* ? */
  return /* tick(counted) or counted? */
}

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.

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?
  2. 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?
  3. Predict: from a fresh game (frame 0, score 0, Playing), how many Frame actions until the snake first moves? With score 18? Check against the table.
  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?

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).
  • 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).
  • 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!").
  • 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