API reference · layer 2

@latticekit/draw

The rendering layer: a Surface interface with a Canvas2D backend, color derivation, and the isometric solid kit that makes procedural art read as designed.

exports100 symbols in 13 modules — start with createCanvas2dSurface, renderFrame, createPalette, createLightField
depends on@latticekit/core, @latticekit/iso
environmentbrowser (Canvas2D) with an offscreen/headless backend for tests
gzipped12.33 kB against a 12.5 kB budget — exclusive backends, charged at the heaviest: canvas2d 12.33 charged, record 11.31, shared 10.19
sourcepackages/draw · README · index.d.ts

@latticekit/draw — one color and one grid footprint into a stylised isometric solid, on a surface it does not own.

The two halves of that sentence are the two things this package is for. One color is the art direction: three-tone faces derived from a single hex, cool shadows, warm highlights, a silhouette stroke on everything. A surface it does not own is the engineering: nothing in this package, and nothing above it, ever holds a CanvasRenderingContext2D — so the same code paints the world, a shop thumbnail and a golden test, and a WebGL backend can replace the Canvas2D one without a sprite noticing.

const surface = createCanvas2dSurface(canvasEl);
const pen = beginFrame({ surface, camera, palette, t, clear: 'sky' });
isoTile(pen, 4, 7, 'ground');
isoBox(pen, 4, 7, 2, 2, { color: 'brand', h: 3 });
endFrame(pen);

What is deliberately not here

A sorted draw list, a depth key, a comparator or a Rect. All four are iso's. There is one sorted list in the kit; draw walks DepthSorter's permutation in the Solids pass and contributes nothing to how it got ordered — and must not reorder it, because pickSorted walks that same instance backwards and a partitioned repaint makes a player tap a rack and open the headquarters behind it.

A sprite bitmap cache. It was in the RFC as provisional, with deleting it named as a clean outcome, and the benchmark decided: direct drawing of a thousand sprites costs a small fraction of the 8 ms budget, so a cache would have bought zoom buckets, palette revisions, pixel snapping and a don't-fill-while-moving rule — four new ways to render something stale — in exchange for nothing. docs/PERFORMANCE.md has the number. The massing/animate split survives it and was never only about caching: it is what makes a sprite's static art declarative and its motion explicit.

Bezier paths, concave polygons, clipping, a general composite API, a transform stack, filters, images the kit did not render, and perceptual color interpolation. Each is either something a WebGL backend could not honour in fifty lines without lying, or something that would change every screenshot in the kit and improve none of them. docs/rfc/draw.md §4 has the reason for each, which is what stops the next agent adding it back.

Hit-testing. iso owns picking. This package contributes spriteBounds and spriteVolume — the geometry a pick test needs — and stops there. In particular it never records what it drew for picking to read back, because a frame the renderer skipped would then leave the controls somewhere the building is not.

Lights that cast shadows or are occluded. A lamp behind a hill still spills over it. Real occlusion needs a shadow map per light and a depth buffer this renderer does not have, and it would cost more than everything else in the package put together. This is the largest honest limitation here.

A serialization format for color. draw has no serialization and must never grow any: the moment it can write a color to a save, someone writes a presentation-tier value into a document that travels between engines. Store the hue; derive the tokens on load.

What it promises

  • Draw calls go through Surface. No package reaches for CanvasRenderingContext2D directly.
  • A solid is described by one color; its faces are derived. Shadows cool, highlights warm.
  • Everything is drawable into an offscreen surface, which is what makes UI thumbnails and golden tests possible.

Verbatim from .lattice/kit.json, which npm run lint keeps in step with the code.

color17 symbols

Color: one packed integer per color, and the three-tone face derivation the look rests on.

No DOM, no canvas — this module runs unchanged in Node. It is arithmetic over uint32s.

The rule persist asked for, stated once here and again at every call site

**A game persists the input to a color, never the output.** Store the player's brand hue — one number — and re-derive every token from it on load. Never write a derived #rrggbb into a save.

Derivation is presentation-tier: it is allowed to use maths whose last unit may differ between engines, because a pixel that differs in its last unit is a pixel nobody can see. A save file that differs in its last unit is another matter entirely — it travels. Persist a derived token and you have written an engine-specific artifact into a document that will be opened on a different engine, and the player gets a campus that is a shade off on their phone from what it is on their laptop, with nothing anywhere to explain it.

Two alpha conventions, and why they are not a mistake

functiona isbecause
rgba0–255its other three arguments are 0–255
hsl, withAlpha0–1their other arguments are 0–1

Each function's alpha matches the arguments beside it, which is the convention a caller can actually hold in their head. Mixing them silently is the one way to get this wrong, so both doc comments say which is which.

Everything here is Tier A+ - * /, comparisons and bitwise operators — including the HSL conversion, which is written without sin or pow for exactly that reason. That is not because a color is ever hashed (it must not be), but because it costs nothing here and it keeps the greppable Tier B list in this package down to the one site that genuinely needs it.

Rgba type ↳ src/color.ts:48

type Rgba = number

A color packed as 0xRRGGBBAA in a uint32.

Not a CSS string. shade() in the source game returned rgb(12,34,56), which meant three fresh strings per box per frame — the largest single source of garbage in the renderer, and invisible in a profile because strings die young. Packed integers compare with ===, key a Map with no hashing, and hand a WebGL backend its vertex color with two shifts.

Always stored unsigned: every function here returns >>> 0, so #ff0000 opaque is 4278190335 and never -255. A signed one would still render, and would compare unequal to the same color produced anywhere else, which is a cache key that never hits.

Ink type ↳ src/color.ts:58

type Ink = Rgba | string

A color, or the name of a palette slot resolved at draw time.

A slot name is what lets one campus recolour to a player's brand, and it is why a cache key must carry Palette.rev. Passing an unknown slot throws naming the slot and listing the ones that exist; a silent black is a bug report that says "the game looks wrong" and nothing more useful than that.

rgba function ↳ src/color.ts:97

function rgba(r: number, g: number, b: number, a?: number): Rgba

Pack four channels. All four are 0–255, including a, which defaults to fully opaque.

Values are clamped and rounded rather than rejected: a channel arrives from a derivation that may legitimately overshoot — shade(c, 1.4) on an already-bright color — and throwing there would make every highlight a caller's problem. Use withAlpha when your alpha is a 0–1 fraction; passing 0.5 here is very nearly transparent, not half.

FACE_TOP const ↳ src/color.ts:108

const FACE_TOP = 1

Relative brightness of each visible face. The sun sits high and front-left.

FACE_LEFT is the face whose normal points along +gy — screen-left — and FACE_RIGHT the +gx face. Swap the two and every building in the kit is lit from the wrong side, which reads as "the art is flat" rather than as a bug, so it goes unreported.

SHADE_TINT const ↳ src/color.ts:121

const SHADE_TINT: Rgba

Cool target that shadowed surfaces drift toward. The whole trick, in one constant.

Shading toward blue in shadow and amber in light is what separates a stylised render from a flat gray lerp. Neutralise this to gray and every screenshot still renders and every screenshot looks like a placeholder.

shade function ↳ src/color.ts:150

function shade(base: Rgba, factor: number): Rgba

Derive a face color from a base color — the rule the whole look rests on.

factor below 1 darkens and pulls toward SHADE_TINT; above 1 brightens and pulls toward LIGHT_TINT. Tint strength scales with distance from neutral, so shade(c, 1) === c exactly and nothing drifts by accident — which matters because a top face is drawn at FACE_TOP, and a top face that is not bit-identical to the color the caller asked for makes every golden test in a game a re-blessing exercise.

Replace it with a plain multiply and the kit's art dies quietly.

Presentation only. Never persist what this returns — store the base color and derive again on load. A derived token in a save file is an engine-specific artifact in a document that travels between engines.

outlineOf function ↳ src/color.ts:184

function outlineOf(base: Rgba): Rgba

The silhouette stroke for a solid: its own hue, very dark, never pure black.

Derived from the solid's own color rather than fixed, so a brand recolour moves the outlines with it. A shared constant outline is what makes a recoloured campus look like stickers on a fixed drawing.

withAlpha function ↳ src/color.ts:202

function withAlpha(color: Rgba, a: number): Rgba

Replace the alpha channel. a is 0–1; the rgb channels are untouched.

The 0–1 form because every caller of this has a fraction in hand — an intensity, a ghost opacity, a falloff — and none of them has a byte. See the module header's table.

mix function ↳ src/color.ts:214

function mix(a: Rgba, b: Rgba, t: number): Rgba

Linear per-channel blend in sRGB bytes, alpha included. t is clamped to 0–1.

Deliberately not perceptual. OKLab would be more correct and is not this look: the three-tone face derivation is a byte-space lerp toward two fixed tints, and its slight non-linearity is why the faces read as painted rather than as computed. A "correct" mix would change every screenshot in the kit and improve none of them.

cssOf function ↳ src/color.ts:251

function cssOf(color: Rgba): string

Packed color → a CSS rgb()/rgba() string, memoised. Backends only; not for game code.

This is the only place in the kit where a color becomes a string, and it exists because Canvas2D takes strings. Calling it from a solid would reintroduce trap 4 — three fresh strings per box per frame — at the one layer that cannot see it happening.

hexOf function ↳ src/color.ts:274

function hexOf(color: Rgba): string

Packed color → #rrggbb, or #rrggbbaa when it is not opaque. The DOM-facing form.

ui writes these into custom properties. The string this returns belongs in a stylesheet, never in a save — it is derived, and derived color is presentation-tier.

hex function ↳ src/color.ts:290

function hex(css: string): Rgba

Parse #rgb, #rrggbb or #rrggbbaa into a packed color.

Authoring time only, never per frame. It is here so a palette can be written as hex in a source file, which is the form a designer hands over; the frame path never sees a string, and that contract is why this is deliberately not memoised — see cssMemo.

Throws

RangeError naming the input if it is not one of the three forms. A silent black here would be a typo that ships as art.

hsl function ↳ src/color.ts:333

function hsl(h: number, s: number, l: number, a?: number): Rgba

HSL → packed. h in degrees; s, l and a in 0–1.

Hue is how a player picks a brand color — a wheel, one number — and how a theme derives a dozen related tokens from that one number.

The hue is the thing a game saves. Persist h, never the Rgba this returns and never the #rrggbb that comes out of hueToHex. One number in the save, a whole palette derived on load, and the same save renders identically on any engine — which the derived tokens, being presentation-tier, cannot promise.

h wraps, so 380 and 20 are the same color and a hue driven by an accumulating slider never needs a modulo at the call site.

hueToHex function ↳ src/color.ts:369

function hueToHex(hue: number, sat?: number, light?: number): string

A brand hue straight to #rrggbb, for the DOM.

hexOf(hsl(hue, sat, light)), and it exists as its own export because ui derives its whole theme from one hue and must not grow a second color model to do it. Color lives in exactly one package, and this is itcore deliberately has none, so a second implementation anywhere above this line is the bug, not the convenience.

The string this returns belongs in a stylesheet, never in a save. The hue argument is the durable value.

palette12 symbols

Named color, the revision that keeps a cache honest, and the day/night spine.

No DOM, no canvas — this module runs unchanged in Node.

A slot name is the whole recolour-the-campus story: art is authored against 'brand', the player picks a hue, one set recolours everything that was ever drawn with it. rev is what makes that safe in the presence of any cache — bumped on every write, part of every key, and the single reason a recoloured campus cannot render stale.

lerp is the day/night spine, and two things about it are load-bearing

  1. t is quantised before it is applied, and rev bumps only when the quantised step changes. A continuous lerp that bumped rev every frame would invalidate every cached sprite every frame, which turns the prettiest moment in the game into its slowest. PALETTE_STEPS levels across a six-second dusk is a color delta of under two levels per step — invisible — and at most that many cache generations.
  2. Both stop sets must define exactly the same slots. A half-defined night palette is precisely how one thing stays gold at midnight, and the failure is silent everywhere else.

