API reference · layer 2

@latticekit/input

Normalized input: pointer, touch and keyboard into one per-tick sample stream, plus gestures, an action map, a camera controller, and the recorded log a replay is driven from.

exports44 symbols in 17 modules — start with createInput, createHeadlessInput, createLog
depends on@latticekit/core, @latticekit/iso
environmentbrowser
gzipped15.39 kB against a 16 kB budget — override 16 kB, argued in .lattice/kit.json — budgets.overrides.input
sourcepackages/input · README · index.d.ts

@latticekit/input — every way a person can touch a game, as one replayable stream of intents in tile coordinates, bucketed to simulation ticks, behind one object that unbinds all of it.

const input = createInput({
  element: canvas,
  camera,
  step: loop,
  actions: { collect: ['tap', 'key:Space'], build: ['key:KeyB'] },
});
input.onAction('collect', (a) => collectAt(state, a.gx, a.gy));
loop.onUpdate((_dt, tick) => input.tick(tick));
loop.onRender(() => { input.frame(now); render(state, camera); });
onSceneEnd(() => input.dispose());

Four claims, each load-bearing:

  • One stream. A game written against this package never learns which device it is being played on. "Collect" is one handler, not three.
  • Tile coordinates. Every event arrives as a tile, converted once, through the camera the player was actually looking at and the ground the system was told about. No game does the conversion. A game with elevation passes terrain: { field, maxHeightPx } and a game without it passes terrain: 'flat'; a game that passes neither is answering on the plane z = 0 and is told so once, because on a hillside that answer is a real tile, next to the right one, and wrong by more the higher the player is pointing.
  • Bucketed to ticks. Browser events arrive on the browser's schedule and a fixed-step loop runs on its own. A log of wall-clock events is not replayable; a log of tick-bucketed samples is.
  • One object. Teardown is a tree, not a list of disposers you can forget to add to.

The two things this package refuses to know

What is in the world. There is no registry, no rect, no pickable flag and no hit callback anywhere in this surface, so an implementation that caches hit boxes during the draw pass cannot be built on it: there is nowhere to put them and nothing that would read one. In the source game the cached version made every collect bubble untappable in a backgrounded tab, where the draw pass had stopped running and the cached boxes were minutes old. gx, gy is geometry; what is at that tile is iso's pickSorted over the caller's own state.

What time it is. No Date.now, no performance.now, no requestAnimationFrame, no setTimeout, and no wall-clock timestamp in a log. Time is the tick index passed to InputSystem.tick and the milliseconds passed to InputSystem.frame, and only the first is recorded.

What is deliberately absent

Hit-testing (above). The gamepad — cut from 0.1, because it is the one input source that cannot answer where: a stick is a direction, and making a pad honor ActionEvent.gx/gy needs a virtual reticle that moves, accelerates, snaps to candidates and is drawn and focus-managed by ui. That is a second interaction model, not one more row in an action map, and the kit has not designed one; adding pad: bindings without it would give a game a binding that fires at the middle of the screen for ever. It also cannot be exercised — there is no headless gamepad — and non-negotiable 10 says green is not evidence. It comes back when a game shape asks for it, and it comes back with the reticle, because that is the part that is actually hard; the cost is one member on ActionBinding, one RawSample kind, one poller, and the focus seam that positionless sources already use.

Double tap, which costs every single tap ~300 ms of latency because a tap cannot be delivered until a second one has failed to arrive — catastrophic and invisible in review for a game whose primary verb is "tap the thing". Release edges and analog axes, which could only ever be honest for some sources. Rebindable keymaps and their UI: the map is data the game owns, persist stores the edits and ui renders the screen, and what this package owes them is InputSystem.bindings so the sheet is generated rather than transcribed. Camera animation beyond inertia — that is a tween over the camera, which is loop's tween and iso's camera and needs neither imported here. Rotation, three-finger gestures, swipe and edge-scroll. Text entry, IME, clipboard and file drop, where the browser is better at it and what this package owes text is a guard: a key aimed at a field never becomes an action, and neither does one carrying a modifier no binding asked for.

What it promises

  • Input never learns what is in the world. No option, method or callback takes state, a rect, a pickable flag or a hit — so there is nowhere to cache a hit box and nothing that would read one.
  • Gestures are delivered on simulation ticks, never on frames, and never from a clock this package read itself.
  • Every event carries the tile it resolved to, through the camera as it stood when the tick opened and the ground as the caller declared it. A declared height field is marched; "flat" is the plane, stated; and an omitted declaration is still the plane but says so once, the first time a coordinate is read, because a picked tile that is silently wrong on a slope is the failure this seam exists to prevent.
  • Off the map the coordinates are NaN and onGround is false, never a sea-level fallback. A number that is wrong and plausible is worse than one that is unmistakably absent, and a throw would crash the ordinary gesture of dragging the sky.
  • Zoom is anchored to the pointer, and zoom is not publicly assignable — zoomAt is the only mutator, so skipping the anchoring is unavailable rather than discouraged.
  • There is no free-function binder. Listeners come only from a scope, so an unowned listener cannot be constructed.
  • A tick sees a bucket closed before it started. Mid-tick arrivals go to the next bucket; overflow collapses moves and never drops a down, up, cancel, key or wheel.

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

system3 symbols

The system: the buffer, the recognizer, the action map and the camera controller, wired.

Nothing in this file names a browser global. It is the same recognizer a game runs, fed only by InputSystem.submit, which is how the package is tested and how a replay runs in Node. The pure half of an input package genuinely is pure, and hiding that behind a DOM constructor would waste it.

Two entry points, two clocks, and the reason they are different

callcarrieswhat happens
tick(index)an integer tick indexthe bucket closed before it started is delivered. The only place a handler ever runs.
frame(nowMs)wall-clock millisecondsthe camera integrates its glide. Delivers nothing and calls no handler.

Everything game-visible happens in tick, in the loop's fixed step, before the game's own update — so input cannot be a side effect of rendering, and it happens in the half of the frame that rendering is not in. Everything frame moves is a view, and no view is simulation state. Draining input in the render callback, or after the camera has moved, means the tile a tap resolves to is not the tile that was under the finger in the last frame the player actually saw.

The order a game runs them in is tick, then its own update, then frame, then draw — and inside frame the controller integrates its glide after everything else, so the ordering holds within the package too.

What this package can never be told

There is no option, no method and no callback here that could carry what is in the world: no registry, no rectangle, no pickable flag, no hit callback. That absence is the mechanism behind "input never learns what is in the world" — a naive implementation that caches hit boxes during the draw pass cannot be built on this API, because there is nowhere to put them and nothing that would read one.

terrain is not an exception to that, and the distinction is exact. A HeightField is the shape of the ground: one number per grid vertex, no ids, no extents, no ordering, and no way to ask what is standing on it. It is a parameter of the projection, in the same sense the camera is — without it "which tile is under this pixel" has no answer rather than a different one — and a hit box cannot be stored in it or recovered from it. What is at the tile is still iso's pickSorted over the caller's own state, called from a handler.

HeadlessInputOptions interface ↳ src/system.ts:78

