API reference · layer 1

@latticekit/iso

Isometric space: the projection, the camera, depth sorting, tile maps, footprints, hit-testing, and grid pathfinding.

exports80 symbols in 10 modules — start with createCamera, DepthSorter, screenToTile, pathSample
depends on@latticekit/core
environmentisomorphic
gzipped11.47 kB against a 12 kB budget
sourcepackages/iso · README · index.d.ts

@latticekit/iso — the three coordinate spaces of a 2:1 tile game, and every operation that is only correct because it knows which one it is in.

Grid, world and screen: projection, elevation, camera, depth order, footprints, picking, and paths that can be sampled as well as followed. If a bug can be described as "the wrong tile", "drawn through a wall", "the tap opened the building behind", "the walkers hitch on the diagonals" or "I lost my island off the edge of the screen", it is this package's fault and nobody else's.

The frame, in six lines

const camera = createCamera(960, 540, { bounds: tileBounds(0, 0, 48, 48, 0, worldRect) });
const order = new DepthSorter(512);                    // allocated once, reused for ever
order.clear();
for (const b of buildings) order.add(b.gx, b.gy, b.w, b.d, b.heightPx);
order.sort(camera);                                    // culls, then orders back-to-front
for (let i = 0; i < order.count; i++) paint(buildings[order.indexAt(i)]);

And the line on tap, which has to be the exact reverse of the fifth:

const hit = pickSorted(order, (i) => silhouetteHit(buildings[i], pointerX, pointerY));

The allocation contract, which is not negotiable

No function here returns a point, a rectangle or any other object the caller did not hand in. There are exactly three shapes:

shapeexamplefor
scalarcamera.toScreenX(wx)the innermost loop; returns a number, so it cannot allocate and the engine inlines it
out-parametergridToScreen(cam, gx, gy, zPx, out)anywhere a point is genuinely wanted as a point
bufferboxSilhouette(cam, gx, gy, vol, out)geometry with more than one point

The only functions that produce an object are the constructors and createCamera, which run at setup. Everything else writes into what it was given. @latticekit/draw cannot meet the constitution's rule 7 otherwise, and the rule is checkable by reading the emitted .d.ts: no return type is a bare interface the caller did not pass in.

Note that toScreenX takes only wx and toScreenY only wy. Screen x depends on world x alone, so the eight corners of a box need four x projections, not eight.

Determinism

No function in this package calls a trigonometric, exponential or logarithmic function. The geometry here is linear and it costs nothing: the facing is one of eight direction codes, the A\* heuristic is the integer octile metric, an isometric "rotation" does not exist, and the only Math.sqrt is arc length — which ECMA-262 specifies exactly. iso contains no randomness of any kind and holds no Rng; everything that varies comes in through a TileSource the caller filled.

What is deliberately not here

A runtime tile size (any uniform size is exactly a camera zoom). A third grid axis — but elevation itself is here, as a layer. Anything that draws, including LEVEL_H, which is an art proportion and @latticekit/draw's. Camera feel: inertia, pinch, edge-scroll and smooth follow need a clock and a pointer, and both live in @latticekit/input. Steering, avoidance and anything that owns a walker. Entities, components and any scene graph. Serialization. Fog of war and line of sight. An incremental replanner — recompute is a few tens of microseconds against an 8 ms budget, and MutableTileSource.version makes it happen exactly once. And a priority queue as an export: iso builds one, and does not publish it.

What it promises

  • Three coordinate spaces — grid, world, screen — never conflated. Every conversion is a named function taking an output parameter.
  • Tile lookup floors, never rounds. Rounding snaps to the nearest lattice vertex and picks the wrong tile for three quarters of every diamond.
  • Hit-testing is computed from state and camera, never cached during a draw pass. pick() is a method on the sorted Scene, so 'reverse of paint order' is structural rather than remembered.
  • Tile size is fixed at 64x32. Any other uniform size is exactly a camera zoom, and parameterizing it would infect draw, input and ui signatures permanently.
  • Elevation lives on grid vertices, not tile centres. Once z exists the projection is no longer invertible, so picking must be terrain-aware.
  • A path is a curve to be sampled by arc length, not a list of nodes to be stepped. Grid-unit parameterization would make a walker 58% faster on one diagonal than the other.

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

projection32 symbols

The lattice itself: grid ↔ world, the rectangle every other package borrows, and the scalar depth key.

Three coordinate spaces exist in this kit and conflating them is the bug class this whole package was written to remove:

spaceunitwho produces it
gridtiles, fractional or whole, fields gx/gygame state, worldToGrid, pathSample
worldpixels at zoom 1, fields x/ygridToWorldX and friends
screenCSS pixels in the viewportCamera.toScreenX/toScreenY only

A grid position is a GridPoint (gx/gy) and a world or screen position is core's Vec2 (x/y), so the type system refuses the mix-up that comments cannot catch.

Everything here is Tier A. + - * / and comparisons; no sin, no pow, no log, and — because HALF_W and HALF_H are powers of two — every division in the inverse is exact. That is why worldToGridX round-trips a grid coordinate bit for bit rather than within an epsilon, and why a replay lands on the same pixel on every engine.

GridPoint interface ↳ src/projection.ts:39

