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>
560 lines
40 KiB
Markdown
560 lines
40 KiB
Markdown
# 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](method.md)**. 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](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)): 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](https://web.stanford.edu/class/sts145/Library/life.pdf)). 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.
|
||
|
||
```js
|
||
// 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:
|
||
|
||
```js
|
||
// 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?
|
||
|
||
<details><summary>Think about it first, then open the trace</summary>
|
||
|
||
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.
|
||
|
||
</details>
|
||
|
||
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](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`)
|
||
|
||
```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`)
|
||
|
||
```scala
|
||
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)
|
||
|
||
```fsharp
|
||
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**
|
||
|
||
```js
|
||
// 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)
|
||
|
||
```scala
|
||
enum LifeAction:
|
||
case Frame
|
||
case Toggle(cell: Point)
|
||
case TogglePause, Step, Clear, Randomize
|
||
```
|
||
|
||
**F#** (given)
|
||
|
||
```fsharp
|
||
[<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](#the-rules-precisely). 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**
|
||
|
||
```js
|
||
// 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)
|
||
|
||
```scala
|
||
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)
|
||
|
||
```fsharp
|
||
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:
|
||
|
||
```js
|
||
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)
|
||
|
||
```js
|
||
export const initialLife = (seed) => /* LifeState */
|
||
export const LifeAction = { Toggle: (cell) => ..., Frame: ..., TogglePause: ..., Step: ..., Clear: ..., Randomize: ... }
|
||
```
|
||
|
||
**Scala** (`scala/src/life/Model.scala`)
|
||
|
||
```scala
|
||
object LifeModel:
|
||
def initialLife(seed: Long): LifeState = ???
|
||
```
|
||
|
||
**F#** (`fsharp/src/Life/Model.fs`)
|
||
|
||
```fsharp
|
||
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`)
|
||
|
||
```js
|
||
export const lifeStep = (cells) => /* Point[], canonical */
|
||
export const lifeUpdate = (state, action) => /* LifeState */
|
||
```
|
||
|
||
**Scala** (`scala/src/life/Rules.scala`)
|
||
|
||
```scala
|
||
object LifeRules:
|
||
def lifeStep(cells: List[Point]): List[Point] = ???
|
||
def lifeUpdate(state: LifeState, action: LifeAction): LifeState = ???
|
||
```
|
||
|
||
**F#** (`fsharp/src/Life/Rules.fs`)
|
||
|
||
```fsharp
|
||
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](#why-the-whole-old-board-the-in-place-loop) 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`)
|
||
|
||
```js
|
||
export const lifeView = (state) => /* Scene */
|
||
```
|
||
|
||
**Scala** (`scala/src/life/View.scala`)
|
||
|
||
```scala
|
||
object LifeView:
|
||
def lifeView(state: LifeState): Scene = ???
|
||
```
|
||
|
||
**F#** (`fsharp/src/Life/View.fs`)
|
||
|
||
```fsharp
|
||
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`)
|
||
|
||
```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`)
|
||
|
||
```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`)
|
||
|
||
```fsharp
|
||
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`)
|
||
|
||
```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`)
|
||
|
||
```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`)
|
||
|
||
```fsharp
|
||
// 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:**
|
||
- JS: `cd js && node --test "test/m13-*.test.js"`, then `./test.sh`; from the repository root `node scripts/serve.mjs`, and open <http://localhost:8000/js/life.html>
|
||
- Scala: `cd scala && ./test.sh M13`, then `./test.sh`; `./build-web.sh`, serve from the repository root, open <http://localhost:8000/scala/life.html>
|
||
- F#: `cd fsharp && ./test.sh M13`, then `./test.sh`; `./build-web.sh`, serve from the repository root, open <http://localhost:8000/fsharp/life.html>
|
||
|
||
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.
|
||
|
||
<details><summary>Exercise 13.2 — nudge</summary>
|
||
|
||
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`.
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 13.2 — stronger hint</summary>
|
||
|
||
- 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.
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 13.4 — nudge</summary>
|
||
|
||
`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.
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 13.4 — stronger hint</summary>
|
||
|
||
`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))`.
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 13.5 — nudge</summary>
|
||
|
||
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`.
|
||
|
||
</details>
|
||
|
||
<details><summary>Exercise 13.5 — stronger hint</summary>
|
||
|
||
`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`.
|
||
|
||
</details>
|
||
|
||
## 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](https://gameprogrammingpatterns.com/double-buffer.html)). 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](https://en.wikipedia.org/wiki/Hashlife); 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`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX)).
|
||
|
||
## Further reading
|
||
|
||
- [The method](method.md) — the recipe, now applied three times.
|
||
- [Martin Gardner, "The fantastic combinations of John Conway's new solitaire game 'life'"](https://web.stanford.edu/class/sts145/Library/life.pdf) — the 1970 column that introduced Life.
|
||
- [Glider (Wikipedia)](https://en.wikipedia.org/wiki/Glider_%28Conway%27s_Game_of_Life%29) — the pattern you drew, why it moves, and why it matters to the theory of Life.
|
||
- [Robert Nystrom, Game Programming Patterns: Double Buffer](https://gameprogrammingpatterns.com/double-buffer.html) — the in-place bug from the stateful side, and the two-buffer discipline that avoids it.
|
||
- [MDN: `Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) — what `canvasPoint` measures, and why CSS size and canvas size differ.
|