Art from Rectangles
Mar cannot load an image. No PNG sprites, no tilesheets, no texture atlas. Your entire visual budget is rectangles, circles, triangles, and text, in flat colors.
Before you close the book: the games that made you love 2D games were drawn under budgets like this one. Early arcade and console art is rectangles with discipline; its beauty came from constraint, palette, and motion, not resolution. This chapter is a working method for getting real charm out of the five primitives, distilled from a soccer game, two RPGs, a racer, a shmup, and an FPS that were all drawn this way. The ethic throughout: be inspired by the classics, never trace them; your palette and shapes should be your own.
Palette first
Pick 8 to 16 colors before drawing anything, and give them names:
ink : Color
ink = rgb 24 20 37
sky : Color
sky = rgb 11 16 38
gold : Color
gold = rgb 246 196 83
blood : Color
blood = rgb 217 88 59
Named top-level colors keep the whole game consistent, make “night mode = swap the palette function” possible, and make tuning visual mood a five-minute job. A small palette is also what makes primitive art read as style rather than programmer art: three greens for foliage looks intentional; fifteen arbitrary greens looks like debugging.
(One language note: top-level values are evaluated in file order, so a palette constant may only refer to names defined above it. Keep the palette near the top of the file and this never bites; the full trap is in Appendix B.)
Sprites are functions
A “sprite” is a function from a position (and a frame) to shapes, drawn around a local origin and placed with a group:
hero : Int -> List Shape
hero frame =
let
bob = if modI frame 2 == 0 then 0 else 1 -- 2-frame walk bounce
in
[ rect (0 - 4) (0 - 14 + bob) 8 6 skin -- head
, rect (0 - 5) (0 - 8 + bob) 10 8 tunic -- body
, rect (0 - 5) 0 4 4 boot -- feet
, rect 1 0 4 4 boot
]
drawHero : Model -> List Shape
drawHero model =
[ group [ Canvas.Translate (model.hx // 1000) (model.hy // 1000) ]
(hero (model.tick // 8))
]
Compose characters from few, chunky parts: head, body, feet is enough for a readable little person. Work on a mental pixel grid (everything a multiple of 1 or 2 logical pixels) and the result looks drawn rather than assembled.
Three specific tricks carry most of the load:
- Outlines by layering. There is no stroke, so a bigger dark shape behind a smaller bright one makes the classic outlined sprite:
circle x y (r + 1) inkthencircle x y r gold. The party-duel example draws all its pieces this way. - Silhouette test. If a character were all one color, could you still tell what it is and which way it faces? Fix shape before adding detail; detail cannot rescue silhouette.
- Face direction with asymmetry. A 2-pixel nose rectangle, a shifted eye, a weapon on one side. Flipping art is just negating the offsets in your sprite function (pass
dirin and multiply x-offsets by it).
Animation is arithmetic
With sprites as functions, animation is choosing arguments:
- Cycles:
modI (model.tick // 8) 2is a two-frame walk at 7.5 fps, the classic cadence.// 8is the animation speed dial. - Squash and stretch: wrap the sprite in
group [ Canvas.Scale 112 ]for 2 ticks on landing,Canvas.Scale 92at a jump’s apex. Two keyframes of scale read as weight. - Blinking (invulnerability, pickups about to expire): alternate the sprite with nothing:
if modI model.invulnT 8 < 4 then hero f else []. The shmup flickers its ship with a group alpha the same way. - Wobble: a 1-pixel
Translatealternating each few ticks makes anything nervous, alive, or about to explode.
Text is a sprite too
text with the Pixelated canvas already looks pleasantly retro, and it is the right tool for HUDs, dialogue, and menus. For big score counters and title logos where you want drawn letters, the soccer example goes further and builds a tiny pixel font: each glyph a list of row-strings, rendered as one rect per on-pixel:
glyph3x5 : String -> List String
glyph3x5 ch =
case ch of
"0" -> [ "###", "#.#", "#.#", "#.#", "###" ]
"1" -> [ ".#.", "##.", ".#.", ".#.", "###" ]
_ -> [ "...", "...", "...", "...", "..." ]
A dozen glyphs cover a scoreboard; the renderer is ten lines of nested mapping (String.toList gives the columns). It sounds absurd and takes twenty minutes, and afterward your numbers match your world perfectly at any scale.
Scenes: bands, parallax, and light
Backgrounds are horizontal bands (the cover of this book is six rects of sky and sea), and depth is layered motion, not detail:
- Parallax: two or three groups of scenery translated by fractions of the camera (
camX // 2,camX // 4); the shmup runs two starfield layers atCanvas.Alpha 62and84so the far one also reads farther. - Light:
Canvas.Blend Canvas.Addon a soft shape cluster is a glow: muzzle flash, torch halo, pickup shimmer.Multiplywith a dark blue rect over the whole world is instant night; over a band, instant fog. - Weather: a list of falling pixels (2×2 rects) recycled by
modIwrapping is rain or snow for one function’s effort; the racer’s four seasons are mostly palette plus one particle list.
Ship it chunky
Resist the urge to fight the aesthetic. The 1x pixelated canvas (chapter 5) rewards art that commits to being blocky: whole-pixel positions, hard color steps, few big shapes over many small ones. Every game in the gallery that looks good looks good because it leaned in. Rectangles are not what you are stuck with; they are what the style is.