Files
functional-programming-snake/course/13-capstone-life.md
T
Lucas WintherandClaude Fable 5.2 edd57f0148 feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
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>
2026-11-11 12:05:29 +01:00

40 KiB
Raw Blame History

Module 13 — Capstone: Conway's Life, with the mouse

Time: ~6–8 h (spread it over several sessions) · You'll edit: JS js/src/life/model.js, rules.js, view.js, app.js, and at the edge js/src/runtime.js and js/src/browser.js · Scala scala/src/life/Model.scala, Rules.scala, View.scala, App.scala, and scala/src/Runtime.scala · F# fsharp/src/Life/Model.fs, Rules.fs, View.fs, App.fs, and fsharp/src/Runtime.fs Tests: JS cd js && node --test "test/m13-*.test.js" · Scala cd scala && ./test.sh M13 · F# cd fsharp && ./test.sh M13 — and afterwards all tests of your track (./test.sh) Prerequisites: Modules 0–11 and the method. Module 12 is not needed.

Why this module

Pong was a second game, but a close cousin of Snake: a board, a clock, keys. Everything it needed from the outside world was already wired. This capstone is different in two ways.

First, the player uses the mouse: clicking a cell switches it on or off. Nothing in the course so far has received a click. The runtime registers three callbacks (keys down, keys up, the clock), the platform offers three ways to listen, and the stream inputs have three cases. A fourth kind of input means changing the edge itself, the one place you have not touched since Module 9 except for the high score. You will see exactly how much that change costs, and why Snake and Pong do not notice it.

Second, the rules of the game look trivial and hide the classic bug of stateful simulations: updating the cells one by one, in place, so that later cells see a board that is half old and half new. In this course you cannot write that bug, and the reason why is worth understanding precisely.

The game is Conway's Game of Life (Wikipedia): not really a game, but a "zero-player" world of cells on a grid that live, die and are born by three rules, invented by John Conway in 1970 (Martin Gardner's column in Scientific American, October 1970). You draw a starting pattern with the mouse, start the clock, and watch.

As in Module 11, the scaffolding is light: the rules and tests are complete, the hints ask questions and stop before a skeleton. Look at your Snake and Pong code first; you have written every pattern you need, except one.

Learning objectives

By the end of this module you can:

  • apply the conversion method to a simulation whose state is a whole board;
  • explain why the next generation must be computed from the whole old board, demonstrate the bug of the in-place loop on a blinker, and explain why immutable data rules it out;
  • choose a canonical representation for a set of cells, and justify it with the needs of tests in three languages;
  • implement a neighbour count that is fast enough without mutation;
  • add a new kind of input to the edge (a stream source, a runtime registration, a platform method) without changing any existing game;
  • reuse Random, IO, streams, clampTime and the runtime for a third program.

The game, as you'd find it

Here is a typical stateful implementation sketch. Read it the way you read the Pong sketch: count the hidden things.

// The usual, stateful way (a sketch to convert; do NOT write this in the course)
const W = 40, H = 30, SIZE = 16
let grid = Array.from({ length: H }, () => Array(W).fill(false))
let running = false, generation = 0

canvas.addEventListener('click', (e) => {
  const x = Math.floor(e.offsetX / SIZE), y = Math.floor(e.offsetY / SIZE)
  grid[y][x] = !grid[y][x]
  draw()
})
window.addEventListener('keydown', (e) => {
  if (e.key === ' ') running = !running
  if (e.key === 'n' && !running) { step(); draw() }
  if (e.key === 'c') { grid = grid.map((row) => row.map(() => false)); generation = 0; draw() }
  if (e.key === 'r') { grid = grid.map((row) => row.map(() => Math.random() < 0.25)); generation = 0; draw() }
})
setInterval(() => { if (running) { step(); draw() } }, 150)

function step() {
  const next = grid.map((row) => row.slice())            // forget this line and see the next section
  for (let y = 0; y < H; y++)
    for (let x = 0; x < W; x++) {
      const n = countNeighbours(grid, x, y)
      next[y][x] = n === 3 || (n === 2 && grid[y][x])
    }
  grid = next
  generation++
}

