@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.
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
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.
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.
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.
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.
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.
const FACE_LEFT = 0.74
The +gy face — screen-left, and the lit one. See FACE_TOP.
const FACE_RIGHT = 0.52
The +gx face — screen-right, and the shaded one. See FACE_TOP.
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.
const LIGHT_TINT: Rgba
Warm target that lit surfaces drift toward. See SHADE_TINT.
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.
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.
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.
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.
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.
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.
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.
ThrowsRangeError naming the input if it is not one of the three forms. A silent black
here would be a typo that ships as art.
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.
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 it — core 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.
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
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.- 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.
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.
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.
interface Palette {
Live slot state for one frame's worth of drawing, plus the revision any cache keys on.
7 members
readonly rev: numberBumped 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): RgbaThrowsRangeError 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): voidWrite 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): booleanWhether a slot exists, for a caller building a theme editor.
ink(value: Ink): RgbaResolve 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): voidCross-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.
ThrowsRangeError if the two stop sets do not define exactly the same slots.
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.
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.
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.
function lerpPalette(a: Stops, b: Stops, t: number): Vars
Interpolate two stop sets into CSS strings — the draw → ui 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).
ThrowsRangeError if the two stop sets do not define exactly the same slots.
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.
const BASE_SLOTS: Stops
Full daylight, and the working default: createPalette(BASE_SLOTS) is a game that renders.
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.
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.
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.
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.
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.
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.
type BlitMode = 'over' | 'add' | 'cut'
How a bitmap lands on what is already there. Three modes, not a composite API.
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.
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: numberEm size in CSS pixels.
readonly weight: numberCSS font weight, 100–900.
readonly family: stringCSS 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 | 1Horizontal anchor: -1 start, 0 center, 1 end.
readonly baseline: -1 | 0 | 1Vertical anchor: -1 top, 0 middle, 1 bottom.
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: numberCSS pixels.
readonly height: numberCSS pixels.
readonly pixelRatio: numberDevice pixels per CSS pixel in the backing store.
readonly bytes: numberApproximate 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(): voidRelease the backing store. A bitmap that outlives its surface leaks GPU memory.
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: SurfaceKindWhich backend this is.
readonly width: numberCSS pixels. Never device pixels — see Surface.pixelRatio.
readonly height: numberCSS pixels.
readonly pixelRatio: numberDevice 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): voidResize the backing store. Coordinates stay in CSS pixels either side of it.
begin(clear: Rgba): voidStart 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(): voidFinish the frame. A backend that batches flushes here; Canvas2D does nothing.
poly(xy: Float64Array, count: number, fill: Rgba): voidFill 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): voidFill 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): voidStroke 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): voidAn axis-aligned filled ellipse — cylinder caps, glow cores, bubbles.
softEllipse(cx: number, cy: number, rx: number, ry: number, inner: Rgba, outer: Rgba): voidAn 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): voidDraw 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): numberAdvance 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): numberSet 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): voidDraw 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): RenderTargetA 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.
interface RenderTarget extends Surface {
A Surface that renders into memory and hands back the result.
1 member
readonly bitmap: BitmapThe finished image. Valid only after Surface.end; reading it before is undefined.
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: SurfaceWhere the drawing goes. Never a canvas; see Surface.
readonly camera: CameraThe transform. draw reads it and never moves it — panning is input's.
readonly palette: PaletteSlot → color for this frame. Its rev is what keeps any cache honest.
readonly t: numberSeconds since the session began. The only clock in this package, and it arrives here as a
parameter — nothing under src/ reads one.
readonly xy: Float64ArrayScratch 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 | undefinedThe 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: numberThe 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: numberSee Pen.snapX.
readonly snap: booleanWhether 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.
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: SurfaceWhere the frame lands.
readonly camera: CameraThe transform for this frame.
readonly palette: PaletteSlot colors for this frame.
readonly t: numberSeconds since the session began. From loop; this package never reads a clock.
readonly clear?: InkPainted 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?: LightFieldAttach a night. Omit and every light in the kit costs nothing.
readonly snap?: booleanWhole-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.
function beginFrame(opts: FrameOpts): Pen
Open a frame: clear the surface and build the pen every primitive is handed.
ThrowsRangeError 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.
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.
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.
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
- 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".
- 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.
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.
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.
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.
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.
const GHOST_LIFT = 0.01
A placement ghost, above anything painted onto the ground. See GROUND_LIFT.
const SELECT_LIFT = 0.02
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: InkBase color. The three faces are derived from it; there is no per-face override.
readonly h: numberHeight in storeys.
readonly z?: number | undefinedBase height in storeys, so a box can sit on top of another. Default 0.
readonly inset?: number | undefinedShrink the footprint on all sides, in tiles. Ledges and setbacks. Default 0.
readonly outline?: boolean | undefinedSilhouette 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 | undefinedOverride the top face only — roofs, solar glass, water. The one sanctioned exception
to faces-are-derived.
readonly alpha?: number | undefined0–1 opacity, for ghosts. Applied to the whole solid, not per face.
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
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.
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.
ThrowsRangeError 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.
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.
ThrowsRangeError if radiusTiles, opts.h or opts.z is not finite.
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
ThrowsRangeError if w, d, rise or z is not finite.
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.
ThrowsRangeError if the two endpoints differ equally in gx and gy, naming both.
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
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.
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.
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
ThrowsRangeError 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.
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:
- squeeze the along-axis by
min(1, downLen / alongLen), restoring the letterform while keeping the shear; - 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.
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.
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.
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.
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.
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.
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 structurally — something 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.
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: numberUpgrade level. Massing may branch on it freely.
readonly seed: numberPer-instance determinism. Seeds the Rng the kit hands to every hook.
readonly flags: numberBitfield — 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: number0–1 construction progress.
readonly label: stringInstance 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.
const VARIANT_ZERO: Variant
A finished, powered, unnamed instance. The variant a test uses and a sprite falls back to.
const FLAG_POWERED = 1
The instance is connected and running.
const FLAG_BUILDING = 2
The instance is under construction; progress is meaningful.
const FLAG_SELECTED = 4
The player has it selected.
const FLAG_GHOST = 8
It is a placement preview rather than a real thing.
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: PaletteThe 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): voidA flat tile diamond. See isoTile.
box(gx: number, gy: number, w: number, d: number, opts: BoxOpts): voidThe workhorse box. See isoBox.
cylinder(gx: number, gy: number, radiusTiles: number, opts: BoxOpts): voidAn upright cylinder. See isoCylinder.
roof(gx: number, gy: number, w: number, d: number, z: number, rise: number, color: Ink, outline?: boolean): voidA gabled roof. See isoRoof.
patch(gx: number, gy: number, w: number, d: number, z: number, fill: Ink, stroke?: Ink): voidA flat quad at height z. See isoPatch.
wall(ax: number, ay: number, bx: number, by: number, z0: number, z1: number, fill: Ink, stroke?: Ink): voidA 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): voidA thin upright post. See isoPost.
glow(gx: number, gy: number, z: number, color: Ink, radius?: number, intensity?: number): voidA 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): voidText sheared onto a vertical face. See wallText.
shadow(gx: number, gy: number, w: number, d: number, strength?: number, z?: number): voidThe 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.
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.
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.
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.
interface SpriteDef {
A sprite: a footprint, static art, and two optional live hooks.
6 members
readonly id: stringStable 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: numberFootprint 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: numberFootprint depth in tiles. See SpriteDef.w.
readonly massing: MassingThe static art.
readonly animate?: Animator | undefinedLive art over it.
readonly emit?: Emitter | undefinedLight 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.
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.
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
ThrowsRangeError if zPx is not finite.
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
ThrowsRangeError if zPx is not finite.
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
ThrowsRangeError if zPx is not finite.
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.
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
ThrowsRangeError if zPx is not finite.
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.
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.
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.
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) | undefinedvisible is camera.visibleWorldBounds() — a gradient needs the world box, not tiles.
readonly terrain?: ((pen: Pen, visible: Readonly<TileRange>) => void) | undefinedvisible is camera.visibleTileBounds(), already computed and margined by
Passes.maxHeightPx.
readonly maxHeightPx?: number | undefinedThe 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) | undefinedorder 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) | undefinedThe placement ghost and the selection rim.
readonly overlay?: ((pen: Pen) => void) | undefinedScreen-space HUD, drawn after the light composite so it reads at midnight.
readonly effects?: ((pen: Pen) => void) | undefinedFloating numbers and bursts, above everything.
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
ThrowsRangeError 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.
ThrowsRangeError 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.