Files
functional-programming-snake/docs/superpowers/plans/2026-10-28-pure-snake-course.md
T
2026-10-28 23:31:16 +01:00

25 KiB
Raw Blame History

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.

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.