Traps

SkillDev tools

The failures in a Lattice game that produce no error and a plausible-looking wrong result — a black screen, a tap that opens the wrong thing, art that floats above its own hill, a frame counter that lies, a game that gets slower at dusk, a save that silently stops being written. Use when something builds and runs but looks or behaves wrong, when a symptom points nowhere near its cause, or before believing that a passing check means anything.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Traps skill

What this skill tells your AI

The instructions your AI receives, as published by plausibleventures/lattice in skills/traps/SKILL.md and read by ahel’s review.

Every entry here was found as working-looking code. None of them throws. Most of them point somewhere other than their cause, which is why they are worth carrying: an agent can read a .d.ts and cannot read any of this.

Use it two ways. Symptom first if something is already wrong; the list if you are about to write one of these.


Symptom → cause

what you seelook at
a black or one-color screencamera never framed · sort() never called · darkness at 1 with no lights · light field not on the pen · a canvas with no size · a fractional grid index
the tap opens the thing behind the one under the fingersomething reordered after sort(), or two collections and an offset
taps land uphill or downhill of where the finger was, by more the higher the hillcreateInput was never told about the terrain, so every gx/gy is the sea-level answer
a sculpting brush slides off the far side of a hill while the hand holds stillthe same, on ground that moves: the error tracks the height under the pointer
something got placed at NaN, and no bounds check said anythingonGround unchecked on a system that does declare terrain
taps miss on tall things specificallyspriteVolume called without the ground
art floats above or sinks below its hilldrawSprite's ground argument omitted
a building renders near-black, or one glows all nighta palette slot used as the wrong kind — ink and night are not fills, and warn/ok/bad do not darken
the world sits in the middle of a big empty backgroundit is a diorama: the world ran out before the frame did
the game gets slower at dusk and stays slowpalette stop sets rebuilt inside the render callback
the game gets slower over minutes, with no eventan animated color feeding the ramp cache
a frame counter reads 0.0 msthe tab is hidden
a frame counter reads healthy while the game visibly hitchesworstFrameMs cannot see between pumps
the game keeps drawing but nothing respondsa hot reload left a zombie instance bound to the canvas
a tap on the world does nothing at allthe HUD is covering it — run auditOverlay
the HUD is frozen at old numbers after switching tabsstate is being updated in render
a wall, fence or run of flags draws nothingit is edge-on: equal gx and gy deltas
terrain renders perfectly flatthe relief axis, or its sign
income is wrong after coming back to the tabderived from dt, or the offline warp was restarted
a modal reopens after the player confirmeda poll of derived state with no latch
the save quietly stops being writtena save from the future made the store read-only
progress vanished on reloadlocalStorage.clear() raced the autosave's flush
a currency comes back as NaNInfinity went through JSON and returned as null
a world regenerates differently after panning away and backa sequential Rng where a hash belonged

The list

Readonly<Vec2> is not a barrier, and a callee will write to your frozen constant

TypeScript ignores property readonly modifiers when checking assignability, so two interfaces differing only in readonly are mutually assignable — and a Readonly<Vec2> flows happily into a parameter typed Vec2, where the callee writes to it.

import { v2, v2Add } from '@latticekit/core';

const ORIGIN: Readonly<{ x: number; y: number }> = Object.freeze(v2(0, 0));
// Compiles. Throws a TypeError on the one frame this path executes.
v2Add(ORIGIN as { x: number; y: number }, ORIGIN as { x: number; y: number }, v2(1, 1));
import { v2, v2Len } from '@latticekit/core';
import type { ReadonlyVec2 } from '@latticekit/core';

// `ReadonlyVec2` builds a real barrier — a phantom property whose types conflict in exactly one
// direction. It erases at runtime and costs nothing.
export function lengthOf(a: ReadonlyVec2): number {
  return v2Len(a);
}
export const p = lengthOf(v2(3, 4));

Import ReadonlyVec2 for read parameters. Never hand-write Readonly<Vec2> and assume it is the same thing. Note also that Object.freeze(v2(0, 0)) infers Readonly<Vec2> — the explicit annotation is the entire protection. This claim survived ten design documents, a compile of the whole surface, and a review; it was falsified only when somebody tried to make the compiler enforce it.

ink is the outline slot, and nothing in the slot list says so

BASE_SLOTS names ten colors and offers them as equals. One real session, choosing two roof materials for a market, wrote this unprompted:

isoRoof(pen, item.gx + 0.15, item.gy + 0.15, w, d, h, 0.72, item.seed % 2 ? 'warn' : 'ink');

Half the roofs came out near-black and the town read as burnt. ink's three faces shade to 13 / 11 / 9% luminance with an outline at 4% — the three-tone spread that makes a solid legible is four points wide, and the silhouette stroke is derived from the fill, so there is nothing left below near-black for it to be derived into. brand in the same call is 42 / 30 / 21 with a 13% outline.

