Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Frame Is a Value

view does not draw. It returns a description of one frame, and the runtime draws that. The description is a plain list:

view : Model -> View Msg
view model =
    canvas Pixelated [ onResize Resized, onTap Tapped ]
        (List.concat
            [ [ rect 0 0 model.w model.h (rgb 11 16 38) ]   -- sky, deepest
            , List.map drawFoe model.foes                    -- world
            , hud model                                      -- HUD, on top
            ])

Order is depth. First in the list is painted first, so it sits at the back; there is no z-index, no layer system, no sorting API. You compose screens with List.concat and you reorder depth by reordering lists. This chapter is the complete catalog of what can go in that list.

The shapes

Five constructors cover every example game in the repository:

rect     x y w h color                 -- axis-aligned rectangle
circle   cx cy r color                 -- filled circle
triangle x1 y1 x2 y2 x3 y3 color       -- any filled triangle
text     x y size align color string   -- align: Canvas.Center | Canvas.Left | Canvas.Right
group    transforms shapes             -- a sub-list, transformed as one

All coordinates and sizes are Int, in logical pixels (more on that below). Colors come from two constructors:

rgb  246 196 83        -- opaque
rgba 246 196 83 45     -- alpha 0..100

That is the entire vocabulary. No sprites, no paths, no gradients. It sounds austere and it is; chapter 8 is about how much art these five shapes turn out to contain. What matters mechanically is that every shape is a value: drawFoe is a pure function from a foe to a list of shapes, your ship is a List Shape you can define once and stamp anywhere, and a frame is data you could inspect in a test.

Groups: transform once, move everything

group applies a list of transforms to a whole sub-list:

group [ Canvas.Translate wx wy, Canvas.Rotate angle ]
    [ rect (0 - 12) (0 - 4) 24 8 hull
    , triangle 12 (0 - 6) 22 0 12 6 nose
    ]

The idiom: draw at the origin, place with the group. The ship above is defined around (0, 0); the Translate puts it in the world and the Rotate (degrees, integer) turns hull and nose together. Nested groups compose, which is how a turret rotates on a tank that rotates on the map.

The available transforms, all constructors of Canvas’s own Transform type (qualified names, like all builtin constructors):

  • Canvas.Translate dx dy moves the sub-list.
  • Canvas.Rotate degrees rotates around the group origin.
  • Canvas.Scale percent scales around the origin; 100 is natural size. The racer draws its whole road at logical size and scales sprites by distance with this.
  • Canvas.Alpha percent fades the group as one surface (see below).
  • Canvas.Blend mode changes how the group’s pixels combine with what is beneath: Canvas.Add (light: glows, lasers, fire), Canvas.Multiply (shadow and tint), Canvas.Screen, Canvas.Erase, Canvas.Normal.

The camera is a Translate

There is no camera API because one transform is the whole feature:

world : Model -> List Shape
world model =
    [ group [ Canvas.Translate (0 - model.camX) (0 - model.camY) ]
        (List.concat [ tiles model, actors model ])
    ]

Everything inside the group is authored in world coordinates; the group shifts them into view. The HUD is simply drawn outside that group, in screen coordinates. Scrolling, screen-locked UI, parallax (several groups translated by different fractions of camX), and split-screen (two groups, two cameras) all fall out of this one idea.

One caution that has bitten a real game: a scaled or translated group is not clipped to any stage rectangle. If your world draws beyond the intended viewport (spawning enemies just off-stage, wrapping parallax bands), those shapes render happily outside it. On a canvas that exactly fits the stage nothing shows; on a letterboxed phone screen everything shows. Chapter 15 gives the standard fix (opaque mattes drawn after the world).

Transparency: rgba versus Alpha

Two tools, one classic bug. rgba sets alpha per shape: each shape blends with whatever is under it, including its neighbors in the same visual object. Give both circles of a smoke puff rgba ... 45 and their overlap paints twice: a darker seam, an accidental x-ray.

Canvas.Alpha fades a group as one composited surface: the group is rendered fully opaque off-screen, then blended once at the given opacity. Overlaps inside the group do not double-paint.

Rule of thumb: rgba for glass-like shapes that should show what is beneath them individually; Canvas.Alpha to fade a multi-shape object or a whole layer (the shmup fades its far parallax starfields with exactly Canvas.Alpha 62 and 84).

Pixelated, and why everything is chunky

The first argument to canvas picks the scaling flavor:

  • Pixelated: hard-edged pixels when scaled up. The retro look; almost every example game uses it.
  • Crisp: smooth scaling, for games that want clean vector-like edges.

Behind both sits a deliberate runtime decision: the canvas renders at 1x logical resolution, not at the device’s retina scale. On a modern phone, rendering at full device resolution means pushing 9 times the pixels, and it is the difference between a game that runs and a “slow-motion” game on an iPhone; measured on real devices, 1x plus pixel-art upscaling was the version that held 60 fps. So: design in logical pixels, embrace the chunk. It is not a limitation you work around; it is the aesthetic the whole art chapter builds on. Keep text at size 10 or larger and it stays perfectly readable.

Reflow or fixed stage

Two honest ways to handle screen size, both used by shipped games:

  • Reflow (Star Catch, Touch-me-When): subscribe to onResize, keep w/h in the Model, compute every position from them. Best for UI-like games and one-screen arcade games.
  • Fixed stage (the shmup: 320×240): design on fixed coordinates, then scale and center the whole world group to fit the real canvas, with letterbox mattes. Best when the game’s balance depends on exact geometry. Chapter 15 builds this rig completely.

Pick one per game. Mixing them (some positions from w, some hard-coded) is where layout bugs live.

What the runtime does with your list

For the curious: on the web the list is drawn to an HTML canvas; on iOS, through SwiftUI’s Canvas. Same list, same order, same colors, pixel-for-pixel-identical intent, verified by golden-image tests per blend mode. You never think about it again, which is the point.

The frame is a value. Next: the numbers inside it.