A player told us, plainly: "our games UI is poor, need improvements."

The tempting response is a design system and a six-week redesign. Instead we counted things, and the counting produced a much smaller answer.

The join

  • 28 of 81 bundles draw with zero gradients.
  • 16 are under 700 lines.
  • 38 of 81 never load gd-juice.js, our shared material layer.

And they are almost exactly the same 38 games.

The debt is chronological. The shared layer arrived partway through the catalogue, and everything built before it simply never picked it up. Nobody decided those games should look flat. They were written first.

So the job was never "invent a design system". It was "build the two things the shared layer is missing, then go back through the catalogue" — which is a completely different amount of work.

The early bundles also share one palette. Same near-black, same off-white, same cyan, same amber. That is why 81 distinct games read as one thin template applied forty times: they literally were.

What was actually missing

gd-juice.js owned the piece — how a tile looks, how it moves, how it lands. Nothing owned the place: the board, the table, the room the piece sits in.

That gap became gd-art.js: baking, one light source made explicit, decreasing-inset fills, seeded texture, and role-based palettes. It ships with 51 assertions.

The contrast check that passed the failure that ships

The suite's first run caught two defects, and they were the same mistake in different clothes.

A light-surface palette kept accent colours that only work on a dark surface — contrast ratios of 2.60 and 2.92. Another put a brown accent on brown at 2.66.

Ink was fine in both. A contrast check that only tests text-on-surface passes every one of these, because the problem is never the text. It is the pieces.

Assert every colour a player has to find, not just the ones they read. Accent, secondary accent, positive and warning, each against their own surface. Neither defect is visible in a swatch list. Both are obvious the moment you look at a board — which is exactly the class of thing that reaches a player before it reaches a test.

Two things that made a catalogue-wide retrofit safe

Retrofit as a repaint, never a re-layout. Checkers kept its playfield at 0..W in eight cells, so every coordinate and every hit test stayed byte-identical and no existing suite had to change. Geometry changes are where a cosmetic pass turns into a regression.

Read the rules before picking colours. Checkers pieces only ever sit on dark squares. That means the light squares never carry a piece, so they can be genuinely light — which is the entire reason a checkerboard reads as a checkerboard instead of two shades of the same navy. The contrast structure is the design. The hue is decoration.

And a baked layer needs a paint-count seam. A layer that repaints every frame looks identical to one that caches, and is slower than the flat version it replaced. Checkers.artPaints() stays at 1 across 90 real animation frames. Without that seam the claim is unfalsifiable — and disabling the cache does turn 7 assertions red, so we know the seam bites.

Then we re-measured, and the metric was wrong in both directions

gd-art.js is canvas-only, and 16 of our bundles are DOM rather than canvas. So we re-ranked them by CSS gradients, box-shadows and insets instead of canvas calls.

The ranking inverted. 2048 scored "zero gradients" on the canvas metric and actually has eleven CSS ones, five box-shadow layers and inset wells. TriPeaks, Hearts, Word Weave, Pyramid Solitaire, Mancala and Letter Hive were all fine too.

Measure the medium the game is actually drawn in, or your priority list is noise.

And the metric hid a game with no cards in it

Memory Match was not thinly styled. It had no visible cards at all.

.card-inner used width: 100%; height: 100%, and .card is a <button>. A button wraps its content in an anonymous box, which gives a percentage height nothing definite to resolve against — so both card faces laid out at 0 by 0 while the button itself was 127 by 127. The backs, their gradient, their border and their glow were all being computed and painted into a zero-pixel box. Only the ::after question mark, which overflows, was ever visible.

A percentage height inside a <button> is not reliable. Position absolutely against it instead.

Two rules did most of the work

One light, from the upper left, in CSS exactly as on canvas — so a keypad and a game board on the same site agree about where the light is coming from. Lit top edge, dark bottom edge, contact shadow offset down and right.

When several games share byte-identical CSS, fix all of them. Our three solitaires had the same flat card face. Fixing one would have split a family a player moves between inside a single session.

One game was left flat deliberately: Daily Word. In the Wordle shape the colour of a tile is the entire signal, and anything added to it is noise.


Play them: Checkers · 2048 · Memory Match

Related reading: what breaks when you frame a game on a phone