The world's blue and the HUD's blue are the same blue

Palette.lerp and lerpPalette share their quantisation and their interpolation, and that is not an implementation detail. If the canvas lerped in draw and the overlay lerped in ui, both "obviously" a linear blend, they would disagree by a shade because one of them quantised — and nightfall is the one moment where a mismatch is unmissable and unnameable.

Stops type ↳ src/palette.ts:40

type Stops = Readonly<Record<string, Rgba>>

A named, immutable set of slot colors: DAY, DUSK, NIGHT.

Plain data, so a game authors them in one object literal, diffs them in review, and hands two of them to Palette.lerp. Not a Palette — a Palette is live state with a revision, and stop sets are constants.

PALETTE_STEPS const ↳ src/palette.ts:49

const PALETTE_STEPS = 32

Quantisation levels for Palette.lerp and lerpPalette.

32 levels, so t = 0 is exactly the from set, t = 1 is exactly the to set, and a continuous sweep between them bumps rev at most 32 times rather than once per frame. The divisor is PALETTE_STEPS - 1 for that reason: 32 levels means 31 intervals.

Palette interface ↳ src/palette.ts:76

interface Palette {

Live slot state for one frame's worth of drawing, plus the revision any cache keys on.

7 members
readonly rev: number

Bumped on every mutation.

Part of every sprite cache key, and the single reason a recoloured campus cannot render stale. A cache keyed on (sprite, level, zoom) alone will happily blit yesterday's brand color for ever, and the player files it as "the rebrand did not apply".

get(slot: string): Rgba
Throws

RangeError naming the slot and listing the known ones. A typo that rendered black would be filed as an art bug and never found.

set(slot: string, color: Rgba): void

Write a slot and bump Palette.rev. Adding a slot the kit does not know about is fine and expected — a game's own vocabulary lives here beside the kit's.

has(slot: string): boolean

Whether a slot exists, for a caller building a theme editor.

ink(value: Ink): Rgba

Resolve an Ink: a number passes through untouched, a string is a slot lookup.

keys(): readonly string[]

Every slot name, sorted. Stable across calls until a slot is added.

lerp(from: Stops, to: Stops, t: number): void

Cross-fade every slot between two stop sets. One call and one number recolours the entire world — the day/night spine, and the strongest argument the zero-asset rule has.

See the module header for the two things about it that are load-bearing.

Throws

RangeError if the two stop sets do not define exactly the same slots.

createPalette functionstart here ↳ src/palette.ts:114

function createPalette(slots: Stops): Palette

Build a live palette from a stop set.

The slots are copied, so the stop set stays a constant a game can hand to Palette.lerp afterwards without the palette's own writes having moved it.

extendStops function ↳ src/palette.ts:203

function extendStops(base: Stops, extra: Stops): Stops

A stop set plus a game's own slots. The sanctioned way to add a color to the day/night lerp.

Palette.lerp requires both stop sets to define exactly the same slots, and that rule is right — a half-defined night palette is how one thing stays gold at midnight, silently. But the consequence was that a game with one color of its own, sand or foam or faction red, could not add it to a transition without redefining every stop set in the transition, and could not add it to the kit's DAY/DUSK/NIGHT at all, because those are frozen constants. So the color lived outside the palette, was blended by hand, and was the one thing in the frame that did not roll at dusk with everything else.

const SAND = { day: 0xe8d9a8ff, dusk: 0xcfa87dff, night: 0x5d6478ff };
const DAY_X = extendStops(DAY, { sand: SAND.day });      // hoisted, at module scope
const DUSK_X = extendStops(DUSK, { sand: SAND.dusk });
palette.lerp(DUSK_X, DAY_X, t);
pen.palette.get('sand');

Hoist the results. Palette.lerp compares its stop sets by identity to decide whether a frame changed anything, so a set rebuilt inside the render callback is a new object every frame: every frame then bumps rev, and rev is what every cache in the kit keys on. The symptom is not a wrong color — it is a game that gets slower at dusk and stays slow.

Extending each set of a family separately is deliberate, and it is the same discipline the same-slots rule enforces: there is no shape here in which a slot can be given a daytime color and forgotten at night. extra may also replace a slot the base defines — a biome recolouring ground — which is why it is applied second.

The result is frozen, and the base is never touched: both may already be shared constants.

Vars type ↳ src/palette.ts:218

type Vars = Readonly<Record<string, string>>

A flat slot → CSS color bag. The only shape color crosses into the DOM in.

draw emits bare slot names; ui owns the prefix, because a package that does not touch the DOM has no business naming a custom property.

lerpPalette function ↳ src/palette.ts:235

function lerpPalette(a: Stops, b: Stops, t: number): Vars

Interpolate two stop sets into CSS strings — the drawui seam.

Pure: it touches no Palette and no DOM. ui writes the entries onto custom properties under its own prefix, guarded per key, on its own slow cadence, and lets a CSS transition do the smoothing. Optimized for clarity, not for the frame: at one call a second the allocation of a fresh object is not worth a line of thought.

It shares its quantisation and its interpolation with Palette.lerp. See the module header for why that is a promise rather than a coincidence — and note what the promise does not cover: it proves the two functions agree, and cannot save a game that passes them different (from, to, t).

Throws

RangeError if the two stop sets do not define exactly the same slots.

paletteVars function ↳ src/palette.ts:245

function paletteVars(p: Palette): Vars

The same bag, from whatever a live palette currently is. For a rev-guarded push into the DOM: read rev, and only rebuild the bag when it moved.

DAY const ↳ src/palette.ts:294

const DAY: Stops

Full daylight. The same object as BASE_SLOTS, named for the transition rather than for the default, so palette.lerp(DAY, NIGHT, night) reads as what it is.

DUSK const ↳ src/palette.ts:298

const DUSK: Stops

The middle of the transition, as a stop set a game can hold at. Not a blend of the other two: dusk is warmer than the midpoint, which is the whole reason it is authored.

NIGHT const ↳ src/palette.ts:304

const NIGHT: Stops

Midnight. Everything cool and dark; warn, ok and bad stay legible because a HUD must read at midnight — the darkness is LightField's job, not the palette's.

surface12 symbols

The seam: what a backend must provide, and the per-frame context a primitive is handed.

No DOM, no canvas — this module runs unchanged in Node. It declares an interface; the two implementations are canvas2d.ts (browser) and record.ts (headless).

How narrow the seam had to be

The test applied to every candidate method was: *could a competent WebGL backend implement this in under fifty lines, without lying?* Bezier paths fail it — they need a tessellator bigger than this whole package. Clipping fails it. globalCompositeOperation fails it, with its twenty-six Porter-Duff modes. What survives is convex polygons, polylines, ellipses, text and a render target: thirteen methods, each one something an isometric solid genuinely needs and a GPU backend can genuinely honour.

Every coordinate on this interface is in CSS pixels

Device-pixel-ratio is entirely the backend's business, and that is not a convenience. In the game this kit came from, the ratio transform was applied on resize and re-applied by the wall-text routine, which was correct only because both places agreed and one edit from a half-scale campus. Here no caller can see the ratio, so no caller can apply it twice.

SurfaceKind type ↳ src/surface.ts:31

type SurfaceKind = 'canvas2d' | 'recording'

Which backend a Surface is, for the two places that legitimately need to know: a golden test asserting it is not accidentally running against a canvas, and an error message.

TargetMode type ↳ src/surface.ts:41

type TargetMode = 'image' | 'light'

What a render target accumulates.

'image' is ordinary source-over painting. 'light' blends by per-channel maximum and starts black — which is the entire reason two lamp pools can overlap without a seam. It is globalCompositeOperation = 'lighten' on Canvas2D and blendEquation(MAX) on a GPU, so both backends honour it in one line and neither has to lie.

BlitMode type ↳ src/surface.ts:56

type BlitMode = 'over' | 'add' | 'cut'

How a bitmap lands on what is already there. Three modes, not a composite API.

modeCanvas2DWebGLused for
'over'source-overSRC_ALPHA, ONE_MINUS_SRC_ALPHAeverything ordinary
'add'lighterONE, ONEthe warm bloom a lamp throws
'cut'destination-outalpha ZERO, ONE_MINUS_SRC_COLORpunching light holes in darkness

Three named modes, each one blend state on both backends, each one demanded by a picture the kit has to be able to draw. A fourth arrives the way the third did: a demo that cannot be built without it.

TextStyle interface ↳ src/surface.ts:68

interface TextStyle {

A text run's appearance, passed per call.

Per call and not as state, because a font left set on a 2D context is the classic Canvas2D leak: the next caller inherits it and the symptom appears somewhere unrelated to the cause.

align and baseline are -1 | 0 | 1 (start | center | end) rather than strings, so a backend switches on a number and a golden log records an integer rather than a word that two backends might spell differently.

5 members
readonly size: number

Em size in CSS pixels.

readonly weight: number

CSS font weight, 100–900.

readonly family: string

CSS font family list. The kit ships no fonts — rule 8 — so this is always a stack of system faces, and a golden test must not assert glyph positions because of it.

readonly align: -1 | 0 | 1

Horizontal anchor: -1 start, 0 center, 1 end.

readonly baseline: -1 | 0 | 1

Vertical anchor: -1 top, 0 middle, 1 bottom.

Bitmap interface ↳ src/surface.ts:88

interface Bitmap {

An image the kit rendered itself. Opaque: a canvas element, a GPU texture, or an op log.

There is no way to construct one from a URL or a file, and that is rule 8 — zero assets — expressed in the type system rather than in a lint somebody can disable.

5 members
readonly width: number

CSS pixels.

readonly height: number

CSS pixels.

readonly pixelRatio: number

Device pixels per CSS pixel in the backing store.

readonly bytes: number

Approximate resident bytes. Anything budgeting on this — a sprite cache, a debug overlay — is lied to if a backend fakes it, and the lie surfaces as an out-of-memory on a phone rather than as a wrong number.

dispose(): void

Release the backing store. A bitmap that outlives its surface leaks GPU memory.

Surface interface ↳ src/surface.ts:109

interface Surface {

Everything a backend must provide.

Thirteen methods. Nothing above this interface ever holds a CanvasRenderingContext2D, which is what lets the same sprite code paint the world, a shop thumbnail and a golden test.

17 members
readonly kind: SurfaceKind

Which backend this is.

readonly width: number

CSS pixels. Never device pixels — see Surface.pixelRatio.

readonly height: number

CSS pixels.

readonly pixelRatio: number

Device pixels per CSS pixel. Read-only to callers; the backend applies it internally, and a caller that multiplies by it has applied it twice.

resize(width: number, height: number, pixelRatio: number): void

Resize the backing store. Coordinates stay in CSS pixels either side of it.

begin(clear: Rgba): void

Start a frame: erase the surface, then paint clear over it.

Resets every piece of backend state — alpha, dash, font, composite — so a frame can never inherit the previous frame's leak.

0 is a transparent start, not "keep what is there". The RFC said the latter; the light accumulator settled it, because a buffer that blends by per-channel maximum and is never erased keeps every pool it has ever been given, and the symptom is a night that gets gradually brighter the longer the player looks at it. Nothing in the kit wants a frame composited over its predecessor, and a begin that forgets to erase is the single easiest ghosting bug to ship.

end(): void

Finish the frame. A backend that batches flushes here; Canvas2D does nothing.

poly(xy: Float64Array, count: number, fill: Rgba): void

Fill a convex polygon given as count xy pairs from the start of xy.

Convex is the contract, not an optimization: it is what lets a GPU backend fan-triangulate in place with no tessellation library. Every face of every iso solid in this kit is convex; if a shape is not, the sprite author splits it, because they know how and a general tessellator does not.

polyRamp(xy: Float64Array, count: number, x0: number, y0: number, x1: number, y1: number, from: Rgba, to: Rgba): void

Fill a convex polygon with a linear color ramp along the screen-space segment (x0,y0) → (x1,y1).

Two stops, no gradient object. This is the cylinder body and the sky backdrop, and it is per-vertex color on a GPU. A createLinearGradient-shaped API would allocate an object per cylinder per frame and hand WebGL something it cannot honour.

stroke(xy: Float64Array, count: number, closed: boolean, color: Rgba, width: number, dash?: number, dashOffset?: number): void

Stroke a polyline, optionally closed, with round joins and caps.

dash and dashOffset are per call and not state. Marching ants on a placement ghost are the one place the kit needs a dash, and a dash pattern left set on a shared context is the bug that draws every subsequent outline dotted.

ellipse(cx: number, cy: number, rx: number, ry: number, fill: Rgba): void

An axis-aligned filled ellipse — cylinder caps, glow cores, bubbles.

softEllipse(cx: number, cy: number, rx: number, ry: number, inner: Rgba, outer: Rgba): void

An ellipse with a radial falloff from inner at the center to outer at the rim.

The single most load-bearing call in the kit's look: it is the contact shadow that grounds a building and the halo on a glow dot. A primitive rather than a gradient object because a gradient object is an allocation per shadow per frame — the source game made one — and because on a GPU this is one quad and a ramp texture.

The color pair is a cache key, and a backend is allowed to snap it. This is the one thing about this call that is not obvious from its signature, so it is stated here rather than left in a backend: a falloff cannot be drawn per call at a sane price, so both backends that rasterize render one small ramp per (inner, outer) pair and reuse it. Canvas2D snaps each channel of both colors to 32 levels before it looks the pair up and before it renders it, which is the resolution a 64-pixel ramp has anyway. Three consequences, and the middle one is the reason this paragraph exists:

