feat(scala): m11 pong capstone (model, rules, view, app) with tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:15:33 +01:00
co-authored by Claude Opus 5.5
parent c6ecb7c013
commit f4b671678e
10 changed files with 479 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
package snake
package pong
import PongModel.initialPong
/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */
object PongApp:
val FRAME_MS_PONG: Long = 30
private def paddleKey(key: String): Option[Paddle] = key match
case "ArrowUp" | "w" | "W" => Some(Paddle.Up)
case "ArrowDown" | "s" | "S" => Some(Paddle.Down)
case _ => None
def pongKey(isDown: Boolean, key: String): Option[PongAction] =
paddleKey(key) match
case Some(p) => Some(if isDown then PongAction.Press(p) else PongAction.Release(p))
case None => if isDown && (key == "r" || key == "R") then Some(PongAction.Restart) else None
lazy val pongActions: Stream[PongAction] =
Stream
.interval(FRAME_MS_PONG)
.map(_ => PongAction.Frame)
.merge(Stream.keyDowns.filterMap(pongKey(true, _)))
.merge(Stream.keyUps.filterMap(pongKey(false, _)))
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] =
actions.scan(initialPong(seed))(PongRules.pongUpdate).startWith(initialPong(seed))
lazy val pongMain: IO[Stream[IO[Unit]]] =
IO.randomSeed.map(seed => pongStates(seed, pongActions).map(state => IO.drawScene(PongView.pongView(state))))
+73
View File
@@ -0,0 +1,73 @@
package snake
package pong
// m11 pong model: the same ideas as the snake model, for a different game.
/** The ball's direction of travel, one cell per frame on each axis. */
final case class Velocity(dx: Int, dy: Int)
/** Which of the player's two keys are being held down right now. */
final case class Held(up: Boolean, down: Boolean)
final case class Score(player: Int, cpu: Int)
enum PongStatus:
case Playing, Over
/** The two keys that move the player's paddle. */
enum Paddle:
case Up, Down
/**
* A whole match. Positions are in cells: `ball` is a cell of the 80x50 court; `player` and `cpu` are the y of the
* top cell of each paddle. `seed` is the unsigned 32-bit random seed used to pick the direction of each serve.
*/
final case class PongState(
ball: Point,
vel: Velocity,
player: Int,
cpu: Int,
held: Held,
score: Score,
status: PongStatus,
seed: Long,
frame: Int
)
/** Everything that can happen in a match, as data. */
enum PongAction:
case Press(key: Paddle)
case Release(key: Paddle)
case Frame
case Restart
object PongModel:
// The court, in cells.
val W: Int = 80
val H: Int = 50
/** Pixels per cell. */
val PCELL: Int = 8
/** Paddle height in cells. */
val PADDLE: Int = 8
/** Column of the player's paddle (left) and of the cpu's paddle (right). */
val PLAYER_X: Int = 2
val CPU_X: Int = 77
/** Points needed to win the match. */
val WIN: Int = 5
def initialPong(seed: Long): PongState = PongState(
ball = Point(40, 25),
vel = Velocity(1, 1),
player = 21,
cpu = 21,
held = Held(up = false, down = false),
score = Score(player = 0, cpu = 0),
status = PongStatus.Playing,
seed = seed,
frame = 0
)
+71
View File
@@ -0,0 +1,71 @@
package snake
package pong
import PongModel.*
/** m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state. */
object PongRules:
def clamp(lo: Int, hi: Int, x: Int): Int = math.min(hi, math.max(lo, x))
private def clampPaddle(y: Int): Int = clamp(0, H - PADDLE, y)
private def movePlayer(state: PongState): Int =
clampPaddle(state.player + (if state.held.down then 1 else 0) - (if state.held.up then 1 else 0))
// The cpu chases the middle of its paddle toward the ball, one cell every other frame.
private def moveCpu(state: PongState, frame: Int): Int =
if frame % 2 == 0 then clampPaddle(state.cpu + (state.ball.y - PADDLE / 2 - state.cpu).sign)
else state.cpu
private def hits(paddle: Int, y: Int): Boolean = paddle <= y && y < paddle + PADDLE
private enum Side:
case Player, Cpu
// Where the ball goes next, given the paddles' new positions, and who scored, if anyone.
private def moveBall(state: PongState, player: Int, cpu: Int): (Point, Velocity, Option[Side]) =
val (ball, vel) = (state.ball, state.vel)
val wall = ball.y + vel.dy < 0 || ball.y + vel.dy >= H
val dy = if wall then -vel.dy else vel.dy
val ny = ball.y + dy
val nx = ball.x + vel.dx
val bounce =
(nx == PLAYER_X && vel.dx < 0 && hits(player, ny)) ||
(nx == CPU_X && vel.dx > 0 && hits(cpu, ny))
val scorer = if nx < 0 then Some(Side.Cpu) else if nx >= W then Some(Side.Player) else None
(Point(if bounce then ball.x else nx, ny), Velocity(if bounce then -vel.dx else vel.dx, dy), scorer)
// After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down.
private def serve(state: PongState, scorer: Side): PongState =
val (r, seed) = Random.nextUint32.run(state.seed)
val score = scorer match
case Side.Player => state.score.copy(player = state.score.player + 1)
case Side.Cpu => state.score.copy(cpu = state.score.cpu + 1)
state.copy(
ball = Point(40, 25),
vel = Velocity(if scorer == Side.Cpu then -1 else 1, if r % 2 == 0 then 1 else -1),
score = score,
seed = seed,
status = if math.max(score.player, score.cpu) >= WIN then PongStatus.Over else PongStatus.Playing
)
private def frame(state: PongState): PongState =
if state.status != PongStatus.Playing then state
else
val n = state.frame + 1
val player = movePlayer(state)
val cpu = moveCpu(state, n)
val (ball, vel, scorer) = moveBall(state, player, cpu)
val next = state.copy(ball = ball, vel = vel, player = player, cpu = cpu, frame = n)
scorer.fold(next)(serve(next, _))
private def setHeld(state: PongState, key: Paddle, value: Boolean): PongState = key match
case Paddle.Up => state.copy(held = state.held.copy(up = value))
case Paddle.Down => state.copy(held = state.held.copy(down = value))
def pongUpdate(state: PongState, action: PongAction): PongState = action match
case PongAction.Press(key) => setHeld(state, key, true)
case PongAction.Release(key) => setHeld(state, key, false)
case PongAction.Restart => initialPong(state.seed)
case PongAction.Frame => frame(state)
+29
View File
@@ -0,0 +1,29 @@
package snake
package pong
import PongModel.*
/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */
object PongView:
private def text(x: Int, y: Int, str: String, size: Int): Shape = Shape.Text(x, y, str, "#ffffff", size, "center")
private def paddle(column: Int, y: Int): Shape =
Shape.Rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, "#ffffff")
private def overlay(state: PongState): Scene = state.status match
case PongStatus.Over =>
List(
text(320, 200, if state.score.player > state.score.cpu then "You Win!" else "CPU Wins", 32),
text(320, 230, "Press R to restart", 16)
)
case PongStatus.Playing => Nil
def pongView(state: PongState): Scene =
List(
Shape.Rect(0, 0, 640, 400, "#111111"),
paddle(PLAYER_X, state.player),
paddle(CPU_X, state.cpu),
Shape.Rect(state.ball.x * PCELL, state.ball.y * PCELL, PCELL, PCELL, "#ffeb3b"),
text(320, 30, s"${state.score.player} ${state.score.cpu}", 24)
) ++ overlay(state)
+27
View File
@@ -0,0 +1,27 @@
package snake
package pong
/** m11 pong app: the same wiring as App.scala (streams in, drawings out), reusing the course's modules. */
object PongApp:
/** Milliseconds between frames of the match. */
val FRAME_MS_PONG: Long = 30
/**
* Translate a key event into a `PongAction`, if any. `isDown` is true for a key press, false for a release.
* "ArrowUp"/"w"/"W" -> `Press(Paddle.Up)` / `Release(Paddle.Up)`, "ArrowDown"/"s"/"S" -> `Press(Paddle.Down)` /
* `Release(Paddle.Down)`, "r"/"R" -> `Restart` (on press only; releasing r does nothing). Any other key -> `None`.
*/
def pongKey(isDown: Boolean, key: String): Option[PongAction] = ???
/**
* Everything that can happen in a match: `Frame` every FRAME_MS_PONG (from `Stream.interval`), merged with the
* `pongKey(true, key)` of every key press and the `pongKey(false, key)` of every key release.
*/
lazy val pongActions: Stream[PongAction] = ???
/** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first. */
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] = ???
/** The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings of `pongView`. */
lazy val pongMain: IO[Stream[IO[Unit]]] = ???
+67
View File
@@ -0,0 +1,67 @@
package snake
package pong
// m11 pong model: the same ideas as the snake model, for a different game.
/** The ball's direction of travel, one cell per frame on each axis. */
final case class Velocity(dx: Int, dy: Int)
/** Which of the player's two keys are being held down right now. */
final case class Held(up: Boolean, down: Boolean)
final case class Score(player: Int, cpu: Int)
enum PongStatus:
case Playing, Over
/** The two keys that move the player's paddle. */
enum Paddle:
case Up, Down
/**
* A whole match. Positions are in cells: `ball` is a cell of the 80x50 court; `player` and `cpu` are the y of the
* top cell of each paddle. `seed` is the unsigned 32-bit random seed used to pick the direction of each serve.
*/
final case class PongState(
ball: Point,
vel: Velocity,
player: Int,
cpu: Int,
held: Held,
score: Score,
status: PongStatus,
seed: Long,
frame: Int
)
/** Everything that can happen in a match, as data. */
enum PongAction:
case Press(key: Paddle)
case Release(key: Paddle)
case Frame
case Restart
object PongModel:
// The court, in cells.
val W: Int = 80
val H: Int = 50
/** Pixels per cell. */
val PCELL: Int = 8
/** Paddle height in cells. */
val PADDLE: Int = 8
/** Column of the player's paddle (left) and of the cpu's paddle (right). */
val PLAYER_X: Int = 2
val CPU_X: Int = 77
/** Points needed to win the match. */
val WIN: Int = 5
/**
* A fresh match: ball at (40, 25) moving `Velocity(1, 1)`, both paddles at y 21, no keys held,
* score 0 to 0, status Playing, the given seed, frame 0.
*/
def initialPong(seed: Long): PongState = ???
+32
View File
@@ -0,0 +1,32 @@
package snake
package pong
import PongModel.*
/** m11 pong rules: one pure `pongUpdate`. */
object PongRules:
/** Keep `x` inside `[lo, hi]`. */
def clamp(lo: Int, hi: Int, x: Int): Int = ???
/**
* The one function that says how a match changes: `(state, action) => state`.
*
* - `Press(key)` / `Release(key)`: set `held.up` or `held.down` to true / false (even when the match is over).
* - `Restart`: `initialPong(state.seed)`.
* - `Frame`: unchanged unless status is Playing. Otherwise, in this order:
* 1. `frame + 1`.
* 2. The player moves one cell down for `held.down` and one up for `held.up` (both cancel out),
* kept within `[0, H - PADDLE]`.
* 3. When the new `frame` is even, the cpu moves one cell toward `ball.y - PADDLE / 2` (it stays put when it is
* already there), kept within `[0, H - PADDLE]`. It looks at the ball before the ball moves.
* 4. The ball moves vertically: `ny = y + dy`; if `ny < 0 || ny >= H` then `dy` flips and `ny = y + dy`.
* 5. Horizontally: `nx = x + dx`. It bounces (`dx` flips, `nx = x`) when `nx == PLAYER_X`, `dx < 0` and
* `player <= ny < player + PADDLE` (using the player's new position), or when `nx == CPU_X`, `dx > 0` and
* `cpu <= ny < cpu + PADDLE`.
* 6. If `nx < 0` the cpu scores; if `nx >= W` the player scores. A point serves a new ball: position (40, 25),
* `dx` toward the side that conceded (cpu scored -> -1, player scored -> +1), `dy = 1` when the value of
* `Random.nextUint32.run(seed)` is even and `-1` otherwise, and the seed advances.
* 7. A score of `WIN` ends the match: status Over.
*/
def pongUpdate(state: PongState, action: PongAction): PongState = ???
+17
View File
@@ -0,0 +1,17 @@
package snake
package pong
import PongModel.*
/** m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it. */
object PongView:
/**
* Describe the match as shapes, drawn in order (all text is "#ffffff", align "center"):
* background `Rect(0, 0, 640, 400, "#111111")`; the player paddle `Rect(PLAYER_X*8, player*8, 8, 64, "#ffffff")`,
* then the cpu paddle the same way at CPU_X; the ball `Rect(x*8, y*8, 8, 8, "#ffeb3b")`;
* the score `Text(320, 30, "<player> <cpu>" (three spaces), size 24)`.
* When Over: `Text(320, 200, "You Win!" if the player has more points, else "CPU Wins", size 32)` and
* `Text(320, 230, "Press R to restart", size 16)`.
*/
def pongView(state: PongState): Scene = ???
+130
View File
@@ -0,0 +1,130 @@
package snake
package pong
import Fixtures.*
import PongModel.*
import PongRules.{pongUpdate, clamp}
import PongView.pongView
import PongApp.*
import M09Helpers.drawn
class M11PongSuite extends munit.FunSuite:
private def pongState(v: ujson.Value): PongState = PongState(
ball = point(v("ball")),
vel = Velocity(v("vel")("dx").num.toInt, v("vel")("dy").num.toInt),
player = v("player").num.toInt,
cpu = v("cpu").num.toInt,
held = Held(v("held")("up").bool, v("held")("down").bool),
score = Score(v("score")("player").num.toInt, v("score")("cpu").num.toInt),
status = PongStatus.valueOf(v("status").str),
seed = v("seed").num.toLong,
frame = v("frame").num.toInt
)
// `Paddle` only has Up and Down, so a JSON key like "constructor" decodes to None instead of crashing.
private def paddle(v: ujson.Value): Option[Paddle] = Paddle.values.find(_.toString == v.str)
/** Decode a PongAction; `None` when its key is not a `Paddle` (such an action cannot even be built in Scala). */
private def pongAction(v: ujson.Value): Option[PongAction] = v("tag").str match
case "Press" => paddle(v("key")).map(PongAction.Press(_))
case "Release" => paddle(v("key")).map(PongAction.Release(_))
case "Frame" => Some(PongAction.Frame)
case "Restart" => Some(PongAction.Restart)
case other => throw new IllegalArgumentException(s"unknown pong action tag: $other")
cases("m11-pong-update").foreach { c =>
test(s"pongUpdate: ${c("name").str}") {
val a = c("args")
val state = pongState(a(0))
pongAction(a(1)) match
case Some(action) => assertEquals(pongUpdate(state, action), pongState(c("expected")))
// An unknown key is ruled out by the types: the Paddle enum has no such case. What the
// fixture asks for (state unchanged) therefore holds by construction; check that it says so.
case None => assertEquals(pongState(c("expected")), state)
}
}
cases("m11-pong-view").foreach { c =>
test(s"pongView: ${c("name").str}") {
assertEquals(pongView(pongState(c("args")(0))), list(c("expected"))(shape))
}
}
cases("m11-pong-key").foreach { c =>
test(s"pongKey: ${c("name").str}") {
val a = c("args")
val expected = opt(c("expected"))(e => pongAction(e).getOrElse(fail(s"undecodable expected action: $e")))
assertEquals(pongKey(a(0).bool, a(1).str), expected)
}
}
private def time(t: Long): Event = Event.Time(t)
private def down(key: String): Event = Event.KeyDown(key)
private def up(key: String): Event = Event.KeyUp(key)
test("the court is 80 by 50 cells of 8 pixels, with 8-cell paddles") {
assertEquals(List(W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN), List(80, 50, 8, 8, 2, 77, 5))
}
test("initialPong puts the ball in the middle, both paddles centred, and nobody ahead") {
assertEquals(
initialPong(5),
PongState(Point(40, 25), Velocity(1, 1), 21, 21, Held(false, false), Score(0, 0), PongStatus.Playing, 5, 0)
)
}
test("clamp keeps a number inside [lo, hi]") {
assertEquals(List(clamp(0, 42, -1), clamp(0, 42, 17), clamp(0, 42, 43)), List(0, 17, 42))
}
test("pongUpdate does not modify the state it is given") {
val s = initialPong(0)
pongUpdate(s, PongAction.Frame)
assertEquals(s, initialPong(0))
}
// 3000 frames with no one touching the keys: the ball must always stay on the court and the match must end.
test("an idle match stays on the court and ends once someone reaches five") {
val states = List.fill(3000)(PongAction.Frame).scanLeft(initialPong(3))(pongUpdate)
assert(states.forall(s => s.ball.y >= 0 && s.ball.y < H && s.ball.x >= 0 && s.ball.x < W))
assert(states.forall(s => s.player >= 0 && s.player <= H - PADDLE && s.cpu >= 0 && s.cpu <= H - PADDLE))
val last = states.last
assertEquals(last.status, PongStatus.Over)
assertEquals(math.max(last.score.player, last.score.cpu), WIN)
}
test("the clock runs every 30 ms") { assertEquals(FRAME_MS_PONG, 30L) }
test("pongActions turns the clock into Frames and keys into Press, Release and Restart") {
assertEquals(
pongActions.run(List(time(30), down("ArrowUp"), up("ArrowUp"), down("s"), down("r"), up("r"), down("x"), time(90))),
List(
PongAction.Frame,
PongAction.Press(Paddle.Up),
PongAction.Release(Paddle.Up),
PongAction.Press(Paddle.Down),
PongAction.Restart,
PongAction.Frame,
PongAction.Frame
)
)
}
test("pongStates starts with the new match, then yields one state per action") {
val out = pongStates(42, pongActions).run(List(time(30), down("w"), time(60)))
assertEquals(out.length, 4)
assertEquals(out.head, initialPong(42))
assertEquals(out.map(_.frame), List(0, 1, 1, 2))
assertEquals(out.map(_.held.up), List(false, false, true, true))
assertEquals(out(3).player, 20)
}
test("pongMain asks for a seed, draws the new match first, and then the updated one") {
val traced = IO.trace(pongMain, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
val (first, next) = traced.result.step(time(0))
val (second, _) = next.step(time(30))
assertEquals(drawn(first.head), pongView(initialPong(42)))
assertEquals(drawn(second.head), pongView(pongUpdate(initialPong(42), PongAction.Frame)))
}
+1 -1
View File
@@ -10,7 +10,7 @@ repo="$(cd "$(dirname "$0")/.." && pwd)"
export PATH="$HOME/.dotnet:$HOME/.local/bin:$PATH"
# Module -> files (relative to src/ and solutions/) that the module introduces. A directory entry copies it whole.
# Later tasks: extend the entries for m07+ in the Scala and F# tables.
# Later tasks: extend the F# table.
files_for() {
case "$track:$1" in
js:01) echo "util.js todo.js" ;;