25 KiB
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 banslet,var,++,--,.push(,.pop(,.shift(,.unshift(,.splice(,.sort(,.reverse(,Math.random,Date.now,performance.now,document,window,addEventListener(comments stripped before scanning). Scala bansvar,mutable,while,scala.util.Random,System.,println. F# bansmutable,<-,System.Random,DateTime,printf,ref. src/andsolutions/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 importtodofromjs/src/todo.js(identical insolutions/).- Every test suite passes against
solutions/(viascripts/verify.sh <track>), and every module's tests fail against freshsrc/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, ScalaLong, 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
- Quick double-turn (Up then Left within one tick) must not reverse the snake into itself →
turnvalidates againstdir(last moved), notnextDir. Testturn-against-last-moved-dirin fixtures/m03-turn.json (Task 3). - Snake moving into the cell its tail is leaving is legal; into its tail while eating is not → fixtures
advance-into-vacating-tailandadvance-into-tail-while-eating(Task 3). - Board full → no free cell for an apple →
spawnApplesets statusWoninstead of crashing. Fixturespawn-on-full-board(Task 4). - Keys pressed while paused / game over do nothing; restart works from any status → fixtures in m10-update.json (Task 9).
- Cross-language RNG drift (Scala/F# signed-int math) → identical first-10
nextUint32values 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 inAction = { 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 anddir !== opposite(state.dir); thennextDir = 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)orbodycontainsh→{...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](ScalaLong => (A, Long), F#uint32 -> 'a * uint32).nextUint32: Random<uint32>= mulberry32:Cross-check: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]value / 2^32equals bryc's referencemulberry32(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:
Rect(0, 0, width*CELL, height*CELL, '#111111')- apple if present:
Rect(ax*CELL+1, ay*CELL+1, CELL-2, CELL-2, '#e53935') - each snake segment in list order:
Rect(x*CELL+1, y*CELL+1, CELL-2, CELL-2, i==0 ? '#7cb342' : '#43a047') Text(6, 18, 'Score: <n>', '#ffffff', 16, 'left')- 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'}; Scalaenum 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}wherek : 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 everyRandomSeedwithseed,Drawwith unit;ops= list of ops in execution order. Implemented by recursion, no mutation.- Tests include the three monad laws checked via
traceequality.
m08 stream:
Input = {tag:'Time', t} | {tag:'KeyDown', key} | {tag:'KeyUp', key}.Stream<A> = { step: Input → [A[], Stream<A>] }(Scalafinal 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>— onTime t, emits every multiplek*mswithlast < k*ms ≤ t(values are those multiples, ascending), starting fromlast = 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, notz),startWith(s, v)(emitsvbefore 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))(wheregameActionsisclassicActionsin m09 andspeedActionsafter 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; feedsKeyDown/KeyUp/Timeinputs; runs each emitted IO; on the firststartClockcallback also runs nothing special (thestartWithvalue is emitted on the first input).- JS
browser.js:browserPlatform(canvas, win) → Platform(clock viarequestAnimationFrame,t= ms since start; seed viacrypto.getRandomValues);main.js: one callrunApp(main, browserPlatform(...));prevent defaulton 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/W → Up, ArrowDown/s/S → Down, r/R (down only) → Restart.pongUpdate(s, a): Press/Release setheld; 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 towardball.y - PADDLE/2, clamped; ball:ny = y+dy; ifny<0 || ny>=Hthendy=-dy,ny=y+dy;nx = x+dx; ifnx==PLAYER_X && dx<0 && player≤ny<player+PADDLEornx==CPU_X && dx>0 && cpu≤ny<cpu+PADDLEthendx=-dx,nx=x; ifnx<0cpu scores, ifnx>=Wplayer scores; scoring → serve: ball (40,25),dxtoward the side that conceded (cpu scored → -1, player scored → +1),dy = (nextUint32 % 2 == 0) ? 1 : -1advancing seed; reaching WIN → status Over.pongView(s): bgRect(0,0,640,400,'#111111'), paddlesRect(X*8, y*8, 8, 64, '#ffffff')player then cpu, ballRect(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').pongMainlikemain, usinginterval(30)→ Frame,keyDowns/keyUps→pongKey. JS pagejs/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-exportdeepFreeze-equivalentfreeze(x)local to tests (tests must not depend on studentdeepFreeze). -
Scala
Fixtures.load(name): ujson.Value+ decoderspoint,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.shruns 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 afixtures/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;deepFreezemakes nested object frozen. m02 via fixtures:delta(4 cases),opposite(4),pointEquals,initialState(7)exact object.purity.test.jsper Global Constraints (scanssrc/excludingruntime.js,browser.js,main.js; also asserts the scanner flags a known-bad sample string). - Step 2:
node --test test/m0[12]-*.test.jsagainst 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.