  • 0 and 255 are exact, so a rim at alpha 0 is transparent and an opaque core is opaque. Nothing rings.
  • Animate a color as freely as you like. A flame core mixed against noise every frame, or an alpha that is a continuous function of a ripple's age, costs nothing: a moving endpoint visits at most 32 keys per channel and then hits for ever. You do not have to quantize in your own art code, and if you already did, you can stop.
  • Two calls whose colors differ by less than a thirty-second of a channel paint the same pixels. A two-second fade steps 32 times rather than 120; on a soft falloff that is not visible, but if you need a hard edge to move smoothly you want Surface.ellipse, which is exact.

What is still worth knowing: animating both endpoints along independent paths multiplies the pairs rather than adding them. Move one end, or move the radius — which is what the eye tracks — and the pair count stays flat. A backend that recorded rather than rasterized keeps the colors you passed, so a golden test sees your values and not the snapped ones.

And "do not animate a color" is not the whole of it, because your palette counts as one. A day cycle running Palette.lerp 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 thing, with nothing at any call site that looks like an animation. An exhibit found this with no flickering light anywhere in it and 27% of its soft ellipses missing. The snap above absorbs most of it, and Palette.lerp absorbs the rest by quantizing t and bumping Palette.rev only when the quantized step moves — which is the same defense one layer up, and the reason to prefer lerp over re-deriving a palette from a continuous t yourself.

text(value: string, x: number, y: number, style: TextStyle, color: Rgba, xform?: Float64Array): void

Draw a text run, optionally through a 2×3 affine transform [a,b,c,d,e,f] mapping local (x, y) to (a·x + c·y + e, b·x + d·y + f).

The transform argument exists only because text on a vertical face has to shear into the isometric plane, and it is deliberately not a transform stack: the solids are already computed in screen space, so nothing else in this package wants one, and a stack invites a save/restore imbalance across a frame boundary.

measure(value: string, style: TextStyle): number

Advance width in CSS pixels.

Backends disagree here and are allowed to. The recording backend has no fonts and estimates — see ESTIMATED_ADVANCE_RATIO. A golden test may assert that text was shrunk to fit; it may not assert where a glyph landed.

alpha(multiplier: number): number

Set the multiplier applied to the alpha of every subsequent call, and return the previous value.

const prev = s.alpha(0.34); …; s.alpha(prev); — a save/restore with no stack, no object, and no way to leave one unbalanced across a frame boundary, because begin() resets it to 1 regardless of what the last frame did.

It sets; it does not compose. A nested caller that wants both multipliers passes their product — s.alpha(outer * inner) — and restores the outer one afterwards. Composing here instead would make the restore call itself compound, and a ghost inside a ghost would fade to nothing over a few frames for reasons nothing in a stack trace could explain.

blit(source: Bitmap, dx: number, dy: number, dw: number, dh: number, mode?: BlitMode): void

Draw a bitmap this kit produced. The only way an image reaches the screen.

Implementations must snap dx/dy to whole device pixels: a cached sprite drawn at dx = 41.3 resamples, and the whole campus then shimmers against terrain that is drawn directly.

createTarget(width: number, height: number, mode?: TargetMode): RenderTarget

A sibling surface that renders into memory: an offscreen canvas, an FBO, a nested log.

This is what makes thumbnails, the light buffer and golden tests one mechanism instead of three, and it is why Surface is an interface rather than a class.

RenderTarget interface ↳ src/surface.ts:294

interface RenderTarget extends Surface {

A Surface that renders into memory and hands back the result.

1 member
readonly bitmap: Bitmap

The finished image. Valid only after Surface.end; reading it before is undefined.

Pen interface ↳ src/surface.ts:314

interface Pen {

A frame's worth of context, so a primitive takes coordinates and not plumbing.

One Pen is allocated per frame. That — plus the FrameOpts literal the caller writes — is this package's entire per-frame allocation: two objects, not two per sprite.

9 members
readonly surface: Surface

Where the drawing goes. Never a canvas; see Surface.

readonly camera: Camera

The transform. draw reads it and never moves it — panning is input's.

readonly palette: Palette

Slot → color for this frame. Its rev is what keeps any cache honest.

readonly t: number

Seconds since the session began. The only clock in this package, and it arrives here as a parameter — nothing under src/ reads one.

readonly xy: Float64Array

Scratch vertex buffer, owned by the pen and reused by every primitive on it.

This is the anti-garbage mechanism, stated as a field so a builder cannot miss it: a box computes its corners into xy and hands (xy, n) to the surface. The source game's pt() returned {x, y} per corner — seven objects per box per frame, four hundred buildings, sixty times a second. Never retain a reference to this array, and never hold a value read out of it across a call that might write to it.

readonly light: LightField | undefined

The light accumulator for this frame, if the game has one.

drawSprite reads it to run a sprite's emit hook. undefined means the game has no night, and every light in the kit then costs nothing at all rather than a little.

readonly snapX: number

The device-pixel snap offset, added to every screen coordinate this pen produces.

iso computes the camera in continuous world space and declines to round. draw rounds, because draw is the package touching a device. beginFrame projects the world origin, takes its position in device pixels, and stores the correction that lands it on a whole one; every primitive then adds (snapX, snapY) to each corner.

Two adds per point buys: 1 px strokes that stay 1 px instead of shimmering between one and two across a pan, blits that land on pixel boundaries, and terrain seams that do not open and close. Because the offset is uniform, every geometric relationship — and every hit test computed from the unsnapped camera — survives exactly.

readonly snapY: number

See Pen.snapX.

readonly snap: boolean

Whether FrameOpts.snap asked for whole-device-pixel snapping — the option, read back off the pen it configured.

snapX === 0 is not the same answer. Zero is also what an origin that already lands on a whole device pixel produces, so a caller reading the offsets to find out whether snapping is on gets true for most of a pan and false for the frames it happens to line up on. That is the shadow-copy failure non-negotiable 11 exists to remove, arriving as a derived value rather than as a second variable: the information is genuinely not recoverable from what was already exposed, so it is exposed under its own name.

A sub-pen always snaps and reports true; it is drawing into its own target, where there is no cinematic pan to keep continuous.

FrameOpts interface ↳ src/surface.ts:383

interface FrameOpts {

What a frame needs to start. Named fields rather than positional, because the sixth is an optional LightField and nobody should have to count commas to reach it.

Every field that survives the call reads back off the Pen it made, under its own name: surface, camera, palette, t, light, snap. clear is the one exception and it is an honest one — it is painted and then gone. Nothing retains it, and a getter would have to invent a value out of pixels that any subsequent draw has already covered.

7 members
readonly surface: Surface

Where the frame lands.

readonly camera: Camera

The transform for this frame.

readonly palette: Palette

Slot colors for this frame.

readonly t: number

Seconds since the session began. From loop; this package never reads a clock.

readonly clear?: Ink

Painted over the whole surface first. Omit to keep what is already there — which is what a render target filling a sprite wants, and what a full-screen frame never does.

readonly light?: LightField

Attach a night. Omit and every light in the kit costs nothing.

readonly snap?: boolean

Whole-device-pixel snapping. Default true.

Off costs a sub-pixel shimmer and buys perfectly continuous motion, which matters for a slow cinematic pan and for nothing else. At pixelRatio 2 the snap is at most half a CSS pixel of position error, which is why it is on by default and why turning it off is a deliberate act rather than a default someone drifted into.

beginFrame function ↳ src/surface.ts:441

function beginFrame(opts: FrameOpts): Pen

Open a frame: clear the surface and build the pen every primitive is handed.

Throws

RangeError if t is not finite. A NaN clock does not throw anywhere downstream; it turns every animated position into NaN and paints an empty screen, which is reported as "the game went black" with nothing in the console.

endFrame function ↳ src/surface.ts:467

function endFrame(pen: Pen): void

Close the frame. A batching backend flushes here; Canvas2D does nothing, and calling it anyway is what stops a game from having to know which backend it has.

subPen function ↳ src/surface.ts:482

function subPen(pen: Pen, surface: Surface, camera: Camera): Pen

A pen onto a different surface and camera, sharing this one's palette and clock.

How a thumbnail, a cache fill and a minimap are drawn by exactly the code that draws the world. It gets its own scratch buffer, so a sub-pen may be used inside a draw call without the outer call's half-built polygon being overwritten underneath it.

It carries no light field. A sprite drawn into a thumbnail must not post a pool into the frame's night mask — the pool would appear in the valley, at the sprite's world position, because a shop card was open.

canvas2d5 symbols

The browser backend. @browser-only — this module touches the DOM, and it is the only one in the package that does.

Everything above it works through Surface, so nothing else in this package, and nothing in any package above it, ever holds a CanvasRenderingContext2D. That is what lets the same sprite code paint the screen, a shop thumbnail and a golden test in Node.

Three things this backend does that a naive one does not

