Files
functional-programming-snake/course/06-rendering-as-data.md
T
2026-10-29 01:00:23 +01:00

21 KiB
Raw Blame History

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:

// 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 alignment ('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 Scene is a List[Shape] where Shape is a sum type with two cases. You would never think of a List[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'):

  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):

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 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) 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 Msg is 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

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 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

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

  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, 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). 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