Files
functional-programming-snake/course/06-rendering-as-data.md
T

349 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Module 6 — Rendering as data
> **Time:** ~2 h · **You'll edit:** JS `js/src/view.js` · Scala `scala/src/View.scala` · F# `fsharp/src/View.fs`
> **Tests:** JS `cd js && node --test "test/m06-*.test.js"` · Scala `cd scala && ./test.sh M06` · F# `cd fsharp && ./test.sh M06`
> **Prerequisites:** Modules 0–5 (you have a complete, pure game: `update`, `replay`, `newGame`)
## Why this module
At the end of Module 5 you had a whole game of Snake that nobody can see. `replay(newGame(42), actions)` gives you a final `GameState`, and the tests tell you that it is the right one, but there is no picture yet.
The usual way to draw a game looks like this:
```js
// The usual, stateful way (do NOT write this in the course)
function draw(ctx, state) {
ctx.fillStyle = '#111111'
ctx.fillRect(0, 0, 400, 400)
for (const p of state.snake) {
ctx.fillStyle = '#43a047'
ctx.fillRect(p.x * 20 + 1, p.y * 20 + 1, 18, 18)
}
// ... apple, score, overlays
}
```
Look at what this function needs and what it gives back. It needs a canvas context `ctx` — a live object connected to a screen. It gives back nothing (`undefined`); its whole purpose is the changes it makes to `ctx`. Now ask yourself: **how would you test it?** You could open a browser, look at it, and say "looks right". You could build a fake `ctx` that records the calls. You could take a screenshot and compare pixels. All three are awkward, and all three exist only because `draw` *does* something instead of *returning* something.
This module makes a small change with big consequences. Instead of drawing, your `view` function will **return a description of the drawing**: a list of shapes, as plain data. Checking that the picture is right becomes the same thing you have done in every module so far: compare a value with an expected value.
```
Module 6 Modules 7 and 9
GameState ───────────▶ Scene (data) ───────────▶ pixels on a canvas
view (pure) an interpreter (the edge)
```
## Learning objectives
By the end of this module you can:
- **explain** why a function that returns a description of a picture is easier to test and reuse than a function that draws;
- **implement** `view : GameState → Scene` exactly to a specification, including the drawing order;
- **convert** between board coordinates (cells) and screen coordinates (pixels);
- **compare** this design with the virtual DOM used by React and the `view` function of the Elm Architecture;
- **predict** the scene for a small state by hand, before running the tests.
## Concepts
### A picture is a list of shapes
What is the smallest vocabulary you need to draw Snake? Look at the game: a dark background, coloured squares for the snake and the apple, a line of text for the score, and sometimes a big message in the middle ("Paused", "Game Over"). Two kinds of shape are enough:
- a **rectangle**: position `x, y`, size `w, h`, a `color`;
- a **text**: position `x, y`, the `text` itself, a `color`, a font `size`, and an `align`ment (`'left'` or `'center'`, meaning "x is the left edge" or "x is the middle of the text").
A **scene** is an ordered list of shapes. The order matters because later shapes are painted over earlier ones — this is called the [painter's algorithm](https://en.wikipedia.org/wiki/Painter%27s_algorithm). The background must come first, otherwise it would cover everything else.
Here is how the three tracks spell a shape. In JS a shape is a plain object with a `tag`, exactly like the `Action`s of Module 2. In Scala and F# it is a tagged union that you already have in the stub file — you will not have to write these types, only use them.
**JavaScript**
```js
// Plain objects; no type declaration is needed.
const background = { tag: 'Rect', x: 0, y: 0, w: 400, h: 400, color: '#111111' }
const label = { tag: 'Text', x: 6, y: 18, text: 'hello', color: '#ffffff', size: 16, align: 'left' }
const scene = [background, label] // a Scene is an array of shapes, drawn in order
```
**Scala**
```scala
// Already in scala/src/View.scala
enum Shape:
case Rect(x: Int, y: Int, w: Int, h: Int, color: String)
case Text(x: Int, y: Int, text: String, color: String, size: Int, align: String)
type Scene = List[Shape]
val scene: Scene = List(Shape.Rect(0, 0, 400, 400, "#111111"), Shape.Text(6, 18, "hello", "#ffffff", 16, "left"))
```
**F#**
```fsharp
// Already in fsharp/src/View.fs
type Shape =
| Rect of x: int * y: int * w: int * h: int * color: string
| Text of x: int * y: int * text: string * color: string * size: int * align: string
type Scene = Shape list
let scene : Scene = [ Rect (0, 0, 400, 400, "#111111"); Text (6, 18, "hello", "#ffffff", 16, "left") ]
```
> **From everyday programming:** think of a shopping list, or the list of rows a SQL query returns. You would never think of such a list as "doing" anything; it just sits there until someone reads it. A `Scene` is the same kind of thing: a list of shapes, each of which is one of exactly two kinds (a rectangle or a text). That is the whole point. In the Scala and F# tracks the compiler keeps the list and its shapes immutable; in JS you build it from arrays of tagged objects, and nothing stops you from mutating them, which is why the tests freeze the inputs and why you keep using the habits from Module 1.
Notice what is *not* in a shape: no canvas, no context, no "current fill colour". In the stateful version, `ctx.fillStyle = color` sets a hidden variable that the next `fillRect` reads. In a shape, the colour travels *with* the rectangle. Every shape is complete on its own. That is the same move you made with `GameState` in Module 2: instead of state that lives somewhere and changes, a value that contains everything.
### From cells to pixels
The game lives on a grid of 20 × 20 **cells**; the screen is measured in **pixels**. One constant connects them:
**JavaScript**
```js
export const CELL = 20 // pixels per board cell
```
**Scala**
```scala
val CELL: Int = 20 // View.CELL
```
**F#**
```fsharp
let CELL = 20 // View.CELL
```
A cell at `(x, y)` covers the pixels from `x*CELL` to `x*CELL + CELL - 1` horizontally (and the same vertically). If you filled the whole cell, the snake would be one solid green blob and you could not tell its segments apart. So every square is drawn **1 pixel inside** its cell on each side: it starts at `x*CELL + 1` and is `CELL - 2` pixels wide.
```
pixel: 20 21 38 39 40
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───┬─────────────────────────┬───┐
│gap│ 18 px of colour │gap│ ← cell x = 1 (pixels 20..39)
└───┴─────────────────────────┴───┘
x*CELL + 1 = 21 width CELL - 2 = 18
```
The background is the full board: `width * CELL` by `height * CELL` pixels — 400 × 400 for the standard board. Take that from the state rather than hard-coding 400: the fixtures include other board sizes on purpose.
### The specification of the scene
Your `view` must produce exactly this list, in this order (all colours are strings like `'#111111'`):
1. **Background:** `Rect(0, 0, width*CELL, height*CELL, '#111111')`.
2. **Apple**, only if there is one: `Rect(ax*CELL+1, ay*CELL+1, CELL-2, CELL-2, '#e53935')`.
3. **Snake**, one square per segment, in list order (head first): same position rule and size as the apple; colour `'#7cb342'` for the head (index 0) and `'#43a047'` for every other segment.
4. **Score:** `Text(6, 18, 'Score: <n>', '#ffffff', 16, 'left')`, e.g. `'Score: 3'`.
5. **Overlay**, depending on `status`, centred at `cx = width*CELL/2`, `cy = height*CELL/2`, all `'#ffffff'` and `'center'`-aligned:
- `Paused`: `Text(cx, cy, 'Paused', …, 32, …)` then `Text(cx, cy+30, 'Press Space to resume', …, 16, …)`;
- `GameOver`: `'Game Over'` (size 32) then `'Press R to restart'` (size 16, 30 px lower);
- `Won`: `'You Win!'` (size 32) then `'Press R to restart'` (size 16, 30 px lower);
- `Playing`: no overlay.
Why should the specification be this exact? Because the scene is now a *value*, and values can be compared for equality. A vague specification ("draw the snake in green") cannot be tested by equality; a precise one can. In the stateful world you would accept "looks right"; here you can demand "is right".
Predict before you go on: for `initialState(0)` (snake `[(10,10),(9,10),(8,10)]`, no apple, score 0, Playing), how many shapes does `view` return, and what are the `x` coordinates of the three snake squares? (Answer at the end of this section.)
### A pure view is testable by equality
Here is the test for the whole of this module, in JS (the Scala and F# suites do the same with their own fixture loaders):
```js
const fx = loadFixture('m06-view')
fx.cases.forEach((c) =>
test(`m06 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(view(...freeze(c.args)), c.expected)))
```
There is no canvas, no browser, no mock, no screenshot. The fixture `fixtures/m06-view.json` holds a few states and, for each, the exact list of shapes expected. Open it now and read the case `paused-overlay`: you can check by eye that it matches the specification above. That file is shared by all three tracks, so all three `view` functions must agree to the pixel.
Compare the two worlds side by side:
| | `draw(ctx, state)` (stateful) | `view(state)` (pure) |
|---|---|---|
| Returns | nothing | a `Scene` |
| Needs to run | a canvas | nothing |
| Test | fake `ctx`, screenshots, eyes | `deepEqual(view(s), expected)` |
| Call it twice | draws twice | same value twice |
| Reuse | only on a canvas | canvas, SVG, terminal, a test, a replay viewer… |
That last row deserves a pause. Since a `Scene` is data, *anything* can consume it. In Module 7 you will turn a scene into an `IO` program; in Module 9 a runtime turns that into canvas calls. But you could equally write a function that turns a scene into SVG text, or into ASCII art for a terminal (see the optional exercise). The view does not know or care. This is the separation that John Hughes, in ["Why Functional Programming Matters"](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf), calls *gluing*: one part produces data, another consumes it, and you can swap either side.
### The interpreter is somebody else's job
Something must eventually turn shapes into pixels. That something is an **interpreter**: a function that walks a description and performs it. You will meet this word a lot from now on. The plan for the next modules:
- Module 7: a `Scene` becomes an `IO` value — still a description, now of *the act of drawing* (`drawScene`).
- Module 9: a tiny runtime interprets that `IO`, calling the canvas's `fillRect` and `fillText`.
The canvas API itself ([MDN: Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API)) is a classic stateful API: you set `fillStyle`, `font`, `textAlign`, and then call `fillRect`/`fillText`, which read those settings. Our `Shape` was designed so that one shape maps to one such call, with all its settings attached. Look at the `fillRect(x, y, w, h)` and `fillText(text, x, y)` methods on MDN and convince yourself that every argument they need is in our shapes.
### The same idea in React and Elm: the virtual DOM
You have just reinvented one of the central ideas of modern web user interfaces.
- In **React**, a component's render function does not touch the page. It returns *elements* — plain JavaScript objects that describe what should be on screen. React then compares the new description with the previous one and changes only what differs in the real page (the "DOM"). That description is called the **virtual DOM**. React's docs describe this as "render and commit": rendering computes the description, committing applies it ([React: Render and Commit](https://react.dev/learn/render-and-commit)).
- In **Elm**, every program has a function `view : Model -> Html Msg`. `Html Msg` is a value, not a side effect. The Elm runtime does the drawing ([The Elm Architecture](https://guide.elm-lang.org/architecture/)). F# has the same design in [Elmish](https://elmish.github.io/elmish/).
Our `view : GameState → Scene` is exactly this, for a canvas instead of HTML. And because the description is data, the interpreter is free to be clever: React's diffing is only possible because the description is a value that can be compared with the previous one. Our interpreter will be simple — redraw everything every frame — but it *could* compare scenes and skip unchanged frames, without `view` ever knowing.
*(Prediction answer: 5 shapes — background, three snake squares, the score text; no apple, no overlay. The squares' `x` values are `10*20+1 = 201`, `181`, `161`.)*
## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones, which carry an `m06` prefix (e.g. `m06 CELL is 20 pixels`). The Scala and F# suites test the same things one-to-one, without that prefix: `CELL is 20 pixels` is identical, and the fixture-driven cases appear as `view: <case name>` with the same case names in every track. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M06View.test.scala` or `fsharp/tests/M06View.fs`.
### Exercise 6.1 — `view`
Implement `view` so that it returns the scene described in [The specification of the scene](#the-specification-of-the-scene).
**JavaScript** (`js/src/view.js`)
```js
export const CELL = 20
export const view = (state) => /* Scene: an array of {tag:'Rect',...} / {tag:'Text',...} */
```
**Scala** (`scala/src/View.scala`)
```scala
object View:
val CELL: Int = 20
def view(state: GameState): Scene = ???
```
**F#** (`fsharp/src/View.fs`)
```fsharp
// module Snake.View
let CELL = 20
let view (state: GameState) : Scene = failwith "TODO m06"
```
**Proved by:** `fixtures/m06-view.json` (cases `playing-with-apple`, `playing-without-apple-has-no-apple-rect`, `paused-overlay`, `game-over-overlay-on-20x20-board`, `won-overlay`) and a test that `CELL` is 20.
**Watch out for:**
- The **order** of the list is part of the answer. `deepStrictEqual` on arrays (and `==` on Scala/F# lists) compares position by position.
- The apple is absent as `null` in JS and `None` in Scala/F#. Don't emit a rectangle at `(0,0)` or at `null*20`.
- The head is the segment at **index 0**. You need the index while mapping — in JS `map`'s callback receives it as a second argument; in Scala `zipWithIndex`; in F# `List.mapi`.
- The text arguments come in the order `x, y, text, color, size, align`. In JS the order of object keys does not matter for `deepStrictEqual`, but the key *names* must be exactly `x, y, text, color, size, align`.
- Integer division: `width*CELL/2` is a whole number for even widths. In Scala and F# `/` on `Int` is already integer division; in JS it is not, but the fixture boards make the result whole anyway.
- Don't build the list with `push` (the purity audit will reject it in JS, and it is not the style of the course). Build it with spreads/concatenation of smaller lists.
**Run:**
- JS: `cd js && node --test "test/m06-*.test.js"`
- Scala: `cd scala && ./test.sh M06`
- F#: `cd fsharp && ./test.sh M06`
### Exercise 6.2 (optional, untested) — a second interpreter
To feel what "the scene is data" buys you, write — in a scratch file outside `src/`, or in a REPL — a function `sceneToAscii(scene, width, height)` that turns a scene into a string of `width × height` characters: `#` for snake squares, `@` for the apple, `.` elsewhere (you can recognise squares by their colour and recover the cell as `(x - 1) / CELL`). Print `sceneToAscii(view(newGame(42)), 20, 20)`.
You did not change `view` at all, and yet you have a new way of displaying the game. Ask yourself what you would have had to do to get this from the stateful `draw(ctx, state)`.
## Hints
<details><summary>Exercise 6.1 — nudge</summary>
Split the problem the same way the specification does: five small pieces (background, apple, snake, score, overlay), each of which is a *list* of zero or more shapes. Then the scene is those lists concatenated in order. A missing apple is then an empty list, and `Playing` gives an empty overlay list. Can you write each piece as its own small function or constant?
</details>
<details><summary>Exercise 6.1 — stronger hint</summary>
Write one helper that turns a board cell and a colour into the inset square: it takes a point and a colour and returns `Rect(p.x*CELL+1, p.y*CELL+1, CELL-2, CELL-2, color)`. Use it for the apple and for every snake segment.
For the overlay, write a helper that takes the big message and the small message and returns the two centred texts. Then the overlay is a `switch`/`match` on `status` that picks which messages to pass, with `Playing` returning an empty list.
</details>
<details><summary>Exercise 6.1 — near-solution (try the others first)</summary>
**JavaScript**
```js
const square = (p, color) => ({ tag: 'Rect', x: /* ? */, y: /* ? */, w: CELL - 2, h: CELL - 2, color })
const text = (x, y, s, size, align) => ({ tag: 'Text', x, y, text: s, color: '#ffffff', size, align })
const overlay = (state) => {
const cx = /* ? */, cy = /* ? */
const two = (big, small) => [text(cx, cy, big, 32, 'center'), text(/* ? */)]
switch (state.status) {
case 'Paused': return two('Paused', 'Press Space to resume')
/* GameOver, Won ... */
default: return []
}
}
export const view = (state) => [
{ tag: 'Rect', x: 0, y: 0, w: /* ? */, h: /* ? */, color: '#111111' },
...(state.apple === null ? [] : [/* ? */]),
...state.snake.map((p, i) => square(p, /* head or body colour? */)),
text(6, 18, /* ? */, 16, 'left'),
...overlay(state),
]
```
**Scala**
```scala
private def square(p: Point, color: String): Shape = Shape.Rect(/* ? */, /* ? */, CELL - 2, CELL - 2, color)
def view(state: GameState): Scene =
val apple = state.apple.toList.map(/* ? */)
val snake = state.snake.zipWithIndex.map { case (p, i) => /* ? */ }
val overlay: List[Shape] = state.status match
case Status.Paused => /* two texts */
/* ... */
case Status.Playing => Nil
List(Shape.Rect(0, 0, /* ? */, /* ? */, "#111111")) ++ apple ++ snake ++ List(/* score */) ++ overlay
```
**F#**
```fsharp
let private square (p: Point) color = Rect (p.X * CELL + 1, (* ? *), CELL - 2, CELL - 2, color)
let view (state: GameState) : Scene =
let apple = state.Apple |> Option.map (fun a -> (* ? *)) |> Option.toList
let snake = state.Snake |> List.mapi (fun i p -> (* ? *))
let overlay =
match state.Status with
| Paused -> (* two texts *)
| (* ... *)
| Playing -> []
[ Rect (0, 0, (* ? *), (* ? *), "#111111") ] @ apple @ snake @ [ (* score *) ] @ overlay
```
</details>
## Check your understanding
1. `view` takes a state and returns a value. Name two things that were *hidden inputs* or *hidden outputs* of the stateful `draw(ctx, state)` and say where each went in the new design.
2. Predict: what is the first shape of `view(s)` for a 10 × 6 board? What is the last shape if `s.status` is `GameOver`?
3. Suppose you swapped steps 1 and 3 of the specification (snake first, then background). Would the tests notice? Would a player notice? Which one is the more important observation?
4. Why does the specification put the colour inside every shape, instead of having a "set colour" shape followed by rectangles? What would go wrong in a test, or in a second interpreter (like the ASCII one), with a "set colour" shape?
5. React re-renders the whole description and then *diffs* it against the previous one. Our runtime will redraw every shape every frame. Which part of our code would have to change to add diffing — `view`, or the interpreter? Why is that the right answer?
6. Is `view` total — does it return a scene for *every* possible `GameState`? Can you find a state for which it returns something surprising (think about a snake with zero segments, or an apple outside the board)?
7. Explain to a classmate, in two sentences, why "returning a description" makes rendering testable without a screen.
## In production
- **React** components return a description (elements) instead of touching the page; React's reconciler compares descriptions and applies the minimal changes ([React: Render and Commit](https://react.dev/learn/render-and-commit), [Preserving and resetting state](https://react.dev/learn/preserving-and-resetting-state) explains how the tree of descriptions is compared).
- **Elm** forces this design: `view : Model -> Html Msg` is the only way to show anything, and the runtime owns the screen ([The Elm Architecture](https://guide.elm-lang.org/architecture/)). **Elmish** brings the same model to F#, usually with React as the renderer ([Elmish](https://elmish.github.io/elmish/)).
- **Snapshot tests** in tools like Jest store a serialised description of what a component renders and fail when it changes ([Jest: Snapshot Testing](https://jestjs.io/docs/snapshot-testing)) — this is only possible because the render output is data.
- **Game engines** often build a *display list* or *command buffer* each frame — a list of draw commands — that a separate renderer submits to the GPU. Same separation, much more performance pressure.
## Further reading
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — the shortest description of model / update / view; you now have the first and the last, and you will recognise all three by Module 9.
- [React: Render and Commit](https://react.dev/learn/render-and-commit) — how a mainstream UI library separates describing from doing.
- [MDN: Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) — the stateful API our shapes are designed to map onto.
- [Painter's algorithm (Wikipedia)](https://en.wikipedia.org/wiki/Painter%27s_algorithm) — why the order of a scene matters.
- [John Hughes, "Why Functional Programming Matters"](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf) — the "glue" argument: producing data and consuming it separately is what makes programs modular.