  1. It owns the device pixel ratio, once. The backing store is css × ratio and the context carries a single setTransform(ratio, 0, 0, ratio, 0, 0); every coordinate that crosses Surface is CSS pixels. The source game set the ratio transform on resize and re-applied it in its wall-text routine — correct only because both places agreed, and one edit from a half-scale campus.
  2. It caches the radial ramp. softEllipse is the contact shadow under every building, so a createRadialGradient per call is an allocation per building per frame. Instead one small offscreen ramp is rendered per color pair and blitted, which is also exactly what a GPU backend would do with a ramp texture. The two properties that make that a cache rather than a slow allocator are written on RAMP_LEVELS and RAMP_LIMIT, and both are there because the first version of this cache shipped without them: the key is snapped to the resolution the ramp actually has, so a color that moves every frame still lands on a handful of keys, and a full cache evicts one entry rather than all of them, so one animated call site cannot delete every other call site's work.
  3. It resets its own state on begin. setLineDash, globalAlpha, font, globalCompositeOperation and lineJoin left set are the classic Canvas2D leaks: the next caller inherits them and the symptom appears somewhere unrelated to the cause. There is no save/restore anywhere in this file, and therefore no imbalance to leave across a frame.

Canvas2dOpts interface ↳ src/canvas2d.ts:51

interface Canvas2dOpts {

How a screen surface is configured.

Every field reads back off the surface it configured, per non-negotiable 11: pixelRatio as Surface.pixelRatio and alpha as OffscreenSurface.hasAlpha. maxPixelRatio is the one that does not, and the reason is written on it.

3 members
readonly pixelRatio?: number | undefined

Override the device pixel ratio outright. Tests and thumbnails pin it to 1, which is what makes a thumbnail byte-identical across machines.

Reads back as Surface.pixelRatio — the ratio in force, which is this value if it was given and the clamped device ratio if it was not, and which resize then moves.

readonly maxPixelRatio?: number | undefined

Clamp for devicePixelRatio. Defaults to 2: a 3× phone costs 2.25× the fill for a difference nobody can see on a five-inch screen, and it is the single cheapest frame-time win available on the hardware that needs one most.

This one is deliberately not readable, and that is not an oversight. It does not survive its constructor: it is consumed once to pick the opening ratio and nothing reads it again — resize(w, h, ratio) takes a ratio and walks straight past this clamp. A getter over it would report a bound the surface does not enforce, which is the stale-local loophole docs/rfc/live-options.md §6b names, and a caller who trusted it would size a buffer against a ceiling that is not there. It becomes readable in the same change that makes it live — that RFC's finding 4, which is what makes resize honor it — and not before.

readonly alpha?: boolean | undefined

false lets the compositor skip a blend. Defaults to false — the kit always clears. Reads back as OffscreenSurface.hasAlpha.

OffscreenOpts interface ↳ src/canvas2d.ts:81

interface OffscreenOpts {

How a detached surface is configured. Both fields read back off the surface: Surface.pixelRatio and OffscreenSurface.hasAlpha.

2 members
readonly pixelRatio?: number | undefined

Default 1. A thumbnail pinned to 1 is byte-identical across machines, which a test wants and a shop card does not care about.

readonly alpha?: boolean | undefined

Default true: a thumbnail with an opaque background cannot sit on a card.

OffscreenSurface interface ↳ src/canvas2d.ts:103

interface OffscreenSurface extends Surface {

A Surface backed by a <canvas> element — both factories in this file return one, the detached thumbnail and the screen alike.

The same seam as createRecordingSurface, pointed at a browser instead of at Node: one Surface interface, three places it can end up — a screen, a memory image, an op log — and one body of drawing code that cannot tell which. It is what stops a shop card and the building it sells from ever drifting apart.

The name says offscreen because for most of this package's life only the detached factory declared it. It is the canvas-backed surface type; a screen surface has an element and a toDataUrl for the same reason a thumbnail does, and it needs OffscreenSurface.hasAlpha so that a caller can read back what they configured.

4 members
readonly kind: 'canvas2d'

Narrowed, so a caller holding one knows it can reach OffscreenSurface.element.

readonly element: HTMLCanvasElement

The backing element. Prefer this: it can be appended, or drawn into another surface, with no encode and no decode.

readonly hasAlpha: boolean

Whether the backing context was opened with an alpha channel — Canvas2dOpts.alpha and OffscreenOpts.alpha, read back off the surface they configured.

It is hasAlpha and not alpha because alpha is taken, by Surface.alpha, which sets the multiplier applied to subsequent draws. Two different meanings of one word, and non-negotiable 11's "a getter of the same name" loses to that: a boolean channel flag sharing a name with a number-returning method is a collision the compiler catches once and a reader trips over forever. Where the name is unavailable the rule's second form applies — the value is readable, under a name that says which alpha it means.

It has no setter and cannot have one. getContext('2d', { alpha }) fixes the channel for the element's lifetime; a second getContext with different attributes returns the first context, ignoring them silently. So this is identity in the sense of docs/rfc/live-options.md §4 Q1 — the honest signature for changing it is a new surface — and identity still means readable, which is what this getter is for. A caller compositing the canvas against a page background needs to know which it got, and guessing from the default is how a thumbnail ends up with a black rectangle behind it.

toDataUrl(type?: string, quality?: number): string

A data: URL of the current contents.

Roughly a third larger than the bytes it encodes and it costs a synchronous encode, so it earns its place only when the caller is caching the string across DOM rebuilds — which is exactly what a shop card does.

createCanvas2dSurface functionstart here ↳ src/canvas2d.ts:676

function createCanvas2dSurface(canvas: HTMLCanvasElement, opts?: Canvas2dOpts): OffscreenSurface

Wrap a canvas element.

Sizes the backing store from clientWidth/clientHeight × the pixel ratio, and re-applies that on resize; callers work in CSS pixels and never see the ratio. An element that is not in the document yet has no client size, so its width/height attributes are used instead and a later resize picks up the real one.

Returns an OffscreenSurface rather than a bare Surface so that the two things a caller configured here are readable off the thing they configured: the ratio in force as pixelRatio, and the alpha channel as alpha. element and toDataUrl come with that type and are both meaningful on a screen canvas — the second is how a game screenshots itself.

Throws

RangeError if an explicit pixelRatio or maxPixelRatio is not finite and positive.

Throws

Error if the element has no 2D context.

createOffscreenSurface function ↳ src/canvas2d.ts:701

function createOffscreenSurface(width: number, height: number, opts?: OffscreenOpts): OffscreenSurface

A detached surface of a fixed size — the one ui needs for shop thumbnails.

Always a detached <canvas>, never an OffscreenCanvas, and that is deliberate: OffscreenCanvas has no toDataURL, only an async convertToBlob, and an async thumbnail is a shop card that pops in one frame late every time it is opened.

Throws

RangeError if either dimension or pixelRatio is not finite and positive.

record6 symbols

The headless backend: no DOM, no canvas, runs in Node.

What a test backend should record: draw commands, not pixels

A rasteriser in Node would need a font stack, an antialiasing policy and most of this package's byte budget, and it would produce an image whose diff says "412 pixels changed". A command log says poly[2].fill: 0xc9553fff → 0xc95540ff, which is a bug report. Pixel exactness is not what golden tests here are protecting; the shape of the draw is.

That is also why this is src/ and not test/: ui wants it for layout measurement without a canvas, and putting it in canvas2d.ts would drag HTMLCanvasElement into the one import a Node test must be able to make.

Two properties a golden test rests on

  • Coordinates are CSS pixels, so the same scene at pixelRatio 1 and 2 records identical numbers. A backend that multiplied by the ratio itself would make every golden ratio- specific, and the failure would look like a rendering change.
  • createTarget returns another recording surface, and its digest is what the parent's blit op records. A cached sprite's contents are therefore covered by the parent's digest rather than vanishing behind an opaque image.

OpName type ↳ src/record.ts:39

type OpName = 'clear' | 'poly' | 'polyRamp' | 'stroke' | 'ellipse' | 'softEllipse' | 'text' | 'blit' | 'alpha'

The nine calls a surface can record. resize, end and createTarget are structural and produce nothing to compare.

Op interface ↳ src/record.ts:71

interface Op {

One recorded call, rounded to three decimal places on the way in.

A golden that fails on the last bit of a float is a golden everyone learns to re-bless without reading, which is strictly worse than not having one.

The xy layout is per op, and it is documented here because reading a failed golden is the whole point of this backend:

opxycolorsvaluetext
clearthe clear color0
polythe pointsfillpoint count
polyRampthe points, then x0,y0,x1,y1from, topoint count
strokethe pointscolorline widthopen/closed, and the dash
ellipsecx,cy,rx,ryfillrx
softEllipsecx,cy,rx,ryinner, outerrx
textx,y,size,weight,align,baseline, then the 6 transform valuescolorem sizethe string
blitdx,dy,dw,dhdwthe mode and the source's digest
alphathe new multiplier
5 members
readonly op: OpName

Which call this was.

readonly xy: readonly number[]

The numbers, laid out per the table on Op.

readonly colors: readonly Rgba[]

The colors, in argument order.

readonly value: number

The scalar the op carries: stroke width, alpha multiplier, blit width.

readonly text: string

Empty except for text, stroke and blit.

ESTIMATED_ADVANCE_RATIO const ↳ src/record.ts:96

const ESTIMATED_ADVANCE_RATIO = 0.55

Advance width per point of font size, used by Surface.measure where there are no fonts.

Public because it is the reason a wall sign's shrink-to-fit lands differently in Node than in Chrome, and a test author who does not know that will write a flaky golden. Assert that the shrink branch ran; never assert where a glyph landed.

RecordingSurface interface ↳ src/record.ts:112

interface RecordingSurface extends Surface {

A recording surface, and the extra half of the contract a test reads.

This is the one place in the kit permitted to allocate freely, because it never runs in a frame: an Op per call, an array per surface, and a fresh string per digest.

4 members
readonly kind: 'recording'

Narrowed, so surface.kind === 'recording' discriminates in a caller's own union.

readonly ops: readonly Op[]

Every call since the last RecordingSurface.reset, in order, readable in a test failure.

digest(): string

A stable hash of RecordingSurface.ops — the value a golden file stores. Eight hex digits, and it changes when anything in the draw does.

reset(): void

Drop every recorded op. begin() deliberately does not do this, so a test can record several frames and compare them.

RecordingTarget interface ↳ src/record.ts:128

interface RecordingTarget extends RecordingSurface, RenderTarget {

A recording surface used as a render target: the same log, plus the bitmap handle a blit takes and the mode it accumulates in.

2 members
readonly kind: 'recording'

Narrowed again, because RenderTarget widens it back to SurfaceKind and an interface may not inherit two different answers to the same question.

readonly mode: TargetMode

'light' targets blend by per-channel maximum; 'image' targets paint source-over. The field exists so a test can prove the light field accumulated rather than composited.

createRecordingSurface function ↳ src/record.ts:154

function createRecordingSurface(width: number, height: number, pixelRatio?: number): RecordingSurface

A surface that records draw commands and a digest rather than pixels.

Parameters
pixelRatio

Recorded on the surface and on every bitmap it makes, and applied to nothing — which is the point. Ops are CSS pixels at every ratio.

Throws

RangeError if either dimension is not a finite number greater than zero, or if pixelRatio is not finite and positive. A zero-sized surface silently records a frame nobody can look at.

solids15 symbols

The isometric solid kit: eight primitives, one color each, three faces derived.

No DOM, no canvas — this module runs unchanged in Node. Everything here computes screen coordinates into pen.xy and hands them to a Surface.

Two rules that are the difference between art and programmer art

