feat(scala): m09 input/app/runtime and m10 extensions with tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:13:52 +01:00
co-authored by Claude Opus 5.5
parent bd0c9fcc0c
commit c6ecb7c013
13 changed files with 448 additions and 6 deletions
+25
View File
@@ -0,0 +1,25 @@
package snake
/** m09 app: the whole game wired together as values: streams of actions in, a stream of drawings out. */
object App:
val TICK_MS: Long = 150
val FRAME_MS: Long = 25
// Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key.
lazy val classicActions: Stream[Action] =
Stream.interval(TICK_MS).map(_ => Action.Tick).merge(Stream.keyDowns.filterMap(Input.keyToAction))
// m10: a Frame every FRAME_MS instead of a Tick; the game itself decides (movesEvery) which frames take a step.
lazy val speedActions: Stream[Action] =
Stream.interval(FRAME_MS).map(_ => Action.Frame).merge(Stream.keyDowns.filterMap(Input.keyToAction))
// Actions folded into game states. startWith puts the initial state first so it is drawn right away.
def states(seed: Long, actions: Stream[Action]): Stream[GameState] =
actions.scan(Spawn.newGame(seed))(Game.update).startWith(Spawn.newGame(seed))
def render(state: GameState): IO[Unit] = IO.drawScene(View.view(state))
// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings.
// Nothing runs here; Runtime interprets it.
lazy val main: IO[Stream[IO[Unit]]] = IO.randomSeed.map(seed => states(seed, speedActions).map(render))
+26 -4
View File
@@ -1,12 +1,34 @@
package snake
/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */
/**
* m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions.
* m10 adds pause, restart and a frame clock that speeds up as the score grows.
*/
object Game:
/** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. */
def movesEvery(score: Int): Int = math.max(2, 8 - score / 3)
// One animation frame: count it, and every `movesEvery(score)`-th frame run a game step.
private def frame(state: GameState): GameState =
if state.status != Status.Playing then state
else
val counted = state.copy(frame = state.frame + 1)
if counted.frame % movesEvery(counted.score) == 0 then Spawn.tick(counted) else counted
// Pause and resume only make sense in those two statuses; a finished game stays finished.
private def togglePause(state: GameState): GameState = state.status match
case Status.Playing => state.copy(status = Status.Paused)
case Status.Paused => state.copy(status = Status.Playing)
case Status.GameOver | Status.Won => state
// Every case is listed (no `case _`): adding an Action later makes the compiler point here.
def update(state: GameState, action: Action): GameState = action match
case Action.Tick => Spawn.tick(state)
case Action.Turn(dir) => Rules.turn(state, dir)
case _ => state // TogglePause, Restart and Frame arrive in m10
case Action.Tick => Spawn.tick(state)
case Action.Turn(dir) => Rules.turn(state, dir)
case Action.TogglePause => togglePause(state)
case Action.Restart => Spawn.newGame(state.seed)
case Action.Frame => frame(state)
def replay(state: GameState, actions: List[Action]): GameState = actions.foldLeft(state)(update)
+14
View File
@@ -0,0 +1,14 @@
package snake
/** m09 input: from keyboard keys to game actions. */
object Input:
// A pattern match on string literals is the lookup table; `|` lists the alternatives.
def keyToAction(key: String): Option[Action] = key match
case "ArrowUp" | "w" | "W" => Some(Action.Turn(Direction.Up))
case "ArrowDown" | "s" | "S" => Some(Action.Turn(Direction.Down))
case "ArrowLeft" | "a" | "A" => Some(Action.Turn(Direction.Left))
case "ArrowRight" | "d" | "D" => Some(Action.Turn(Direction.Right))
case " " => Some(Action.TogglePause) // m10
case "r" | "R" => Some(Action.Restart) // m10
case _ => None
+42
View File
@@ -0,0 +1,42 @@
package snake
/** The outside world, as the runtime sees it. */
trait Platform:
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit
def randomSeed(): Long
def startClock(onTime: Long => Unit): Unit
def onKeyDown(cb: String => Unit): Unit
def onKeyUp(cb: String => Unit): Unit
/**
* m09 runtime: the single impure place in the course (with the web/ glue); everything else only builds values.
* Here we interpret IO descriptions against a Platform and hold the one mutable variable, the current stream.
*/
object Runtime:
// Perform one op on the platform and return its answer.
private def perform(op: Op, p: Platform): OpResult = op match
case Op.RandomSeed => OpResult.Seed(p.randomSeed())
case Op.Draw(Shape.Rect(x, y, w, h, color)) =>
p.fillRect(x, y, w, h, color)
OpResult.Done
case Op.Draw(Shape.Text(x, y, text, color, size, align)) =>
p.fillText(x, y, text, color, size, align)
OpResult.Done
// Tail recursive, so it compiles to a loop: a scene is hundreds of ops and the stack must not grow with them.
@annotation.tailrec
def runIO[A](io: IO[A], p: Platform): A = io match
case IO.Pure(a) => a
case IO.Effect(op, k) => runIO(k(perform(op, p)), p)
def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit =
var stream = runIO(main, p)
def feed(event: Event): Unit =
val (ios, next) = stream.step(event)
stream = next
ios.foreach(runIO(_, p))
p.onKeyDown(key => feed(Event.KeyDown(key)))
p.onKeyUp(key => feed(Event.KeyUp(key)))
p.startClock(t => feed(Event.Time(t)))
+37
View File
@@ -0,0 +1,37 @@
package snake
/** m09 app: the whole game wired together as values: streams of actions in, a stream of drawings out. */
object App:
/** Milliseconds between game steps. */
val TICK_MS: Long = 150
/** Milliseconds between animation frames (m10). */
val FRAME_MS: Long = 25
/**
* Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `Stream.interval`),
* merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`).
*/
lazy val classicActions: Stream[Action] = ???
/**
* (m10) Like `classicActions`, but the clock emits `Action.Frame` every FRAME_MS instead of `Tick` every TICK_MS;
* key presses are translated by `Input.keyToAction` as before (now including pause and restart).
*/
lazy val speedActions: Stream[Action] = ???
/**
* Fold a stream of actions into a stream of game states: start from `Spawn.newGame(seed)`, apply `Game.update` to
* each action (scan), and emit the initial state first (startWith) so there is something to draw before anything happens.
*/
def states(seed: Long, actions: Stream[Action]): Stream[GameState] = ???
/** The drawing of one state, as an IO: `IO.drawScene(View.view(state))`. */
def render(state: GameState): IO[Unit] = ???
/**
* The whole program as a value: ask for a random seed, then turn the stream of states (built from `classicActions`
* in m09, from `speedActions` after m10) into a stream of drawings (`render`). Nothing runs here.
*/
lazy val main: IO[Stream[IO[Unit]]] = ???
+14 -2
View File
@@ -3,11 +3,23 @@ package snake
/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */
object Game:
/**
* How many frames pass between game steps (m10): `max(2, 8 - score / 3)` (integer division).
* Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
*/
def movesEvery(score: Int): Int = ???
/**
* The one function that says how the game changes: `(state, action) => state`.
*
* `Tick` runs a game step (`Spawn.tick`), `Turn` asks to change direction (`Rules.turn`);
* every other action returns the state unchanged (m10 gives them meaning).
* m05: `Tick` runs a game step (`Spawn.tick`), `Turn` asks to change direction (`Rules.turn`);
* every other action returns the state unchanged.
*
* (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
* (m10) `Restart` gives `Spawn.newGame(state.seed)`, whatever the status.
* (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `frame`, and when the new `frame` is a multiple of
* `movesEvery(score)` it also runs a game step (`Spawn.tick`) on that incremented state.
* In m10, match every case of `Action` explicitly (no `case _`), so the compiler tells you if one is forgotten.
*/
def update(state: GameState, action: Action): GameState = ???
+12
View File
@@ -0,0 +1,12 @@
package snake
/** m09 input: from keyboard keys to game actions. */
object Input:
/**
* Translate a keyboard key name (as in the browser's `KeyboardEvent.key`) into the action it triggers, if any.
* m09: "ArrowUp"/"w"/"W" -> `Action.Turn(Direction.Up)`, "ArrowDown"/"s"/"S" -> Down, "ArrowLeft"/"a"/"A" -> Left,
* "ArrowRight"/"d"/"D" -> Right. Any other key -> `None`.
* (m10) " " (Space) -> `Action.TogglePause`, "r"/"R" -> `Action.Restart`.
*/
def keyToAction(key: String): Option[Action] = ???
+37
View File
@@ -0,0 +1,37 @@
package snake
/**
* The outside world, as the runtime sees it. The browser implements it with a canvas (web/BrowserPlatform.scala);
* tests implement it with a fake that records calls.
*/
trait Platform:
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit
/** A random unsigned 32-bit seed. */
def randomSeed(): Long
/** Call `onTime(t)` again and again, `t` = milliseconds since the clock started. */
def startClock(onTime: Long => Unit): Unit
def onKeyDown(cb: String => Unit): Unit
def onKeyUp(cb: String => Unit): Unit
/**
* m09 runtime: the single impure place in the course (with the web/ glue); everything else only builds values.
* Here a `var` is allowed (exactly one). Keep it tiny.
*/
object Runtime:
/**
* Interpret an IO against a platform and return its final value.
* `Draw` of a Rect -> `p.fillRect(x, y, w, h, color)` and of a Text -> `p.fillText(x, y, text, color, size, align)`,
* both answered with `OpResult.Done`; `RandomSeed` -> `OpResult.Seed(p.randomSeed())`. Each answer is passed to `k`.
* Must not grow the stack with the number of ops: a loop, or a `@annotation.tailrec` function (which the compiler
* turns into a loop).
*/
def runIO[A](io: IO[A], p: Platform): A = ???
/**
* Run the program: `runIO(main, p)` gives the stream; keep the current stream in the one `var`.
* Register `p.onKeyDown` / `p.onKeyUp` / `p.startClock` so that each event steps the stream with the matching
* `Event` (`KeyDown(key)`, `KeyUp(key)`, `Time(t)`), stores the next stream, and runs every IO the step emitted, in order.
*/
def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit = ???
+56
View File
@@ -0,0 +1,56 @@
package snake
import M09Helpers.{classicMain, drawn}
class M09AppSuite extends munit.FunSuite:
private def time(t: Long): Event = Event.Time(t)
private def down(key: String): Event = Event.KeyDown(key)
test("the game ticks every 150 ms") { assertEquals(App.TICK_MS, 150L) }
test("classicActions turns the clock into Tick actions and keys into Turn actions") {
assertEquals(
App.classicActions.run(List(time(150), down("ArrowUp"), down("x"), time(300))),
List(Action.Tick, Action.Turn(Direction.Up), Action.Tick)
)
}
test("states starts with the new game, then yields one state per action") {
val out = App.states(42, App.classicActions).run(List(time(150), down("ArrowUp"), time(300)))
assertEquals(out.length, 4)
assertEquals(out.head, Spawn.newGame(42))
assertEquals(out.map(_.snake.head), List(Point(10, 10), Point(11, 10), Point(11, 10), Point(11, 9)))
assertEquals(out.map(_.nextDir), List(Direction.Right, Direction.Right, Direction.Up, Direction.Up))
assertEquals(out.map(_.dir), List(Direction.Right, Direction.Right, Direction.Right, Direction.Up))
}
test("render draws the whole view of the state") {
assertEquals(IO.trace(App.render(Spawn.newGame(42)), 0).ops, View.view(Spawn.newGame(42)).map(Op.Draw(_)))
}
test("main asks for a random seed, then builds the stream from it") {
val traced = IO.trace(App.main, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
val (outs, _) = traced.result.step(time(0))
assertEquals(outs.length, 1)
assertEquals(drawn(outs.head), View.view(Spawn.newGame(42)))
}
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
test("the program built from classicActions draws the new game first, then the ticked game after 150 ms") {
val stream = IO.trace(classicMain, 42).result
val (first, next) = stream.step(time(0))
val (second, _) = next.step(time(150))
assertEquals(drawn(first.head), View.view(Spawn.newGame(42)))
assertEquals(second.length, 1)
assertNotEquals(drawn(second.head), drawn(first.head))
}
// Holds before and after m10: whatever `main` ticks with, the clock must be wired in, so time passing produces more drawings.
test("main draws again once time has passed") {
val stream = IO.trace(App.main, 42).result
val (first, next) = stream.step(time(0))
val (later, _) = next.step(time(150))
assertEquals(first.length, 1)
assert(later.nonEmpty)
}
+11
View File
@@ -0,0 +1,11 @@
package snake
/** Test helpers shared by the m09 suites. */
object M09Helpers:
/** The m09 program built from classicActions (ticks every 150 ms). The shipped `main` switches to speedActions in m10. */
lazy val classicMain: IO[Stream[IO[Unit]]] =
IO.randomSeed.map(seed => App.states(seed, App.classicActions).map(App.render))
/** The shapes an IO draws, in order (interpreted by `IO.trace`). */
def drawn(io: IO[Unit]): List[Shape] = IO.trace(io, 0).ops.collect { case Op.Draw(shape) => shape }
+10
View File
@@ -0,0 +1,10 @@
package snake
import Fixtures.*
class M09InputSuite extends munit.FunSuite:
cases("m09-input").foreach { c =>
test(s"keyToAction: ${c("name").str}") {
assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action))
}
}
+112
View File
@@ -0,0 +1,112 @@
package snake
import scala.collection.mutable.ListBuffer
/** A fake platform: records every call and lets the test fire events by hand. */
class FakePlatform(seed: Long = 42) extends Platform:
val calls: ListBuffer[List[Any]] = ListBuffer.empty
private var onTime: Long => Unit = _ => ()
private var onDown: String => Unit = _ => ()
private var onUp: String => Unit = _ => ()
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit = calls += List("fillRect", x, y, w, h, color)
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit =
calls += List("fillText", x, y, text, color, size, align)
def randomSeed(): Long =
calls += List("randomSeed")
seed
def startClock(cb: Long => Unit): Unit = onTime = cb
def onKeyDown(cb: String => Unit): Unit = onDown = cb
def onKeyUp(cb: String => Unit): Unit = onUp = cb
def fireTime(t: Long): Unit = onTime(t)
def fireKey(key: String): Unit = onDown(key)
def fireKeyUp(key: String): Unit = onUp(key)
class M09RuntimeSuite extends munit.FunSuite:
// The m09 program (classicMain) ticks every 150 ms; the shipped `main` uses speedActions after m10.
private def main = M09Helpers.classicMain
// What the platform should have been asked to draw for a scene.
private def expectedCalls(scene: Scene): List[List[Any]] = scene.map {
case Shape.Rect(x, y, w, h, color) => List("fillRect", x, y, w, h, color)
case Shape.Text(x, y, text, color, size, align) => List("fillText", x, y, text, color, size, align)
}
private val rect = Shape.Rect(1, 2, 3, 4, "#abcdef")
private val text = Shape.Text(5, 6, "hi", "#fff", 16, "center")
test("runIO of a pure value returns it without touching the platform") {
val p = FakePlatform()
assertEquals(Runtime.runIO(IO.pure(7), p), 7)
assertEquals(p.calls.toList, Nil)
}
test("runIO maps Draw Rect to fillRect(x, y, w, h, color)") {
val p = FakePlatform()
Runtime.runIO(IO.draw(rect), p)
assertEquals(p.calls.toList, List(List("fillRect", 1, 2, 3, 4, "#abcdef")))
}
test("runIO maps Draw Text to fillText(x, y, text, color, size, align)") {
val p = FakePlatform()
Runtime.runIO(IO.draw(text), p)
assertEquals(p.calls.toList, List(List("fillText", 5, 6, "hi", "#fff", 16, "center")))
}
test("runIO answers RandomSeed with platform.randomSeed() and continues with it") {
val p = FakePlatform(99)
assertEquals(Runtime.runIO(IO.randomSeed.map(_ + 1), p), 100L)
assertEquals(p.calls.toList, List(List("randomSeed")))
}
test("runIO performs effects in order") {
val p = FakePlatform()
Runtime.runIO(IO.sequence(List(IO.draw(rect), IO.draw(text), IO.draw(rect))), p)
assertEquals(p.calls.toList.map(_.head), List("fillRect", "fillText", "fillRect"))
}
test("runIO handles 100000 effects without overflowing the stack") {
val p = FakePlatform()
val n = 100000
Runtime.runIO(IO.sequence(List.fill(n)(IO.draw(rect))), p)
assertEquals(p.calls.length, n)
}
test("runApp asks the platform for a seed once, at start, and draws nothing before the first event") {
val p = FakePlatform()
Runtime.runApp(main, p)
assertEquals(p.calls.toList, List(List("randomSeed")))
}
test("runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms") {
val p = FakePlatform(42)
Runtime.runApp(main, p)
p.calls.clear()
p.fireTime(150)
assertEquals(
p.calls.toList,
expectedCalls(View.view(Spawn.newGame(42))) ++ expectedCalls(View.view(Spawn.tick(Spawn.newGame(42))))
)
}
test("runApp feeds key presses to the game") {
val p = FakePlatform(42)
Runtime.runApp(main, p)
p.fireTime(0)
p.calls.clear()
p.fireKey("ArrowUp")
p.fireTime(150)
val turned = Spawn.newGame(42).copy(nextDir = Direction.Up)
assertEquals(p.calls.toList, expectedCalls(View.view(turned)) ++ expectedCalls(View.view(Spawn.tick(turned))))
}
test("runApp ignores key releases and unmapped keys (no redraw)") {
val p = FakePlatform(42)
Runtime.runApp(main, p)
p.fireTime(0)
p.calls.clear()
p.fireKeyUp("ArrowUp")
p.fireKey("x")
assertEquals(p.calls.toList, Nil)
}
+52
View File
@@ -0,0 +1,52 @@
package snake
import Fixtures.*
import M09Helpers.drawn
class M10ExtensionsSuite extends munit.FunSuite:
cases("m10-update").foreach { c =>
test(s"update: ${c("name").str}") {
val a = c("args")
assertEquals(Game.update(gameState(a(0)), action(a(1))), gameState(c("expected")))
}
}
cases("m10-moves-every").foreach { c =>
test(s"movesEvery: ${c("name").str}") {
assertEquals(Game.movesEvery(c("args")(0).num.toInt), c("expected").num.toInt)
}
}
cases("m10-input").foreach { c =>
test(s"keyToAction: ${c("name").str}") {
assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action))
}
}
private def time(t: Long): Event = Event.Time(t)
private val frameTimes = List(25L, 50L, 75L, 100L, 125L, 150L, 175L, 200L)
test("the animation clock runs every 25 ms") { assertEquals(App.FRAME_MS, 25L) }
test("speedActions turns the clock into Frame actions every 25 ms") {
assertEquals(App.speedActions.run(frameTimes.map(time)), List.fill(8)(Action.Frame))
}
test("speedActions still turns keys into actions, including pause and restart") {
assertEquals(
App.speedActions.run(List(Event.KeyDown(" "), Event.KeyDown("r"), Event.KeyDown("ArrowUp"))),
List(Action.TogglePause, Action.Restart, Action.Turn(Direction.Up))
)
}
test("main (seed 42) draws a moved snake after eight 25 ms frames") {
val traced = IO.trace(App.main, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
val (first, afterFirst) = traced.result.step(time(0))
assertEquals(drawn(first.head), View.view(Spawn.newGame(42)))
val drawings = afterFirst.run(frameTimes.map(time))
assertEquals(drawings.length, 8)
val start = Spawn.newGame(42)
val moved = start.copy(snake = start.snake.map(p => p.copy(x = p.x + 1)), frame = 8)
assertEquals(drawn(drawings(7)), View.view(moved))
}