377 lines
20 KiB
Markdown
377 lines
20 KiB
Markdown
# The method: from a stateful game to a functional one
|
||
|
||
> **When to read this:** after Module 9 (you need `IO`, streams and the runtime), and before Module 11, which asks you to apply it to Pong on your own. Come back to it whenever you convert something.
|
||
> **What it is:** the course, compressed into a recipe you can carry to other programs — with a small worked example, a checklist and a list of the mistakes people make most often.
|
||
|
||
## The recipe in one page
|
||
|
||
You've built Snake in a particular order. That order wasn't arbitrary: each step removes one kind of hidden impurity and replaces it with a value. Here it is as a general method.
|
||
|
||
| Step | Question to ask the stateful code | What you produce | Where you did it for Snake |
|
||
|---|---|---|---|
|
||
| 0 | What does it remember, react to, and do? | an inventory | (implicitly, Module 2) |
|
||
| 1 | **State → value.** Which variables change over time? | one immutable state type and an initial value | Module 2 (`GameState`, `initialState`) |
|
||
| 2 | **Events → data.** What can happen to it? | an action type (a tagged union) | Module 2 (`Action`), Module 9 (`keyToAction`) |
|
||
| 3 | **Update → fold.** How does each event change the state? | `update(state, action) → state`, pure | Modules 3–5, 10 |
|
||
| 4 | **View → data.** What does it show? | `view(state) → Scene` | Module 6 |
|
||
| 5 | **Effects → values.** What does it ask of or do to the world? | `Op`s, `IO` programs | Module 7 |
|
||
| 6 | **Wiring → streams.** Where do events come from over time? | action stream, `startWith(scan(…))`, `main` | Modules 8–9 |
|
||
| 7 | **One runtime at the edge.** Who performs it all? | one interpreter, one mutable variable | Module 9 |
|
||
|
||
Two rules hold throughout:
|
||
|
||
- **Hidden inputs become explicit inputs.** Time, randomness, keyboard: if the code *reads* it from somewhere, it must instead *receive* it — as an argument, a field of the state, or an event.
|
||
- **Hidden outputs become returned values.** Drawing, saving, logging: if the code *does* it, it must instead *return a description* of it.
|
||
|
||
The rest of this page walks the recipe through a program small enough to see in one go.
|
||
|
||
## A worked example: the auto-counter
|
||
|
||
### The stateful version
|
||
|
||
Here is a tiny program written the usual way. It shows a number on a canvas. Clicking a button adds one. Every second, it also adds one by itself. Pressing R resets it to zero.
|
||
|
||
```html
|
||
<canvas id="c" width="200" height="100"></canvas>
|
||
<button id="bump">+1</button>
|
||
```
|
||
|
||
```js
|
||
// The usual, stateful way: the program we are going to convert
|
||
let count = 0
|
||
const ctx = document.getElementById('c').getContext('2d')
|
||
|
||
function draw() {
|
||
ctx.fillStyle = '#111111'
|
||
ctx.fillRect(0, 0, 200, 100)
|
||
ctx.fillStyle = '#ffffff'
|
||
ctx.font = '32px monospace'
|
||
ctx.textAlign = 'center'
|
||
ctx.fillText(String(count), 100, 60)
|
||
}
|
||
|
||
document.getElementById('bump').addEventListener('click', () => { count++; draw() })
|
||
window.addEventListener('keydown', (e) => { if (e.key === 'r' || e.key === 'R') { count = 0; draw() } })
|
||
setInterval(() => { count++; draw() }, 1000)
|
||
draw()
|
||
```
|
||
|
||
Fifteen lines, and it works. Let's convert it anyway, because the conversion is the point, and because every problem of a big game is already visible here in miniature.
|
||
|
||
### Step 0 — Make an inventory
|
||
|
||
Read the code and sort everything into three lists. Do it on paper before reading the answer.
|
||
|
||
| Remembers (state) | Reacts to (events) | Does (effects) |
|
||
|---|---|---|
|
||
| `count` | a click on the button | draws on the canvas (5 calls that depend on hidden `ctx` settings) |
|
||
| *the phase of the timer* (when the next second will fire) | a key going down (only R matters) | starts a timer |
|
||
| | a second passing | registers two listeners |
|
||
|
||
Did you get the second line of the first column? It's easy to miss: `setInterval` *remembers* when it last fired. That's state, too — **hidden time**. Here's a question the stateful code answers silently and you now have to answer on purpose: *when the user presses R at 2.9 s, should the next automatic increment come at 3.0 s (0.1 s later) or at 3.9 s (a full second later)?* The stateful code says 3.0 s, without anyone having decided it. Converting forces the decision into the open. We'll keep the stateful behaviour (the clock beats on its own, regardless of resets), because that's exactly what `interval` does.
|
||
|
||
### Step 1 — State becomes a value
|
||
|
||
All the program remembers is a number. We'll still wrap it in a record, because a game state always grows (a high score, a pause flag…), and adding fields to a record is easier than turning a number into a record later.
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
// CounterState = { count: number }
|
||
export const initialCounter = { count: 0 }
|
||
```
|
||
|
||
**Scala**
|
||
|
||
```scala
|
||
final case class CounterState(count: Int)
|
||
val initialCounter = CounterState(0)
|
||
```
|
||
|
||
**F#**
|
||
|
||
```fsharp
|
||
type CounterState = { Count: int }
|
||
let initialCounter = { Count = 0 }
|
||
```
|
||
|
||
The timer's phase is *not* in the state. Our `interval` stream (Module 8) carries its own `last` from step to step; the game doesn't need to know it.
|
||
|
||
### Step 2 — Events become data
|
||
|
||
What can happen? Look at the "reacts to" column, and describe each event by what it *means* to the program, not by where it comes from:
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
// CounterAction = {tag:'Bump'} | {tag:'Second'} | {tag:'Reset'}
|
||
export const CounterAction = { Bump: { tag: 'Bump' }, Second: { tag: 'Second' }, Reset: { tag: 'Reset' } }
|
||
```
|
||
|
||
**Scala**
|
||
|
||
```scala
|
||
enum CounterAction:
|
||
case Bump, Second, Reset
|
||
```
|
||
|
||
**F#**
|
||
|
||
```fsharp
|
||
type CounterAction = Bump | Second | Reset
|
||
```
|
||
|
||
Now the click. Our `Platform` from Module 9 knows keys and time, but not mouse clicks. There are two honest options:
|
||
|
||
1. **Extend the edge.** Add a `{tag:'Click'}` event to the stream inputs, an `onClick(cb)` method to the `Platform`, a `clicks` source stream, and one line in `runApp` to register it. Notice that *every one of those changes is at the edge or in the stream library*; the counter's logic wouldn't change.
|
||
2. **Map another input to the same meaning.** Let Space mean "bump".
|
||
|
||
We'll take option 2 here so that the example runs on your existing runtime unchanged — and that choice is only possible *because* we named the action by its meaning (`Bump`), not by its source (`Click`). The translation from keys is a pure function, like `keyToAction`:
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
export const keyToCounterAction = (key) => {
|
||
switch (key) {
|
||
case ' ': return CounterAction.Bump
|
||
case 'r': case 'R': return CounterAction.Reset
|
||
default: return null
|
||
}
|
||
}
|
||
```
|
||
|
||
**Scala**
|
||
|
||
```scala
|
||
def keyToCounterAction(key: String): Option[CounterAction] = key match
|
||
case " " => Some(CounterAction.Bump)
|
||
case "r" | "R" => Some(CounterAction.Reset)
|
||
case _ => None
|
||
```
|
||
|
||
**F#**
|
||
|
||
```fsharp
|
||
let keyToCounterAction (key: string) : CounterAction option =
|
||
match key with
|
||
| " " -> Some Bump
|
||
| "r" | "R" -> Some Reset
|
||
| _ -> None
|
||
```
|
||
|
||
### Step 3 — The update is a fold
|
||
|
||
Each callback in the stateful code did "change `count`, then draw". Keep the first half, drop the second (drawing is step 4's job), and turn "change `count`" into "return a new state":
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
export const counterUpdate = (state, action) => {
|
||
switch (action.tag) {
|
||
case 'Bump':
|
||
case 'Second': return { ...state, count: state.count + 1 }
|
||
case 'Reset': return initialCounter
|
||
}
|
||
}
|
||
```
|
||
|
||
**Scala**
|
||
|
||
```scala
|
||
def counterUpdate(state: CounterState, action: CounterAction): CounterState = action match
|
||
case CounterAction.Bump | CounterAction.Second => state.copy(count = state.count + 1)
|
||
case CounterAction.Reset => initialCounter
|
||
```
|
||
|
||
**F#**
|
||
|
||
```fsharp
|
||
let counterUpdate (state: CounterState) (action: CounterAction) : CounterState =
|
||
match action with
|
||
| Bump | Second -> { state with Count = state.Count + 1 }
|
||
| Reset -> initialCounter
|
||
```
|
||
|
||
The first payoff is immediate: the whole behaviour can be tested with a list, no browser, no waiting a second:
|
||
|
||
```js
|
||
const actions = [CounterAction.Bump, CounterAction.Second, CounterAction.Bump, CounterAction.Reset, CounterAction.Second]
|
||
assert.deepStrictEqual(actions.reduce(counterUpdate, initialCounter), { count: 1 })
|
||
```
|
||
|
||
That's Module 5's "a program is a fold over its events", in one line.
|
||
|
||
### Step 4 — The view returns data
|
||
|
||
The stateful `draw` set three hidden context properties and made two calls. As a `Scene`, every setting travels with its shape:
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
export const counterView = (state) => [
|
||
{ tag: 'Rect', x: 0, y: 0, w: 200, h: 100, color: '#111111' },
|
||
{ tag: 'Text', x: 100, y: 60, text: String(state.count), color: '#ffffff', size: 32, align: 'center' },
|
||
]
|
||
```
|
||
|
||
**Scala**
|
||
|
||
```scala
|
||
def counterView(state: CounterState): Scene = List(
|
||
Shape.Rect(0, 0, 200, 100, "#111111"),
|
||
Shape.Text(100, 60, state.count.toString, "#ffffff", 32, "center"),
|
||
)
|
||
```
|
||
|
||
**F#**
|
||
|
||
```fsharp
|
||
let counterView (state: CounterState) : Scene =
|
||
[ Rect (0, 0, 200, 100, "#111111")
|
||
Text (100, 60, string state.Count, "#ffffff", 32, "center") ]
|
||
```
|
||
|
||
Test: `assert.deepStrictEqual(counterView({ count: 7 })[1].text, '7')`.
|
||
|
||
### Step 5 — Effects become values
|
||
|
||
Which effects remain? Drawing — and that's covered: `drawScene(counterView(state))` is an `IO` that draws a frame. Starting the timer and registering listeners aren't effects *of the program*; they're the runtime's job (step 7). And there's no randomness, so we don't need `randomSeed`.
|
||
|
||
That last observation has a nice consequence. `main` must have type `IO<Stream<IO<unit>>>`, but our counter needs nothing from the world to *build* its stream. The `IO` that does nothing and produces a value is `pure` from Module 7. So the outer layer will be `pure(…)`.
|
||
|
||
### Step 6 — Wire the streams
|
||
|
||
Exactly the shape of Snake's `app`:
|
||
|
||
**JavaScript**
|
||
|
||
```js
|
||
import { pure, drawScene } from './io.js'
|
||
import { interval, keyDowns, map, filterMap, merge, scan, startWith } from './stream.js'
|
||
|
||
export const counterActions = merge(
|
||
map(interval(1000), () => CounterAction.Second),
|
||
filterMap(keyDowns, keyToCounterAction),
|
||
)
|
||
export const counterStates = startWith(scan(counterActions, counterUpdate, initialCounter), initialCounter)
|
||
export const counterMain = pure(map(counterStates, (s) => drawScene(counterView(s))))
|
||
```
|
||
|
||
**Scala**
|
||
|
||
```scala
|
||
lazy val counterActions: Stream[CounterAction] =
|
||
Stream.interval(1000).map(_ => CounterAction.Second).merge(Stream.keyDowns.filterMap(keyToCounterAction))
|
||
lazy val counterStates: Stream[CounterState] = counterActions.scan(initialCounter)(counterUpdate).startWith(initialCounter)
|
||
lazy val counterMain: IO[Stream[IO[Unit]]] = IO.pure(counterStates.map(s => IO.drawScene(counterView(s))))
|
||
```
|
||
|
||
**F#**
|
||
|
||
```fsharp
|
||
let counterActions : Stream<CounterAction> =
|
||
Stream.merge (Stream.interval 1000L |> Stream.map (fun _ -> Second)) (Stream.keyDowns |> Stream.choose keyToCounterAction)
|
||
let counterStates : Stream<CounterState> =
|
||
counterActions |> Stream.scan counterUpdate initialCounter |> Stream.startWith initialCounter
|
||
let counterMain : IO<Stream<IO<unit>>> = IO.pure (counterStates |> Stream.map (counterView >> IO.drawScene))
|
||
```
|
||
|
||
And the second payoff: the *timing* behaviour, which in the stateful version you could only check by watching a screen for a few seconds, is now a pure test:
|
||
|
||
```js
|
||
const time = (t) => ({ tag: 'Time', t }), down = (key) => ({ tag: 'KeyDown', key })
|
||
assert.deepStrictEqual(
|
||
run(counterStates, [time(500), down(' '), time(1000), down('r'), time(2100)]).map((s) => s.count),
|
||
[0, 1, 2, 0, 1])
|
||
```
|
||
|
||
Work it out by hand: at 500 ms nothing ticks but `startWith` emits the initial state; Space bumps to 1; at 1000 ms the first second ticks (2); R resets (0); at 2100 ms the second "second" ticks (1). This test also *documents* the decision from step 0 — the reset didn't restart the clock.
|
||
|
||
### Step 7 — One runtime at the edge
|
||
|
||
```js
|
||
// counter-main.js — the only impure line of the program
|
||
runApp(counterMain, browserPlatform(document.getElementById('c'), window))
|
||
```
|
||
|
||
Nothing to write: the runtime from Module 9 runs it. Compare with where we started. The fifteen stateful lines had a mutable `count`, three callbacks that each both changed state and drew, and a timer with hidden phase. Now there's a state type, an action type, three pure functions (`keyToCounterAction`, `counterUpdate`, `counterView`), three lines of wiring, and one line at the edge. It's longer — for a fifteen-line program, the functional version *is* longer. What you bought is that every behaviour, including the timing and the reset decision, is a value you can test with `deepEqual`, and the program can grow without anybody having to remember which callbacks touch which variables.
|
||
|
||
*(Want to run it? Put the JS in a file next to `app.js`, copy `index.html` to `counter.html` with a 200 × 100 canvas with id `c`, and point its script tag to a `counter-main.js` containing the line above plus the three imports.)*
|
||
|
||
## The checklist
|
||
|
||
Use this when you convert anything. Each line is a yes/no question about your result.
|
||
|
||
**State**
|
||
- [ ] Is everything the program remembers in one immutable state value (or derivable from it)?
|
||
- [ ] Is there a function that builds the initial state from explicit inputs (e.g. a seed)?
|
||
- [ ] Is anything stored twice (e.g. a `length` next to the list it measures)? Remove derived fields.
|
||
|
||
**Events**
|
||
- [ ] Is every way the program can be influenced an action value?
|
||
- [ ] Are actions named by *meaning* (`Bump`, `Turn Up`), with a pure translation from raw input (`keyToAction`)?
|
||
- [ ] Is "a key being held" (if needed) tracked in the state from press/release events?
|
||
|
||
**Update**
|
||
- [ ] Is `update(state, action)` pure: no reads of time, randomness, the DOM, or globals; no writes anywhere?
|
||
- [ ] Is randomness a seed in the state, threaded through a `Random` function?
|
||
- [ ] Does every action handle every status explicitly (paused, over…)?
|
||
- [ ] Can you `replay` a list of actions in a test?
|
||
|
||
**View**
|
||
- [ ] Does `view(state)` return data (a `Scene`) and nothing else?
|
||
|
||
**Effects**
|
||
- [ ] Is every interaction with the world an `Op`, described by an `IO` value?
|
||
- [ ] Can a pure interpreter (`trace`) observe every effect in a test?
|
||
|
||
**Wiring and edge**
|
||
- [ ] Is `main` a value of type `IO<Stream<IO<unit>>>` (or similar)?
|
||
- [ ] Is there exactly one impure runtime, holding exactly one mutable variable (the current stream), that knows nothing about the game?
|
||
- [ ] Is time an *input* (events), never a *query*?
|
||
|
||
## Common pitfalls
|
||
|
||
**Hidden time.** `Date.now()` or `performance.now()` inside the logic, "move faster after 30 seconds" implemented by reading a clock, or a speed that depends on how often the browser calls you. *Fix:* time arrives as `Time t` events; turn it into actions with `interval`; measure durations by counting frames in the state (Module 10's `frame` and `movesEvery`).
|
||
|
||
**Hidden randomness.** `Math.random()` in the update ("spawn the apple somewhere"). It makes tests flaky and replays impossible. *Fix:* a seed in the state, `[value, nextSeed] = nextUint32(seed)`, and store the next seed (Module 4). Only the *first* seed comes from the world, through `randomSeed`.
|
||
|
||
**Mutation in loops.** `for (…) result.push(…)`, `board[y][x] = …`, `list.sort()` (which sorts in place). It's tempting when the rule is naturally "go through all the bricks and remove the hit ones". *Fix:* `map`/`filter`/`reduce`, building new arrays; `[...xs].sort()` or a non-mutating `toSorted()` if you must sort; for grids, a list of rows rebuilt with `map`.
|
||
|
||
**Effects in update.** A `console.log` for debugging that stays, a `localStorage.setItem` "just when the game ends", a sound played on eating. Each one makes `update` untestable and order-dependent. *Fix:* return data. For a sound or a save, add an `Op` and produce the `IO` from the state stream (see Module 10's high-score thought exercise). For debugging, use `history` and `trace` instead of logging.
|
||
|
||
**State in the runtime.** "I'll just keep `upHeld` in the runtime and pass it in." Then the runtime knows about the game and half the behaviour can't be replayed. *Fix:* the runtime holds only the current stream; held keys are state derived from `Press`/`Release` actions (Module 11).
|
||
|
||
**Changing the clock instead of counting.** `clearInterval`/`setInterval` to speed up. *Fix:* one fixed clock; decide in `update` which frames matter (Module 10).
|
||
|
||
**Forgetting to wrap the next stream.** A combinator that returns the inner `next` instead of `combinator(next, …)` works for exactly one event. *Fix:* every combinator's next stream is the same combinator around the inner next stream(s) (Module 8).
|
||
|
||
**Left-nested `flatMap` chains.** Building a long sequence with a left fold overflows the stack in the interpreter's continuations. *Fix:* fold right (Module 7, section 12).
|
||
|
||
## Where to go next: more conversions
|
||
|
||
Each of these is a good exercise in the method. Start from the inventory (step 0), write the state and action types first, and write fixtures for `update` before the code.
|
||
|
||
**Tetris** ([Wikipedia](https://en.wikipedia.org/wiki/Tetris)).
|
||
- *State:* the board as a list of rows (each a list of cells, empty or a colour), the falling piece (shape, rotation, position), the next piece, score, lines, level, seed, frame, status.
|
||
- *Events:* `Left`, `Right`, `Rotate`, `SoftDrop`, `HardDrop`, `Frame`, `Pause`, `Restart`.
|
||
- *Hints:* gravity is `movesEvery(level)` on `Frame`, exactly like Snake's speed. Rotation is a pure function on a list of points; check collisions by asking "would the rotated piece overlap or leave the board?" and keep the old piece if so. Line clearing is `filter` (remove full rows) plus prepending empty rows. The "7-bag" randomizer (shuffle the seven pieces, deal them out) is a nice use of the `Random` state monad: a shuffle is a `Random<Piece[]>`.
|
||
|
||
**Breakout** ([Wikipedia](https://en.wikipedia.org/wiki/Breakout_%28video_game%29)).
|
||
- *State:* ball, velocity, paddle x, held keys, bricks, lives, score, status.
|
||
- *Events:* `Press`/`Release` (left/right), `Frame`, `Launch`, `Restart`.
|
||
- *Hints:* reuse Pong's held-key pattern and its step-by-step frame pipeline. Represent bricks as a list (or a set) of positions; a collision step returns the remaining bricks and the bounced velocity together. Decide, and write a fixture for, what happens when the ball hits two bricks in one frame.
|
||
|
||
**Conway's Game of Life** ([Wikipedia](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)).
|
||
- *State:* the set of live cells (a list of points, or a set of `"x,y"` strings in JS), generation, paused flag, speed.
|
||
- *Events:* `Frame`, `TogglePause`, `Step` (one generation while paused), `Clear`, `Randomize` (needs a seed), and — for editing — clicking a cell.
|
||
- *Hints:* `step(cells)` is the purest function you'll ever write: count neighbours with `map`/`filter`, keep cells with 2–3 neighbours, birth cells with exactly 3. Clicking needs a new input type and a new `Platform` method (`onClick(cb: (x, y) => void)`): that's a change to the stream inputs, the runtime and the browser platform, but *not* to the game logic. Doing it will teach you exactly where the edge is.
|
||
|
||
## Further reading
|
||
|
||
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — this method as a framework; compare its parts with the steps above.
|
||
- [Gary Bernhardt, "Functional Core, Imperative Shell"](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) — the architectural idea behind steps 5–7.
|
||
- [Redux: Style Guide](https://redux.js.org/style-guide/) — a production team's list of the pitfalls above (pure reducers, serializable state, no side effects in reducers).
|
||
- [*Functional Programming in Scala*, 2nd edition](https://www.manning.com/books/functional-programming-in-scala-second-edition) — chapters 6 (purely functional state), 13 (external effects and I/O) and 15 (stream processing) cover steps 3, 5 and 6 in depth.
|
||
- [Robert Nystrom, Game Programming Patterns](https://gameprogrammingpatterns.com/game-loop.html) — the "Game Loop" and "Update Method" chapters show the stateful designs you now know how to convert.
|