Hidden state: grid, running, generation, and the timer's phase. Hidden randomness: Math.random. Hidden effects: draw() in four places. And one thing that is new: an event (click) that arrives with a position, in the coordinates of the page, which the code turns into a cell with offsetX (and which breaks as soon as the canvas is scaled by CSS or has a border; we will come back to that at the edge).

The rules, precisely

The tests are shared fixtures, so the rules must be exact.

The board is LW = 40 cells wide and LH = 30 cells high; each cell is LCELL = 16 pixels. Under the board the picture has a status strip STRIP = 24 pixels high, so the canvas is 640 × 504 pixels. A cell is a point {x, y} with 0 <= x < 40 and 0 <= y < 30.

The state is {cells, running, seed, generation}: the live cells, whether the clock makes generations, the seed for Randomize, and how many generations have been computed since the last Clear or Randomize. cells is canonical: sorted row-major (by y, then by x), each cell at most once, all on the board. A new world (initialLife(seed)) has no live cells, is paused, and is at generation 0.

One generation (lifeStep(cells)), the classic rules, called B3/S23:

  • a live cell with 2 or 3 live neighbours stays alive;
  • a dead cell with exactly 3 live neighbours is born;
  • every other cell is dead in the next generation.

The neighbours of a cell are the up to eight cells around it that are on the board. The edges are dead: nothing wraps around, so a cell in column 39 is not a neighbour of a cell in column 0. Every cell is judged by the old board only. The result is canonical again.

Actions:

  • Frame (every GEN_MS = 150 ms): when running, the next generation (lifeStep) and generation + 1; when paused, nothing.
  • Step (key n/N): when paused, the next generation and generation + 1; when running, nothing.
  • TogglePause (Space): running flips.
  • Toggle(cell) (a click on that cell): a dead cell becomes alive and a live one dead, whether the world is running or paused. cells stays canonical; generation does not change. A cell off the board changes nothing.
  • Clear (key c/C): no live cells, generation 0; running and seed stay.
  • Randomize (key r/R): for every cell of the board in row-major order (row 0 from x = 0 to 39, then row 1, …), draw intBelow(4) from Module 4, threading the seed from state.seed; a cell is alive when its draw is 0 (so about one cell in four lives). The state gets those cells, the seed after the 1200th draw, and generation 0; running stays.

A generation on an empty running board still counts (frame-on-an-empty-running-board-still-counts-the-generation): an empty world ages like any other.

Keys: Space → TogglePause, n/N → Step, c/C → Clear, r/R → Randomize; everything else is ignored. Clicks: a click at canvas pixel (x, y) is Toggle of the cell (floor(x / 16), floor(y / 16)) when that pixel is on the board; a click left of, above, right of or below the board (the status strip) is ignored.

The picture (lifeView), drawn in order, all text '#ffffff', size 14:

  • the board Rect(0, 0, 640, 480, '#111111');
  • one Rect(x*16 + 1, y*16 + 1, 14, 14, '#7cb342') per live cell, in the order of cells;
  • the status strip Rect(0, 480, 640, 24, '#222222');
  • Text(6, 497, 'Generation <n>', …, 'left');
  • Text(634, 497, 'Running: Space pauses' when running, else 'Paused: Space runs, N steps', …, 'right').

Why the whole old board: the in-place loop

Look again at the sketch's step. Its first line copies the grid into next, the loop reads only grid and writes only next, and the last line swaps them. Here is the version that people write first, because it looks like the same thing with one variable fewer:

// The classic bug: one grid, updated in place
function step() {
  for (let y = 0; y < H; y++)
    for (let x = 0; x < W; x++) {
      const n = countNeighbours(grid, x, y)
      grid[y][x] = n === 3 || (n === 2 && grid[y][x])
    }
}

Before reading on, run it by hand on a horizontal blinker: live cells (4,5), (5,5), (6,5). The right answer is the vertical blinker (5,4), (5,5), (5,6) (fixture a-horizontal-blinker-turns-vertical). What does the loop produce?

Think about it first, then open the trace

The loop visits row 4 before row 5, and within a row, left to right.

