25 KiB
Module 11 — Capstone: Pong, by the method
Time: ~6–10 h (it's a whole game; spread it over several sessions) · You'll edit: JS
js/src/pong/model.js,rules.js,view.js,app.js· Scalascala/src/pong/Model.scala,Rules.scala,View.scala,App.scala· F#fsharp/src/Pong/Model.fs,Rules.fs,View.fs,App.fsTests: JScd js && node --test "test/m11-*.test.js"· Scalacd scala && ./test.sh M11· F#cd fsharp && ./test.sh M11Prerequisites: Modules 0–10, and the method — read it before starting; this module is you applying it.
Why this module
Everything so far was guided: each module told you which function to write and why. That's how you learn the pieces. But the goal of this course is not "can build Snake when told how". It is: given a game written the usual way — with variables that change, timers and listeners — can you turn it into a purely functional one, and explain every step?
So in this module you get a different game, Pong, described the way you'd find it in the wild: a set of rules and a sketch of a stateful implementation. You convert it, following the recipe in method.md. The scaffolding is deliberately lighter. The specification and the tests are complete (so you know when you're done), but the hints mostly ask questions instead of giving shapes. Where Snake modules gave you a near-solution, this one asks you to look back at your own Snake code — you've written every pattern you need already.
You reuse, unchanged: IO (m07), streams (m08), the runtime and platform (m09), nextUint32 (m04). Notice, as you go, how much of the course turns out to be reusable infrastructure and how little is Snake-specific.
Learning objectives
By the end of this module you can:
- apply the conversion method to an unfamiliar game: identify state, events, the update fold, the view, the effects and the wiring;
- design a state that turns a held key (keydown + keyup) into data, and explain why Snake didn't need that;
- implement a multi-step frame update as a pipeline of small pure functions, in a specified order;
- reuse the IO, stream and runtime modules for a second program without modifying them;
- justify your design choices against the course's canonical ones.
The game, as you'd find it
Pong is a two-player tennis game: each side has a vertical paddle and must stop the ball from leaving the court on their side (Pong on Wikipedia). Our version: you play the left paddle with the keyboard against a simple computer player on the right; first to 5 points wins.
Here is a typical stateful implementation sketch. Read it the way you'd read a colleague's code that you've been asked to convert. Don't run it; it isn't in the repository.
// The usual, stateful way (a sketch to convert; do NOT write this in the course)
let ball = { x: 40, y: 25 }, dx = 1, dy = 1
let player = 21, cpu = 21, frame = 0
let scorePlayer = 0, scoreCpu = 0, over = false
let upHeld = false, downHeld = false
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') upHeld = true
if (e.key === 'ArrowDown') downHeld = true
if (e.key === 'r') { /* reset everything... hopefully */ }
})
window.addEventListener('keyup', (e) => {
if (e.key === 'ArrowUp') upHeld = false
if (e.key === 'ArrowDown') downHeld = false
})
setInterval(() => {
if (over) return
frame++
if (downHeld) player++
if (upHeld) player--
player = Math.max(0, Math.min(42, player))
if (frame % 2 === 0) cpu += Math.sign(ball.y - 4 - cpu) // cpu follows the ball, at half speed
// ... move the ball, bounce on walls and paddles, score, serve with Math.random() ...
ctx.fillRect(/* paddles, ball, score ... */)
}, 30)
Count the hidden things before reading on: how many mutable variables? Where is time? Where is randomness? Where are effects mixed into logic? (You'll answer these in step 1 below.)
The rules, precisely
The tests are shared fixtures, so the rules must be exact. Here they are in prose. The court is measured in cells: W = 80 wide, H = 50 high; each cell is PCELL = 8 pixels. Paddles are PADDLE = 8 cells tall. The player's paddle is in column PLAYER_X = 2, the computer's in column CPU_X = 77. A paddle's position is the y of its top cell. WIN = 5 points end the match.
A new match (initialPong(seed)): ball at (40, 25) moving dx = 1, dy = 1; both paddles at y = 21; no keys held; score 0–0; status Playing; the given seed; frame 0.
Keys. ArrowUp/w/W and ArrowDown/s/S are held down and released; while held, the paddle keeps moving. r/R pressed restarts. Everything else is ignored.
Each frame (every 30 ms), if the match is being played, in this order:
- The frame counter goes up by one.
- The player's paddle moves one cell down if "down" is held and one cell up if "up" is held (both held: they cancel out), and is kept within
[0, H - PADDLE]. - The computer's paddle moves only when the new frame number is even. It moves one cell toward
ball.y - PADDLE / 2(it aims to centre itself on the ball) and stays put if it's already there; also kept within[0, H - PADDLE]. It looks at the ball before the ball moves this frame. - The ball moves vertically:
ny = y + dy. Ifny < 0orny >= H, it bounces off the wall:dyflips andny = y + dy(with the newdy). - The ball moves horizontally:
nx = x + dx. It bounces off a paddle —dxflips andnx = x(the ball stays in its column this frame) — whennx == PLAYER_X, the ball is moving left (dx < 0), andplayer <= ny < player + PADDLE(the player's new position, after step 2); ornx == CPU_X, the ball is moving right (dx > 0), andcpu <= ny < cpu + PADDLE.
- Scoring: if
nx < 0, the computer scores; ifnx >= W, the player scores. A point serves a new ball: position(40, 25),dxtoward the side that conceded (computer scored →-1, player scored →+1), anddy = 1ifnextUint32(seed)is even,-1otherwise — and the seed advances to the onenextUint32returned. - End: when a score reaches
WIN, the status becomesOver.
When the match is Over, frames change nothing. Key presses and releases are still recorded (so that a key held across a restart behaves sensibly). Restart gives initialPong(state.seed) at any time.
The picture (pongView), drawn in order, all text '#ffffff' and 'center'-aligned:
- background
Rect(0, 0, 640, 400, '#111111'); - player paddle
Rect(PLAYER_X*8, player*8, 8, 64, '#ffffff'), then the cpu paddle the same way atCPU_X; - ball
Rect(x*8, y*8, 8, 8, '#ffeb3b'); - score
Text(320, 30, '<player> <cpu>', …, 24, …)— three spaces between the numbers; - when
Over:Text(320, 200, 'You Win!' or 'CPU Wins', …, 32, …)('You Win!'if the player has more points) andText(320, 230, 'Press R to restart', …, 16, …).
Note that, unlike the Snake view, there's no 1-pixel inset: paddles and ball fill their cells.
Applying the method
Open method.md next to this page. Each step below names the step of the method, asks you the questions you should answer before looking at the course's choice, then gives the canonical choice (fixed, because the fixtures depend on it). As in Module 2, write your own answer first, then compare. Where your design differs, ask whether yours is better, worse, or only different — and why.
Step 1 — Identify the state; make it one value
Go through the stateful sketch and list every let. Then ask:
- Which of them change during a frame? Which change only between frames because of keys?
- Is
framereally state, or could it be computed? (Could the cpu's "every second frame" rule work without it?) - Where is the randomness hiding? What must be in the state so that serving is a pure function?
- Is anything in the sketch derived (computable from other state) and therefore not needed in the state?
Canonical choice:
JavaScript (js/src/pong/model.js)
// PongState = {
// ball: {x, y}, vel: {dx, dy}, player, cpu,
// held: {up, down}, score: {player, cpu},
// status: 'Playing' | 'Over', seed, frame
// }
Scala (scala/src/pong/Model.scala, given)
final case class Velocity(dx: Int, dy: Int)
final case class Held(up: Boolean, down: Boolean)
final case class Score(player: Int, cpu: Int)
enum PongStatus:
case Playing, Over
final case class PongState(ball: Point, vel: Velocity, player: Int, cpu: Int, held: Held,
score: Score, status: PongStatus, seed: Long, frame: Int)
F# (fsharp/src/Pong/Model.fs, given)
type Velocity = { Dx: int; Dy: int }
type Held = { Up: bool; Down: bool }
type Score = { Player: int; Cpu: int }
[<RequireQualifiedAccess>]
type PongStatus =
| Playing
| Over
type PongState =
{ Ball: Point
Vel: Velocity
Player: int
Cpu: int
Held: Held
Score: Score
Status: PongStatus
Seed: uint32
Frame: int }
// The court, in cells: W = 80, H = 50, PCELL = 8, PADDLE = 8, PLAYER_X = 2, CPU_X = 77, WIN = 5
F# note:
[<RequireQualifiedAccess>]means you must writePongStatus.Playing, never plainPlaying. Snake'sStatusalso has aPlayingcase (and, in step 2, Snake'sActionhasFrameandDirectionhasUp/Down). Without the attribute, whichever type was opened last would silently win, andPlayingin a Pong file could mean Snake's status. Qualified access makes every use unambiguous.
The most interesting field is held. In Snake, a key press was an instantaneous event: pressing ArrowUp meant "turn up" once. In Pong, what matters is whether a key is being held at the moment a frame happens. The browser doesn't give us "is held"; it gives us two events, keydown and keyup. So "held" is state that we derive from those events, and it must live in PongState — not in a variable in the runtime, and certainly not by asking the browser during update. That's the stateful sketch's upHeld/downHeld, turned into data.
Step 2 — Identify the events; make them data
What can happen to a match? List the events in the sketch's callbacks and timer. Then decide: what's the smallest set of actions that the update needs to know about?
Canonical choice: four actions.
JavaScript
// PongAction = {tag:'Press', key:'Up'|'Down'} | {tag:'Release', key:'Up'|'Down'} | {tag:'Frame'} | {tag:'Restart'}
// constructors: PongAction.Press(key), PongAction.Release(key), PongAction.Frame, PongAction.Restart
Scala (given)
enum Paddle:
case Up, Down
enum PongAction:
case Press(key: Paddle)
case Release(key: Paddle)
case Frame
case Restart
F# (given)
[<RequireQualifiedAccess>]
type Paddle =
| Up
| Down
[<RequireQualifiedAccess>]
type PongAction =
| Press of Paddle
| Release of Paddle
| Frame
| Restart
In F# you write these as Paddle.Up, PongAction.Press Paddle.Up, PongAction.Frame — qualified, for the reason given in step 1.
And the translation from browser keys is a pure function, now with a second argument because the same key means different things going down and going up:
pongKey(isDown, key):
ArrowUp / w / W → Press Up (isDown) | Release Up (not isDown)
ArrowDown / s / S → Press Down (isDown) | Release Down (not isDown)
r / R → Restart (only when isDown; releasing r does nothing)
anything else → nothing (null / None)
Question: why are Press and Release separate actions rather than one SetHeld(key, bool)? (There's no single right answer; think about what reads better in update and in fixtures.)
Step 3 — Write update as a fold
pongUpdate(state, action) is your Pong update. Press/Release set a field of held; Restart is one call; Frame is the seven-step pipeline from the rules.
The Frame case is the biggest function you'll write in the course. The way to keep it readable is the way you kept Snake readable: split it into small, pure, separately-understandable steps, each taking a state and returning a state, applied in the order of the rules. You'll want a clamp(lo, hi, x) helper (it's tested on its own).
Questions to answer before writing code:
- The cpu "looks at the ball before the ball moves", and the player paddle bounce uses "the player's new position". What does that tell you about the order of your steps?
- Where does the serve's randomness come from? Which Module 4 function do you call, and what must you do with the seed it returns?
- Steps 4–6 compute
ny, thennx, then maybe a score. Will you do the ball in one function or several? If several, how do they shareny?
Step 4 — Write the view as data
pongView(state) returns a Scene — the same Shapes as Snake. You already know how to do this; follow the picture spec exactly.
Step 5 — Effects as values
Which effects does Pong need? Drawing, and… is there anything else? The serve's randomness is pure (it's in the seed). The only impure randomness is the seed at the start, which randomSeed already describes. So: no new Op, no change to IO. That is worth pausing on. A whole second game with randomness, a clock, held keys and a computer opponent needs exactly the effects you already have.
Step 6 — Wire the streams
Like Snake's app:
pongActions:FrameeveryFRAME_MS_PONG = 30ms (frominterval), merged withpongKey(true, key)of every key press (keyDowns) andpongKey(false, key)of every key release (keyUps). That's three streams merged — in which order? (The testpongActions turns the clock into Frames and keys into Press, Release and Restarttells you what it expects; read it.)pongStates(seed, actions): start frominitialPong(seed), fold withpongUpdate, emit the initial state first.pongMain: ask for a seed; turn the stream of states into a stream of drawings ofpongView.
Step 7 — One runtime at the edge
Nothing to write. js/src/pong/main.js is one line: runApp(pongMain, browserPlatform(document.getElementById('game'), window)). In Scala, scala/web/Main.scala has a PongMain object; build-web.sh already compiles it to web/dist/pong.js. F# has the equivalent in fsharp/web/PongMain.fs: Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ()). Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant.
Exercises
About test names. The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs",
Noneinstead ofnull,()instead ofundefined,bind/chooseinstead offlatMap/filterMap,IO.map throwingF ioinstead ofmap(io, throwingF), and so on. Failing tests are reported by name when you run the module's tests; to read all titles, openscala/test/M11Pong.test.scalaorfsharp/tests/M11Pong.fs. Fixture-based tests have the same case names in every track.
Exercise 11.1 — model
JavaScript (js/src/pong/model.js; the constants W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN are given)
export const initialPong = (seed) => /* PongState */
export const PongAction = { Press: (key) => ..., Release: (key) => ..., Frame: ..., Restart: ... }
Scala (scala/src/pong/Model.scala)
object PongModel:
def initialPong(seed: Long): PongState = ???
F# (fsharp/src/Pong/Model.fs)
let initialPong (seed: uint32) : PongState = failwith "TODO m11"
Proved by: the court is 80 by 50 cells of 8 pixels, with 8-cell paddles, initialPong puts the ball in the middle, both paddles centred, and nobody ahead, PongAction builds Press and Release with a key, and has Frame and Restart as plain values (JS only; in Scala and F# the types are given, so there is nothing to test).
Exercise 11.2 — rules
JavaScript (js/src/pong/rules.js)
export const clamp = (lo, hi, x) => /* number */
export const pongUpdate = (state, action) => /* PongState */
Scala (scala/src/pong/Rules.scala)
object PongRules:
def clamp(lo: Int, hi: Int, x: Int): Int = ???
def pongUpdate(state: PongState, action: PongAction): PongState = ???
F# (fsharp/src/Pong/Rules.fs)
let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
let pongUpdate (state: PongState) (action: PongAction) : PongState = failwith "TODO m11"
Proved by: clamp keeps a number inside [lo, hi], pongUpdate does not modify the state it is given, an idle match stays on the court and ends once someone reaches five, and fixtures/m11-pong-update.json — 43 named cases. Read the case names: they're the specification broken into single facts (player-stops-at-the-top-edge, cpu-does-not-move-on-an-odd-frame, paddle-bounce-uses-the-player-position-after-it-moved, serve-direction-comes-from-the-seed-odd-value-serves-upward, cpu-reaching-five-ends-the-match, …). When one fails, open the fixture, find the case, and work it out by hand.
Strategy: don't write all seven steps and then run the tests. Write Press/Release/Restart and run; then step 1 and 2 of Frame and run (watch the paddle cases go green); then the cpu; then the ball. Let the case names guide you.
Exercise 11.3 — view
JavaScript (js/src/pong/view.js)
export const pongView = (state) => /* Scene */
Scala (scala/src/pong/View.scala)
object PongView:
def pongView(state: PongState): Scene = ???
F# (fsharp/src/Pong/View.fs)
let pongView (state: PongState) : Scene = failwith "TODO m11"
Proved by: fixtures/m11-pong-view.json (new-match, paddles-and-ball-move-with-the-state, player-wins, cpu-wins).
Exercise 11.4 — keys and wiring
JavaScript (js/src/pong/app.js)
export const FRAME_MS_PONG = 30
export const pongKey = (isDown, key) => /* PongAction or null */
export const pongActions = /* Stream<PongAction> */
export const pongStates = (seed, actions) => /* Stream<PongState> */
export const pongMain = /* IO<Stream<IO<undefined>>> */
Scala (scala/src/pong/App.scala)
object PongApp:
val FRAME_MS_PONG: Long = 30
def pongKey(isDown: Boolean, key: String): Option[PongAction] = ???
lazy val pongActions: Stream[PongAction] = ???
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] = ???
lazy val pongMain: IO[Stream[IO[Unit]]] = ???
F# (fsharp/src/Pong/App.fs)
let FRAME_MS_PONG = 30L
let pongKey (isDown: bool) (key: string) : PongAction option = failwith "TODO m11"
let pongActions : Stream<PongAction> = (* ... *)
let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState> = failwith "TODO m11"
let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
In F#, main and pongMain are functions of unit (pongMain ()), not values. F# evaluates a module's top-level values when the module is first used, so a value whose definition fails (like a stub) would break every test that touches the module; a function of unit is only evaluated when called. The value it returns is still the same pure description.
Proved by: fixtures/m11-pong-key.json (21 cases, including r-up-does-nothing and the constructor/toString traps from Module 9), the clock runs every 30 ms, pongActions turns the clock into Frames and keys into Press, Release and Restart, pongStates starts with the new match, then yields one state per action, pongMain asks for a seed, draws the new match first, and then the updated one.
Run and play:
- JS:
cd js && node --test "test/m11-*.test.js"; then, from the repository root,node scripts/serve.mjsand open http://localhost:8000/js/pong.html - Scala:
cd scala && ./test.sh M11; then./build-web.sh; serve from the repository root (node scripts/serve.mjs) and open http://localhost:8000/scala/pong.html - F#:
cd fsharp && ./test.sh M11; then./build-web.sh; serve from the repository root (node scripts/serve.mjs) and open http://localhost:8000/fsharp/pong.html
Finally run the whole suite of your track (./test.sh). The purity audit now scans your Pong files too.
Hints
The hints for this module are deliberately lighter. Before opening one, look at the equivalent piece of your Snake code; that's the real hint.
Exercise 11.2 — nudge
Write frame(state) as a composition of steps, each PongState → PongState: count, movePlayer, moveCpu, moveBall (which may itself call serve), checkOver. In JS, pipe from Module 1 (or a chain of calls) reads top to bottom in rule order; in Scala andThen; in F# |>. Which of these steps need the guard "only when Playing"? (Only the whole Frame.)
Exercise 11.2 — stronger hint
moveCpucomparescpuwithtarget = ball.y - PADDLE / 2: below target → +1, above → -1, equal → 0 (a "sign" function); clamp; and only on evenframe— which, aftercount, is the new frame.- In
moveBall, computedy/nyfirst, thendx/nxusing the already-movedplayer/cpufrom the state. Then: ifnx < 0ornx >= W, returnserve(...)with the right scorer; otherwise, the state with the new ball and velocity. serve:[v, seed'] = nextUint32(state.seed);dy = v % 2 === 0 ? 1 : -1. In Scala the seed is aLong, in F# auint32; the fixtures are the same in all tracks, so watch for sign problems.- Did your serve test fail on the score but pass on the ball? Check that you add the point and serve, in the same step.
Exercise 11.4 — nudge
Three sources, one merge chain: merge(merge(frames, presses), releases). presses is keyDowns through filterMap with pongKey(true, …); releases similarly. The rest is your Snake app with different names.
Check your understanding
- In your own words, apply the first two steps of the method to the stateful sketch: list its hidden state (including hidden time and randomness) and its events.
- Why is
heldinPongStateand not in the runtime? What would break in testing if the runtime tracked held keys and passed "is up held?" toupdate? - Predict: the player holds
ArrowUpand, while still holding it, presses and releasesArrowDown. What isheldafter each of the three events? Which way does the paddle move on frames in between? - The cpu moves on even frames and looks at the ball before it moves. What would change in the fixtures if it looked after the ball moved? Find the case that pins this down (hint: its name mentions "before").
- You reused
IO,Stream,runIO,runAppandnextUint32without changing a line. List what was Snake-specific in the course, and what was general infrastructure. What does that suggest about where effort should go in a functional codebase? - Pong's serve direction is random. Explain why
pongUpdateis still a pure function, and how a test can check a "random" serve exactly. - Imagine a two-player mode: W/S for the left paddle, arrows for the right. Which steps of the method change, and which files? Is there any change to the runtime?
- Compare your own design from steps 1–2 with the canonical one. Name one place where yours was different and argue for or against it.
In production
- The Elm Architecture and Elmish are this method frozen into a framework: model (step 1), messages (step 2), update (step 3), view (step 4), commands (step 5), subscriptions (step 6), and a runtime you don't write (step 7) (The Elm Architecture, Elmish).
- Redux applies steps 1–3 to web apps; its "Redux Style Guide" rules (state is serializable, reducers are pure, no side effects in reducers) are the pitfalls list of the method (Redux).
- Deterministic lockstep multiplayer games (many real-time strategy games) send only inputs between machines and run the same pure update on each one; they depend on exactly the property you just built — same seed and same inputs give the same game (Glenn Fiedler, "Deterministic Lockstep").
Further reading
- The method — the recipe you applied, with a worked example and a checklist.
- Pong (Wikipedia) — history and the original rules.
- The Elm Architecture — read it again now; every part should look familiar.
- Glenn Fiedler, "Deterministic Lockstep" — what pure, deterministic game updates make possible in networked games.
- John Hughes, "Why Functional Programming Matters" — reread the conclusion with the whole course behind you.