# Art

> Make procedural isometric art that reads as designed rather than as programmer output — buildings, trees, towers, terrain shading, palettes, day and night. Use when drawing a building, sprite, tree, tower or any object in an isometric game, when the art looks flat, gray, muddy, toy-like or like placeholder blocks, when choosing colors or a palette, when adding a day/night cycle, or when the frame time climbs at dusk.

- Skill: `plausibleventures/art` (Agent Skill)
- Install (CLI): `npx skillmds@latest add plausibleventures/art`
- Raw SKILL.md: https://api.skillmd.com/api/skills/plausibleventures/art/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: plausibleventures (https://skillmd.com/u/plausibleventures)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/plausibleventures/art

---


# Art

Every solid in this kit is described by **one color** and a footprint; the three faces are
derived. There is no `leftColor`, because offering one is offering you a way to break the look.

Six rules make procedural art read as designed. They are cheap and they are not optional — a
render that skips them still produces a picture, and the picture looks like a placeholder.

| | |
|---|---|
| **silhouette first** | a shape you can recognize as a black cutout at thumbnail size. If it needs its detail to be legible, it is the wrong shape |
| **detail at three scales** | a mass, a feature on the mass, and something small enough to only just resolve. Two scales reads as a toy |
| **setback massing** | stack narrower volumes as they rise. A single extruded box is the shape every naive isometric demo has |
| **window rhythm** | repeat with a break — three, three, gap, three. Even spacing reads as a spreadsheet |
| **cool shadows, warm highlights** | this is what separates a stylized render from a gray lerp. `shade` does it for you; do not replace it with a multiply |
| **something moves on everything** | every building gets an `animate` hook. A static frame reads as a screenshot of a game |

---

## The slots, and which of them are fills

`createPalette(BASE_SLOTS)` names ten colors. **It is not a menu of ten interchangeable ones.**
Two are not fills at all, three do not obey nightfall, and picking one of those for a roof gives
you a building that compiles, passes every test, and is wrong at a glance.

| slot | day color | it is | use it for |
|---|---|---|---|
| `ground` | soft green, 58% light | **fill** | terrain, tiles, earth, anything the world is made of |
| `brand` | terracotta, 42% | **fill** — the recolorable one | whatever the game is *about*. `palette.set('brand', …)` is the whole recolor story |
| `metal` | blue-gray, 59% | **fill** | machinery, rails, pipes, hardware |
| `glass` | pale cyan, 79% | **fill** | windows, water, ice, anything wet |
| `sky` | pale blue, 77% | **fill, backdrop only** | `clear: 'sky'`, and the color haze pulls toward |
| `warn` `ok` `bad` | amber 71%, green 64%, red 39% | **signal** | lamps, hazards, a legality tint on a ghost, a HUD mark |
| `ink` | near-black, **13%** | **outline and text. Not a fill** | the `stroke` argument of `isoTile`, `isoPatch`, `isoWall`; `wallText`, `sign`, `screenText`; and genuinely black hardware at small area — an iron post, a flue |
| `night` | near-black, **8%** | **not a color you draw with** | one place only: the `tint` argument of `light.begin` — the color the dark itself goes |

**`palette.ink(value)` is the resolver, not the slot.** It takes any `Ink` — a number or *any*
slot name — and returns a color. The name collision with the `'ink'` slot is exactly how a roof
ends up black.

**A fill in the outline slot is a hole in the picture.** `shade` derives three faces from one
color, and that spread is what makes a solid legible: `brand` comes out **42 / 30 / 21%** with an
outline at **13%**. `ink` comes out **13 / 11 / 9%** with an outline at **4%** — the whole
three-tone read spans four points of brightness, and because the silhouette stroke is *derived
from the fill* there is nothing left below near-black for it to be derived into. It is not a dark
building; it is a building with no faces and no silhouette.

