Files
functional-programming-snake/docs/superpowers/specs/2026-11-11-course-extensions.md
T
Lucas WintherandClaude Fable 5.2 edd57f0148 feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:05:29 +01:00

22 KiB
Raw Blame History

Course extensions — spec (2026-11-11)

Status: all twelve items built and verified on 2026-11-11. Where the build differed from the first draft, the text below was corrected in place. The rules as they now stand are in docs/contract.md; results and decisions are in docs/superpowers/notes/2026-10-29-build-log.md. Known leftovers in the wording: Item 9's command is tsc -p js/jsconfig.json (not -p js); Item 11's scripts/play.mjs was built and then replaced by Item 12; Item 9's event type is called Input in JS.

Eleven additions to the Pure Snake course, approved by the owner on 2026-11-11. This document is the contract for the builders. Where it says "builder's call", decide, and list the decision in your report.

Read first: README.md ("For maintainers"), docs/superpowers/notes/2026-10-29-build-log.md (both sections), one finished module end to end (course/10-extensions.md) and its code in all places (js/src, js/solutions, js/test, fixtures).

Rules for every builder

  • Layout. Student stubs in <track>/src, finished reference in <track>/solutions, same file names. A stub compiles, carries the doc comment that specifies behaviour, and fails per test (todo('mNN'), ???, failwith "TODO mNN"; in F# use unit functions where a module-level value would fail at load). Tests in js/test/mNN-*.test.js, scala/test/MNN*.test.scala, fsharp/tests/MNN*.fs (+ fsproj entries; src/Snake.fsproj and solutions/Snake.fsproj stay identical).
  • Fixtures. Pure-logic cases live in fixtures/mNN-<function>.json as {function, cases:[{name, args, expected}]}, generated from the JS solution with a throwaway script and then read by hand. All three tracks read the same file. Case names are kebab-case sentences.
  • Purity. Outside the runtime/browser files: no let/var/mutation/loops with counters/Math.random/clock/DOM (the purity tests of each track enforce it). Recursion and folds instead.
  • JS is the reference track. A JS builder writes JS stubs, solutions, tests, fixtures and the course text. Port builders port to Scala or F# and touch only their track's directory.
  • Course text (course/*.md): explain why before how; assume general programming experience and no FP or Scala background; never explain by reference to Scala; connect ideas to everyday programming in blockquotes starting > **From everyday programming:**. Signatures in all three languages. Every coding exercise gets three folded hints (nudge, stronger hint, near-solution skeleton with ___ holes — never the full solution), a "Tests"/"Proved by" list naming the JS test titles and fixture cases, and "Check your understanding" questions. Plain, direct prose; British spelling as in the existing text. Update course/reference.md, the README module map/checkpoint table, course/00-setup.md tables and scripts/check-progression.sh (files_for) when files or modules are added. External links must resolve (node scripts/check-links.mjs).
  • Signature conventions. JS: data first, plain objects with tag. Scala: methods/objects in package snake, Option, List, Long for seeds and time. F#: data last, bind/choose, PascalCase fields, uint32 seeds, int64 time.
  • Verification before you report (from the repo root): scripts/verify.sh <track>, scripts/check-stubs.sh <track>, scripts/check-progression.sh <track> for every track you touched; node scripts/check-links.mjs --offline (and online if you added links) if you touched text. For browser-facing changes: copy the repo to a temp dir, replace src with solutions, run build-web.sh (Scala/F#), serve with node scripts/serve.mjs and run node scripts/smoke.mjs <url> for each page.
  • Do not commit, stash, reset or checkout. Do not edit files outside your stated scope; other builders are working in the tree at the same time. Do not weaken or delete an existing test to make something pass; if an existing test contradicts this spec, change it deliberately and report it.
  • Report: files created/changed, final script results with test counts, every decision you made that this spec left open, and anything in the spec you found wrong or ambiguous.

Item 1 — Turn queue (Module 10, new Exercise 10.6)

Why. Module 3 admits that Up-then-Left within one step loses the second key. Fix it as an extension, without changing the Module 3 rules.

Model. GameState gets one more field, dormant until Module 10 like frame: queued — JS Direction | null, Scala Option[Direction], F# Direction option (Queued). initialState sets it to none. Add "queued": null to every game state in every existing fixture with a script; existing expectations otherwise stay as they are unless they contradict the semantics below (report each such case).

Semantics (in the game module, used by update; turn, advance, tick of Modules 3–4 are unchanged and ignore queued):

  • steer(state, dir) — what update does for Turn(dir) from Module 10 on:
    • not Playing → unchanged;
    • no turn pending (nextDir == dir) → turn(state, dir) (the Module 3 rule);
    • a turn is pending (nextDir != dir) → if dir is neither nextDir nor its opposite, the state with queued = dir (a later key replaces an earlier queued one); otherwise unchanged.
  • After every game step taken by update (the Tick case and the stepping branch of Frame): if the result is Playing and queued is set, then nextDir = queued and queued = none. If the result is not Playing, queued is left as it is. Restart gives a state with nothing queued.
  • Result: Up then Left within one step makes the snake go Up on the next step and Left on the one after.

Tests. New m10-update.json cases covering each branch above, plus a non-fixture test that plays "Turn Up, Turn Left, step, step" and checks the head's path. Module 5's "every other action unchanged"/Turn cases must still pass with the finished update.

Module 12. Add invariant queued-not-reversed (queued is none, or differs from the opposite of nextDir) after turn-not-reversed; extend m12-violations.json; genActions unchanged.

Text. Module 2 (field list, "ten fields" → eleven, canonical model, fixtures note), Module 3 (the question about the queue now points to Exercise 10.6), Module 10 (new section + exercise + hints + questions), reference.

Item 7 — Random states and a model-based test (Module 12, Exercises 12.6–12.7)

Why. Module 12 says invariants cannot see the wrong collision check of Puzzle 1. Close that.

  • genSnake(width, height, maxLen) : Random of a snake (head first): a random start cell, then up to maxLen - 1 further cells, each chosen with elementOf among the in-bounds neighbours of the last cell that are not yet in the snake; stop early when there is none. (Exact draw order is part of the contract so that tracks agree; write it in the stub doc.)
  • genState(width, height) (given, built from genSnake): a sane Playing state: snake from genSnake, dir consistent with the first two cells (any fixed choice for a one-cell snake), nextDir = dir or a perpendicular direction, apple = none or a free cell, queued none.
  • advanceSpec(state) : the step written straight from the rules, in a different style from Module 3's advance: build the snake the step would produce (new head in front; drop the last cell unless eating), then ask violations whether that snake is on the board and free of overlap; if not, game over as Module 3 specifies. It must agree with advance on every state.
  • Tests: fixture or seed-pinned tests for genSnake; every generated state has no violations; advance and advanceSpec agree on 500 generated states of a 5×5 board; a mutant advance with Puzzle 1's wrong check ("new head must not be on the old snake") is caught by that comparison.
  • Text: new sections in Module 12 ("generating states", "a second implementation as an oracle"), rewrite the bullet that says invariants cannot see Puzzle 1, question 6 now refers to the exercise.

Item 6 — Random is State (Module 4, optional Exercise 4.7)

New file state.js / State.scala / State.fs: State<S, A> = S -> (A, S) with pure, map, flatMap (bind), get, put, modify, run. Tests (m04-state): the laws on small examples; a counter example; and "Random is State with the seed as S": the dice of Module 4 written with State give the same values and seeds as with Random. Nothing in the game changes to use it. Text: a "Going further" section in Module 4 after "A pattern with a name", exercise 4.7 marked optional, hints, two questions (one asks the reader to express a label counter or the Pong serve with State). Progression mapping: with Module 4's files.

Item 4 — Stack-safe IO (optional Module 7b)

New text course/07b-stack-safe-io.md and new file safeio.js / SafeIO.scala / SafeIO.fs, independent of the game and of io: an IO with three constructors (Pure, Effect(op, k), FlatMap(io, f)), where flatMap only builds a FlatMap node, and an interpreter written as a loop (in the purity-audited file a loop is not allowed: write it as a tail-recursive function in Scala/F#; in JS put the interpreter where a while is permitted or — builder's call — express it with a trampoline; say which). Provide pure, effect, map, flatMap, sequence, a pure trace-like interpreter that does not grow the stack, and the re-association step explained in the text. Tests (m07b-*, module tag M07b; make test.sh M07b, check-stubs and check-progression understand it — builder's call how, e.g. number it so the scripts' loops find it): the three monad laws observationally; 100 000 left-nested flatMaps and 100 000 right-nested ones both run; traces equal those of Module 7's IO on the same programs. Text: why left nesting is quadratic and deep for the simple IO, the trampoline idea, the re-association rule as a picture, what production libraries add. Link from Module 7 section 12 and the README map (marked optional).

Item 8 — Mutation analysis as a maintainer script

scripts/mutate.mjs [file...]: single-token mutants of js/solutions files run against the JS suite in a temp copy; prints killed/survived and each survivor with file, line, original and mutated text; exits 1 when there are survivors not listed in an allow-list file of known equivalent mutants (scripts/mutate-allow.txt, with a reason per line). Start from the prototype at /tmp/claude-1000/-home-user-functional-programming-course/9bf00381-a84f-4bd0-87b0-ffcbdbf94782/scratchpad/mutate.mjs. Add a row to the README maintainer table and CLAUDE.md's list of checks is left alone.

Item 2 — A persisted high score (Module 10, Exercise 10.7, replaces the thought exercise)

Effects. Two new Ops: LoadBest (answer: a number) and SaveBest(score) (answer: unit). Scala/F#: OpResult gets Best(value: Int). io: loadBest (a value; F# loadBest ()), saveBest(n). trace must answer LoadBest: JS trace(io, seed, best = 0), Scala a default argument, F# traceWith seed best io with trace seed = traceWith seed 0. Platform gets loadBest() and saveBest(n); the browser implementations use localStorage key pure-snake-best, treat anything unreadable as 0 and never throw; runIO performs them; the fake platforms in the tests record them.

State. GameState, update and view are not changed. A new value wraps the game: Session = {game, best, improved}. sessionUpdate(session, action): game' = update(game, action), best' = max(best, game'.score), improved = best' > best. sessions(seed, best, actions) = startWith(scan(actions, sessionUpdate, s0), s0) with s0 = {game: newGame(seed), best, improved: false}.

Output. sessionView(session) = view(game) followed by one text shape Best: <n> (right-aligned at the top right; exact numbers builder's call, fixed by a fixture). renderSession(session) = drawScene(sessionView(session)), followed by saveBest(best) when improved. main = ask for the seed, then load the best, then the clamped stream of renderSession over sessions(seed, best, speedActions).

Tests. Module 7/9/10 tests that pin main's ops change from [RandomSeed] to [RandomSeed, LoadBest]: update them deliberately. classicMain in the test helpers stays the Module 9 program without the session. New tests: the interpreters handle the new ops; a traced renderSession contains SaveBest exactly when improved; a scripted run that beats the record saves once per improvement; the runtime with a fake platform loads once at start.

Text. Section 7 of Module 10 becomes a built feature: keep the six design questions as the lead-in, then the canonical design above, exercise, hints, questions. Module 7's "adding an effect is adding an Op" now points to it.

Item 5 — The whole program in one equality (Module 9, Exercise 9.5)

A pure helper in the app module: play(main, seed, events) (after Item 2: also the stored best; JS default 0, Scala default argument, F# explicit) returns, for each drawing the program emits while fed events, the list of ops it performs (trace of main, run of the stream, trace of each emitted IO). Tests: play on classicMain equals the scenes of the expected states for a short scripted game; a golden fixture m10-golden.json (built under that name because the final main only exists after Module 10) holds a seed, a list of events and the expected final scene, for main. Text: a short section in Module 9 ("testing the program end to end without a browser"), exercise, hints.

Item 3 — A replay viewer (Module 10, Exercise 10.8)

Why. Module 5 promises replays and nothing shows one.

  • A recording is {seed, actions}. fixtures/replay-sample.json holds one worth watching (the snake eats several apples and then dies; produce it with a small bot script and keep it under ~400 actions; Tick/Turn only).
  • Pure viewer module (replay.js / Replay.scala / Replay.fs): frames(recording) = history(newGame(seed), actions); viewer state = index into the frames; keys: ArrowRight/d next, ArrowLeft/a previous, Home first, End last, Space toggles autoplay (advance one frame every 100 ms from interval while on; stops at the end); viewerUpdate(viewerState, viewerAction) is total and clamps at both ends; viewerView = the game's view of the current frame plus a step i / n text and a hint line; replayMain(recording) : IO<Stream<IO<unit>>> (no seed needed, so it starts with pure).
  • Pages: js/replay.html (+ src/replay-main.js, given), scala/replay.html, fsharp/replay.html with given browser glue and build-script changes; the given glue of all three tracks loads the sample recording with fetch("../fixtures/replay-sample.json") (as built; an earlier draft had Scala/F# embed a generated value).
  • Tests m10-replay: viewerUpdate fixture; autoplay via run on scripted events; replayMain traced; stepping back shows exactly the earlier scene.
  • Text: Module 10 section + exercise + hints; Module 5's "Replays" bullet points forward to it. README quick start mentions the page.

Item 10 — Second capstone: Conway's Life with the mouse (Module 13)

Why. Pong is close to Snake. Life forces a new kind of input.

  • New event: Click(x, y) in canvas pixels. Stream source clicks. Platform.onClick(cb); runtime feeds it; browser glue listens for click on the canvas and converts to canvas coordinates. Existing sources ignore it. This touches stream, runtime, browser in all tracks: keep every existing test green.
  • Game in life/ (Life/ in F#): board 40×30 cells of 16 px; state {cells (set of live points, kept as a sorted list, row-major, no duplicates), running, seed, generation}; actions Frame (when running: next generation every 150 ms), Toggle(cell) from a click (as built: always, running or paused; a click that is silently ignored looks broken), TogglePause (Space), Step (n, one generation while paused), Clear (c), Randomize (r: each cell alive with probability about 1/4 drawn from the seed; seed advances). Classic rules B3/S23, edges are dead (no wrap). lifeView, lifeKey, lifeActions, lifeStates, lifeMain (clamped like the others).
  • Fixtures m13-life-step.json (blinker, block, glider, edge cases, empty), m13-life-update.json, m13-life-view.json, m13-life-key.json; tests M13. Pages life.html per track with given glue.
  • Text course/13-capstone-life.md: the stateful sketch to convert, the rules precisely, the method step by step with questions first and the canonical choice after (as Module 11 does), lighter hints (nudge + stronger hint only), a section on why the next generation is a pure function of the whole board and what goes wrong when the usual in-place loop updates cells one by one. README, setup, reference, progression script, method.md's "next projects" list.

Item 9 — Type checking for the JavaScript track, with no install

js/jsconfig.json with checkJs, strict null checks, ES modules; shared types in js/src/types.d.ts (Point, Direction, Status, Action, GameState, Shape, Op, IO, Event, Stream, Random, Platform, Session, Pong and Life types); JSDoc on exported functions in stubs and solutions referring to them. Goal: an editor with TypeScript support (VS Code out of the box) shows errors, including a non-exhaustive switch over Action (use the never trick in the text, not in the given stubs). No dependency is added to the course: the optional command is npx -y -p typescript tsc -p js --noEmit. scripts/typecheck-js.sh runs it against a temp copy with solutions as src and must pass; if npx has no network, it exits 2 with a clear message. Text: a short optional section in Module 0 ("if your editor understands types") and a paragraph in Module 2 where exhaustiveness is discussed, replacing "in JS you are the type checker" with the more precise statement. The purity test must ignore .d.ts.

Item 11 — An automated play session

scripts/play.mjs <base-url> (Chrome DevTools protocol, zero dependencies, same style as smoke.mjs): for each track's Snake page: load, check the first paint, press keys and check that the picture changes, press Space and check that it stops changing, press R; simulate a hidden tab if CDP allows (Emulation.setCPUThrottlingRate or Page.setWebLifecycleState; otherwise skip and say so) and check that the game is still alive afterwards; for Pong, Life and the replay viewer: load, interact once, check a change. Prints one line per check; exit 1 on any failure. README maintainer row. This does not replace a person playing; the report must say what it could not check.


Order of work

Wave 1 (parallel, disjoint files): A = Items 1 + 7 (JS, fixtures, text) · B = Items 6 + 8 · C = Item 4. Wave 2: D = Items 2 + 5 (JS, fixtures, text). Ports 1: Scala and F# builders port Items 1, 7, 6, 4, 2, 5. Wave 3: E = Items 3 + 10 (JS, fixtures, text), then ports 2 (Scala, F#). Wave 4: F = Items 9 + 11.


Item 12 — End-to-end tests with Playwright (replaces scripts/play.mjs)

Suggested by the owner after Item 11. Maintainer tooling only: the three course tracks stay free of dependencies.

Layout. e2e/ at the repo root: package.json (private; one dev dependency, @playwright/test, pinned to the exact version installed here), playwright.config.mjs, tests/*.spec.mjs, lib/ helpers. e2e/node_modules, reports and traces are git-ignored. scripts/e2e.sh [--tracks js,scala,fsharp] [-- <playwright args>] builds a temp copy with solutions as src (running build-web.sh for Scala/F# when those tracks are selected), serves it with scripts/serve.mjs on a free port, runs the tests against it and cleans up; exit code is Playwright's. Projects: Chromium and WebKit (Firefox too if a build is installed; never download browsers implicitly — if a browser is missing, skip that project with a clear message).

Three techniques every test builds on (put them in e2e/lib, each proven by its own small test):

  1. Fixed seed. An init script replaces crypto.getRandomValues so the page's seed is a known number.
  2. Fake clock. page.clock drives requestAnimationFrame, so time advances only when the test says so. Confirm on all three tracks' glue that the timestamp the page receives really is the fake one.
  3. Recorded drawing. An init script wraps the canvas 2D context's fillRect and fillText and records each call with the fill style, font and alignment in force, marking where each frame starts. A test can then read the last complete frame as a list of shapes.

The oracle. The tests import the pure JS solutions (js/solutions/*.js) in Node and compute what the page must draw: the last recorded frame must equal the model's scene (sessionView, pongView, viewerView, lifeView) for the same seed and the same events. This is a differential test of each track's browser build against the pure model; it is what replaces pixel-guessing.

Checks, for each track and each browser:

  • Snake: first frame equals the new session's scene; one step every 200 ms of fake time at score 0; a scripted game (keys at chosen fake times, enough to eat at least three apples with the fixed seed) matches the model after every key and at the end, including the faster step after the third apple; Up then Left inside one step gives Up then Left (turn queue); Space shows Paused and freezes the scene, Space resumes; a crash shows Game Over, R restarts; a 10 s jump of the fake clock in one frame advances the game by 250 ms only; the best score is saved on improvement, shown after reload, and a page whose localStorage throws or holds garbage still runs and shows Best: 0.
  • Pong: holding and releasing keys moves the paddle as the model says; a whole match on the fake clock ends with the model's score and the right winner text; R restarts and keeps a held key.
  • Replay: Right/Left/Home/End match the model's frames; autoplay advances one frame per 100 ms of fake time and stops on the last frame.
  • Life: clicks on a CSS-scaled canvas with a border toggle the cells the model says (click at cell centres and near cell edges); N, Space (one generation per 150 ms), C and R (with the fixed seed: exactly the model's cells) match the model.
  • No page errors or console errors anywhere.

Retire scripts/play.mjs (delete it; scripts/smoke.mjs stays as the dependency-free quick check) and update the README maintainer table, CLAUDE.md is left alone.

Report what the suite still cannot check and what a person still has to try.