The other half is the mirror failure. warn, ok and bad barely move between DAY and NIGHT — 71→66, 64→58, 39→36 — because a HUD must stay readable at midnight, while ground goes 58→22. So those roofs glow all night over a town that went dark, with nothing anywhere to explain it. Fills are ground, brand, metal, glass; night is the tint argument of light.begin and nothing else; a game's own materials go in through extendStops. Full table in art.

It compiles, it passes every test, and it is obvious in one glance at the running game — which is the only place it is visible at all.

A world that fits in the frame

The default outcome of a from-scratch build: a small complete world dead center, corners on all four sides, two thirds of the opening frame background. It reads as a model of a place at every zoom, because the problem is not the zoom — the world ran out before the frame did. The call that does it is camera.fitBounds(worldRect): fit a region instead, and size the map against the viewport. The five rows are in starting; extent is the one that is expensive to retrofit.

A moving color is a cache key

softEllipse — under glowDot, contact shadows and every light pool — renders one ramp per (inner, outer) color pair and reuses it. The pair is a cache key, which no signature says.

That cache shipped once keyed on the exact 8-bit pair and evicting wholesale, so one animated color deleted every other call site's ramp as well: 3.74 misses a frame, a full cache drop every 26 frames, about 3.7 MB/s of garbage — with the miss table naming contact shadows, light pools, sky and walkers, all constant-color sites that should have been permanent hits. In a heavier scene it was 15.9 a frame.

The key is snapped to 32 levels per channel now and eviction takes one entry, so you do not have to quantize colors in your own art code. What remains: animating both endpoints independently multiplies pairs (32 → 32²), and your palette counts as an animated color — a Palette.lerp on a continuous t every frame moves every slot in the scene at once, which one exhibit found as 27% of its soft ellipses missing with no flickering light anywhere in it.

The tell was never a flicker. It was a game that got slower and stayed slower.

gx/gy resolve on the ground you declared — and on z = 0 if you declared none

Screen → grid inverts on the plane z = 0 and on no other, so a system told nothing answers with the sea-level member of the family of tiles under that pixel: a real tile, next to the right one, moving with the pointer, and wrong by more the taller the terrain — 281 px and 14–16 tiles on one slope, 212–237 px on another, always uphill of the finger. On ground the player deforms it is not even a constant offset: the error tracks the height under the cursor, so a brush walks off the far side of a hill while the hand holds still. The fix is one optionterrain: { field, maxHeightPx } on createInput, or terrain: 'flat' to say the ground really is level. Undeclared, it says so once in the console as flat-ground-pick.

The declared version has a new failure of its own: over the sky a gesture reports onGround: false and gx/gy of NaN, every comparison against which is false, so a handler that skips the check places the building nowhere and no bounds test objects. Check onGround wherever you used to read gx straight. Full treatment in input.

loop.stats.worstFrameMs cannot see a pause between pumps

It measures the pump's own wall time, so a garbage collection or a style recalculation landing between two pumps is in neither reading. One game measured 23.1 ms on one machine and 13.1 ms on another for the same build; another shipped a HUD reading 0.0 ms against a real worst gap of 9.2 ms. Use worstGapMs, and read it next to cadenceMs rather than next to a budget.

A frame readout of 0.0 ms means the tab is hidden

Not that the game is fast. requestAnimationFrame is 0 Hz in a backgrounded tab — measured suspending for 6,108 ms in one case — and the loop reports a confident zero. Every number beside it is also wrong.

Two clocks in one game

A modal polled "should this be open?" every 900 ms while quests settled every 1,000 ms. Between a settle and the next one the derived condition was briefly true again, so the modal reopened after the player had confirmed — and the obvious recovery, pressing confirm again, overwrote the company name they had just typed. One-shot UI is driven off a latch or an event, never off a poll of derived state. And one createLoop per game: @latticekit/ui starts no timer of its own precisely so a second one cannot exist.

A light field that was never attached to the pen

Leave light out of the beginFrame literal and there is no night at all: the composite is a no-op, every sprite's emit hook is skipped, and every add() accumulates into a buffer nobody reads — while the field reports 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.

A stepMs typed by hand

16 against a loop running at 16.667 is a long press that fires at 432 ms, a fling 4% low, and a recorded log a replay refuses months later with a message nobody can trace back to the literal. It no longer compiles — pass the loop — but the shape of the failure is worth knowing, because every duration in this kit is a plain number whose name ends in its unit and the name is the entire defense. after(3000, …) on a timeline measured in seconds is fifty minutes.

Reordering after sort()

