# Starting

> The 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.

- Skill: `plausibleventures/starting` (Agent Skill)
- Install (CLI): `npx skillmds@latest add plausibleventures/starting`
- Raw SKILL.md: https://api.skillmd.com/api/skills/plausibleventures/starting/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: plausibleventures (https://skillmd.com/u/plausibleventures)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/plausibleventures/starting

---


# Starting a Lattice game

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.

```ts
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.** `renderFrame` calls `order.sort(camera)` and the cull
  lives inside that sort, so everything you spend *before* `add` — sampling terrain, rebuilding a
  sprite definition, planning a path — is paid in full for things off the screen. Put that work
  behind `camera.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

```ts wrong
// 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.

```ts
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

```ts wrong
// 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.

```ts
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.zoom` has no setter, on purpose.** `zoomAt` takes a factor and a required anchor,
  which is right for a wheel notch and wrong for "show me the world I just made". Framing is
  `fitBounds(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, hand `fitBounds` a rectangle of the
  viewport's own aspect at that scale — and know that having to fabricate it is a known gap.
- **`bounds` you 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.
- **`vite` does not typecheck.** A type error will not stop the page loading; it produces a
  subtly wrong game instead. Run `tsc --noEmit` before 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.

