From 6dc5bdde9e08c905df562d6459d2915fe6250fc0 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:31:16 +0100 Subject: [PATCH] docs: course design spec and implementation plan Co-Authored-By: Claude Opus 5.5 --- .gitignore | 7 + .../plans/2026-10-28-pure-snake-course.md | 202 ++++++++++++++++++ .../2026-10-28-pure-snake-course-design.md | 171 +++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 .gitignore create mode 100644 docs/superpowers/plans/2026-10-28-pure-snake-course.md create mode 100644 docs/superpowers/specs/2026-10-28-pure-snake-course-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbda906 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.superpowers/ +node_modules/ +bin/ +obj/ +.scala-build/ +.bsp/ +*/web/dist/ diff --git a/docs/superpowers/plans/2026-10-28-pure-snake-course.md b/docs/superpowers/plans/2026-10-28-pure-snake-course.md new file mode 100644 index 0000000..cb7ca77 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-pure-snake-course.md @@ -0,0 +1,202 @@ +# Pure Snake Course Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A self-paced, three-track (JS / Scala 3 / F#) course in which students build a fully pure Snake game (pure rules, pure randomness, rendering-as-data, a hand-built `IO`, hand-built event streams, a tiny interpreter at the edge), with per-module test suites, reference solutions, Socratic-mentor support and source links. + +**Architecture:** One shared Markdown course text (`course/`) with side-by-side code. Three track folders (`js/`, `scala/`, `fsharp/`), each with `src/` (student stubs), `solutions/` (same file set, reference implementation), and `test/` (per-module tests). Pure-logic test cases live in shared JSON fixtures (`fixtures/`). `IO` is a free-monad-style data type (`Pure | Effect(op, k)`), streams are pure Mealy-machine automata (`step : Input → (outputs, nextStream)`), and the single impure file per track (`runtime`) drives a stream with clock/keyboard inputs and interprets `IO` against an injected `Platform`. + +**Tech Stack:** Node 24 (`node --test`, ES modules, zero deps); Scala 3.5 + scala-cli + munit 1.0.x + ujson (tests only) + Scala.js for the browser; .NET 10 F# + Expecto (via `dotnet run`) + Fable for the browser; HTML canvas. + +**Spec:** `docs/superpowers/specs/2026-10-28-pure-snake-course-design.md` + +## Global Constraints + +- Student code uses no FP libraries in any track (no Ramda/RxJS/cats/fs2/FSharpPlus). Test-only deps allowed: munit, ujson, Expecto. +- JS: ES modules, `"type": "module"`, no npm dependencies, Node built-in test runner. +- Purity rule (enforced by a per-track "purity audit" test): outside the runtime/browser/main files there is no reassignment, no mutation, no `Math.random`/`Date.now`/`System.Random`/DOM access. JS bans `let`, `var`, `++`, `--`, `.push(`, `.pop(`, `.shift(`, `.unshift(`, `.splice(`, `.sort(`, `.reverse(`, `Math.random`, `Date.now`, `performance.now`, `document`, `window`, `addEventListener` (comments stripped before scanning). Scala bans `var`, `mutable`, `while`, `scala.util.Random`, `System.`, `println`. F# bans `mutable`, `<-`, `System.Random`, `DateTime`, `printf`, `ref `. +- `src/` and `solutions/` contain exactly the same file names; `src/` files are stubs (`todo("mNN")` in JS, `???` in Scala, `failwith "TODO mNN"` in F#) with the final signatures and doc comments. JS stubs import `todo` from `js/src/todo.js` (identical in `solutions/`). +- Every test suite passes against `solutions/` (via `scripts/verify.sh `), and every module's tests fail against fresh `src/` stubs. +- Every module ends with working links in "Further reading"; all links are checked with `scripts/check-links.mjs`. +- Seeds and PRNG values are unsigned 32-bit integers; JS `number`, Scala `Long`, F# `uint32`. +- Names below are the contract. JS uses data-first standalone functions (`map(io, f)`), Scala uses extension methods / companion functions (`io.map(f)`, `IO.pure(a)`), F# uses data-last module functions (`IO.map f io`). The course's reference page lists all three side by side. + +## Review Focus + +1. **Quick double-turn (Up then Left within one tick) must not reverse the snake into itself** → `turn` validates against `dir` (last moved), not `nextDir`. Test `turn-against-last-moved-dir` in fixtures/m03-turn.json (Task 3). +2. **Snake moving into the cell its tail is leaving is legal; into its tail while eating is not** → fixtures `advance-into-vacating-tail` and `advance-into-tail-while-eating` (Task 3). +3. **Board full → no free cell for an apple** → `spawnApple` sets status `Won` instead of crashing. Fixture `spawn-on-full-board` (Task 4). +4. **Keys pressed while paused / game over do nothing; restart works from any status** → fixtures in m10-update.json (Task 9). +5. **Cross-language RNG drift** (Scala/F# signed-int math) → identical first-10 `nextUint32` values from seed 0, 1 and 4294967295 in fixtures/m04-random.json, consumed by all three tracks (Task 4, Task 11, Task 13). + +--- + +## Repository layout (locked) + +``` +README.md course front page: what, who, how, tracks, solo vs mentor +MENTOR.md instructions for an AI Socratic mentor +AGENTS.md, CLAUDE.md two-line pointers to MENTOR.md +course/00-setup.md … 11-capstone-pong.md, reference.md, method.md +fixtures/mNN-.json shared cases: { "function": "...", "cases": [ { "name", "args": [...], "expected" } ] } +scripts/verify.sh copy track to tmp, replace src/ with solutions/, run all tests +scripts/check-stubs.sh run tests against stubs, expect every module to fail +scripts/serve.mjs tiny static file server (tooling, not course code) on :8000 +scripts/check-links.mjs HEAD/GET every http(s) link in *.md, report non-2xx/3xx +scripts/smoke.mjs headless Chrome (CDP via --remote-debugging) or puppeteer-less check: page loads, canvas has non-background pixels after 1s +js/ package.json, index.html, src/*.js, src/pong/*.js, solutions/(same), test/mNN-*.test.js, test/helpers.js, test/purity.test.js +scala/ project.scala, src/*.scala, src/pong/*.scala, solutions/(same), test/MNN*.test.scala, test/Fixtures.scala, test/PurityTest.test.scala, web/Main.scala, web/BrowserPlatform.scala, index.html, test.sh +fsharp/ src/Snake.fsproj + *.fs, solutions/(same), tests/Snake.Tests.fsproj + MNN*.fs + Fixtures.fs + Purity.fs + Main.fs, web/ (Fable project: Web.fsproj, BrowserPlatform.fs, Main.fs), index.html, test.sh +``` + +Module → file map (same base names per track; Scala/F# PascalCase): m01 `util`, m02 `model`, m03 `rules`, m04 `random` + `spawn`, m05 `game`, m06 `view`, m07 `io`, m08 `stream`, m09 `input` + `app` + `runtime` (+ `browser`/`main` in JS, `web/` in Scala/F#), m10 edits `game`, `input`, `app`, m11 `pong/{model,rules,view,app}`. + +F# compile order: Util, Model, Rules, Random, Spawn, Game, View, IO, Stream, Input, App, Runtime, Pong/Model, Pong/Rules, Pong/View, Pong/App. + +Running one module's tests: JS `node --test test/m03-*.test.js`; Scala `./test.sh M03` (= `scala-cli test src test project.scala -- '*M03*'`); F# `./test.sh M03` (= `dotnet run --project tests -- --filter-test-list M03`). + +## The contract (canonical model and APIs) + +JS shapes shown; Scala = case classes / enums with the same field names; F# = records / DUs with the same names (PascalCase cases). + +**m01 util:** `range(n) → [0..n-1]`, `dropLast(xs) → xs without last` (empty → empty), `contains(xs, p, eq) → bool`; JS-only `pipe(x, ...fns)`, `compose(...fns)`, `deepFreeze(obj)` (also used by JS tests). Scala/F# m01 also: `compose` exercise phrased via `andThen`/`>>` in text; tests cover `range`, `dropLast`. + +**m02 model:** +- `Point {x, y}`; `point(x, y)`; `pointEquals(a, b)`. +- `Direction = 'Up'|'Down'|'Left'|'Right'`; `delta(dir) → Point` (Up = {0,-1}, Down {0,1}, Left {-1,0}, Right {1,0}); `opposite(dir)`. +- `Status = 'Playing'|'Paused'|'GameOver'|'Won'`. +- `Action = {tag:'Tick'} | {tag:'Turn', dir} | {tag:'TogglePause'} | {tag:'Restart'} | {tag:'Frame'}`; JS constructors in `Action = { Tick, Turn(dir), TogglePause, Restart, Frame }`. +- `GameState {width, height, snake: Point[] (head first), dir, nextDir, apple: Point|null, seed, score, status, frame}`. +- `initialState(seed)`: width 20, height 20, snake [(10,10),(9,10),(8,10)], dir Right, nextDir Right, apple null, seed, score 0, status Playing, frame 0. + +**m03 rules:** +- `move(p, dir) → Point` (no wrapping). +- `inBounds(state, p) → bool`. +- `turn(state, dir)`: unchanged unless status Playing and `dir !== opposite(state.dir)`; then `nextDir = dir`. +- `advance(state)`: unchanged unless Playing. `d = nextDir`; `h = move(head, d)`; `eating = apple ≠ null && h == apple`; `body = eating ? snake : dropLast(snake)`; if `!inBounds(h)` or `body` contains `h` → `{...state, dir: d, status: 'GameOver'}` (snake unchanged); else `{...state, snake: [h, ...body], dir: d, apple: eating ? null : apple, score: score + (eating ? 1 : 0)}`. + +**m04 random + spawn:** +- `Random = seed → [A, seed]` (Scala `Long => (A, Long)`, F# `uint32 -> 'a * uint32`). +- `nextUint32: Random` = mulberry32: + ``` + s = (seed + 0x6D2B79F5) mod 2^32 + t = imul(s ^ (s >>> 15), s | 1) + t = t ^ (t + imul(t ^ (t >>> 7), t | 61)) + value = (t ^ (t >>> 14)) as uint32; return [value, s] + ``` + Cross-check: `value / 2^32` equals bryc's reference `mulberry32(seed)()`. +- `pure(a)`, `map(r, f)`, `flatMap(r, f)`, `intBelow(n) = map(nextUint32, v => v % n)`, `run(r, seed) → [A, seed]`. +- `freeCells(state) → Point[]`: row-major (y outer, x inner), excluding snake cells. +- `spawnApple(state)`: unchanged if apple ≠ null or status ≠ Playing; if no free cells → status Won; else `[i, s'] = intBelow(cells.length)(seed)`, `apple = cells[i]`, `seed = s'`. +- `tick(state) = spawnApple(advance(state))`; `newGame(seed) = spawnApple(initialState(seed))`. + +**m05 game:** `update(state, action)`: Tick → `tick`; Turn → `turn`; other actions → state (extended in m10). `replay(state, actions) = fold`; `history(state, actions) → [state, ...each intermediate]` (length = actions + 1). + +**m06 view:** `CELL = 20`. `view(state) → Scene` (array of shapes), shapes `{tag:'Rect', x, y, w, h, color}` / `{tag:'Text', x, y, text, color, size, align}`. Order: +1. `Rect(0, 0, width*CELL, height*CELL, '#111111')` +2. apple if present: `Rect(ax*CELL+1, ay*CELL+1, CELL-2, CELL-2, '#e53935')` +3. each snake segment in list order: `Rect(x*CELL+1, y*CELL+1, CELL-2, CELL-2, i==0 ? '#7cb342' : '#43a047')` +4. `Text(6, 18, 'Score: ', '#ffffff', 16, 'left')` +5. overlay by status, at `cx = width*CELL/2`, `cy = height*CELL/2`: Paused → `Text(cx, cy, 'Paused', '#ffffff', 32, 'center')`, `Text(cx, cy+30, 'Press Space to resume', '#ffffff', 16, 'center')`; GameOver → `'Game Over'` + `'Press R to restart'`; Won → `'You Win!'` + `'Press R to restart'`; Playing → nothing. + +**m07 io:** +- `Op = {tag:'Draw', shape} | {tag:'RandomSeed'}`; Scala `enum Op`, `enum OpResult { Done; Seed(value: Long) }`; F# `Op = Draw of Shape | RandomSeed`, `OpResult = Done | Seed of uint32`. (JS: results are plain values: `undefined` / number.) +- `IO = {tag:'Pure', value} | {tag:'Effect', op, k}` where `k : result → IO`. +- `pure(a)`, `effect(op)` (= `Effect(op, pure)` with result-typing in Scala/F#), `map(io, f)`, `flatMap(io, f)`, `sequence(ios: IO[]) → IO` (left to right), `draw(shape)`, `randomSeed: IO`, `drawScene(scene) = sequence(scene.map(draw))`. +- `trace(io, seed) → {result, ops}`: pure interpreter; answers every `RandomSeed` with `seed`, `Draw` with unit; `ops` = list of ops in execution order. Implemented by recursion, no mutation. +- Tests include the three monad laws checked via `trace` equality. + +**m08 stream:** +- `Input = {tag:'Time', t} | {tag:'KeyDown', key} | {tag:'KeyUp', key}`. +- `Stream = { step: Input → [A[], Stream] }` (Scala `final case class Stream[A](step: Input => (List[A], Stream[A]))`; F# `type Stream<'a> = Stream of (Input -> 'a list * Stream<'a>)`). +- Sources: `keyDowns: Stream`, `keyUps: Stream`, `interval(ms): Stream` — on `Time t`, emits every multiple `k*ms` with `last < k*ms ≤ t` (values are those multiples, ascending), starting from `last = 0`; ignores keys. +- Combinators: `map(s, f)`, `filter(s, p)`, `filterMap(s, f: A → B|null)` (Option in Scala/F#), `merge(a, b)` (both step on every input; a's outputs before b's), `scan(s, f, z)` (emits each new accumulator, not `z`), `startWith(s, v)` (emits `v` before the outputs of the first step only). +- `run(s, inputs) → A[]`: pure fold, all outputs in order. + +**m09 input + app + runtime:** +- `keyToAction(key) → Action|null`: `ArrowUp|w|W → Turn Up`, `ArrowDown|s|S → Turn Down`, `ArrowLeft|a|A → Turn Left`, `ArrowRight|d|D → Turn Right`; else null. +- `TICK_MS = 150`; `classicActions = merge(map(interval(TICK_MS), _ => Tick), filterMap(keyDowns, keyToAction))`; `states(seed, actions) = startWith(scan(actions, update, newGame(seed)), newGame(seed))`; `render(state) = drawScene(view(state))`; `main: IO>> = map(randomSeed, seed => map(states(seed, gameActions), render))` (where `gameActions` is `classicActions` in m09 and `speedActions` after m10). +- `Platform = { fillRect(x,y,w,h,color), fillText(x,y,text,color,size,align), randomSeed() → uint32, startClock(onTime: t → void), onKeyDown(cb: key → void), onKeyUp(cb) }` (Scala trait, F# record of functions). +- `runIO(io, platform) → A`: iterative interpreter (Draw→fillRect/fillText, RandomSeed→platform.randomSeed()). +- `runApp(main, platform)`: `runIO(main)` to get stream; holds the current stream in the only mutable variable; feeds `KeyDown`/`KeyUp`/`Time` inputs; runs each emitted IO; on the first `startClock` callback also runs nothing special (the `startWith` value is emitted on the first input). +- JS `browser.js`: `browserPlatform(canvas, win) → Platform` (clock via `requestAnimationFrame`, `t` = ms since start; seed via `crypto.getRandomValues`); `main.js`: one call `runApp(main, browserPlatform(...))`; `prevent default` on arrow keys/space. + +**m10 extensions:** `update` adds TogglePause (Playing↔Paused, else unchanged), Restart (`newGame(state.seed)` from any status), Frame (unchanged unless Playing; `frame+1`; if `(frame+1) % movesEvery(score) === 0` then `tick` of the incremented state). `movesEvery(score) = max(2, 8 - floor(score / 3))`, `FRAME_MS = 25`. `keyToAction` adds `' ' → TogglePause`, `r|R → Restart`. `speedActions = merge(map(interval(FRAME_MS), _ => Frame), filterMap(keyDowns, keyToAction))`; `main` switches to `speedActions`. + +**m11 pong (capstone):** W=80, H=50, `PCELL=8`, `PADDLE=8`, `PLAYER_X=2`, `CPU_X=77`, `WIN=5`. +- `PongState {ball:{x,y}, vel:{dx,dy}, player, cpu, held:{up,down}, score:{player,cpu}, status:'Playing'|'Over', seed, frame}`; `initialPong(seed)`: ball (40,25), vel (1,1), player 21, cpu 21, held false/false, score 0/0, Playing, frame 0. +- `PongAction = {tag:'Press', key:'Up'|'Down'} | {tag:'Release', key} | {tag:'Frame'} | {tag:'Restart'}`; `pongKey(isDown, key)`: ArrowUp/w → Up, ArrowDown/s → Down, r (down only) → Restart. +- `pongUpdate(s, a)`: Press/Release set `held`; Restart → `initialPong(s.seed)`; Frame (only when Playing): `frame+1`; player += (down?1:0)-(up?1:0) clamped to [0, H-PADDLE]; on even new frame cpu moves 1 toward `ball.y - PADDLE/2`, clamped; ball: `ny = y+dy`; if `ny<0 || ny>=H` then `dy=-dy`, `ny=y+dy`; `nx = x+dx`; if `nx==PLAYER_X && dx<0 && player≤ny0 && cpu≤ny=W` player scores; scoring → serve: ball (40,25), `dx` toward the side that conceded (cpu scored → -1, player scored → +1), `dy = (nextUint32 % 2 == 0) ? 1 : -1` advancing seed; reaching WIN → status Over. +- `pongView(s)`: bg `Rect(0,0,640,400,'#111111')`, paddles `Rect(X*8, y*8, 8, 64, '#ffffff')` player then cpu, ball `Rect(x*8, y*8, 8, 8, '#ffeb3b')`, `Text(320, 30, '