cell    live neighbours it counts                      decision       correct?
(5,4)   (4,5) (5,5) (6,5)                       = 3    born           yes
(6,4)   (5,4)* (5,5) (6,5)                      = 3    born           no: (5,4) was dead in the old board
(4,5)   (5,4)* (5,5)                            = 2    stays alive    no: it had 1 neighbour
(5,5)   (5,4)* (6,4)* (4,5) (6,5)               = 4    dies           no: it had 2 and should stay
(6,5)   (5,4)* (6,4)*                           = 2    stays alive    no: (5,5) is already dead, and it had 1
(5,6)   (4,5) (6,5)                             = 2    stays dead     no: it had 3 and should be born
                                                   (* = a value written during this very loop)

The result is (5,4), (6,4), (4,5), (6,5): four cells, lopsided, and not a blinker at all. Each cell was judged by a board in which the cells above it and to its left were already the next generation and the others were still the old one. The answer even depends on the order of the loops: scan the columns from right to left and you get a different wrong picture.

The rules say "every cell is judged by the old board". The in-place loop breaks that sentence, and nothing warns you: it runs, it draws something plausible, and still-lifes like the block even come out right, so a quick test passes.

The stateful fix is discipline: keep two grids, read one, write the other, swap. In the pure version there is nothing to remember, because you cannot write the in-place loop at all. cells is an immutable value; lifeStep(cells) can only read it and return a new list. There is no "half-updated board" anywhere, because the old board never changes while the new one is built. What the stateful programmer must remember to do (copy first, write to the copy) is the only thing the pure version can do.

From everyday programming: this is the reason spreadsheets do not recalculate cells in the order you typed them, and why a database transaction reads a consistent snapshot ("snapshot isolation") instead of rows that other writers are changing underneath it. Image filters have the same trap: a blur that writes each blurred pixel back into the image it is still reading smears in one direction. The fix is always the same: read from a version that does not change while you write the next one.

Applying the method

Open method.md next to this page. Each step names the step of the method, asks you questions to answer before reading the course's choice, and then gives the canonical choice (fixed, because the fixtures depend on it). Write your own answer first, then compare.

Step 1 — Identify the state; make it one value

List every let of the sketch. Then ask:

  • The sketch stores a 40 × 30 grid of booleans. What else could store "which cells are alive"? Think of at least two other representations and what each makes easy or hard (counting neighbours, toggling one cell, comparing two boards in a test, writing a fixture).
  • If two states describe the same live cells, should they be equal in a test? What does that demand of your representation?
  • Is generation state, or could it be computed?
  • Where must the randomness live so that Randomize is a pure function?

Canonical choice: the live cells as a list of points, sorted row-major, without duplicates.

JavaScript (js/src/life/model.js)

// LifeState = { cells: Point[] (canonical), running: boolean, seed: number, generation: number }
// constants: LW = 40, LH = 30, LCELL = 16, STRIP = 24 (given)

Scala (scala/src/life/Model.scala, given; package snake.life)

final case class LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)

object LifeModel:
  val LW = 40
  val LH = 30
  val LCELL = 16
  val STRIP = 24
  def initialLife(seed: Long): LifeState = ???

F# (fsharp/src/Life/Model.fs, module Snake.Life.Model, the record given)

type LifeState =
    { Cells: Point list
      Running: bool
      Seed: uint32
      Generation: int }

// LW = 40, LH = 30, LCELL = 16, STRIP = 24 (given)
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"

Why a sorted list and not a grid or a set? A grid of 1200 booleans makes every fixture 1200 values long, and a glider would be hard to see in it. A set says exactly what we mean ("these cells are alive"), but sets are printed and compared differently in the three languages, and the order in which you walk through one is not the same in JavaScript, Scala and F#. A list in one agreed order has neither problem: two states with the same live cells are equal (deepStrictEqual, ==, =) without any special comparison, a fixture is a short readable list, and the three tracks agree on every byte. The price is a rule every function must keep: whatever goes in canonical comes out canonical. That rule is a small version of something databases do all the time (a primary key that keeps rows in order), and it is tested: every fixture compares lists, so a result in the wrong order fails.

Nothing stops lifeStep from building a set inside itself for fast lookups (step 3). Canonical is a promise about the values that cross function boundaries and land in fixtures, not about how a function works inside.

Step 2 — Identify the events; make them data

What can happen to the world? List the sketch's callbacks. Then decide: should a click be an action Click(x, y) in pixels, or something else? Where should "which cell is under the mouse" be worked out?

Canonical choice: six actions. A click becomes Toggle(cell), a meaning, as keys became Turn(dir) in Snake.

JavaScript

