535 lines
31 KiB
Markdown
535 lines
31 KiB
Markdown
# 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` · Scala `scala/src/Input.scala`, `scala/src/App.scala`, `scala/src/Runtime.scala` · F# `fsharp/src/Input.fs`, `fsharp/src/App.fs`, `fsharp/src/Runtime.fs`
|
||
> **Tests:** JS `cd js && node --test "test/m09-*.test.js"` · Scala `cd scala && ./test.sh M09` · F# `cd fsharp && ./test.sh M09`
|
||
> **Prerequisites:** Modules 0–8. You need working `update` (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);
|
||
- `IO` describes effects such as drawing and asking for a seed (Module 7);
|
||
- `Stream` describes 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`), `render` and `main` from the pieces of Modules 4–8;
|
||
- **explain** the type `IO<Stream<IO<unit>>>` layer by layer, and why `main` is a value;
|
||
- **implement** an interpreter `runIO` as a loop and a driver `runApp` that holds the only mutable variable of the program;
|
||
- **explain** how a `Platform` interface lets the runtime be tested with a fake instead of a browser;
|
||
- **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels.
|
||
|
||
## 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 `Action`s. 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* (`null` in JS, `None` in 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:
|
||
|
||
```js
|
||
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. `scan` alone is the list of balances *after* each transaction; `startWith` adds 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 `map`s 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 `IO`s; 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:
|
||
|
||
```js
|
||
const { ops, result: stream } = trace(main, 42)
|
||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) // main asks for a seed, nothing else
|
||
const [outs] = stream.step(time(0)) // the first event…
|
||
assert.equal(outs.length, 1) // …produces exactly one drawing: the new game
|
||
```
|
||
|
||
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**
|
||
|
||
```js
|
||
// 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`)
|
||
|
||
```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
|
||
|
||
```fsharp
|
||
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 }
|
||
```
|
||
|
||
The runtime only ever talks to a `Platform`. The browser implementation (`browser.js` in JS, `web/BrowserPlatform.scala`, `fsharp/web/BrowserPlatform.fs`) wraps a canvas, `requestAnimationFrame` and `crypto.getRandomValues`. The tests use a **fake platform** that records every call in a list and lets the test fire events by hand — a test double in the sense of [Martin Fowler's "Test Double"](https://martinfowler.com/bliki/TestDouble.html):
|
||
|
||
```js
|
||
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:
|
||
|
||
- `Draw` of a `Rect` → `platform.fillRect(x, y, w, h, color)`; of a `Text` → `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 reach `Pure(value)`; return `value`.
|
||
|
||
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:
|
||
|
||
1. **Interpret `main`** with `runIO`. This asks the platform for a seed and gives you the stream (`Stream<IO<unit>>`).
|
||
2. **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 with `stream.Value`, written with `stream.Value <- next`), because F# closures can't capture a `let mutable`, and the callbacks are closures. (The loop in `runIO` in 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.)
|
||
3. **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 `next` in the variable;
|
||
- run each IO in `ios` with `runIO`, in order.
|
||
|
||
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` — after `runApp`, the only call is `['randomSeed']`. Nothing is drawn until an event arrives (remember `startWith`).
|
||
- `runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms` — a single `fireTime(150)` produces two drawings: the `startWith` state and the state after one `Tick`. Can you explain why one event produces two frames?
|
||
- `runApp feeds key presses to the game`, and `runApp 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.
|
||
|
||
## Exercises
|
||
|
||
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", `None` instead of `null`, `()` instead of `undefined`, `bind`/`choose` instead of `flatMap`/`filterMap`, `IO.map throwingF io` instead of `map(io, throwingF)`, and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M09*.test.scala` or `fsharp/tests/M09*.fs`. Fixture-based tests have the same case names in every track.
|
||
|
||
### Exercise 9.1 — `keyToAction`
|
||
|
||
**JavaScript** (`js/src/input.js`)
|
||
|
||
```js
|
||
export const keyToAction = (key) => /* Action or null */
|
||
```
|
||
|
||
**Scala** (`scala/src/Input.scala`)
|
||
|
||
```scala
|
||
object Input:
|
||
def keyToAction(key: String): Option[Action] = ???
|
||
```
|
||
|
||
**F#** (`fsharp/src/Input.fs`)
|
||
|
||
```fsharp
|
||
// 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`)
|
||
|
||
```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`)
|
||
|
||
```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`)
|
||
|
||
```fsharp
|
||
// 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 `map`s 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)
|
||
|
||
```js
|
||
export const runIO = (io, platform) => /* the final value */
|
||
export const runApp = (main, platform) => /* undefined; registers callbacks */
|
||
```
|
||
|
||
**Scala** (`scala/src/Runtime.scala` — exactly one `var`)
|
||
|
||
```scala
|
||
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)
|
||
|
||
```fsharp
|
||
// 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`)
|
||
|
||
```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; the test is that the game runs. Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description.
|
||
|
||
**Run:**
|
||
- JS: `cd js && node --test "test/m09-*.test.js"`, then `node scripts/serve.mjs` (from the repo root) and open <http://localhost:8000/js/>
|
||
- Scala: `cd scala && ./test.sh M09`, then `./build-web.sh`; from the repository root run `node scripts/serve.mjs` and open <http://localhost:8000/scala/>
|
||
- F#: `cd fsharp && ./test.sh M09`, then `./build-web.sh`; from the repository root run `node scripts/serve.mjs` and open <http://localhost:8000/fsharp/>
|
||
|
||
## Hints
|
||
|
||
<details><summary>Exercise 9.1 — nudge</summary>
|
||
|
||
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?
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 9.2 — nudge</summary>
|
||
|
||
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.
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 9.2 — near-solution (try the others first)</summary>
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
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**
|
||
|
||
```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#**
|
||
|
||
```fsharp
|
||
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.)
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 9.3 — nudge</summary>
|
||
|
||
`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?
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 9.3 — near-solution (try the others first)</summary>
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
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**
|
||
|
||
```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#**
|
||
|
||
```fsharp
|
||
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))
|
||
(* ... *)
|
||
```
|
||
|
||
</details>
|
||
|
||
## Check your understanding
|
||
|
||
1. Read `IO<Stream<IO<unit>>>` aloud and explain each layer. Why can't the outer `IO` be removed? Why isn't the inner one a `Scene`?
|
||
2. 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?
|
||
3. One call `fireTime(150)` right after `runApp` produces two full drawings. Explain exactly which outputs of which streams cause each.
|
||
4. 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?
|
||
5. Why must `runIO` be a loop while `trace` may be recursive? Is there a program for which even the loop overflows? Whose fault would it be?
|
||
6. What makes the runtime testable without a browser? What would you have to do to test it if it called `canvas.getContext('2d')` directly?
|
||
7. Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you?
|
||
8. `main` is defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what does `trace(main, 1).ops` contain, and when does the platform's `randomSeed` get called?)
|
||
|
||
## In production
|
||
|
||
- **The Elm Architecture** is this module, packaged: you provide `init`, `update`, `view` and `subscriptions` (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](https://guide.elm-lang.org/architecture/)). **Elmish** gives F# the same shape ([Elmish](https://elmish.github.io/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](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers)).
|
||
- **cats-effect `IOApp`** and **ZIO's `ZIOAppDefault`** are the "one call at the edge": your `run` method returns an effect value, and the library's runtime performs it ([cats-effect](https://typelevel.org/cats-effect/), [ZIO](https://zio.dev/)). An fs2 application is literally an `IO` that runs a `Stream` of effects ([fs2](https://fs2.io/)).
|
||
- **"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](https://www.destroyallsoftware.com/screencasts/catalog/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](https://guide.elm-lang.org/architecture/) — compare `init`/`update`/`view`/`subscriptions` with `newGame`/`update`/`view`/`classicActions`.
|
||
- [Gary Bernhardt, "Functional Core, Imperative Shell"](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) — the architecture you just built, explained in 13 minutes.
|
||
- [MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) — where the browser platform's clock comes from.
|
||
- [MDN: `Crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) — where the seed comes from.
|
||
- [Martin Fowler, "Test Double"](https://martinfowler.com/bliki/TestDouble.html) — the vocabulary for fakes, stubs and mocks; our fake platform is a "fake".
|
||
- [Scala.js](https://www.scala-js.org/) and [Fable](https://fable.io/) — how the Scala and F# tracks run in the browser.
|