22 KiB
Module 6 — Rendering as data
Time: ~2 h · You'll edit: JS
js/src/view.js· Scalascala/src/View.scala· F#fsharp/src/View.fsTests: JScd js && node --test "test/m06-*.test.js"· Scalacd scala && ./test.sh M06· F#cd fsharp && ./test.sh M06Prerequisites: 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:
// 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 → Sceneexactly 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
viewfunction 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, sizew, h, acolor; - a text: position
x, y, thetextitself, acolor, a fontsize, and analignment ('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. 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 Actions 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
// 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
// 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#
// 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 Scala: a
Sceneis aList[Shape]whereShapeis a sum type with two cases. You would never think of aList[Shape]as "doing" anything; it just sits there. That is the whole point. In JS you get the same thing with an array of tagged objects; nothing stops you from mutating it, 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
export const CELL = 20 // pixels per board cell
Scala
val CELL: Int = 20 // View.CELL
F#
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'):
- Background:
Rect(0, 0, width*CELL, height*CELL, '#111111'). - Apple, only if there is one:
Rect(ax*CELL+1, ay*CELL+1, CELL-2, CELL-2, '#e53935'). - 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. - Score:
Text(6, 18, 'Score: <n>', '#ffffff', 16, 'left'), e.g.'Score: 3'. - Overlay, depending on
status, centred atcx = width*CELL/2,cy = height*CELL/2, all'#ffffff'and'center'-aligned:Paused:Text(cx, cy, 'Paused', …, 32, …)thenText(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):
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", 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
Scenebecomes anIOvalue — still a description, now of the act of drawing (drawScene). - Module 9: a tiny runtime interprets that
IO, calling the canvas'sfillRectandfillText.
The canvas API itself (MDN: 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).
- In Elm, every program has a function
view : Model -> Html Msg.Html Msgis a value, not a side effect. The Elm runtime does the drawing (The Elm Architecture). F# has the same design in 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
m06prefix (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 pixelsis identical, and the fixture-driven cases appear asview: <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, openscala/test/M06View.test.scalaorfsharp/tests/M06View.fs.
Exercise 6.1 — view
Implement view so that it returns the scene described in The specification of the scene.
JavaScript (js/src/view.js)
export const CELL = 20
export const view = (state) => /* Scene: an array of {tag:'Rect',...} / {tag:'Text',...} */
Scala (scala/src/View.scala)
object View:
val CELL: Int = 20
def view(state: GameState): Scene = ???
F# (fsharp/src/View.fs)
// 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.
deepStrictEqualon arrays (and==on Scala/F# lists) compares position by position. - The apple is absent as
nullin JS andNonein Scala/F#. Don't emit a rectangle at(0,0)or atnull*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 ScalazipWithIndex; 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 fordeepStrictEqual, but the key names must be exactlyx, y, text, color, size, align. - Integer division:
width*CELL/2is a whole number for even widths. In Scala and F#/onIntis 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
Exercise 6.1 — nudge
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?
Exercise 6.1 — stronger hint
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.
Exercise 6.1 — near-solution (try the others first)
JavaScript
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
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#
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
Check your understanding
viewtakes a state and returns a value. Name two things that were hidden inputs or hidden outputs of the statefuldraw(ctx, state)and say where each went in the new design.- Predict: what is the first shape of
view(s)for a 10 × 6 board? What is the last shape ifs.statusisGameOver? - 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?
- 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?
- 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? - Is
viewtotal — does it return a scene for every possibleGameState? Can you find a state for which it returns something surprising (think about a snake with zero segments, or an apple outside the board)? - 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, Preserving and resetting state explains how the tree of descriptions is compared).
- Elm forces this design:
view : Model -> Html Msgis the only way to show anything, and the runtime owns the screen (The Elm Architecture). Elmish brings the same model to F#, usually with React as the renderer (Elmish). - Snapshot tests in tools like Jest store a serialised description of what a component renders and fail when it changes (Jest: 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 — 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 — how a mainstream UI library separates describing from doing.
- MDN: Canvas API — the stateful API our shapes are designed to map onto.
- Painter's algorithm (Wikipedia) — why the order of a scene matters.
- John Hughes, "Why Functional Programming Matters" — the "glue" argument: producing data and consuming it separately is what makes programs modular.