**A signal in a fill slot does not obey nightfall.** `warn`, `ok` and `bad` barely move between
`DAY` and `NIGHT` — 71→66, 64→58, 39→36 — because a HUD has to stay readable at midnight.
`ground` goes 58→22 and `sky` 77→14. So a roof painted `warn` sits at 93% of its noon brightness
over a town at 38% of its, and glows all night with nothing anywhere to explain why.

**An outline in a fill slot** is the milder mirror: stroke with `ground` or `metal` and the
stroke lands within a few points of the face it is meant to separate, so a crowd of buildings
merges into one mass at exactly the zoom where the silhouette was doing all the work.

```ts wrong
// A market with two roof materials, picked off the slot list as equals. Half the town renders
// as black holes and the other half glows at midnight.
isoRoof(pen, gx + 0.15, gy + 0.15, w, d, h, 0.72, seed % 2 ? 'warn' : 'ink');
```

**Ten is a starting vocabulary, not a limit.** A market with two roof materials wants two slots
of its own — added to *both* stop sets, so both roll at dusk with everything else.

```ts
import { DAY, NIGHT, createPalette, extendStops, isoRoof } from '@latticekit/draw';
import type { Pen } from '@latticekit/draw';

const DAY_X = extendStops(DAY, { tile: 0xb4573cff, thatch: 0xc9a15aff });
const NIGHT_X = extendStops(NIGHT, { tile: 0x59323aff, thatch: 0x5f5546ff });
export const palette = createPalette(DAY_X);

export function marketRoof(pen: Pen, gx: number, gy: number, seed: number): void {
  isoRoof(pen, gx + 0.15, gy + 0.15, 1.7, 1.7, 1.4, 0.72, seed % 2 ? 'tile' : 'thatch');
}
```

---

## A sprite

```ts
import { VARIANT_ZERO, defineSprite, drawSprite, glowDot, spriteHeightPx } from '@latticekit/draw';
import type { Pen } from '@latticekit/draw';

const LIGHTHOUSE = defineSprite({
  id: 'lighthouse',
  w: 2,
  d: 2,
  // Static art. `rng` is freshly seeded from `v.seed` on every call, so a tower cannot
  // reshuffle itself on reload and a replay from a seed lands on the same pixel.
  massing(s, v, rng) {
    s.shadow(0, 0, 2, 2);
    s.box(0, 0, 2, 2, { color: 'stone', h: 1.2 });                       // the plinth
    s.cylinder(1, 1, 0.72, { color: 'paper', h: 4 + v.level, z: 1.2 });  // the tower
    s.cylinder(1, 1, 0.86, { color: 'stone', h: 0.3, z: 5.2 + v.level }); // the gallery
    s.roof(0.4, 0.4, 1.2, 1.2, 5.5 + v.level, 0.9, 'roof');
    if (rng.next() > 0.5) s.post(0.25, 1.7, 0, 1.4, 'stone');            // a mooring bollard
  },
  // Live art, over the static art, every frame. `pen.t` is the only clock, and it arrived as a
  // parameter — nothing in this package reads one.
  animate(pen, gx, gy, v, _rng, zPx) {
    const sweep = (pen.t * 0.22) % 1;
    const bright = sweep < 0.25 ? 1 : 0.25;
    glowDot(pen, gx + 1, gy + 1, 5.4 + v.level, 'warn', 0.2, snap(bright));
    void zPx;
  },
  // The light it throws into the night. Runs only when the frame has one, and pools it on the
  // ground the tower stands on — `zPx` is the elevation `drawSprite` was given, passed through.
  emit(lights, gx, gy, v, _rng, zPx) {
    lights.add(gx + 1, gy + 1, zPx, 3 * 0.3, 1, 'warn');       // the core
    lights.add(gx + 1, gy + 1, zPx, 3 + v.level, 0.32, 'warn'); // the halo
  },
});

/** Nine levels of brightness. See "an animated color is an allocator", below. */
function snap(x: number): number {
  return Math.round(x * 8) / 8;
}

export function paint(pen: Pen, gx: number, gy: number, groundPx: number): number {
  const v = { ...VARIANT_ZERO, seed: 7, level: 2 };
  drawSprite(pen, LIGHTHOUSE, gx, gy, v, groundPx);
  return groundPx + spriteHeightPx(LIGHTHOUSE, v);   // what DepthSorter.add wants, for culling
}
```