// LifeAction = {tag:'Frame'} | {tag:'Toggle', cell} | {tag:'TogglePause'} | {tag:'Step'} | {tag:'Clear'} | {tag:'Randomize'}
// constructors: LifeAction.Toggle(cell), and the plain values LifeAction.Frame, .TogglePause, .Step, .Clear, .Randomize

Scala (given)

enum LifeAction:
  case Frame
  case Toggle(cell: Point)
  case TogglePause, Step, Clear, Randomize

F# (given)

[<RequireQualifiedAccess>]
type LifeAction =
    | Frame
    | Toggle of Point
    | TogglePause
    | Step
    | Clear
    | Randomize

([<RequireQualifiedAccess>] for the reason Module 11 gives: Snake's Action also has Frame and TogglePause.)

The translation from pixels to a cell is a pure function, lifeClick(x, y), next to lifeKey(key). The update never sees a pixel; the input stream never sees a cell. Each side speaks its own language, and one small, fixture-tested function translates.

Question: why is Toggle better than separate Birth(cell) and Kill(cell) actions for a click? When would the second pair be the better design?

Step 3 — Write update as a fold

lifeUpdate(state, action) is your Life update, and lifeStep(cells) is the heart of it. Questions to answer before writing code:

  • How will you count the live neighbours of a cell? How many operations does your way take per generation, for an empty board and for a full one? Is that fast enough to stay well under 150 ms?
  • How do you produce the next generation in canonical order without sorting? (Hint: which list of cells is already in row-major order, before you know anything about the board?)
  • Toggle must insert a cell at its row-major place, or remove it. How do you do that with filter alone?
  • Randomize needs 1200 draws in a fixed order, each with the seed the previous one left behind. Which tool from Module 4 (or from Module 12, if you did it) threads a seed through a list of draws?
  • Should clicking toggle cells only while paused, or always?

The canonical choices:

  • Counting. Two ways are fast enough, and both are pure. The straightforward one: for each of the 1200 board cells, count the live cells in the list that are one step away in both directions. That is 1200 × (number of live cells) comparisons, at most about 1.4 million when the board is full, which every language in the course does in a few milliseconds. The faster one: first build a lookup set of the old live cells (a Set of numbers y * LW + x in JS, because a JS Set of objects compares them by identity; a Set[Point] in Scala; a Set<Point> in F#), then ask it about each of the eight neighbours: 1200 × 8 lookups, however many cells live. The reference solution does the second. The set is built once per generation from the old list and only read afterwards, so it is as immutable as everything else.
  • Order for free. Make a constant list of all 1200 board cells, row-major, once. The next generation is that list filtered by the rules. A filter keeps order, so the result is canonical without a sort.
  • Toggle. If the cell is in the list, filter it out. Otherwise the new list is "the cells before it", the cell, "the cells after it", where before and after compare y * LW + x. Off the board, nothing changes.
  • Randomize. A fold over the 1200 board cells that carries the seed and collects the draws (or listOf(intBelow(4), 1200) if you built it in Module 12), then the board cells whose draw is 0. The order of the draws is part of the contract: it is the only way three languages can produce the same random board from the same seed, and the test Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0 checks it against Module 4's generator.
  • Clicks while running. The course's choice is: always. A player who clicks a running world and sees nothing happen thinks the program is broken; drawing into a running world is part of the fun of Life; and it is perfectly well defined here, because a click is one action between two generations, never in the middle of one (the fold applies one action at a time). In the stateful sketch the same question has a subtler answer, since the click handler and the timer callback both write to the same grid. The other policy, "only while paused", is a one-line guard in update; question 3 asks you to compare.

Step 4 — Write the view as data

lifeView(state) returns a Scene, exactly as specified in the rules. Because cells is canonical, the order of the cell rectangles is fixed too, and the view fixture can compare the scene as a list.

Step 5 — Effects as values

Which effects does Life need? Drawing, and a seed at the start, which randomSeed already describes; Randomize itself is pure. So: no new Op.

But what about the click? Ask yourself which direction it travels. An Op is something the program asks the world to do, or to tell it when asked (a seed, the stored best score). A click is the world telling the program something whenever it likes, like a key press or the clock. That is not an effect the program performs; it is an input the program receives. Inputs do not go into IO. They go into the stream, and they enter through the runtime. That is steps 6 and 7.

Step 6 — Wire the streams, with a fourth kind of input

The stream inputs get a fourth case, and the stream module a fourth source:

JavaScript

// input:   { tag: 'Click', x, y }      x, y: canvas pixels, whole numbers
// source:  clicks  emits {x, y} for every Click input and ignores everything else

Scala (scala/src/Stream.scala, given)

enum Event:
  case Time(t: Long)
  case KeyDown(key: String)
  case KeyUp(key: String)
  case Click(x: Int, y: Int)

// object Stream:
//   val clicks: Stream[(Int, Int)]

F# (fsharp/src/Stream.fs, given)

type Event =
    | Time of int64
    | KeyDown of string
    | KeyUp of string
    | Click of int * int

// let clicks : Stream<int * int>

clicks is already in your stream file: it was given there from the start, as one more source next to the two you wrote in Module 8, and it is built exactly like keyDowns. Read it. Then answer: why do keyDowns, keyUps, interval and clampTime not need a single change to cope with a new kind of input? (Each source looks at the tag of the input and ignores what it does not know; clampTime passes everything that is not Time straight through. That is why Snake and Pong cannot even tell that clicks exist: test Snake and Pong ignore clicks: their actions and drawings are the same with and without them.)

Then wire Life like Snake and Pong:

  • lifeActions: Frame every GEN_MS = 150 ms (from interval), merged with lifeKey of every key press, merged with lifeClick of every click (from clicks), in that order;
  • lifeStates(seed, actions): start from initialLife(seed), fold with lifeUpdate, emit the initial state first;
  • lifeMain: ask for a seed, turn the stream of states into a stream of drawings of lifeView, and wrap it in clampTime(…, MAX_GAP_MS).

Step 7 — The edge: this time you change it

In Snake and Pong this step said "nothing to write". Now the edge must learn about the mouse, and it is the first time since Module 9 that you change its shape (the high score only added two operations). Two changes, both small:

  1. The runtime registers a fourth callback. In runApp, next to the key and clock registrations:

    platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
    

    In Scala p.onClick((x, y) => feed(Event.Click(x, y))), in F# p.OnClick(fun x y -> feed (Click(x, y))). The runtime still knows nothing about any game: it turns a click into an input and feeds it, as it does with keys.

  2. The platform gets a method onClick(cb), which calls cb(x, y) with canvas pixels.

    • JavaScript: browserPlatform is yours, from Module 9. Add onClick to it: listen for 'click' on the canvas (not on the window, or a click anywhere on the page would toggle a cell), and convert the event's position with canvasPoint(canvas, e.clientX, e.clientY), a helper that is given at the bottom of js/src/browser.js. Read it: the page may show the canvas larger or smaller than its width and height (CSS, zoom) and draws a 2-pixel border round it, so the window coordinates of the mouse must be made relative to the drawing area and then scaled. That is the bug the sketch's offsetX had. Add the listener inside onClick, not when the platform is built.
    • Scala and F#: the Platform type in your runtime file already declares onClick (Scala def onClick(cb: (Int, Int) => Unit): Unit) / OnClick: (int -> int -> unit) -> unit (F#), given and unused until now, and the browser glue in scala/web/ and fsharp/web/ (given) implements it. Your only change at the edge is the registration in runApp.

Why do the Snake and Pong pages keep working? Their platform now has one more method, the runtime calls it once at start, and clicks flow into streams that ignore them. A runtime from Module 9 that does not register clicks also keeps working for Snake and Pong; it just never feeds a click, so Life does not react to the mouse until you make this change. The tests for this step say exactly that: runApp registers a click handler with the platform, runApp feeds clicks to the stream, and (JS) browserPlatform listens for clicks on the canvas, not before onClick is called and browserPlatform converts a click to canvas pixels, through the border and the CSS scaling.

Compare this with what a click costs in the stateful sketch: there, the click handler is also where the game logic lives (grid[y][x] = !grid[y][x]; draw()). Here, the edge gained four lines that know nothing about Life, and Life gained one pure function, lifeClick, tested without a browser.

The entry file is given: js/src/life/main.js is runApp(lifeMain, browserPlatform(document.getElementById('game'), window)), and the page is js/life.html. Scala and F# have the same entry points in their web glue, built by build-web.sh, with the pages scala/life.html and fsharp/life.html.

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, in each track's vocabulary ("events" instead of "inputs", None instead of null). To read all titles, open scala/test/M13Life.test.scala or fsharp/tests/M13Life.fs. Fixture-based tests have the same case names in every track.

Exercise 13.1 — model

JavaScript (js/src/life/model.js; the constants are given)

export const initialLife = (seed) => /* LifeState */
export const LifeAction = { Toggle: (cell) => ..., Frame: ..., TogglePause: ..., Step: ..., Clear: ..., Randomize: ... }

Scala (scala/src/life/Model.scala)

object LifeModel:
  def initialLife(seed: Long): LifeState = ???

F# (fsharp/src/Life/Model.fs)

let initialLife (seed: uint32) : LifeState = failwith "TODO m13"

Proved by: the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below, initialLife is an empty, paused world at generation 0 with the given seed, LifeAction builds Toggle with a cell, and has the other actions as plain values (JS only).

Exercise 13.2 — rules

JavaScript (js/src/life/rules.js)

export const lifeStep = (cells) => /* Point[], canonical */
export const lifeUpdate = (state, action) => /* LifeState */

Scala (scala/src/life/Rules.scala)

object LifeRules:
  def lifeStep(cells: List[Point]): List[Point] = ???
  def lifeUpdate(state: LifeState, action: LifeAction): LifeState = ???

F# (fsharp/src/Life/Rules.fs)

let lifeStep (cells: Point list) : Point list = failwith "TODO m13"
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState = failwith "TODO m13"

Proved by: fixtures/m13-life-step.json (an-empty-board-stays-empty, a-lonely-cell-dies, two-cells-die-of-loneliness, a-block-is-stable, a-horizontal-blinker-turns-vertical, a-vertical-blinker-turns-horizontal, three-cells-in-an-l-give-birth-to-a-block, a-cell-with-four-neighbours-dies-of-overcrowding, a-glider-moves-and-the-result-is-sorted-row-major, a-block-in-the-corner-is-stable, a-block-in-the-far-corner-is-stable, a-blinker-on-the-top-edge-cannot-grow-beyond-it, a-blinker-on-the-right-edge-does-not-wrap-to-the-left-edge, cells-on-opposite-edges-are-not-neighbours, a-blinker-on-the-bottom-edge-cannot-grow-beyond-it); fixtures/m13-life-update.json (20 cases: frame-…, step-…, toggle-pause-…, the toggle-… cases including toggle-adds-a-cell-between-two-on-the-same-row, toggle-works-while-running-too and toggle-off-the-board-is-ignored, clear-empties-the-board-and-restarts-the-count, randomize-fills-the-board-from-the-seed-and-advances-it, randomize-replaces-the-cells-restarts-the-count-and-keeps-running); and the tests lifeUpdate does not modify the state it is given, a glider moves one cell diagonally every four generations, the next generation is computed from the whole old board: a blinker blinks forever, Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0, every generation of a random board stays sorted row-major, on the board and without duplicates, lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms.

Strategy: lifeStep first, case by case: the empty board, then the lonely cell, then the block, then the blinkers (if the blinker fails while the block passes, read the in-place section again), then the edges. Then lifeUpdate, one action at a time.

Watch out for: a neighbour list that includes the cell itself (then a live cell counts itself, and the block "dies of overcrowding"); off-board neighbours that wrap round (cells-on-opposite-edges-are-not-neighbours); a result in the order the cells were found instead of row-major; and, in Scala and F#, a seed that changes type on the way through the 1200 draws.

Exercise 13.3 — view

JavaScript (js/src/life/view.js)

export const lifeView = (state) => /* Scene */

Scala (scala/src/life/View.scala)

object LifeView:
  def lifeView(state: LifeState): Scene = ???

F# (fsharp/src/Life/View.fs)

let lifeView (state: LifeState) : Scene = failwith "TODO m13"

Proved by: fixtures/m13-life-view.json (a-new-world-is-an-empty-paused-board, live-cells-in-row-major-order-while-running, paused-after-some-generations).

Exercise 13.4 — keys, clicks and wiring

JavaScript (js/src/life/app.js)

export const GEN_MS = 150
export const lifeKey = (key) => /* LifeAction or null */
export const lifeClick = (x, y) => /* LifeAction (a Toggle) or null */
export const lifeActions = /* Stream<LifeAction> */
export const lifeStates = (seed, actions) => /* Stream<LifeState> */
export const lifeMain = /* IO<Stream<IO<undefined>>> */

Scala (scala/src/life/App.scala)

object LifeApp:
  val GEN_MS: Long = 150
  def lifeKey(key: String): Option[LifeAction] = ???
  def lifeClick(x: Int, y: Int): Option[LifeAction] = ???
  lazy val lifeActions: Stream[LifeAction] = ???
  def lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState] = ???
  lazy val lifeMain: IO[Stream[IO[Unit]]] = ???

F# (fsharp/src/Life/App.fs)

let GEN_MS = 150L
let lifeKey (key: string) : LifeAction option = failwith "TODO m13"
let lifeClick (x: int) (y: int) : LifeAction option = failwith "TODO m13"
let lifeActions : Stream<LifeAction> = (* ... *)
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> = failwith "TODO m13"
let lifeMain () : IO<Stream<IO<unit>>> = failwith "TODO m13"

Proved by: fixtures/m13-life-key.json (12 cases, with the constructor/toString traps); fixtures/m13-life-click.json (the-top-left-pixel-is-cell-0-0, the-last-pixel-of-the-first-cell, the-next-pixel-is-the-next-cell, the-row-below, a-pixel-in-the-middle, the-bottom-right-pixel-of-the-board, right-of-the-board-is-nothing, the-status-strip-under-the-board-is-nothing, left-of-the-board-is-nothing, above-the-board-is-nothing); clicks emits the position of every Click input and ignores everything else, the other sources ignore clicks, a generation every 150 ms, lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles, lifeStates starts with the new world, then yields one state per action, lifeMain asks for a seed, draws the empty world first, and then a click as a live cell, lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds, Snake and Pong ignore clicks: their actions and drawings are the same with and without them.

Watch out for: in Scala and F#, integer division rounds towards zero, so -1 / 16 is 0, not -1: a click one pixel left of the board would toggle cell 0. Check that the pixel is on the board before you divide (left-of-the-board-is-nothing).

Exercise 13.5 — the click at the edge

Make the two changes of step 7: the fourth registration in runApp (all tracks) and, in JS, onClick in browserPlatform.

JavaScript (js/src/runtime.js, js/src/browser.js)

// runtime.js, in runApp:  platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
// browser.js, in the platform object:
//   onClick: (cb) => canvas.addEventListener('click', (e) => { /* canvasPoint(canvas, e.clientX, e.clientY), then cb(x, y) */ })

Scala (scala/src/Runtime.scala)

// trait Platform (given):  def onClick(cb: (Int, Int) => Unit): Unit
// in runApp:               p.onClick((x, y) => feed(Event.Click(x, y)))

F# (fsharp/src/Runtime.fs)

// type Platform (given):  OnClick: (int -> int -> unit) -> unit
// in runApp:              p.OnClick(fun x y -> feed (Click(x, y)))

Proved by: runApp registers a click handler with the platform, runApp feeds clicks to the stream, runApp runs Life: a click on the canvas draws the world with that cell alive; JS only: browserPlatform listens for clicks on the canvas, not before onClick is called, browserPlatform converts a click to canvas pixels, through the border and the CSS scaling. All tests of Modules 9–12 must still pass.

Run and play:

Draw a glider near the top left (three cells in a row, one cell above the right one, and one cell two rows up above the middle one), press Space and watch it walk down to the bottom edge, where it turns into a block. Press R, then Space, and watch a random world settle down. Then open Snake and Pong again and check that they still play.

Hints

As in Module 11, the hints are deliberately lighter: a nudge and a stronger hint, no skeleton. Look at your Snake and Pong code first.

Exercise 13.2 — nudge

Build lifeStep from three small functions: onBoard(p), neighbours(p) (the eight offsets, moved to p, filtered by onBoard) and count(p) (how many neighbours are alive in the old list). Then filter a list of all board cells. For lifeUpdate, a flat switch on the action, one small function per action, as in pongUpdate.

Exercise 13.2 — stronger hint
  • All board cells, row-major: Array.from({length: LW * LH}, (_, i) => ({x: i % LW, y: Math.floor(i / LW)})) (Scala: for y <- 0 until LH; x <- 0 until LW yield Point(x, y), F#: [ for y in 0 .. LH - 1 do for x in 0 .. LW - 1 -> { X = x; Y = y } ]). Filter it with "n === 3 || (n === 2 && alive(p))".
  • alive(p): either cells.some(...) (fine), or a set built once at the start of lifeStep from the old cells (faster). In JS, put numbers p.y * LW + p.x in the set, not points.
  • Toggle: compare cells by that same number p.y * LW + p.x: [...before, cell, ...after].
  • Randomize: a reduce over the board cells whose accumulator is [draws, seed]; each step runs intBelow(4) on the seed it carries. Then keep the board cells whose draw is 0.
Exercise 13.4 — nudge

lifeKey is keyToAction with other keys. lifeClick: two divisions and a check. lifeActions is pongActions with clicks in the place of keyUps; lifeMain is pongMain with other names.

Exercise 13.4 — stronger hint

lifeClick(x, y): return null unless 0 <= x < LW * LCELL and 0 <= y < LH * LCELL; otherwise Toggle({x: floor(x / LCELL), y: floor(y / LCELL)}). The clicks source emits {x, y}, so the third branch of the merge is filterMap(clicks, (p) => lifeClick(p.x, p.y)).

Exercise 13.5 — nudge

The runtime change is one line, a copy of the onKeyDown registration. In browserPlatform, the listener goes on canvas, and the handler does two things: convert, then call cb.

Exercise 13.5 — stronger hint

onClick: (cb) => canvas.addEventListener('click', (e) => { const p = canvasPoint(canvas, e.clientX, e.clientY); cb(p.x, p.y) }). If the test "not before onClick is called" fails, you added the listener while building the platform object instead of inside onClick.

Check your understanding

  1. Apply steps 0–2 of the method to the stateful sketch: its hidden state (including time and randomness), its events and its effects.
  2. Run the in-place loop by hand on a 2 × 2 block. Why does it give the right answer there, and what does that tell you about testing a simulation with still-lifes only?
  3. The course lets clicks toggle cells while the world runs. Write the one-line change for the other policy (only while paused). Which fixture would fail, and which new one would you write? Which policy do you prefer, and why?
  4. cells is a sorted list, not a set. Name two things that would get harder in the tests if each track used its own language's set type in the state, and one thing that would get easier in the code.
  5. Your lifeStep builds a set inside itself. Is lifeStep still pure? Is the set "state"? Argue from what a caller can observe.
  6. Estimate the work per generation of the "scan the list for each board cell" method on a quarter-full board, and compare it with the lookup set. At which board size would the first stop being fast enough at 150 ms?
  7. Why is a click an input (an event in the stream) and not an Op? Give an example of a mouse feature that would need a new Op.
  8. List every file the click changed, in your track. Which of them are pure? Why did no Snake or Pong file change, and which test proves that their behaviour did not change either?
  9. A runtime from Module 9 never registers onClick. Why do the Module 9–12 tests still pass with it, and which Module 13 tests fail?
  10. canvasPoint subtracts the border and scales by width / clientWidth. What goes wrong with e.offsetX alone when the page shows the canvas at twice its size? Which test would catch it?
  11. Randomize fixes the order of 1200 draws. What would go wrong between the three tracks if each one drew in the order its own data structure happened to iterate?
  12. Design wrap-around edges (a torus) as an option: which function changes, which fixtures would you add, and does anything outside lifeStep need to know?

In production

  • Double buffering is the stateful world's fix for the in-place bug: two buffers, read one, write the other, swap. Graphics cards do the same with the picture itself so that the screen never shows a half-drawn frame (Game Programming Patterns: Double Buffer). Immutable data gives you the same guarantee without the swap.
  • Hashlife computes Life on enormous patterns by caching the next generation of every square block it has seen, which only works because the step is a pure function of the old board: the same block always has the same future (Hashlife on Wikipedia; the free Life program Golly uses it).
  • Hit testing. Every UI toolkit has one place that turns a raw pointer position into "what was hit", and the rest of the program only sees the answer: a button, a list row, a cell. canvasPoint plus lifeClick is that split in miniature: the edge converts window coordinates to canvas pixels, a pure function converts pixels to a cell (MDN: MouseEvent.clientX).

Further reading