Traps
SkillDev toolsThe 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.
No other account needed.
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 see | look at |
|---|---|
| a black or one-color screen | camera 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 finger | something reordered after sort(), or two collections and an offset |
| taps land uphill or downhill of where the finger was, by more the higher the hill | createInput 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 still | the same, on ground that moves: the error tracks the height under the pointer |
something got placed at NaN, and no bounds check said anything | onGround unchecked on a system that does declare terrain |
| taps miss on tall things specifically | spriteVolume called without the ground |
| art floats above or sinks below its hill | drawSprite's ground argument omitted |
| a building renders near-black, or one glows all night | a 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 background | it is a diorama: the world ran out before the frame did |
| the game gets slower at dusk and stays slow | palette stop sets rebuilt inside the render callback |
| the game gets slower over minutes, with no event | an animated color feeding the ramp cache |
a frame counter reads 0.0 ms | the tab is hidden |
| a frame counter reads healthy while the game visibly hitches | worstFrameMs cannot see between pumps |
| the game keeps drawing but nothing responds | a hot reload left a zombie instance bound to the canvas |
| a tap on the world does nothing at all | the HUD is covering it — run auditOverlay |
| the HUD is frozen at old numbers after switching tabs | state is being updated in render |
| a wall, fence or run of flags draws nothing | it is edge-on: equal gx and gy deltas |
| terrain renders perfectly flat | the relief axis, or its sign |
| income is wrong after coming back to the tab | derived from dt, or the offline warp was restarted |
| a modal reopens after the player confirmed | a poll of derived state with no latch |
| the save quietly stops being written | a save from the future made the store read-only |
| progress vanished on reload | localStorage.clear() raced the autosave's flush |
a currency comes back as NaN | Infinity went through JSON and returned as null |
| a world regenerates differently after panning away and back | a 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 option —
terrain: { 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 area | skill |
|---|---|
| the boot, the loop, the two silent wiring mistakes | starting |
| color, sprites, terrain shading, palettes | art |
| terrain, paths, picking on slopes, endless worlds | world |
| production, prices, offline | economy |
| taps, drags, picking backwards | input |
| synthesis, buses, the bed | sound |
| migrations, statuses, reset | saving |
| overlays, latches, the pointer contract | hud |
| replays and what may be hashed | determinism |
| what is actually slow, and what not to optimize | performance |
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