docs: course design spec and implementation plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user