Three things in there are load-bearing and easy to leave out.

**`drawSprite`'s last argument is the ground under the footprint.** Leave it off on a heightfield
and every sprite floats or sinks by its own terrain height — which reads as *the art is wrong*,
sprite by sprite, rather than as one missing argument. The same number goes to `spriteVolume`
and to `DepthSorter.add`, and those three agreeing is what stops the picture, the tap target and
the sort order disagreeing about which hill this is standing on.

**Two `add` calls, not one, for a light.** A single pool is a linear ramp, and the eye reads a
linear ramp as *the size of the lamp* rather than as the reach of its light. Two pools — a small
bright core inside a wide dim halo — meet a neighbor in each other's halo, where both curves
are nearly flat, instead of in each other's ramp, where the slope is constant and the union has
a visible crease down the middle.

**`spriteHeightPx` replays the whole massing to answer.** Calling it per sprite per frame is a
second full massing pass over the entire scene every frame, for a number that cannot change.
Compute it once, when the thing is created.

---

## Heights are storeys here and world pixels everywhere else

Every height a sprite author writes — `BoxOpts.h`, `BoxOpts.z`, `roof`'s rise, `post`'s `h` — is
in **storeys**, because "three storeys" is what a person means. Every height that crosses into
`iso` — `heightAt`, `footprintBase`, `Volume.zPx`, `DepthSorter.add` — is in **world pixels**.
`levelsToPx` and `pxToLevels` are the two conversions, and they happen at the boundary.

`LEVEL_H` is 26, not 32, on purpose: a storey exactly one tile tall makes every building a cube,
and cubes read as programmer art.

Pass one unit where the other is expected and the building is 26× wrong, which is at least
visible. **The dangerous version is subtler**: a `Volume` built in storeys makes `boxSilhouette`
return an outline that is *nearly* right, so picking works everywhere except near the roof. Use
`spriteVolume`, which does the conversion for you and is the only thing that knows how tall the
massing actually built itself.

---

## A moving color is a cache key

This one sits two layers below you and no signature states it, so it is worth knowing even
though the sharpest version of it has been fixed.

`softEllipse` — which is what `glowDot`, contact shadows and every light pool are built on —
cannot draw a falloff per call at a sane price, so it renders one small ramp per `(inner, outer)`
color pair and reuses it. **The color pair is a cache key.**

