scripts/fixtures/ holds the scripts that produced the 2026-11-11 fixtures, with a README on running them safely into a copy. Run in order they reproduce the content of every fixture they touch. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
23 KiB
Pure Snake — the contract
The canonical description of what the course code must do, as of 2026-11-11. It replaces the "contract" section of docs/superpowers/plans/2026-10-28-pure-snake-course.md, which is out of date.
What is normative, in order
- The shared fixtures in
fixtures/*.json. All three tracks read the same files. - The doc comments of the stubs in
js/src,scala/src,fsharp/src. They state each function's behaviour; solutions and course text must agree with them. - This document, for everything that crosses files: data shapes, orders, constants, pinned values, conventions.
course/reference.mdgives every name in all three languages side by side. It is a lookup table, not a specification.
If two of these disagree, that is a bug: fix the lower one, and say so in the build log.
Conventions
- Tracks. JavaScript is the reference track. Scala 3 and F# are ports that must produce the same values for the same inputs, including every random draw.
- Files. Student stubs in
<track>/src, finished code in<track>/solutions, same file names. A stub compiles, carries the specifying doc comment and fails per test (todo('mNN'),???,failwith "TODO mNN"). In F#, anything that would fail at module load is a unit function (main (),randomSeed (),loadBest (),pongMain (),lifeMain (),State.get ()). - Given code (present and complete in the stubs): types and constants in Scala/F#;
todo;genActions,genState;CheckResult,Session,Recording,Viewer, the action types; the stream sourceclicks;canvasPoint;drive/answerinsafeio; allmainentry files andtypes.d.ts; the browser glue of Scala and F# (scala/web,fsharp/web). - Calling style. JS: data first, plain objects with a
tag. Scala: methods and objects inpackage snake;Option,List;Longfor seeds and time. F#: data last;bindforflatMap,chooseforfilterMap; PascalCase fields;uint32seeds,int64time. - Purity. Outside the edge files there is no reassignment, mutation, loop with a counter, randomness, clock or DOM. Edge files: JS
runtime.js,browser.js,main.js,replay-main.js,*/main.js; ScalaRuntime.scalaandscala/web; F#Runtime.fsandfsharp/web. Each track's purity test enforces this onsrc. - Module tags.
M00–M13plus the optionalM07b../test.sh MNNin each track runs exactly that module's tests. - Coordinates.
xgrows right,ygrows down. "Row-major" always means: byy, then byx.
Data
Point {x, y}
Direction Up | Down | Left | Right delta: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0)
Status Playing | Paused | GameOver | Won
Action Tick | Turn(dir) | TogglePause | Restart | Frame
GameState {width, height, snake (head first), dir, nextDir, apple (or none), seed, score, status, frame, queued (or none)}
Shape Rect {x, y, w, h, color} | Text {x, y, text, color, size, align}
Scene list of Shape, drawn in order
Op Draw(shape) | RandomSeed | LoadBest | SaveBest(score)
Input Time(t ms) | KeyDown(key) | KeyUp(key) | Click(x, y) (called Event in Scala and F#)
Session {game, best, improved}
- JSON field order of a game state is as listed;
queuedis last. "None" isnullin JSON and JS. diris the direction the snake last moved;nextDirthe direction of its next step;queueda second pending turn (Module 10);framethe number of frames since the snake last moved (Module 10).frameandqueuedare dormant (0 and none) until Module 10.initialState(seed): 20×20, snake(10,10) (9,10) (8,10),dir = nextDir = Right, no apple, score 0,Playing, frame 0, nothing queued.
Modules
M01 util
range(n) = [0..n-1]; dropLast (total: empty stays empty); contains(xs, p, eq) calls eq(x, p). JS only: pipe, compose, deepFreeze (cycle-safe, descends into already-frozen parents, leaves null and primitives alone).
M02 model
point, pointEquals, delta, opposite, Action, initialState.
M03 rules
move(p, dir): no wrapping.inBounds(state, p):0 <= x < width,0 <= y < height.turn(state, dir): unchanged unlessPlayinganddir != opposite(state.dir)(checked againstdir, notnextDir); thennextDir = dir.advance(state): unchanged unlessPlaying.head = move(snake[0], nextDir);eating= head is on the apple;body= the whole snake when eating, else the snake without its last cell. If head is off the board or inbody:{dir: nextDir, status: GameOver}, snake unchanged. Otherwise snake = head + body,dir = nextDir, and when eating the apple is removed and score + 1.advanceignoresqueuedand never touchesseedorframe.
M04 random, spawn, optional state
nextUint32is 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)) >>> 0; result(value, s). From seed 0:(1144304738, 1831565813). F# routes*and+throughuint64helpers because Fable does not wrapuint32.Random<A>=seed -> (A, seed)withpure,map,flatMap,run.intBelow(n)=nextUint32mapped withv % n(biased on purpose; the fixtures depend on it).freeCells(state): board cells not in the snake, row-major; the apple's cell counts as free.spawnApple(state): unchanged if there is an apple or the status is notPlaying;Wonif no cell is free (seed unchanged); otherwise apple =freeCells[i]withifromintBelow(count)onstate.seed, and the seed advances.tick=spawnApple ∘ advance.newGame(seed)=spawnApple(initialState(seed)).state(optional Exercise 4.7):State<S, A>=s -> (A, s)withpure,map,flatMap,get,put,modify,run. Nothing in the game uses it. Its tests are under M04; a student who skips it copies the solution file.
M05 game (extended in M10)
movesEvery(score)=max(2, 8 - floor(score / 3)).update(state, action), final form:Tick:step(state)wherestep = takeQueued ∘ tick.Turn(dir):steer(state, dir).TogglePause:Playing <-> Paused; other statuses unchanged.Restart:newGame(state.seed)from any status (the current seed).Frame: unchanged unlessPlaying.n = frame + 1; ifn >= movesEvery(score)thenstepof the state withframe: 0, else the state withframe: n. The threshold uses the score before the step.
steer(state, dir): notPlaying→ unchanged. No turn pending (state.nextDir == state.dir) →turn(state, dir). A turn pending → ifdiris neithernextDirnor its opposite,queued = dir(a later key replaces it); otherwise unchanged.takeQueued: after a step, if the result isPlayingand something is queued:nextDir = queued,queued= none. A result that is notPlayingkeepsqueued.replay(state, actions)is the left fold ofupdate;historyis the scan (length = actions + 1, starts withstate).- In Module 5 itself,
updateonly handlesTick(→tick) andTurn(→turn); the Module 5 fixtures hold under both forms.
M06 view
CELL = 20. Scene, in order: background Rect(0, 0, w*20, h*20, '#111111'); the apple, if any, as a cell '#e53935'; each snake cell (head '#7cb342', body '#43a047'), where a cell is Rect(x*20+1, y*20+1, 18, 18); Text(6, 18, 'Score: <n>', '#ffffff', 16, 'left'); then for Paused / GameOver / Won two centred texts at (w*20/2, h*20/2) size 32 and 30 px lower size 16: Paused + Press Space to resume, Game Over + Press R to restart, You Win! + Press R to restart.
M07 io
IO<A>=Pure(value)|Effect(op, k).pure,effect(op)=Effect(op, pure),flatMap(onPureit callsfat once; onEffectit extendsk),mapviaflatMap,sequence(right-nested; left nesting overflows the 100 000-effect runtime test),draw,randomSeed,drawScene(scene)=sequence(scene.map(draw)).- Module 10 adds
loadBestandsaveBest(score). trace(io, seed, best = 0)→{result, ops}: answersRandomSeedwithseed,LoadBestwithbest, the others with unit. Scala: default argument. F#:traceWith seed best io, andtrace seed io = traceWith seed 0 io. Scala/F# answers areOpResult:Done | Seed(n) | Best(n).
M07b safeio (optional)
A second, independent IO: Pure | Effect(op, k) | FlatMap(io, f). flatMap only builds a node and never calls f. step(io) does one constant-work rewrite: FlatMap(Pure(a), f) → f(a); FlatMap(Effect(op, k), f) → Effect(op, r => FlatMap(k(r), f)); FlatMap(FlatMap(m, g), f) → FlatMap(m, x => FlatMap(g(x), f)). trace(io, seed) must not grow the stack for 100 000 left- or right-nested binds, and must equal Module 7's trace on programs of Draw and RandomSeed. It answers only those two ops and throws for any other. JS gets a given trampoline drive; Scala and F# use a tail-recursive loop. F# erases the intermediate type (FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>), given private erase).
M08 stream (extended in M10 and M13)
Stream<A>={step: input -> (outputs, nextStream)}; no hidden state.- Sources:
keyDowns,keyUps(emit the key),clicks(emit(x, y), given, used from M13),interval(ms): onTime temits every multiplek*mswithlast < k*ms <= t, ascending;laststarts at 0 and becomest. - Combinators:
map,filter,filterMap/choose(drops none),merge(a, b)(both step on every input;a's outputs first),scan(s, f, z)(emits each new accumulator, notz),startWith(s, v)(emitsvbefore the outputs of the first step, whatever the first input is),run(s, inputs). clampTime(s, maxGap)(M10): givessa clock of its own starting at 0. OnTime tthe clock advances bymin(max(t - last, 0), maxGap)andsis stepped withTime(clock);last(initially 0) becomest. Every other input is passed through unchanged.
M09 input, app, runtime, browser (extended in M10 and M13)
keyToAction(key):ArrowUp/w/W→Turn Up, likewise Down (s), Left (a), Right (d); from M10' '→TogglePause,r/R→Restart; anything else none (includingconstructor,toString).- Constants:
TICK_MS = 150,FRAME_MS = 25,MAX_GAP_MS = 250. classicActions=merge(map(interval(TICK_MS), Tick), filterMap(keyDowns, keyToAction));speedActionsthe same withFrameeveryFRAME_MS.states(seed, actions)=startWith(scan(actions, update, newGame(seed)), newGame(seed));render(state)=drawScene(view(state)).- Session (M10):
sessionUpdate(s, a):game' = update(game, a),best' = max(best, game'.score),improved = best' > best.sessions(seed, best, actions)=startWith(scan(actions, sessionUpdate, s0), s0),s0 = {newGame(seed), best, false}.sessionView(s)=view(game)+Text(width*20 - 6, 18, 'Best: <best>', '#ffffff', 16, 'right').renderSession(s)=drawScene(sessionView(s)), thensaveBest(best)exactly whenimproved. main:IO<Stream<IO<unit>>>. Final form:randomSeed, thenloadBest, thenclampTime(map(sessions(seed, best, speedActions), renderSession), MAX_GAP_MS). Its ops when traced:[RandomSeed, LoadBest]. In Module 9 it ismap(randomSeed, seed => map(states(seed, classicActions), render)); the tests keep that program asclassicMain.play(main, seed, events, best = 0): the list of ops of each drawingmainemits when fedevents. F#:play seed events mainandplayWith seed best events main.Platform:fillRect(x, y, w, h, color),fillText(x, y, text, color, size, align),randomSeed(),startClock(onTime),onKeyDown(cb),onKeyUp(cb),loadBest(),saveBest(n)(M10),onClick(cb)(M13).runIO(io, platform): a loop (or tail recursion); performs each op on the platform.runApp(main, platform): runsmain, keeps the current stream in the program's one mutable variable, and feedsKeyDown,KeyUp,Time(and from M13Click) inputs: step, store the next stream, run each emitted IO in order. Nothing is drawn before the first input.- Browser platform: the clock reports milliseconds since the first animation frame; the seed is one
Uint32fromcrypto.getRandomValues; text is<size>px monospace. Storage keypure-snake-best: only a string of 1–10 decimal digits with a value of at most 2147483647 counts; anything else, a missing entry, or storage that throws reads as 0; saving never throws. Click position:floor((clientX - rect.left - canvas.clientLeft) * canvas.width / canvas.clientWidth), same fory; the listener is registered only whenonClickis called.
M10 additions not listed above: replay
- Recording
{seed, actions};frames(rec)=history(newGame(seed), actions). - Viewer
{index, last, playing};viewerStart(last)={0, last, false}. ActionsNext | Prev | First | Last | TogglePlay | Beat. viewerUpdate:Next/Prevmove one frame, clamped to[0, last];First/Lastjump;TogglePlayflipsplaying, and starting to play at the last frame goes back to frame 0;Beatadvances one frame when playing; after every action a viewer on the last frame is not playing.viewerKey:ArrowRight/d/DNext,ArrowLeft/a/APrev,HomeFirst,EndLast,' 'TogglePlay.viewerView(frames, viewer)=view(frames[index])+Text(w - 6, 18, 'step <index> / <last>', '#ffffff', 16, 'right')+Text(w/2, h*20 - 8, hint, '#aaaaaa', 12, 'center'), hint'Space: pause'when playing, else'Left/Right: step Home/End: jump Space: play'.REPLAY_MS = 100;replayActions= beats merged with keys (beats first);viewerStates(last, actions);replayMain(rec)=pureof the clamped stream of drawings. No seed and no stored best are asked for.fixtures/replay-sample.json: seed 1, 226 actions, 19 apples, ends in game over. All three tracks' given glue loads it withfetch("../fixtures/replay-sample.json").
M11 pong
- Constants:
W = 80,H = 50,PCELL = 8,PADDLE = 8,PLAYER_X = 2,CPU_X = 77,WIN = 5,FRAME_MS_PONG = 30. - State
{ball, vel {dx, dy}, player, cpu, held {up, down}, score {player, cpu}, status Playing | Over, seed, frame};initialPong(seed): ball(40, 25), vel(1, 1), both paddles at 21, nothing held, 0–0. - Actions
Press(key) | Release(key) | Frame | Restartwith keyUp | Down.Press/Releasesetheldeven when the match is over.Restart=initialPong(state.seed)withheldkept. FramewhilePlaying, in order: frame + 1; player moves byheld(both cancel), clamped to[0, H - PADDLE]; on an even new frame the cpu moves one cell towardball.y - PADDLE/2, clamped, looking at the ball before it moves;ny = y + dy, and ifny < 0 || ny >= Hthendyflips andny = y + dy;nx = x + dx, and the ball bounces (dxflips,nx = x) whennx == PLAYER_X,dx < 0andplayer <= ny < player + PADDLE(player's new position), ornx == CPU_X,dx > 0andcpu <= ny < cpu + PADDLE;nx < 0→ cpu scores,nx >= W→ player scores; a point serves from(40, 25)withdxtoward the side that conceded anddy = 1ifnextUint32(seed)is even else-1, and the seed advances; a score ofWINsetsOver.pongKey(isDown, key): arrows andw/s(either case) press/release;r/Rrestart on press only.pongMainis clamped likemainand has no stored best.pongView: backgroundRect(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, '<player> <cpu>', 24, 'center'); when overText(320, 200, 'You Win!' | 'CPU Wins', 32)andText(320, 230, 'Press R to restart', 16).
M12 check (bonus)
- A generator is a
Random<A>.elementOf(xs)= element atintBelow(length).listOf(gen, n):ndraws threaded left to right;n <= 0→pure([]). genActions(n)=listOf(elementOf(pool), n)with pool exactly[Tick, Tick, Tick, Turn Up, Turn Down, Turn Left, Turn Right].forAll(gen, prop, runs, seed): draws fromseed, each draw continuing from the previous one's seed; stops at the first failure. Result: passed withruns, or failed withrun(0-based), theseedthat draw started from, and thevalue. Stack-safe in Scala and F#.removals(xs): every list with one element removed, by index.minimize(xs, fails): repeatedly continue with the first failing removal; the result is 1-minimal.violations(state): the names of broken invariants in this order:snake-not-empty,snake-in-bounds,snake-no-overlap,snake-connected(consecutive cells exactly one step apart),apple-in-bounds,apple-not-on-snake,turn-not-reversed(nextDir != opposite(dir)),queued-not-reversed(nothing queued, orqueued != opposite(nextDir)).moveGaps(states): differences between consecutive indicesi >= 1at which the head differs from the head ati - 1; total on empty snakes.genSnake(width, height, maxLen): drawx = intBelow(width), theny = intBelow(height); then, until the snake hasmaxLencells, oneelementOfamong the in-bounds neighbours of the last cell that are not in the snake, listed in the order Up, Down, Left, Right; stop early when there is none (stopping uses no draw). The first cell is the head.genState(width, height)(given), draws in order:n = intBelow(width*height); the snakegenSnake(width, height, n + 1);nextDir=elementOfof[Up, Down, Left, Right]withoutopposite(dir); a coinintBelow(2): 0 = no apple, 1 =elementOf(freeCells)(none if no cell is free).diris the direction from the second cell to the head (Rightfor one cell); seed, score, frame are 0; nothing queued;Playing.advanceSpec(state): builds the next snake naively, asksviolationsforsnake-in-bounds/snake-no-overlap, and otherwise behaves asadvance. It must equaladvanceon every sane state and must not call it.
M13 life
- Constants:
LW = 40,LH = 30,LCELL = 16,STRIP = 24,GEN_MS = 150. Canvas 640×504. - State
{cells, running, seed, generation};cellsis always canonical: on the board, no duplicates, row-major.initialLife(seed): no cells, paused, generation 0. lifeStep(cells): B3/S23 judged by the old board only; edges are dead, nothing wraps; the result is canonical.lifeUpdate:Frame→ next generation and generation + 1 when running;Step→ the same when paused;TogglePauseflipsrunning;Toggle(cell)flips a cell whether running or not (off the board: unchanged; generation unchanged);Clear→ no cells, generation 0,runningand seed kept;Randomize→ oneintBelow(4)per board cell in row-major order fromstate.seed, alive when the draw is 0, seed after the last draw, generation 0,runningkept.lifeKey:' 'TogglePause,n/NStep,c/CClear,r/RRandomize.lifeClick(x, y):Toggleof(floor(x/16), floor(y/16)), or none when the pixel is not on the board (testx >= 0 && y >= 0before dividing).lifeActions= frames, then keys, then clicks (merge order).lifeMain: seed, then the clamped stream of drawings; no stored best.lifeView:Rect(0, 0, 640, 480, '#111111');Rect(x*16+1, y*16+1, 14, 14, '#7cb342')per live cell in order;Rect(0, 480, 640, 24, '#222222');Text(6, 497, 'Generation <n>', '#ffffff', 14, 'left');Text(634, 497, 'Running: Space pauses' | 'Paused: Space runs, N steps', '#ffffff', 14, 'right').- Clicks reach a student only here: Module 13 has them add
onClickto their runtime (and, in JS, tobrowser.js). Modules 9–12 must pass with a runtime that does not register clicks.
Pinned values that all tracks must reproduce
| What | Value |
|---|---|
nextUint32(0) |
(1144304738, 1831565813) |
listOf(nextUint32, 3) from 0 |
[1144304738, 1416247, 958946056], seed 1199730143 |
newGame(1) |
apple at (12, 8), seed 1831565814 |
genSnake(5, 5, 6) from seed 0 |
(3,2) (3,1) (3,0) (2,0) (1,0) (0,0), next seed 4231026099 |
Double-turn mutant, forAll(genActions(40), …, 300, 2024) |
shrinks to [Turn Up or Down, Turn Left] |
| No-walls mutant, same call | shrinks to ten Ticks |
| "Tick forgets the queue" mutant | shrinks to [Turn Up, Turn Right, Tick, Turn Left] |
| "Queue accepts a reversal" mutant | shrinks to [Turn Up, Turn Down] |
Puzzle 1 mutant vs advanceSpec, 500 states of 5×5, seed 2024 |
caught at run 21 |
Fixtures
Format: {function, cases: [{name, args, expected}]}; names are kebab-case sentences. Generated from the JS solutions with small scripts (those of 2026-11-11 are kept in scripts/fixtures/, with a README) and then read by hand; formatting is JSON.stringify(x, null, 2) plus a newline.
| File | Function | Cases |
|---|---|---|
m00-sanity |
sanity | 2 |
m02-delta, m02-opposite, m02-point-equals, m02-initial-state |
as named | 4, 4, 4, 2 |
m03-move, m03-in-bounds, m03-turn, m03-advance |
as named | 5, 7, 8, 12 |
m04-random, m04-int-below |
nextUint32, intBelow |
30, 7 |
m04-free-cells, m04-spawn, m04-tick, m04-new-game |
as named | 5, 8, 5, 2 |
m05-update, m05-replay, m05-history |
as named | 7, 2, 2 |
m06-view |
view |
5 |
m09-input, m10-input |
keyToAction |
17, 7 |
m10-moves-every, m10-update |
movesEvery, update |
6, 40 |
m10-session-update, m10-session-view |
as named | 11, 3 |
m10-golden |
play on main; args [seed, events, best], expected = the last drawing's shapes |
3 |
m10-viewer-update, m10-viewer-key, m10-viewer-view |
as named | 18, 14, 3 |
m11-pong-update, m11-pong-key, m11-pong-view |
as named | 49, 21, 4 |
m12-violations |
violations |
21 |
m13-life-step, m13-life-update, m13-life-key, m13-life-click, m13-life-view |
as named | 15, 21, 12, 10, 3 |
replay-sample |
data, not cases | — |
Changing an existing case is a contract change: say which and why in the build log.
Checks a change must pass
Run from the repository root; see the README maintainer table for details.
| Command | Must show |
|---|---|
scripts/verify.sh <js|scala|fsharp> |
whole suite green against the solutions |
scripts/check-stubs.sh <track> |
every module M01–M13 and M07b fails against the stubs, no build errors |
scripts/check-progression.sh <track> |
every checkpoint passes with solutions up to module N |
scripts/typecheck-js.sh |
JS stubs and solutions type-check (needs network for npx) |
node scripts/mutate.mjs |
no surviving mutant outside scripts/mutate-allow.txt |
scripts/e2e.sh |
every page of every track draws exactly what the pure JS model says (Playwright; Chromium and WebKit) |
node scripts/smoke.mjs <url> |
quick dependency-free check that a page paints |
node scripts/check-links.mjs |
every link in the Markdown resolves |
Test counts on 2026-11-11: JS 728, Scala 689, F# 691, end-to-end 176. They differ between tracks because a few tests are track-specific (JS browser-glue and constructor tests, one F# stack test, one JS-only play default test).