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>
37 KiB
Module 9 — Wiring it together
Time: ~4 h · You'll edit: JS
js/src/input.js,js/src/app.js,js/src/runtime.js,js/src/browser.js· Scalascala/src/Input.scala,scala/src/App.scala,scala/src/Runtime.scala· F#fsharp/src/Input.fs,fsharp/src/App.fs,fsharp/src/Runtime.fsTests: JScd js && node --test "test/m09-*.test.js"· Scalacd scala && ./test.sh M09· F#cd fsharp && ./test.sh M09Prerequisites: Modules 0–8. You need workingupdate(m05),newGame(m04),view(m06),IO(m07) and streams (m08).
Why this module
You now have every piece of a game, and none of them does anything:
update(state, action)says how the game changes (Module 5);view(state)describes a picture (Module 6);IOdescribes effects such as drawing and asking for a seed (Module 7);Streamdescribes how to react to time and keys (Module 8).
In this module you connect them into one value that is the whole program, and then write the one small function that brings that value to life in a browser. At the end of the module, Snake is playable.
The connection has a striking shape. Try to read this type out loud:
main : IO<Stream<IO<unit>>>
"main is an effect (ask for a random seed) that produces a stream (react to time and keys) of effects (draw this frame)." Or, in one sentence that sums up the course so far:
A program is an effect that produces a stream of effects.
Everything in that sentence is a value you can test with trace and run. The only thing that is not a value is the runtime that performs it, and you'll write it yourself and see how small it is: one loop, three callbacks and one mutable variable.
Learning objectives
By the end of this module you can:
- implement
keyToAction, the action stream, the state stream (startWith+scan),renderandmainfrom the pieces of Modules 4–8; - explain the type
IO<Stream<IO<unit>>>layer by layer, and whymainis a value; - implement an interpreter
runIOas a loop and a driverrunAppthat holds the only mutable variable of the program; - explain how a
Platforminterface lets the runtime be tested with a fake instead of a browser; - run the game in a browser in your track, and trace one key press from the keyboard to the pixels;
- implement
play, which states the behaviour of the whole program, for a list of inputs, as one comparison of plain data.
Concepts
1. From keys to actions
The browser tells us which key went down as a string (KeyboardEvent.key: 'ArrowUp', 'w', 'W', 'x' …). The game speaks in Actions. The translation is a pure function:
'ArrowUp','w','W'→Turn Up;'ArrowDown','s','S'→Turn Down;'ArrowLeft','a','A'→Turn Left;'ArrowRight','d','D'→Turn Right;- any other key → nothing (
nullin JS,Nonein Scala/F#).
It returns "maybe an action" because most keys mean nothing to the game. That's exactly the shape filterMap (F#: choose) from Module 8 wants.
A small JS trap is tested on purpose: if you implement the table as an object lookup, table['constructor'] and table['toString'] are not undefined — every JS object inherits those from Object.prototype. The fixture cases constructor-is-not-a-key and toString-is-not-a-key catch it. A switch, a Map, or an object created with Object.create(null) avoids the problem. (Scala and F# pattern matches don't have this issue.)
2. From events to actions: one stream of everything that can happen
What can happen to the game? A tick every 150 ms, and the actions of recognised key presses. As streams:
ticks = map(interval(TICK_MS), _ => Tick) Stream<Action>
turns = filterMap(keyDowns, keyToAction) Stream<Action>
classicActions = merge(ticks, turns) Stream<Action>
Read it as a diagram:
events ──┬──▶ interval(150) ──map(→Tick)──────────────┐
│ ├──merge──▶ classicActions : Stream<Action>
└──▶ keyDowns ──filterMap(keyToAction)───────┘
Each Time event goes to both branches (that's what merge does); interval decides whether a tick happened, keyDowns ignores it. Each KeyDown also goes to both; interval ignores it, keyToAction decides whether it means something.
Check that you can predict the test:
run(classicActions, [time(150), down('ArrowUp'), down('x'), time(300)])
// → [Tick, Turn('Up'), Tick] 'x' produced nothing
3. From actions to states: a running fold
Module 5's insight was that a game is a fold over actions: replay(newGame(seed), actions). A stream of actions folds the same way, except we want every intermediate state, not only the last one, because we must draw each of them. That's scan:
scan(actions, update, newGame(seed)) Stream<GameState>: one new state per action
There's one more thing: scan emits only new accumulators, never the initial one. If we stopped here, nothing would be drawn until the first action — 150 ms of blank screen, and a board that's never shown in its starting position. So we put the initial state in front with startWith:
states(seed, actions) = startWith(scan(actions, update, newGame(seed)), newGame(seed))
From everyday programming: in the classic game loop,
while (running) { input(); update(); render(); }, the game state is one variable that each iteration overwrites, and the sequence of states exists only as the history of that variable.startWith(scan(…), s0)gives you that history as a value:s0, s1, s2, …, one state per action, the way a bank statement lists the opening balance followed by the balance after each transaction.scanalone is the list of balances after each transaction;startWithadds the opening balance in front.
You may have seen this exact shape elsewhere: Redux reducers are (state, action) → state folded over a stream of dispatched actions, and Elm's update : Msg -> Model -> Model is the same fold run by the Elm runtime. (The argument order differs per library; the idea doesn't.)
4. From states to drawings, and main
To draw a state: describe the picture (view, Module 6) and turn the description into a drawing program (drawScene, Module 7):
render(state) = drawScene(view(state)) IO<unit>
Mapping render over the states gives a stream of drawing programs, Stream<IO<unit>>. The only thing still missing is the seed, which must come from the outside world — that's an IO (randomSeed). Put together:
main = map(randomSeed, seed => map(states(seed, classicActions), render))
Here the two maps are different functions: the outer one is IO's map, the inner one is Stream's map. (In JS they come from two modules, so you'll import one of them under another name, e.g. import { map as mapIO } from './io.js'. In Scala they're methods, so the types pick the right one. In F# they're IO.map and Stream.map.) Let's read the type from the outside in:
IO< an effect: ask the world for a seed, and then produce…
Stream< …a machine that, for every event…
IO<unit> …produces the effects that draw a frame.
>>
Why must the outer layer be an IO? Because the stream of states depends on the seed, and the seed isn't known until the outside world is asked. Why is the inner layer an IO and not a Scene? Because the runtime should only know how to perform IOs; turning scenes into effects is the program's business, not the runtime's. (In Module 10 you could make frames that also play sounds, without touching the runtime.)
And main is a value. Defining it performs nothing — no seed is asked for, no listener is registered. Look at the test:
const { ops, result: stream } = trace(main, 42)
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' }) // main asks for a seed first
const [outs] = stream.step(time(0)) // the first event…
assert.equal(outs.length, 1) // …produces exactly one drawing: the new game
(The real test also allows exactly one more operation after the seed, and lets the first drawing carry shapes after the new game's view. Both are for Module 10, where main also loads the stored best score and draws it; in this module, ops is simply [{ tag: 'RandomSeed' }].)
The whole program, with its randomness, time and keyboard, is tested with the pure interpreter from Module 7 and step from Module 8. No browser.
About classicActions and Module 10. In Module 10, main will switch from classicActions to speedActions. The m09 tests that check the 150 ms behaviour therefore use a copy of main built from classicActions (classicMain in the test helpers), so they keep passing after Module 10.
5. The edge: a Platform and a runtime
Now somebody has to do it. Let's list everything the outside world must provide:
- draw a rectangle; draw a text;
- give a random seed;
- call us back with the time, again and again;
- call us back when a key goes down; when a key goes up.
That list is an interface, which the course calls a Platform:
JavaScript
// Platform = {
// fillRect(x, y, w, h, color),
// fillText(x, y, text, color, size, align),
// randomSeed() → number (uint32),
// startClock(onTime: (t) => void),
// onKeyDown(cb: (key) => void),
// onKeyUp(cb: (key) => void),
// }
Scala (scala/src/Runtime.scala)
trait Platform:
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit
def randomSeed(): Long
def startClock(onTime: Long => Unit): Unit
def onKeyDown(cb: String => Unit): Unit
def onKeyUp(cb: String => Unit): Unit
F# (fsharp/src/Runtime.fs) — a record of functions
type Platform =
{ FillRect: int -> int -> int -> int -> string -> unit
FillText: int -> int -> string -> string -> int -> string -> unit
RandomSeed: unit -> uint32
StartClock: (int64 -> unit) -> unit
OnKeyDown: (string -> unit) -> unit
OnKeyUp: (string -> unit) -> unit }
(Module 10 adds two more methods, loadBest and saveBest, for a best score that survives a reload.)
The runtime only ever talks to a Platform. The browser implementation (browser.js in JS, web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs) wraps a canvas, requestAnimationFrame and crypto.getRandomValues. The tests use a fake platform that records every call in a list and lets the test fire events by hand — a test double in the sense of Martin Fowler's "Test Double":
const fakePlatform = (seed = 42) => {
const calls = []; const handlers = {}
return {
calls,
fillRect: (...args) => calls.push(['fillRect', ...args]),
fillText: (...args) => calls.push(['fillText', ...args]),
randomSeed: () => { calls.push(['randomSeed']); return seed },
startClock: (cb) => { handlers.time = cb },
onKeyDown: (cb) => { handlers.down = cb },
onKeyUp: (cb) => { handlers.up = cb },
fireTime: (t) => handlers.time(t),
fireKey: (k) => handlers.down(k),
fireKeyUp: (k) => handlers.up(k),
}
}
(Yes, the fake uses push and mutation; it's test code, which is allowed to be impure. It plays the role of the outside world.)
6. runIO: the real interpreter
runIO(io, platform) is trace from Module 7 with real answers:
Drawof aRect→platform.fillRect(x, y, w, h, color); of aText→platform.fillText(x, y, text, color, size, align); the answer is unit (Done);RandomSeed→platform.randomSeed(); the answer is that number (Seed(n));- pass the answer to
k, and continue with the program it returns, until you reachPure(value); returnvalue.
One requirement is new: it must be a loop, not recursion. A single frame is a few hundred draws, a game runs for minutes, and the test runIO handles 100000 effects without overflowing the stack makes sure. In JS that means a while loop over a local "current program" variable (allowed here, and only here). In Scala, write a @annotation.tailrec recursive function: the compiler turns it into a loop, and you keep your one var for runApp. In F#, either a loop or a tail-recursive function works: F# compiles a self-recursive call in tail position into a loop, so let rec runIO p io = match io with ... | Effect (op, k) -> runIO p (k ...) doesn't grow the stack.
Remember section 12 of Module 7: the loop only helps if each continuation does a small, constant amount of work. If your sequence is left-nested, the 100 000-effect test overflows inside k, and the fix is in io, not in the runtime.
7. runApp: one mutable variable
runApp(main, platform) brings the program to life:
-
Interpret
mainwithrunIO. This asks the platform for a seed and gives you the stream (Stream<IO<unit>>). -
Keep the current stream in a mutable variable. This is the only mutable variable in the whole program that outlives a function call. In F# it is a ref cell (
let stream = ref …, read withstream.Value, written withstream.Value <- next), because F# closures can't capture alet mutable, and the callbacks are closures. (The loop inrunIOin JS reassigns a local "current program" variable, but that variable exists only during one call and nobody else can see it; it is a loop counter, not state. The stream variable is the program's entire memory between events.) -
Register three callbacks on the platform — key down, key up, clock — each of which turns what it receives into an event (
KeyDown key,KeyUp key,Time t) and feeds it:- step the current stream with the event →
(ios, next); - store
nextin the variable; - run each IO in
ioswithrunIO, in order.
(Module 13 adds a fourth registration, for mouse clicks on the canvas, and a matching
onClickmethod to the platform. Write the three now; the tests of this module do not expect the fourth.) - step the current stream with the event →
That's the whole runtime. Go back to the stateful code at the start of Module 8, with its let state and logic in callbacks, and compare. There, the mutable variable held the game state, and game logic lived in the callbacks. Here, the variable holds the current stream — the runtime doesn't even know there's a game. It has no idea what a snake, a tick or a score is. All the game logic is in pure values; the runtime is a few lines of plumbing. That is what "pushing effects to the edge" means in practice.
┌────────────────────────────── runtime (impure, ~20 lines) ─────────────────────────────────┐
browser ──key──▶│ feed(KeyDown key): [ios, next] = stream.step(ev); stream = next; ios.forEach(runIO) │
──time─▶│ feed(Time t) │
└────────────────────────────────────────────────────────────────────────────────────────────┘
▲ the one `let` / `var` / ref cell: stream
The tests describe the behaviour precisely; read them before writing the code:
runApp asks the platform for a seed once, at start, and draws nothing before the first input— afterrunApp, the only call is['randomSeed']. Nothing is drawn until an event arrives (rememberstartWith).runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms— a singlefireTime(150)produces two drawings: thestartWithstate and the state after oneTick. Can you explain why one event produces two frames?runApp feeds key presses to the game, andrunApp ignores key releases and unmapped keys (no redraw).
8. Who is impure, exactly?
Let's take inventory. In JS, three files are allowed to be impure and are excluded from the purity audit: runtime.js (runIO, runApp), browser.js (the canvas Platform) and main.js (one line: runApp(main, browserPlatform(document.getElementById('game'), window))). In Scala it's Runtime.scala plus the browser glue in scala/web/; in F#, Runtime.fs plus fsharp/web/ (whose SnakeMain.fs is the one line Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (App.main ()) — note main (): in F#, main is a function of unit, like IO.randomSeed (), for the reason explained in Module 7). Everything else — rules, randomness, view, IO, streams, main — is pure and passes the audit.
Count the lines of your runtime when you're done. It's probably around twenty. That's the whole imperative shell around a functional core.
9. Running the game
JavaScript. You also write browser.js (see Exercise 9.4). Then, from the repository root:
node scripts/serve.mjs
and open http://localhost:8000/js/. (A static server is needed because browsers refuse to load ES modules from file:// URLs.)
Scala. The browser glue is given (scala/web/BrowserPlatform.scala, scala/web/Main.scala); read it — it's short. Compile your code to JavaScript with Scala.js:
cd scala && ./build-web.sh
The first run downloads the Scala.js linker and takes a while. It writes scala/web/dist/snake.js. Then serve the repository root (node scripts/serve.mjs) and open http://localhost:8000/scala/.
F#. The browser glue is given in fsharp/web/. Compile with Fable:
cd fsharp && ./build-web.sh
The first run restores Fable (a local .NET tool listed in fsharp/.config/dotnet-tools.json) and takes a while. It writes fsharp/web/dist/. Then serve the repository root (node scripts/serve.mjs, run from the repository root) and open http://localhost:8000/fsharp/.
Use the arrow keys or WASD. You built a game in which every line of logic is pure. Play a round. Then, before moving on, do the trace exercise in "Check your understanding": follow one key press from the keyboard to the pixels, naming each function it passes through.
10. Testing the program end to end without a browser
The tests so far check the pieces: trace(main, 42) for the first operation, one step for the first drawing, the runtime with a fake platform. Can you state the behaviour of the whole program in one line, the way a fixture states the behaviour of update?
Everything you need is already a value. trace interprets main and gives the stream; run (Module 8) feeds the stream a list of inputs and collects every IO it emits; trace again turns each emitted IO into the list of operations it would perform. Put together:
play(main, seed, events) = run(trace(main, seed).result, events).map(io => trace(io, seed).ops)
The result is a list with one entry per drawing, and each entry is the list of operations of that drawing. For our program each entry is a scene turned into Draw operations, so the whole game, with its seed, clock and keyboard, collapses into one comparison:
const events = [time(150), down('ArrowUp'), down('x'), time(300), time(310)]
play(classicMain, 42, events)
// equals: history(newGame(42), [Tick, Turn('Up'), Tick]).map(state => the Draw ops of view(state))
Read that equality slowly. On the left is the program as the browser would run it, event by event. On the right is the game as Module 5 described it, a fold over actions, and the picture of each state as Module 6 described it. The test says they are the same thing. Nothing is mocked: play uses the same main that main.js hands to the runtime, and the only difference from a real session is the interpreter that answers the operations.
A test like this has a name in industry: a golden (or snapshot) test. You record the output of a known-good run once, keep it in a file, and from then on check that the program still produces exactly that. Module 10 keeps one in fixtures/m10-golden.json: a seed, a scripted list of inputs and the scene of the last drawing of the finished game. When main grows in Module 10 (it will also load the stored best score), play grows one parameter, the number that answers that load, exactly like seed answers RandomSeed.
From everyday programming: this is an end-to-end test that needs no browser automation. Tools such as Playwright or Selenium start a real browser, click, wait and take screenshots, which is slow and sometimes flaky because real time passes. Here time is just an input value, so the "recording" of a whole game session is a list of events, the "screenshot" is a list of shapes, and the test runs in milliseconds and gives the same answer every time.
Exercises
About test names. The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs",
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/M09*.test.scalaorfsharp/tests/M09*.fs. Fixture-based tests have the same case names in every track.
Exercise 9.1 — keyToAction
JavaScript (js/src/input.js)
export const keyToAction = (key) => /* Action or null */
Scala (scala/src/Input.scala)
object Input:
def keyToAction(key: String): Option[Action] = ???
F# (fsharp/src/Input.fs)
// module Snake.Input
let keyToAction (key: string) : Action option = failwith "TODO m09"
Proved by: fixtures/m09-input.json (ArrowUp-turns-up … D-turns-right, x-is-ignored, Enter-is-ignored, empty-string-is-ignored, constructor-is-not-a-key, toString-is-not-a-key).
Exercise 9.2 — the app: classicActions, states, render, main
JavaScript (js/src/app.js)
export const TICK_MS = 150
export const classicActions = /* Stream<Action> */
export const states = (seed, actions) => /* Stream<GameState> */
export const render = (state) => /* IO<undefined> */
export const main = /* IO<Stream<IO<undefined>>> */
Scala (scala/src/App.scala)
object App:
val TICK_MS: Long = 150
lazy val classicActions: Stream[Action] = ???
def states(seed: Long, actions: Stream[Action]): Stream[GameState] = ???
def render(state: GameState): IO[Unit] = ???
lazy val main: IO[Stream[IO[Unit]]] = ???
F# (fsharp/src/App.fs)
// module Snake.App
let TICK_MS = 150L
let classicActions : Stream<Action> = (* ... *)
let states (seed: uint32) (actions: Stream<Action>) : Stream<GameState> = failwith "TODO m09"
let render (state: GameState) : IO<unit> = failwith "TODO m09"
let main () : IO<Stream<IO<unit>>> = failwith "TODO m09"
Proved by: the game ticks every 150 ms, classicActions turns Tick into actions on the clock and keys into Turn actions, states starts with the new game, then yields one state per action, render draws the whole view of the state, main asks for a random seed, then builds the stream from it, the program built from classicActions draws the new game first, then the ticked game after 150 ms, main draws again once time has passed.
Watch out for: the JS stubs of classicActions and main are placeholder objects so that the file loads; replace them with real values. In JS, import the two maps under different names. Each of these definitions is one line; if yours is longer, re-read sections 2–4.
Exercise 9.3 — the runtime: runIO and runApp
JavaScript (js/src/runtime.js — let and loops allowed here)
export const runIO = (io, platform) => /* the final value */
export const runApp = (main, platform) => /* undefined; registers callbacks */
Scala (scala/src/Runtime.scala — exactly one var)
object Runtime:
def runIO[A](io: IO[A], p: Platform): A = ???
def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit = ???
F# (fsharp/src/Runtime.fs — one ref cell allowed here)
// module Snake.Runtime
let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09"
let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m09"
Proved by (m09-runtime): runIO of a pure value returns it without touching the platform, runIO maps Draw Rect to fillRect(x, y, w, h, color), runIO maps Draw Text to fillText(x, y, text, color, size, align), runIO answers RandomSeed with platform.randomSeed() and continues with it, runIO performs effects in order, runIO handles 100000 effects without overflowing the stack, and the four runApp … tests from section 7.
Watch out for: registering the callbacks before the stream exists, or reading the variable in a callback without updating it. Each callback must step the current stream and store the next one. In Scala, runIO with an Op.Draw must match on the shape inside (Shape.Rect / Shape.Text).
Exercise 9.4 (JS only) — browserPlatform
JavaScript (js/src/browser.js)
export const browserPlatform = (canvas, win) => /* Platform */
Build a Platform from a canvas and a window: fillRect/fillText use canvas.getContext('2d') (font `${size}px monospace`, textAlign from align, fillStyle from color); startClock calls onTime(t) on every win.requestAnimationFrame, with t = milliseconds since the first frame; onKeyDown/onKeyUp listen on win and call preventDefault() for arrow keys and space so the page doesn't scroll; randomSeed returns crypto.getRandomValues(new Uint32Array(1))[0]. There is no automated test for this file in this module; the test is that the game runs. (Module 10 adds two methods for a stored best score, and tests for those two with a fake window.) Scala and F# students: read the given BrowserPlatform in your track and compare it with this description.
Exercise 9.5 — play: the whole program in one equality
JavaScript (js/src/app.js)
export const play = (main, seed, events) => /* one list of ops per drawing */
// Module 10 adds a fourth parameter: (main, seed, events, best = 0)
Scala (scala/src/App.scala)
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] = ???
// Module 10 adds a fourth parameter: best: Int = 0
F# (fsharp/src/App.fs)
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m09"
// Module 10 adds playWith seed best events main, and play becomes playWith seed 0 events main
play is a pure helper: it takes the program as an argument, so it works for main, for classicMain in the tests, and for Pong's program in Module 11.
Proved by: play on the classic program equals the scenes of the expected states, play with no events gives no drawings, play uses the seed for the program's RandomSeed.
Watch out for: trace returns {result, ops}; the stream is the result of tracing main, but for each emitted IO you want its ops. Use the same seed for both traces.
Run:
- JS:
cd js && node --test "test/m09-*.test.js", thennode scripts/serve.mjs(from the repo root) and open http://localhost:8000/js/ - Scala:
cd scala && ./test.sh M09, then./build-web.sh; from the repository root runnode scripts/serve.mjsand open http://localhost:8000/scala/ - F#:
cd fsharp && ./test.sh M09, then./build-web.sh; from the repository root runnode scripts/serve.mjsand open http://localhost:8000/fsharp/
Hints
Exercise 9.1 — nudge
A switch (JS) or match (Scala/F#) with several keys per case is the most readable. In JS, case 'ArrowUp': case 'w': case 'W': return Action.Turn('Up'), and default: return null. Why is a switch safe from the constructor trap and a plain object lookup isn't?
Exercise 9.2 — nudge
Each definition is a direct translation of a line in sections 2–4. Write them in the order classicActions, states, render, main, and run the tests after each one.
Exercise 9.2 — near-solution (try the others first)
JavaScript
import { map as mapIO, randomSeed, drawScene } from './io.js'
import { interval, keyDowns, map as mapS, filterMap, merge, scan, startWith } from './stream.js'
export const classicActions = merge(mapS(interval(TICK_MS), () => /* ? */), filterMap(/* ? */, /* ? */))
export const states = (seed, actions) => startWith(scan(actions, /* ? */, /* ? */), /* ? */)
export const render = (state) => /* ? */
export const main = mapIO(randomSeed, (seed) => /* ? */)
Scala
lazy val classicActions: Stream[Action] =
Stream.interval(TICK_MS).map(_ => /* ? */).merge(Stream.keyDowns.filterMap(/* ? */))
def states(seed: Long, actions: Stream[Action]): Stream[GameState] =
actions.scan(/* ? */)(Game.update).startWith(/* ? */)
lazy val main: IO[Stream[IO[Unit]]] = IO.randomSeed.map(seed => /* ? */)
F#
let classicActions = Stream.merge (Stream.interval TICK_MS |> Stream.map (fun _ -> (* ? *))) (Stream.keyDowns |> Stream.choose (* ? *))
let states seed actions = actions |> Stream.scan (* ? *) (* ? *) |> Stream.startWith (* ? *)
let main () = IO.randomSeed () |> IO.map (fun seed -> (* ? *))
(F#'s Game.update takes state then action, which is the order Stream.scan's folder wants.)
Exercise 9.3 — nudge
runIO: keep a "current program" variable. While it's an Effect, perform its op on the platform and replace the current program with k(answer). When it's Pure, return the value. Write a small helper perform(op, platform) that returns the answer.
runApp: one variable for the stream, one helper feed(event), three registrations. Which of the three registrations should come last, and does it matter?
Exercise 9.3 — near-solution (try the others first)
JavaScript
export const runIO = (io, platform) => {
let current = io
while (current.tag === 'Effect') current = current.k(/* perform current.op, get the answer */)
return /* ? */
}
export const runApp = (main, platform) => {
let stream = /* ? */
const feed = (input) => {
const [ios, next] = /* ? */
stream = /* ? */
ios.forEach(/* ? */)
}
platform.onKeyDown((key) => feed({ tag: 'KeyDown', key }))
platform.onKeyUp(/* ? */)
platform.startClock(/* ? */)
}
Scala
@annotation.tailrec
def runIO[A](io: IO[A], p: Platform): A = io match
case IO.Pure(a) => a
case IO.Effect(op, k) => runIO(k(/* perform op on p, giving an OpResult */), p)
def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit =
var stream = /* ? */
def feed(e: Event): Unit =
val (ios, next) = /* ? */
stream = next
/* ? */
p.onKeyDown(key => feed(Event.KeyDown(key)))
/* ... */
F#
let rec runIO (p: Platform) (io: IO<'a>) : 'a =
match io with
| Pure a -> a
| Effect (op, k) -> runIO p (k ((* perform op on p, giving an OpResult *))) // tail call: compiled to a loop
let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
let stream = ref ((* ? *)) // the one ref cell
let feed (e: Event) =
let ios, next = (* step stream.Value with e *)
stream.Value <- next
(* run every io in ios, in order *)
p.OnKeyDown (fun key -> feed (KeyDown key))
(* ... *)
Exercise 9.5 — nudge
Three steps, each one a function you have: get the stream out of main, feed it the events, and turn each IO it emitted into its list of operations. Which function feeds a stream a whole list of inputs and collects the outputs?
Exercise 9.5 — stronger hint
trace(main, seed).result is the stream. run(stream, events) is the list of IOs emitted, in order. map over that list with io => trace(io, seed).ops.
Exercise 9.5 — near-solution (try the others first)
JavaScript
export const play = (main, seed, events) => run(trace(main, ___).result, ___).map((io) => ___)
Scala
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] =
IO.trace(main, seed).result.run(___).map(io => ___)
F#
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
(IO.trace seed main).Result |> Stream.run ___ |> List.map (fun io -> ___)
Check your understanding
- Read
IO<Stream<IO<unit>>>aloud and explain each layer. Why can't the outerIObe removed? Why isn't the inner one aScene? - Trace one key press: the player presses
ArrowUp. Name, in order, every function and value it passes through until pixels change. When does the snake actually turn on screen, and why not immediately? - One call
fireTime(150)right afterrunAppproduces two full drawings. Explain exactly which outputs of which streams cause each. - The runtime's only variable holds a stream, not a game state. What would be worse if it held the game state instead? Which code would have to move into the runtime?
- Why must
runIObe a loop whiletracemay be recursive? Is there a program for which even the loop overflows? Whose fault would it be? - What makes the runtime testable without a browser? What would you have to do to test it if it called
canvas.getContext('2d')directly? - Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you?
mainis defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what doestrace(main, 1).opscontain, and when does the platform'srandomSeedget called?)play(classicMain, 42, events)equals the scenes ofhistory(newGame(42), actions). Which part of the program does the left side exercise that the right side does not? Name a bug inclassicActionsorstatesthat this equality would catch and the Module 5 fixtures would not.- A golden test fails after you change the colour of the apple. Is that a bug in the program or in the test? What should you do, and what does that tell you about when golden tests are useful?
In production
- The Elm Architecture is this module, packaged: you provide
init,update,viewandsubscriptions(Elm's version of our input streams); the Elm runtime holds the model, feeds it messages and draws your view. You never write the runtime (The Elm Architecture). Elmish gives F# the same shape (Elmish). - Redux is the
scan(actions, update, initial)part: a store holds the current state, and a pure reducer computes the next one for each dispatched action (Redux fundamentals: state, actions and reducers). - cats-effect
IOAppand ZIO'sZIOAppDefaultare the "one call at the edge": yourrunmethod returns an effect value, and the library's runtime performs it (cats-effect, ZIO). An fs2 application is literally anIOthat runs aStreamof effects (fs2). - "Functional core, imperative shell" is Gary Bernhardt's name for this architecture: keep decisions in pure functions and push the effects to a thin shell (Functional Core, Imperative Shell). Our shell is thinner than usual: even the wiring is pure, only the interpreter isn't.
Further reading
- The Elm Architecture — compare
init/update/view/subscriptionswithnewGame/update/view/classicActions. - Gary Bernhardt, "Functional Core, Imperative Shell" — the architecture you just built, explained in 13 minutes.
- Gary Bernhardt, "Boundaries" — the longer talk on the same idea: values as the boundaries between parts of a system.
- MDN:
requestAnimationFrame— where the browser platform's clock comes from. - MDN:
Crypto.getRandomValues— where the seed comes from. - Martin Fowler, "Test Double" — the vocabulary for fakes, stubs and mocks; our fake platform is a "fake".
- Scala.js and Fable — how the Scala and F# tracks run in the browser.