Starting a Lattice game
SkillMonitoring & opsThe wiring order for a Lattice game — canvas, surface, camera, palette, light, depth sorter, loop and input — and the shape a first build should take so it does not come out a diorama. Use when starting an isometric game, setting up a Lattice project, writing the boot or main.ts, adding a game loop to a canvas, choosing a map size or an opening camera, when the world looks small or sits in the middle of an empty background, or when the first screen is blank, black, empty, or the game keeps drawing but stops responding to taps.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Starting a Lattice game skill
What this skill tells your AI
The instructions your AI receives, as published by plausibleventures/lattice in skills/starting/SKILL.md and read by ahel’s review.
Nine packages, one boot file, and an order that is not obvious. Get it wrong in two specific places and there is no error, no warning, and a picture that looks plausible — the whole reason this skill exists.
The layering, so you never have to guess which package a thing is in:
core ─┬─▶ iso ──┬─▶ draw ─┬─▶ ui
├─▶ loop │ │
├─▶ sim └─────────┤
├─▶ persist │
├─▶ input ──────────┘
└─▶ audio
core imports nothing. Nothing imports ui. If you find yourself wanting an upward import you
have the design backwards, not a missing export.
The boot, in full
This compiles and runs. Copy it, rename it, and change the world it builds — not the order.
import { createScope } from '@latticekit/core';
import { DepthSorter, createCamera, tileBounds } from '@latticekit/iso';
import type { Rect } from '@latticekit/iso';
import {
BASE_SLOTS,
beginFrame,
createCanvas2dSurface,
createLightField,
createPalette,
endFrame,
isoTile,
renderFrame,
} from '@latticekit/draw';
import type { Passes } from '@latticekit/draw';
import { browserFrames, createLoop, createTweens } from '@latticekit/loop';
import { createInput } from '@latticekit/input';
// ── the screen ────────────────────────────────────────────────────────────────────
const host = document.getElementById('app') ?? document.body;
const canvas = document.createElement('canvas');
canvas.style.cssText = 'display:block;width:100%;height:100%';
host.append(canvas);
const scope = createScope();
const surface = createCanvas2dSurface(canvas);
const palette = createPalette(BASE_SLOTS);
// ── the world, and the camera that has to be told about it ────────────────────────
const W = 160; // not 64. See "the shape a first build takes"
const H = 160;
const MAX_HEIGHT_PX = 96; // the tallest ground on the map
const worldRect: Rect = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
tileBounds(0, 0, W, H, MAX_HEIGHT_PX, worldRect);
// The OPENING SHOT is a region of the world, never the whole of it. A fresh camera looks at
// world (0, 0) — in a 2:1 projection the *top corner* of the map — so with no fit at all the
// first frame is empty space beside the world. Fitting `worldRect` is the opposite failure:
// every corner of the map on screen at once, which is the diorama the next section is about.
const opening: Rect = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
tileBounds(W * 0.3, H * 0.3, W * 0.4, H * 0.4, MAX_HEIGHT_PX, opening);
const camera = createCamera(Math.max(1, innerWidth), Math.max(1, innerHeight), {
bounds: worldRect, // where the player may go: the whole map
minZoom: 0.25,
keepVisible: 0.5,
});
camera.fitBounds(opening, 24); // where they start: a part of it
// ── the night. Built unconditionally: it costs nothing while darkness is 0 ─────────
const light = createLightField(surface, { scale: 0.6, falloff: 1, bloom: 0.3 });
const order = new DepthSorter(512); // allocated once, reused for ever
const tweens = createTweens();
// ── the clock, BEFORE the input, because the input needs it ────────────────────────
const loop = createLoop({
clock: { now: () => performance.now() },
frames: browserFrames(), // rAF paints; an interval ticks when hidden
});
const input = createInput({
element: canvas,
camera,
step: loop, // the loop itself. Never a number
terrain: 'flat', // this ground IS level, and says so. The moment
// it grows a heightfield: { field, maxHeightPx },
// or every tap resolves at sea level. See `input`
actions: { touch: ['tap'] },
});
// ── one resize handler, so there cannot be two that disagree ───────────────────────
function fit(): void {
const w = Math.max(1, innerWidth);
const h = Math.max(1, innerHeight);
// `surface.pixelRatio`, never `devicePixelRatio` — the surface already clamped the device's
// ratio, and re-reading the raw one here silently undoes that.
surface.resize(w, h, surface.pixelRatio);
camera.resize(w, h);
camera.fitBounds(opening, 24);
}
addEventListener('resize', fit);
visualViewport?.addEventListener('resize', fit); // iOS: a collapsing URL bar fires only this
scope.add(() => {
removeEventListener('resize', fit);
visualViewport?.removeEventListener('resize', fit);
});
fit();
// ── the two wirings it is fatal to cross ──────────────────────────────────────────
let daylight = 1;
loop.onUpdate((dt, tick) => {
input.tick(tick); // BEFORE the game's update: a handler must see the world as the
// player left it, not one step behind it
daylight = 0.5 + 0.5 * Math.cos(loop.realTime * 0.1);
tweens.step(dt); // AFTER: a tween started this step should not also advance in it
});
const passes: Passes = {
maxHeightPx: MAX_HEIGHT_PX, // or a summit vanishes when its own base leaves the bottom edge
terrain(pen, visible) {
for (let gy = visible.gy0; gy < visible.gy1; gy++) {
for (let gx = visible.gx0; gx < visible.gx1; gx++) isoTile(pen, gx, gy, 'ground');
}
},
solids(pen, sorted) {
for (let i = 0; i < sorted.count; i++) {
const index = sorted.indexAt(i);
void pen;
void index; // draw the thing at `index` here
}
},
};
loop.onRender((_alpha, time, nowMs) => {
input.frame(nowMs); // the camera's glide integrates here, at display rate
const pen = beginFrame({ surface, camera, palette, t: time, clear: 'sky', light });
light.begin(pen, 1 - daylight, 'night'); // darkness 0–1, and the color the dark goes
order.clear();
// …fill `order` with everything on screen…
renderFrame(pen, passes, order); // renderFrame calls sort() itself
endFrame(pen);
});
// ── teardown, and the one line that saves an hour under Vite ──────────────────────
function dispose(): void {
loop.stop();
input.dispose();
light.dispose();
scope.dispose();
canvas.remove();
}
if (import.meta.hot) import.meta.hot.dispose(dispose);
loop.start(); // nothing runs before this. No ambient loop, no autostart
The shape a first build should take
Almost every from-scratch isometric build comes out as a diorama: a small complete world in the middle of a large empty background, corners visible on all four sides, two thirds of the opening frame sky. It reads as a model of a place rather than as a place, and it reads that way at every zoom, because the problem is not the zoom — the world ran out before the frame did.
Eleven worlds in this kit were rebuilt against the five rows below, over several passes, and they are cheap to get right up front and expensive to retrofit: extent decides the map size, the camera bounds and the generator at once. On a 1440×900 viewport at the opening zoom:
| the rule | the failure it names | |
|---|---|---|
| extent | the world's bounding rect is at least 1.6× the viewport on its long axis, and something the game is about is off-screen on the first frame | a world with visible corners. Nothing invites a drag |
| fill | no more than a third of the opening frame is empty background — sky, sea, void | a diamond of content ringed by flat color is the shape every naive isometric demo has |
| edges | the world meets the frame edge, or a horizon does. Never a hard corner with background behind it | a floating slab announces the map's dimensions, which are an implementation detail |
| density | whatever the game repeats — trees, towers, walkers, lamps — is counted in hundreds, not dozens | thirty of anything disproves the claim that these are cheap |
| cost | 60 fps on a mid laptop, judged on the worst frame in ten seconds | grandeur that costs the frame is the same mistake as a diorama, arrived at from the other side |
Read them in order, and cost is a gate rather than a trade. A build that is grand and drops frames has not half-passed; it fails before density is scored, because a stuttering scene reads as cheap no matter how much is in it. That row was missing for exactly one exhibit and that exhibit came back dense and slow — a standard that asks for more of something and names no price gets paid for out of frame time, which is the one budget nobody is watching.
None of this is expensive. Extent is a constant, density is a loop bound, and the far band is
art; together they are usually under twenty lines. W = H = 160 and an opening rect that is a
part of the world — both in the boot above — are the whole of the first three rows: measured
on a 1440×900 viewport that boot opens at zoom 0.34 with the world 3480 × 1773 px, or 2.4×
the frame on its long axis and 2.0× on its short one. A 64-tile map fitted to worldRect instead
puts the entire world in 1392 × 729 px — 0.97× the frame, every corner on screen, background
all the way round. That is the diorama, and it is one fitBounds argument away.
When it is slow, measure before you cut anything
The obvious suspect is usually innocent. 400 sprites of 42 draw ops each is 2.14 ms — 27% of a 60 fps budget — so at the density these rows ask for, drawing is not what makes a scene slow. Suspect instead:
- work spent on entities nobody can see.
renderFramecallsorder.sort(camera)and the cull lives inside that sort, so everything you spend beforeadd— sampling terrain, rebuilding a sprite definition, planning a path — is paid in full for things off the screen. Put that work behindcamera.isVisible, or do it once instead of per frame. - something periodic. A 6 ms mean beside a 23 ms worst is not a scene that is too big; it is something happening every N frames, and cutting the count will not touch it.
- an allocation on the hot path, which reads as a sawtooth rather than as a level cost.
There is no sprite bitmap cache in draw — one was written, measured and deleted on purpose —
so "cache it" is not a move available to you. Spend detail where the eye is instead: cost scales
with ops-per-sprite times sprites, so the lever on a distant thing is how many faces it is made
of, not whether it exists. Reducing the count is the last answer rather than the first, and if you
get there the number is a finding about the kit rather than a defeat.
The art half of this — three distance bands, and why another depth cue will not rescue a
composition that is not reading — is in art. The terrain half — the arithmetic that turns a
viewport into a map size, and the axis an isometric projection flattens — is in world.
The two mistakes that are silent
1. A stepMs typed by hand
// This no longer compiles — and that is the fix, not the problem.
createInput({ element: canvas, camera, stepMs: 16, actions: { touch: ['tap'] } });
createInput counts every gesture duration in ticks and multiplies by the step it was handed;
it never reads a clock. A step that is not the loop's does not fail — it lies by a constant
ratio. 16 against a loop running at 16.667 is a long press that fires at 432 ms and a
fling velocity 4% low.
So step takes a FixedStep and the shortest thing that type-checks is the loop itself. Where
there is no loop — a headless test, a replay — use fixedStep(60), which derives the step with
createLoop's own arithmetic so the two are bit-identical rather than merely close.
import { createHeadlessInput, fixedStep } from '@latticekit/input';
import { createCamera } from '@latticekit/iso';
const camera = createCamera(800, 600);
const input = createHeadlessInput({ camera, step: fixedStep(60), terrain: 'flat', actions: { collect: ['tap'] } });
Build the loop before the input. That is the whole reason for the order in the boot above.
2. A light field that was never attached to the pen
// The field exists, `light.add()` is being called, and there is no night. No error anywhere.
const pen = beginFrame({ surface, camera, palette, t: time, clear: 'sky' });
light.begin(pen, 0.8, 'night');
Leave light out of the beginFrame literal and pen.light is undefined. Then
renderFrame's pen.light?.composite() does nothing, drawSprite skips every sprite's emit
hook so no lamp glows, and every light.add() you make accumulates into a buffer nobody ever
reads — while the field goes on reporting active: true with a live count. The natural
diagnosis is "the night is broken" and the natural place to look is the light field, where
nothing is wrong.
begin throws when the field is not the pen's, which is one comparison per frame and buys you
that sentence on the first one. Keep the field on the beginFrame literal and there is nothing
to remember.
Two clocks in one game is the bug
A Lattice game contains exactly one thing that decides when work happens. That is the loop. Packages expose a tick-shaped method; they never go and find a clock.
In the game this kit came from, a modal polled at 900 ms while quests settled at 1,000 ms, so
between a settle and the next one the derived condition was briefly true again and the modal
reopened after the player had confirmed — overwriting the company name they had just chosen
when they pressed confirm again. Not a flicker: the loss of the most personal value in the save.
traps has the full version.
So: one createLoop, and everything else hangs off it.
import { browserFrames, createLoop } from '@latticekit/loop';
import { createOverlay, drive } from '@latticekit/ui';
const now = (): number => performance.now();
const loop = createLoop({ clock: { now }, frames: browserFrames() });
const ui = createOverlay({ now }); // the SAME clock. Two clocks in one HUD is the bug above
drive(ui, loop); // update → ui.tick, render → ui.repaint
@latticekit/ui starts no timer and no rAF loop of its own, deliberately, for exactly this reason.
loop.realTime is seconds; createOverlay's now wants milliseconds. If you need a
millisecond clock inside a game whose only clock is the loop, it is loop.realTime * 1000 —
four separate exhibits arrived at that same expression independently.
What goes on update and what goes on render
The table is short and getting it wrong is not a stutter, it is a world that stops existing.
| attach it to | runs in a hidden tab? | put here |
|---|---|---|
loop.onRender(alpha, time, nowMs) | no — rAF is 0 Hz | pixels, and nothing else |
loop.onUpdate(dt, tick) | yes | rules, HUD data, chunk streaming, anything that must not freeze |
loop.real.every(s, fn) | yes, and unclamped | autosave, telemetry, "has the day rolled over?" |
| a timestamp in state, integrated on read | yes, exactly | the economy, and any long duration |
The classic version of getting this wrong: an endless world that streams chunks from render.
Switch tabs for ten seconds and come back, and the world has not merely stuttered — it stopped
existing while you were away.
And loop.time is not real time. It drifts below it on purpose: a hidden pump is clamped to
250 ms of catch-up and the excess is dropped, not deferred. A thirty-second build timer put on
loop.sim takes two minutes if the player looks away, which reads as a bug and is worse than
one because you cannot reproduce it in the foreground.
The order inside one frame, and why
input.tick(tick) → your update → tweens.step(dt) (fixed step)
input.frame(nowMs) → beginFrame → renderFrame → endFrame (display rate)
input.tick delivers the bucket of events that closed before this tick started; it is the
only place a handler ever runs. input.frame integrates the camera's glide and delivers
nothing. Drain input in the render callback, or after the camera has moved, and the tile a tap
resolves to is not the tile that was under the finger in the last frame the player actually saw.
Under Vite, dispose on hot reload
HMR re-evaluates the module, createInput correctly throws on a second binding to the same
canvas — and the first instance is still bound and still rendering. So the symptom is not the
error: it is a game that keeps drawing while every tap does nothing and the readout is frozen,
with the real message buried in a console nobody is looking at by then. The
import.meta.hot.dispose line in the boot above is the whole fix.
Things that will bite you in the first hour
- A camera copies the rectangle you hand it. If the world's bounds are not known until after
the seed is read, pass an empty rect, fill it, then call
camera.setBounds(worldRect). It is not politeness; it is the only way the second half of that order gets across. camera.zoomhas no setter, on purpose.zoomAttakes a factor and a required anchor, which is right for a wheel notch and wrong for "show me the world I just made". Framing isfitBounds(rect, marginPx), whose margin is in CSS pixels so the gutter is the same at every fitted zoom. If you genuinely want a specific zoom, handfitBoundsa rectangle of the viewport's own aspect at that scale — and know that having to fabricate it is a known gap.boundsyou omit is not "unbounded". The default is about ±10,000 world pixels — roughly ±312 tiles — and a game that pans forever crosses it in fourteen screens of travel.vitedoes not typecheck. A type error will not stop the page loading; it produces a subtly wrong game instead. Runtsc --noEmitbefore you believe a screenshot.
What this skill does not cover
| you want | read |
|---|---|
| drawing anything that is not a flat tile | art |
| terrain, roads, walkers, flow fields | world |
| taps, drags, pinch, placing things | input |
| numbers that grow, prices, offline progress | economy |
| sound | sound |
| saving and migrations | saving |
| a HUD, buttons, toasts | hud |
| replays, or two runs that differ | determinism |
| a stutter, or a bad frame number | performance |
| something that works and looks wrong | traps |
Every package also ships its own README, and it is on disk:
node_modules/@latticekit/loop/README.md and its siblings are the long-form version of everything
above.
Signals
- GitHub stars
- 38
- Forks
- 5
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
starting- Source
- github.com/plausibleventures/lattice