', '#ffffff', 24, 'center')`, Over → `Text(320, 200, p>c ? 'You Win!' : 'CPU Wins', '#ffffff', 32, 'center')` + `Text(320, 230, 'Press R to restart', '#ffffff', 16, 'center')`. +- `pongMain` like `main`, using `interval(30)` → Frame, `keyDowns`/`keyUps` → `pongKey`. JS page `js/pong.html`. + +--- + +### Task 1: Scaffolding and tooling + +**Files:** Create `README.md` (placeholder heading only), `.gitignore`, `scripts/verify.sh`, `scripts/check-stubs.sh`, `scripts/serve.mjs`, `js/package.json`, `scala/project.scala`, `scala/test.sh`, `fsharp/src/Snake.fsproj`, `fsharp/tests/Snake.Tests.fsproj`, `fsharp/tests/Main.fs`, `fsharp/test.sh`, `js/test/helpers.js`, `scala/test/Fixtures.scala`, `fsharp/tests/Fixtures.fs`. + +**Interfaces — Produces:** +- JS `helpers.js`: `loadFixture(name) → {function, cases}` (reads `../../fixtures/.json`), re-export `deepFreeze`-equivalent `freeze(x)` local to tests (tests must not depend on student `deepFreeze`). +- Scala `Fixtures.load(name): ujson.Value` + decoders `point`, `direction`, `status`, `action`, `state` (GameState), `opt`. +- F# `Fixtures.load name : JsonElement` + same decoders. + +- [ ] Step 1: `git init`, `.gitignore` (node_modules, bin, obj, .scala-build, .bsp, web build outputs `*/web/dist`). +- [ ] Step 2: Write `verify.sh`: `track=$1; tmp=$(mktemp -d); cp -r $tmp; rm -rf $tmp/$track/src; cp -r $tmp/$track/solutions $tmp/$track/src; (cd $tmp/$track && )`; exit code = test exit code. `check-stubs.sh` runs each module filter against stubs and fails if any module passes. +- [ ] Step 3: Create a trivial m00 sanity test in each track (`1 + 1 == 2`, plus loading a `fixtures/m00-sanity.json`) and run it with each track's test command. Expected: PASS in all three. +- [ ] Step 4: Commit `chore: scaffold tracks and tooling`. + +### Task 2: JS m01–m02 (util, model) + +**Files:** `fixtures/m02-*.json`, `js/test/m01-util.test.js`, `js/test/m02-model.test.js`, `js/test/purity.test.js`, `js/src/{util,model}.js`, `js/solutions/{util,model}.js`. + +- [ ] Step 1: Tests. m01: `range(0)→[]`, `range(3)→[0,1,2]`; `dropLast([])→[]`, `dropLast([1,2,3])→[1,2]` and input untouched (frozen); `pipe(2, x=>x+1, x=>x*10)→30`; `compose(x=>x*10, x=>x+1)(2)→30`; `deepFreeze` makes nested object frozen. m02 via fixtures: `delta` (4 cases), `opposite` (4), `pointEquals`, `initialState(7)` exact object. `purity.test.js` per Global Constraints (scans `src/` excluding `runtime.js`, `browser.js`, `main.js`; also asserts the scanner flags a known-bad sample string). +- [ ] Step 2: `node --test test/m0[12]-*.test.js` against stubs → FAIL (todo errors). +- [ ] Step 3: Implement solutions; write stubs with JSDoc signatures. +- [ ] Step 4: `scripts/verify.sh js` → m01, m02, purity PASS. +- [ ] Step 5: Commit. + +### Task 3: JS m03 rules +Fixtures `m03-move.json`, `m03-in-bounds.json`, `m03-turn.json`, `m03-advance.json` — at least: move ×4 dirs; inBounds edges (0,0),(19,19),(-1,0),(20,5); turn: normal, reverse rejected, `turn-against-last-moved-dir` (dir Right, nextDir Up, turn Left → rejected because Left is opposite of the last *moved* dir Right; nextDir stays Up), paused ignored; advance: plain move, eat (grow, score+1, apple null), wall → GameOver, self-collision, `advance-into-vacating-tail` (legal), `advance-into-tail-while-eating` (GameOver), not-Playing unchanged. Same TDD steps as Task 2. Commit. + +### Task 4: JS m04 random + spawn +Fixtures `m04-random.json` (first 10 `nextUint32` values and seeds from seeds 0, 1, 4294967295 — generated with the formula, cross-checked against bryc's float reference in the test generator, values recorded literally), `m04-int-below.json`, `m04-free-cells.json` (small 3×2 board), `m04-spawn.json` (incl. `spawn-on-full-board` → Won, apple-present unchanged, GameOver unchanged), `m04-new-game.json` (seeds 0 and 42). Tests also check monad-ish behaviour of `map`/`flatMap`/`pure` on Random. Same TDD steps. Commit. + +### Task 5: JS m05 game + m06 view +Fixtures `m05-update.json` (Tick, Turn, TogglePause-unchanged-in-m05 is NOT tested), `m05-replay.json` (a 12-action game from `newGame(42)` that eats one apple), `m05-history.json` (length check + last == replay), `m06-view.json` (Playing with apple, Paused, GameOver, Won). Same TDD steps. Commit. + +### Task 6: JS m07 io +Native tests (`m07-io.test.js`): `trace(pure(3))` → `{result:3, ops:[]}`; `trace(randomSeed, 9).result === 9`; `drawScene([r1, t1])` ops = `[Draw r1, Draw t1]`; `sequence([])` no ops; left identity, right identity, associativity checked on 3 sample programs via `trace` deep-equality of `{result, ops}`; `map` doesn't execute anything until traced (constructing an IO with a throwing `f` does not throw). Same TDD steps. Commit. + +### Task 7: JS m08 stream +Native tests: `run(keyDowns, [KeyDown a, Time 5, KeyUp a, KeyDown b])→['a','b']`; `interval(10)` with `Time 5, Time 10, Time 35` → `[10, 20, 30]`; map/filter/filterMap; `merge` ordering at the same input; `scan` with counting; `startWith` emits once before first step's outputs; streams are reusable (running the same stream value twice gives same output — no hidden state). Same TDD steps. Commit. + +### Task 8: JS m09 input + app + runtime + browser +Tests: `m09-input.json` fixture for keyToAction; `states(42, classicActions)` run on `[Time 150, KeyDown ArrowUp, Time 300]` → 4 states (initial via startWith, after first tick, after turn, after second tick moving Up) — assert heads and nextDir; `main` traced with seed 42 → stream whose first output IO, traced, draws `view(newGame(42))`; runtime with a fake platform (records calls, exposes `fireTime(t)`, `fireKey(k)`): `runIO` maps Draw→fillRect/fillText correctly; `runApp` after `fireTime(150)` has drawn initial scene then tick scene (calls match `view` of those states). Create `js/index.html` (canvas 400×400, `