interface GridPoint {

A position in grid space, fractional or whole.

The fields are gx/gy and not x/y, and that is the entire point: a grid position that arrives in a Vec2 can be handed to a world-space function with nothing to stop it, and the resulting sprite is off by a factor of thirty-two with no error anywhere. Every function here that produces a grid position writes into one of these.

Mutable, for the same reason Vec2 is: it is an output parameter far more often than it is an input, and a readonly variant would force a second type into every signature that fills one.

2 members
gx: number
gy: number

Tile type ↳ src/projection.ts:51

type Tile = GridPoint

A GridPoint whose components are whole numbers: a tile address.

The same shape deliberately, so nothing has to convert at a boundary. Tile in a signature promises the value has been floored — never rounded, see worldToTile — and GridPoint says fractions are meaningful there.

Rect interface ↳ src/projection.ts:75

interface Rect {

The kit's rectangle, in min/max form and carrying no coordinate space of its own.

iso owns it because iso is the lowest common ancestor of everyone who needs one: draw culls with it, input tests hit regions with it, ui lays panels out with it, and all three already depend on this package. core declined it, correctly — a layer-0 package that accretes convenience types makes every consumer pay for the spatial half of the kit.

Min/max rather than x/y/w/h. Overlap and containment are what rectangles are for, and in this form each is four comparisons with no arithmetic. In x/y/w/h form every one of them recomputes x + w at the call site, which is both slower and a place to put the sign wrong. rectFromSize and rectWidth close the gap for callers who think in sizes.

The parameter name says which space it is inworldRect, screenRect. A typed wrapper per space was considered and rejected: it doubles the surface and the conversions still have to be written by hand.

Mutable, because it is an output parameter as often as it is an input. There is no allocator for it: write the literal { minX: 0, minY: 0, maxX: 0, maxY: 0 } once, at setup, and reuse it — no function in this package returns a rectangle it was not given.

4 members
minX: number
minY: number
maxX: number
maxY: number

TileRange interface ↳ src/projection.ts:89

interface TileRange {

A half-open rectangle of tiles: gx0 ≤ gx < gx1, gy0 ≤ gy < gy1.

Half-open so that adjacent ranges tile the plane without either double-covering a column or leaving a gap. A closed range is off by one in whichever direction its author was not thinking about, once, permanently.

4 members
gx0: number
gy0: number
gx1: number
gy1: number

TILE_W const ↳ src/projection.ts:111

const TILE_W = 64

Tile width in world pixels. A compile-time constant, not a runtime parameter.

Any other uniform tile size is exactly a camera zoom — a game that wants 32×16 runs this lattice at zoom = 0.5 — so parameterising it would buy a label and cost a projection object threaded through every signature in draw, input and ui, plus two property loads on the innermost line of the frame. A different aspect ratio is a different projection and therefore a different package: 2:1 is what makes the inverse exact, the diamond test two half-planes, and the depth key a sum.

Even, so HALF_W is exact and every grid vertex lands on a whole pixel at every power-of-two zoom.

TILE_H const ↳ src/projection.ts:115

const TILE_H = 32

Tile depth in world pixels. Exactly half TILE_W — the 2:1 that defines iso, and the reason gx + gy is a usable depth key at all.

HALF_W const ↳ src/projection.ts:119

const HALF_W = 32

TILE_W / 2, spelled out because it appears in every projection and the division would otherwise be written at each site. Exact: 64 and 32 are powers of two.

gridToWorldX function ↳ src/projection.ts:145

function gridToWorldX(gx: number, gy: number): number

grid → world, x only.

+gx runs down-right on screen and +gy down-left, so gx + gy increases towards the viewer — which is exactly depthOf — and gx - gy runs across the screen. Swap the two and every building in the game is rotated ninety degrees.

Scalar rather than a point because this is the innermost line of the frame: it returns a number, so it cannot allocate and the engine inlines it. Screen x depends on world x alone, so a caller projecting the eight corners of a box projects four numbers, not eight.

gridToWorld function ↳ src/projection.ts:157

function gridToWorld(gx: number, gy: number, out: Vec2): Vec2

grid → world, both axes, written into out. Returns out so calls chain. Writes a Vec2 and not a GridPoint, because what comes out is world pixels.

worldToGridX function ↳ src/projection.ts:172

function worldToGridX(wx: number, wy: number): number

world → grid, x only. Fractional, and the exact inverse of gridToWorldX.

Exact rather than approximately exact: HALF_W and HALF_H are powers of two, so both divisions are error-free and an integer grid coordinate comes back bit-identical. That is what lets worldToTile floor with confidence instead of nudging by an epsilon first.

worldToGrid function ↳ src/projection.ts:183

function worldToGrid(wx: number, wy: number, out: GridPoint): GridPoint

world → grid, both axes, fractional. Writes a GridPoint — not a Vec2, because what comes out is tiles and handing tiles to a world-space function is trap number one.

worldToTile function ↳ src/projection.ts:204

function worldToTile(wx: number, wy: number, out: Tile): Tile

The tile containing a world point.

Floors both components. Never rounds. Math.round snaps to the nearest lattice vertex, and a vertex is the shared corner of four diamonds, so rounding returns the wrong tile over three quarters of the area of every one of them. The visible symptom is a placement ghost that jumps a tile as the pointer crosses the middle of a tile rather than its edge, and it is the single most common isometric bug there is.

Math.floor and not a truncating | 0: truncation rounds toward zero, so -0.5 and 0.5 would both land on tile 0 and the map would have a one-tile seam through the world origin. The same trap sits behind core.hash2, which truncates by design.

depthOf function ↳ src/projection.ts:227

function depthOf(gx: number, gy: number, w?: number, d?: number): number

Painter's-algorithm scalar key: larger draws later, i.e. nearer the viewer.

Taken at the footprint's far corner — (gx + w) + (gy + d) — so a 2×2 building sorts as if it stood on the tile nearest the camera. Without the extent terms a large building draws behind the small one beside it. With the extents defaulting to zero the key degenerates to gx + gy, which is the right key for a point.

This is a tie-break, not the order. A scalar cannot express "beside": a pedestrian well down the map but far to the left of a building has a larger key and gets drawn straight through its wall at second-storey height. That was a real, player-found bug. The relation that is actually true is a ends before b begins on either axis, which is not a total order, which is why DepthSorter is a topological sort that falls back to this rather than a comparator handed to Array.sort.

isEdgeOn function ↳ src/projection.ts:257

function isEdgeOn(gx0: number, gy0: number, gx1: number, gy1: number): boolean

Does a grid-space segment project to a vertical line with no screen width — is it edge-on to the camera?

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. On screen it is a line. This is not a degenerate case in the numerical sense — every number involved is finite and the projection is doing precisely what it promises — which is why it is silent, and why it has to be a named predicate rather than a paragraph somebody reads afterwards.

segmentdgx, dgyon screen
along +gx1, 0down-right, full width
along the (1, 1) diagonal1, 1straight down, zero width
along the (1, −1) diagonal1, −1straight across, zero height — thin, but visible

The trap it names: a wall, fence, hedge or run of flags drawn between two grid points that differ equally in gx and gy has no width to draw. Nothing throws, nothing warns, and the art is simply not there. Test the two endpoints before drawing — or, better, in the assertion a drawing kit runs in development — and either refuse the call or say which two tiles were asked for.

A zero-length segment answers true: a point also has no width, and it is the same bug arriving from a different direction. A segment with a NaN coordinate answers false; this asks about the projection, not about whether the coordinates are worth projecting.

tileDiamond function ↳ src/projection.ts:277

function tileDiamond(gx: number, gy: number, out: Float64Array): Float64Array

The four world-space corners of a tile diamond, clockwise from the north vertex, written into out as [x0,y0, x1,y1, x2,y2, x3,y3].

North, east, south, west — north is the (gx, gy) grid vertex, and the diamond is the unit cell whose other three corners are (gx+1, gy), (gx+1, gy+1) and (gx, gy+1).

World space, not screen, so a caller can cache ground geometry once and re-apply the camera every frame; a screen-space version would have to be rebuilt on every pan.

Parameters
out

Length ≥ 8. @throws RangeError otherwise, naming the length it got — a short buffer would otherwise write undefined into three of the four corners and the tile would silently collapse to a line.

footprintBounds function ↳ src/projection.ts:306

function footprintBounds(gx: number, gy: number, w: number, d: number, heightPx: number, out: Rect): Rect

The world-space box of a w × d footprint standing heightPx world pixels tall.

The top edge extends upward by the height, which is the only reason a tall building whose base is below the viewport still draws its roof. A culler that forgets it pops skylines in and out along the bottom edge of the screen.

Parameters
heightPx

Measured from the z = 0 plane, not from the ground under the footprint. On a heightfield pass footprintBase(field, f) + ownHeight. There is deliberately no separate base parameter: a box standing on flat ground and one standing on a ridge need the same single number, and two numbers invite passing one of them twice.

tileBounds function ↳ src/projection.ts:341

function tileBounds(gx: number, gy: number, w: number, d: number, heightPx: number, out: Rect): Rect

The world box of a whole rectangle of tiles — the value CameraOptions.bounds wants.

Identical arithmetic to footprintBounds; it exists under its own name because a caller reading tileBounds(0, 0, 48, 48, 0, worldRect) at a camera construction site is not thinking about footprints, and a shared name would make the island's extent look like a building.

It takes an out, where the RFC's sketch returned a fresh Rect. Nothing this package exports returns an object the caller did not hand in — that rule is checkable by reading the emitted .d.ts, and one setup-time exception is how it stops being checkable.

Parameters
heightPx

Required rather than defaulted, so that the one number a culler forgets is the one number the signature makes you type. Pass 0 for ground-level bounds.

rectSet function ↳ src/projection.ts:355

function rectSet(out: Rect, minX: number, minY: number, maxX: number, maxY: number): Rect

Set all four edges at once. Returns out so calls chain.

rectFromSize function ↳ src/projection.ts:365

function rectFromSize(out: Rect, x: number, y: number, w: number, h: number): Rect

From a position and a size — the form ui and input think in, and the only place in this package where x + w is computed, which is the point of storing min/max.

rectWidth function ↳ src/projection.ts:375

function rectWidth(r: Readonly<Rect>): number

Width. Negative for an inverted rectangle rather than clamped to zero, because a negative width is a bug worth seeing and rectIsEmpty is the test that names it.

rectCenterX function ↳ src/projection.ts:388

function rectCenterX(r: Readonly<Rect>): number

Center x. Written as min/2 + max/2 rather than (min + max) / 2 — or min + (max - min) / 2, which overflows the same way — so that the default ±1e4 bounds scaled up to a really large world still finds its own middle instead of Infinity. Halving is exact in binary, so this loses nothing for ordinary values.

rectContains function ↳ src/projection.ts:399

function rectContains(r: Readonly<Rect>, x: number, y: number): boolean

Is the point inside? Half-open on the max edges, so a plane tiled with rectangles assigns every point to exactly one of them instead of double-counting the seams.

rectIntersects function ↳ src/projection.ts:406

function rectIntersects(a: Readonly<Rect>, b: Readonly<Rect>): boolean

Do two rectangles share any area? Touching edges do not count, which is the same half-open convention as rectContains and keeps "adjacent" and "overlapping" distinguishable — a placement check needs them to be.

rectExpand function ↳ src/projection.ts:413

function rectExpand(out: Rect, margin: number): Rect

Grow (or, with a negative margin, shrink) in place. The culling margin and the tap slop. A shrink past the middle inverts the rectangle rather than clamping — see rectIsEmpty, which is how you find out.

rectUnion function ↳ src/projection.ts:424

function rectUnion(out: Rect, a: Readonly<Rect>, b: Readonly<Rect>): Rect

out becomes the smallest rectangle containing both. out may alias a or b: every component is read before any is written, which is the same aliasing rule core's vectors keep and for the same reason — the callers who reuse buffers are the careful ones.

rectMakeEmpty function ↳ src/projection.ts:445

function rectMakeEmpty(out: Rect): Rect

Reset to the inverted-infinity rectangle, so a loop of rectUnion accumulates a bounding box correctly from zero items.

Without it the first item has to be special-cased at every call site, and one of those call sites will forget and start the box at the origin — which produces a bounding box that always contains (0, 0) and a cull that draws the whole map whenever the camera is near the middle of it.

rectIsEmpty function ↳ src/projection.ts:457

function rectIsEmpty(r: Readonly<Rect>): boolean

True when the rectangle encloses no area, including the rectMakeEmpty state and any rectangle whose edges have crossed. A zero-width rectangle is empty: it contains no point under the half-open rule, so any other answer would contradict rectContains.

camera4 symbols

The camera: a pan, a zoom, and a clamp. No DOM anywhere in this file.

It is given a viewport size, never a canvas, which is what lets the whole of iso run in Node and be tested without a shim — and the two things most worth testing here, the clamp and the pointer-anchored zoom, are precisely the two that a browser-only camera can only be tested by hand.

The viewport is in CSS pixels. A pointer event arrives in CSS pixels, so a camera that worked in device pixels would make every input path multiply by a ratio this package must not name. devicePixelRatio is @latticekit/draw's business at the point it sets a transform, and nowhere else.

x, y and zoom are getters over private state and there is no setter. That is not tidiness. zoomAt exists to keep the world point under the pointer pinned; if any path can write camera.zoom = 2 it skips the anchoring, and no test can catch what it cannot observe — the invariant holds in the suite and breaks in the game. Making the assignment unavailable turns a documented rule into an unrepresentable state, which is why this module exports an interface and a factory rather than a class with public fields.

The policy, though, is readable and settable, and that is not a hole in the rule above. Every field of CameraOptions except zoom has a getter, and each has a setter that re-clamps in the same statement — setZoomLimits, setKeepVisible, setBounds, resize. The line between the two halves is what a value is, not how much it is worth protecting:

what moves itwhy it is shaped this way
positionx, y, zooma gesture, sixty times a secondevery mutator has to decide what stays put under the pointer, and a setter is a path that does not decide
policy — the zoom limits, keepVisible, bounds, the viewporta settings screen, a level load, a sliderchanging it is a whole-camera decision made a handful of times a session, from code holding no pointer

A value a caller supplied and cannot read back is a value they have to store twice, and two copies drift: before these getters existed the gallery's control panel kept a shadow copy of all three zoom-policy numbers and rebuilt the camera — and the input system bound to it — on every drag of a slider. That is the cost of baking an option that was never expensive to move.

No inertia, no drag handling, no pinch, no edge-scroll, no smooth follow, no shake. Feel needs a clock and a pointer and both live in @latticekit/input, which drives this camera through panByScreen, zoomAt and centerOn. A camera that eases itself cannot be stepped deterministically in a replay.

CameraOptions interface ↳ src/camera.ts:69

interface CameraOptions {

What a camera is allowed to do — the opening value of every policy, none of them baked.

Every field here except zoom is a policy rather than a state: the camera's position moves through panByScreen, zoomAt and centerOn, and nothing in this object moves with it. But policy is not frozen either — each field has a getter of the same name on Camera and a setter that re-applies the clamp in the same statement, so a caller never has to keep a second copy of a number it already handed over.

optionread it backmove it
minZoom, maxZoomCamera.minZoom, Camera.maxZoomCamera.setZoomLimits
keepVisibleCamera.keepVisibleCamera.setKeepVisible
boundsCamera.boundsCamera.setBounds
zoomCamera.zoomzoomAt / fitBounds only — it is a position, not a policy
5 members
readonly minZoom?: number

How far out you may pull. Below this the art stops being readable and the depth sort starts costing more than the pixels are worth. Default 0.5.

readonly maxZoom?: number

How far in you may push. Default 4; vector art costs nothing to magnify.

readonly zoom?: number

Starting zoom. Default 1. Clamped into [minZoom, maxZoom] at construction rather than rejected, because a saved zoom outliving a change to the limits is a migration problem and not a reason to refuse to open the game — and Camera.setZoomLimits applies that same rule for the rest of the camera's life.

readonly bounds?: Readonly<Rect>

The world rectangle the player is allowed to look at. Default ±DEFAULT_EXTENT.

Copied at construction, so a caller may reuse the rectangle it passed in — this is the one place in the package where an input rectangle is retained, and retaining the caller's object would make every later rectSet on it move the camera's world.

readonly keepVisible?: number

The fraction of the viewport that must still show bounds on each axis after any gesture. Default 0.35.

0 lets a player strand themselves on empty ground with nothing to tap and no idea which way is back; 1 requires the viewport to lie entirely inside the bounds, which pins the map rigidly and feels stuck — and, on a map smaller than the viewport, is unsatisfiable, which is the case Camera.clamp has to detect rather than hand to a min > max comparison.

Camera interface ↳ src/camera.ts:105

interface Camera {

The transform. Every method is a pure function of its arguments and the current state; the only mutation is through the five mutators, all of which re-clamp.

29 members
readonly x: number

World x at the center of the viewport. Read-only, and not merely by convention — see the module header for why the field is unavailable rather than discouraged.

readonly y: number

World y at the center of the viewport. See Camera.x.

readonly zoom: number

World pixels per CSS pixel. Moved only by Camera.zoomAt, so the pointer anchor cannot be skipped.

readonly viewW: number

Viewport width in CSS pixels — never device pixels, never a canvas.

readonly viewH: number

Viewport height in CSS pixels. See Camera.viewW.

readonly bounds: Readonly<Rect>

The reachable world rectangle. The camera's own copy; mutating it does nothing until Camera.setBounds is called, which is the honest half of that trade.

readonly minZoom: number

The zoom-out limit in force — CameraOptions.minZoom, or the default 0.5, or whatever Camera.setZoomLimits last set.

It exists so that nobody has to keep a second copy. A settings panel that draws the zoom slider needs the range the slider is allowed to span; a "zoom out" button needs to know whether it should be disabled; a save file needs to record the policy it was played under. Each of those, given no reader, keeps its own copy of a number this object already holds — and the copies drift the first time anything else moves the limits.

readonly maxZoom: number

The zoom-in limit in force. See Camera.minZoom.

readonly keepVisible: number

The fraction of the viewport that must still show Camera.bounds after any gesture — CameraOptions.keepVisible, or the default 0.35, or whatever Camera.setKeepVisible last set. Read it to show the clamp you are subject to: it is the one number that explains why a pan stopped where it did.

resize(viewW: number, viewH: number): void

Re-clamps against a new viewport size. Call it on every viewport change including an orientation flip: the clamp depends on the half-viewport in world units, so a camera that is not told the window shrank keeps letting the player look outside the map.

Throws

RangeError if either dimension is not a finite number greater than zero.

setBounds(bounds: Readonly<Rect>): void

Replace the reachable rectangle — the island grew, the level loaded — and re-clamp at once, so no frame is ever drawn against bounds the camera has not been checked against.

setZoomLimits(minZoom: number, maxZoom: number): void

Replace the zoom limits — a settings change, a difficulty tier, an accessibility option — and re-clamp at once.

Why this exists beside a zoom that is deliberately unassignable. They are not the same kind of thing. zoom is a position: it moves under a pointer, and the rule zoomAt enforces is that no path may move it without deciding what stays put — origin- anchored zoom is the single most common reason a tile-game camera feels broken, and a set zoom accessor is precisely a path that decides nothing. The limits are policy: they say what the player is allowed to do, they are set by configuration rather than by a gesture, and this method does decide what stays put — the viewport center, exactly as fitBounds decides on the rectangle's center. The invariant was never "zoom is immutable"; it was "nothing changes zoom without naming an anchor", and this names one.

The escape it opens is real and worth stating rather than hiding: setZoomLimits(2, 2) does force zoom to 2 with no pointer involved. It also freezes the zoom permanently, which is a loud symptom and useless as a way to sneak a gesture through. The rule makes the common mistake unrepresentable; it is not a security boundary and was never sold as one.

If the current zoom falls outside the new range it is clamped on the spot, and that can move the view. Raising minZoom past the current zoom pushes the camera in; lowering maxZoom below it pulls the camera out; and either can then move x/y, because the half-viewport in world units changed and the Camera.keepVisible clamp is computed from it. So a minZoom slider dragged live rescales the world under the finger. That is the correct behavior — the alternative is a camera sitting outside its own declared limits until the player's next wheel notch snaps it, at a moment they did not cause and cannot connect to anything — but a panel that does not want the view moving mid-drag should commit on release. It is the same rule CameraOptions.zoom already applies to a stale saved zoom at construction, applied for the rest of the camera's life.

Both limits are taken together, not one at a time, because minZoom <= maxZoom is a relation between them: a single-field setter would have to either reject the halfway state of a slider drag that crosses the other limit, or silently reorder the pair. Taking both makes the invariant a thing the caller states and this method checks.

Throws

RangeError if either limit is not a finite number greater than zero, or if minZoom > maxZoom. The message shape is createCamera's, with this method's name.

setKeepVisible(keepVisible: number): void

Replace the fraction of the viewport that must keep showing Camera.bounds, and re-clamp at once.

Re-clamping is the point, and it means this call can move the camera. Raising the fraction while the player is near a map edge pulls them back toward it in the same statement — with nothing in flight, no animation, no next frame required. Deferring instead would leave a camera showing a view its own policy forbids until the next pan, which is the failure Camera.setBounds already refuses for the same reason.

Throws

RangeError if keepVisible is outside [0, 1]NaN included, which is what an unparsed slider value arrives as and which would otherwise turn the clamp into NaN on both axes and put the camera nowhere.

toScreenX(wx: number): number

world → screen x. Takes wx alone, because screen x depends on world x alone.

This is the form that writes into a Float64Array: pen[i] = cam.toScreenX(wx); pen[i + 1] = cam.toScreenY(wy); — no intermediate object at any point. A caller projecting the eight corners of a box projects four x values, not eight, which is the whole reason the two axes are separate functions.

toScreenY(wy: number): number

world → screen y. See Camera.toScreenX.

toScreen(wx: number, wy: number, out: Vec2): Vec2

world → screen, both axes, into a caller-owned Vec2. Returns out so calls chain.

normalizedX(wx: number): number

Where a world x sits across the viewport: -1 at the left edge, 0 at the center, +1 at the right, continuing past them rather than clamping.

The third member of the projection family, and it exists because @latticekit/audio needs it and may not depend on this package: a sound's stereo pan is the normalizedX of the thing that made it. Unclamped on purpose — how far a pan may go is a mixing policy (audio caps at ±0.6, because full-width panning is unpleasant on headphones and inaudible on a phone speaker) and a policy does not belong in a projection.

There is no normalizedY. Stereo has one axis.

toWorldX(sx: number): number

screen → world x. The exact inverse of Camera.toScreenX.

toWorldY(sy: number): number

screen → world y.

toWorld(sx: number, sy: number, out: Vec2): Vec2

screen → world, both axes, into a caller-owned Vec2.

panByScreen(dxScreen: number, dyScreen: number): void

Pan by a screen-space delta — a drag.

Divided by zoom internally so the world tracks the finger exactly at any scale. Multiplying instead of dividing is the bug where a zoomed-in map slides at a crawl and a zoomed-out one bolts, and it looks like a tuning problem rather than a sign error.

zoomAt(factor: number, sx: number, sy: number): void

Zoom, keeping the world point under (sx, sy) pinned to that screen pixel.

centerOn(wx: number, wy: number): void

Put a world point at the center of the viewport immediately, then clamp.

fitBounds(worldRect: Readonly<Rect>, marginPx?: number): void

Frame a world rectangle: the zoom that makes it fit, then the center that shows it.

The first thing every game does, and the one thing Camera.zoomAt cannot do. zoomAt takes a factor and a required anchor because that is what a wheel notch and a pinch are. Framing a generated world is the other problem: the caller knows the rectangle it wants on screen and does not know — must not have to compute — the ratio between that and the zoom it happens to be at. Written against zoomAt it comes out as zoomAt(want / camera.zoom, viewW / 2, viewH / 2), and that division is this method's absence rather than anyone's style.

Content height enters through the rectangle, and there is nowhere else it can. A rectangle is the whole of what this method knows, so a caller that frames tileBounds(0, 0, w, d, 0, out) frames the ground plane and a 440-pixel summit lands off the top of the screen on the first frame. Pass the map's tallest elevation as tileBounds's heightPx — it extends minY upward, which is exactly the extra span the fit has to pay for — or union in the boxes of whatever stands on the map. There is deliberately no separate height parameter: two ways to say the same thing is how one of them gets passed twice.

The final center is the rectangle's center after the bounds clamp, so on a map smaller than keepVisible demands the two differ, and the clamp wins. Frame first, then read Camera.x if you need to know where it settled.

Zoom is written directly here, and that is not a hole in the anchoring rule. zoomAt exists so that a gesture cannot skip pinning the world point under the pointer; a fit has no pointer and pins the rectangle's center instead. The invariant is "no path changes zoom without deciding what stays put", and this path decides.

Throws

RangeError if any edge is not finite — including the rectMakeEmpty state, which is what an accumulator loop that unioned nothing leaves behind — if the rectangle is inverted, or if marginPx is negative or not finite.

centerOnTile(gx: number, gy: number): void

Put a tile at the center. The form callers actually want after loading a save, and the one that stops every game writing gridToWorld into a scratch vector to do it.

clamp(): void

Re-apply the clamp. Every mutator calls it already; it is exposed for a caller who changed the bounds rectangle by some other route, and for tests asserting idempotence — clamp(); clamp() must be a no-op, and the version that oscillates between two positions is the one that fed min > max to a two-sided clamp.

isVisible(minX: number, minY: number, maxX: number, maxY: number): boolean

Is this world box worth drawing? A cheap AABB reject, generous by one tile on each axis so that geometry poking outside its declared box does not flicker at the edge of the screen.

visibleTileBounds(out: TileRange, marginTiles?: number): TileRange

The conservative grid rectangle covering the viewport — the terrain loop's bounds.

Computed by projecting the four screen corners into grid space and taking the min/max, because the visible region is a diamond in grid space, not a rectangle. A loop derived from a grid-space rectangle silently misses the two side corners of the screen and leaves triangular holes of unpainted ground. The returned range over-covers by roughly 2×, and that is the correct trade against a per-tile diamond intersection test.

visibleWorldBounds(out: Rect, marginPx?: number): Rect

The world rectangle covering the viewport, for culling anything not on the tile lattice — a backdrop gradient, a light pool, a cached scenery chunk.

The Rect-shaped counterpart to Camera.isVisible: that one asks about a box you have, this one hands over the box to test against.

createCamera functionstart here ↳ src/camera.ts:407

function createCamera(viewW: number, viewH: number, options?: CameraOptions): Camera

Build a camera.

Throws

RangeError if viewW/viewH are not finite and positive, if minZoom or maxZoom are not finite and positive, if minZoom > maxZoom, or if keepVisible is outside [0, 1]. Every message names the parameter and the value it got: createCamera: expected viewW to be a finite number > 0, got 0.

gridToScreen function ↳ src/camera.ts:684

function gridToScreen(camera: Camera, gx: number, gy: number, zPx: number, out: Vec2): Vec2

grid → screen, including elevation. The composite the renderer calls most.

zPx is world pixels of elevation and shifts screen y by -zPx * zoom and nothing else — elevation is not a third projection axis. Two different (grid, z) pairs therefore land on the same screen pixel, which is precisely why picking cannot be done by inverting this function: screen → (grid, z) is one equation short of solvable. Pick by walking the sorted order and testing silhouettes, or, on terrain, with screenToTileOnHeights.

depth2 symbols

An order over a frame's ground footprints, and nothing else.

This package owns the comparator, the order, and the backwards walk that makes picking correct. @latticekit/draw owns the items, the passes and the bucket. A draw list is a list of things to draw and iso must not know what a drawable is: the moment a sorter holds ids it is modeling the caller's entities, and the moment it has passes it is a renderer. What is genuinely ours is narrower and sharper — *given a set of ground footprints, what order do they occlude in* — and that is a permutation of integers with no notion of an item at all.

The frame-bucket rule

The integers handed back here index one array, and the caller owns it:

One sorter per frame, one item array per sorter, and every drawable in the frame goes through the same fill. add returns the slot to write the item into; indexAt and pickSorted hand that same slot back.

It is stated here rather than left to each caller because breaking it does not crash. An insertion index is an index into the fill, not into any of the collections that fed it, so a frame that keeps buildings in one array and walkers in another has to translate — and the items[index - buildings.length] that does the translating is a guess about how many items the first collection contributed this frame. It is right until the frame the counts differ, and then a tap opens the building behind the one under the finger: intermittent, invisible in a screenshot, and with nothing in either array to say which one is wrong.

docs/rfc/depth-bucket.md is the long version, and examples/_shared's createBucket is the helper that makes the two writes a single call so they cannot drift apart.

Why this is not a comparison sort

The isometric occlusion rule is

a is strictly behind ba ends before b begins on either axis.

That relation is not a total order and not even transitive, so handing it to Array.sort gives an implementation-defined result that differs between engines and flickers between frames. A scalar depth cannot express it either: in the game this kit came from, buildings sorted on the sum of their far corner and pedestrians on gx + gy, and a pedestrian standing well down the map but far to the left of the headquarters got a larger key and was drawn straight through its wall at second-storey height. Player pass 3 reproduced it twice.

So DepthSorter.sort is a topological sort with a depth-ordered ready set. Genuinely incomparable pairs fall back to depthOf, then to insertion index — the Lattice ordering rule, which is why there is no comparator parameter anywhere in this file.

Why it is not the obvious O(n²)

Kahn's algorithm needs to know when an item has nothing left behind it, and the naive form asks that of every pair. It does not have to. Item b is ready exactly when

b.gx0 < min{ a.gx1 } and b.gy0 < min{ a.gy1 }, over the items not yet emitted

— because "no unemitted a ends before b begins" is a statement about two minima. The a ≠ b the definition asks for turns out not to matter: the only item the exclusion could affect is the one holding the minimum, and for that item the test is b.gx0 < b.gx1, which is true for every footprint with a positive extent. That is why DepthSorter.add refuses a zero-width one by name rather than quietly accepting it.

Both minima only ever rise as items are emitted, so readiness is monotone and four sorted index arrays plus four pointers find every newly-ready item in linear total time. The sort is O(n log n), allocation-free once warm, and identical on every engine.

Elevation is not in it

add takes heightPx for culling only and the sort never reads it. In a 2:1 projection what occludes what is decided entirely on the ground plane; height moves a sprite up the screen, it does not move it towards the viewer. Sorting by gx + gy + z draws a lamp on the ridge in front of the gate that is plainly standing between it and the camera.

DepthSorter classstart here ↳ src/depth.ts:101

class DepthSorter {

An order over a frame's ground footprints: fed rectangles, hands back a permutation.

Holds five numbers per item in flat typed arrays — no ids, no closures, no entities. The game this kit came from pushed { depth, x0, x1, y0, y1, draw: () => … } per item per frame; that is one object plus one closure per sprite per frame, and it was the largest avoidable allocation in the whole renderer.

Fill it, sort it, walk it forwards to paint, walk it backwards to pick. What sits at each position is the caller's business.

One sorter takes everything — buildings, scenery, walkers, ghosts. Two separately sorted lists make trees pop through walls no matter how correct each list is on its own, and that is the deeper reason this holds rectangles rather than typed items: a sorter that knew what a building was would invite a second one for trees.

8 members
#private
get count(): number

Items surviving the cull. Before DepthSorter.sort this is everything added.

get sorted(): boolean

Whether the permutation is currently valid for the contents — the only question a reader of DepthSorter.indexAt needs answered, and the reason it is phrased that way.

Not "has sort ever been called". DepthSorter.add, addPoint and DepthSorter.clear lower it, because they change the set the permutation is a permutation of, and those three are the only ways the contents can move. So the honest question and the cheap flag are the same bit, and the name is about the order rather than about a call in the past.

It exists because nothing else on this surface can tell the two states apart. Before a sort, count is the fill count and indexAt(i) is i — and an unculled frame whose items happened to arrive in depth order is bit-identical to that. Every detector assembled from count and indexAt is therefore a false alarm on a real frame, which is why the frame bucket above this package shipped no detector at all and routed the gap back here: one bit that only this class can set closes it, and nothing outside could.

What it deliberately does not claim is that the cull still matches the camera. A camera that pans after sort() leaves the survivor set stale, and that is not this flag's business: paint and pick read the same stale set from the same instance, so they still agree with each other, and agreement is the property the contract with @latticekit/draw actually rests on.

clear(): void

Drop every item, keeping the buffers. Call it once at the top of the frame — a sorter that is not cleared paints last frame's world underneath this one's.

add(gx: number, gy: number, w: number, d: number, heightPx: number): number

Add a footprint.

Throws

RangeError if w or d is not a positive finite number. A zero-extent footprint is incomparable with everything that shares either of its spans, which is not a corner case but the exact condition the readiness test in this module's header relies on being impossible.

addPoint(gx: number, gy: number, heightPx: number, radius?: number): number

Add a point-like thing — a walker, a floating number's origin, a dropped resource.

Given a small square footprint rather than zero extent, so it can be strictly beside a wall instead of forever ambiguous with it. A true point shares a span with every footprint it stands near and is therefore incomparable with all of them, which is how pedestrians end up drawn through a wall at second-storey height.

Throws

RangeError if radius is not a positive finite number.

sort(camera?: Camera): void

Cull against the camera, then order back-to-front. Allocation-free after warm-up.

Culling lives here rather than in draw because it is a camera-geometry question, not a rendering one — it is Camera.isVisible applied to a footprint's world bounds, extended upward by the height. Every consumer would otherwise write the same six lines and one of them would forget the height, which pops skylines in and out along the bottom edge.

indexAt(i: number): number

The insertion index at sorted position i, 0 ≤ i < count, back to front.

This is the whole output: for (let i = 0; i < s.count; i++) paint(items[s.indexAt(i)]) is the painter's algorithm, correctly.

Throws

TypeError if DepthSorter.sorted is false — before the first sort(), or after an add or a clear invalidated the permutation. This used to return insertion order, "a defined answer rather than a useful one", and defined was the problem: it is indistinguishable from a sorted, unculled frame, so the caller got a plausible integer, painted a plausible-looking frame in fill order, and picked from a permutation that no longer described their items. There is no longer any expression that yields an index from an invalid permutation — this method and pickSorted are the only two readers, and both refuse. A TypeError rather than a RangeError because i is not the wrong value: the receiver is in the wrong state, the same kind of mistake as releasing a pooled instance twice.

Throws

RangeError outside [0, count). Checked second, because before a sort count is the fill count rather than the survivor count, and a range reported against it would name a bound that the caller's next correct step is about to change. An out-of-range read would return undefined from the typed array, and the ! someone would reach for to silence that is how a renderer ships a black screen.

pickSorted function ↳ src/depth.ts:499

function pickSorted(order: DepthSorter, test: (index: number) => boolean): number

What the player tapped: the insertion index of the last-painted item whose test returns true, or -1.

Walks a sorted DepthSorter backwards, so it is the exact reverse of the paint order including the tie-break. A tap on a rack that opened the headquarters beside it — both at the same depth, the pick testing the one that had been painted underneath — was a real, shipped, player-found bug, and it cannot recur as long as the sorter passed here is the one that produced the paint order.

That last clause is a cross-package contract, not a hope. @latticekit/draw paints for i in 0..count: paint(items[order.indexAt(i)]) and must not reorder after sort(); this walks that same instance backwards. The two cannot disagree unless that rule is broken, which is why the contract is written down above both packages rather than left as a comment each side hopes the other read.

DepthSorter.sorted now holds up one half of it. A frame that adds, or clears and refills, between the paint and the tap has changed the permutation under the pick, and that is the half this can see: it throws instead of answering. The other half — a pass that partitions or re-walks draw's own item array while leaving the sorter alone — is invisible from here, because nothing about it touches this object; it stays a contract test above both packages. Knowing which half is enforced is worth more than believing both are.

Parameters
order

the sorter that painted. Named for what it is rather than for its state, now that the state is a property on it.

test

receives the insertion index. Hoist it out of the frame — a closure allocated per tap is a closure allocated per tap, and on a drag that is per pointer event.

Throws

TypeError if order is not sorted. The loop below would throw from indexAt on its first step anyway — but not when count is 0, and that is the case this guard is really for. An unsorted sorter with a count of 0 is an empty one, so -1 would be a true statement about the sorter and a false one about the frame: it reads as "the player tapped empty ground" when the honest answer is "this order does not know what was painted". A caller who genuinely wants the tolerant version — a tap that can arrive before the first frame has rendered — writes if (order.sorted) … and gets to decide what silence means, which is the other thing publishing the bit is for.

tilemap5 symbols

Storage: two strategies behind one two-method interface.

you haveusecosts
a bounded worldTileGridone flat typed array, w × h cells
an unbounded worldtileSourceOfnothing at all

tileSourceOf is the unbounded one, and it is read-only. A function is defined at every coordinate, so a world with no edge needs no storage and no size: pathfinding, culling and placement take TileSource and cannot tell a generated world from an island. Note what that leaves out — the writable unbounded map, a sparse table of chunks allocated on first write. A class for it lived in this file and was deleted; docs/rfc/chunkgrid.md keeps the reasoning, the full surface, and the two implementation facts that are expensive to re-derive.

**What brings it back is a second writer, not a bigger map.** The trigger, in its checkable form: the first time an exhibit or a game has to reallocate a TileGrid because the player built past its edge — a world extended by playing, rather than a large world framed to fill a viewport. Two things sound like that trigger and are not, and mistaking either for it is how the class comes back without having earned it:

it sounds likeit is actually
"this exhibit wants a bigger map"new TileGrid(512, 512) — 256 kB at 8 bits
"this exhibit wants terrain that goes on forever"tileSourceOf, which costs nothing

**One array per layer, not one struct per tile.** A game needing terrain, buildings and movement cost makes three grids. Structure-of-arrays is why a pathfinder can scan a cost layer without dragging terrain colors through the cache, and why @latticekit/persist can take a whole map as one buffer.

Reads are forgiving, writes are not. get outside the map returns the map's out-of-bounds value and never throws, because a pathfinder scanning a border tile must not throw mid-frame; set outside the map throws, because a write outside the map is always a bug and silently dropping it produces a save that is missing exactly the tile the player just changed.

Coordinates are whole numbers. A tile address with a fraction in it is a world pixel that forgot to be converted: has is false for it, get returns the out-of-bounds value, and set throws. No function here floors on the caller's behalf — that would turn the mistake into a plausible answer.

TileSource interface ↳ src/tilemap.ts:55

interface TileSource {

Anything that can answer "what is on this tile" with a number.

Pathfinding, culling and placement take this and nothing more, so a purely procedural infinite world implements it with a closure and pays for no storage at all — and so a game can swap a streamed map for a generated one without touching a line of the code that reads it.

2 members
get(gx: number, gy: number): number

Value at (gx, gy). Out of bounds returns the source's out-of-bounds value; never throws, because this is read inside pathfinding loops that scan past the edge by design.

has(gx: number, gy: number): boolean

Is this tile inside the map's defined region? Always true for an infinite source, which has no outside. Distinguishing "empty" from "absent" is what lets terrain-aware picking report that the ray left the map instead of returning a plausible tile.

MutableTileSource interface ↳ src/tilemap.ts:66

interface MutableTileSource extends TileSource {

A TileSource that can be written to, and that says when it changed.

4 members
set(gx: number, gy: number, value: number): void
Throws

RangeError out of bounds, naming the coordinate and the map's extent.

fill(value: number): void

Every tile to one value.

fillFrom(get: (gx: number, gy: number) => number): void

Fill from a function — a seeded heightfield through core.hash2, a river mask, a noise field. It saves every game the same nested loop, and it is the seam where determinism enters a map: the function sees only coordinates, so the result cannot depend on the order tiles were visited.

readonly version: number

Bumped on every mutation that changes a value.

This is the whole of the cheap-recompute answer. A caller holding a path, a flow field or a cached arc length compares against the version it was built from; when the rockfall is cleared one set bumps this, everything downstream recomputes exactly once, and nothing has to be told what changed. Comparing map contents to detect a change costs more than replanning.

TileGridOptions interface ↳ src/tilemap.ts:96

interface TileGridOptions {

The shape of an island, fixed at construction.

bits and outOfBounds are the two worth thinking about: a value wider than the store wraps silently, because that is what a typed array does, and the out-of-bounds value is what every pathfinder and culler sees when it scans past the shore — set it to whatever your cost function reads as impassable and the search stops at the water for free.

5 members
readonly originGx?: number

Grid origin in tiles. Default 0, 0. Lets an island sit at negative coordinates without every consumer subtracting an offset it might get the sign of wrong.

readonly originGy?: number

See TileGridOptions.originGx.

readonly bits?: 8 | 16 | 32

Storage width per tile. Default 8. Pick the smallest that holds your value set: values wider than the store wrap silently, because that is what a typed array does.

readonly fill?: number

Initial value everywhere. Default 0.

readonly outOfBounds?: number

What TileSource.get returns outside the grid. Default 0. Set it to whatever your cost function reads as impassable and the pathfinder stops at the shore for free.

TileGrid class ↳ src/tilemap.ts:129

class TileGrid implements MutableTileSource {

A fixed rectangle of tiles in one flat typed array. The island.

Bounded on purpose: knowing the extent is what makes the index arithmetic two multiplies and what lets @latticekit/persist write the map as a single buffer with no framing.

13 members
#private
readonly w: number

Width in tiles.

readonly h: number

Height in tiles.

readonly originGx: number

Grid x of the first column.

readonly originGy: number

Grid y of the first row.

readonly data: Uint8Array | Uint16Array | Uint32Array

The backing store, exposed on purpose so saves and workers can take it whole. Row-major from the origin corner: index (gy - originGy) * w + (gx - originGx).

get version(): number

Bumped whenever a write changed a stored value. Compared, never interpreted: the number itself means nothing and only its inequality with a cached copy does.

get(gx: number, gy: number): number

Value at (gx, gy), or the grid's out-of-bounds value outside it and for any coordinate that is not a whole number.

has(gx: number, gy: number): boolean

Is (gx, gy) a tile of this grid? False for fractional coordinates: a tile address with a fraction in it is a world pixel that forgot to be converted.

set(gx: number, gy: number, value: number): void

Write one tile.

Bumps TileGrid.version only when the stored value actually changed — compared after the store truncated it, so writing 300 into an 8-bit grid that already holds 44 correctly counts as no change rather than as one. Callers use the version to decide whether to rebuild a flow field, and a spurious bump costs a Dijkstra sweep.

Throws

RangeError outside the grid, naming the coordinate and the extent.

fill(value: number): void

Every tile to one value, bumping the version once.

fillFrom(get: (gx: number, gy: number) => number): void

Every tile from a function of its coordinates, row-major, bumping the version once.

forEach(range: Readonly<TileRange>, fn: (gx: number, gy: number, value: number) => void): void

Iterate a sub-rectangle, clipped to the grid. The terrain draw loop.

Clipped rather than throwing, because the range this is called with comes from Camera.visibleTileBounds, which deliberately over-covers and will therefore routinely name tiles that are off the map. Half-open on gx1/gy1, like every range in this kit.

tileSourceOf function ↳ src/tilemap.ts:273

function tileSourceOf(get: (gx: number, gy: number) => number): TileSource

A read-only tile source backed by a function — procedural terrain from core.hash2 or core.noise2, or a view that combines two grids.

The unbounded storage strategy, and it costs one export rather than a class. A function is defined at every coordinate, so this is the whole of "a world with no edge" as far as every reader of TileSource is concerned — pathfinding, culling and placement cannot tell it from an island, and none of them allocates anything that depends on how far from the origin they are looking.

Callers who want generate-on-demand with caching hold a Map of TileGrid tiles in the game, which is a dozen lines and keeps the eviction policy where the game can see it. That is deliberately not offered here: see the file header for the trigger that would change it, and docs/rfc/chunkgrid.md for the design if it fires.

has is always true: a function is defined everywhere, so this source has no edge. If your generated world does have one, encode it in the value — an impassable cost, a sentinel height — rather than expecting this to know about it.

height6 symbols

Elevation — as a layer over the tile map, not a third grid axis.

One number per grid vertex, read through a sampler, multiplied into a screen-space y shift. That buys the valley, the river bank, the ridge, the slope-aware movement cost and the flatness test, and it costs nothing anywhere else in the package: the projection stays linear, the depth sort stays two-dimensional, and a game with flat ground never allocates a byte for it.

inout
one height per grid vertex, sampled bilinearlya stack of tiles per column
a screen-space y shift of -zPx · zoomz entering the depth key or the occlusion test
slope, flatness, terrain-aware pickingbridges, overpasses, tunnels, floors above floors

Everything in the left column keeps the projection linear and the sort two-dimensional; everything in the right replaces the depth sort with a different algorithm over a different data structure. A game that wants floors draws one DepthSorter per floor in order, which is what every shipped 2:1 game with floors actually does and which this API already supports at no extra cost.

HeightField interface ↳ src/height.ts:34

interface HeightField {

A tile layer read as terrain height, plus the world pixels one height unit is worth.

Two fields rather than a class, so a game can point one at a TileGrid it saves, or at tileSourceOf(seeded noise) — unbounded, no edge — and store nothing at all.

2 members
readonly heights: TileSource

The layer. Values are height units, whatever the game decided those are — the conversion to pixels lives in HeightField.stepPx so an 8-bit grid can hold a useful range.

readonly stepPx: number

World pixels per height unit. An art constant the game chooses.

TILE_H / 4 is a good first guess, because four steps of rise per tile is where a 2:1 slope stops reading as a slope and starts reading as a wall.

unitsToPx function ↳ src/height.ts:55

function unitsToPx(field: HeightField, units: number): number

Height units → world pixels. The direction everything in this module already goes: heightAt and slopeAt both end in this multiply.

It exists as a function so that the reverse can exist as a function — see pxToUnits, which is the one that was missing.

pxToUnits function ↳ src/height.ts:89

function pxToUnits(field: HeightField, px: number): number

World pixels → height units: the inverse of unitsToPx, and the conversion that was being written by hand at every boundary.

Everything this package produces is world pixels — heightAt, slopeAt, footprintBase, Volume.zPx. Everything a game authors is units: the numbers in the TileSource behind HeightField.heights, the step counts a cost function reasons about, the storey a sprite is drawn at. So / field.stepPx appears wherever the two meet, un-named and un-audited, and a division written by hand is a division nobody can grep for the day stepPx changes.

The canonical use is the slope half of a movement cost, which this module's own slopeAt documentation used to spell out as a raw division:

const cost = 1 + (pxToUnits(field, slopeAt(field, gx, gy)) | 0);

Units here are the game's, not draw's storeys. One height unit is stepPx world pixels and is whatever the game decided a step of terrain is; one storey is LEVEL_H world pixels and is an art proportion that lives in @latticekit/draw with its own pair, levelsToPx/pxToLevels. World pixels are the currency both convert through, and mixing the two conversions gives a building that stands stepPx / LEVEL_H of the way up its own hill — close enough to look like a shading bug.

Throws

nothing. A stepPx of zero yields Infinity rather than an error: this is arithmetic on a per-entity path, and a field with no vertical scale is a construction-time mistake that heightAt has already flattened to a plane by the time anyone gets here.

heightAt function ↳ src/height.ts:110

function heightAt(field: HeightField, gx: number, gy: number): number

Height in world pixels at a fractional grid position, bilinear between the four vertex values the position lies between.

Heights live on grid vertices, not tile centers. heights.get(gx, gy) is the elevation of the north corner of tile (gx, gy), so adjacent tiles share their corner values exactly and their drawn quads cannot leave a seam. A center-sampled heightfield needs an averaging pass to close those seams, it is invisible until the terrain is actually drawn, and every game that starts center-sampled rewrites this later.

Bilinear rather than nearest because walkers are sampled at fractional positions: a nearest-neighbor height makes a pilgrim climb a hill in visible steps.

Floors before sampling, and that matters most at the origin. Math.floor(-0.5) is -1; a truncating | 0 — and core.hash2, which truncates by design — would put -0.5 and 0.5 in the same cell and leave a one-tile seam running through the world origin.

worldToTileOnHeights function ↳ src/height.ts:191

function worldToTileOnHeights(field: HeightField, wx: number, wy: number, maxHeightPx: number, out: Tile): boolean

The tile whose terrain surface is drawn at a world point, or false if the ray that arrives there never meets the ground.

The camera-free half of picking, and the reason it exists as its own function rather than inside screenToTileOnHeights: the camera is not part of this question. Once a caller has a world point, the march is pure heightfield geometry, and the one caller that most needs it — @latticekit/input — deliberately does not hold a live camera at the moment it resolves. Every event it delivers resolves through the camera as it stood when the tick opened, so a handler that recenters the view cannot move where a later event in the same bucket landed. Passing it the live camera would reintroduce exactly that bug; passing it a fabricated one would be a lie about which transform froze.

Why a march at all

The projection stops being invertible once terrain has height: raising a point by HALF_H world pixels and moving it one unit of gx + gy further from the viewer land on the same screen pixel, so world → (grid, z) is one equation short of solvable and worldToTile will confidently return the flat-ground answer — the tile the ray crosses at sea level, which on a hill is many tiles from the one under the player's finger.

So a world point corresponds to a whole family of candidate ground positions, one per elevation t, and larger t means a candidate nearer the viewer. The surface the player can see is the nearest one, so the march starts at maxHeightPx and works down in steps of one grid unit of travel, takes the first elevation at which the terrain reaches the ray, then refines the bracket by bisection — the terrain is bilinear and therefore continuous, which is what makes bisection sound here.

screenToTileOnHeights is this function with a camera in front of it, and is where a caller who has a live camera and a screen pixel should go. That is now true by construction rather than by agreement: the wrapper is camera.toWorldX, camera.toWorldY, and this call. The cross-package pin in packages/input/test/terrain.test.ts § *the two marches are one march* is kept anyway, because it is the test that would notice if anyone unpicked the composition — and input resolving through a frozen transform is exactly the case a refactor inside iso cannot see.

Parameters
maxHeightPx

The tallest terrain on the map, in world pixels, which bounds where the march starts. Pass it: too small and the march begins below a peak and misses it, too large and every pick scans ground that is not there. Negative or non-finite throws.

Returns

true with out filled, or false — leaving out untouched — when the ray leaves the field before it meets ground, or lands where heights.has says there is no map. false rather than a plausible tile, because a tap on the sky that selects the shore is worse than a tap that does nothing. A source whose has answers true everywhere can only report the first of those, which is correct for an unbounded procedural world and is why no caller should treat true as proof that a tile exists in its own map.

slopeAt function ↳ src/height.ts:264

function slopeAt(field: HeightField, gx: number, gy: number): number

The steepest rise between any two edge-adjacent corners of tile (gx, gy), in world pixels.

The four corners of a tile are its own vertex, the two beside it and the far one; the four edges between them are what a walker actually climbs, so the diagonals across the quad are deliberately not measured — a tile whose two diagonal corners differ but whose edges do not is a saddle, and a saddle is not steep.

The terrain half of a movement cost function: cost = 1 + (pxToUnits(field, slopeAt(field, gx, gy)) | 0) is a complete, deterministic "rough ground is slower" rule in one line, and it is what makes a ridge route shorter but harder rather than merely shorter. Through pxToUnits and not a hand-written / field.stepPx, so that the one conversion between this package's pixels and the game's units is greppable.

Floors its arguments, because a tile address with a fraction in it is a bug and answering for two different tiles depending on the fraction would hide it.

footprint7 symbols

Footprints: the grid rectangle a thing stands on, and the four questions a placement system asks about one.

A footprint is w × d tiles with its north corner at (gx, gy). Occupancy, flatness, base height and attachment point are four separate questions on purpose — the oil press needs flat riverside ground and free ground, and a system that conflates the two gives an error message naming the wrong reason, which is worse than no message.

Footprint interface ↳ src/footprint.ts:26

interface Footprint {

An axis-aligned footprint on the ground: w × d tiles with its north corner at (gx, gy).

w runs along +gx (down-right on screen) and d along +gy (down-left). Getting those two the wrong way round rotates every building in the game by ninety degrees, and it is the single most common mistake in a first placement system — the symptom is that square buildings look fine and every rectangular one is wrong.

Read-only, because unlike the out-parameter types in this package a footprint is a property of a placed thing rather than scratch space, and a footprint that changes under a sorter is a draw order that changes under a renderer.

4 members
readonly gx: number

Grid x of the north corner.

readonly gy: number

Grid y of the north corner.

readonly w: number

Extent along +gx, in tiles.

readonly d: number

Extent along +gy, in tiles.

footprintContains function ↳ src/footprint.ts:39

function footprintContains(f: Footprint, gx: number, gy: number): boolean

Does this footprint cover tile (gx, gy)? Half-open: the far edge is not covered, so two footprints laid edge to edge cover every tile exactly once.

footprintOverlaps function ↳ src/footprint.ts:45

function footprintOverlaps(a: Footprint, b: Footprint): boolean

Do two footprints share any tile? The whole of a placement-legality check, and half-open on the same edges as footprintContains, so buildings may touch but not overlap.

forEachFootprintTile function ↳ src/footprint.ts:60

function forEachFootprintTile(f: Footprint, fn: (gx: number, gy: number) => void): void

Call fn once per tile of a footprint, in row-major grid order (gy outer, gx inner).

A callback rather than an array of { gx, gy }, because the alternative allocates w × d objects every time a player drags a placement ghost across the map — sixty times a second, for as long as they are deciding.

Whole tiles only: a footprint at a fractional position visits the tiles from ceil of its corner, which is what "the tiles this occupies" has to mean when the answer must be countable.

footprintFlatness function ↳ src/footprint.ts:83

function footprintFlatness(field: HeightField, f: Footprint): number

How far from flat the ground under a footprint is, in world pixels: the largest vertex height minus the smallest, over the (w + 1) × (d + 1) vertices it stands on.

(w + 1) × (d + 1) and not w × d, because heights live on vertices: a 1×1 building rests on four corners, not one. Sampling the tile origins instead misses the far edge of the footprint entirely, which is exactly where a building on the lip of a cliff is wrong.

Placement legality is footprintFlatness(field, f) <= tolerance, and it is a separate question from occupancy. Returns 0 on level ground, so <= 0 is the strict test, and it is invariant under adding a constant to the whole field — a difference, not an absolute — so raising sea level does not make the whole map unbuildable.

footprintBase function ↳ src/footprint.ts:109

function footprintBase(field: HeightField, f: Footprint): number

The height a footprint's base should be drawn at, in world pixels: the maximum vertex height under it.

The maximum rather than the mean, because a building resting on the mean of a slope has one corner buried in the hill and one floating — and a floating corner reads as a bug where a buried one reads as foundations.

Returns 0 for a degenerate footprint with no vertices, which is the flat-ground answer and the only one that does not propagate an -Infinity into a draw call.

footprintAnchor function ↳ src/footprint.ts:134

function footprintAnchor(f: Footprint, heightPx: number, out: Anchor): Anchor

The Anchor a footprint's label, ring, bubble or confirm control should hang from: the center of the footprint, raised by heightPx.

The center and not the origin corner — on a 3×3 those are most of a building apart, and anchoring UI to the corner is what makes a confirm button appear to belong to the building next door.

It produces an anchor rather than a screen point on purpose: the attachment point is a property of the building, so it is computed once when the building is placed, not sixty times a second against a camera that has not moved.

anchor4 symbols

Attaching a durable thing to the world.

A name tag, a construction ring, a health bar and a walker all need the same thing: a place in the world that survives a pan, a zoom and a re-route. That place is a grid position, which is the currency this whole package deals in — pathSample writes one for a moving thing, footprintAnchor writes one for a static thing, and the three functions here turn either into the three things a world position has to become: a screen point for drawing, a visibility answer for a DOM overlay, and a stereo pan for a sound.

There is no Anchor class, no registry, no subscription and nothing to tear down. An anchor computed against a camera would be stale the next time anyone pans, so none is.

Anchor interface ↳ src/anchor.ts:36

interface Anchor extends GridPoint {

A durable attachment point: where a thing is in grid space, plus how high above the ground plane it hangs.

Three mutable numbers, owned by whoever owns the entity. It extends GridPoint, which is what makes the unification with path sampling literal rather than rhetorical: pathSample(road, s, anchor) writes a walker's position straight into its anchor, no conversion and no intermediate, and the caller then sets zPx from heightAt. A static anchor is written once at placement time and never again.

An overlay must hold its entity's anchor, not a copy of it. A tag that copied { gx, gy, zPx } at creation stays where the building used to be when it moves and stays on screen when it is demolished. iso cannot help — it does not know entity lifetimes — so the rule is that the entity owns exactly one anchor, everything attached to it holds a reference, and whatever destroys the entity destroys the overlay in the same statement.

1 member
zPx: number

Height above the z = 0 plane in world pixels, not tiles and not storeys. On terrain this is heightAt(field, gx, gy) plus however far up the thing hangs.

anchorToScreen function ↳ src/anchor.ts:51

function anchorToScreen(camera: Camera, a: Readonly<Anchor>, out: Vec2): Vec2

Project an anchor to a screen point, now, for this camera. Allocation-free; call it once per anchored thing per frame and never store the result.

This is the function @latticekit/ui should be handed as its project hook and the one @latticekit/draw should call for a world-space label. Both get the same pixel, which is the point: a HUD tag and a canvas ring on the same building must not disagree by a subpixel, and they will if each derives its own.

anchorVisible function ↳ src/anchor.ts:67

function anchorVisible(camera: Camera, a: Readonly<Anchor>, marginPx?: number): boolean

Is this anchor within marginPx CSS pixels of the viewport?

A DOM tag for an off-screen building must be hidden rather than positioned at −4000px: every browser still lays out and composites the second one, and a hundred of them is a measurable frame cost for something nobody can see.

Parameters
marginPx

Slack on every side, default 0. Pass roughly half the overlay's width if a tag should fade out rather than vanish the instant its anchor crosses the edge.

anchorPan function ↳ src/anchor.ts:90

function anchorPan(camera: Camera, a: Readonly<Anchor>): number

Stereo pan for a sound made at this anchor: -1 hard left, 0 center, +1 hard right, unclamped beyond the viewport edges.

The third of the three things a world position has to become. @latticekit/audio cannot compute it because the mapping needs a camera and audio may not depend on this package; the game should not compute it because then every game rewrites it. How far a pan is allowed to travel is a mixing policy and belongs to whoever owns the mixer — clamp it there, not here.

Elevation does not enter it, deliberately: raising a lamp does not move the sound sideways.

hittest6 symbols

screen → what.

Who owns tap → grid cell. iso owns the geometry, @latticekit/input owns the event and the composition, and the composition is one line: input turns a PointerEvent into CSS-pixel coordinates relative to the viewport, decides whether it was a tap or a drag, and then calls screenToTile. The inverse split is unbuildable — a screenToTile living in input would drag the projection, the camera and the heightfield up a layer, and iso cannot own the event because it may not name a DOM global. If a builder finds themselves writing a pointerToTile(ev, …) here, they have the seam the wrong way round.

Three questions, three answers, and the third is the one that replaces the hitTest(state, camera, sx, sy) that input asked for:

the questioncallreturns
which cell is under the pointer?screenToTile(camera, sx, sy, out)a tile, always
…on terrain with height?screenToTileOnHeights(camera, sx, sy, field, maxHeightPx, out)a tile, or false off-map
which object is under the pointer?pickSorted(order, test)the caller's insertion index, or -1

iso cannot take a state parameter — it would have to name the type of a thing it is forbidden to know about, which is the whole reason DepthSorter holds rectangles. The state lives in the closure the caller already has, and nobody holds a registry or sets a pickable flag.

What this file holds is the camera, not the geometry. Every function here starts by asking the camera where a screen pixel is in the world and then does something a camera has no part in: a floor, a polygon crossing count, or — for the terrain answer — worldToTileOnHeights, which lives in height.ts because it wants a world point rather than a viewport. That split is what lets @latticekit/input reach the same march against the transform it froze when the tick opened, and it is why nothing in this file marches a heightfield itself.

Never cache hit boxes during the draw pass. An earlier version of the source game recorded tap targets while painting, so any frame the renderer did not run — a backgrounded tab, a throttled requestAnimationFrame, a paused loop — left the game visibly showing bubbles that could not be tapped. Everything in this file recomputes from state and camera, every time.

screenToTile functionstart here ↳ src/hittest.ts:59

function screenToTile(camera: Camera, sx: number, sy: number, out: Tile): Tile

The tile under a screen point, on flat ground. The exact inverse of gridToScreen at zPx = 0, and the last member of the conversion family: gridToWorld, worldToGrid, worldToTile, gridToScreen, screenToTile.

Floors, never rounds. Math.round snaps to the nearest lattice vertex and returns the wrong tile over three quarters of the area of every diamond; the symptom is a placement ghost that jumps a tile as the pointer crosses the middle of a tile rather than its edge.

@latticekit/input resolves this on every pointer event against the camera as the tick opened, so it is on that package's hottest path: two multiplies, two adds and two floors, no allocation and no branch.

screenToTileOnHeights function ↳ src/hittest.ts:95

function screenToTileOnHeights(camera: Camera, sx: number, sy: number, field: HeightField, maxHeightPx: number, out: Tile): boolean

The tile under a screen point on a heightfield, or false if the ray leaves the map.

Needed because the projection stops being invertible once terrain has height: raising a point by 32 world pixels and moving it one tile further north land on *the same screen pixel*, so screen → (grid, z) is one equation short of solvable and screenToTile will confidently return the flat-ground answer.

This is worldToTileOnHeights with a camera in front of it, and the camera is the entire difference. The march lives in height.ts rather than here because it does not want a camera: once a caller holds a world point the answer is pure heightfield geometry, and the caller that needs it most — @latticekit/input — resolves every event against the transform it froze as the tick opened, so it has no live camera to hand in. Held as two copies of one bisection the two would drift, and the symptom would be a tap that disagrees with the hover ring drawn under it, with each package's suite green against its own copy. Composed, they cannot. Why the march starts high and walks down, and why it bisects rather than stops at a tolerance, is documented once, on worldToTileOnHeights.

Parameters
maxHeightPx

The tallest terrain on the map, in world pixels, which bounds where the march starts. Pass it: too small and the march begins below a peak and misses it, too large and every tap scans ground that is not there. Negative or non-finite throws.

Returns

true with out filled, or false — leaving out untouched — when the ray leaves the field before it meets ground, or resolves to a tile heights.has does not define. false rather than a plausible tile, because a tap on the sky that selects the shore is worse than a tap that does nothing. A source whose has answers true everywhere can only report the first of those, which is why an unbounded procedural field needs a real bound written into it before a tap on the horizon is trusted.

Volume interface ↳ src/hittest.ts:121

interface Volume {

A rectangular volume in a building's local space: offsets and extents in tiles, elevation and height in world pixels.

The units differ because height has no tile. A storey is an art proportion and belongs to @latticekit/draw; iso's entire height vocabulary is world pixels. Mixing the two produces buildings a hundred tiles tall, which is at least an obvious failure.

6 members
readonly ox: number

Offset of the volume's north corner from the anchor tile, along +gx, in tiles.

readonly oy: number

Offset along +gy, in tiles.

readonly w: number

Extent along +gx, in tiles.

readonly d: number

Extent along +gy, in tiles.

readonly zPx: number

Elevation of the volume's base above the z = 0 plane, in world pixels.

readonly hPx: number

Height of the volume itself, in world pixels.

boxSilhouette function ↳ src/hittest.ts:156

function boxSilhouette(camera: Camera, gx: number, gy: number, volume: Volume, out: Float64Array): Float64Array

The screen-space silhouette of one box: six points as [x0,y0, … x5,y5] written into out.

Six, not eight. In a 2:1 projection a box's outline is north-top, east-top, east-base, south-base, west-base, west-top; the two remaining corners always project strictly inside that hexagon. Walking eight corners and taking a convex hull would produce the same shape and cost a hull.

The order is a cross-package contract. @latticekit/draw's solid kit must stroke a box in this same order, or hit-testing and pixels diverge with no test in either package noticing — each is correct against its own idea of the shape. This function is the definition and draw is the conformer, which is why the shared assertion lives in this package's suite.

Only four toScreenX calls happen, because the box's eight corners have four distinct world x values; that is the entire reason Camera.toScreenX takes wx alone.

Parameters
out

Length ≥ 12. @throws RangeError otherwise — a short buffer would leave half the outline as whatever the caller last put there, and a hit test against it would be wrong only for some taps.

pointInPolygon function ↳ src/hittest.ts:213

function pointInPolygon(sx: number, sy: number, poly: Float64Array, count: number): boolean

Even-odd ray cast against count points packed as x,y pairs.

Boundary-exact is deliberately not interesting: a pixel either side of an outline is the same tap, so no epsilon is applied and no effort is spent deciding which side of an edge a point exactly on it belongs to. What is guaranteed is that the answer depends only on the numbers passed in, so two runs of a replay agree.

Parameters
count

Points, not numbers — poly must hold at least 2 × count values.

Throws

RangeError if it does not. Fewer than three points is not an error and is false: a degenerate polygon contains nothing, and throwing would make a caller special-case a volume that happens to be empty this frame.

pointInTile function ↳ src/hittest.ts:252

function pointInTile(camera: Camera, sx: number, sy: number, gx: number, gy: number): boolean

Is a screen point inside the tile diamond of (gx, gy)? Two comparisons, no polygon.

The diamond in world space is the unit square in grid space, so the test is: convert to grid, floor, compare. That identity is a property of the 2:1 projection and is the reason this is cheaper than a four-edge test rather than merely tidier.

For ground-level targets — a selected tile, a road segment — where the footprint is the thing, and as the flat fallback behind silhouette picking: a building is drawn standing up from its footprint, so the pixels showing its body sit over the tile behind it, and resolving a tap to the tile under the cursor means tapping the middle of a rack does nothing. Test the silhouette first, and keep this for things so flat that their silhouette is barely taller than the ground.

path13 symbols

A path is a curve to be sampled, not a list of nodes to be stepped through.

That one claim decides every signature in this file. A node-stepping API forces every consumer to carry a cursor, a remainder and a lerp — per walker, per frame — and the moment a walker has state it has to be saved, replayed, and reconciled when the route changes. Sampling by arc length has none of that:

for (let i = 0; i < n; i++) {
  pathSample(road, (t * speed + (i / n) * road.arcLength) % road.arcLength, here);
  order.addPoint(here.gx, here.gy, heightAt(valley, here.gx, here.gy));
}

Fifty walkers, no per-walker state, nothing allocated, identical on every replay. The same expression drives a crowd, a staggered ignition wave along a road, and the reach number an idle economy is built on. It is also why re-routing is free: nobody holds a route, they hold an arc length along one, and the route is what changed.

Everything here is integer arithmetic, and that is not a style choice

A\* orders its frontier by summed cost. Float summation is associative only by luck, so two engines can pop equal-f nodes in a different order and produce different — both optimal, both different — paths, and a replay that diverges by one tile diverges by everything. Integer 10/14 costs make the order total and the path byte-identical everywhere. For the same reason the heuristic is the integer octile metric and there is no sqrt in it, and pathDirAt returns one of eight direction codes rather than an angle: Math.atan2 is not required to be correctly rounded, so a facing that reached a save file would not survive the trip to another engine.

The one Math.sqrt in this file is arc length, which ECMA-262 does specify exactly.

TileCost type ↳ src/path.ts:54

type TileCost = (gx: number, gy: number) => number

Movement cost of entering a tile: 0 (or less) for impassable, otherwise a positive integer weight where 1 is ordinary ground, 2 is twice as slow, and so on.

Weighted, not binary. Binary walkability cannot say "shorter but rougher", and that sentence is a whole mid-game decision. The step cost is weight × STEP_ORTHO or weight × STEP_DIAG, so a scree tile at weight 3 is exactly three times the road beside it. Keep weights under about 100 so a route's total stays comfortably inside a 32-bit integer.

A cost function is the right place to combine layers: terrain type from one TileGrid, slope from a HeightField, occupancy from another. It is called once per examined neighbor, so keep it arithmetic — no allocation, no Math.pow.

STEP_DIAG const ↳ src/path.ts:61

const STEP_DIAG = 14

Cost of a diagonal step: 14 ≈ 10√2. The integer octile metric — close enough that a diagonal route does not look preferred, exact enough that two engines agree.

DIR_DX const ↳ src/path.ts:75

const DIR_DX: readonly number[]

Unit grid offsets for direction codes 1..8; index 0 is (0, 0) and means "no route".

code12345678
dgx+1+10−1−1−10+1
dgy0+1+1+10−1−1−1

Odd codes are orthogonal and even codes are diagonal, which is the whole of code & 1 ? STEP_ORTHO : STEP_DIAG. These are grid directions, not screen compass points: code 1 runs down-right on screen and code 2 runs straight down.

PathOptions interface ↳ src/path.ts:97

interface PathOptions {

How a search is allowed to move, shared by PathFinder and FlowField.

All five fields are determinism controls as much as behavior ones: change any of them and the same query returns a different — still optimal — route, so a recorded session replayed against different options diverges at the first junction. Pick them once, per game, and keep them with the save.

FlowField.build reads only diagonals and cutCorners: it is a Dijkstra sweep, so it has no heuristic to scale and no frontier to bound — it is bounded by its own rectangle. The other three describe a PathFinder.find.

5 members
readonly diagonals?: boolean

Allow 8-way movement. Default true.

readonly cutCorners?: boolean

Allow a diagonal step when a shared orthogonal neighbor is blocked. Default false, and leave it false: true walks agents through the corner where two walls meet, which looks exactly like clipping through the building.

readonly maxNodes?: number

Hard ceiling on expanded nodes. Default 20000.

Not a performance knob — a determinism and liveness one. A TileSource need not have an edge: tileSourceOf answers has with true everywhere, so on a procedural world an unreachable goal otherwise searches until the tab dies. Nothing else stops it — a bounded grid stops a search by running out of tiles, and an unbounded source never does. The ceiling has to be a node count rather than a time limit so that the same query gives the same answer on a slow phone as on a desktop.

readonly bounds?: Readonly<TileRange>

Confine the search to a tile rectangle, half-open. Cheaper than making the cost function say so, and it is the difference between a failed search that stops and one that explores the whole world first.

readonly minWeight?: number

The smallest weight the cost function will return for any passable tile this search can reach. A positive integer, default 1, and the one number that lets a weighted map keep A\*'s heuristic instead of sliding into Dijkstra.

What it buys

The heuristic is the integer octile metric, which is the true cost of crossing an offset over ground that weighs 1. Tell the searcher the ground weighs at least 3 and the estimate can be three times larger and still never overestimate — see the admissibility argument in PathFinder.find. An estimate that is wMin times too small is an estimate A\* has to buy back by expanding nodes, and the bill is exponential in the gap:

groundminWeightwhat the frontier does
every passable tile weighs 11nothing changes — this is the shipped behavior, to the bit
every passable tile weighs 3 to 83the estimate is three times tighter; the expanded set collapses towards the corridor
tiles weigh 1 to 81nothing changes, and nothing can: one tile of weight 1 anywhere on a cheaper route is enough to make any larger estimate a lie

The third row is the honest limit and is why this is an option rather than a fix. This number is a property of the whole cost function, not of the route, so a single cheap tile holds it down for the entire map. A cost function that returns 1 + roughness gets nothing here; one that returns 2 + roughness — the same ordering, the same ratios, one unit of floor — gets a heuristic twice as tight, for free, forever.

Why the caller declares it rather than the searcher deriving it

It is a property of the cost function, and the cost function is the caller's. Scanning the map for it costs a pass over every tile per search, needs a bound to scan (a TileCost over seeded noise has no edge), and is stale the instant a brush moves the ground — which is the case this exists for. Caching it on the PathFinder would be worse still: one finder serves many cost functions, so the cache would be keyed on nothing. Declaring it beside the cost function it describes also satisfies non-negotiable 11 without a line of code: PathOptions is a plain object the caller built and still holds, so every field is readable back off it and there is nothing to shadow-copy.

Declare it wrong and you get an error, not a wrong road

A minimum higher than the truth makes the heuristic overestimate, and an overestimating A\* returns a route that is merely good while reporting it as cheapest — a wrong answer with no crash behind it, which is the worst failure this module has. So PathFinder.find throws the moment the cost function contradicts the declaration, naming the tile and both numbers. It is one comparison per examined neighbor, on the same line as the integer check that is already there for the same class of bug.

That check covers every tile the search paid to look at, which is a superset of the route it returns and a subset of the map. A tile cheaper than the declaration that the search never reaches at all cannot be caught, and in the rare shape where such a tile sits one step beyond the frontier it can still cost optimality — so the declaration is a promise about the cost function, and the check is the net under it, not a substitute for meaning it.

An integer, and that is not tidiness: octile × minWeight becomes the heap key, and this module's determinism rests on those keys being exact integers (see the file header). minWeight: 1.5 would put a float in the frontier's ordering and hand two engines two different roads.

Path class ↳ src/path.ts:197

class Path {

A route: a polyline through grid space that also knows how long it is.

Nodes are grid coordinates — whole numbers when they came from PathFinder, fractional when the game authored them with Path.push — and alongside them the path keeps the cumulative world-pixel arc length to each node. That second array is what makes pathSample possible and is why this is a class rather than an array of tiles.

World pixels rather than tiles, because the grid→world map is linear but not conformal: one grid unit along +gx is 35.8 world pixels and one along the (1,1) diagonal is 22.6. A walker advanced at a constant rate in grid units visibly speeds up by 58% every time the road turns, which looks exactly like a frame-rate problem and is not one.

There is no length, deliberately: nodeCount and arcLength are different numbers in different units, and a game that computes reach from the node count instead of the arc length gets an economy that pays more for a zigzag than for a road.

12 members
#private
get nodeCount(): number

Number of nodes, including both endpoints. 0 for an empty path.

get arcLength(): number

Total length in world pixels, and the domain of every s parameter in this module. 0 for an empty or single-node path.

get version(): number

Bumped on every mutation. Cache anything derived from the path against it — a crowd's spacing, a reach, a set of lamp offsets — and the recompute happens exactly once.

get searchFailure(): string | undefined

Why the last PathFinder.find writing into this path found nothing — a clause naming the two tiles — or undefined if the last thing to touch it was a successful search, a Path.push or a Path.clear.

This is the boot-time check that the boolean from find cannot be, because the search and the sampling are usually in different modules. A failed search clears its out path, an empty path throws from pathSample and pathProject, and a generated world puts a river across the gate on roughly one seed in fifty — so the first anyone hears of it is a white screen on somebody else's machine, thrown from the render loop, a long way from the search that caused it. A world builder that hands out a Path should either check the boolean where it searched or leave this for whoever receives the path:

if (road.searchFailure !== undefined) {
  // no route, and the clause says between which two tiles. Author a fallback, pick another
  // seed, or refuse to start — but do it here, not sixty frames later.
}

A string rather than a boolean so the reason survives the trip: pathSample quotes it, and "no route from (25, 9) to (7, 22)" is the difference between a bug report and a bug.

noteSearchFailed(fromGx: number, fromGy: number, toGx: number, toGy: number): void

Record that a search found no route, so an empty path can say why instead of only that it is empty.

Called by PathFinder.find on every failing return. Public because a game that authors its routes some other way — a flow field walk, a hand-written spline generator — has the same gap and the same need to say so; harmless to call, and cleared by the next Path.push or Path.clear.

gxAt(i: number): number

Grid x of node i. @throws RangeError when i is out of range, rather than returning undefined for a caller to trip over three systems away.

gyAt(i: number): number

Grid y of node i. @throws RangeError when i is out of range.

sAt(i: number): number

Arc length in world pixels from the start to node i; sAt(nodeCount - 1) is Path.arcLength. @throws RangeError when i is out of range.

push(gx: number, gy: number): void

Append a node, extending Path.arcLength by the world distance from the previous one.

Fractional coordinates are allowed and are how a game hands in an authored road spline: a valley road that is generated rather than searched still needs to be sampled, and it would be a strange API that could only sample the routes it found itself.

clear(): void

Drop every node, keeping the buffers, and bump Path.version. Also forgets any Path.searchFailure: a deliberate clear is not a failed search, and a path that reported one after being emptied on purpose would cry wolf.

compactTo(keep: Int32Array, kept: number): void

Keep the nodes whose index appears in keep[0 .. kept), in order, then reindex. The one mutation pathSimplify needs and the only reason this method exists.

pathSample functionstart here ↳ src/path.ts:407

function pathSample(path: Path, sPx: number, out: GridPoint): GridPoint

The grid position at arc length sPx along the path, written into out.

The most important function in this package. Fifty walkers are fifty calls, no per-walker state, nothing allocated, identical on every replay.

It takes a world-pixel arc length and writes a GridPoint, which is not a mismatch but the point: parameterising by world length is what makes the motion look uniform, and producing a grid position is what lets the result go straight into DepthSorter.addPoint, heightAt and gridToScreen without a conversion — and, because Anchor is a GridPoint, straight into an anchor.

Clamps sPx to [0, arcLength] rather than wrapping. A caller who wants a loop writes the modulo themselves and can therefore also write a ping-pong, a pause at the end, or a queue that bunches up at the gate — none of which a built-in wrap would allow.

O(log nodeCount): a binary search over the cumulative lengths, then one lerp.

Throws

RangeError on an empty path. A walker sampling a path that was cleared this frame would otherwise sit silently at whatever out last held, which is a bug that looks like a rendering problem for as long as it takes to find. When the path is empty because a search failed, the message says so and names the two tiles — see emptyPathReason for why that sentence is worth building.

pathDirAt function ↳ src/path.ts:464

function pathDirAt(path: Path, sPx: number): number

Which of the eight compass directions the path is heading in at arc length sPx, as a direction code for DIR_DX/DIR_DY. 0 on an empty path, on a single-node path, and on a zero-length segment.

A direction code rather than an angle, and that is a determinism decision as much as an ergonomic one: the obvious implementation is Math.atan2, which ECMA-262 does not require to be correctly rounded, so a facing that reaches a save file or a hash is not replayable across engines. Comparing the signs and magnitudes of dgx and dgy against an exact decimal constant is Tier A arithmetic — and is also exactly what a sprite with eight facings wants.

The eight sectors are equal in grid space, because the eight codes are grid directions. They are emphatically not equal on screen: the projection squashes the vertical axis, so the screen angles of the eight are 0°, 26.6°, 90°, 153.4°, 180°, 206.6°, 270° and 333.4°.

pathProject function ↳ src/path.ts:517

function pathProject(path: Path, gx: number, gy: number): number

The arc length of the point on the path nearest to grid position (gx, gy).

The inverse of pathSample, and the function that turns a place into a number: reach is pathProject(road, furthestLitLamp.gx, furthestLitLamp.gy), and an ending that ignites each lamp staggered by its own projection is one line. Without it a game has to store an arc length beside every object on the road and keep the two in sync through every re-route.

Nearest is measured in world space, like every other distance in this module, so a point beside a diagonal stretch of road projects where it looks like it should rather than where the grid metric would put it. Ties go to the smaller arc length, which keeps the answer stable when a road doubles back on itself.

Throws

RangeError on an empty path — there is no point to be nearest to. The message names the reason, including the two tiles when a search is what emptied it.

pathSimplify function ↳ src/path.ts:624

function pathSimplify(path: Path, cost?: TileCost): void

Collapse the staircase: remove collinear runs, then pull the path straight wherever the straight line is passable and does not move the route onto worse ground than it was already on. Mutates in place and shortens Path.arcLength.

A raw 8-way A\* result is a stair of unit steps — a road across open ground comes back as alternating east and south-east moves — and a walker sampled along it weaves from side to side like someone finding their keys in the dark. The artifact reads as "the pathfinder is broken" when the path is in fact optimal. The same staircase also makes arcLength about 8% longer than the road looks, which quietly overpays a reach-based economy.

The pull is cost-aware, and that is not an optimization

A shortcut test that asks only "is this passable?" throws away the weighted route it was handed. Weighted movement cost is this module's headline feature: a searcher told that scree is three times a road, or that a slope is 1 + steps of rise, contours around the hard ground and comes back with a route that is longer and cheaper. Hand that route to a passability-only simplifier and every one of those contours is a shortcut it will happily take, because the expensive ground is still passable — so the road comes back as exactly the straight line the weights existed to avoid, the search having been run for nothing. The only visible symptom is that the road looks wrong, and the natural conclusion is that the cost function is wrong, which it is not.

The rule instead is one sentence: a pull may straighten the route, and may never move it onto worse ground than the route was already on. A shortcut is taken only when every tile its straight line touches weighs no more than the cheapest tile on the stretch of route it would remove.

ground under the runwhat happens
one uniform weightthe line is never worse, so it always wins — the staircase collapses exactly as it did before
a detour around expensive groundthe line enters the expensive ground and is refused, contour intact
a route standing on ground the cost function now refusesthe route is already illegal, so any passable shortcut is taken

A comparison of weights and not of totals, which is the version that was tried first and does not work. Two totals can only be compared through some notion of length, and there is no length here that means the same thing on both sides: PathFinder prices a route by the tile each step enters, while a straight line crosses tiles part-way and clips their corners, so an integral along it is a different quantity that happens to have the same units. On a real heightfield the two disagreed by 12% — enough that a dead-straight line came out "cheaper" than the contour A\* had chosen, which is precisely the bug this is here to prevent. Weights compare exactly, need no metric at all, and cannot be decided by a last-bit difference.

The floor is taken over the route's own nodes, which for a searched route are exactly the tiles it entered and were exactly what the search paid for. A node the cost function now refuses drops the floor to zero or below, which is the third row of the table.

On a map whose weights vary tile to tile — a heightfield with a slope term, which is the case this exists for — that rule refuses most shortcuts, and the collinear pass below is where nearly all the node count goes. That is the correct division of labour: removing a node that lies exactly on the line between its neighbors cannot change the route at all, and moving one always can.

What it allocates

Three small typed arrays, one per re-route: the surviving node list, one weight per node, and the pulled list. The alternatives are a module-level scratch buffer — module-level mutable state, banned by the constitution, and non-re-entrant besides — or three more parameters at every call site. This runs when a route changes, not per frame.

Parameters
cost

Omit to remove only exactly-collinear nodes, which is free and always safe. Pass one — the same one the search used — to also string-pull, which is what makes a route look like a road. Passing a different, stricter predicate used to be the only way to keep a weighted route; it is no longer, and it never should have been. The passability walk is a supercover — it visits every tile the straight line touches, including the ones it only clips — so a pull can refuse a legal shortcut but never accept an illegal one.

PathFinder class ↳ src/path.ts:822

class PathFinder {

A\* over a tile source. Owns its node table and frontier, so a repeated query allocates nothing.

One instance per caller, not one per agent and not a module singleton — module-level mutable state is banned by the constitution and would make two interleaved searches corrupt each other's frontier in a way that reproduces once an hour and never in a test.

Nodes are appended to dense arrays and found through a separate open-addressed index on core.hash2, which is what lets both grow without invalidating the node indices the frontier is holding. An unbounded source — tileSourceOf over seeded noise, which has no edge at all — therefore costs exactly what a bounded island does, and no allocation depends on how far from the origin the search happens to be. A design keyed on grid extent would have made that impossible, which is why this one is not.

2 members
#private
find(cost: TileCost, fromGx: number, fromGy: number, toGx: number, toGy: number, out: Path, options?: PathOptions): boolean

Search.

Throws

RangeError if the cost function returns a non-integer weight for a passable tile. Float costs are the replay divergence this module's header is about, and one comparison per examined neighbor is a cheap price for a bug whose only symptom is two players seeing different roads.

Throws

RangeError if options.minWeight is not an integer >= 1, or if the cost function returns a passable weight below it. The second is the caller's declaration being wrong, and it is thrown rather than tolerated because the symptom of tolerating it is a route that is not the cheapest one with nothing at all to say so.

FlowField class ↳ src/path.ts:1150

class FlowField {

A direction per tile, pointing downhill towards the nearest goal. The answer to "fifty walkers, one depot".

A\* is O(agents × path); a flow field is one Dijkstra sweep over the region, shared by every agent and rebuilt only when the map changes. At fifty agents it is roughly fifty times cheaper, it handles many goals for free — a walker heads for the nearest of six warehouses at no extra cost, which A\* cannot do without six searches — and an agent that spawns mid-frame gets a route with no search at all.

Reachability comes free. "Have I just walled my walkers in?" is dirAt(x, y) === 0 after the wall is placed, or costAt(x, y) < 0. There is no flood-fill export and no connected-component API, because the flow field the game already keeps is the connectivity oracle.

Bounded to a rectangle by construction: an infinite flow field is not a thing.

9 members
#private
get range(): Readonly<TileRange>

The rectangle this field covers, half-open. The field's own object: read it, do not keep it and mutate it.

get builtAtVersion(): number

The map version the last FlowField.build was told about; -1 before the first build and after any build that was not told one.

-1 and not 0, so that an untold field always compares unequal to a real map's version and therefore rebuilds. Failing towards a spare Dijkstra sweep is the right direction to fail: the other way round, the crowd walks the old road for ever.

clearGoals(): void

Forget the previous goals. Cheap; the buffers stay.

addGoal(gx: number, gy: number): void

Add a destination. Tiles outside FlowField.range are ignored rather than an error — a warehouse can legitimately sit off the edge of the field, and refusing to build because of one would take the whole crowd down with it.

build(cost: TileCost, options?: PathOptions, sourceVersion?: number): void

Integrate: one Dijkstra sweep outward from every goal at once.

Deterministic — the frontier is ordered by accumulated cost with ties broken by tile index, which is a total order over the field, so the same map and the same goals give the same field on every engine.

*(The RFC sketched a bucket queue. A binary heap is used instead because the largest edge weight is whatever the caller's cost function returns, and a bucket queue sized for an unknown maximum is either wrong or unbounded. The ordering guarantee is identical, and it is the ordering that the determinism rests on.)*

Throws

RangeError if the cost function returns a non-integer weight, for the reason PathFinder.find gives.

dirAt(gx: number, gy: number): number

Direction code 1..8 to step next, or 0 for "no route from here" — which is also what a goal tile returns, because there is nowhere left to step. FlowField.costAt tells the two apart: a goal is 0 and no route is -1.

costAt(gx: number, gy: number): number

Accumulated cost to the nearest goal in STEP_ORTHO units, or -1 when the tile is unreachable or outside the field.

step(gx: number, gy: number, out: GridPoint): boolean

Sugar over FlowField.dirAt: writes the next tile into out and returns true, or returns false leaving out untouched when there is no route.

index1 symbol