API reference · layer 0

@latticekit/core

Deterministic primitives. Seeded randomness, noise, math, easing, typed events, pools, formatting.

exports100 symbols in 12 modules — start with createRng, hash2, v2, createScope
depends onnothing
environmentisomorphic
gzipped7.40 kB against a 12 kB budget
sourcepackages/core · README · index.d.ts

@latticekit/core — deterministic primitives. Zero dependencies, zero DOM, layer 0.

Everything else in the kit is built on this file, and nothing in it is built on anything. That is the whole design: core imports nothing, holds no module-level mutable state, and reads no clock. Seeds and timestamps arrive as parameters.

The two tiers, because "deterministic" was two claims wearing one word

ECMA-262 specifies + - * /, Math.sqrt, Math.imul and the bitwise operators exactly. It explicitly does not require sin, cos, pow, exp or log to be correctly rounded, so two conforming engines may disagree in the last bit.

arithmeticpromisemay reach
Tier A+ - * /, sqrt, imul, bitwisebit-identical on every enginehashes, save files, replays, anything
Tier Bsin, cos, pow, …correct to within an ulp or sopixels only — never hashed, never persisted

Tier B is four symbols and five call sites in this package, every one marked @tier-b and checked by npm run lint. There are deliberately no sine or expo easings in the kit.

And one thing Tier A does not promise: a round trip through JSON. Infinity is a perfectly Tier A result and is precisely the value that does not survive being written down — it serializes to null, with a valid checksum, so nothing downstream can detect it. That is what expectSerializable and isSerializable are for.

What it promises

  • Zero dependencies and zero DOM references.
  • Two tiers of determinism. Tier A uses only arithmetic ECMA-262 specifies exactly (+ - * /, Math.sqrt, Math.imul, bitwise) and is bit-identical everywhere. Tier B uses sin/cos/pow/exp/log, which the spec does not require to be correctly rounded, and is presentation-only: never hashed, never persisted, never replayed. Every Tier B site declares itself with `@tier-b`.
  • No module-level mutable state. There is no global Rng, deliberately, and no id counter.
  • Sub-streams fork from a stream's identity, not its cursor, so a draw made out of order elsewhere cannot reshuffle this one.
  • Validators return their argument rather than taking a boolean — a boolean has already discarded the value that was wrong, so it cannot name it in the error.

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

rng3 symbols

The seeded stream: one deterministic generator, forked by identity rather than by cursor.

A game built on Lattice must be able to replay a session from a seed and an input log and land on the same pixel. That promise rests entirely on this file, so it obeys three rules that are stricter than the rest of the kit's:

  • Bit-exact across platforms. Every intermediate stays in uint32 space via Math.imul, >>> and ^, whose semantics ECMA-262 fixes exactly. The only division is by 2 ** 32: an exact integer below 2^32 over a power of two is exactly representable as an IEEE-754 double, so Rng.next is bit-identical on every conforming engine. Tier A throughout — no sin, no pow, no exp.
  • No module-level mutable state. There is deliberately no default or global Rng, and no Math.random fallback. A shared implicit stream makes every subsystem's output depend on every other subsystem's draw count, which is the single highest-value absence in the package.
  • One Rng per subsystem, obtained by Rng.derive, never shared. Two subsystems on one stream is the bug; a stream per subsystem is free.

Taken from a shipped game's RNG, with weighted, shuffleInPlace and the hashStep fold added.

RngSnapshot interface ↳ src/rng.ts:59

