# Lattice > The grid underneath. A TypeScript kit for building isometric, deterministic, zero-asset games. > Nine composable libraries, no dependencies of any kind, no asset files, 83.02 kB gzipped > for all of them, and 2,648 tests. Repository: https://github.com/C-Aniruddh/lattice This file is the whole kit in the form an agent wants it. Everything in it is generated from `.lattice/kit.json` and `site/data/*.json` at build time, so it cannot disagree with the code. The same content as JSON is at /api.json; the repository's own manifest is at /kit.json. ## Install Two different installs, for two different readers, and confusing them is the mistake this file exists to prevent. **A person installs the plugin**, once, into the agentic environment they already use. It carries the parent skill that owns `/lattice` and eleven specialists, and it is what makes an agent good at this kit rather than merely able to import it. Claude Code /plugin marketplace add C-Aniruddh/lattice /plugin install lattice@lattice Codex codex plugin marketplace add C-Aniruddh/lattice codex plugin add lattice@lattice Grok Build grok plugin install C-Aniruddh/lattice **An agent installs the libraries**, per project, and this is the line you want if you are reading this file: npm i @latticekit/core @latticekit/iso @latticekit/draw @latticekit/loop @latticekit/input Add `@latticekit/audio`, `@latticekit/persist`, `@latticekit/sim` and `@latticekit/ui` as you need them. There are no peer dependencies and nothing transitive. Both work today: all nine packages are on the public npm registry at 0.1.1, and the repository is public. This file, /api.json and /kit.json are served alongside them. ## The rules that bind every package 1. Determinism is a feature. `Math.random()`, `Date.now()` and `performance.now()` are banned inside every package's `src/`. Randomness comes from a seeded `Rng` the caller passes in; time arrives as a parameter. It has two tiers: Tier A is `+ - * /`, `sqrt`, `imul` and the bitwise operators, which ECMA-262 specifies exactly and which may reach a save file. Tier B is `sin`, `cos`, `pow`, `exp`, `log`, which the spec does not require to be correctly rounded and which may reach pixels only. Every Tier B site is marked `@tier-b` and is greppable. 2. No dependencies. Not on npm, not on the DOM unless the package name says so, and on each other only along the layering below. 3. The dependency graph is a DAG and points one way. `core` imports nothing; nothing imports `ui`. 4. Pure and impure never mix in one file. A module that touches `window`, `document`, `AudioContext` or `localStorage` says so in its first doc line. 5. Every public symbol is documented with a *why*, not a *what*. 6. No public API without a test that would fail if it were deleted. 90% statements per package, 100% on everything in `core`. 7. The hot path allocates nothing. Anything called per frame or per entity takes an output parameter or returns a primitive. 8. Zero assets. No images, no audio files, no fonts, no binaries. Art is procedural, sound is synthesized. 9. Errors name the caller's mistake, never a bare `Error`. 10. Green is not evidence. A UX-affecting change ends with somebody looking at the thing running. 11. An option a caller supplied is a value they can read back. ## Is this ready? What is stable and what is not Version 0.1.1, published to npm as `@latticekit/*`. Stable: the 527 exported names (`npm run lint` fails the build if a package exports a name `.lattice/kit.json` does not list); their behavior (2,648 tests, 90% statements per package, 100% in core); the layering and the determinism rule, both lint-enforced; the per-package size budgets. Not stable: function signatures, because nothing has shipped to a registry and nothing outside this repository uses them yet; the `/lattice` plugin, which is specified in docs/SKILLS.md and not built. Versioning: semver, with the pre-1.0 rule stated — a minor bump may break source compatibility, a patch never does. The nine packages version and publish in lockstep, one number for the whole kit. Two kinds of breakage are tracked separately: source breaks, which a compiler finds, and artifact breaks, which make something already written down invalid (a save, a replay log, a shared seed). The second kind ships with a migration or it does not ship. docs/SEAMS.md is the list. ## Browser support Canvas2D. No WebGL, no WebGPU, no WebAssembly, no workers, no OffscreenCanvas. Beyond the canvas: `requestAnimationFrame`, `ResizeObserver`, and Pointer Events with `setPointerCapture`. `@latticekit/persist` uses `localStorage` behind a swappable adapter; `@latticekit/audio` uses `AudioContext` and needs a user gesture before it makes a sound. Neither is required by the rest. Published as ES2022 ES modules, unminified. The newest syntax in the built output is private class fields and `Array.prototype.at`, which puts the floor at about Chrome 92, Edge 92, Firefox 90 and Safari 15.4 — spring 2022. That floor is read off the compiler target and the built output, not off a browser test matrix: CI runs the suite in Node on 20.19, 22 and 24 and there is no browser matrix. ## Why not Phaser, Pixi or Three Each is better than this at what it is for and none of them is for this. Three is a 3D renderer; an isometric game is a 2D projection with a sorting rule, and a scene graph is a large dependency for a coordinate transform. Pixi is a fast 2D renderer and nothing else, so the projection, depth sort, pathfinding, seeded noise, save migrations and sound remain yours to write — which is most of what these nine packages are. Phaser is the closest and fairest comparison: a complete engine with scenes, physics, input, audio and a loader, and a decade of documentation. If you want a game engine, use Phaser. Three things here are not on that list: determinism by rule rather than by discipline (the clock and the random source are lint errors inside a package, which is what makes a replay land on the same pixel); no asset pipeline at all, because art is derived and sound is synthesized; and a kit written to be handed to an agent, with the manifest, invariants, contracts and known traps machine-readable at /api.json. If none of those is worth anything to you, use Phaser. ## The layering layer 0: core layer 1: iso, loop, sim, persist, audio layer 2: draw, input layer 3: ui ## The packages ### @latticekit/core (7.40 kB gzipped) Deterministic primitives. Seeded randomness, noise, math, easing, typed events, pools, formatting. - layer: 0; environment: isomorphic; depends on: nothing - modules: rng, hash, noise, math, easing, vec2, events, pool, format, guard, time, dispose - start with: createRng, hash2, v2, createScope - signatures and doc comments: /reference/core/ — generated from packages/core/dist/**/*.d.ts - invariants: - 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. - exports (100): COMPACT_SUFFIXES, Disposer, DurationStyle, EASINGS, EPSILON, Easing, EasingName, Emitter, EpochMillis, MonotonicMillis, MonotonicNow, Now, Pool, PoolOptions, ReadonlyVec2, Rng, RngSnapshot, Scope, TAU, Vec2, approx, asEpochMillis, asMonotonicMillis, backIn, backOut, bounceOut, clamp, clamp01, createRng, createScope, cubicIn, cubicInOut, cubicOut, damp, expectFinite, expectIndex, expectInt, expectNonEmpty, expectObject, expectRange, expectRecordOfFinite, expectSafeInteger, expectSerializable, fbm2, fbm3, fmtCompact, fmtDuration, fmtInteger, fmtPercent, fmtRate, fmtSigned, hash2, hash3, hashBytes, hashNumber, hashParts, hashStep, hashString, inOut, inverseLerp, isSerializable, lerp, linear, mix32, mod, moveTowards, noise2, noise3, quadIn, quadInOut, quadOut, quartOut, remap, reverse, smooth, smoother, smoothstep, toUnit, unreachable, v2, v2Add, v2AddScaled, v2Angle, v2Approx, v2Copy, v2Cross, v2Dist, v2DistSq, v2Dot, v2FromAngle, v2Len, v2LenSq, v2Lerp, v2Normalize, v2Perp, v2Rotate, v2Scale, v2Set, v2Sub, wrap ### @latticekit/audio (7.76 kB gzipped) Sound without assets: WebAudio synthesis from declarative sound definitions, with voice limiting, buses and a music sequencer. - layer: 1; environment: browser; depends on: core - modules: engine, voice, sounds, bus, bed, music - start with: createAudio, createBed, createDeck - signatures and doc comments: /reference/audio/ — generated from packages/audio/dist/**/*.d.ts - invariants: - No AudioContext exists until a user gesture unlocks it. - Silent, not throwing, where there is no WebAudio. play() in a headless run produces no sound and still reports acceptance — the policy above is pure and testable, the rendering below is not. - A hard voice ceiling. Summed gains above 1 clip into a click, and twenty voices never sound twenty times better. - A layer is one fixed chain of ten numbers, not author-defined routing. The moment routing is author-defined the clipping ceiling can no longer be validated statically. - This package stores nothing. The mixer returns a versioned snapshot and the game hands it to persist — there is no edge between two layer-1 packages. - exports (35): ATTACK_SEC, Audio, AudioOptions, BUS_NAMES, Bed, BedLayer, BedOptions, BusId, BusName, LOOKAHEAD_SEC, Layer, MAX_VOICES, Mixer, MixerState, MusicDeck, Note, PUMP_INTERVAL_MS, PlayOptions, RAMP_SEC, SEMITONE, Song, SongProblem, SoundDef, SoundProblem, Track, TrackVoice, VERSION, VoicePlan, Wave, createAudio, createBed, createDeck, effectiveGain, validateSong, validateSounds ### @latticekit/iso (11.47 kB gzipped) Isometric space: the projection, the camera, depth sorting, tile maps, footprints, hit-testing, and grid pathfinding. - layer: 1; environment: isomorphic; depends on: core - modules: projection, footprint, camera, depth, tilemap, height, hittest, anchor, path - start with: createCamera, DepthSorter, screenToTile, pathSample - signatures and doc comments: /reference/iso/ — generated from packages/iso/dist/**/*.d.ts - invariants: - Three coordinate spaces — grid, world, screen — never conflated. Every conversion is a named function taking an output parameter. - Tile lookup floors, never rounds. Rounding snaps to the nearest lattice vertex and picks the wrong tile for three quarters of every diamond. - Hit-testing is computed from state and camera, never cached during a draw pass. pick() is a method on the sorted Scene, so 'reverse of paint order' is structural rather than remembered. - Tile size is fixed at 64x32. Any other uniform size is exactly a camera zoom, and parameterizing it would infect draw, input and ui signatures permanently. - Elevation lives on grid vertices, not tile centres. Once z exists the projection is no longer invertible, so picking must be terrain-aware. - A path is a curve to be sampled by arc length, not a list of nodes to be stepped. Grid-unit parameterization would make a walker 58% faster on one diagonal than the other. - exports (80): Anchor, Camera, CameraOptions, DIR_DX, DIR_DY, DepthSorter, FlowField, Footprint, GridPoint, HALF_H, HALF_W, HeightField, MutableTileSource, Path, PathFinder, PathOptions, Rect, STEP_DIAG, STEP_ORTHO, TILE_H, TILE_W, Tile, TileCost, TileGrid, TileGridOptions, TileRange, TileSource, VERSION, Volume, anchorPan, anchorToScreen, anchorVisible, boxSilhouette, createCamera, depthOf, footprintAnchor, footprintBase, footprintBounds, footprintContains, footprintFlatness, footprintOverlaps, forEachFootprintTile, gridToScreen, gridToWorld, gridToWorldX, gridToWorldY, heightAt, isEdgeOn, pathDirAt, pathProject, pathSample, pathSimplify, pickSorted, pointInPolygon, pointInTile, pxToUnits, rectCenterX, rectCenterY, rectContains, rectExpand, rectFromSize, rectHeight, rectIntersects, rectIsEmpty, rectMakeEmpty, rectSet, rectUnion, rectWidth, screenToTile, screenToTileOnHeights, slopeAt, tileBounds, tileDiamond, tileSourceOf, unitsToPx, worldToGrid, worldToGridX, worldToGridY, worldToTile, worldToTileOnHeights ### @latticekit/loop (6.57 kB gzipped) Time. A wall-clock game loop with fixed-step simulation and interpolated rendering, plus scheduling, tweens and frame statistics. - layer: 1; environment: isomorphic (host clock is injected); depends on: core - modules: clock, frames, loop, scheduler, tween, stats, replay - start with: createLoop, browserFrames, createTweens, replay - signatures and doc comments: /reference/loop/ — generated from packages/loop/dist/**/*.d.ts - invariants: - Simulation advances on the wall clock, never on frame deltas — rAF is 0 Hz in a hidden tab. - Catch-up is clamped at 250ms per pump and the excess is dropped, not deferred. The loop advances callbacks; sim advances value. - loop.time deliberately drifts below real time while hidden. Anything that must be truthful about the player's wall clock is a timestamp in state, never a duration accumulated on the fixed step. - The clock and the frame source are both injected, so every test runs at whatever speed it likes with no timers. - This package has no epoch and stamps nothing. The calendar is one game-owned function, injected. - exports (38): BrowserFramesOptions, Clock, DEFAULT_ABSENCE_MS, DEFAULT_BUDGET_MS, DEFAULT_HZ, DEFAULT_IDLE_PUMP_MS, DEFAULT_MAX_CATCH_UP_MS, DEFAULT_WARMUP_FRAMES, DEFAULT_WINDOW_MS, Disposer, FrameHost, FrameSource, FrameStats, Job, Loop, LoopOptions, LoopPhase, ManualClock, ManualFrames, Pump, PumpKind, ReplayOptions, ReplayResult, ReplaySource, Scheduler, Timeline, TimerId, TweenId, TweenOptions, Tweens, VERSION, browserFrames, createLoop, createTimeline, createTweens, manualClock, manualFrames, replay ### @latticekit/persist (5.79 kB gzipped) Saves that survive: versioned state, an explicit migration chain, pluggable storage, debounced writes and integrity checks. - layer: 1; environment: isomorphic (storage adapter is injected); depends on: core - modules: store, migrate, adapters, integrity, replay, browser - start with: createStore, migrations, createRecorder - signatures and doc comments: /reference/persist/ — generated from packages/persist/dist/**/*.d.ts - invariants: - The chain IS the version. createStore reads the head off the migration chain, so declaring version 7 and shipping a chain that ends at 6 is inexpressible. - Every migration steps exactly one rung, and every rung carries a recognizer. A save can never be orphaned. - Writes flush on visibilitychange, not beforeunload — mobile Safari does not reliably deliver the latter. reset() closes handles BEFORE removing the key, or the autosave writes the live state back over the clear. - A corrupt save degrades to a fresh one with one of seven closed reasons, returned as a value. Never a thrown exception on boot. - A save from the future makes the store read-only. A stale deploy must not eat a good save. - A replay log is evidence, not progress: it is never migrated. A version, stepMs or profile mismatch is refused by name, because a migrated recording would produce a confident wrong answer. - exports (49): Autosave, AutosaveOptions, Cancel, ChainBuilder, Checkpoint, Checksum, Digest, Divergence, Envelope, FailureReason, FlushTargets, Increment, ListenerTarget, MigrationChain, MigrationStep, OpenResult, ReadFailure, Recognize, Recorder, RecorderOptions, Refusal, Rejected, ReplayCompat, ReplayLog, ReplayVerdict, ReplayVerifier, Schedule, SecondsTimeline, StorageAdapter, StorageLike, Store, StoreOptions, StoreStatus, VERSION, WriteFailure, WriteResult, WriteSkip, browserStorage, createRecorder, createStore, createVerifier, defaultChecksum, elapsedSince, inspect, installFlushTriggers, memoryStorage, migrations, scheduleFrom, webStorage ### @latticekit/sim (7.58 kB gzipped) Idle-economy mathematics in closed form: cost curves, the flow integrator, offline accrual, and capacity gating. - layer: 1; environment: isomorphic; depends on: core - modules: graph, flow, ledger, offline, schedule, crossing, capacity, cost, ids - start with: defineEconomy, advance, advanceOver, maxBuyable - signatures and doc comments: /reference/sim/ — generated from packages/sim/dist/**/*.d.ts - invariants: - Closed form, never a loop. maxBuyable is O(1) and 12x faster than a 400-step buy loop; the loop is legitimate only as a test oracle. - The economy has no tick. State is (stocks, rates, lastTimestamp) and is integrated on read. sim reads no clock and accepts no delta — every call that moves the anchor takes a required epoch timestamp. - The topological order is computed by Kahn and therefore proven. Declared storage order stays separate from evaluation order, so a v4 node cannot move a v1 save's fields. - Cycles and self-loops are refused at construction, naming the cycle. A numerical fallback would be a second implementation of the economy that diverges silently on exactly the saves that matter. - Offline progress warps time, never yield, and a plan is never re-based. Credit for a resumed absence is W(span) - W(from), which telescopes; restarting the warp at each discovered crossing would pay for K absences instead of one, and each restart is cheaper. - The upper clamp on an offline gap is the softcap's flat branch. A device clock a year fast credits eleven hours. - exports (50): CapacityCurve, CatchUp, CostCurve, Crossing, Economy, EconomySpec, Edge, EdgeScale, EdgeSpec, EntityId, Flow, GateRatios, IdSource, Ledger, Milestones, NO_GATES, OfflineCurve, Phase, StockVec, Stocks, VERSION, advance, advanceOver, asEntityId, buildFlow, bulkCost, capacityLoad, capacityShare, capacityWall, costOfNext, createFlow, createIdSource, defineEconomy, degreeOf, elapsedSeconds, expectFiniteStocks, integrate, maxBuyable, maxOfflineCredit, milestoneMultiplier, mintId, offlineCredit, offlineCreditRate, offlineElapsed, project, ratesOf, reanchor, solveCrossing, solveCrossingOver, zeroStocks ### @latticekit/draw (12.33 kB gzipped) The rendering layer: a Surface interface with a Canvas2D backend, color derivation, and the isometric solid kit that makes procedural art read as designed. - layer: 2; environment: browser (Canvas2D) with an offscreen/headless backend for tests; depends on: core, iso - modules: surface, canvas2d, record, color, palette, solids, sprite, shadow, light, text, layers, cache - start with: createCanvas2dSurface, renderFrame, createPalette, createLightField - signatures and doc comments: /reference/draw/ — generated from packages/draw/dist/**/*.d.ts - invariants: - Draw calls go through Surface. No package reaches for CanvasRenderingContext2D directly. - A solid is described by one color; its faces are derived. Shadows cool, highlights warm. - Everything is drawable into an offscreen surface, which is what makes UI thumbnails and golden tests possible. - exports (100): Animator, BASE_SLOTS, Bitmap, BlitMode, BoxOpts, Canvas2dOpts, DAY, DEFAULT_TEXT, DUSK, ESTIMATED_ADVANCE_RATIO, Emitter, FACE_LEFT, FACE_RIGHT, FACE_TOP, FLAG_BUILDING, FLAG_GHOST, FLAG_POWERED, FLAG_SELECTED, FrameOpts, GHOST_LIFT, GROUND_LIFT, Ink, LEVEL_H, LIGHT_TINT, Layer, LightField, LightFieldOpts, MIN_WALL_TEXT_PX, Massing, NIGHT, OffscreenOpts, OffscreenSurface, Op, OpName, PALETTE_STEPS, PASS_NAMES, Palette, Passes, Pen, RecordingSurface, RecordingTarget, RenderTarget, Rgba, SELECT_LIFT, SHADE_TINT, SolidWriter, SpriteDef, Stops, Surface, SurfaceKind, TargetMode, TextStyle, VARIANT_ZERO, VERSION, Variant, Vars, beginFrame, contactShadow, createCanvas2dSurface, createLightField, createOffscreenSurface, createPalette, createRecordingSurface, cssOf, defineSprite, drawFootprint, drawGhost, drawSprite, endFrame, extendStops, glowDot, hex, hexOf, hsl, hueToHex, isoBox, isoCylinder, isoPatch, isoPost, isoRoof, isoTerrain, isoTile, isoWall, lerpPalette, levelsToPx, mix, outlineOf, paletteVars, pxToLevels, renderFrame, rgba, screenText, shade, spriteBounds, spriteHeightPx, spriteVolume, subPen, wallText, wash, withAlpha ### @latticekit/input (15.39 kB gzipped) 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. - layer: 2; environment: browser; depends on: core, iso - modules: profile, sample, step, recognize, actions, terrain, events, cameracontrol, scope, system, record, dom - start with: createInput, createHeadlessInput, createLog - signatures and doc comments: /reference/input/ — generated from packages/input/dist/**/*.d.ts - invariants: - 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. - exports (44): ActionBinding, ActionEvent, ActionMap, CameraController, DEFAULT_PROFILE, Diagnostic, DiagnosticCode, DiagnosticSink, Disposer, DomInputSystem, DragGesture, FixedStep, GestureBase, GestureMap, GestureName, GestureProfile, GridPoint, HeadlessInputOptions, HeightField, InputLog, InputOptions, InputRecording, InputScope, InputSystem, LOG_VERSION, PointerKind, ProfileOverrides, ProfileScalar, RawSample, ReplayCursor, TapGesture, Terrain, TerrainOption, VERSION, Vec2, ZoomGesture, ZoomSource, createHeadlessInput, createInput, createLog, fixedStep, record, replay, replayCursor ### @latticekit/ui (8.72 kB gzipped) DOM overlay primitives — a declarative element builder, panels, toasts, number rolls, and thumbnails rendered from the draw kit. Deliberately not a framework. - layer: 3; environment: browser; depends on: core, draw - modules: overlay, el, panel, toast, roll, thumb, theme - start with: (none declared) - signatures and doc comments: /reference/ui/ — generated from packages/ui/dist/**/*.d.ts - invariants: - No virtual DOM. The whole overlay is a few dozen nodes that change a few times a second. - The package ships no stylesheet at all. The root is inline pointer-events:none and interactivity is granted per node, so the specificity fight that swallows taps on the world has no rule to lose to. - State updates on the interval cadence, never inside the render callback. If render never runs, every number on screen is still right. - Anything that is not painting must survive a hidden tab. The loop's update callback IS the interval — a second setInterval here recreates the poll-races-settle data-loss bug. - exports (48): AcknowledgeOptions, Attrs, BrandOptions, CadenceFn, Child, Dispose, Disposer, Driven, FloatHost, FloatKind, FloatOptions, LayerName, MountOptions, Overlay, OverlayOptions, Palette, PaletteOptions, Panel, PanelOptions, Roll, RollOptions, ScreenPoint, ThumbCache, ThumbSpec, ToastHost, ToastKind, ToastOptions, VERSION, acknowledge, applyPalette, auditOverlay, clear, createOverlay, drive, el, floats, hide, interactive, panel, passthrough, pulse, roll, setBrand, setText, setTokens, show, thumbnails, toasts ## Cross-package contracts - **draw must not reorder after iso's sort()** (iso + draw) — breaks as: the tap opens the building behind the one under the finger. Tested in `test/contracts/`. - **draw's stroke traces boxSilhouette's six points in order** (iso + draw) — breaks as: hit-testing and pixels diverge with no test in either package noticing. Tested in `test/contracts/`. - **the tick index starts at 0, increments by one, never skips or repeats** (loop + input + persist) — breaks as: a replay that reports a confident wrong answer. Tested in `test/contracts/`. - **stepMs is a compatibility constant appearing in recorded sessions** (loop + persist) — breaks as: a log recorded at 60Hz replayed at 50Hz diverges for reasons no stack trace shows. Tested in `test/contracts/`. ## A program that compiles import { createCamera } from '@latticekit/iso'; import { BASE_SLOTS, beginFrame, createCanvas2dSurface, createPalette, endFrame, isoBox } from '@latticekit/draw'; import { browserFrames, createLoop } from '@latticekit/loop'; const surface = createCanvas2dSurface(document.body.appendChild(document.createElement('canvas'))); const camera = createCamera(innerWidth, innerHeight, { zoom: 0.62 }), palette = createPalette(BASE_SLOTS); createLoop({ clock: { now: () => performance.now() }, frames: browserFrames(), render: (_alpha, t) => { const pen = beginFrame({ surface, camera, palette, t, clear: 'sky' }); // erase, then paint the sky // Back to front is just the loop order in a 2:1 projection, so this city needs no depth sort. for (let gy = -7; gy < 7; gy++) for (let gx = -7; gx < 7; gx++) isoBox(pen, gx, gy, 1, 1, { color: 'metal', h: 2 + 5 * Math.sin(t + (gx + gy) * 0.4) ** 2 }); endFrame(pen); } }).start(); ## The gallery Each exhibit is a complete, runnable page under `examples/`, under 200 lines of logic, seeded from its URL, with a control panel exposing the kit parameters it uses. - **Lamp Road** (the hero) — A valley at dusk, and the proof that the seams fit. — `examples/demo` — uses core, iso, draw, loop, input, audio, sim, ui - **Crowd** — Nine hundred walkers, one expression, no per-walker state. A walker's position is pathSample(route, ((phi*i + t*v) mod 1) * arcLength). There is no walker struct. — `examples/crowd` — measured: 0 bytes of state per walker (examples/crowd/src/hud.ts:74) - **Island** — One day, in ninety seconds. Terrain, a shoreline, trees, and a full day/night cycle — the palette lerp doing the whole job. — `examples/island` — measured: a 90-second day (examples/island/src/main.ts:45) - **Clay** — The ground is material. Drag it, and watch everything else resettle. Raise a ridge and the water re-routes, the walkers re-plan, and the trees ride it up and slide off. — `examples/clay` — measured: 2,200 props over a live height field (examples/clay/README.md:181) - **Harbor** — Tall thin objects, one depth order. A mast is a twentieth of a tile wide and twelve storeys high, and only its base says where it belongs. One DepthSorter holds every hull, crane and the ninety-two-tile jetty. — `examples/harbor` — measured: 197 objects, one depth order (examples/harbor/README.md:92) — built by Codex from docs/GALLERY.md alone - **Wayfinding** — One field, six hundred and forty readers, one rebuild. There is no per-walker path. Closing a crossing rebuilds one field, and every walker's next read returns a different answer — so a map change costs the same whatever the crowd is. — `examples/wayfinding` — measured: 640 walkers, one rebuild (examples/wayfinding/src/main.ts:207) — built by Codex from docs/GALLERY.md alone - **Builder** — Footprints, a ghost, validity, and the tap-to-tile seam. One predicate between the pixel a finger landed on and the six tiles it would occupy. The ghost, the color under it and the refusal are all that same boolean. — `examples/builder` — measured: 1,444 yard tiles, 66 obstacles (examples/builder/README.md:84) — built by Codex from docs/GALLERY.md alone - **Caverns** — Pools that meet without a seam. The light field costs its buffer, not its light count — which is why the price is flat from 104 pools to 704. — `examples/caverns` — measured: 704 light pools (examples/caverns/README.md:98) - **Orbit** — No ground at all — platforms, stars, a cold palette. No TileGrid, no terrain pass and no height field. Every platform's angle is a closed-form function of t, and three star bands carry the depth the ground usually would. — `examples/orbit` — measured: 214 sorted objects, no ground (examples/orbit/README.md:94) — built by Codex from docs/GALLERY.md alone - **Replay** — Tap the marsh to seed a bloom. Then scrub the tape: every frame is recomputed from the seed and the log, never remembered. The bar re-runs from tick zero on every pointer move, checks each checkpoint as it passes, and can come back red — a check that cannot fail proves nothing. — `examples/replay` — measured: 1,081 ticks re-run in 1.5 ms (examples/replay/README.md:179) — built by Claude from docs/GALLERY.md alone - **Terraces** — Elevation, and why a tap needs the terrain. On ground with height the obvious screen-to-tile conversion is wrong, and it is wrong by more the higher you climb. — `examples/terraces` — measured: the naive pick misses by 1,400 px at the ridge (examples/terraces/README.md:41) - **Instrument** — Sound with no files. The ribbon in the air is Audio.onScheduled — what the synthesizer was asked to make, drawn before it is audible. An analyser would draw the speaker instead. — `examples/instrument` — measured: 60 recipes, 0 bytes of audio (examples/instrument/README.md:37) — built by Grok from docs/GALLERY.md alone - **Endless** — Pan forever. Nothing is loaded and nothing is kept. Chunks are minted from the seed as you reach them and evicted behind you. Come back and they are identical. — `examples/endless` — measured: 256 chunks resident, a flat 128 KiB (examples/endless/README.md:79) - **Canyon** — A million years of a river. The bar re-runs the model. Erosion accumulates, so the scrub bar is a deterministic re-run from the nearest checkpoint, never a lookup. — `examples/canyon` — measured: one erosion step, 112x112 grid, 0.30 ms (examples/canyon/README.md:112) - **Idle** — Fourteen hours, one step. The price of the next kiln is b·r^k evaluated rather than accumulated, so buy-max is arithmetic; a fourteen-hour absence is one advanceOver between two frames. — `examples/idle` — measured: 28,285 s credited in one step (examples/idle/README.md:98) — built by Grok from docs/GALLERY.md alone - **City block** — Setback massing and a window rhythm. Every value here is in the URL. The technique that carries the whole look: one color per solid, three faces derived, a rhythm of warm windows. — `examples/city` — measured: 36 buildings, 120 cars (examples/city/src/traffic.ts:30) - **Migration** — A v1 save opened by a v5 build, one rung at a time. Four migrations declared once, and five sealed builds fall out of the same chain. A crate that a build refuses topples back over the rung it failed, named. — `examples/migration` — measured: 750 saves on the ladder (examples/migration/README.md:114) — built by Claude from docs/GALLERY.md alone - **Resonance** — Answer the chord. A puzzle you solve by ear. Every gate hums a chord; you carry six tuned strings and no audio files exist. — `examples/resonance` — measured: 6 ms attack on a struck string (examples/resonance/README.md:29) - **Errand** — Walk, talk, take, use, save. The whole genre, small enough to read. Five verbs and deliberately no sixth. The save envelope is about sixty bytes and the HUD prints its size. — `examples/errand` — measured: a ~60-byte save envelope (examples/errand/src/main.ts:35) All 18 specified exhibits are built, plus the hero. ## From one sentence — three games nobody here designed Not exhibits, not bound by the gallery's rules, and not written in this repository. Each of these was built by a different vendor's agent in an **empty directory**, from **one sentence**, with the `@latticekit/*` packages installed from the public npm registry and no access to this repository. The source is unedited: the only change made to any of them is the `--port` in the dev script. - **Before the Bell** (Grok) — *"a game where you place stalls and open gates to pull the crowd to your bakery before the market closes"* — `from-one-sentence/before-the-bell` — 1,482 lines over 9 modules; uses audio, core, draw, input, iso, loop, ui - **Chime Path** (Claude) — *"a game where you hang chimes along a mountain path and tune each one, so the wind plays them in order as walkers pass"* — `from-one-sentence/chime-path` — 1,341 lines over 6 modules; uses audio, core, draw, input, iso, loop, persist, ui. Known defect, left in: five HUD nodes ship under the contrast floor, and the agent that drove a browser still missed them - **Evenfall Orchard** (Codex) — *"a game where you plant an orchard and each evening choose to harvest or let it grow, and it keeps growing while the tab is closed"* — `from-one-sentence/evenfall-orchard` — 73 lines in one module; uses core, draw, input, iso, loop, persist, sim, ui. Known defect, left in: one phase of its day is 84% one near-black color, which its author never saw Two of the three carry a real defect and both are recorded rather than fixed, because a record that hides its blemishes is not a record. `from-one-sentence/README.md` has the provenance, the transcripts, what was verified by hand, and why these must keep their registry dependencies rather than being converted to workspace ones. ### The fan-out 8 of the 18 were built by 3 vendors' agents — Codex (Harbor, Wayfinding, Builder, Orbit); Claude (Replay, Migration); Grok (Instrument, Idle) — each given only its own row of docs/GALLERY.md, the standard, and the tools, and none of them allowed to read an existing exhibit's source. 7 of the 8 passed every row of the looking harness unaided; the exception was Replay, on legibility, for a text node too small for the pass to measure. Every one of the 8 carries its author's own list of the places the document could not be acted on, verbatim in its README, and all 8 hit the same wall: `examples/_shared` — the bootstrap and the control panel those pages assume — lives in this repository and is not shipped. The collected findings are docs/GALLERY.md § What eight strangers found in this document. ## Traps that cost this project real time - **An animated color is an allocator.** `draw`'s Canvas2D backend caches each radial ramp against the exact color pair it was built from, and the cache evicts wholesale. A color that moves continuously — a flame mixed against noise, a palette lerping every frame — misses every frame and takes every other call site's entry down with it. Snap the color to eight or twelve levels; keep position, scale and timing continuous. - **`loop.stats.worstFrameMs` cannot see a pause between pumps.** Use `worstGapMs`. One exhibit read 4.6 ms and 69.2 ms from the two at the same instant. - **A frame readout of 0.0 ms means the tab is hidden**, not that you are fast. Check `document.visibilityState` before believing a number read through tooling. - **There is no sprite bitmap cache in `draw`.** "Cache it" is not a move available to you; the direct path is 2.14 ms for 400 sprites of 42 ops, 27% of the 8 ms budget. - **`readonly` is not a barrier.** TypeScript ignores property `readonly` when checking assignability, so a `Readonly` flows into a parameter typed `Vec2` and the callee writes to your frozen constant. Import `ReadonlyVec2` from `@latticekit/core`; never hand-write `Readonly` and assume it is the same thing. - **Tile lookup floors, never rounds**, and once elevation exists the projection is no longer invertible, so picking must be terrain-aware — `screenToTileOnHeights`, not `screenToTile`. The naive version misses by over a thousand pixels at the top of a hill. ## Measured figures, and the command behind each - packages: 9 — .lattice/kit.json — packages - dependencies: 0 — every package.json has no `dependencies` outside @latticekit; core has none at all - assetFiles: 0 — find packages -type f -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/coverage/*' — 207 .ts, 19 .json, 9 .md, nothing else - tests: 2648 — npx vitest run — 2648 passed (2648) - testFiles: 100 — npx vitest run — 100 passed (100) - publicSymbols: 527 — unique names across .lattice/kit.json packages.*.exports (raw sum 544; VERSION appears in eight) - gzipTotal: 83.02 kB — npm run size — total 83.02 kB, exclusive backends charged at the heaviest, never summed - frameBudget: 8 ms — .lattice/kit.json — budgets.maxFrameBudgetMs - spriteDraw: 2.14 ms — docs/PERFORMANCE.md:318 — 400 sprites x 42 ops, dpr 3: 2.14 ms mean, 2.24 ms p99, 27% of the budget - coverageFloor: 90 % — .lattice/kit.json — budgets.coverageStatements 0.9, coverageCore 1.0 - exhibits: 18 — site/data/exhibits.json — live[], one row per exhibit, plus the hero at hero{}, which is nineteen worlds in all. Each is a directory under examples/, built into /x// by site/tools/build.mjs and served as a page of its own; open any of them and it is running, not a picture. - exampleLines: 10 — grep -cvE '^[[:space:]]*($|//|/*|*)' site/example/hello.ts — docs/GALLERY.md's own line rule, applied to the whole program printed in Getting started and running beside it. site/tools/build-page.mjs re-counts the file at build time and fails the build if this number has drifted from it. - skills: 12 — find skills -name SKILL.md | wc -l — twelve directories under skills/, each carrying one SKILL.md: the parent that owns /lattice, plus eleven specialists it loads (starting, art, world, economy, input, sound, saving, hud, determinism, performance, traps). The parent is counted because it is a skill and not a wrapper — it is the one that fires on a from-scratch build. - traps: 34 — grep -c '^### ' skills/traps/SKILL.md — one heading per named failure that compiles, runs, and produces a plausible-looking broken game. Each carries the wrong version as well as the right one, because recognizing the wrong one is the job. - pageFrame: live — read off this page's own @latticekit/loop while you look at it: worstGapMs, the longest wall gap between two painted frames in the last ten seconds, with every world above it running on the same main thread. The only figure in this strip that was not measured on one machine on one day — it is measuring yours. It reads 'warming' for the first three seconds of a loop and an em dash in a background tab, because 0.0 ms means requestAnimationFrame stopped rather than that anything got faster. Measured at cbd48bd on 2026-08-18, Apple Silicon, Node v24.18. ## Further reading in the repository - https://github.com/C-Aniruddh/lattice/blob/main/AGENTS.md — the constitution, and the eleven rules above in full - https://github.com/C-Aniruddh/lattice/blob/main/docs/SEAMS.md — what holds between the nine, and what breaks if it moves. (AGENTS.md points at docs/ARCHITECTURE.md for this; that file does not exist. This one does.) - https://github.com/C-Aniruddh/lattice/blob/main/docs/GUIDE.md — the walkthrough - https://github.com/C-Aniruddh/lattice/blob/main/docs/PERFORMANCE.md — every benchmark, with the tail argument - https://github.com/C-Aniruddh/lattice/blob/main/docs/GALLERY.md — what makes an exhibit good, and the scale standard - https://github.com/C-Aniruddh/lattice/blob/main/docs/SKILLS.md — the agent skills and the `/lattice` command