Files
2026-10-29 01:00:23 +01:00

20 KiB
Raw Permalink Blame History

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? Ops, 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.

<canvas id="c" width="200" height="100"></canvas>
<button id="bump">+1</button>
// 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

// CounterState = { count: number }
export const initialCounter = { count: 0 }

Scala

final case class CounterState(count: Int)
val initialCounter = CounterState(0)

F#

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

// CounterAction = {tag:'Bump'} | {tag:'Second'} | {tag:'Reset'}
export const CounterAction = { Bump: { tag: 'Bump' }, Second: { tag: 'Second' }, Reset: { tag: 'Reset' } }

Scala

enum CounterAction:
  case Bump, Second, Reset

F#

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

export const keyToCounterAction = (key) => {
  switch (key) {
    case ' ': return CounterAction.Bump
    case 'r': case 'R': return CounterAction.Reset
    default: return null
  }
}

Scala

def keyToCounterAction(key: String): Option[CounterAction] = key match
  case " "       => Some(CounterAction.Bump)
  case "r" | "R" => Some(CounterAction.Reset)
  case _         => None

F#

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

export const counterUpdate = (state, action) => {
  switch (action.tag) {
    case 'Bump':
    case 'Second': return { ...state, count: state.count + 1 }
    case 'Reset':  return initialCounter
  }
}

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#

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:

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

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

def counterView(state: CounterState): Scene = List(
  Shape.Rect(0, 0, 200, 100, "#111111"),
  Shape.Text(100, 60, state.count.toString, "#ffffff", 32, "center"),
)

F#

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

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

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#

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:

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

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

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

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

  • 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