interface HeadlessInputOptions<A extends string> {

Everything a system needs that is not a browser.

createInput extends this with the element and the things only a DOM binding has. Splitting it this way rather than by Omit is what keeps every DOM type in one module: nothing here names HTMLElement, so nothing here has to be re-checked when the adapter changes.

8 members
readonly camera: Camera

The camera every coordinate is resolved through, and the one the controller drives.

readonly step: FixedStep

The loop's fixed step. Pass the loop.

createInput({ element: canvas, camera, step: loop });

The recognizer counts ticks and multiplies by this; it never reads a clock. A step that is not the loop's does not fail, it lies by a constant ratio — a long press at the wrong moment, a fling at the wrong speed, and a recorded log a replay refuses months later. That is why this is a FixedStep and no longer a bare number: @latticekit/loop's Loop satisfies it, 16 does not compile, and fixedStep(hz) covers the headless cases.

readonly actions?: ActionMap<A>

The action map, as data.

The names are inferred from this object, so onAction, held and bindings accept only names that exist. This object is also the single source of truth for a shortcut sheet.

The names are identity and the bindings are policy, and the split is enforced: InputSystem.setActions rebinds any of them at any time and keeps every handler, and cannot add or remove a name — see its doc comment for why.

readonly profile?: ProfileOverrides

Override any threshold in the profile. Everything not named keeps its default. Not a one-shot: InputSystem.setProfile takes the same object and keeps every handler.

readonly control?: boolean

Set false for a game whose camera is fixed. The gestures still arrive.

readonly terrain?: TerrainOption

What the ground looks like. Answer it even when the answer is 'flat'.

createInput({ element: canvas, camera, step: loop, terrain: { field: hill, maxHeightPx } });
createInput({ element: canvas, camera, step: loop, terrain: 'flat' }); // and mean it

Every gx/gy this package reports is the inverse of the projection on the plane it is given, and screen → grid inverts on z = 0 and nowhere else. With a Terrain the pointer is marched down the heightfield by iso and lands on the tile the player can see; without one it lands on the tile the ray crosses at sea level, which on a hillside is several tiles uphill of the finger — examples/terraces measures 281 px and 14 tiles of it.

Omitting this is not an error and cannot be: a game with genuinely level ground is the common case and has nothing to pass. It does raise the flat-ground-pick diagnostic once, the first time a coordinate is read, because the alternative is the silent wrong answer this option exists to end. 'flat' says the same thing as omitting it and says it on purpose, which is the difference the diagnostic is testing for.

Not fixed for the life of the system: InputSystem.setTerrain replaces it, and the field itself is held rather than copied, so ground the player raises this frame is ground the next event resolves on.

readonly focus?: (out: Vec2) => boolean

Where a keyboard action points.

Write the screen point of the current selection into out and return true; return false and the viewport center is used. This is the seam between "the player pressed Space" and "at what", and a game that leaves it unimplemented is still playable — it just collects from the middle.

readonly onDiagnostic?: DiagnosticSink

Where problems this package can detect go. Default: console.warn.

At most once per code per system, whichever sink is used. Each of these has a legitimate cause as well as a broken one, and a diagnostic that repeats sixty times a second is one nobody reads.

InputSystem interface ↳ src/system.ts:168

interface InputSystem<A extends string = never> extends InputScope<A> {

A recognizer, an action map and a camera controller over one camera.

Obtained from createHeadlessInput or from createInput. It is an InputScope: the root of the teardown tree, so input.dispose() is the only call a scene needs.

17 members
readonly camera: CameraController

The gestures-to-camera policy. iso owns where the camera may be; this owns where the player is trying to put it.

readonly profile: Readonly<GestureProfile>

The thresholds in force right now, defaults filled in and every override validated.

A live read, not the object handed to the constructor: after setProfile this is the new one. Frozen, so a game that wants a different threshold changes it through setProfile rather than by writing to a shared object three other things are reading.

setProfile(overrides: ProfileOverrides | undefined): Readonly<GestureProfile>

Replace every threshold, and keep every handler.

input.setProfile({ tapSlopPx: { touch: 14 } }); //  handlers, scopes and camera all survive

A full replacement of the override set, resolved against the defaults exactly as construction does — not a patch onto the profile in force. setProfile({}) therefore returns to the defaults, and a game that keeps its overrides in one object and re-passes it gets a profile that depends only on that object and not on the order the sliders were moved. A patching version would make the thresholds path-dependent, and a path-dependent value is one a recorded log's fingerprint cannot be reasoned about.

The recognizer is rebuilt behind the seam — its tick counts, its velocity rings and its pointer slots are all sized from the profile — but the buffer's slot pool, the camera controller, every handler, every child scope and the DOM binding are the same objects afterwards. That is what makes this cheap enough to put behind a slider: retuning one threshold used to mean dispose, recreate and re-register every handler.

Every live gesture ends first, under the old thresholds: each drag gets its dragend and each held key its release, exactly as dispose does it, for the same reason — a recognizer replaced mid-drag is a placement ghost stuck to the cursor and a camera that pans for ever.

Throws

RangeError if any override is out of range, before anything changes; if called from inside a handler, because the bucket being delivered was recognized under the old thresholds and the samples behind it would meet a recognizer that never saw their press; if a recording is running, because the profile fingerprint is a third of a log's identity and a log that changed rules half way through describes no session that can be replayed; or if the system has been disposed.

setActions(actions: ActionMap<A>): void

Rebind every action, and keep every handler.

input.setActions({ collect: ['tap', 'key:Space'], build: ['key:KeyN'] }); // was KeyB

A full replacement of the map, compiled exactly as construction compiles it — the same validator, the same errors, the same unknown-key-code diagnostic. A binding an action had and this map does not name is gone; there is no patch form, for setProfile's reason.

The names are identity; only the bindings move

Passing a map whose names are not exactly the declared ones throws. A was inferred from the constructor's map and has already been handed out — every onAction handler is keyed to one of those names, actionNames has been read into a shortcut sheet, and the type of this very argument is derived from it. A name that appeared would have no handler list and no way to acquire one; a name that vanished would take a live handler with it and look, from the game's side, exactly like a handler that stopped being called. Adding an action is a new system. Which key produces build is the thing a settings screen moves, and that is what this method is for.

Unlike setProfile this ends nothing first, because an action map holds no live state: actions fire on the press edge, so every press that has already fired has already been delivered under the map that was in force when it fired. held is answered through the new map from the next call onward, which is the honest reading of "is the key bound to build down".

Throws

RangeError if a binding is malformed, if the map names an action that was not declared or omits one that was — before anything changes; if called from inside a handler, because half of the bucket being delivered would dispatch through each map; if a recording is running; or if the system has been disposed.

The recording refusal is the one worth reading twice, because the reason is not setProfile's. A log stores RawSamples, and actions is not in the compatibility triple — so a mid-recording rebind changes nothing about what the log says and everything about what a replay of it does, behind a triple that still matches exactly. setProfile refuses to keep the log's declared identity true; this refuses because there is no declared identity here to keep true, and the alternative is a divergence report that is confidently wrong. See docs/rfc/live-options.md §6b.

readonly terrain: TerrainOption | undefined

The ground in force right now, exactly as it was declared, or undefined if it never was.

A live read, not the object handed to the constructor: after setTerrain this is the new one. It is the same object the caller passed — not a copy — so a HUD that wants to show the march ceiling reads it here instead of keeping a second copy that drifts.

setTerrain(terrain: TerrainOption): void

Declare the ground, or change it. Keeps every handler, every scope and the camera.

input.setTerrain({ field: hill, maxHeightPx: hill.tallestPx }); // after the map generated
input.setTerrain('flat');                                       // the tunnel level

Settable rather than baked, and the readback rule's three questions are why. Identity: nothing allocated or handed out depends on it — every coordinate is resolved from the pointer at the moment it is read, so there is no derived value to invalidate. Record: a log stores RawSamples, which are screen pixels; gx/gy have never been in one, any more than the camera position they equally depend on is, so no recording is made invalid by this. Cost: what the hot path reads is the field itself, which is exactly what a game with deformable ground needs to be live.

The march ceiling moving under a slider is the case that settled it — examples/terraces ships that slider — and a game whose map is generated after its input system is bound is the case that made it necessary at all.

It does not bump the epoch setProfile and setActions bump, and a recording does not refuse it. Those two replace recognition and dispatch rules, which a log's samples were produced under; this replaces the surface a coordinate is measured against, which is game state and moves during ordinary play — examples/clay deforms it every frame. A cursor that refused here would refuse every session in which a player dug a hole.

Throws

TypeError / RangeError for a malformed declaration, naming the field that is wrong, before anything changes; RangeError if called from inside a handler, because half of the bucket being delivered would then have resolved on a different surface from the other half; or if the system has been disposed.

readonly stepMs: number

The fixed step every duration is counted in. Fixed for the life of the system: changing it would re-time every gesture and invalidate every log, which is a new system, not a knob.

readonly actionNames: readonly A[]

Every declared action, in declaration order. A live read: after setActions the order is the new map's, and the set is necessarily the same one.

bindings(action: A): readonly ActionBinding[]

What is bound to an action right now.

Exists so a keyboard-shortcut sheet is rendered from the map rather than transcribed beside it — and so that a sheet re-rendered after setActions shows the new keys without the game keeping a second copy of the map to read them from.

Throws

RangeError naming an action that was never declared.

tick(index: number): void

Close the sample buffer and deliver everything in it as simulation tick index.

The only place handlers run. Call it once per fixed step, before the game's own update. A pump with no ticks loses nothing; a pump with five delivers the backlog to the first and leaves the other four empty, which is correct — they are catch-up for time that already passed, and a tap did not happen five times.

Throws

RangeError if index is not an integer, or is not greater than the previous one. A repeated index makes the log ambiguous — two buckets under one key — and a regression makes it unreplayable, and both are silent until a replay reports a confident wrong answer months later.

frame(nowMs: number): void

Advance the view: the camera's glide, and nothing else.

Called once per rendered frame, before drawing. Delivers nothing and calls no handler.

Throws

RangeError if nowMs is not finite. A NaN here propagates into the camera and the screen goes blank a hundred frames from the mistake.

submit(sample: RawSample): void

Feed the recognizer directly. The DOM binding is a producer of these and nothing more.

The sample is copied, so a producer may reuse one object for every event it makes.

Throws

RangeError for a tick sample — tick(index) produces those, and one submitted by hand would put a marker in the log at a position no tick closed — or for a coordinate that is not a finite number.

held(action: A): boolean

Is any binding of this action currently held? Continuous input is a query, not a stream.

keyHeld(code: string): boolean

Escape hatch for a key with no action, e.g. a debug overlay. KeyboardEvent.code.

hoverTile(out: GridPoint): boolean

The tile under the pointer, for a hover highlight.

A query, answered from the newest position submitted and through the live camera — so a ghost following a finger is smooth at display rate even when ticks are slow. Querying is safe outside a tick precisely because it cannot mutate simulation state.

Returns false when there is no pointer over the world — which is every touch device, always, between taps. A control that only appears on hover does not exist on a phone; this signature exists to make that impossible to forget.

On a system with HeadlessInputOptions.terrain it also returns false when the pointer is over the sky or past the edge of the field, and leaves out untouched: a ghost with nowhere to stand should not be drawn on the shore instead.

pointerScreen(out: Vec2): boolean

The pointer's screen position, same contract as hoverTile.

readonly buffered: number

Samples waiting for the next tick. A number a stall diagnostic can watch.

createHeadlessInput functionstart here ↳ src/system.ts:457

function createHeadlessInput<A extends string = never>(options: HeadlessInputOptions<A>): InputSystem<A>

Build a system with no DOM at all, fed only by InputSystem.submit.

This is how the package is tested and how a replay runs in Node.

Throws

RangeError if step describes no coherent step, if a profile override is out of range, or if an action binding is malformed.

Throws

TypeError if camera is missing, or if step is not the loop or a fixedStep(hz).

dom3 symbols

@browser-only — the one module in this package that knows a browser exists.

Everything else here is a pure state machine fed by InputSystem.submit; this file is a producer of samples and nothing more. That is the whole architecture in one sentence, and it is why every invariant in the package is testable in Node with no shim: the DOM's job is to answer "which pixel, which pointer, which key", and it has no opinion about what any of them mean.

If a second module in this package ever needs this header, the split has failed and the change should be argued rather than merged.

The six traps this file exists to close

