docs: course design spec and implementation plan

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:31:16 +01:00
co-authored by Claude Opus 5.5
commit 6dc5bdde9e
3 changed files with 380 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.superpowers/
node_modules/
bin/
obj/
.scala-build/
.bsp/
*/web/dist/
@@ -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 <track>`), 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-<fn>.json shared cases: { "function": "...", "cases": [ { "name", "args": [...], "expected" } ] }
scripts/verify.sh <js|scala|fsharp> copy track to tmp, replace src/ with solutions/, run all tests
scripts/check-stubs.sh <track> 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 <url> 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<A> = seed → [A, seed]` (Scala `Long => (A, Long)`, F# `uint32 -> 'a * uint32`).
- `nextUint32: Random<uint32>` = 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: <n>', '#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<A> = {tag:'Pure', value} | {tag:'Effect', op, k}` where `k : result → IO<A>`.
- `pure(a)`, `effect(op)` (= `Effect(op, pure)` with result-typing in Scala/F#), `map(io, f)`, `flatMap(io, f)`, `sequence(ios: IO<unit>[]) → IO<unit>` (left to right), `draw(shape)`, `randomSeed: IO<uint32>`, `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<A> = { step: Input → [A[], Stream<A>] }` (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<string>`, `keyUps: Stream<string>`, `interval(ms): Stream<number>` — 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<Stream<IO<unit>>> = 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≤ny<player+PADDLE` or `nx==CPU_X && dx>0 && cpu≤ny<cpu+PADDLE` then `dx=-dx`, `nx=x`; if `nx<0` cpu scores, if `nx>=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, '<p> <c>', '#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/<name>.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 <repo> $tmp; rm -rf $tmp/$track/src; cp -r $tmp/$track/solutions $tmp/$track/src; (cd $tmp/$track && <run all tests>)`; 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, `<script type="module" src="src/main.js">`), `browser.js`, `main.js`, `scripts/serve.mjs`. Verify: `scripts/verify.sh js` PASS; serve solutions copy and run `scripts/smoke.mjs` → canvas painted. Commit.
### Task 9: JS m10 extensions
Fixtures `m10-update.json` (TogglePause both ways, ignored when GameOver; Restart from Playing/GameOver/Won → `newGame(seed)`; Turn while paused ignored; Frame below threshold just increments; Frame at threshold ticks; Frame when Paused unchanged), `m10-moves-every.json` (scores 0,2,3,17,18,100 → 8,8,7,3,2,2), `m10-input.json`. Native: `speedActions` on `[Time 25…Time 200]` yields 8 Frames. `main` uses `speedActions`. Same steps. Commit.
### Task 10: JS m11 pong
Fixtures `m11-pong-update.json` (press/release; player clamp at 0 and 42; cpu moves only on even frames; wall bounce; paddle bounce each side; cpu scores → serve toward player with dy from seed; player reaches 5 → Over; Frame when Over unchanged; Restart), `m11-pong-view.json`, `m11-pong-key.json`. `js/pong.html` + `src/pong/main.js` entry (excluded from purity scan like `main.js`). Same steps; smoke test pong page. Commit.
### Task 11: Scala track m01–m06
Port all JS fixture-driven and native tests for m01–m06 to munit (`test/M01UtilTest.test.scala` …), purity test, stubs with `???`, solutions in package `snake`. Decoders in `Fixtures.scala`. Verify `scala/test.sh` per module against stubs FAIL, `scripts/verify.sh scala` PASS. Commit.
### Task 12: Scala track m07–m11 + Scala.js page
Port m07–m11 tests and solutions (IO as `enum IO[+A] { Pure(a); Effect(op: Op, k: OpResult => IO[A]) }`; Stream as case class; Platform trait; Runtime with the single `var`). `web/Main.scala` + `web/BrowserPlatform.scala` using `scala.scalajs.js.Dynamic` (no extra deps). Build: `scala-cli --power package --js --js-module-kind es src web project.scala -o web/dist/snake.js -f` (pong: separate `@main` selected with `--main-class`). `scala/index.html`, `scala/pong.html`. Verify tests + smoke. Commit.
### Task 13: F# track m01–m06
As Task 11 with Expecto test lists named `M01`…, `Fixtures.fs` using System.Text.Json, stubs `failwith "TODO mNN"`. Commit.
### Task 14: F# track m07–m11 + Fable page
As Task 12. IO `type IO<'a> = Pure of 'a | Effect of Op * (OpResult -> IO<'a>)`; `Stream<'a> = Stream of (Input -> 'a list * Stream<'a>)`; Platform record. Fable project `fsharp/web/Web.fsproj` linking `../src/*.fs` + `BrowserPlatform.fs` (uses `Fable.Core.JsInterop` dynamic access only) + `Main.fs`; build `dotnet fable fsharp/web -o fsharp/web/dist` (local tool manifest). Pages + smoke. Commit.
### Task 15: Course text m00–m05
`course/00-setup.md` … `05-folding-time.md` per the module template: header (time estimate, files, test command per track), Why, Learning objectives, Concepts with Scala bridges and side-by-side JS/Scala/F# code, Exercises (signatures in 3 languages, which fixture/test proves it), tiered hints in `<details>` (nudge / stronger / near-solution), "Check your understanding" Socratic questions, "In production", "Further reading" links. Module 2 asks the student to design their own model first, then reveals the canonical one. Text must not contain solution code for the exercises (concept examples on other data only). Commit.
### Task 16: Course text m06–m11 + method + reference
`06-rendering-as-data.md` … `11-capstone-pong.md`, `method.md` (the stateful→functional conversion recipe, with a worked mini example of a stateful counter-with-timer converted), `reference.md` (all contract signatures in three languages). Module 11 gives the Pong spec in prose plus "apply the method" steps with lighter hints. Commit.
### Task 17: README, mentor mode, link check, final verification
`README.md` (what you'll build, who it's for, prerequisites, tracks, how to run tests/game per track, solo vs mentor, module map, checkpoints policy), `MENTOR.md` (Socratic rules from spec: ask before explaining, never write solution code unless the student explicitly gives up on an exercise, use hint tiers in order, ask the student to predict/explain, consult `solutions/` only to evaluate), `AGENTS.md`/`CLAUDE.md` pointers. `scripts/check-links.mjs` run over all Markdown → all OK (fix or replace dead links). Final: `verify.sh` for all three tracks PASS, `check-stubs.sh` for all three tracks shows every module failing, smoke tests for 3× snake + 3× pong pages. Commit.
@@ -0,0 +1,171 @@
# Pure Snake — a functional programming course (design)
## Intent
A self-paced course that teaches functional programming by building a complete,
playable Snake game in which **everything is pure**: the game rules, randomness,
rendering, time, keyboard input, and the program itself. Side effects exist only
as *values* (an `IO` type and event streams) that the student builds from scratch;
a single `run` call at the very edge of the program is the only place anything
actually happens.
**Why:** students who've had one Scala course know `map`/`filter`/`fold` and case
classes, but rarely know how to apply them to an interactive, stateful system. The
outcome we want is practical: by the end, a student can take a game written the
usual way (`snake.currentPos = ...`, `apple.pos = ...`) and convert it into a purely
functional system — and explain each step of how and why — because they have
actually done it, not just read about it.
## Audience
- Bachelor's/master's CS students with one prior Scala course.
- Working **alone at home**. Two supported modes:
1. **Solo** — the text, tiered hints and tests carry them.
2. **With an AI as a Socratic mentor** — the course ships instructions that
make an AI assistant guide by questioning rather than handing over answers.
- Assumed: basic JS syntax familiarity is *not* required to be strong; Module 1
bridges from Scala.
## Goals
- A fully functioning Snake game at the end, built by the student.
- Teaching and understanding over speed: each module explains *why*, not just *what*.
- Test suites the student runs against their own code at every step.
- Links to online sources for every idea used (papers, docs, blog posts, talks).
- Three tracks: **JavaScript (primary), Scala 3, F#** — each with its own runnable
test suites, so the course is fully doable in any of the three.
- A transferable method: a capstone where the student converts a different game.
## Non-goals
- Not a "before" stateful version for the student to start from — the student can
reason about stateful code themselves. (Stateful snippets may appear briefly as
illustration in prose, but there is no stateful codebase to refactor.)
- No FP libraries in the student's code (no Ramda, RxJS, cats-effect, fs2, FSharp
async/observables as the implementation). Production libraries are *linked* as
"what this looks like in the real world".
- No TypeScript, no bundlers, no frameworks.
- Not a course on game design or graphics.
## Anti-goals (failure even if it "works")
- Course reads as a fill-in-the-blanks exercise that can be completed by
pattern-matching tests without understanding.
- Impurity smuggled in "just for convenience" (a `let` that's reassigned, a
`Math.random()` in game logic, `addEventListener` outside the edge).
- The Scala/F# tracks feel like second-class afterthoughts with broken or
missing tests.
- AI mentor mode that just dumps solutions.
## Constraints and decided tooling
- **JS:** modern JavaScript, ES modules, zero dependencies. Tests with Node's
built-in runner (`node --test`). Game renders to an HTML `<canvas>`; a thin
`index.html` only loads the module.
- **Scala 3:** tests with munit, run via `scala-cli` (no sbt project). Game runs
in the browser via Scala.js (scala-cli supports it) on the same canvas page.
- **F#:** tests with Expecto via `dotnet test` (or `dotnet run` for Expecto).
Game runs in the browser via Fable on the same canvas page.
- Students hand-build `IO` and streams in **all three** languages; the APIs have
the same shape and names across tracks (adjusted for idiom, e.g. `flatMap` vs
`bind`), documented side-by-side.
- Where possible, pure-logic test cases live as **shared language-neutral data
files** (JSON fixtures: given state + input → expected state) consumed by all
three suites, so behaviour cannot drift between tracks. Tests for IO/streams
are written natively per language but mirror each other one-to-one.
- Reference solutions ship in a separate `solutions/` folder per track; students
are told to try first. All test suites must pass against the solutions
(this is how the course itself is verified).
## Course structure (decisions marked *[call]* were made by the designer, not the requester)
- **One shared text, side-by-side code.** *[call]* Each module's prose is written
once and language-neutral where possible; every code listing, signature and
exercise appears in all three languages (JS / Scala / F#) next to each other.
Where a concept is language-specific (Module 1, tagged unions in Module 2), the
module has a short per-track section. Bridges go from Scala (the shared
background) to each track — for F#, "the Scala thing you know is spelled like
this in F#".
- **Canonical data model, but designed first by the student.** *[call]* Module 2
has the student sketch their own model and answer design questions *before*
revealing the course's canonical model, which they then adopt and compare with
theirs. The canonical model is fixed so that the shared JSON fixtures work
identically in every track.
- **One growing codebase per student, per track.** *[call]* The student builds a
single project across modules. Each module states which files it touches. If
stuck, a student may copy the previous module's checkpoint from `solutions/` to
continue — tests are organised per module so earlier ones keep passing.
- **All three tracks ship together, complete.** *[call]* Following "not
second-class": every module is finished in all three tracks, with tests passing
against reference solutions, before the course is called done. No hour budget;
depth is whatever the concept needs.
- **Cross-language determinism.** The PRNG must give bit-identical sequences in JS,
Scala and F# (32-bit integer arithmetic) so fixtures involving apples are shared.
## Module sequence
Each module contains: concept text with Scala bridges, learning objectives,
exercises with signatures in all three languages, tiered hints (nudge → stronger
hint → near-solution), Socratic discussion questions, test suites per track,
"further reading" links, and a short "in production" note (cats-effect, fs2,
Elm, Elmish, RxJS, etc.).
0. **Setup & how to use the course** — tracks, running tests, solo vs. mentor mode.
1. **Purity & immutability in JS for Scala people** — referential transparency,
`const` vs. immutable data, `Object.freeze`, spread copies, functions as values,
currying/composition (`pipe`, `compose`).
2. **The game as data** — `Point`, `Direction`, `Snake`, `GameState`; algebraic data
types as tagged unions in JS (mapping from Scala `enum`/case classes and F#
discriminated unions); smart constructors.
3. **Pure game rules** — `step : State → State`: movement, growth, wall/self
collision, illegal 180° turns, eating.
4. **Randomness without side effects** — seeded PRNG as `seed → [value, seed]`;
apple placement avoiding the snake; first encounter with the state-passing
pattern (naming the State monad).
5. **Folding time** — the game as `reduce` over a list of inputs; deterministic
replays; why this makes testing trivial.
6. **Rendering as data** — `view : State → Scene` returning a description of what
to draw (rectangles, text), not drawing it.
7. **The IO type** — effects as values; `of`/`map`/`flatMap`; monad laws (tested);
interpreting a `Scene` into an `IO` of canvas operations; a test interpreter so
IO is verifiable without a screen.
8. **Event streams** — ticks and key presses as values; `map`/`filter`/`merge`/`scan`
built from scratch; streams as descriptions that do nothing until run.
9. **Wiring it together** — `main` is a single `IO` value: `scan(update)` over merged
input + tick streams, rendered each frame; exactly one `run` at the edge. Game
is playable.
10. **Extensions, all pure** — score, game over, restart, pause, speed-up.
11. **Capstone: the method** — an explicit recipe for converting a stateful game
into a functional one, then a guided conversion of a second game (Pong or
Tetris) with lighter scaffolding and its own tests.
## Socratic mentor mode
- A `MENTOR.md` (with thin `AGENTS.md` / `CLAUDE.md` pointers) that instructs an AI
assistant to: ask questions before explaining, never write solution code unless
the student explicitly gives up on an exercise, use the module's discussion
questions and hint tiers in order, check understanding by asking the student to
explain/predict, and consult `solutions/` only to evaluate the student's work.
- Each module lists the discussion questions the mentor (or the solo student)
should be able to answer before moving on.
## Sources
Each module ends with links to the ideas used. Expected anchors include: Hughes
"Why Functional Programming Matters"; the Elm Architecture guide; Haskell/Scala
`IO` writings (cats-effect docs, "Functional Programming in Scala"); Conal Elliott
on FRP; "Mostly Adequate Guide to FP" (JS); Expecto/munit/Node test docs; Scala.js
and Fable docs; PCG/xorshift/mulberry32 PRNG references; Gary Bernhardt
"Functional Core, Imperative Shell" (as the contrast to what we're doing). Links
must be checked as live at build time.
## Left to the builder
- Exact repository layout, file names, and module file format (Markdown assumed).
- Exact API names for `IO`/stream operations, as long as they're consistent
across tracks and documented side-by-side.
- Stream implementation strategy (push vs. pull), with the choice explained to
the student.
- Choice of PRNG algorithm, grid size, tick rate.
- Which capstone game (Pong or Tetris) — pick whichever teaches the method best.
- Visual style of the game page (keep it clean and simple).