interface RngSnapshot {

A serialisable capture of an Rng's full internal state.

seed is the stream's identity and never changes with drawing; state is the cursor and advances once per draw. Both are uint32, so this round-trips through JSON losslessly — which is what lets persist store a stream mid-run and resume the exact sequence. Store only one of the two fields and the resumed run either repeats itself or diverges.

2 members
readonly seed: number
readonly state: number

Rng class ↳ src/rng.ts:74

class Rng {

One deterministic random stream (mulberry32).

Instances are mutable — each draw advances the cursor — and must not be shared between subsystems. Two subsystems on one stream means the contents of each depend on how often the other drew, which is a bug that only ever shows up as "the world changed when I opened the menu". Use Rng.derive instead; that is the entire reason it exists.

Construct with createRng, or with Rng.fromSnapshot to resume a saved one.

17 members
readonly seed: number

The stream's identity as a uint32, invariant under drawing.

derive forks from this and never from the cursor, so a child stream is the same stream no matter how far its parent has advanced. It is also what noise and hash want: fbm2(rng.derive('terrain').seed, ...) is a pure function of a number, with no stream to thread through a renderer.

private state

The mulberry32 cursor. uint32, advances once per Rng.nextUint32.

static fromUint32Seed(seed: number): Rng

Build a stream whose identity and cursor are both seed.

static fromSnapshot(snapshot: RngSnapshot): Rng

Rebuild a stream from a snapshot — the save/load and replay path.

Throws

RangeError if either field is not a uint32, i.e. a corrupted or hand-edited save. Accepting it would resume a stream at a cursor that no longer means anything, and the divergence would be blamed on whatever drew next.

nextUint32(): number

The raw generator: one mulberry32 step, uniform over the full uint32 range.

Every multiply is Math.imul because a plain a * b on two 32-bit integers produces up to 64 bits, which exceeds the 53-bit mantissa — and the bits that round away are the low ones, which are the entire output of a hash.

next(): number

A float in [0, 1). Exactly nextUint32() / 2**32.

An integer below 2^32 over a power of two is exactly representable as a double, so this is bit-identical everywhere. Any other normalization — / (2**32 - 1), or the rounded literal * 2.3283064365386963e-10 — reintroduces rounding and quietly breaks replay on one engine out of three.

int(minInclusive: number, maxExclusive: number): number

A uniform integer in [minInclusive, maxExclusive).

Rejection-samples against the largest multiple of the span that fits in a uint32. min + (nextUint32() % span) over-represents the low 2^32 % span values: invisible on a d6, and visible on a one-in-three loot table over a session — a bias with no symptom until someone counts. The expected number of retries is below one.

The bound checks are written out here rather than delegated to guard, deliberately: shuffleInPlace calls this once per element per frame, and guard's own contract is that a validator belongs at an API entry point and not in a per-entity loop.

Throws

RangeError unless both bounds are integers, max > min, and the span is at most 2^32. A span of exactly 2^32 is allowed and never rejects a draw.

float(min: number, max: number): number

A float in [min, max), or exactly min when the bounds are equal.

The quantity that has to be finite is the span, not the bounds. Checking the two bounds and stopping there is a guard that passes at exactly the moment it should fire: float(-Number.MAX_VALUE, Number.MAX_VALUE) has two perfectly finite arguments whose difference overflows, and min + next() * Infinity is Infinity on every draw — or NaN on a draw of exactly 0, since 0 * Infinity is NaN. That is the worst possible value to produce here: JSON.stringify writes both as null, so the number vanishes from a save with the checksum still matching and no layer downstream can see it happen.

Throws

RangeError unless both bounds are finite, max >= min, and max - min is itself finite.

bool(probability?: number): boolean

A coin flip.

probability <= 0 never fires and >= 1 always does, since Rng.next is half-open — so a rate driven to its extremes by a game's own maths degrades to "never" and "always" rather than to an off-by-one.

Throws

RangeError if probability is not finite. NaN would compare false against everything and read as a silently dead branch.

pick<T>(items: readonly T[]): T

A uniformly chosen element.

Throws

RangeError on an empty array. Under noUncheckedIndexedAccess that is the only way to return T rather than T | undefined, and an empty pick is a caller bug in every case we have — a table that is empty for two of four biomes is exactly the shape that shipped a black screen in the source game.

weighted(weights: readonly number[]): number

The index of a weighted choice. Weights need not sum to 1, and zero weights are never chosen.

This exists because every game hand-rolls it and half of them accumulate in a different order each call — a determinism bug with no symptom until a replay diverges. Exactly one draw is consumed regardless of the table's size, so adding a zero-weight row to a loot table does not shift every later draw in the session.

A hole in a sparse array counts as a zero weight, the same as an explicit 0.

Throws

RangeError if weights is empty, contains a negative or non-finite value, or sums to zero. A table that sums to zero has no answer to give, and returning 0 would make "everything is disabled" look like "the first one always wins".

shuffle<T>(items: readonly T[]): T[]

Fisher-Yates.

Not items.sort(() => rng.next() - 0.5): that is not a uniform permutation under any sort algorithm, and its result depends on the engine's sort implementation — so it is non-deterministic across platforms on top of being biased.

shuffleInPlace<T>(items: T[]): T[]

Fisher-Yates in place, returning the same array.

The hot-path form: shuffling a 400-entry draw order every frame must not allocate a 400-entry array every frame. Draws the same sequence as Rng.shuffle for the same input, so switching between them does not change a replay.

derive(...labels: readonly (number | string)[]): Rng

Fork an independent child stream from this stream's IDENTITY, not its cursor.

rng.derive('scenery') returns the same stream whether the parent has drawn zero times or a million. That is what lets terrain stay byte-stable while the UI draws freely, and what lets a per-tick stream be addressed as derive('event', tick). Forking from the cursor instead passes every test and then regenerates the world differently because a menu animation drew a sparkle first — which is the exact failure this method exists to make unreachable.

Labels are order-sensitive: derive('a', 'b') and derive('b', 'a') are different streams, so a (category, id) pair cannot collide with its own transpose. They are also a path: derive('a').derive('b') is derive('a', 'b'), so a subsystem may be handed either a pre-derived stream or the labels to derive with and reach the same world.

Throws

RangeError if called with no labels — an unlabelled fork is a clone, and silently sharing a stream is the bug this method exists to prevent — or if a numeric label is not finite.

snapshot(): RngSnapshot

Capture the full internal state. JSON-serialisable; see RngSnapshot.

restore(snapshot: RngSnapshot): Rng

Restore in place when the identity matches, otherwise return a new instance.

seed is readonly, and a stream that could change identity under you would make every derive call above it a lie — the children would keep forking from an identity their parent no longer has. So a cross-identity restore hands back a different object, and a caller that ignores the return value keeps the stream it already had rather than a silently mutated one.

Throws

RangeError if either snapshot field is not a uint32.

clone(): Rng

An independent copy positioned exactly here. Advancing it never touches this one — which is what lets a system speculate ahead (a preview, a lookahead) without spending draws the real stream will need.

createRng functionstart here ↳ src/rng.ts:416

function createRng(seed: number | string): Rng

Create a stream from a numeric or string seed.

The seed is hashed first, so 1, 2, 3 — or 'level-1', 'level-2' — are well-separated streams and not correlated ones. Pass the key itself, not a pre-hash of it: createRng(hashString(key)) hashes twice, which is harmless but reads as though the first hash were load-bearing, and someone will later optimize away the wrong one.

This is also the answer to "materialise a stream from a key that already identifies the thing": per-instance sprite variation from createRng(spriteKey) is identical whether the sprite is drawn directly, drawn into a cache, or redrawn after an eviction. Allocation is fine there — an Rng is a two-field object and one per cache miss is nothing. One per sprite per frame is not; that case wants hash2/hash3 with toUnit, which allocates nothing and needs no stream at all.

Throws

RangeError if a numeric seed is not finite.

hash9 symbols

32-bit avalanche mixing: one hash, for seeds, coordinates, cache keys and checksums.

Four packages arrived at this module independently and from different directions — persist wanted a save checksum, iso a per-tile scramble, audio a stateless roll over (bar, step, track), draw a sprite-cache key and a frame digest. What they share is not randomness: it is a value that depends only on its coordinates, and not on how many times anything else was drawn, played or saved first. That is a different primitive from a stream, and it lives in its own module so that choosing correctly is the obvious thing rather than the informed thing.

Everything here is Tier A (see the tier table in AGENTS.md). Every multiply is Math.imul, every intermediate is reduced with >>> or ^, and the only division is by 2 ** 32 — a power of two, so it is exact. There is no Math.sin, no Math.pow, and no floating-point accumulation anywhere in the file. The output is therefore the same bits on a phone, in CI, and on a server, which is the only reason a save file may carry a digest at all.

There is deliberately no module-level mutable state: every binding below is a const primitive or a function declaration, so importing this module twice — or importing two copies of it — changes nothing observable.

The one portability seam is hashString, which walks UTF-16 code units. See its own note.

mix32 function ↳ src/hash.ts:69

function mix32(value: number): number

murmurhash3's 32-bit finaliser (fmix32) — an avalanche bijection over uint32.

Why it exists: seeds arrive in narrow ranges (0, 1, 2; tick indices; entity ids). Fed to a generator raw, adjacent inputs produce visibly correlated first draws — three worlds that share their first tree. This spreads them across the whole 32-bit space first, so 1 and 2 are unrelated rather than adjacent.

Being a bijection, it never collides and it has exactly one fixed point: mix32(0) is 0. Do not read a zero result as "this value was never hashed", and do not build a fold out of mix32 and ^ alone — see hashStep.

value is taken modulo 2^32 (ToUint32), so fractions truncate toward zero and negatives wrap; non-finite input becomes 0 rather than throwing, because this is the primitive every other function here is built from and a throw belongs at the boundary.

hashString function ↳ src/hash.ts:94

function hashString(value: string): number

Deterministic 32-bit hash of a string (xmur3-style mixing, fmix32 finalise).

Hashes by UTF-16 code unit, so 'é' as U+00E9 and as U+0065 U+0301 hash differently, and an astral-plane character is hashed as its two surrogates. Normalize (value.normalize('NFC')) before hashing anything that crosses a platform boundary or a save file: a player name typed on macOS and on Windows otherwise seeds two different worlds, and the bug reproduces on nobody's machine. This is the one portability seam in the package, and it is a seam rather than a defect because normalising here would cost every call site an allocation to protect the few that need it.

The length is folded in, so 'a\0' and 'a' differ. 32 bits is a birthday collision at ~77,000 distinct inputs: plenty for cache keys and corruption detection, not a content-address, and never cryptographic.

hashNumber function ↳ src/hash.ts:118

function hashNumber(value: number): number

Fold an arbitrary finite number into a uint32 without discarding its high bits.

Timestamps (~1.7e12) and generated ids both exceed 2^32, and a bare value >>> 0 throws away everything above bit 31 — so a million distinct ids collapse onto a few thousand hashes and two saves an hour apart key the same cache entry. This truncates toward zero and mixes the high and low halves together instead.

hashNumber(0) is 0, inherited from mix32's fixed point. That is safe for a seed (mulberry32's increment is odd, so a zero state is an ordinary state) and is exactly why hashStep carries an odd constant.

Throws

RangeError if value is not finite — NaN and Infinity would both collapse to 0 under ToUint32, silently seeding one stream from two different mistakes.

hashStep function ↳ src/hash.ts:147

function hashStep(accumulator: number, value: number): number

Fold one more value into a running hash. This is the general mechanism; everything below is a convenience over it.

hash2 and hash3 are literally two and three of these, unrolled — not separate algorithms — which is why there is no hash4 and never will be. Four coordinates is hashStep(hashStep(hashStep(hashStep(seed, a), b), c), d): still one expression, still allocation-free, still the same avalanche. A package that needs five does the same thing, and nothing gets added to core.

The step avalanches the incoming value before combining it and avalanches the accumulator after, which is what makes the result non-linear in every argument independently. Combining raw (31 * x + 17 * y, then one mix at the end) passes a naive uniformity test and still bands diagonally, because equal values of 31x + 17y lie on a line and no single final mix can separate them again.

It is order-sensitive by construction — the nesting differs, so (a, b) and (b, a) fold to different values.

value is truncated to int32 (ToUint32 semantics); accumulator likewise.

hashParts function ↳ src/hash.ts:166

function hashParts(...parts: readonly (number | string)[]): number

Combine parts into one uint32, non-commutatively: hashParts('a', 'b') and hashParts('b', 'a') differ. That is what makes (worldSeed, chunkX, chunkY) a usable key rather than one that aliases its own transpose.

A hashStep fold, with string parts routed through hashString first. The part count participates, so hashParts('a', 'b') and hashParts('ab') also differ.

Variadic, so it allocates a rest array, and it accepts strings. Setup and cache keys, not the hot path — use hash2, hash3 or hashStep for numbers in a loop.

Throws

RangeError if called with no parts (the empty key is almost always a bug in the caller's key construction, and returning a constant would make every such bug collide), or if a numeric part is not finite.

hash2 functionstart here ↳ src/hash.ts:199

function hash2(seed: number, x: number, y: number): number

White noise from a coordinate pair: a uint32 that is a pure function of (seed, x, y) and nothing else. No stream, no cursor, no allocation.

This is the per-tile variation primitive — grass tint, prop rotation, whether this cobble is the cracked one. Drawing that from an Rng instead ties every tile's appearance to the order tiles were visited, so the valley reshuffles the first time anything culls, batches or re-sorts, and the bug presents as "the world changed when I bought a lamp". The stateless form is toUnit(hash2(seed, tx, ty)) < 0.1.

The seed argument is not optional and not decorative: two systems sampling the same tile (tree jitter and grass tint) must not receive the same number, or the jitter and the tint correlate and the field reads as a visible grid. Give each system its own constant, or rng.derive('grass').seed.

x and y are truncated to int32. Fractional coordinates therefore hash to their integer cell — which is what a tile lookup wants, and a surprise to anyone passing world pixels. Truncation is toward zero and not Math.floor, so -0.5 and 0.5 land in the same cell 0: west and south of the origin, a fractional coordinate belongs to the cell on the origin's side of it. Floor the coordinate yourself if the cells must be uniform across the axes — which they must be for anything the player can walk past.

hash3 function ↳ src/hash.ts:215

function hash3(seed: number, x: number, y: number, z: number): number

The same, over three integers — hash2 plus one hashStep.

It exists because two packages arrived at it independently from different domains: iso samples a tile grid by (x, y) and needed a third axis for layers; audio's sequencer rolls per (bar, step, track), where there is no stream position to advance and no ordering guarantee between tracks — a track muted at load must not shift what every other track plays.

Beyond three axes, fold with hashStep directly. That is the whole point of hashStep being exported.

hashBytes function ↳ src/hash.ts:238

function hashBytes(seed: number, bytes: ArrayLike<number>): number

Digest an integer array — a frame buffer, a serialized save, any byte sequence.

draw's headless renderer digests a rendered frame to compare against a golden; persist digests a serialized save to detect corruption. Neither can reach hashString without first turning a megabyte of bytes into a string, which allocates the megabyte again to hash it once.

Values are truncated to int32, like every other input in this module. That is correct for Uint8Array, Uint8ClampedArray and integer arrays generally, and silently wrong for a Float32Array — every value between -1 and 1 truncates to zero, so the digest of a normalized buffer becomes a digest of its length and two completely different frames compare equal. View the bytes instead: new Uint8Array(floats.buffer, floats.byteOffset, floats.byteLength).

The length is folded in first, so a run of trailing zeroes cannot be dropped without changing the digest — which is how a truncated save otherwise passes its own checksum. A hole in a sparse array counts as 0.

toUnit function ↳ src/hash.ts:261

function toUnit(hashed: number): number

A uint32 as a float in [0, 1) — exactly hashed / 2**32, the same normalization Rng.next uses, so a hashed value and a drawn value are interchangeable in any threshold test.

It must be / 4294967296 and not / (2**32 - 1) or * 2.3283064365386963e-10: the first is not a power of two so the division rounds, and the second is a rounded literal of the same thing. Either one quietly stops the result being bit-identical, which is the whole promise being made here.

Exists so hash2 can stay integer-valued — a caller who wants a bucket wants % n, not a float they immediately re-scale — without every consumer writing the magic constant. Input is reduced with >>> 0, so a signed int32 from a caller's own bit twiddling cannot produce a negative "probability".

noise4 symbols

Gradient noise and fBm, as pure functions of a seed.

Every function here is a pure function of its arguments — no cursor, no permutation table, no setup call, no module-level state. A tile can therefore be regenerated in isolation, in any order, on any machine, five versions later, which is the property that lets a renderer cull, batch and re-sort freely. Sampling terrain from an Rng instead ties the field to the order tiles were visited; see hash2 for why that presents as "the world changed when I bought a lamp".

Tier A throughout. The gradients come from a fixed direction table selected by hash bits, and not from the shader idiom sin(hash) * 43758.5453 — which is not stable across GPUs, let alone across JS engines, and would silently demote every field built on it to presentation-only. There is no Math.sin, Math.pow or Math.exp in this file; the only transcendental-looking constants are literals of 1/sqrt(2) and 2/sqrt(3), written out so no engine ever computes them.

Determinism has a numeric range, though, and it is stated on each function: coordinates must stay under ~2^24 in magnitude. Beyond that the fractional part loses resolution and the field visibly flattens — the arithmetic is still bit-identical, it is just measuring something else.

noise2 function ↳ src/noise.ts:172

function noise2(seed: number, x: number, y: number): number

2D gradient noise in [-1, 1]. A pure function of (seed, x, y): no cursor, no setup, no permutation table.

Integer coordinates land on lattice points and return exactly 0 (positive zero, so Object.is(noise2(s, 1, 1), 0) holds and a saved value survives a JSON round trip) — sample at a fractional scale (x * 0.06) or you will get a field of zeroes and conclude the noise is broken. That is a property of gradient noise and not a bug: the gradient at a lattice point is dotted with a zero distance vector.

The seed argument separates fields that sample the same coordinates. Two systems on one seed (height and moisture, say) receive the same field and the map reads as one feature painted twice; give each its own constant or rng.derive('moisture').seed.

Coordinates must stay under ~2^24 in magnitude. Beyond that the fractional part loses resolution and the field visibly flattens; beyond 2^31 the lattice indices wrap, and the field repeats.

noise3 function ↳ src/noise.ts:199

function noise3(seed: number, x: number, y: number, z: number): number

3D gradient noise in [-1, 1].

The third axis is usually time — which is how a zero-asset kit animates water, smoke and glow without storing a single frame. Advance z by elapsed * rate and the field flows; because it is a pure function, the same z always renders the same frame, so a replay and a screenshot test both stay stable.

Same contract as noise2: integer coordinates return exactly 0, and every axis must stay under ~2^24.

fbm2 function ↳ src/noise.ts:263

function fbm2(seed: number, x: number, y: number, octaves?: number, gain?: number): number

Fractal Brownian motion: octaves layers of noise2, each at twice the frequency and gain times the amplitude, normalized back into [-1, 1].

The normalization is the point. Un-normalized fBm has a range that depends on the octave count, so raising the detail of a terrain silently changes its sea level — the coastline moves and nobody connects it to the slider they nudged.

Each octave is sampled from its own derived seed rather than from the same field at a doubled frequency, so no two layers share lattice points and the sum has no residual grid in it.

Lacunarity is fixed at 2 rather than exposed: it is the only value anyone uses, it is a power of two (so the frequency ladder stays exact), and a fourth positional number here would be write-only code at every call site.

Parameters
octaves

default 4. Above ~8 the extra layers are below one screen pixel.

gain

default 0.5. Above 1 the sum diverges and the normalization is meaningless.

Throws

RangeError if octaves is not an integer in [1, 16] or gain is not in (0, 1].

fbm3 function ↳ src/noise.ts:285

function fbm3(seed: number, x: number, y: number, z: number, octaves?: number, gain?: number): number

fBm over noise3. Same contract, same normalization, same octave separation.

Parameters
octaves

default 4.

gain

default 0.5.

Throws

RangeError if octaves is not an integer in [1, 16] or gain is not in (0, 1].

math13 symbols

Scalar maths, in the exact forms the rest of the kit depends on.

Everything here is Tier A — + - * /, Math.abs, Math.sqrt, comparison — and therefore bit-identical on every conforming engine, safe to feed a hash, a save file or a replay. The single exception is damp, which is marked @tier-b and is presentation-only.

The forms matter more than the functions. lerp is written the expensive way on purpose, mod exists because % is not a modulo, and damp exists because the smoothing everyone writes by hand is frame-rate dependent. Each doc comment below names the bug the naive version ships.

Nothing in this module allocates: no object literal, no array, no closure. It is called per entity per frame by four packages downstream.

And nothing here calls a guard validator, deliberately. These functions run per entity per frame; a validator in that loop is a measurable cost paid every frame for a mistake a caller makes once, at construction. Validate at the API entry point that accepted the value, and let the arithmetic here propagate NaN where it must — which is why clamp does not swallow one.

TAU const ↳ src/math.ts:30

const TAU: number

Full turn in radians.

Present because 2 * Math.PI otherwise appears in every package in the kit and half of them eventually write 6.28, which is a fifth of a degree short and shows up as a seam where a swept arc fails to close.

EPSILON const ↳ src/math.ts:40

const EPSILON = 1e-9

The kit's default comparison tolerance: 1e-9.

Deliberately not Number.EPSILON, which is the gap between representable doubles near 1.0 (~2.2e-16) and is a property of the format rather than of the game. Comparing world positions against Number.EPSILON means "approximately" never returns true and every settle-detection loop runs forever.

clamp function ↳ src/math.ts:49

function clamp(value: number, min: number, max: number): number

Constrain value to [min, max].

NaN propagates rather than snapping to a bound: both comparisons are false, so a NaN comes back out. That is the wanted behavior — a clamp that silently turned NaN into min would hide the division by zero that produced it until it reached the screen.

clamp01 function ↳ src/math.ts:57

function clamp01(value: number): number

clamp(value, 0, 1). The normalized-time and normalized-progress case, which is most of them; spelled out so call sites do not carry two magic numbers.

lerp function ↳ src/math.ts:74

function lerp(a: number, b: number, t: number): number

Linear interpolation, in the precise form (1 - t) * a + t * b.

NOT a + (b - a) * t, which is one operation cheaper and does not return exactly b at t === 1. A tween that ends at 0.9999999 of its target leaves a sprite a sub-pixel off its tile forever, which presents as text that looks slightly blurry and never as a bug in the tween. No test that checks "approximately" catches it, so the form is the contract: lerp(a, b, 0) === a and lerp(a, b, 1) === b, exactly, for every finite pair.

Unclamped: t outside [0, 1] extrapolates, which is what makes it usable for overshoot.

inverseLerp function ↳ src/math.ts:86

function inverseLerp(a: number, b: number, value: number): number

Where value sits between a and b, as a fraction. The inverse of lerp.

Returns 0 when a === b rather than NaN, because the degenerate case is a progress bar with no range or a gradient with one stop — a display with nothing to show, not an arithmetic fault. A NaN here would propagate through the whole layout before anyone saw it, and it would surface as an invisible element rather than as a divide by zero.

remap function ↳ src/math.ts:102

function remap(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number

Move value from one range to another: inverseLerp then lerp, unclamped.

The workhorse for turning a noise sample in [-1, 1] into a game quantity, or a progress value into a pixel. Unclamped because clamping is a separate decision — a caller that wants the ends held calls clamp on the result and can see it doing so; a caller that wants extrapolation cannot recover it from a version that clamped.

A zero-width input range yields outMin, following inverseLerp.

smoothstep function ↳ src/math.ts:122

function smoothstep(edge0: number, edge1: number, value: number): number

Hermite smoothstep, clamped to [0, 1].

The zero derivative at both endpoints is the entire point: a linear fade meets flat color at an angle, and the eye reads that discontinuity in the slope as a visible band even though the value itself is continuous. Fog, vignettes, distance culling and audio crossfades all want this rather than clamp01.

edge0 === edge1 returns 0 rather than NaN, per inverseLerp.

mod function ↳ src/math.ts:138

function mod(value: number, divisor: number): number

Euclidean modulo: the result carries the sign of divisor, never of value.

-1 % 8 is -1 in JavaScript, which indexes off the front of every wrap-around table in the kit — tile lookups west of the origin, hue wrapping below zero, a ring buffer stepped by a negative delta. Each of those reads as a rendering glitch in one quadrant of the map, never as an arithmetic mistake. Use this and never the operator on a value that can be negative.

mod(v, 0) is NaN, as the operator is: a zero divisor has no answer to give.

wrap function ↳ src/math.ts:151

function wrap(value: number, min: number, max: number): number

Wrap value into the half-open range [min, max).

Half-open on purpose: max maps back to min, so an angle of exactly TAU and an angle of 0 are the same heading and a tiling coordinate at the seam belongs to exactly one tile. A closed range would let two representations of one position compare unequal.

Built on mod, so negative inputs behave.

moveTowards function ↳ src/math.ts:167

function moveTowards(current: number, target: number, maxDelta: number): number

Step at most maxDelta toward target, never overshooting.

The Tier A alternative to damp for anything a replay depends on: constant speed, exact arithmetic, and it arrives — it reaches target exactly and stays, where exponential smoothing only ever approaches. Use it for a build timer, a resource transfer, or any motion whose end state is compared for equality.

A negative maxDelta holds position rather than stepping away from the target: a negative distance is a sign error at the call site, and turning it into motion nobody ordered is how it stays hidden.

damp function ↳ src/math.ts:191

function damp(current: number, target: number, lambda: number, dt: number): number

Frame-rate-independent exponential smoothing — the correct form of "ease toward target".

current += (target - current) * 0.1, the version everyone writes, converges at a rate proportional to frame rate: twice as fast at 120Hz as at 60Hz, and differently again on a machine that drops frames. The camera then follows tighter on a better monitor, which nobody files a bug for and everybody feels. This takes dt and a rate lambda (larger is snappier, units of 1/second) and lands in the same place at any step size.

lambda * dt is the whole model: at lambda = 1 the remaining distance falls by a factor of e per second.

tier-b

— uses Math.exp, which ECMA-262 does not require to be correctly rounded, so two engines may disagree in the last bit. Presentation only. Never feed the result to a hash, a save file, or a replay comparison; use moveTowards when the motion must be Tier A.

approx function ↳ src/math.ts:206

function approx(a: number, b: number, epsilon?: number): boolean

Absolute-difference comparison against epsilon (default EPSILON), inclusive.

Named approx and not equals so that no call site can be read as an exact comparison — the name is the documentation at the place it is needed. Use it for settle detection and for tests; never for a value that keys a map or gates a save, where two "equal" values must be Object.is-equal.

NaN is never approximately anything, including itself.

easing18 symbols

The curve library — thirteen easings, all Tier A.

There is no sine easing and no expo easing in this kit, deliberately. The textbook easeInOutSine is Math.cos, and easeOutExpo is Math.pow; neither is required by ECMA-262 to be correctly rounded, so either one silently demotes every tween that uses it out of Tier A. A tween drives a position, a position gets written to a save, and the save no longer replays. Polynomials and sqrt cover the same feel with an exact guarantee, so bounceOut here is piecewise quadratic rather than a damped sine, and quartOut stands in for the expo curve.

Every curve satisfies e(0) === 0 and e(1) === 1 exactly, not approximately. That is an arithmetic constraint on how each one is written, not a property that comes free: the usual backIn form c3*t³ - c1*t² evaluates to 0.9999999999999998 at t === 1, and a panel that ends its slide two-tenths of a nanometre short is a panel that never fires its "arrived" callback. Values between the endpoints may leave [0, 1] — that is what backIn and backOut are for.

The curves themselves allocate nothing. reverse and inOut allocate one closure each, at authoring time; calling either inside a frame is the mistake their doc comments name.

Easing type ↳ src/easing.ts:33

type Easing = (t: number) => number

A curve from normalized time to normalized progress.

The contract a consumer may rely on: e(0) === 0 and e(1) === 1 exactly, and e is finite across [0, 1]. Inputs outside [0, 1] are the caller's problem — most curves here extrapolate rather than clamp, so a tween that overruns its duration must clamp t before the call, not after.

EasingName type ↳ src/easing.ts:42

type EasingName = 'linear' | 'quadIn' | 'quadOut' | 'quadInOut' | 'cubicIn' | 'cubicOut' | 'cubicInOut' | 'quartOut' | 'backIn' | 'backOut' | 'bounceOut' | 'smooth' | 'smoother'

The name of a built-in curve.

A union of string literals rather than an enum so that { ease: 'backOut' } in a config file, a level definition or a save is checked by the compiler with no import and no runtime value. Adding a name here is a breaking change for anything that persisted the old one.

linear const ↳ src/easing.ts:66

const linear: Easing

Identity. Present so that "no easing" is a value rather than a null check at every call site that takes an Easing.

quadIn const ↳ src/easing.ts:70

const quadIn: Easing

Accelerates from rest. Use for something leaving the screen — an object that starts slow and speeds up reads as departing, and the reverse reads as arriving.

quadOut const ↳ src/easing.ts:74

const quadOut: Easing

Decelerates into rest. The default for anything appearing: it is the cheapest curve that does not stop dead.

cubicOut const ↳ src/easing.ts:89

const cubicOut: Easing

Decelerates harder than quadOut, and the most-reached-for curve in the kit: most of the motion happens in the first third, so the eye registers the change immediately and the settle is still smooth.

quartOut const ↳ src/easing.ts:104

const quartOut: Easing

Decelerates harder still — the "expensive" feel for a panel that slides in. This is the curve to reach for instead of an expo easing, which would cost the module its Tier A guarantee for a difference nobody can see.

backIn const ↳ src/easing.ts:120

const backIn: Easing

Pulls back below 0 before moving. Anticipation without a spring simulation.

Because it leaves [0, 1], never drive an index, an array position, a color channel or anything clamped with it — the excursion is the effect, and clamping it away leaves a curve that visibly stalls at its start.

Written as t³ + c·t²(t - 1) rather than the textbook (c+1)t³ - c·t². They are the same polynomial and they are not the same arithmetic: the textbook form ends at 0.9999999999999998 and starts at -0, and this one is exact at both ends.

backOut const ↳ src/easing.ts:123

const backOut: Easing

Overshoots past 1 and settles back. The same warning as backIn: it leaves [0, 1].

bounceOut const ↳ src/easing.ts:135

const bounceOut: Easing

Four decaying bounces, piecewise quadratic.

Chosen over the usual damped sine so the whole module stays Tier A — see the module note. It stays inside [0, 1] and touches 1 at each bounce apex, so it is safe on a clamped value, unlike the back pair.

smooth const ↳ src/easing.ts:152

const smooth: Easing

smoothstep(0, 1, t): symmetric, zero derivative at both ends. Clamped, so — with smoother — it is one of the two curves here that tolerate a t outside [0, 1] rather than extrapolating into a shape nobody designed.

smoother const ↳ src/easing.ts:161

const smoother: Easing

Ken Perlin's quintic: zero second derivative at both ends as well as the first.

Use it when the curve drives a value that is itself differentiated — a camera pan, where a discontinuity in acceleration reads as a jolt at the start and end of the move even though the position and the velocity are both continuous.

EASINGS const ↳ src/easing.ts:177

const EASINGS: Readonly<Record<EasingName, Easing>>

Every curve above, keyed by name.

This is what lets a tween be authored as data{ ease: 'backOut' } in a config object, a level file or a save — without every consumer growing its own string-to-function switch, each with a different fallback for an unknown name. loop's tween API takes Easing | EasingName and resolves through this table.

A frozen object literal, not a table built by a loop: a loop would run on every import of the package, including in a bundle that only wanted clamp.

reverse function ↳ src/easing.ts:203

function reverse(easing: Easing): Easing

Run a curve backwards: reverse(quadIn) is the corresponding out-curve.

A combinator instead of thirty more constants — and the reason the table above has no quartIn or bounceIn. Allocates one closure per call, so hoist it to module scope or build it at setup; calling reverse inside a frame allocates a function per frame, which is the shape of garbage collection pause that non-negotiable #7 exists to prevent.

Endpoints survive: if e(0) === 0 and e(1) === 1, so does the reversal.

inOut function ↳ src/easing.ts:215

function inOut(easing: Easing): Easing

Mirror an in-curve into a symmetric in-out curve: the first half is the curve at double speed and half scale, the second half is its reflection.

Same reason as reverse, and the same allocation warning. inOut(quadIn) reproduces quadInOut to within rounding; the named constants exist because they are one polynomial rather than two calls, and this exists for the curves that have no named in-out form.

vec222 symbols

2D vectors, written for the frame budget rather than for the call site.

Every function that produces a vector takes out first and returns it. That is non-negotiable #7 made concrete: at 400 sprites and 60Hz a returned { x, y } is 24,000 allocations a second, and a garbage collector pause with a pleasant API is still a pause. out comes first rather than last so the writable argument is visible at a glance at every call site in the kit — you never have to read to the end of the line to find out what got clobbered.

Vec2 is mutable on purpose, and the read side is a separate type. Vec2 is assignable to ReadonlyVec2; ReadonlyVec2 is not assignable to Vec2. The assignability runs exactly one way and it is the useful way, so a caller declares everything — variables, fields, scratch, array elements — as Vec2, and ReadonlyVec2 appears only inside signatures, on parameters that are read. Nobody converts and no call site has to choose. There is deliberately no MutableVec2 in this kit. That one-way rule needs one line of machinery to be true at all — readonly alone does not do it — and the note on READONLY_VEC2 below is the one place in the kit that explains why.

The aliasing rule. Every function here is safe to call with out aliasing any input: v2Add(a, a, b) and v2Normalize(a, a) do what you expect. That is not free — it is why each body reads every component it needs into a local before writing a single one. Adding a function that writes out.x before reading a.y breaks it silently for exactly the callers who were being careful about allocation.

The returned reference is the one you passed in. const mid = v2Lerp(scratch, a, b, 0.5) hands you scratch, and the next call overwrites it. A value that must survive the frame is copied into a vector the caller owns.

Three functions here are Tier B — v2Rotate, v2Angle, v2FromAngle — and each says so. Everything else is Tier A: + - * / and Math.sqrt only.

No function here calls a guard validator, for the reason math gives at more length: this is the per-entity, per-frame path, and a check here is paid every frame for a mistake made once. v2Normalize is the one place a bad input is handled at all, and it returns (0, 0) rather than throwing — because the frame after a division by zero is not the place to throw.

Vec2 interface ↳ src/vec2.ts:72

interface Vec2 {

A mutable 2D point — the storage, scratch and output type of the whole kit.

Mutable on purpose: an out-parameter API cannot take a readonly type, and making the fields readonly here would force a second writable interface into every signature that fills one. Declare your variables and your entity fields as this — there is deliberately no MutableVec2 in the kit, because there is only ever one type a caller declares.

3 members
x: number
y: number
readonly [READONLY_VEC2]?: never

Phantom. Never present at runtime; see READONLY_VEC2 above for what it buys.

ReadonlyVec2 interface ↳ src/vec2.ts:89

interface ReadonlyVec2 {

The read side. Use it for any parameter a function does not write to.

Vec2 is assignable to it, so a caller never converts and no call site has to choose. The reverse is not, which is what stops a frozen shared constant — const ORIGIN: ReadonlyVec2 = Object.freeze(v2(0, 0)) — being handed in as an output parameter. That rejection happens at compile time, where the alternative is a TypeError thrown in strict mode on the one frame that path executes, in the one build nobody type-checked.

3 members
readonly x: number
readonly y: number
readonly [READONLY_VEC2]?: true

Phantom, and the half of the pair that does the work. See READONLY_VEC2 above.

v2 functionstart here ↳ src/vec2.ts:104

function v2(x?: number, y?: number): Vec2

Allocate a vector.

The one function here that allocates, and the reason every other one does not. Call it at setup, when an entity is created, or to build the scratch vectors a system reuses — never inside a loop that runs per frame or per entity. If you find yourself writing v2( inside a render pass, the fix is a scratch vector hoisted to module or system scope.

v2Set function ↳ src/vec2.ts:110

function v2Set(out: Vec2, x: number, y: number): Vec2

Write components into out. The assignment form, so a system can set a position without naming both fields at every call site and without allocating a temporary to copy from.

v2Copy function ↳ src/vec2.ts:118

function v2Copy(out: Vec2, a: ReadonlyVec2): Vec2

Copy a into out. This — not assignment — is how a value escapes a scratch vector: p = scratch aliases the scratch and every later write to it silently moves p.

v2Sub function ↳ src/vec2.ts:133

function v2Sub(out: Vec2, a: ReadonlyVec2, b: ReadonlyVec2): Vec2

out = a - b. Note the order: this is the vector from b to a, which is the direction a "look at" or a separation impulse needs reversed.

v2AddScaled function ↳ src/vec2.ts:153

function v2AddScaled(out: Vec2, a: ReadonlyVec2, b: ReadonlyVec2, scalar: number): Vec2

out = a + b * scalar — the integration step, without a temporary.

v2AddScaled(pos, pos, velocity, dt) is one call where the obvious spelling is a scale into a scratch and then an add. It exists because that scratch is per entity per frame in the only code path that runs for every entity every frame.

v2Lerp function ↳ src/vec2.ts:171

function v2Lerp(out: Vec2, a: ReadonlyVec2, b: ReadonlyVec2, t: number): Vec2

out = (1 - t) * a + t * b, component-wise and unclamped.

Written in the same expensive form as lerp for the same reason: at t === 1 it lands on b exactly, so a tween that finishes leaves the sprite on its tile rather than a sub-pixel off it forever.

v2Dot function ↳ src/vec2.ts:181

function v2Dot(a: ReadonlyVec2, b: ReadonlyVec2): number

Dot product. Zero when perpendicular; its sign says whether b points with or against a, which is how a facing test is written without a single trig call.

v2Cross function ↳ src/vec2.ts:193

function v2Cross(a: ReadonlyVec2, b: ReadonlyVec2): number

The z component of the 3D cross product.

The sign tells you which side of a the point b lies on — positive is counter-clockwise. This is how iso decides facing and polygon winding, and it is exact: a comparison of two cross products orders two directions without an atan2 anywhere, which keeps depth sorting in Tier A.

v2LenSq function ↳ src/vec2.ts:199

function v2LenSq(a: ReadonlyVec2): number

Squared length. Prefer it to v2Len for comparisons and radius tests: it is exact where the square root is merely correctly rounded, and it is a multiply instead of a sqrt.

v2Len function ↳ src/vec2.ts:205

function v2Len(a: ReadonlyVec2): number

Length. Math.sqrt is one of the operations ECMA-262 specifies exactly, so this stays Tier A — unlike Math.hypot, which is not specified exactly and is slower.

v2DistSq function ↳ src/vec2.ts:210

function v2DistSq(a: ReadonlyVec2, b: ReadonlyVec2): number

Squared distance. The one to use inside a proximity loop — see v2LenSq.

v2Normalize function ↳ src/vec2.ts:241

function v2Normalize(out: Vec2, a: ReadonlyVec2): Vec2

Unit vector in the direction of a, or (0, 0) when a has no length.

Returning (0, 0) rather than NaN is deliberate and is the single most valuable decision in this module. A NaN position propagates silently through a whole scene graph — every add, every lerp, every projection downstream of it — and surfaces as an invisible sprite three systems away from the zero-length subtraction that caused it. A zero vector is wrong in an obvious, local, debuggable way.

A NaN or infinite input yields (0, 0) for the same reason, as does a vector whose squared length overflows to Infinity (components beyond ~1e154) — there is no direction to recover once the sum has saturated, and the alternative is NaN again.

The squared length is also where the precision floor sits: components below ~1e-154 square into the subnormal range and the direction loses digits, and below ~1e-162 they underflow to zero and this returns (0, 0). No game coordinate is within a hundred orders of magnitude of that, which is why the fast form is the right one.

v2Perp function ↳ src/vec2.ts:268

function v2Perp(out: Vec2, a: ReadonlyVec2): Vec2

Rotate 90° counter-clockwise: (-y, x).

Exact, no trigonometry, and it is what almost every "perpendicular" in a game actually needs — a normal for a wall segment, an offset for a parallel line, the side vector of a heading. Note the local reads in the body: written the obvious way, v2Perp(a, a) would clobber a.x before reading it and produce (-y, -y).

v2Approx function ↳ src/vec2.ts:283

function v2Approx(a: ReadonlyVec2, b: ReadonlyVec2, epsilon?: number): boolean

Component-wise comparison within epsilon (default EPSILON), inclusive. Named approx and not equals so that no call site reads as exact — two positions that pass this may still hash and serialize differently.

v2Rotate function ↳ src/vec2.ts:298

function v2Rotate(out: Vec2, a: ReadonlyVec2, radians: number): Vec2

Rotate a counter-clockwise by radians.

tier-b

Math.cos and Math.sin, which ECMA-262 does not require to be correctly rounded. Presentation only: a rotated position may differ in its last bit between two engines, so never hash it, never write it to a save, and never compare it for replay equality. Store the angle (Tier A, it is just a number you chose) and rotate at draw time. For the quarter turn, use v2Perp, which is exact.

v2Angle function ↳ src/vec2.ts:319

function v2Angle(a: ReadonlyVec2): number

The direction of a as an angle in (-PI, PI], measured counter-clockwise from +x.

v2Angle(0, 0) is 0 because that is what Math.atan2(0, 0) returns — a zero vector has no direction, and the value is meaningless rather than wrong. Check the length first if the distinction matters.

tier-b

Math.atan2. Presentation only. If you need to order two directions rather than name them, v2Cross does it exactly and faster.

v2FromAngle function ↳ src/vec2.ts:331

function v2FromAngle(out: Vec2, radians: number, length?: number): Vec2

Build a vector of the given length (default 1) pointing at radians.

tier-b

Math.cos and Math.sin. Presentation only: a position produced from an angle is a Tier B value from then on, and everything computed from it inherits that. Keep the angle in your state and derive the vector each frame rather than the other way round.

time6 symbols

The calendar type, and only the type.

loop refused to own an epoch and was right to: its clock is monotonic, performance.now() has no calendar, and a package that cannot stamp anything should not define the stamp. But persist stamps every save, sim integrates elapsed time from that stamp, and the game injects the one function in the whole application that reads a wall clock. Three layer-1 siblings naming the same concept have no common home below core, so core owns the word.

Core still may not read a clock. Non-negotiable #1 is unchanged, lint still bans Date.now() in every src/, and there is deliberately no default implementation of Now anywhere in the kit. Owning the word is not owning the reading.

The two kinds of millisecond are both number and are silently interchangeable, which is precisely the bug: passing a monotonic reading where a calendar instant is expected stamps a save with a number whose origin was the document, so offline accrual credits the few seconds since the page loaded and the report reads "offline progress is broken" rather than "wrong clock". The brands make that assignment a compile error. That is the entire product of this module — everything else here is two calls to guard's expectFinite, at the one boundary where a real clock is read.

Core does not export Millis or Seconds. loop owns those two names for durations, and a second identical alias in core would be exactly the drift this module exists to prevent, with core as the culprit. Durations elsewhere stay plain number with the unit in the parameter name.

EpochMillis type ↳ src/time.ts:48

type EpochMillis = number & {
    readonly [EPOCH_MILLIS]: true;
}

Milliseconds since the Unix epoch — wall-clock calendar time, as Date.now() returns it.

It answers "what time is it", it survives a reload, and it is the only kind of time that may be written to a save file. It can also jump backwards: an NTP correction, a timezone change, or a player setting their clock forward to skip a build timer all move it. Anything that subtracts two of these must tolerate a negative result — and must never feed it to an integrator as a frame delta, which runs the simulation backwards.

Branded, so a monotonic reading cannot be assigned here by accident. The brand is erased at runtime: it costs one call to asEpochMillis at the single Date.now() the kit permits, plus a re-brand after arithmetic, because epoch + 1000 widens to number. That widening is a feature — epochA - epochB is a duration, not an instant, and the type saying so out loud is worth the keystroke.

MonotonicMillis type ↳ src/time.ts:61

type MonotonicMillis = number & {
    readonly [MONOTONIC_MILLIS]: true;
}

Milliseconds from an arbitrary origin — monotonic time, as performance.now() returns it.

It answers "how long since", it never goes backwards, and it is meaningless in a save file: the origin is the document, so a value stamped before a reload compares against a different zero afterwards. It may also freeze while the machine sleeps, which is why loop clamps catch-up and credits nothing for the gap.

Branded for symmetry, and because the confusion runs both ways: using a calendar reading as a frame delta is trap 29, and it is just as expensive.

Now type ↳ src/time.ts:71

type Now = () => EpochMillis

The calendar: the game's single wall-clock reading, injected.

There is exactly one of these per application, the game owns it, and it is almost always () => asEpochMillis(Date.now()). persist takes one to stamp a save; sim takes one to integrate to the present. Injecting it is what lets a test run a year of offline accrual in a millisecond, and what lets lint ban the global read everywhere else.

MonotonicNow type ↳ src/time.ts:82

type MonotonicNow = () => MonotonicMillis

The stopwatch: a monotonic reading, injected. Usually () => asMonotonicMillis(performance.now()).

A separate type from Now so the two cannot be swapped at an injection site — which is the failure this whole module exists to prevent, and which no amount of documentation on a number prevents on its own. Use it for cadence and elapsed time; use Now for the calendar.

asEpochMillis function ↳ src/time.ts:111

function asEpochMillis(value: number, label?: string): EpochMillis

Brand a number as calendar time, at the one boundary where a real clock is read — or where a stored value is read back.

Use it rather than value as EpochMillis. The brand is erased at runtime, so a cast is a claim about data you did not produce: a save hand-edited to "lastSeen": null becomes an EpochMillis of null under a cast, and every later subtraction is NaN with no exception anywhere near the cause. This function is a real check.

Validates finite and nothing else, deliberately. A range check that rejected "this looks like seconds, not milliseconds" would also reject 0 and 1000, which is what every manual clock in every test starts at — so the unit lives in the name and nowhere else. Never divide an EpochMillis by 1000 and keep the type.

The check is guard's expectFinite, not a hand-written one, so this error reads like every other error in the package.

Parameters
label

the caller's symbol, for the error message. Defaults to 'epochMillis' — which names the unit when nothing better is available, since the unit is the thing that goes wrong. Pass the real name ('save.stampedAt') or the message cannot tell anyone where to look.

Throws

RangeError for NaN or either infinity — a number whose value is impossible.

Throws

TypeError for something that is not a number at all, which only a caller from untyped JavaScript or a value straight out of JSON.parse can manage. Wrong kind of thing is a TypeError, wrong value of the right kind is a RangeError, everywhere in this kit.

asMonotonicMillis function ↳ src/time.ts:126

function asMonotonicMillis(value: number, label?: string): MonotonicMillis

Brand a number as a monotonic reading. As asEpochMillis, for the stopwatch.

The same finite-only check, and the same reason: an origin is arbitrary, so no range is wrong. Note that this cannot tell you the value really came from a monotonic source — it brands what you hand it. Call it at the injection site, next to the performance.now(), and nowhere else.

Throws

RangeError for NaN or either infinity; TypeError for a non-number. See asEpochMillis for the split.

dispose3 symbols

One teardown vocabulary for the whole kit.

Before this module, five packages had each invented their own: input returned disposers from a scope, ui from interactive, loop from subscriptions, persist from store handles, audio from buses. A game tearing down a scene had to remember all five, and the one it forgot was a listener that stayed live — invisible for an hour, then the tab is using two gigabytes. Everything in Lattice that binds something now hands back a Disposer, and anything with a lifetime owns a Scope.

There is exactly one ordering rule — reverse registration order — because child() registers the child's dispose into the parent's own list. "Children before parent" is not a second rule to remember; it falls out of the first.

Tier A: no clock, no platform, no allocation beyond the disposer list itself.

Disposer type ↳ src/dispose.ts:30

type Disposer = () => void

Undo one thing.

Idempotent by contract. Calling a disposer twice must be safe and must not undo something else. The failure this rule exists to prevent: a handle is released, its slot is reused by somebody else, and the second call to the stale disposer releases their handle — a bug that presents as a completely unrelated subsystem losing its subscription. Every disposer the kit returns satisfies it, and every disposer a game writes is expected to.

A Scope calls each disposer exactly once, so idempotence is not for the scope's benefit: it is for the caller that also holds the disposer directly and disposes early.

Scope interface ↳ src/dispose.ts:45

interface Scope {

A teardown tree. One per scene, screen, or anything else with a lifetime.

The shape input proved and the kit adopts: a package ships no free-function binder, so a listener can only be created through a scope and an unowned listener is unconstructable. That turns "remember to unsubscribe" from documentation into something the type system enforces, which is the difference between a guarantee and a hope.

This is an interface with a factory rather than a class, deliberately. input had already built one before this module existed; a structural type lets it conform without inheriting anything. Five packages agreeing on a shape is the goal — five packages extending a base class is a different and worse thing.

5 members
add(disposer: Disposer): Disposer

Register a disposer. Returns it unchanged, so a caller can also hold it directly for early disposal without losing the scope's ownership.

Registering on a disposed scope runs the disposer immediately rather than storing it. A subscription created during teardown — by a disposer that emits, say, whose listener subscribes — would otherwise outlive the scope that was supposed to own it, and it is unreachable by definition, so nothing could ever clean it up. That is the leak that survives its own scene.

Throws

TypeError if disposer is not a function. scope.add(handle.close) on a handle that has no close fails here, at the line that made the mistake, instead of silently registering undefined and failing at teardown an hour later.

child(): Scope

A nested scope, disposed with this one.

There is only one ordering rule, because this registers the child's dispose into this scope's own list: everything disposes in reverse registration order. A child created after a resource is torn down before that resource, exactly as if it were one.

Called on an already-disposed scope, the child comes back already disposed — add ran its dispose immediately, per the rule above — so anything registered on it also runs at once and nothing leaks.

dispose(): void

Tear down everything, in reverse registration order, then mark this scope disposed.

Idempotent: the second call does nothing. This is the one that matters in practice — every scene is eventually torn down by both its owner and its parent, and without this the second teardown double-releases everything the first one released.

A throwing disposer does not stop the rest. Every remaining disposer still runs and the failures are collected and thrown together as an AggregateError afterwards, because one bad teardown must not leak the other fourteen.

Safe to pass as a value: scope.dispose closes over its own state and never reads this, so onExit(scope.dispose) works without binding.

Throws

AggregateError if any disposer threw, after all of them have run.

readonly disposed: boolean

True once disposed. Checked in tests and by anything that must not re-enter teardown.

readonly size: number

Registered disposers not yet run.

The leak assertion: a closed screen's scope is zero, and a screen whose count climbs across open/close cycles is registering into a scope that outlives it.

createScope functionstart here ↳ src/dispose.ts:111

function createScope(): Scope

Build an empty scope.

No arguments and no options on purpose — a scope with a policy would be a second thing to agree on, and the whole value of this module is that there is only one.

events1 symbol

A typed synchronous emitter, with a dispatch order a replay can depend on.

Two decisions carry this module, and both are recorded here because both were bugs first.

Dispatch is synchronous, in registration order, over a snapshot. Asynchrony would mean a listener runs on a different tick than the state change that caused it, which is exactly how a replay diverges from a live session. The snapshot is what makes "unsubscribe when the panel closes" safe to do from inside a listener: splicing an array that a for loop is walking skips the next listener, so one unrelated system silently stops.

on returns a Disposer rather than relying on off. Matching a function reference fails silently for this.handler.bind(this), which returns a new function on every call and therefore never matches anything — so every closed screen leaks its entire state. That leak has a name in every codebase that has ever shipped an emitter.

Tier A: no clock, no platform, no randomness. emit allocates nothing.

Emitter class ↳ src/events.ts:48

class Emitter<TEvents extends object> {

A typed synchronous emitter.

Declare the event map as an interface and the payloads type-check at every call site:

interface GameEvents { built: { id: string }; ready: void }
const events = new Emitter<GameEvents>();
const off = events.on('built', ({ id }) => place(id));
events.emit('built', { id: 'mine' });
events.emit('ready', undefined);   // a payload-free event is typed `void`
off();

An event with no payload is typed void and emitted as emit('ready', undefined). The explicit undefined is deliberate: an optional second argument would make emit('built') — a payload-carrying event with its payload forgotten — compile.

The type parameter is constrained to object rather than Record<string, unknown> so that an interface map works. TypeScript gives implicit index signatures to type aliases and not to interfaces, and an emitter that rejects the more natural of the two declarations would be a papercut on every consumer.

7 members
#private
on<K extends keyof TEvents & string>(event: K, listener: (payload: TEvents[K]) => void): Disposer

Subscribe. Returns a Disposer that unsubscribes.

The disposer is idempotent per that type's contract, so it can be handed straight to Scope.add and disposed again with the scene without removing a later listener that happens to be the same function.

Throws

TypeError if listener is not a function — a typo'd method reference otherwise registers undefined and fails inside emit, one stack frame away from any clue.

once<K extends keyof TEvents & string>(event: K, listener: (payload: TEvents[K]) => void): Disposer

Fires at most once, then unsubscribes itself before the listener body runs.

The order is the point: a listener that re-emits its own event — a ready handler that marks the world ready, say — would otherwise recurse until the stack gives out.

off<K extends keyof TEvents & string>(event: K, listener: (payload: TEvents[K]) => void): void

Remove by reference.

Prefer the Disposer from on. This is here for the case where the reference is genuinely stable — a module-level function, not this.handler.bind(this), which creates a new function every call and so never matches. Removing something that was never subscribed is a no-op rather than an error, because teardown paths run twice.

If the same function was subscribed twice, one registration is removed, not both.

emit<K extends keyof TEvents & string>(event: K, payload: TEvents[K]): void

Dispatch, synchronously, in registration order, over a snapshot of the listener list taken before the first call.

A listener that unsubscribes during dispatch is still called this round; one that subscribes during dispatch is not called until the next. Both fall out of the snapshot, and both are what stops "unsubscribe from inside a handler" from skipping the listener that happened to sit next to it.

A throwing listener propagates and the remaining listeners do not run. Swallowing it would turn a crash into a silent half-updated world, which is strictly harder to debug than the crash.

clear(event?: keyof TEvents & string): void

Drop listeners for one event, or every listener when called with no argument.

What a scene teardown calls as a backstop. It is a backstop and not the mechanism: an emitter shared with anything outside the scene loses that owner's listeners too, which is why the primary path is a Scope full of disposers.

listenerCount(event: keyof TEvents & string): number

How many listeners are subscribed to one event.

For tests and leak assertions: a screen that has been closed should be at zero, and a count that grows across open/close cycles is the bug this whole module exists to make visible.

pool2 symbols

Object reuse for the hot path.

A pool exists for one reason: four hundred particles allocated and dropped sixty times a second is a garbage collector pause with a nice API. So the rule this module holds itself to is stricter than the one it enforces — nothing here allocates per acquire or per release. No closures, no wrappers, no bookkeeping objects, no iterators.

That rule is also why there is no releaseAll(). Tracking live instances to support it costs a per-object slot written on every acquire, and the discipline it papers over — release in the same frame you acquire — is the one that keeps a pool honest.

Tier A: no clock, no randomness, no platform.

PoolOptions interface ↳ src/pool.ts:17

interface PoolOptions<T> {

How a pool builds, resets and bounds the instances it hands out.

5 members
readonly create: () => T

Build a fresh instance. Called only when the free list is empty.

readonly reset?: (item: T) => void

Return an instance to a neutral state on release.

Clear every reference here, not just the numbers. A pooled particle that keeps a pointer to the entity that spawned it holds that entity's whole subtree alive, and the leak is invisible because the pool "reuses" objects — which sounds like the opposite of a leak. Set object fields to undefined, not just x and y to 0.

readonly initial?: number

Instances to build up front. Do this at load, not during the first explosion.

readonly max?: number

Hard ceiling on total instances. Exceeding it throws rather than growing, because a pool that grows without bound has become a slower new with extra steps — and the throw names the leak at the moment it happens instead of at the out-of-memory twenty minutes later. Omit for unbounded.

readonly checked?: boolean

O(n) double-release detection. Off by default because it is O(n) per release; turn it on in tests.

A double release puts one object on the free list twice, so two callers are handed the same instance and each sees the other's writes. It is the single nastiest bug this module can cause and the one that looks least like a pool bug — it presents as a physics glitch, or as sprites drawing in the wrong order.

Pool class ↳ src/pool.ts:68

class Pool<T> {

A fixed-shape allocator for one type of object.

const sparks = new Pool({
  create: () => ({ x: 0, y: 0, owner: undefined as Entity | undefined }),
  reset: (s) => { s.x = 0; s.y = 0; s.owner = undefined; },
  initial: 64,
  max: 512,
});
const spark = sparks.acquire();
sparks.release(spark);

A released instance must not be touched again. The pool cannot enforce that — enforcing it would mean a wrapper per instance, which is the allocation the pool exists to avoid — so it is the caller's discipline, and checked: true in tests is how it is verified.

6 members
#private
get size(): number

Instances ever created — not instances currently out.

Watch this flatten. If it climbs forever, something acquires and never releases, and the pool is quietly becoming a leak with a free list attached.

get free(): number

Instances currently available for reuse. size - free is how many are out.

acquire(): T

Take an instance, reusing a released one when there is one.

A reused instance has already been through reset; a fresh one comes from create and is assumed neutral. Either way the caller must write every field it depends on, because "reset" is a contract the pool cannot check.

Throws

RangeError when max is reached. Raising max is occasionally the right fix and is usually the wrong one — a pool at capacity almost always means a release was missed.

release(item: T): void

Give an instance back, resetting it on the way in.

Reset happens here rather than in acquire so that references die at the moment the caller is finished with them. Resetting on acquire would hold the last user's entity graph alive for as long as the instance sat on the free list.

Throws

TypeError on a double release when checked is on. It is a TypeError and not a bare Error because the argument is invalid for this operation — the instance is not live — and per the constitution an error names the caller's mistake with the right kind.

preallocate(count: number, label?: string): void

Build count instances into the free list ahead of time.

The first explosion of the session is the worst moment to allocate four hundred particles, and it is also the moment the player is most likely to be watching.

Throws

RangeError if count is not a non-negative integer, or if it would push the pool past max — which is a sizing mistake, and better found at load than mid-frame.

guard11 symbols

Validators, not assertions.

Every function here takes a value, throws an error that names what it got, and returns that value. That shape is the whole design, and it is chosen against the habit everyone arrives with:

assert(zoom > 0.25 && zoom < 8, 'bad zoom');          // what this kit does not have
this.zoom = expectRange(zoom, 0.25, 8, 'camera.zoom'); // what it has instead

A boolean has already discarded the value that was wrong, so its message can only ever be prose — which is precisely the failure the constitution's rule 9 names. And assert is the exact call shape build tools strip in production, so the check would run only where it is least needed. A validator that returns its argument cannot be stripped, because the call site does not compile without the result.

Every message follows rule 9: the caller's symbol, the expectation, and the value received. camera.zoom: expected a finite number in [0.25, 8], got -1.

These run at construction and at API entry points. They do not run per frame or per entity: a guard inside a per-sprite loop is a measurable cost for a mistake a caller makes once. Tier A — no clock, no randomness, no platform.

expectFinite function ↳ src/guard.ts:69

function expectFinite(value: unknown, label: string): number

Reject NaN and both infinities.

The guard for anything that will be multiplied into a position, a volume or a rate. NaN is the value that spreads: one of them in a velocity turns a position into NaN, which turns a camera target into NaN, and the screen goes blank a hundred frames from where the mistake was made. Catching it at the entry point is the difference between a stack trace and an afternoon.

Throws

RangeError naming the label and the value.

expectInt function ↳ src/guard.ts:86

function expectInt(value: number, label: string): number

Reject anything that is not a whole number, including NaN and the infinities.

For the things that are counted rather than measured: tile coordinates, octave counts, item quantities. A fractional tile index silently floors somewhere downstream, and the tile that gets drawn is one the caller never asked about.

Throws

RangeError naming the label and the value.

expectRange function ↳ src/guard.ts:103

function expectRange(value: number, min: number, max: number, label: string): number

Inclusive on both ends: min <= value <= max.

NaN fails, because the comparison is written so that it must — !(value >= min && value <= max) rather than value < min || value > max, which lets NaN through both tests and is the single most common way a range check does nothing at all.

Throws

RangeError naming the label, both bounds, and the value.

expectIndex function ↳ src/guard.ts:123

function expectIndex(index: number, length: number, label: string): number

An integer index in [0, length) — the half-open interval arrays actually use.

Returns the index, so it sits inside the subscript: items[expectIndex(i, items.length, 'tileMap.at')]. Under noUncheckedIndexedAccess an out-of-range read is undefined and the type system tells you so; this is for the cases where the index came from outside and the caller wants the mistake named rather than propagated as an undefined.

Throws

RangeError if index is not an integer in [0, length).

expectNonEmpty function ↳ src/guard.ts:142

function expectNonEmpty<T>(items: readonly T[], label: string): readonly T[]

Reject an empty array, returning it otherwise.

The guard for every "pick one of these" API. An empty weight table, an empty biome list, an empty palette: each of them returns undefined from an index that the code around it treats as always present, which is how a ! gets added and how a black screen ships.

Throws

TypeError if items is not an array, RangeError if it is empty.

expectSerializable function ↳ src/guard.ts:169

function expectSerializable(value: unknown, label: string): number

Reject a number that will not survive JSON.stringify — the save-path guard.

JSON.stringify(Infinity) is "null", and so is NaN. That is the worst corruption shape in the kit: the bytes are intact, the checksum matches, the schema is the right shape, and an infinite stock silently returns as nothing. No layer downstream can detect it, which is why the check belongs at the moment of writing rather than the moment of reading.

Normalizes -0 to 0, because JSON.stringify(-0) is "0" and a value that changes across a round trip fails an integrity comparison for a reason nobody will ever find.

This is not a magnitude cap. 2 ** 60 and 1e308 pass, and they round-trip through JSON exactly — 2^53 is an arithmetic limit, not a serialization one, and the guard for that is expectSafeInteger.

Throws

RangeError naming the caller and the value.

isSerializable function ↳ src/guard.ts:187

function isSerializable(value: number): boolean

The non-throwing form — the load-path predicate.

persist's invariant is that a corrupt save degrades to a fresh one with a reported reason and never throws on boot, so the load path needs to ask rather than assert. Same rule as expectSerializable, both directions of the boundary: it returns false for exactly the inputs that make the other throw, and for no others.

expectSafeInteger function ↳ src/guard.ts:205

function expectSafeInteger(value: number, label: string): number

Reject a count that has left the exactly-representable integers.

For quantities that are counted rather than measured: buildings owned, ticks elapsed, entity ids. Above 2^53 a double cannot hold consecutive integers, so n + 1 quietly becomes n and two different logical values compare equal — an id allocator stops allocating and every entity after it is the same entity.

Deliberately not applied to an idle economy's stocks: those are measured quantities from a closed-form curve, 1e40 is a perfectly good double, and capping them would break the genre. Count with this; measure without it.

Throws

RangeError naming the caller and the value.

unreachable function ↳ src/guard.ts:226

function unreachable(value: never, label: string): never

Exhaustiveness check for a discriminated union.

In the default branch of a switch, unreachable(kind, 'building.kind') stops compiling the day a case is added — which is the only kind of error worth having, because it is found by the person adding the case rather than by a player finding a building that does nothing.

The runtime throw is the backstop for the value that arrived from outside the type system — a save file, a network message, a hand-edited config — and it is a TypeError because the value is of a kind this code has never heard of.

expectObject function ↳ src/guard.ts:255

function expectObject(value: unknown, label: string): Record<string, unknown>

Narrow an unknown to a plain object with string keys.

The one non-numeric guard here, and it exists because every save recognizer in the kit was otherwise hand-rolling the same six lines. A recognizer receives whatever JSON.parse produced — which may be null, an array, a string, or a number — and has to get from unknown to something it can read a field off. Without this, each one writes its own typeof x === 'object' && x !== null && !Array.isArray(x) and half of them forget one of the three clauses.

All three matter, and the two that get forgotten are the interesting ones. typeof null is 'object', so a save whose payload is the literal null sails past a naive check and fails later on a property read, at a point that no longer names the save. And an array is an object, so a payload that was serialized as […] when the schema expected {…} reads as valid until a field comes back undefined — which a permissive migration will then happily carry forward as a default.

Returns a Record<string, unknown> rather than a generic T, deliberately. Narrowing to the caller's own type is a claim, and this function has checked only the shape; handing back T would let a recognizer skip the field checks that are the entire reason it exists.

Parameters
value

Anything, typically straight out of JSON.parse.

label

The caller's symbol, for the message.

Throws

TypeError naming what arrived instead.

expectRecordOfFinite function ↳ src/guard.ts:283

function expectRecordOfFinite(value: unknown, label: string): Record<string, number>

Narrow an unknown to a plain object whose every value is a finite number.

The shape a stock vector, a resource wallet or a settings blob arrives in, and the one a recognizer most often wants. Checking the values here rather than at first use is what lets a corrupt save be reported as corrupt instead of turning into NaN three subsystems later — and NaN is the value that spreads, so the distance between the bad byte and the blank screen is otherwise arbitrary.

Note that this rejects a value that is merely non-finite as firmly as one that is not a number at all, and it should: Infinity does not survive JSON.stringify — it becomes null, with a perfectly valid checksum over it — so a stock that reads back as null is evidence of a write that should never have happened.

Parameters
value

Anything, typically straight out of JSON.parse.

label

The caller's symbol, for the message.

Throws

TypeError if it is not an object, or if any value is not a finite number. The message names the offending key, because "expected finite numbers" without one sends the reader to look at all of them.

format8 symbols

Numbers a player can read at a glance, with no Intl.

Taken from a shipped game and sharpened. Three properties are load-bearing and each one is a bug that shipped before it was a rule:

  • Bounded width. fmtCompact is never wider than six characters, ever, for any finite double. A resource pill that reflows as the number grows makes a HUD feel unstable, and the reflow lands exactly when the player is watching the number.
  • Never NaN on screen. A non-finite input formats as an em dash. A player reads NaN as a broken game; they read as "not yet".
  • Locale-free by construction. ASCII digits, an ASCII comma, no Intl. Intl.NumberFormat formats differently across engines and ICU versions — which makes a screenshot test and a save file both platform-dependent — and constructing one inside a frame is one of the slowest things you can do. A game that wants French grouping formats in its own layer.

This module is deliberately free-standing: it imports nothing, not even from the rest of core. Its place in layer 0 rests on draw alone (canvas text; ui chose to format in the game layer), so if that second consumer never materialises this module moves out — and the move stays cheap only for as long as nothing here has grown a dependency.

Tier A: + - * /, Math.abs/floor/round, and the exactly-specified toFixed and toExponential. No transcendentals.

COMPACT_SUFFIXES const ↳ src/format.ts:48

const COMPACT_SUFFIXES: readonly string[]

The magnitude ladder: ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc'].

Exported so a game can render its own ladder in the same tiers, and so a test can assert the boundary behavior at every tier without duplicating the table. Frozen, because a consumer that mutated it would change every number in the game from one line in one file.

The ladder tops out at Oc = 10^27. Past that fmtCompact switches to exponential form rather than inventing suffixes nobody can rank — an idle economy really does reach 1e40, and 'Qig' communicates nothing while '1e40' communicates everything.

fmtCompact function ↳ src/format.ts:123

function fmtCompact(value: number, decimals?: number): string

Compact magnitude: 12500'12.5K'.

An idle game lives on this function — a player reads a magnitude in a glance with their thumb already moving. Output is never wider than six characters, sign included, for every finite double up to 1e308, so a resource pill never reflows and a wallet that changes width as you play never makes the HUD feel unstable.

The width bound is what decides the decimals, not the other way round. 999_900 is '999.9K' at six characters, -999_900 is '-999K' at five: when the decimal will not fit, it goes, because a truncated digit costs less than a moving layout. Below the first suffix a whole number stays whole — 250 is '250', never '250.0'.

Values are truncated, never rounded up across a tier: 999_950 is '999.9K' and never '1000.0K'. Non-finite input returns '—'; a HUD showing NaN reads as a broken game.

Parameters
decimals

digits of mantissa to attempt. Default 1. More than the width allows are dropped rather than honoured.

Throws

RangeError if decimals is not an integer in [0, 6].

fmtSigned function ↳ src/format.ts:157

function fmtSigned(value: number): string

Compact magnitude with an explicit sign on positives: '+12.5K'.

For deltas, where the sign is the information — an offline-earnings summary, a trade preview, a stat comparison. Zero is rendered without a sign: '+0' next to a stalled production line reads as progress, which is the one thing it is not.

fmtInteger function ↳ src/format.ts:172

function fmtInteger(value: number): string

Grouped integer: 1234567'1,234,567'.

Always an ASCII comma — a locale-aware separator would make a screenshot test engine- dependent, and a save file that embedded one would be worse. Unlike fmtCompact this is unbounded in width, so it belongs in a tooltip or a detail panel, not in a HUD pill.

Rounds to the nearest integer, and falls back to compact form above 1e21, where a double has no exact digit string to group and String() itself switches to exponential.

fmtRate function ↳ src/format.ts:194

function fmtRate(perSecond: number, suffix?: string): string

A per-second rate: '1.2/s'.

Rates get an extra decimal below one, because early-game rates are below one and '0/s' next to a visibly filling bar is the kind of thing players file bugs about. Below one hundredth of a unit the readout becomes '<0.01/s' rather than rounding to zero — the same complaint, one order of magnitude down, and the < is honest where '0.00' is not.

Parameters
suffix

default '/s'. Pass '/min', '/tick' or '' for anything measured on another cadence; the number is formatted the same way regardless.

fmtPercent function ↳ src/format.ts:217

function fmtPercent(fraction: number, decimals?: number): string

0.075'7.5%'.

Takes a fraction, not a percentage, so there is one convention in the kit and not two. The bug this prevents is silent and permanent: a progress bar fed 0.5 where it wanted 50 looks plausible at every value, and nobody notices until a designer asks why nothing ever fills.

Width is deliberately fixed by decimals rather than trimmed — a percentage that switches between '7.5%' and '8%' as it changes makes a row of stats jitter.

Throws

RangeError if decimals is not an integer in [0, 6].

DurationStyle type ↳ src/format.ts:230

type DurationStyle = 'short' | 'clock'

How fmtDuration renders.

'short' reads better in prose and in a tooltip; 'clock' is width-stable for a countdown, which matters because a timer that changes width every ten seconds drags the layout around it once a second.

fmtDuration function ↳ src/format.ts:251

function fmtDuration(seconds: number, style?: DurationStyle): string

'2m 30s' (short) or '02:30' (clock).

Seconds in, always — never milliseconds. Negative input clamps to zero, because the only things that produce a negative duration are a clock correction and a subtraction in the wrong order, and '-3s remaining' on a build timer is worse than '0s'.

Rounds before splitting, not after: rounding each component separately is how 59.6 becomes '0m 60s'. Short style carries at most two units, largest first, and drops a trailing zero unit — '2h', not '2h 0m'.

Throws

TypeError if style is not a DurationStyle. A typo'd style is otherwise a silent fallback to the other format, which reads as a layout bug.