  1. 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. It is the difference between "reads at 40 px" and "reads as a wireframe".
  2. Faces are derived from one color. There is no leftColor. Offering one is offering the caller a way to break the look, and a kit whose look can be broken by a single call is a kit whose look will be broken.

Heights are storeys here and world pixels in iso

Every height a sprite author writes — BoxOpts.h, BoxOpts.z, isoRoof's rise, isoPost's h — is in storeys, because "three storeys" is what a person means. Every height that crosses into iso is in world pixels. levelsToPx is the one conversion, it runs in one direction, and it happens at the boundary rather than at a call site.

The six-point stroke order is a cross-package contract

isoBox strokes north-top, east-top, east-base, south-base, west-base, west-top — the order iso.boxSilhouette returns. Reverse the winding or start at a different corner and the painted outline still looks perfect, because it is the same hexagon, while the hit polygon is a different hexagon and taps land on the wrong building near the edges. Nothing in either package can see that from the inside.

LEVEL_H const ↳ src/solids.ts:45

const LEVEL_H = 26

World pixels per storey. The only bridge between draw's heights and iso's.

26 rather than 32 on purpose: a storey exactly one tile tall makes every building a cube, and cubes read as programmer art. It is an art proportion, tuned beside FACE_LEFT, and it lives here rather than in iso because iso's entire height vocabulary is world pixels — there is no signature there a storey could enter through.

levelsToPx function ↳ src/solids.ts:50

function levelsToPx(levels: number): number

Storeys → world pixels. The only sanctioned way to produce a zPx for iso — a raw multiply at a call site is how a Volume ends up built in storeys, which makes boxSilhouette return an outline that is nearly right and picking wrong only near a roof.

pxToLevels function ↳ src/solids.ts:69

function pxToLevels(px: number): number

World pixels → storeys. The direction every reading of iso needs.

Everything iso hands back is world pixels — heightAt, footprintBase, Volume.zPx — and every height a sprite author writes is storeys. Without this the divisor appears at every boundary in game code, spelled / 26 on the day somebody forgets the constant exists, and a kit whose art proportion is copied into a game is a kit that cannot change it.

The round trip is not bit-identical and does not need to be. levelsToPx(pxToLevels(px)) differs from px by at most a part in 10¹⁵ — four femtopixels at the tallest elevation this kit can draw, nine orders below one device pixel, and deterministic, because / and * are Tier A and specified exactly. It is still a different number, so anything that must compare equal to an iso elevation rather than merely land on the same pixel — a Volume handed to boxSilhouette — carries the pixels through untouched instead. spriteVolume does.

GROUND_LIFT const ↳ src/solids.ts:78

const GROUND_LIFT = 0.002

The z-fight ladder, in storeys. Anything drawn on the ground must be lifted off it by one of these, in this order, or it flickers against the tile beneath at some zooms and not others — which looks like a hardware bug rather than a missing constant.

BoxOpts interface ↳ src/solids.ts:122

interface BoxOpts {

Everything a box-shaped solid can be told. The only object a primitive takes, and deliberately so: eight positional arguments would be unreadable and every one a number.

readonly throughout and never retained by the kit, so the intended use is a module-level constant reused every frame, and the intended misuse — a fresh literal per building per frame — is one small short-lived object rather than a retained one.

7 members
readonly color: Ink

Base color. The three faces are derived from it; there is no per-face override.

readonly h: number

Height in storeys.

readonly z?: number | undefined

Base height in storeys, so a box can sit on top of another. Default 0.

readonly inset?: number | undefined

Shrink the footprint on all sides, in tiles. Ledges and setbacks. Default 0.

readonly outline?: boolean | undefined

Silhouette stroke. Set false for stacked sub-volumes, which would otherwise double-line along every shared edge and read as a seam. Default true.

readonly topColor?: Ink | undefined

Override the top face only — roofs, solar glass, water. The one sanctioned exception to faces-are-derived.

readonly alpha?: number | undefined

0–1 opacity, for ghosts. Applied to the whole solid, not per face.

isoTile function ↳ src/solids.ts:205

function isoTile(pen: Pen, gx: number, gy: number, fill: Ink, stroke?: Ink, inset?: number, z?: number): void

A single flat tile diamond: terrain, pads, the placement grid.

Parameters
inset

Shrink on all sides in tiles, for a grid whose cells read as separate cells.

z

Height in storeys. Use GROUND_LIFT and its siblings for anything meant to sit on the ground rather than be the ground.

isoPatch function ↳ src/solids.ts:236

function isoPatch(pen: Pen, gx: number, gy: number, w: number, d: number, z: number, fill: Ink, stroke?: Ink): void

A flat quad lying in the ground plane at height z — solar glass, helipads, gravel.

Separate from a zero-height box because a zero-height box still draws two degenerate side faces, and those slivers alias badly at low zoom. Not for windows: a patch lies flat, so using one for a window paints a horizontal sliver hovering in mid-air at window height — which shipped, on every building on the map, in the game this kit came from. Use isoWall.

isoBox function ↳ src/solids.ts:274

function isoBox(pen: Pen, gx: number, gy: number, w: number, d: number, opts: BoxOpts): void

The workhorse: an axis-aligned box on the grid.

Draws left face, right face, top, then one stroke around the silhouette.

The six stroke points are, in order: north-top, east-top, east-base, south-base, west-base, west-top — the order iso.boxSilhouette returns, and this is load-bearing. It is the one genuine coupling between the two packages, and the failure mode is the worst kind: iso hit-tests one polygon, draw paints another, both are internally consistent, every test in both packages passes, and a player taps a building and opens its neighbor.

Throws

RangeError if w, d, opts.h or opts.z is not finite. A NaN here paints nothing and reports nothing, and the building is simply missing.

isoCylinder function ↳ src/solids.ts:391

function isoCylinder(pen: Pen, gx: number, gy: number, radiusTiles: number, opts: BoxOpts): void

An upright cylinder — cooling towers, tanks, silos.

An ellipse cap over a body filled with a horizontal ramp. A swept solid would be more correct and completely indistinguishable at this size; the ramp is what sells curvature.

radiusTiles is measured the same way a light pool is: rx = radiusTiles · HALF_W · zoom, and ry is half of that, because a circle on the ground projects 2:1 like everything else lying flat in this world.

Throws

RangeError if radiusTiles, opts.h or opts.z is not finite.

isoRoof function ↳ src/solids.ts:480

function isoRoof(pen: Pen, gx: number, gy: number, w: number, d: number, z: number, rise: number, color: Ink, outline?: boolean): void

A gabled roof: a prism ridged along the gx axis.

What sheds the "everything is a box" read that flat-topped-only kits fall into. A kit without it produces cities that look like spreadsheets.

The far slope is drawn only while it faces the camera. Past rise · LEVEL_H · 2 ≥ d · HALF_H the ridge projects above the far eave and the slope turns away; painting it anyway would put a wedge of roof above the ridge line, which reads as a hole in the building.

Parameters
z

Base of the roof, in storeys. @param rise Height of the ridge above z, in storeys.

Throws

RangeError if w, d, rise or z is not finite.

isoWall function ↳ src/solids.ts:598

function isoWall(pen: Pen, ax: number, ay: number, bx: number, by: number, z0: number, z1: number, fill: Ink, stroke?: Ink): void

A rectangle on a vertical face — windows, doors, vents, signage, hazard panels.

Takes the two grid endpoints of the wall segment and the two heights it spans, in storeys, so it lands flush on the face rather than hovering in front of it. This is the primitive isoPatch is not: a patch lies flat, and a window drawn with one is a horizontal sliver in mid-air.

It refuses an edge-on wall rather than painting nothing

World x is (gx − gy) · HALF_W and nothing else, so a segment whose gx and gy change by the same amount has a world-x delta of exactly zero: it projects to a vertical line and covers no pixels. Every number involved is finite, the projection is doing precisely what it promises, and the art is simply not there — which is why this is a refusal and not a warning. A run of prayer flags laid along the near-far diagonal cost the demo a full iteration with nothing anywhere saying why, and a warning is a thing an author reads after they have spent the afternoon. iso.isEdgeOn is the predicate; the two tiles are in the message, because the fix is always "run it across the lattice, not into it" and that needs both endpoints.

A zero-length wall is refused by the same test and for the same reason: a point has no width either, and it is the same bug arriving from the other direction.

**A wall with an animated endpoint must not be able to sweep through the diagonal.** This throws on the frame it crosses, which is correct — that frame draws nothing — but it ends the frame rather than dropping one flag, so animate the endpoint on an axis that cannot reach dgx === dgy. A sway added to both coordinates in proportion is the shape that can: it passes through the degenerate point every time it changes sign.

Throws

RangeError if the two endpoints differ equally in gx and gy, naming both.

isoPost function ↳ src/solids.ts:639

function isoPost(pen: Pen, gx: number, gy: number, z: number, h: number, color: Ink, width?: number): void

A thin upright post — antennae, lightning rods, flagpoles, pylons.

Parameters
z

Base in storeys. @param h Height in storeys. @param width Thickness in tiles.

glowDot function ↳ src/solids.ts:703

function glowDot(pen: Pen, gx: number, gy: number, z: number, color: Ink, radius?: number, intensity?: number): void

A glowing point: a hard core inside a soft halo — status LEDs, lit windows, strobes.

A hundred of these sell "operational facility" better than any amount of geometry, and they cost one ellipse and one soft ellipse each.

Round, not squashed, and that is not an oversight: this is a light source in the air seen head-on, where a ground-plane pool — LightField.add — is a flat thing and is 2:1.

intensity at or below 0 draws nothing at all, so a blink is intensity: on ? 1 : 0 and costs nothing on the dark half of its cycle.

terrain1 symbol

The terrain tile: one diamond, four corner heights, and the shading that makes them read.

No DOM, no canvas — this module runs unchanged in Node.

iso ships a heightfield; the rest of this package draws flat things at one z. This module is the whole of what sits between those two facts. isoTile and isoPatch take a single elevation, so a game with relief in it either draws terraces — a staircase, visibly wrong at every zoom — or assembles the quad itself out of gridToScreen and surface.poly. The kit had a Terrain pass and no primitive that fitted one, and the first heightfield exhibit built against it wrote this function into a game file, where the next ten would each have written it again slightly differently.

Heights live on vertices, and that is why this takes a HeightField

heights.get(gx, gy) is the elevation of the north corner of tile (gx, gy)iso's rule, not a convention this module invented. Adjacent tiles therefore share their corner values exactly, and two quads drawn from them cannot leave a seam. A game that sampled a height per tile center and averaged its neighbours would leave hairline gaps that open and close as the camera moves, and no amount of care in this file could close them: the fix is upstream, in which lattice the numbers live on.

The relief term, and the direction the sun comes from

A tile painted at one flat color is a rug with a map printed on it. What turns it into ground is that its color knows which way it tilts — and in a 2:1 projection there is exactly one tilt worth measuring.

The four corners project to four screen points, and east and west land on the same screen row: screen y runs with gx + gy, which is gx + gy + 1 at both of them. They are also the two extremes in screen x. So the east→west difference is the tile's slope along the *screen horizontal*, and every other combination of corners is some mixture of that and a slope the projection cannot show.

That axis is also the sun's. This kit lights from the front-left: FACE_LEFT — the +gy face, screen-left — is brighter than FACE_RIGHT, the +gx face, and ROOF_NEAR is brighter than ROOF_FAR for the same reason. A ground plane is lit in proportion to how much its normal points -gx, +gy, which happens exactly when its height rises toward the east corner. Hence east − west, and hence a slope descending toward screen-left is the bright one.

The sign is the part that is easy to get wrong and impossible to see. Inverted, terrain still looks like terrain — it looks like terrain lit from the right — while every building standing on it is lit from the left, and the picture reads as flat for a reason no screenshot names. The exhibit this module was extracted from had it inverted.

isoTerrain function ↳ src/terrain.ts:104

function isoTerrain(pen: Pen, field: HeightField, gx: number, gy: number, fill: Ink, stroke?: Ink, tint?: number): Rgba

One terrain tile, drawn on its own four corner heights.

The quad is (gx, gy), (gx+1, gy), (gx+1, gy+1), (gx, gy+1) — north, east, south, west, iso's order — each lifted by the height field's own value at that vertex. The fill is fill shaded by tint plus the relief term this module's header derives.

Returns the color it actually painted, because a caller almost always needs it: a second pass over the same tile — a water glint, a wetness wash, the hairline seam below — has to be a relative of the tile's own hue or the ground stops being one surface. Returning it is also what stops a game from recomputing the relief itself and drifting away from what was drawn.

The four projected corners are left in pen.xy[0…7], so that second pass costs no projection at all: pen.surface.poly(pen.xy, 4, glint) covers exactly this tile. Like every other use of that buffer, the values survive only until the next primitive writes to it.

Parameters
tint

A multiplier on fill before relief is added. This is where a game folds in its own texture — a coarse patchwork of fields, a per-tile grain, a wetness — so that the kit's relief and the game's noise compose inside one shade call. Two shade calls in series is not the same color: shade pulls toward a cool or a warm tint by distance from neutral, so shading twice tints twice and the ground goes muddy. Default 1 — relief alone.

stroke

A seam around the whole tile. Omitted, there is none; a game that wants the two-edge hairline that reads as a fold in turf strokes pen.xy itself with three points.

Throws

RangeError if tint is not finite. A NaN here paints a tile that is silently absent, and a hole in terrain is read as a missing chunk rather than as a bad number.

shadow2 symbols

The subtractive half of the lighting: what grounds a building, and what dims a whole frame.

No DOM, no canvas — this module runs unchanged in Node.

Both operations here are per object and immediate. That is the line between this module and light.ts, whose field is per frame, accumulated into its own buffer and composited once. Folding the two together would put two opposite lifecycles in one file, and the accumulate-then-composite shape is the only one in which two pools of light meet without a seam — so the merge would quietly cost the demo its premise.

contactShadow function ↳ src/shadow.ts:45

function contactShadow(pen: Pen, gx: number, gy: number, w: number, d: number, strength?: number, z?: number): void

A soft contact shadow under a footprint.

One softEllipse, not a blurred copy of the silhouette: a real drop shadow costs a filter pass per building and buys nothing at this scale. Grounding is the whole point — without it, buildings look pasted onto the grass, and no amount of detail on the buildings fixes it.

The ellipse is 2:1 like everything else lying flat in this world. strength at or below 0 draws nothing, so a building that is being carried by a crane simply passes 0.

Parameters
z

The ground the shadow lands on, in storeys — like every other height in this package, and unlike iso, whose elevations are all world pixels. Without it every shadow is painted at sea level, so on a heightfield the building climbs the hill and its shadow stays in the valley: the one part of a sprite whose whole job is to say the object is here is then the one part pointing somewhere else. A ground elevation read out of iso is pixels — convert it with pxToLevels, or let drawSprite do it, which is where a sprite's ground crosses over exactly once.

wash function ↳ src/shadow.ts:83

function wash(pen: Pen, color: Ink): void

A full-viewport wash — dusk tint, brownout, pause dim. One quad.

Screen space, so it takes no camera and is unaffected by the snap: it covers the surface exactly regardless of where the world is. Call it from the Overlay or Effects pass; calling it from Solids paints it under the buildings drawn after it, which looks like the wash simply failed to apply.

Not a substitute for a LightField. A wash has no edge, and "you can see exactly where the light stops" is a statement about edges.

text4 symbols

Text in two places: sheared onto a wall, and flat on the screen.

No DOM, no canvas — this module runs unchanged in Node. It computes a 2×3 affine transform and hands it to a Surface; who owns a font is the backend's problem.

Why wall text exists at all

A sign is often the only place a player's own choice — a company name — appears in the world. A blank tinted panel there is not a missing polish item; it is the game breaking a promise about the one thing the player personally chose.

The two corrections, both of which shipped wrong once, and how they are spelled here

The obvious transform maps the wall's parameter square — 0…1 along the segment, 0…1 down the face — onto the wall. That basis is anisotropic: its x axis is scaled by the segment's screen length and its y axis by the band's, and those two numbers are nothing like each other. Every glyph comes out stretched sideways and the sign reads as a stretched bitmap. The fix as the trap states it is two lines:

  1. squeeze the along-axis by min(1, downLen / alongLen), restoring the letterform while keeping the shear;
  2. divide that same factor back out of the centring x, because it is in local space and the transform is about to scale it — miss this and the sign slides off its own board, which looks like a layout bug rather than a transform bug.

This module applies both at once, by normalising the basis instead of patching it. Both columns of the transform are unit vectors, so one local unit is one screen pixel along either axis, and the anchor is given in screen lengths (alongLen / 2, downLen / 2) rather than in parameter space. That is exactly corrections 1 and 2 composed with a uniform rescale, and the font size then absorbs the rescale for free.

It is sound because of a property of this projection specifically: an axis-aligned vertical face is sheared but not foreshortened. One world pixel along the wall is one screen pixel, and one world pixel up the wall is one screen pixel, so a unit basis is the wall's own metric and not an approximation of it. Applying the squeeze on top of a normalized basis — the mistake this note exists to prevent — squashes the text horizontally on every long wall.

DEFAULT_TEXT const ↳ src/text.ts:52

const DEFAULT_TEXT: TextStyle

The kit's default text run: a system stack, semibold, centered both ways.

Semibold rather than regular because every string this kit draws is either a name on a building at thumbnail size or a number over a rooftop, and regular weight disappears against a busy background at both.

MIN_WALL_TEXT_PX const ↳ src/text.ts:67

const MIN_WALL_TEXT_PX = 12

Below this many CSS pixels of wall height, glyphs are mush and wallText draws nothing at all.

Drawing them anyway is what gives a zoomed-out campus a rash of gray smears, which reads as a rendering artifact rather than as text that is too small.

wallText function ↳ src/text.ts:111

function wallText(pen: Pen, ax: number, ay: number, bx: number, by: number, ztop: number, heightLevels: number, value: string, color: Ink, style?: TextStyle): void

Text painted onto a vertical face, sheared into the isometric plane.

The segment (ax, ay) → (bx, by) is in grid coordinates and runs along the wall; the band hangs from ztop down by heightLevels, both in storeys like every other height in this package. The text is centered in the band and shrunk to fit if it would overrun the segment.

Backends disagree about measure — the recording surface has no fonts and estimates — so a golden test may assert that the shrink branch ran and may not assert where a glyph landed.

Draws nothing when the band is shorter than MIN_WALL_TEXT_PX on screen, when the segment has zero length, or when value is empty.

screenText function ↳ src/text.ts:176

function screenText(pen: Pen, sx: number, sy: number, value: string, color: Ink, style?: TextStyle): void

Unsheared text at a screen pixel — floating numbers, timers, debug readouts.

Never world-space. Anything that has to stay attached to a thing in the valley belongs in the Overlay pass with its position projected by the caller, because a label that scales and shears with the world stops being readable at exactly the zoom the player uses to look at a lot of things at once.

sprite18 symbols

How a game defines its own building without forking the kit.

No DOM, no canvas — this module runs unchanged in Node.

The source game's answer to this question was one hand-written function per building type, in kit source, which is a fork by construction. The answer here is a SolidWriter: an emitter a game writes its massing against once, which the kit replays through three different consumers.

replayed throughgives you
a writer bound to a Penthe building, drawn
a writer bound to a RenderTarget — a sub-penthe shop thumbnail
a writer that only unions cornersspriteBounds and spriteVolume, for free

Not a data schema: a Solid[] array is serialisable and cannot express "four posts in a loop, and a mast only above level 2" without growing a small interpreter. Not a bare draw callback either: a callback can be drawn and nothing else. An emitter is written like code and is still replayable.

The two hooks, and why they are separate

massing is static art and animate is live art. The split enforces rule three of the source game's art direction structurallysomething moves on every building — in a slot that is named and separate rather than as a thing an author remembers to add. It is also what would make a bitmap cache tractable if one were ever needed: a building whose blinking LED was baked in would be a building that stops blinking, which is a worse bug than a slow frame.

Determinism is structural here, not documented

massing receives (writer, variant, rng) and nothing else. There is no channel through which unkeyed state can reach the art: no surface to reach for, no clock, and an Rng the kit seeds from variant.seed on every call. A rack cannot reshuffle its LEDs on reload, and a replay from a seed lands on the same pixel. A closure over a game object defeats all of that in one line, and the signature is the only thing standing in its way.

Variant interface ↳ src/sprite.ts:73

interface Variant {

The instance facts the static art may depend on — and therefore everything that would ever belong in a cache key.

massing receives this and nothing else, which is what makes staleness impossible rather than unlikely.

5 members
readonly level: number

Upgrade level. Massing may branch on it freely.

readonly seed: number

Per-instance determinism. Seeds the Rng the kit hands to every hook.

readonly flags: number

Bitfield — see FLAG_*. Anything boolean about an instance goes here, not in a closure, because a closure is exactly the channel this type exists to close.

readonly progress: number

0–1 construction progress.

readonly label: string

Instance text — a company name on a roof sign. Empty string when unused, never absent, so a massing never has to test for it and a sign never renders undefined.

VARIANT_ZERO const ↳ src/sprite.ts:89

const VARIANT_ZERO: Variant

A finished, powered, unnamed instance. The variant a test uses and a sprite falls back to.

SolidWriter interface ↳ src/sprite.ts:117

interface SolidWriter {

The emitter a game's massing is written against.

One method per solid, with the same arguments as the free functions in solids minus the pen — because the writer may not be drawing. Nothing here reads back, returns geometry, or exposes the surface: a massing function that could reach the surface could defeat both the measuring replay and the WebGL seam in one line.

Coordinates are relative to the footprint origin, so a sprite is drawn anywhere without knowing where. Heights are in storeys, like everything a sprite author writes.

11 members
readonly palette: Palette

The frame's palette, for a massing that genuinely has to branch on a color.

A measuring replay has no frame, so it sees the kit's BASE_SLOTS rather than the game's live palette. Branch on Variant, which is guaranteed identical in both replays; branching on a color here means spriteBounds can disagree with the pixels.

tile(gx: number, gy: number, fill: Ink, stroke?: Ink, inset?: number, z?: number): void

A flat tile diamond. See isoTile.

box(gx: number, gy: number, w: number, d: number, opts: BoxOpts): void

The workhorse box. See isoBox.

cylinder(gx: number, gy: number, radiusTiles: number, opts: BoxOpts): void

An upright cylinder. See isoCylinder.

roof(gx: number, gy: number, w: number, d: number, z: number, rise: number, color: Ink, outline?: boolean): void

A gabled roof. See isoRoof.

patch(gx: number, gy: number, w: number, d: number, z: number, fill: Ink, stroke?: Ink): void

A flat quad at height z. See isoPatch.

wall(ax: number, ay: number, bx: number, by: number, z0: number, z1: number, fill: Ink, stroke?: Ink): void

A rectangle on a vertical face. See isoWall — and not patch, which lies flat.

post(gx: number, gy: number, z: number, h: number, color: Ink, width?: number): void

A thin upright post. See isoPost.

glow(gx: number, gy: number, z: number, color: Ink, radius?: number, intensity?: number): void

A glowing point. See glowDot. This is the fixture; the light it throws into the night is SpriteDef.emit.

sign(ax: number, ay: number, bx: number, by: number, ztop: number, heightLevels: number, value: string, color: Ink): void

Text sheared onto a vertical face. See wallText.

shadow(gx: number, gy: number, w: number, d: number, strength?: number, z?: number): void

The contact shadow that grounds the sprite. See contactShadow.

z is a storey height above the sprite's own ground, like every other height here, and defaults to 0 — which is the ground itself, wherever drawSprite was told that is. A massing therefore never names its elevation to get its shadow in the right place, and a sprite drawn on a hillside cannot cast into the valley by omission.

Massing type ↳ src/sprite.ts:196

type Massing = (w: SolidWriter, v: Variant, rng: Rng) => void

Static art. Runs on every direct draw and would run on a cache miss only.

rng is freshly seeded from v.seed by the kit on every call, and is its own stream — adding a draw here cannot reshuffle what Animator sees.

A massing is not told its ground elevation, and that is deliberate. The writer already stands on it — every z here is measured from wherever drawSprite put the sprite — so a massing has nothing to do with the number. Handing it over would let one branch on it, and a massing that branched on its elevation would measure differently in spriteBounds, which replays it with no frame and therefore no ground: the same failure, for the same reason, as branching on a color.

Animator type ↳ src/sprite.ts:212

type Animator = (pen: Pen, gx: number, gy: number, v: Variant, rng: Rng, zPx: number) => void

Live art over the static image, every frame. A handful of primitives, no more; pen.t is the only clock, and it arrived as a parameter.

zPx is the sprite's ground elevation in world pixels — the number drawSprite was given, passed straight through. An animator draws through the free primitives rather than through a writer, so nothing can stand it on the ground for it: convert once with pxToLevels and add the result to the storey heights, exactly as the massing's are already offset. Skip it and the flame burns at sea level while the lamp it belongs to is up the hill.

It is the last parameter rather than beside gx and gy, where it belongs, because the shape of this callback is a shipped contract: inserting it would silently rebind v in every animator ever written and every one of them would have to be edited on the same commit.

Emitter type ↳ src/sprite.ts:232

type Emitter = (field: LightField, gx: number, gy: number, v: Variant, rng: Rng, zPx: number) => void

Emissive contribution. Runs only when an active LightField is attached to the frame, so a game in daylight pays nothing for the lamps it is drawing.

zPx is the sprite's ground elevation in world pixels, which is exactly what LightField.add wants for its own third argument — the field pools light on the ground under the fixture, so a lamp on a terrace lights the terrace. No conversion happens on this path in either direction: iso produced the pixels and iso's unit is what the light field speaks.

Last, for the reason Animator gives.

SpriteDef interface ↳ src/sprite.ts:242

interface SpriteDef {

A sprite: a footprint, static art, and two optional live hooks.

6 members
readonly id: string

Stable across releases: it belongs in every golden file and in any key anything ever builds from a sprite. Renaming one is a content migration, not a refactor.

readonly w: number

Footprint width in tiles. Must match the game's own footprint or the shadow, the depth sort and the pixels disagree about where the building is.

readonly d: number

Footprint depth in tiles. See SpriteDef.w.

readonly massing: Massing

The static art.

readonly animate?: Animator | undefined

Live art over it.

readonly emit?: Emitter | undefined

Light this sprite throws into the frame's LightField, if there is one.

Kept separate from animate because it runs at a different time and into a different buffer, and separate from massing because light is never static. This is the answer to "how does a lamp's radius reach the night mask without the mask knowing what a lamp is": it does not — the lamp posts a pool, and a pool is a position, a radius, an intensity and a color.

defineSprite function ↳ src/sprite.ts:269

function defineSprite(def: SpriteDef): SpriteDef

Identity at runtime. It exists to give a sprite literal a contextual type at the call site, so massing(s, v, rng) gets its parameter types without the author naming three of them.

drawSprite function ↳ src/sprite.ts:738

function drawSprite(pen: Pen, def: SpriteDef, gx: number, gy: number, v: Variant, zPx?: number): void

Draw a sprite at a grid position.

Runs massing, then animate, then emit — the last only when the frame has an active light field, so a game in daylight pays nothing for a campus full of lamps.

Each hook is handed its own freshly-rewound stream, derived from v.seed. Adding a draw to one therefore cannot change what another sees, which is the difference between a sprite whose art is stable across a refactor and one that has to be re-blessed after every edit.

Parameters
zPx

The ground elevation under the footprint, in world pixels — iso's unit, and usually footprintBase(field, footprint) or heightAt(field, gx, gy) straight from a heightfield. Default 0, which is a flat world and costs a game with one nothing.

This is the one place a sprite's ground crosses from pixels into storeys, and it is the whole of the crossing: the massing is drawn from it, the contact shadow lands on it, and animate and emit are handed the original pixels. Leave it out 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.

Throws

RangeError if zPx is not finite.

drawGhost function ↳ src/sprite.ts:776

function drawGhost(pen: Pen, def: SpriteDef, gx: number, gy: number, v: Variant, legal: boolean, zPx?: number): void

A translucent preview during placement, tinted by legality: the ok slot means it will land, bad means it will not.

Drawn under the cursor, never as one — on touch a finger covers a cursor exactly, and a placement affordance the player's own hand hides is not an affordance.

It runs massing alone. A ghost that blinked would be indistinguishable from a building that is already there, and a ghost that lit the valley would let a player survey the map by dragging a lamp around it.

Parameters
zPx

The ground under the tile being tested, in world pixels. See drawSprite. A ghost that ignored it would sit at sea level while the tile it is testing is up a hill, and the player would judge the fit of a building against ground it will not stand on.

Throws

RangeError if zPx is not finite.

drawFootprint function ↳ src/sprite.ts:813

function drawFootprint(pen: Pen, gx: number, gy: number, w: number, d: number, color: Ink, z?: number, groundPx?: number): void

The marching-ant footprint rectangle on its own — selection rims, build sites, ranges.

The dash marches off pen.t, which is the frame's clock and arrived as a parameter, so two replays of the same session put the ants in the same place.

z defaults to SELECT_LIFT rather than 0: a rim drawn at ground level z-fights the tile beneath it at some zooms and not others, which looks like a hardware fault.

Parameters
z

Clearance above the ground, in storeys. The z-fight ladder, not an elevation.

groundPx

The ground the rim lies on, in world pixels. The two are separate because they are separate facts in separate units: one is iso's terrain and one is this package's anti-flicker constant, and adding them at the call site is how SELECT_LIFT ends up multiplied by a height.

Throws

RangeError if groundPx is not finite.

spriteVolume function ↳ src/sprite.ts:870

function spriteVolume(def: SpriteDef, v: Variant, out: Volume, zPx?: number): Volume

The sprite's massing as an iso.Volume, in world pixels — the picking half of the seam.

A game picks by handing pickSorted a test of its own, and that test wants boxSilhouette(camera, gx, gy, volume, out) + pointInPolygon, so the player hits the shape they can see rather than a footprint rectangle they cannot. This is the function that produces the volume — nobody else can, because the massing is the only thing that knows how tall the sprite actually built itself. It performs the storey → zPx conversion, so a caller never does and a Volume built in storeys — which makes picking wrong only near a roof, where nobody can characterise it — cannot happen.

function hitsSilhouette(index: number): boolean {   // hoisted, allocated once
  const b = buildings[index];
  if (b === undefined) return false;
  spriteVolume(b.def, b.v, vol);
  boxSilhouette(camera, b.gx, b.gy, vol, sil);
  return pointInPolygon(px, py, sil, 6);
}
const hit = pickSorted(order, hitsSilhouette);
Parameters
zPx

The ground under the footprint, in world pixels — the same number drawSprite was given. It is added in pixels and never converted, so the volume handed to boxSilhouette is exactly the elevation iso produced rather than a storey count multiplied back out. Omit it on a heightfield and the silhouette is computed at sea level while the building is painted up the hill: the picture is right, the taps land in mid-air, and both packages' suites stay green.

Throws

RangeError if zPx is not finite.

spriteHeightPx function ↳ src/sprite.ts:897

function spriteHeightPx(def: SpriteDef, v: Variant): number

The sprite's total height in world pixels — what DepthSorter.add wants for culling.

Under-declare it and roofs pop in along the top edge of the screen; over-declare it and a few off-screen items are drawn for nothing. Derived from the massing rather than guessed, which is the only way it stays right when a sprite grows a mast at level 3.

Measured from the sprite's own base, and it takes no ground, because it is a height and not a position: on a heightfield the caller adds the terrain under the footprint — order.add(gx, gy, w, d, groundPx + spriteHeightPx(def, v)) — which is the same sum spriteVolume makes internally, in the same unit, from the same two numbers.

spriteBounds function ↳ src/sprite.ts:918

function spriteBounds(def: SpriteDef, v: Variant, camera: Camera, gx: number, gy: number, out: Rect, zPx?: number): Rect

Screen-space bounds of a sprite, into iso's Rect.

How a thumbnail frames a subject it has never seen, and how input picks a building rather than a tile without re-deriving a bounding box from constants copied out of a sprite definition.

Conservative by construction: it is the axis-aligned box around the six silhouette points of the whole massing, so it never clips and may be a little generous around an L-shaped building. Generous is the correct direction — a tight bound that is occasionally wrong crops a thumbnail and nobody can say which sprite will do it.

Parameters
zPx

The ground under the footprint, in world pixels. Added in pixels, for the reason spriteVolume gives. A label or a bubble anchored to a bound computed at sea level drifts further from its building the higher the building stands.

Throws

RangeError if zPx is not finite.

light3 symbols

The pool, the edge, and the darkness it is cut from.

No DOM, no canvas — this module runs unchanged in Node. It composites through Surface render targets, which is the only thing in the kit that knows what a framebuffer is.

Why an accumulator, and not either obvious implementation

"You can see exactly where the light stops" is a requirement on compositing, and it rules out the two things a builder reaches for first:

  • Recolour the world at night and draw a warm blob per lamp. There is then no edge — the blob fades into a world that is uniformly darker, and the player cannot tell where light ends because nothing ends.
  • Draw darkness per lamp, punching a hole per lamp as you go. Two overlapping pools punch the same pixels twice — (1−a₁)(1−a₂), not max(a₁,a₂) — so the overlap comes out visibly brighter than either pool and every pair of adjacent lamps grows a hot lens-shaped seam between them. It looks like a driver bug because it is a rendering one, and it is unfixable in that shape.

So light is gathered into its own buffer with per-channel max blending, and darkness is composited once from the finished field. Max is what makes two pools meet as one pool.

stepwhatwhy
1every add() draws into a 'light' targetmax blending, so overlap resolves before anything is composited
2a darkness quad, then one 'cut' blit of the light bufferone hole per pool, with the pool's own soft edge
3one 'add' blit of the light buffer at bloomthe warm spill on the ground inside the pool, where additive is genuinely correct

Two things this deliberately does not do

It retains nothing between frames and has no registration. Pools are re-added every frame; a lamp that stops being drawn stops lighting, with no lifecycle to get wrong. A builder who adds a removeLight has reintroduced the bug the design removed.

Lights do not cast shadows and are not occluded. A lamp behind a hill still spills over it. Real occlusion needs a shadow map per light and a depth buffer this renderer does not have. This is the largest honest limitation in the package.

LightFieldOpts interface ↳ src/light.ts:61

interface LightFieldOpts {

How a light field is configured. Every default is a measured trade, not a preference.

Every field here is live: the same bag goes to createLightField and to LightField.configure, so a quality toggle, a screenshot mode or a control panel moves any of them on a running field. falloff was per-call on LightField.add from the start; that the other two were frozen at construction was an accident of where they happened to be read, and an option that cannot move is an option a game has to rebuild the world to change.

And every field here reads back off the field under its own nameLightField.scale, LightField.falloff, LightField.bloom. Liveness without readback is half a fix: a panel that can move the bloom and cannot read it has to remember what it set, and the copy it remembers is what drifts.

3 members
readonly scale?: number

Buffer resolution relative to the surface. Default 0.5.

Light is low-frequency, and two full-screen RGBA targets at device resolution is 20 MB resident and four times the fill rate for a difference nobody can point at. This is the one place in the kit that deliberately renders soft. Pin it to 1 for a screenshot.

readonly falloff?: number

Falloff exponent from center to rim. Default 2. Higher is a harder-edged pool: the value sets how much of the radius stays at full intensity before the ramp begins, so 1 is a pure linear ramp and 4 is a disc with a soft rim.

readonly bloom?: number

How much of the accumulated light is added back as warm spill. Default 0.35. At 0 the pool is a hole in the dark and nothing more; above about 0.6 an 8-bit buffer blows out to white wherever two lamps meet.

LightField interface ↳ src/light.ts:83

interface LightField {

The frame's light, accumulated and composited once.

12 members
readonly active: boolean

False when darkness is 0 — full day.

The whole subsystem then costs nothing: no buffers allocated, no buffers cleared, no pools drawn, no composite, and drawSprite skips every emit hook. A game with no night pays for none of this, which is what lets the module exist at all inside a 12 KB budget.

readonly count: number

Pools accumulated this frame. For a budget assertion and for docs/PERFORMANCE.md.

readonly scale: number

The buffer resolution last supplied. See LightFieldOpts.scale.

These three readers are what stops a control panel keeping a shadow copy. A panel that renders the current bloom beside its slider has to get that number from somewhere, and before these existed the only place to get it was a second variable in the panel — correct on the day it is written and one configure from disagreeing with the field it describes, forever, with no error. Non-negotiable 11 is that failure written down.

It reports what you set, not what is currently rendering. LightField.configure takes effect on the next LightField.begin, so between a configure({ scale }) and the next frame this reads the new number while the buffers are still at the old one. That is the right trade — the alternative is reallocating halfway through a frame's accumulation — but a caller sizing something off this value between those two moments is sizing it off a resolution that does not exist yet.

readonly falloff: number

The falloff every pool defaults to. See LightFieldOpts.falloff. This is the default the field applies when LightField.add is given none; a pool that named its own falloff is not recorded anywhere and is not readable, because the field retains nothing about a pool once it is drawn.

readonly bloom: number

The warm spill fraction in force. See LightFieldOpts.bloom. Unlike scale this one is read inside LightField.composite rather than baked into anything, so it is in force on the very next composite.

begin(pen: Pen, darkness: number, tint: Ink): void

Start the frame's light field. Call it before the Terrain pass, not in the Light pass — pools accumulate as sprites draw, and only the composite happens in the Light pass.

darkness is 0–1 and is the game's own day/night value, the same number it passes to Palette.lerp. Two schedules — one for color, one for the mask — is a valley whose darkness and whose blue disagree, and it gets reported as a light bug.

tint is the color the dark goes: an Ink, so a slot name lets the dark itself recolour with the palette.

Throws

RangeError if pen.light is not this field — which is what happens when light is left out of the beginFrame literal. That omission used to disable the entire night in silence: renderFrame's pen.light?.composite() becomes a no-op, drawSprite skips every emit hook, and every add accumulates into a buffer nobody ever reads. There is no error, no warning and no night — and the field still reports active: true with a live count, so the one thing an author would check to diagnose it says everything is fine. The check costs one reference comparison per frame and turns all of that into a sentence on the first frame. It is also the reason a subPen may not be begun: a sub-pen carries no light on purpose, so that a sprite drawn into a thumbnail cannot post a pool into the valley's night mask.

configure(opts: LightFieldOpts): void

Move any of the field's options on a running field. Omitted fields keep their current value.

A quality slider, a screenshot mode that pins scale to 1, a bloom a player can turn down. Validated in exactly the same words as construction, so a bad number is refused identically wherever it arrives from.

Between frames, not inside one. scale is baked into every pool coordinate as it accumulates, so changing it after LightField.begin and before LightField.composite puts half a frame's pools at one resolution and half at another. A new scale resizes the buffers on the next begin rather than here, because this field allocates only for a frame that has a night in it — and that stays true of a field whose resolution has just changed.

Throws

RangeError on the same three bad values createLightField refuses.

add(gx: number, gy: number, zPx: number, radiusTiles: number, intensity: number, color: Ink, falloff?: number): void

A pool of light lying in the ground plane at a grid position, radiusTiles across.

The pool is an ellipse, not a circle, and the field does the squashing. A circle of light on the ground projects 2:1 like every other flat thing in a dimetric world; draw it round and it stops being a pool on the road and becomes a sphere hovering above it — which is precisely the illusion the whole package exists to protect. A kit that made every caller remember the aspect would have a round pool in it inside a week.

zPx is the ground elevation under the light in world pixels, not the height of the lamp head, so a lamp on a hillside lights its own terrace rather than the valley floor. The glow on the fixture itself is a glowDot in the Solids pass; this is the light it throws.

The mask knows a position, a radius, an intensity and a color, and deliberately nothing else. It does not know what a lamp is and it holds no list of emitters.

addScreen(sx: number, sy: number, radiusPx: number, aspect: number, intensity: number, color: Ink, falloff?: number): void

A pool in screen pixels — a flash, a cursor glow, a UI-anchored highlight.

aspect is height over width and is required, with no default, because the choice between 1 (a genuine screen-space circle: a flash, a vignette) and 0.5 (something lying on the ground that the caller already has in screen coordinates) is exactly the mistake LightField.add exists to prevent, and a default would pick one silently.

composite(): void

Composite mask and bloom onto the surface. Called once, in the Light pass, by renderFrame — which gives a game no way to call it anywhere else, because a light composite in the Overlay pass takes the HUD dark with the world and the player cannot read their own coin at midnight.

resize(width: number, height: number): void

Rebuild the buffers for a new surface size — optional, and safe to forget.

LightField.begin already sizes the buffers to pen.surface on every active frame, so a field whose surface changed self-heals on the next frame that has a night in it, and forgetting this call costs one reallocation on that frame and nothing else. It was once documented as a step an author must remember, which is worse than useless: it sends people hunting a bug that does not exist, and the real symptom of skipping it — none — is indistinguishable from success.

It is kept for the one case where it earns its line: a game that resizes far more often than it renders, a window drag, can pay for the reallocation at the moment it knows about rather than inside the next frame. It only acts when the size actually changed, and it does nothing at all before the field has buffers.

dispose(): void

Dispose both buffers. A field that outlives its surface leaks GPU memory.

createLightField functionstart here ↳ src/light.ts:266

function createLightField(surface: Surface, opts?: LightFieldOpts): LightField

Build a light field over a surface.

The buffers are not allocated here. They arrive on the first frame whose darkness is above zero, so a game that never has a night never pays for one.

Throws

RangeError if scale is not in (0, 1], if falloff is below 1, or if bloom is outside [0, 1]. Each of those silently produces either a blank mask or a white screen, and neither reports itself.

layers4 symbols

The seven passes, and the runner that makes their order unforgeable. Not the sort.

No DOM, no canvas — this module runs unchanged in Node.

The boundary, settled

iso owns the occlusion relation, the topological sort and the backwards walk — DepthSorter and pickSorted. draw owns which pass the order is walked in, and nothing else about ordering. There is one sorted list in the kit.

Count how many of the seven passes are depth-sorted. Backdrop is one quad. Terrain iterates a TileRange in grid order, which is already back-to-front. Placement is a handful of items. Light is a composite. Overlay and Effects are screen space and deliberately unsorted. *Exactly one pass sorts* — and a scalar depth key cannot express beside, so that sort is a topological one and it lives in iso.

Nor does draw supply an item bucket. DepthSorter deliberately cannot name a drawable, and the reflex is for this package to provide the half it appears to be missing. It should not: the game already has its buildings in an array, the permutation indexes that, and a bucket here would be a second copy of the caller's world kept in step by hand. The whole Solids pass is four lines the game writes itself.

The contract that used to be a constructor, and what draw must not do

An earlier iso draft had a Scene that held ids, sorted them and picked among them. Splitting it into DepthSorter + pickSorted was right, and it moved a guarantee out of the type system and into prose: draw paints indexAt(0…count) forward, pickSorted walks that same instance backward, and nothing between the two may change the order.

Concretely, after sort() this package must:

  • not re-sort, by anything, for any reason;
  • not partition. This is the one that will actually happen. Drawing every contact shadow first and every body second looks better and is a stable partition of the sorted order — and it is a reorder. If you want shadows first, walk indexAt forward twice, shadows on the first walk and bodies on the second. Two forward walks preserve the order; one partitioned walk destroys it while looking like it preserved it;
  • not skip and re-add. Culling already happened inside sort;
  • not paint from a second collection that happens to hold the same items in a different arrangement.

Break it and iso hit-tests one arrangement while draw painted another; both packages are internally correct, both suites stay green, and a player taps a rack and opens the headquarters behind it. renderFrame is shaped to make the compliant path the easy one: it calls sort itself, immediately before the Solids callback, so there is no window in which a caller holds a sorted order and is tempted to improve it.

Layer const ↳ src/layers.ts:62

const Layer: {
    /** A vertical ramp. Never a flat color: flat backgrounds make an island look like a sticker. */
    readonly Backdrop: 0;
    /** Culled tile diamonds, color varied per tile from a stateless hash. */
    readonly Terrain: 1;
    /** Buildings *and* scenery, one list, one sort. Two sorted lists is what makes trees pop
     *  through walls. */
    readonly Solids: 2;
    /** Ghost and selection: above the world, below the UI. */
    readonly Placement: 3;
    /** The night mask goes down and the bloom goes up, in one composite. */
    readonly Light: 4;
    /** Bubbles and timers, in screen space, unsorted, always on top. */
    readonly Overlay: 5;
    /** Floating numbers and bursts. */
    readonly Effects: 6;
}
type Layer = (typeof Layer)[keyof typeof Layer]

The pass ordinals. The order is the product, and it is closed at seven.

There is no way to add an eighth and no way to get a second Solids pass — a second Solids pass is how the tree-through-wall bug comes back, and an eighth is how somebody puts the HUD under the darkness. The seventh was found by the demo's own RFC before a line of this was written; the next one, if there is one, gets found the same way.

PASS_NAMES const ↳ src/layers.ts:84

const PASS_NAMES: readonly [
    'backdrop',
    'terrain',
    'solids',
    'placement',
    'light',
    'overlay',
    'effects'
]

Pass names in order. For a profiler's labels, a debug overlay, and error messages.

Passes interface ↳ src/layers.ts:101

interface Passes {

The game's painting, one callback per pass.

Hoist these to module scope and reuse the object — they are allocated once at setup, never per frame. Every pass is optional: a game with no night supplies no light field anywhere and pays for nothing; a game with no placement mode omits placement.

7 members
readonly backdrop?: ((pen: Pen, visible: Readonly<Rect>) => void) | undefined

visible is camera.visibleWorldBounds() — a gradient needs the world box, not tiles.

readonly terrain?: ((pen: Pen, visible: Readonly<TileRange>) => void) | undefined

visible is camera.visibleTileBounds(), already computed and margined by Passes.maxHeightPx.

readonly maxHeightPx?: number | undefined

The tallest ground on the map, in world pixels — the margin the Terrain cull needs.

renderFrame computes the visible tile range for you, and it computes it on the ground plane, because a camera has no idea what a heightfield is. A tile whose corner stands zPx above sea level is painted zPx further up the screen, so it is on screen while the flat tile at its address is already off the bottom — and the range, honestly answering the question it was asked, leaves it out. The symptom is a summit that vanishes the moment its base leaves the bottom edge, with nothing missing anywhere else in the frame.

Taking the cull away from a game and then not exposing its one parameter is the failure this field exists to close: the one place this package takes ownership from the game is the one place the game had the number. A game reads it off its own generator — maxUnits * field.stepPx — and states it once here, on the Passes object it hoists at setup.

The conversion, so an over- or under-margin can be reasoned about rather than tuned: screen y advances by HALF_H per unit of gx + gy, so a zPx lift is worth zPx / HALF_H of gx + gy. Growing a box range by one tile on each of its two axes grows gx + gy by two, so the margin is zPx / (2 · HALF_H) — that is, zPx / TILE_H, rounded up. Which is exactly what Camera.visibleTileBounds documents its marginTiles to be.

Omit it on flat ground and nothing is margined and nothing is wasted. Costs one extra ring of tiles per unit of height, in a loop that is already generous by roughly 2× — see Camera.visibleTileBounds.

readonly solids?: ((pen: Pen, order: DepthSorter) => void) | undefined

order is sorted and culled before this is called. Walk it forwards: for (i = 0; i < order.count; i++) paint(myItems[order.indexAt(i)]).

Do not sort it, partition it, or paint from anything else — see this module's header. If you need two sweeps, take two forward walks.

readonly placement?: ((pen: Pen) => void) | undefined

The placement ghost and the selection rim.

readonly overlay?: ((pen: Pen) => void) | undefined

Screen-space HUD, drawn after the light composite so it reads at midnight.

readonly effects?: ((pen: Pen) => void) | undefined

Floating numbers and bursts, above everything.

renderFrame functionstart here ↳ src/layers.ts:187

function renderFrame(pen: Pen, passes: Passes, order?: DepthSorter): void

Run one frame's passes in the fixed order.

It calls camera.visibleWorldBounds before Backdrop, visibleTileBounds — margined by Passes.maxHeightPx — before Terrain, order.sort(camera) immediately before Solids, and pen.light.composite() between Placement and Overlay — and the light composite is not a callback, so there is no way for a game to put the night mask over its own HUD.

The two culling calls happen only when their pass exists, so a game with no backdrop pays nothing for one; each happens at most once per frame, so three passes can never each recompute the visible region and disagree at the margins.

Parameters
order

The frame's DepthSorter, already filled by the caller. Sorting happens here rather than in the caller so that no window exists in which somebody holds a sorted order and improves it.

Throws

RangeError if a solids pass is supplied without an order. Silently skipping the pass would mean a frame that draws terrain and nothing else, which reads as "the save did not load" and has no other symptom.

Throws

RangeError if passes.maxHeightPx is negative or not finite. A negative margin shrinks the terrain range, which paints a strip of background along two edges of the screen and looks like a camera bug.

index1 symbol