  1. clientX. Correct only for a full-window canvas at the origin, which is exactly the configuration the first game happens to have and the second one does not. Every coordinate is relative to the element's rect — cached, because getBoundingClientRect() per pointermove forces layout a thousand times a second, and invalidated on resize, on a capture-phase scroll, and from a ResizeObserver on the element.
  2. Not setting touch-action: none. The browser claims the pan, pointermove simply stops arriving mid-gesture, and iOS double-tap-zooms the whole game. Set inline, reverted on dispose, and diagnosed if the computed style disagrees anyway — a stylesheet rule with !important beats an inline style and nothing reports it.
  3. Not capturing the pointer. A drag that leaves the element, or passes under a ui panel, stops receiving moves and the camera halts with the finger still down. To a player that is the game freezing. Capture on pointerdown; every way of losing the pointer maps onto a cancel; the recognizer is never left latched.
  4. Trusting WheelEvent.deltaY. Three delta modes, and Firefox reports lines where Chrome reports pixels — the same flick zooms 30× less without the conversion. A trackpad pinch is a wheel with ctrlKey set. And the listener must be { passive: false }, or the preventDefault that stops the page zooming is ignored.
  5. Stuck keys. keydown without its keyup happens on every alt-tab, and on macOS whenever a command chord is held. blur and visibilitychange release everything.
  6. Two live instances driving one canvas. Vite HMR leaves the previous module's listeners bound; without the throw below the symptom is a camera that pans twice as fast and a game that is impossible to debug.
  7. An invisible element covering the world. It eats every tap and nothing anywhere reports it. Reported once, and only for a node that never declared itself — see declaredChrome for how a legitimate HUD is told apart from a spacer without this file learning anything about what is in the world.

InputOptions interface ↳ src/dom.ts:49

interface InputOptions<A extends string> extends HeadlessInputOptions<A> {

Options for createInput: the headless ones, plus the things only a browser has.

3 members
readonly element: HTMLElement

The world surface. Usually the canvas.

Binding it twice without disposing the first throws — Vite HMR happily leaves two live game instances driving one canvas, and the second one's camera fights the first's.

readonly keepContextMenu?: boolean

Keep the browser context menu over the world. Default false.

A long press on Android raises it mid-gesture, and it lands on top of the building the player has just lifted. A game that wants a right-click menu of its own sets this and binds contextmenu itself.

readonly overlays?: readonly Element[]

Roots whose subtrees are chrome, not a cover. For the covered-by-overlay diagnostic, and for nothing else.

Most games need this and do not know it, because most chrome already declares itself. The diagnostic's real test is whether anything between the pressed node and the document root sets pointer-events inline — see createInput. @latticekit/ui does, on every node it grants, so a ui panel is silent here with no configuration at all. This option is for the HUD that is styled entirely from a stylesheet and therefore cannot be told apart from the spacer the diagnostic exists to catch.

Read at the moment a cover is found rather than captured at construction, so a HUD built after the input still counts and an array a game pushes into keeps working.

This is not a hit-test and cannot become one. It carries no rectangle, no ordering and nothing about the world; the only question it can answer is "did the game already know something was there", which is a question about the page, not about the game. Passing the world element itself would be meaningless — a press on the world never reaches this check.

DomInputSystem interface ↳ src/dom.ts:102

interface DomInputSystem<A extends string = never> extends InputSystem<A> {

An InputSystem bound to an element, which is the only thing a DOM one adds.

1 member
readonly element: HTMLElement

The bound surface. Its rect is what every sx/sy in the package is relative to.

createInput functionstart here ↳ src/dom.ts:149

function createInput<A extends string = never>(options: InputOptions<A>): DomInputSystem<A>

Bind a world surface. Touches document and window, through the element rather than through the globals, so a canvas inside an iframe binds to its document and not the top one.

Also set on the element and reverted on dispose: touch-action: none, overscroll-behavior: contain and user-select: none. See this module's header for what each of them prevents.

A press that lands on something over the world is reported once as covered-by-overlay, unless something between that node and the document root declared pointer-events inline or the node is inside an InputOptions.overlays root. Every @latticekit/ui panel satisfies the first without being configured.

Throws

TypeError if element is not an element with addEventListener.

Throws

RangeError if element already has a live binding — see InputOptions.element.

Throws

RangeError / TypeError for everything createHeadlessInput refuses: a step that is not the loop's, an out-of-range threshold, a malformed action binding.

scope1 symbol

Where bindings are owned, and the only way to obtain one.

There is no free-function binder in this package. A listener can only be created through a scope, so an unowned listener is not a thing that can be constructed. That is the whole answer to "what shape does a game hold so that tearing down a scene cannot leak half of it": it holds a scope, not an array of disposers, because an array is a thing you can forget to push to and a scope is not.

The teardown vocabulary itself is @latticekit/core's Scope. This module adds three things on top of it and nothing else: the typed on/onAction surface, the dispatch order below, and own, so that a scene has one teardown tree rather than one per package it happens to use.

The dispatch order, and why it is worth the insertion sort

Handlers run in registration order, scopes in creation order, and the camera controller runs after all of them.

That last clause is the one games rely on: a handler can claim() a drag and steer a placement ghost with it, and the camera will not also pan. Panning away from the site a player is aiming at is never what anyone means. The first two clauses cost an insertion scan per registration — which happens at scene setup, never per frame — and buy an order that is the same on every run, which is what a replay needs and what "the second panel wins" would not give.

Pure: no DOM, no clock.

InputScope interface ↳ src/scope.ts:164

interface InputScope<A extends string = never> {

A place bindings are owned.

Held by a scene, a screen, a modal — anything with a lifetime. Disposing it disposes every binding made through it and every scope descended from it, in reverse registration order, and disposing it twice does nothing.

6 members
scope(): InputScope<A>

A child scope. Disposing the parent disposes it; disposing it does not touch the parent.

on<K extends keyof GestureMap>(type: K, handler: (gesture: GestureMap[K]) => void): Disposer

Subscribe to a recognized gesture.

Handlers run in registration order, scopes in creation order, and the camera controller runs after all of them — so a handler can claim() a drag and steer a placement ghost with it, and the camera will not also pan.

onAction(action: A, handler: (event: ActionEvent<A>) => void): Disposer

Subscribe to a declared action, whichever device produced it.

action is typed to the names declared in InputOptions.actions, so a renamed action breaks the build rather than silently going quiet.

Throws

RangeError if the action was never declared. A JS caller misspelling one would otherwise register a handler that can never run, which looks exactly like a game bug.

own(disposer: Disposer): Disposer

Hand this scope something else to unbind — an audio node, a ResizeObserver, a ui panel.

Present so a scene has one teardown tree rather than one per package it happens to use.

Throws

TypeError if disposer is not a function, at the line that made the mistake rather than at teardown an hour later.

dispose(): void

Dispose this scope and every scope descended from it. Safe to call during a drain.

readonly disposed: boolean

events6 symbols

What a handler is handed, and the one place a screen pixel becomes a tile.

Coordinates come in all three spaces

Guessing wrong about which space a callback wanted is the most common bug in this layer, and a game that converts by hand will eventually convert with the wrong camera. So every event carries screen, world and tile, all three resolved together, through the camera as it stood when the tick opened — see TickFrame.

…and the tile one depends on the ground

gx/gy are only the tile under the finger if something told this package what the ground looks like. Screen → grid inverts the projection on the plane z = 0 and nowhere else, so on a hillside the undeclared answer is the tile the ray crosses at sea level — real, adjacent, plausible, and several terraces from where the player pointed. terrain.ts is the seam that fixes it and the diagnostic that reports a system nobody ever told.

The objects are reused

There is one event object per gesture kind and one per action, for the life of the system. Copy what you keep; retaining one keeps a reference to next tick's gesture. A fresh event object per pointer move, sixty times a second, is a garbage collector pause with a nice API.

What is not here, structurally

There is no target, no hit, no entity, no id. This package has no way to be told what is in the world — no registry, no rect, no pickable flag, no callback that returns a hit; the one thing it can be told is the shape of the ground, which carries none of those — so a naive implementation that caches hit boxes during the draw pass cannot be built on it: there is nowhere to put them and nothing that would read one. gx, gy is geometry; "the headquarters, not the rack behind it" is iso's pickSorted over the caller's own state, called from a handler with the coordinates below. In the source game the cached version made every collect bubble untappable in a backgrounded tab, where the draw pass had stopped running and the cached boxes were minutes old.

GestureBase interface ↳ src/events.ts:107

interface GestureBase {

The fields every gesture carries.

On flat ground, off the map is still a number: gx, gy is where the pixel falls on the infinite lattice, and iso decides what is in bounds. Returning false here instead would make the most common call — "which tile did they tap" — a two-step for the sake of a case most games handle by looking the tile up and finding nothing.

On terrain there is no infinite lattice to fall on: a pixel above the horizon corresponds to no ground at all, and the only honest answers are GestureBase.onGround and NaN. That is a difference between the two grounds and not an inconsistency — the flat plane genuinely extends for ever, and a heightfield genuinely stops.

12 members
readonly type: GestureName
readonly pointerType: PointerKind
readonly tick: number

The simulation tick this was delivered in. The log's time axis.

readonly sx: number

CSS pixels, relative to the bound element's top-left — never clientX.

readonly sy: number
readonly wx: number

World space, through the camera as it stood when the tick opened.

readonly wy: number
readonly gx: number

Tile, floored — on the ground the system was told about.

With terrain: { field, maxHeightPx } this is the tile whose terrain surface is under the pixel, marched by iso. With terrain: 'flat', or with nothing declared, it is the tile on the plane z = 0 — which is the same answer on level ground and is the wrong one, by several tiles, anywhere the ground rises. See terrain.ts for what that costs and why the undeclared case says so once.

NaN when onGround is false, and only ever then. A tile index that is not a number cannot be mistaken for the tile the player asked for; the sea-level answer can.

readonly gy: number
readonly onGround: boolean

Did the pointer land on ground that exists?

Always true on flat ground: off the map is still a number there, because worldToTile answers for the infinite lattice and iso decides what is in bounds. With a heightfield it is false for a pixel above the horizon or beyond the field's edge — a tap on the sky — and gx/gy are NaN. Check it before using a coordinate on any map with terrain.

claim(): void

Take this gesture. Handlers not yet run, and the camera controller, will not see it.

This is how a handler steers a placement ghost with a drag without the camera also panning. Panning away from the site a player is aiming at is never what anyone means.

readonly claimed: boolean

TapGesture interface ↳ src/events.ts:158

interface TapGesture extends GestureBase {

A press that stayed put.

tap and longpress are mutually exclusive for one press. In the source game the missing version of that guarantee meant the pointerup ending a hold also counted as a tap, which instantly re-dropped the building the player had just lifted.

2 members
readonly type: 'tap' | 'longpress'
readonly heldMs: number

How long the press lasted: whole ticks × stepMs. Feed a press-progress ring with it.

DragGesture interface ↳ src/events.ts:169

interface DragGesture extends GestureBase {

A press that traveled. One dragstart, zero or more drag, and exactly one dragend — including when the system takes the gesture away, because a drag with no end is a camera that pans for ever.

5 members
readonly type: 'dragstart' | 'drag' | 'dragend'
readonly dx: number

Screen-space movement since the previous event of this gesture, in CSS pixels.

readonly dy: number
readonly vx: number

Screen-space velocity in CSS px/s, averaged over flingSampleMs.

Averaged, not differenced: a finger that pauses before lifting has a last-two-points velocity of nearly zero or of nearly anything, and both make flicks feel random. Always zero on a canceled dragend — an interrupted gesture must not fling.

readonly vy: number

ZoomGesture interface ↳ src/events.ts:194

interface ZoomGesture extends GestureBase {

"Scale the world by scale about this point."

One gesture for wheel, trackpad pinch, two-finger pinch and the zoom keys, because the camera does not care which it was and neither does a game. sx, sy is the anchor: the pointer, the midpoint between two fingers, or the viewport center for a source with no position. dx, dy carries the midpoint's own travel, so a two-finger gesture pans and zooms at once the way a map does.

5 members
readonly type: 'zoom'
readonly source: ZoomSource
readonly scale: number

Multiplicative, > 1 zooms in. Never additive: additive zoom is unusable above 2×.

readonly dx: number
readonly dy: number

GestureMap interface ↳ src/events.ts:204

interface GestureMap {

The gesture name → event type mapping InputScope.on is typed against.

6 members
readonly tap: TapGesture
readonly longpress: TapGesture
readonly dragstart: DragGesture
readonly drag: DragGesture
readonly dragend: DragGesture
readonly zoom: ZoomGesture

ActionEvent interface ↳ src/events.ts:222

interface ActionEvent<A extends string> {

An action fired.

The coordinates are always populated, which is the point. A pointer-sourced action carries where the finger was; a key-sourced one carries the game's focus — its current selection — falling back to the viewport center. Without that rule the keyboard path either does nothing or does something different from the touch path, and the keyboard path is the one nobody tests. It is also the seam a gamepad needs: a positionless source is already a solved case.

13 members
readonly action: A
readonly source: 'pointer' | 'key'
readonly binding: ActionBinding

Which binding fired it. Present so a tutorial can say "you can also press Space".

readonly tick: number
readonly sx: number
readonly sy: number
readonly wx: number
readonly wy: number
readonly gx: number

The tile, on the ground the system was told about. See GestureBase.gx.

readonly gy: number
readonly onGround: boolean

Did the pointer land on ground that exists? See GestureBase.onGround.

claim(): void

Take this action. Handlers not yet run will not see it.

readonly claimed: boolean

recognize2 symbols

The gesture state machine. Pure: no DOM, no clock, no timers, no allocation per event.

It sees nothing but SampleSlots and a tick index, which is what makes every invariant in this package testable in Node with no shim and a replay bit-identical to the session it came from. Durations are counted in ticks and multiplied by stepMs; there is no timer that could fire a long press, which is why nothing game-visible can escape outside a tick.

The four traps this file exists to close