Paint order and pick order are the same permutation or the game is lying about what the player tapped. The one that will actually happen is partitioning: drawing every contact shadow first and every body second looks better, is a stable partition of the sorted order, and is a reorder. Walk indexAt forward twice instead. Both packages stay green while a player taps a lamp and opens the building behind it.

The related version is two collections and an offset — index - things.length — which is arithmetic that is correct only while three unchecked facts hold at once. Keep one array in the sorter's own index space.

spriteVolume and drawSprite without the ground

Omit the ground and the silhouette is computed at sea level while the art is painted up the hill. Measured across three seeds of one game: tap targets 212 to 237 CSS pixels below the art, and nothing looked broken because a hand-written bubble fallback that did know the elevation caught most of the taps the silhouette missed. The marker still lit — through a test that had nothing to do with what was on screen.

Infinity through JSON

Infinity is a perfectly exact arithmetic result and is precisely the value that does not survive being written down: it serializes to null, under a valid checksum, and comes back as NaN on the next tick. Nothing downstream can detect it. expectSerializable and a Number.isFinite check inside the head recognizer are what stop it.

hashString walks UTF-16 code units

macOS hands you NFD; Windows and most browsers hand you NFC. The same visible name typed on two machines produces two different save keys and two different worlds — and the bug reproduces on nobody's machine. .normalize('NFC') text a human typed; never normalize bytes you are checksumming, because a save truncated mid-combining-sequence must fail.

An untagged edge is silently never gated

One game passed gate ratios to buildFlow where the edge carried no gate, so the dark paid nothing — while the HUD said +1.7× and a toast promised offerings were worth more after dark. Three surfaces agreeing on a lie, fixed by one word.

Restarting the offline warp

The obvious way to re-enter after a mid-absence event is a fresh span measured from where you left off. It is also the exploit: the warp is strictly concave, so restarting pays for K absences instead of one and each restart is cheaper than the last. 8 × offlineCredit(t/8) is 72,000 s where offlineCredit(t) is 35,348 — the softcap has simply gone. Hand Crossing.atSeconds back as the next call's fromSeconds.

A capped gap with an uncapped anchor

offlineCredit clamps its input, so a device clock a year fast credits about eleven hours. But advance still stamps the ledger at the bogus instant, and when the clock is corrected every read sees time running backwards and credits zero — the economy freezes for a year, with no error and a save that looks fine. reanchor is two lines and nobody writes it.

A save from the future

Degrading to fresh is correct in memory and catastrophic on disk: the old build would autosave four seconds later over a good save. So future is the one failure reason that also sets the store read-only, every write skips, and storage comes out byte-identical. If you are wondering why saving has silently stopped, this is why — and the player needs to be told, in a modal, because everything they do from now on is unrecorded.

localStorage.clear() does not reset a game

The live autosave flushes on pagehide and writes the state back over the clear. The order that works is: close the store to writes, stop every autosave handle, then remove the key. store.reset() does exactly that.

scheduleFrom(loop.real), never loop.real.after

The loop schedules in seconds; persist schedules in milliseconds. Passing the method directly does not compile — and cast through, it asks for a write every 4,000 seconds, so the game autosaves once every 67 minutes while the status reports ok the whole time.

A wall along the near-far diagonal has zero screen width

World x is (gx − gy) · HALF_W, so a segment whose gx and gy change by the same amount projects to a vertical line. Every number is finite, the projection is doing exactly what it promises, and the art is simply not there. A run of prayer flags cost one game a full iteration with nothing anywhere saying why. isoWall now throws and names both tiles; isEdgeOn is the predicate. An animated endpoint must not be able to sweep through the diagonal — the frame it crosses is the frame that throws, and "safe by accident" is not safe.

Terrain that renders flat

isoTerrain measures relief east-to-west, because those are the two corners a 2:1 projection puts on the same screen row. Two failures follow. Invert the sign and terrain still looks like terrain — lit from the right, under buildings lit from the left, reading as flat for a reason no screenshot names. And a landform whose gradient runs along the other diagonal has a relief term of exactly zero: a six-thousand-foot cliff shading like a texture. Supply the north − south term through tint; it is one subtraction.

A continuous height field renders as triangles

Whatever the model does. The fix is a render-side vertex snap — snap each drawn vertex about 86% of the way onto its band, leaving a seventh of the real relief so every frame still differs from the last — and it happens in the render and never in the model, or every checkpoint, gradient and fingerprint moves with it. Snap up: snapping down crosses the band boundary by construction and stripes each bench with the color of the one below it.

tileSourceOf answers has() true everywhere

Correct for an unbounded world, and screenToTileOnHeights uses has() as its only off-map test — which is also what input's onGround reports — so the naive composition of the two never misses. One game reported sculpting grid (-4000, 900) from a tap on the sky. Two correct decisions composing into a wrong answer, which is a class this kit keeps producing.

A camera you gave no bounds is not unbounded