The key is snapped to **32 levels per channel** — the resolution a 64-pixel ramp actually has —
and a full cache evicts one entry rather than all of them. **So you do not have to quantize
colors in your own art code, and if you already did, you can stop.** A flame mixed against noise
every frame visits at most 32 keys per channel and then hits for ever. (`traps` has the measured
version of what this looked like before the key was snapped: one animated color evicting every
*other* call site's ramp, at 3.74 misses a frame and about 3.7 MB/s of garbage.)

Three things remain true and are what to watch:

- **Animating *both* endpoints along independent paths multiplies pairs rather than adding
  them** — one moving end is 32 keys, two moving independently is 32². Move one end, or move the
  radius, which is what the eye tracks anyway.
- **Your palette counts as an animated color.** A day cycle running `Palette.lerp` on a
  continuous `t` every frame moves *every* slot in the scene at once, so every color in every
  call becomes a new pair — a whole-scene version of the same failure, with nothing at any call
  site that looks like an animation. One exhibit found this with **27% of its soft ellipses
  missing and no flickering light anywhere in it**. `Palette.lerp` quantizes `t` and bumps `rev`
  only when the quantized step moves, which is what absorbs it — so **do not defeat that by
  rebuilding stop sets every frame** (see below).
- **A two-second color fade steps 32 times rather than 120.** Invisible on a soft falloff. If you
  need a hard edge to move smoothly, `Surface.ellipse` is exact.

The tell in a profile was never a flicker. It was a game that got slower and stayed slower.

---

## Palettes, and the reason a game gets slower at dusk

```ts
import { DAY, DUSK, createPalette, extendStops, hexOf, hsl } from '@latticekit/draw';

// HOISTED, at module scope. See below — this is the whole point.
const DAY_X = extendStops(DAY, { sand: 0xe8d9a8ff, foam: 0xf4f8ffff });
const DUSK_X = extendStops(DUSK, { sand: 0xcfa87dff, foam: 0xc9d4e8ff });

const palette = createPalette(DAY_X);
palette.set('brand', hsl(28, 0.62, 0.54));   // one number in the save, never a derived token

export function nightfall(t: number): string {
  palette.lerp(DUSK_X, DAY_X, t);
  return hexOf(palette.get('sky'));
}
```

**`lerp` compares its stop sets by identity** to decide whether the frame changed anything. A set
rebuilt inside the render callback bumps `rev` on every frame — and `rev` is what every cache in
the kit keys on. The symptom is not a wrong color; it is a frame time that climbs at dusk and
stays climbed.

**Both stop sets must define exactly the same slots.** A half-defined night palette is how one
thing stays gold at midnight, and the failure is silent everywhere else. `extendStops` is how
you add a color of your own — sand, foam, a faction red — to the transition, rather than
redefining the sets.

**Store the player's hue, never the `#rrggbb` it derives to.** Derivation needs `cbrt` and
`pow`, which are not required by the spec to be correctly rounded, so a stored token is an
engine-specific artifact in a file that will travel to another engine — and the player gets a
world that is a shade off on their phone from what it is on their laptop, with nothing anywhere
to explain it.

`Palette.lerp` quantizes `t` internally, so a six-second dusk bumps `rev` 32 times rather than
361. That is deliberate and it is what makes a dusk affordable at all.

---

## Terrain, and the axis that is missing

`isoTerrain` fits a tile whose four corners are at four different heights: it reads the four
vertex heights, projects the quad, and shades it by its own cross-slope.

```ts
import { isoTerrain } from '@latticekit/draw';
import type { Pen } from '@latticekit/draw';
import type { HeightField } from '@latticekit/iso';

export function paintGround(pen: Pen, field: HeightField, gx: number, gy: number): void {
  //                                                       fill      stroke     tint
  isoTerrain(pen, field, gx, gy, 'ground', undefined, 0.88);
}
```

Three things about it are load-bearing, and two of them have cost exhibits multiple rebuilds.

**The relief is measured east-to-west**, because east and west are the two corners that land on
the same screen row — the tile's slope along the *screen horizontal*, which is the only tilt a
2:1 projection can show. That axis is also the sun's: the left face is brighter than the right,
so ground rising toward the east corner is the lit ground. **Invert the sign and terrain still
looks like terrain** — terrain lit from the right, under buildings lit from the left, and the
picture reads as flat for a reason no screenshot names.

**It reads one of the two diagonals, and a landform whose gradient runs along the other one
renders perfectly flat.** Both corners of a `gx + gy` slope sit on the same screen row, so the
relief term is *zero* — a six-thousand-foot cliff shading like a texture, with no error and
nothing to grep for. If your landform runs that way, supply the `north − south` term yourself
through `tint`. It is one subtraction and it is the difference between a slope and a cliff.

**`isoTerrain` adds up to ±0.32 of its own relief to whatever `tint` you hand it.** So a
flat-ground tint of `1` puts the brightest lit face at 1.5, and `shade` at 1.5 on a pale rock
clips: a fresh mountain sampled at four separate points came back `255, 249, 212` at every one
of them, which is not a highlight, it is a hole in the image. **Sit the flat-ground tint at
about 0.88.** And put your own texture in `tint` rather than in a second `shade` call — shading
twice tints twice, and the ground goes muddy.

---

## Three distance bands, and what a cue cannot do

`starting` carries the shape a first build should take — a world that runs off the edges of the
frame, with hundreds of whatever it repeats. Two parts of that are art's.

**Depth is three bands: something near, something mid, something far and dimmer.** One plane at
one scale is why a diorama looks small, and shading will not rescue it, because shading is a
function of distance and a picture with one distance in it has nothing to shade. Build the far
band out of fewer faces — it is already asked to be dimmer and hazier, which is also permission
for it to be *simpler*, and fidelity at a distance nobody can resolve is the one saving that
costs nothing to take.

**A cue decorates a structure and cannot replace one.** One world shipped every depth cue on this
page — haze, three bands of it, cool shadows, warm highlights, a horizon — and still read as a
hillside rather than as a mountain, because the composition underneath was wrong: the subject sat
entirely inside the frame with air above it. Adding a sixth cue to a scene that is not reading is
the move that never works. **When something is not reading, check the structure first** — what is
off the edge of the frame, how much of it is background, how many distance bands there are — and
only then reach for another cue.

---

## Small things that turn out to be big

**One stroke around the silhouette, never one per face.** Per-face strokes cross-hatch the
interior and destroy the chunky read that makes this style work at thumbnail size. `isoBox`
draws left face, right face, top, then one closed six-point stroke — and that order is a
cross-package contract with the hit-tester, not a convention. In a crowd, stroke the shape that
*is* the silhouette or two hundred people become a mesh of hairlines at low zoom.

**A wall along the near-far diagonal has zero screen width.** World x is `(gx − gy) · HALF_W`, so
a segment whose `gx` and `gy` change by the same amount projects to a vertical line and the art
is simply not there. `isoWall` throws and names both tiles; `iso.isEdgeOn(ax, ay, bx, by)` asks
first. **An animated endpoint must not be able to sweep through the diagonal.**

**`massing` and `animate` get different `Rng` streams.** A sprite whose massing chose a height
and a lean cannot recover either by drawing in the same order in `animate`, so a moving crown
sits beside the static one it is supposed to *be* and the tree renders with its head beside its
neck. Worst on the tallest, leaniest instances, which is why it survives a review. Address by
index instead:

```ts
import { hashStep, toUnit } from '@latticekit/core';
import type { Variant } from '@latticekit/draw';

/** The same number in `massing` and in `animate`, addressed by position rather than by order. */
export function vat(v: Variant, i: number): number {
  return toUnit(hashStep(v.seed, i));
}
```

**`emit` gets no clock.** Its parameters are `(field, gx, gy, v, rng, zPx)` — no pen, no `t` — so
a guttering torch or a lamp being switched on cannot be written through the sanctioned hook.
Drive it from `animate` with `pen.light?.add(…)` instead; two exhibits arrived at that
independently.

**Haze must not converge on the `sky` slot.** At noon `sky` is a fully saturated cyan, so mixing
a green hillside toward it trades one saturated color for another and the far range stays as
loud as the near one. Real distance loses *saturation* before it loses hue, so the target is the
sky pulled a third of the way to white by day and barely at all at night.

**Hard color bands on a noisy field make a mosaic of flat diamonds**, because adjacent tiles land
either side of a boundary. Interpolate the bands over about sixteen quantized stops — which also
keeps the ramp cache happy.

---

## What this skill does not cover

| you want | read |
|---|---|
| the boot, the loop, the frame | `starting` |
| terrain data, roads, paths, walkers | `world` |
| picking, ghosts, drag to place | `input` |
| a HUD, a panel, a toast | `hud` |
| "it's slow" or a frame budget | `performance` |
| something that renders and looks wrong | `traps` |

The long-form version of everything here, with the full primitive list, is on disk at
`node_modules/@latticekit/draw/README.md`.