  1. The release after a long press counting as a tap. The press latches as consumed the moment the hold fires, so tap and longpress are mutually exclusive for one press. In the source game the missing version of this meant the pointerup ending a hold also counted as a tap, which instantly re-dropped the building the player had just lifted.
  2. Not disarming the hold when the finger travels. One number governs it: crossing tapSlopPx ends the press, starts the drag and disarms the hold, in that order. Without it a slightly shaky drag lifts a building mid-pan.
  3. Fling velocity from the last two points. A finger that pauses before lifting produces either zero or nonsense, and both make flicks feel random. Velocity is averaged over flingSampleMs of tick history.
  4. A pinch seeded from a spread of zero. The two pointers never land in the same tick, so the spread is seeded when the second lands, the pinch waits for pinchStartPx of change, and a spread below pinchMinSpreadPx refuses to be a denominator.

The one thing it can never be

Latched. For every down there is exactly one terminal event, and every way a host can take a pointer away — pointerup, pointercancel, lostpointercapture, blur, visibilitychange, dispose — arrives here as an up or a cancel. A recognizer that can be left in a dragging state is worse than one that occasionally drops a drag, because the first symptom is a camera that pans for ever and the second is a gesture you repeat.

GestureName type ↳ src/recognize.ts:39

type GestureName = 'tap' | 'longpress' | 'dragstart' | 'drag' | 'dragend' | 'zoom'

The six things a player can do that this package has a name for.

ZoomSource type ↳ src/recognize.ts:42

type ZoomSource = 'wheel' | 'pinch' | 'key'

What produced a zoom. The camera does not care and neither does a game; a tutorial might.

actions2 symbols

The action map: two sources under one name, declared as data.

A game writes onAction('collect', …) once. A tap reaches it through the gesture recognizer carrying the finger's tile; key:Space reaches it through the keyboard carrying the focus point's tile; both arrive as the same event, in the same tick. The only things a handler can tell them apart by are source and binding, and it is free to ignore both — which is the test of whether the abstraction is real rather than decorative.

The names are inferred from the map object, so onAction('colect', …) is a compile error rather than a handler that silently never runs. A third source is one more string in an array, and still no second handler.

Actions fire on the press edge only, once per physical press. Auto-repeat does not fire them: the repeat rate is an operating-system accessibility setting, so an action that repeats is an action whose count is not reproducible — and a log that does not reproduce is not a log. A held action is a query (CompiledActions.held), not a stream.

Pure: no DOM, no clock.

ActionBinding type ↳ src/actions.ts:35

type ActionBinding = 'tap' | 'longpress' | `key:${string}`

One way of producing an action.

key: takes a KeyboardEvent.code — a physical position, not a letter — so WASD stays under the same four fingers on AZERTY.

Only tap and longpress appear here, out of six gestures. An action must mean the same thing from every device that can produce it, and a drag has no keyboard equivalent that is not a lie. A ` pad:${PadButton} ` member is the intended shape of the third source when it returns; adding a member to this union breaks nothing.

ActionMap type ↳ src/actions.ts:43

type ActionMap<A extends string> = {
    readonly [K in A]: readonly ActionBinding[];
}

The declared map, from which the action names are inferred.

A mapped type rather than Record<string, …>, because Record<string, …> would infer A as string and every misspelling would type-check.

terrain2 symbols

What the ground looks like — the one thing this package must be told before it can answer "which tile" on anything but a plane.

The bug this module exists to end

screen → grid is a linear inverse only on the plane z = 0. Raise a point by HALF_H world pixels and it lands on exactly the same screen pixel as the point one unit of gx + gy further from the viewer at sea level, so a pixel does not name a tile: it names a family of candidates, one per elevation. worldToTile picks the sea-level member of that family, which is the right answer on flat ground and is plausible everywhere else — on a hillside it is the tile the ray crosses several terraces up the slope from the finger that asked for it. examples/terraces measures that at 281 px and 14–16 tiles; examples/demo shipped a 212–237 px version of it; and in examples/clay, where the visitor raises the ground under their own cursor, the error is not even constant — the brush walks off the far side of a hill while the hand holds still.

Nothing downstream can catch it. The tile is a real tile, it is next to the right one, and it moves with the pointer. So the fix is a seam, not a check.

Three states, and only one of them is quiet by accident

terrain:what gx/gy meanwhen it is wrong
{ field, maxHeightPx }the tile whose terrain surface is under the pixelnever — it is the marched answer
'flat'the tile on the plane z = 0if the game grew a hill and nobody came back here
omittedthe plane z = 0, and a diagnostic the first time a coordinate is readthe same, and now it says so

The third row is the whole point. This package cannot see a game's terrain — it has no registry, no map and no way to acquire one — so it cannot detect the mistake. What it can detect is that nobody ever said, and saying so costs one word. A game with genuinely level ground writes terrain: 'flat' once and is silent for ever; a game with a hill that never declared one gets told, in the console, the first time it asks where a tap landed.

Where the maths lives

iso does it. docs/SEAMS.md settles that iso owns tap → grid cell and input owns the gesture and calls it, and this module is the call: TilePicker.resolve is a branch and a delegation to worldToTileOnHeights, and there is no geometry in this package to get wrong.

Terrain interface ↳ src/terrain.ts:55

interface Terrain {

The elevation a pointer is resolved against, and how far up the search for it starts.

Two fields because iso's march needs both and a HeightField carries only one of them: a ceiling that is too low begins the march below a peak and misses it, and one that is too high scans ground that is not there on every event. Every game that has terrain already knows this number — it is the tallest terrain on the map, in world pixels — and every caller that has written the picking call by hand has been carrying it beside the field already.

2 members
readonly field: HeightField

The heightfield the pointer is resolved against. The same object the game draws from: this package holds it, never copies it, so ground the player raises this frame is ground the next event resolves on.

readonly maxHeightPx: number

The tallest terrain on the map, in world pixelsmaxUnits × field.stepPx.

A ceiling of 0 is legal and means every pick is exactly the flat-ground answer, which is what examples/terraces shows by dragging its slider to the bottom. It is not what a game with a hill wants, and it is not the same statement as terrain: 'flat'.

TerrainOption type ↳ src/terrain.ts:77

type TerrainOption = Terrain | 'flat'

What a game says about its ground: a heightfield, or the word 'flat'.

'flat' is a declaration, not a default. It resolves exactly as omitting the option does and differs from it in one way that matters: it silences the flat-ground-pick diagnostic, because a caller who wrote it has answered the question rather than never having been asked.

profile5 symbols

Every number that decides what a gesture is, in one place, with its derivation beside it.

A magic 9 inside a pointermove handler is a number nobody can argue with: it cannot be overridden, it cannot be compared against another game's, and the next person to tune it has no way to know whether it came from a measurement or from a mood. Every default here carries the reason it is that number, because the reasons are the part that does not survive a refactor.

A profile is part of a replay's identity. The same finger movements under a tap slop of 8 px and of 12 px are a different sequence of actions, so profileFingerprint goes into every recorded log and @latticekit/persist refuses a replay whose fingerprint differs rather than migrating it. That is why this module owns a canonical encoding and not merely a set of numbers.

Pure: no clock, no DOM, no allocation outside the two constructors.

PointerKind type ↳ src/profile.ts:28

type PointerKind = 'mouse' | 'touch' | 'pen'

What the player is touching the game with.

Not decoration: GestureProfile.tapSlopPx differs per kind by more than a factor of two, and a recognizer that uses one threshold for all three either eats every short mouse drag or turns half of a phone's taps into one-pixel drags.

GestureProfile interface ↳ src/profile.ts:37

interface GestureProfile {

Every threshold the recognizer and the camera controller consult.

Named in one interface so that a game that needs a different feel changes data rather than forking a state machine, and so that a recorded session can carry the exact rules it was recognized under. See the table in this module's source for the derivation of each default.

15 members
readonly tapSlopPx: Readonly<Record<PointerKind, number>>

Travel above which a press is a drag and never a tap, in CSS pixels, per device.

kinddefaultwhy
touch9A fingertip's contact patch shifts several pixels during a press people experience as perfectly still, and the reported point moves as the patch grows. Shipped at 9 in the source game after tuning against real hands: below ~6 half the taps on a phone become one-pixel drags, above ~12 a deliberate small pan opens whatever was under the finger.
mouse4Windows' SM_CXDRAG. A mouse does not wobble, so touch's 9 would eat every short deliberate drag and make the camera feel stuck.
pen6A stylus wobbles more than a mouse and far less than a finger, and pen users make small deliberate movements. Between the two, nearer the mouse.
readonly longPressMs: number

How long a still press must last to become a longpress, in milliseconds.

450: iOS long-press is ~500 ms and Android ~400, so inside that band the duration is one people's hands already know. Below ~350 it fires during ordinary taps; above ~600 people let go first and report it broken. Counted in whole ticks, so the effective value is ceil(longPressMs / stepMs) * stepMs.

readonly pinchStartPx: number

How much the finger spread must change before a two-finger gesture is a pinch, in CSS pixels.

12: two fingers never land in the same tick and the spread jitters as the second settles. Without a start threshold every two-finger pan zooms slightly, which reads as the map "breathing".

readonly pinchMinSpreadPx: number

The smallest spread the scale ratio may be divided by, in CSS pixels.

24: the scale factor is a ratio of spreads, so near-touching fingers make its denominator tiny and one noisy sample teleports the zoom.

readonly wheelLinePx: number

CSS pixels per line for WheelEvent.deltaMode === 1.

16: Firefox reports 3 lines where Chrome reports 100 pixels, so without this conversion the same flick zooms about 30× less on Firefox.

readonly wheelPagePx: number

CSS pixels per page for WheelEvent.deltaMode === 2.

400: rare, and one page of scroll is about one viewport.

readonly wheelZoomRate: number

Zoom per normalized wheel pixel: scale = exp(-dz * rate).

0.0016. Exponential rather than additive, so a notch feels the same at 0.6× and at 4× and wheeling up then down returns exactly where you started; additive zoom is unusable above 2×. 0.0016 puts a typical 100 px notch at ~1.17×, close to keyZoomStep.

readonly wheelPinchRate: number

The same, for a trackpad pinch — which arrives as a wheel with ctrlKey set and much smaller deltas.

0.0100: using the scroll rate for it makes pinch-to-zoom on a laptop feel dead.

readonly keyZoomStep: number

Multiplicative zoom step for one press of the zoom key.

1.15 — about five presses per doubling: coarse enough to get somewhere, fine enough to frame a building.

readonly keyPanPxPerS: number

Camera pan speed while a pan key is held, in CSS pixels per second.

700, about a viewport every two seconds. It is a speed and not a per-press step because the source game panned 90 px per keydown and thereby inherited the operating system's key-repeat rate — a camera whose speed is set in the player's accessibility preferences, on a setting no game can read.

readonly flingMinPxPerS: number

Below this release speed a drag ends without a glide, in CSS pixels per second.

120: below it a release is a stop rather than a flick. Without a floor every drag drifts after the finger lifts and the camera can never be placed exactly.

readonly flingHalfLifeMs: number

Half-life of the glide's exponential decay, in milliseconds.

150, so the glide is frame-rate independent and a 1200 px/s flick coasts ~260 px: enough to feel alive, short enough that a second gesture is never fighting the first.

readonly flingSampleMs: number

The window release velocity is averaged over, in milliseconds.