The default is about ±10,000 world pixels — roughly ±312 tiles — and its own comment calls that "effectively unbounded". A game that pans forever crosses it in fourteen screens of travel. And tileBounds' height argument extends minY upward, which is right for framing and wrong for a fence: pass your tallest building to frame the shot, and something much smaller as the clamp, or the player parks the viewport in the sky above the far corner and still satisfies keepVisible.

PathFinder's heuristic ignores weights

Any TileCost above 1 makes the estimate inadmissible-by-underestimate and slides A* toward Dijkstra — about 17×: 0.13 ms mean unweighted against 2.18 ms weighted with an 8.9 ms worst, on identical geometry. Weights are the package's own documented way to say "shorter but harder", so the feature and the performance of the feature disagree. Bake the cost grid.

massing and animate get different Rng streams

A sprite whose massing chose a height and a lean cannot recover either by drawing in the same order in animate. So a moving crown sits beside the static one it is supposed to be, and the tree renders with its head beside its neck. Worst on the tallest, leaniest instances, which is why it survives a review at a glance. Address by index: toUnit(hashStep(v.seed, i)).

noise2 returns exactly zero more often than you would guess

397k of 14M samples — but only when both inputs are lattice points. Any code whose correctness depends on "noise is never exactly zero" is a live bug on integer coordinates.

There is no sprite bitmap cache, and there is no hover gesture

Both are deliberate absences, not gaps you are failing to find. The cache was written, measured and deleted — 400 buildings of 42 draw calls each is 2.14 ms, and a perfect cache buys back at most 2.1 ms of an 8 ms budget in exchange for four new ways to render something stale. "Cache it" is not a move available to you. And GestureMap has six members, none of which is a pointer position with no button down — a tile highlight that follows the cursor is input.hoverTile(out), asked once per update, and not a raw pointermove listener of your own, which picks on the flat plane again and disagrees with the tap that follows it.

Rebuilding the audio engine to change a setting

dispose() closes the AudioContext and a document gets about six of them, ever. A voice-ceiling slider that rebuilt the engine on every drag would permanently silence the page in about a second. setMaxVoices and setMaxPan are live setters for exactly this reason.

A chord spelled as one sound

minGapMs is keyed on the sound id, so six strings sharing an id are six plays of the same sound in the same instant and five are thrown away. Right for a "collect all" button, wrong for a chord. And in a tab that has never had a real gesture, available reports true while the audio clock stays at 0 — so the second play of any id is refused for ever, and the symptom is "the first sound works and nothing after it does".

A HUD updated in render

It freezes in a background tab while the canvas keeps showing its last painted frame, so the game looks alive with prices, timers and affordability marks that stopped twenty minutes ago. State goes on update. And the fix is not a setInterval of your own — update already is the interval; a second clock is the two-clocks bug above.

A hot reload leaves a zombie

Vite re-evaluates the module, createInput correctly throws on the second binding to the same canvas — and the first instance is still bound and still rendering. 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. One line: if (import.meta.hot) import.meta.hot.dispose(() => input.dispose());

Fractional grid indices

A droplet spawned one cell past the end of a typed array; a terrain walk whose first diagonal came from a real-valued position and stayed fractional all the way to gy * N + gx. Both read between two cells, which is undefined, which is NaN the moment it is multiplied — and the renderer then correctly refuses the tint and the frame goes black. 0/0, Infinity - Infinity and Math.sqrt(-1) are all exactly specified, so this is never a floating-point-tier problem. The guard belongs at the computation that mints the index, and in both cases it was one clamp or one Math.ceil at a loop bound.


The meta-trap: green is not evidence

The kit's tenth rule. A suite that passes over a black screen is a failure this project has already shipped once. Every change that affects what the player sees ends with somebody actually looking at the running game — open it, screenshot it, judge it.

Two corollaries that keep catching people:

  • High coverage is not evidence a feature works in a real game. One package in this kit reached 100% statement coverage with no consumer anywhere in the plan.
  • A run-tested example and a hand-written one are indistinguishable to a reader, and are read with equal trust. Nobody copies the snippet that happens to be under test; they copy the one nearest the symbol they were looking at.

Where the rest lives

the areaskill
the boot, the loop, the two silent wiring mistakesstarting
color, sprites, terrain shading, palettesart
terrain, paths, picking on slopes, endless worldsworld
production, prices, offlineeconomy
taps, drags, picking backwardsinput
synthesis, buses, the bedsound
migrations, statuses, resetsaving
overlays, latches, the pointer contracthud
replays and what may be hasheddeterminism
what is actually slow, and what not to optimizeperformance

Every package also ships its README: node_modules/@latticekit/<name>/README.md.

Signals

GitHub stars
38
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
traps
Source
github.com/plausibleventures/lattice