Files
functional-programming-snake/course/11-capstone-pong.md
T
2026-10-29 01:00:23 +01:00

23 KiB
Raw Blame History

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 · Scala scala/src/pong/Model.scala, Rules.scala, View.scala, App.scala · F# fsharp/src/Pong/Model.fs, Rules.fs, View.fs, App.fs Tests: JS cd js && node --test "test/m11-*.test.js" · Scala cd scala && ./test.sh M11 · F# cd fsharp && ./test.sh M11 Prerequisites: 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:

  1. The frame counter goes up by one.
  2. 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].
  3. 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.
  4. The ball moves vertically: ny = y + dy. If ny < 0 or ny >= H, it bounces off the wall: dy flips and ny = y + dy (with the new dy).
  5. The ball moves horizontally: nx = x + dx. It bounces off a paddle — dx flips and nx = x (the ball stays in its column this frame) — when
    • nx == PLAYER_X, the ball is moving left (dx < 0), and player <= ny < player + PADDLE (the player's new position, after step 2); or
    • nx == CPU_X, the ball is moving right (dx > 0), and cpu <= ny < cpu + PADDLE.
  6. Scoring: if nx < 0, the computer scores; if nx >= W, the player scores. A point serves a new ball: position (40, 25), dx toward the side that conceded (computer scored → -1, player scored → +1), and dy = 1 if nextUint32(seed) is even, -1 otherwise — and the seed advances to the one nextUint32 returned.
  7. End: when a score reaches WIN, the status becomes Over.

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 at CPU_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) and Text(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 frame really 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 }
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 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)

type Paddle = PaddleUp | PaddleDown      (* check the names in your Model.fs *)
type PongAction =
    | Press of Paddle
    | Release of Paddle
    | Frame
    | Restart

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, then nx, then maybe a score. Will you do the ball in one function or several? If several, how do they share ny?

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: Frame every FRAME_MS_PONG = 30 ms (from interval), merged with pongKey(true, key) of every key press (keyDowns) and pongKey(false, key) of every key release (keyUps). That's three streams merged — in which order? (The test pongActions turns the clock into Frames and keys into Press, Release and Restart tells you what it expects; read it.)
  • pongStates(seed, actions): start from initialPong(seed), fold with pongUpdate, emit the initial state first.
  • pongMain: ask for a seed; turn the stream of states into a stream of drawings of pongView.

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/. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant.

Exercises

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.

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>>> = (* ... *)

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:

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
  • moveCpu compares cpu with target = ball.y - PADDLE / 2: below target → +1, above → -1, equal → 0 (a "sign" function); clamp; and only on even frame — which, after count, is the new frame.
  • In moveBall, compute dy/ny first, then dx/nx using the already-moved player/cpu from the state. Then: if nx < 0 or nx >= W, return serve(...) 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 a Long, in F# a uint32; 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

  1. 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.
  2. Why is held in PongState and not in the runtime? What would break in testing if the runtime tracked held keys and passed "is up held?" to update?
  3. Predict: the player holds ArrowUp and, while still holding it, presses and releases ArrowDown. What is held after each of the three events? Which way does the paddle move on frames in between?
  4. 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").
  5. You reused IO, Stream, runIO, runApp and nextUint32 without 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?
  6. Pong's serve direction is random. Explain why pongUpdate is still a pure function, and how a test can check a "random" serve exactly.
  7. 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?
  8. 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