  1. Averaged and never differenced: a finger that pauses before lifting has a

last-two-points velocity of nearly zero or of nearly anything, and both make flicks feel random.

readonly maxPointers: number

How many simultaneous pointers the recognizer tracks.

2: a third finger on a two-finger gesture is a palm, and ignoring it beats letting it move the midpoint.

readonly maxBufferedSamples: number

The stall ceiling: how many samples may wait for a tick before moves start collapsing.

4096, roughly a minute of pathological input. Beyond it something is wrong, and dropping quietly would be worse than saying so — see the buffer-overflow diagnostic.

ProfileScalar type ↳ src/profile.ts:176

type ProfileScalar = Exclude<keyof GestureProfile, 'tapSlopPx'>

Every field of GestureProfile that is a plain number.

Exported as a type rather than written out twice because it is the domain of both the validation loop and the fingerprint, and a list that appears in two places is a list that will eventually disagree with itself.

DEFAULT_PROFILE const ↳ src/profile.ts:218

const DEFAULT_PROFILE: Readonly<GestureProfile>

The defaults, each one defended in the doc comment of its field.

Frozen, and deliberately so: it is the value resolveProfile falls back to for every field a game does not name, so a mutation here would silently retune every game in the process — including one that overrode nothing and therefore has no idea this object exists.

ProfileOverrides type ↳ src/profile.ts:244

type ProfileOverrides = Partial<Omit<GestureProfile, 'tapSlopPx'>> & {
    readonly tapSlopPx?: Partial<Record<PointerKind, number>>;
}

What a game may override.

A strict widening of Partial<GestureProfile>: the slop record may name one kind, two, or all three. Requiring all three would mean a game that only wants a slightly larger touch slop has to restate the mouse and pen numbers — which is how a game ends up with a stale copy of a default that has since been retuned.

step2 symbols

The fixed step, as a value that cannot be guessed at.

This package never reads a clock. Every duration it reports — a longpress at 450 ms, a fling at 1200 px/s, the heldMs on a tap — is a count of ticks multiplied by one number, and that number is the loop's step. There is no second source for it and no way to detect a wrong one at runtime: a system told the step is 16 ms while the loop runs at 16.667 does not fail, it lies, uniformly, by 4%.

What the 4% actually costs

symptomwith stepMs: 16 against a 16.667 ms loop
long pressfires at 432 ms, not 450 — inside the band where people are still deciding
fling velocity4% low, so every flick coasts short and the camera feels heavy
recorded logcarries stepMs: 16, and @latticekit/persist refuses to replay it against a real 60 Hz loop months later, naming a mismatch nobody can explain

None of those surfaces where the mistake was made. That is the whole reason this module exists: the previous signature was stepMs: number, it rejected only 0 and NaN, and every other wrong number was accepted in silence.

Why it is a pair and not a branded number

The obvious fix is a branded Millis that only the loop can mint. It is not available: @latticekit/loop sits beside this package rather than under it, so the edge cannot be imported (non-negotiable 3), and its Millis is in any case a plain unbranded number whose own doc comment says it "guards nothing".

So the step is taken structurally, as the loop reports itstepMs and stepSeconds, the two fields Loop already publishes. loop satisfies FixedStep with no ceremony at the call site, and the pair does two things one number cannot:

  1. step: 16 no longer compiles, and neither does { stepMs: 16 }. The shortest thing that type-checks is the loop itself, which is the correct answer.
  2. The two are cross-checked. They are the same integer microsecond count divided by 1e3 and by 1e6, so they agree to within a rounding error; a hand-written pair that disagrees is a guess, and resolveStep refuses it by name.

For the cases with no loop to read — a headless replay, a test, a component page — fixedStep builds the pair from an hz, using the same arithmetic createLoop uses, so the two are bit-identical rather than merely close.

Pure: no clock, no DOM.

FixedStep interface ↳ src/step.ts:83

interface FixedStep {

A loop's fixed step, in both units it publishes.

Pass loop. @latticekit/loop's Loop satisfies this exactly, and reading the step off the object that owns it is the only way the two cannot drift. Where there is no loop — a headless replay, a test — build one with fixedStep.

Declared structurally rather than imported: loop and input are siblings on the graph, so the edge does not exist and must not be invented. Loop satisfies this without knowing that input exists.

2 members
readonly stepMs: number

Milliseconds per fixed step. This is the number every duration in this package is counted in, and the number a recorded log carries as one third of its compatibility triple.

readonly stepSeconds: number

The same step in seconds.

This package never uses it. It is required for two reasons, both of them about the caller: it makes the shortest type-checking argument the loop itself rather than a literal, and it gives resolveStep a second reading of the same quantity to check the first against. A pair that disagrees by more than a rounding error was typed by hand, and a step typed by hand is the bug this whole module exists to remove.

fixedStep function ↳ src/step.ts:121

function fixedStep(hz: number): FixedStep

Build a step from a rate, for the callers that have no loop to read one off.

const input = createHeadlessInput({ camera, step: fixedStep(60) });

The arithmetic is createLoop's, to the digit: microseconds are rounded to an integer first and both fields are derived from that count. fixedStep(60).stepMs is therefore 16.667not 1000 / 60, which is 16.6666… and differs from what a real 60 Hz loop reports in the twelfth decimal place. That difference is invisible in a gesture and fatal in a log, because @latticekit/persist compares the recorded stepMs for exact equality.

Parameters
hz

Fixed steps per second, as an integer — the same argument, with the same bounds, that createLoop takes.

Throws

RangeError if hz is not an integer in [1, 1000000]. Non-integer rates are refused rather than rounded, because a caller who wrote 62.5 meant something and silently giving them 63 is how a log ends up recorded at a step nobody chose.

cameracontrol1 symbol

The gestures-to-camera policy. iso owns where the camera may be; this owns where the player is trying to put it.

Each half has a hard requirement the other cannot meet. The camera must run in Node with no DOM — depth sorting, culling, pathfinding, golden tests and a headless replay all need toScreen and none of them have a pointer — so a camera that knew about gestures could not be imported by any of them. The controller cannot run without knowing what a wheel notch is worth, whether a release becomes a glide, and how a held key becomes a speed, and putting that in iso would make the kit's most reusable package the one that has to know Firefox reports scroll in lines.

The seam is one method: zoomAt(factor, sx, sy). Neither package can express a zoom without an anchor, which is how "zoom is anchored to the pointer" stops being a convention and becomes a property of the signatures.

Which clock this runs on. Gestures are delivered on ticks; the camera integrates its pan, its zoom and its glide in frame, at display rate. That asymmetry is deliberate: a camera is a view, not simulation state, and a drag must track a finger at the rate the finger is visibly moving. If a game's fixed step is 100 ms — entirely plausible for an idle economy — a tick-rate camera would lag a drag by a step and feel broken however good the interpolation. The cost is stated plainly: the replay contract covers what the player did, not where the camera was.

Pure, given a camera: this module names no DOM global and reads no clock. nowMs arrives as a parameter.

CameraController interface ↳ src/cameracontrol.ts:87

interface CameraController {

The gestures-to-camera policy.

There is deliberately no setZoom. The only way to change scale is zoomBy, whose anchor is a required parameter — so origin-anchored zoom is not somewhere you can arrive by accident, only by deliberately typing the viewport center. Origin-anchored zoom is the single most common reason tile-game cameras feel broken: the thing you are looking at slides out from under you as you zoom towards it.

5 members
enabled: boolean

Off means gestures still arrive and nothing drives the camera.

For a fixed-camera game, and for the modal case: a game that disables the controller while a dialog is open gets a camera that cannot be nudged behind it. Turning it off also kills any glide, because a camera that coasts while disabled arrives somewhere the player did not choose.

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

Pan by a screen delta. Divided by zoom inside iso, so a drag tracks the finger exactly at any scale — multiplying instead is the bug where a zoomed-in map slides at a crawl.

zoomBy(factor: number, anchorSx: number, anchorSy: number): void

Multiplicative zoom about a screen anchor. The anchor is not optional.

Throws

RangeError if factor is not finite and positive — iso refuses rather than turning the camera into NaN, which is a state nothing downstream recovers from.

stop(): void

Kill any glide immediately.

Call it when a modal opens or a scene ends. A camera still coasting under a dialog has moved somewhere the player did not choose while they could not see it.

readonly gliding: boolean

True while a fling is still moving the camera. False the moment it reaches rest.

sample6 symbols

Samples, the per-tick bucket, and the log — the join that makes a session replayable.

Browser events arrive when the browser feels like it and a fixed-step loop runs on its own schedule. Something has to reconcile those two, and if it is not this package it is game code, which will drop a tap on a slow frame and fire two on a fast one. The reconciliation is one rule:

A tick sees a bucket that was closed before it started.

situationwhat happens
an event arrives between ticksit joins the open bucket and is delivered by the next tick
an event arrives during a tick — including one a handler synthesizesit joins the next bucket, never the running one. Otherwise delivery order would depend on when the browser dispatched, which is not reproducible, and a handler that submits input could recurse
a pump runs no ticksnothing is delivered, nothing is lost; the bucket keeps filling
a pump runs five ticksthe first gets the backlog; the other four are normally empty, which is correct — they are catch-up for time that already passed, and a tap did not happen five times
the bucket reaches maxBufferedSamplesconsecutive moves for one pointer collapse to the newest. A down, up, cancel, key or wheel is never dropped: a stall costs precision, never an event, and one buffer-overflow diagnostic is raised

The consequence worth stating out loud: a tap cannot be dropped by a slow frame and cannot fire twice on a fast one, because ticks — not frames, not events — are what deliver, and each sample is in exactly one bucket.

Why there are two representations of a sample

RawSample is the public, serializable one: a discriminated union of plain objects that goes in a log and through JSON unchanged. SampleSlot is the internal one: a flat, fully-populated record that the buffer owns for ever and overwrites in place. A fixed-shape slot is what makes a thousand pointermoves through one tick allocate nothing, and keeping it internal is what stops that optimization leaking into the recorded format, where a field-per-kind union is far easier to read a year later.

Pure: no clock, no DOM.

LOG_VERSION const ↳ src/sample.ts:45

const LOG_VERSION = 1

The format version of InputLog.

Bumped whenever the meaning of a sample stream changes — a new sample kind, a changed field, or a change in how the recognizer reads one. @latticekit/persist compares it for equality and refuses a replay that differs, because a migrated input log is a log that no longer replays and a divergence report from one is worse than no report at all.

RawSample type ↳ src/sample.ts:57

type RawSample = {
    readonly kind: 'down';
    readonly id: number;
    readonly sx: number;
    readonly sy: number;
    readonly pointerType: PointerKind;
} | {
    readonly kind: 'move';
    readonly id: number;
    readonly sx: number;
    readonly sy: number;
} | {
    readonly kind: 'up';
    readonly id: number;
    readonly sx: number;
    readonly sy: number;
}
/** The pointer was taken away: `pointercancel`, `lostpointercapture`, blur, or dispose. */
 | {
    readonly kind: 'cancel';
    readonly id: number;
}
/** `dz` is already normalized to CSS pixels; `pinch` marks a trackpad pinch arriving as a wheel. */
 | {
    readonly kind: 'wheel';
    readonly sx: number;
    readonly sy: number;
    readonly dz: number;
    readonly pinch: boolean;
} | {
    readonly kind: 'key';
    readonly code: string;
    readonly down: boolean;
}
/** The window lost focus. Everything held is released, and no `up` was needed. */
 | {
    readonly kind: 'blur';
} | {
    readonly kind: 'tick';
    readonly index: number;
}

The entire input to the recognizer. Plain data, serializable, no clock, no DOM.

tick is how time enters — InputSystem.tick appends one — which means a log is a complete description of a session's input including its timing, expressed on the only axis a fixed-step loop can replay against: tick indices. Wall-clock timestamps are deliberately absent. Replayed against a loop whose pumps fall differently, timestamped events land in different ticks and the run diverges; they look like they would help, which is what makes them worse than nothing.

InputLog interface ↳ src/sample.ts:97

interface InputLog {

A recorded session's input, and everything needed to know the recording is still valid.

@latticekit/persist owns the envelope this goes in — versioning, integrity, storage — and stores this verbatim: it never reorders samples and never rewrites a field. The three scalars are its ReplayCompat triple, compared for exact equality before the first tick, because recognition rules change with the package version, gesture durations are counted in ticks, and the same finger movements under different thresholds are a different session.

Read the triple off a freshly created log — see createLog — rather than typing it at a call site, so the recorded and the current cannot drift apart in a refactor.

4 members
readonly version: number

See LOG_VERSION.

readonly stepMs: number

The fixed step the session was recorded at, in milliseconds.

readonly profile: string

The recognition thresholds in force, canonically encoded. See profileFingerprint.

readonly samples: readonly RawSample[]

Arrival order, tick samples included, exactly as submitted.

DiagnosticCode type ↳ src/sample.ts:109

type DiagnosticCode = 'covered-by-overlay' | 'touch-action-overridden' | 'unknown-key-code' | 'pointer-events-none' | 'buffer-overflow' | 'flat-ground-pick'

Things this package can detect about its host that are always bugs.

Diagnostic interface ↳ src/sample.ts:129

interface Diagnostic {

A problem worth a sentence, not a throw.

Every one of these has a legitimate cause as well as a broken one — a modal legitimately covers the world, a keyboard legitimately has a code this build's table does not list — so refusing would be wrong and silence would be worse. The message names the caller's mistake and the element responsible, never a bare description.

3 members
readonly code: DiagnosticCode
readonly message: string
readonly element?: Element

The element responsible, where there is one. Absent for anything the DOM did not cause.

record6 symbols

Recording a session, and playing one back.

input records, persist stores and verifies, loop drives. Nobody owned replay and the constitution's headline claim was therefore unfalsifiable; it is split three ways along the dependency graph, with each side declaring the others structurally rather than importing them. That is why ReplayCursor is written out here instead of imported: loop is layer 1 and this package is layer 2, so the edge does not exist and must not be invented.

Why a log is a list of samples and not a list of events

A stream of timestamped browser events is not a replay log. Replayed against a fixed-step loop whose pumps fall differently, the same events land in different ticks and the run diverges — so the log is bucketed to ticks at capture time, and is replayable by construction rather than by luck. There is not one wall-clock timestamp in this file, and there is nothing here that could produce one.

The compatibility triple

version, stepMs, profile. Compared for exact equality and refused rather than migrated, because a session recorded at a 16.667 ms step and replayed at 20 ms will not land on the same pixel, and a session recorded under a tap threshold of 8 px and replayed at 12 px turns one pointer stream into a different sequence of actions. A migrated input log is a log that no longer replays, and a divergence report nobody should trust is worse than no report.

Read the triple off createLog rather than typing it at a call site, so the recorded and the current cannot drift apart in a refactor.

What the triple does not cover, and who covers it instead

The triple is version, stepMs, profile — three things that decide what the log says. The action map decides what a replay does with what the log says, and it is deliberately not a fourth member: putting it there would make every log ever recorded unreplayable the first time a player rebound a key, which is a far larger claim than the defect warrants. It is covered from the other end instead — InputSystem.setActions refuses while a recording is open, and replayCursor refuses if either setter fires while a replay is in flight.

Which leaves this file with one rule worth stating plainly:

A log is verified once, and the thing verifying it must stay the same afterwards. replay gets that for free — it is synchronous, and the only code that could run during it is a handler, which both setters already refuse. replayCursor does not, because the driver runs the whole game between two applyAt calls, so it carries the system's epoch and re-checks it.

InputRecording interface ↳ src/record.ts:59

interface InputRecording {

A running recording. stop() returns the finished log for persist to put in an envelope.

Idempotent: the second stop() returns the first one's log, so a game that stops in both a normal path and a teardown path does not record two different endings.

1 member
stop(): InputLog

ReplayCursor interface ↳ src/record.ts:71

interface ReplayCursor {

A recorded log, seen the way @latticekit/loop's replay driver sees it.

Structurally identical to that package's ReplaySource, and deliberately not imported from it. The driver calls applyAt exactly once per tick, in ascending order, before that tick's update — which is exactly the contract InputSystem.tick wants, and is the whole reason this shape is worth conforming to rather than inventing a fourth one.

4 members
readonly ticks: number

Ticks recorded. The replay ends here, and ending is the point.

readonly stepMs: number

The step the log was recorded at, so the driver can refuse a mismatch by name.

applyAt(tick: number): void

Submit everything recorded for one tick, then close it.

Allocates nothing: the cursor is one integer into the log's array. Call it once per tick in ascending order; a driver that applied inputs one tick late would produce a divergence report that blames the game for the driver's bug.

Ticks past the end of the log deliver an empty bucket rather than throwing, because a driver running longer than the recording is a legitimate thing to do and a crash is not a useful answer to it.

checkpointAt(tick: number): number | undefined

Always undefined. Checkpoints are digests of game state, which this package cannot see and must not guess at; @latticekit/persist's Recorder owns them. Present so this satisfies the driver's shape without a wrapper object at the call site.

createLog functionstart here ↳ src/record.ts:103

function createLog<A extends string>(system: InputSystem<A>): InputLog

An empty log carrying this system's compatibility triple.

The value to hand @latticekit/persist's createVerifier as current.inputs: read off a live system rather than typed out, so the recorded triple and the current one cannot disagree without the system itself having changed.

record function ↳ src/record.ts:123

function record<A extends string>(system: InputSystem<A>): InputRecording

Begin recording every sample this system receives, plus a marker per tick.

Recording costs one small object per sample and nothing at all when it is off — a game that never calls this pays for none of it.

Throws

RangeError if this system is already recording. Two recorders sharing one sample stream produce two logs that each claim to be the whole session.

Throws

TypeError if system did not come from this package.

replay function ↳ src/record.ts:209

function replay<A extends string>(system: InputSystem<A>, log: InputLog): void

Feed a recorded log back in, tick by tick, using the log's own tick indices.

Synchronous and complete when it returns: every sample submitted, every tick closed, every handler run. The system must be fresh — replaying into one that has already ticked past the log's first index is refused by InputSystem.tick, which is the honest failure.

Throws

RangeError naming the mismatch if the log's version, stepMs or profile differs from the system's. Replaying a log under different thresholds is not a replay; it is a different game with the same finger movements.

replayCursor function ↳ src/record.ts:241

function replayCursor<A extends string>(system: InputSystem<A>, log: InputLog): ReplayCursor

A cursor over a log, for @latticekit/loop's replay driver.

Unlike replay, this closes each tick with the driver's index rather than the log's. The two agree for a session recorded from tick 0, which is every session a game records; where they do not — a recording started mid-game — what matters is that the gaps between markers are preserved, and consuming exactly one marker per call preserves them.

It verifies once and then checks that the verification still holds

The compatibility triple is compared here, at the moment the cursor opens — and then the driver gets control back between every pair of applyAt calls, and runs the whole game in the gap. That gap is a hole the recording refusals cannot reach: setProfile and setActions refuse while a recording is open and a replay is not a recording, so without this a game could retune itself half way through replaying its own log and the second half would be recognized, or dispatched, under rules the log was never recorded under. Nothing would throw, and the report at the end would be confidently wrong.

So the cursor remembers the system's epoch and refuses the first applyAt that finds it moved. @latticekit/loop's driver does not have to know this exists; it is the same refusal a setter would have made, one tick later, from the only place that can still make it.

Throws

RangeError naming the mismatch if the compatibility triple differs.

index1 symbol

height1 symbolre-exported from @latticekit/iso

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.

dispose1 symbolre-exported from @latticekit/core

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.

projection1 symbolre-exported from @latticekit/iso

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

vec21 symbolre-exported from @latticekit/core

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.