---
name: incanto-building-2d-games
description: Build 2D web games with Incanto — y-down pixel coordinates, string-keyed assets, sprites/spritesheets, following camera, screen-space HUD. Use when creating or modifying 2D scenes.
---

# Building 2D Games with Incanto

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

Prerequisite: `incanto-scene-json-authoring.md` (this directory) for the file format. This skill covers the 2D taxonomy.

## Fastest start: scaffold a whole game

```bash
bunx incanto new my-game --template platformer-2d    # tilemap, jump feel, follow cam, coins
bunx incanto new my-game --template star-survivor    # endless waves, auto-attack, upgrades
bunx incanto new my-game --template molehill-2d      # played with the MOUSE — no character at all
bunx incanto new --list                              # every starter, 3D and 2D
```

Templates are COMPLETE games — level, HUD, sound, and a `verify.ts` harness
that plays them end to end — meant to be reshaped rather than read. Prefer
starting from one over wiring from scratch; `bun run verify` in the new project
tells you the moment a change breaks the game.

## Coordinate convention (Phaser/Godot prior)

**1 unit = 1 px · (0,0) top-left · +y DOWN · positive rotation = clockwise (degrees).**

## Boot sequence

```ts
import { createGame2D } from 'incanto/2d';
import { PlayerControl } from './behaviors';
import sceneJson from './my.scene.json';

const game = await createGame2D({
  canvas: document.querySelector('canvas')!,
  scene: sceneJson,             // cloned internally — no cast, no structuredClone
  behaviors: { PlayerControl }, // registered for you (hot-replace tolerant)
});
// game.engine / game.scene / game.renderer / game.physics
// game.dispose() — ONE call tears everything down (SPA unmount)
```

A scaffolded project calls this from `src/App.tsx` — React owns the page, the
component owns the canvas, and the boot runs in an effect. `canvas` comes from a
ref there rather than a `querySelector`. See `incanto-web-integration.md` for
that shell and for the `<IncantoCanvas>` component you use instead when the game
is one part of a larger site.

One call does register → load → physics → input → renderer → start. Physics is
`'auto'`: Rapier is enabled iff the tree has physics bodies (force with
`physics: true|false`). Keyboard attaches to `window` (`keyboard: false`
disables, or pass a target). `seed` / `fixedHz` / `pixelRatio` / `antialias`
pass through. Gesture-blocked audio retries on the first canvas pointerdown.
The manual `Engine` + `loadScene` + `Renderer2D` boot still exists for full
control.

## Assets (scene JSON `assets{}` — referenced as `"$key"`)

```json
"assets": {
  "hero": { "type": "texture", "url": "hero.png", "filter": "nearest" },
  "hero_run": { "type": "spritesheet", "url": "run.png", "frameWidth": 16, "frameHeight": 16 }
}
```
- `filter: "nearest"` = pixel-art mode (no smoothing, no mipmaps). Default is `linear`.
- Data URLs work as `url` (you can generate textures at runtime and inject them before `loadScene`).
- Unknown `$refs` are hard `UNKNOWN_ASSET` errors listing declared keys.

## 2D node taxonomy

All 2D nodes extend `Node2D`: `position [x,y]` px · `rotation` deg · `scale [x,y]` ·
`renderOrder` (draw order, higher = on top — the SAME prop name as 3D; legacy
`zIndex` still loads as an alias) · `visible`.

### `ColorRect2D` (no-art prototyping)
A solid-colored rectangle — no texture, no asset file. Props: `size [100,100]`,
`color '#ffffff'`, `opacity 1`, `anchor [0.5,0.5]`. The prototyping workhorse
(paddles, walls, platforms, flashes); swap in a `Sprite2D` when art arrives.

### `TileMap2D` (a whole tile level in ONE node)
Grid render in a single draw call **plus** greedy-merged static colliders —
stop hand-placing dozens of ColorRect walls:

```jsonc
{ "name": "Level", "type": "TileMap2D", "props": {
    "texture": "$tiles",      // atlas: tileSize×tileSize squares, L→R, T→B
    "tileSize": 32,
    "cells": [
      "..................",
      "......GGG.........",
      "000000000000000000"
    ],
    "legend": { "G": 12 },    // chars → atlas tile index (digits 0-9 are free)
    "solid": [0, 12]          // these tile indices become static colliders
} }
```

- `.` / space = empty; digits `0-9` = atlas tile index directly; any OTHER char
  must be in `legend` — unknown chars **hard-fail at load** (fix the map, not
  the runtime).
- **A legend value is an INDEX, not a description.** Every other tilemap format
  puts solidity in the tile definition, so `"legend": { "#": { "solid": true } }`
  is the natural guess — and it used to load clean and produce a level with no
  floor. It is a hard error now, naming the `solid` prop that was meant.
- `solid` tiles are merged into a handful of `StaticBody2D` rect colliders
  (greedy rectangles), so a 100×50 level costs a few bodies, not thousands.
- Cell (0,0) hangs its TOP-LEFT on the node's origin; position the node to
  place the level. `columns: 0` (default) derives atlas columns from the
  texture width.
- **No art yet? Paint with `colors`.** A char → `#rrggbb` map draws that cell as
  a flat quad, in the same single draw call, with no atlas at all:

  ```jsonc
  { "name": "Walls", "type": "TileMap2D", "props": {
      "tileSize": 56,
      "cells": ["########", "#......#", "########"],
      "colors": { "#": "#3a4356" },
      "solid": ["#"]
  } }
  ```

  A grid with rows and NEITHER of them draws nothing at all — the mesh is
  hidden, the colliders are still there, and you get a floor you can stand on
  and cannot see. `incanto-check` says so now (`examples/tiles-2d` shipped
  exactly that), so if you meant an invisible collision map over scenery you
  drew yourself, the warning is the price of saying so.

  Exclusive with `texture` (one material, one draw call — a half-textured level
  that loads clean is exactly the silence this engine fails loudly about), and
  `solid` takes the CHAR you painted as well as an atlas index, because that is
  the thing an author knows. Everything else is unchanged: `cellAt`, `tileAt`,
  `setTile`, `worldAt` and the merged colliders work the same. It exists because
  a sokoban built from the published package came out as 24 hand-placed
  `ColorRect2D` walls — the one node that answers every question a grid game
  asks could not be SEEN.
- Change the map at runtime by REPLACING `cells` (mutations are not watched):
  `map.cells = [...rows]` — geometry and colliders rebuild next frame. For ONE
  cell use `setTile` below, which does that correctly.

**Pointing at tiles** — the whole input of a tile game, and four calls:

```ts
const cell = map.cellAt(...this.engine.pointerWorld() ?? []);  // [cx, cy] | null
if (cell && map.tileAt(...cell) === 1) {
  map.setTile(cell[0], cell[1], '.');          // dig it out (a CHARACTER)
  marker.position = map.worldAt(...cell);      // the cell's CENTRE
}
```

`cellAt` is null off the grid, `tileAt` is `-1` for an empty cell or one that is
not there, and `worldAt` gives the cell's **centre** — the half-tile a
hand-rolled version forgets. All four measure from the node's WORLD position
(ancestors composed), which is the other thing hand-rolling gets wrong. An
ancestor that SCALES or rotates the map moves the picture and not the answer,
and `incanto-check` says so.

### `Sprite2D`
| Prop | Default | Notes |
|---|---|---|
| `texture` | `""` | `'$key'`; empty = hidden |
| `anchor` | `[0.5,0.5]` | `[0,0]` = top-left sits on the origin |
| `flipX` / `flipY` | `false` | |
| `tint` | `"#ffffff"` | multiplies the texture |
| `opacity` | `1` | |

### `AnimatedSprite2D` (extends Sprite2D — but use `sheet`, not `texture`)
```json
{ "sheet": "$hero_run", "autoplay": "run",
  "animations": { "run": { "frames": [0, 7], "fps": 12, "loop": true },
                   "hit": { "frames": [8, 8, 10], "fps": 10, "loop": false } } }
```
- `frames` with EXACTLY two ASCENDING numbers = inclusive `[start, end]` RANGE; any other
  shape (longer arrays, or a descending pair) = explicit frame list. Frame indices are
  row-major across the sheet.
- API: `play(name)` (hard `UNKNOWN_ANIMATION` error if missing), `stop()`,
  `currentAnimation`, `currentFrame`, `playing`; emits `animationFinished(name)` on
  non-loop completion. `autoplay` starts on ready.

**⚠️ STAND THE FEET ON THE GROUND — the `anchor` is almost never the feet.** A
character that looks like it FLOATS above the platform (or sinks into it) is the #1
sprite bug, and the fix is mechanical — you do NOT need to eyeball it. Two facts
combine: (1) a sheet frame has TRANSPARENT PADDING below the art, and (2) the artist
`origin`/anchor is usually the hip/centre, NOT the feet. So when the node sits where
physics rests (a `CharacterBody2D` capsule rests its BOTTOM on the floor → the node is
the collider CENTRE, `halfH` above the floor) but the sprite's feet render above that,
the character floats on the invisible lower half of the collider.

**Step 1 — find the feet row `feetFrameY` (pixels down the frame).** Easiest: read the
sheet's companion metadata (`bunx incanto-assets info <name>`, or the `<sheet>.json`
next to it). It has `frame` (the frame size), `origin` (the artist pivot — usually the
hip, which is exactly why it's not the feet), and `body {offset, size}` (the character
box). The feet ≈ the body BOTTOM: `feetFrameY = body.offset.y + body.size.y`
(e.g. medieval-knight `76 + 60 = 136` in a 192px frame). No metadata? Measure exactly:
draw the sheet to a `<canvas>` in the browser and scan for the lowest row whose alpha
> 0 within frame 0.

**Step 2 — solve the anchor.** The collider extends `halfH` below the node (capsule
total height = `height + 2·radius`; e.g. `radius 18, height 30` → 66 tall → `halfH 33`).
The sprite's feet render at `node + (feetFrameY − anchor.y·frameH)·scale`; set that equal
to the collider bottom (`node + halfH`) and solve:

```
anchor.y = (feetFrameY − halfH / scale) / frameH
```

e.g. knight `(136 − 33/0.72) / 192 ≈ 0.46` (NOT the sheet's 0.61). For a colliderLESS
patroller (a plain Node2D enemy) there's no `halfH` — pick `anchor.y` so the feet render
on the floor at the y you patrol it at: `anchor.y = (feetFrameY − (floorY − patrolY)/scale) / frameH`.

Equivalent fixes if you prefer: nudge the `Skin` down with a `position` offset, or give
the collider an `offset` so its bottom meets the feet. The anchor route is cleanest.
ALWAYS confirm in the browser (`bun run dev`) that the feet touch the surface.

### `Camera2D`
| Prop | Default | Notes |
|---|---|---|
| `follow` | `""` | NodePath to track (e.g. `"../Player"`) |
| `smoothing` | `0` | 0 = snap; 0.85–0.95 = smooth chase (frame-rate independent) |
| `zoom` | `1` | 2 = world pixels doubled |
| `limits` | `[]` | `[minX,minY,maxX,maxY]` — view rect clamped inside |
| `current` | `false` | mark exactly one — switch at runtime with `cam.makeCurrent()`, which clears the others (a wirable handler) |
| `blendSeconds` | `0` | seconds the view FLIES (centre and zoom, eased) from the last drawn view to this camera when it becomes current; `0` = a cut; `makeCurrent(0.5)` asks once |
Camera `position` is the view CENTER, and it composes with its ancestors like any
other node — so parenting the camera to the player (the Godot/Phaser idiom) works,
and so does putting it inside the container that holds the level. `follow` and the
`FollowCamera` behavior compare WORLD positions at both ends, so the target can live
under a different parent than the camera. With no camera, the view shows (0,0)–(w,h).

(Before 0.62 the view centre was the camera's LOCAL prop. A camera under a player at
`[1400, 900]` framed the world origin — and `framing`, which composes full world
matrices, reported `centred [1400, 900] … 1 in view`, certifying a frame the renderer
never drew.)

### `Label`
`text`, `fontSize 16`, `color '#ffffff'`, `font 'monospace'`, `align 'left'|'center'|'right'`,
`opacity 1` (0..1, like `Sprite2D`/`ColorRect2D` — a `Label` can fade now, which is
what makes `FloatAway` work on one).
CanvasTexture-rendered; mutate `label.text` freely (re-rasterizes only on change).

### `UILayer`
Plain container; everything under it renders in SCREEN space (ignores the camera) —
HUD, score, menus. Put `Label`s and `Sprite2D`s under it with screen-pixel positions.
`anchor` (default `top-left`) pins the layer origin to a screen corner/edge/center —
see "Responsive layout" below.

## Responsive layout (viewport + anchors)

```json
"viewport": { "design": [960, 540], "fit": "expand" }
```

Author the WORLD in fixed design pixels — the renderer maps them onto whatever
canvas size the page provides. Fits: `expand` (the design rect is always fully
visible; extra world shows beyond it — default), `letterbox` (exactly the design
rect, centered bars), `integer` (whole-number scaling for pixel art). World
geometry belongs in design px in the JSON — never hand-place it with
`window.innerWidth` math in TS.

`UILayer.anchor` (`top-left` default · `top` · `top-right` · `left` · `center` ·
`right` · `bottom-left` · `bottom` · `bottom-right`) keeps HUD coordinates tiny
offsets that survive any canvas size: a `Label` at `[-16, 16]` under a
`top-right` layer hugs the corner everywhere. Misspelled anchors fail at load
listing the valid set. With a viewport design, UI coordinates are design px.

**Two spellings, and the engine means it.** `UILayer` writes them with hyphens
(`top-left`); the `HudLayer` widgets write them in camel case (`topLeft`). Nine
of the same anchors, two vocabularies, and neither accepts the other's — a
`UiText` with `"anchor": "top-left"` is a load error, and so is a `UILayer` with
`"topLeft"`. Both errors print their own valid set, so you are one edit from
right; this note is so you know why.

### A `UILayer` still SCALES. On a phone, use `HudLayer`

`UILayer` ignores the camera; it does not ignore the viewport. Its contents are
design pixels multiplied by the same scale as the world, and on a portrait phone
that scale is small:

```
1280×800   scale 1.333   a fontSize 15 Label paints at 20 device px
390×844    scale 0.406   the same Label paints at  6.1 device px
```

Six device pixels is an unreadable smear, and it is the flagship 2D template's
control hint before this was fixed. The vertical framing goes the same way:
`design: [960, 540]` under `fit: "expand"` shows a portrait phone **2077 world
px** of height, so the play surface is a band with acres of empty sky.

**`HudLayer` and the `Ui*` widgets are DOM at their declared CSS pixels** and are
immune — which is why the engine's own volume sliders and touch controls stayed
legible in the same capture that reduced the template's HUD to a grey line. Use
them for anything a player has to READ:

| | |
| --- | --- |
| `Label` under `UILayer` | part of the picture — damage numbers, world-anchored callouts, art |
| `UiText` under `HudLayer` | part of the interface — score, hearts, hints, menus |

See `incanto-hud.md`. It costs nothing on desktop and it is the difference
between a readable and an unusable phone build.

## Patterns

- Group pickups/enemies (`"groups": ["coins"]`) and query `scene.tree.getNodesInGroup('coins')`.
- Destroy with `node.queueFree()` (safe mid-update; flushed at end of frame).
- Sound: an `AudioPlayer` node — zero-asset procedural SFX via `preset`
  (coin/jump/hurt/explosion/…) OR an audio file via `src` (`volume`/`loop`/`autoplay`;
  `play()`/`stop()`, `finished` signal). Global volume via `engine.audio`
  (master/sfx/music + mute). Browsers gate audio behind a gesture; `createGame2D`
  retries blocked plays on the first pointerdown. Headless-safe no-op. Full guide:
  **incanto-audio.md**.
- Reference example: [examples/2d-phaser-sprite-character-gravity](https://github.com/planetarium/Incanto/tree/main/examples/2d-phaser-sprite-character-gravity) —
  spritesheet character with a walk/jump/attack behavior state machine.
- **Tap / drag / click**: `createGame2D({ pointer: true })` attaches pointer
  input — MOUSE, FINGER and pen alike — and then

  ```ts
  game.engine.updated.connect(() => {
    if (!game.engine.input.mouseJustPressed()) return;
    const at = game.engine.input.pointerPosition();   // canvas px, null until
    if (!at) return;                                  // a pointer has been seen
    const world = game.renderer.worldFromScreen(at.x, at.y);   // world px
    popAt(world);
  });
  ```

  Use `renderer.worldFromScreen` — do NOT hand-roll the projection. It already
  accounts for the design `viewport`, the letterbox bars and the camera's
  clamped centre; a formula written against the DESIGN rect is off by
  `(design − canvas) / 2 / zoom`, which on a 390 px-wide phone showing a 480 px
  design measured **62 world px** — a finger-and-a-half from what the player
  touched. Its inverse, `renderer.screenFromWorld(wx, wy)`, pins DOM to the
  world (see incanto-web-integration).

  **From a Behavior, ask the ENGINE** — `this.engine` is all a behavior has, and
  the renderer is not on it:

  ```ts
  const at = this.engine.pointerWorld();          // world px, or null
  if (at) this.node.position = at;                // it IS a position array
  this.engine.toWorld(sx, sy);                    // any screen point
  this.engine.toScreen(this.node.position);       // → { x, y, behind }
  ```

  The renderer installs these, exactly as it installs `engine.picker`: `pickAt`
  answers WHICH NODE the cursor is on, `pointerWorld` answers WHERE it is, and a
  drag-and-launch needs the second one. Both are null with no renderer, and a
  `runScript` `click` step (or `incanto-play`'s `at`) installs a geometric
  version for the run — so aiming is testable headless, not only clicking.

## Game flow recipes

- **A title screen that HOLDS the world**: `GameFlow.pause()` from a behaviour's
  `onReady`, and `resume()` when the player presses your RUN button. The freeze
  survives the boot — the tree readies inside `loadScene` and `setScene` resets
  the time scale for the game-over swap, so a pause asked for during the load
  used to be erased and the game booted with `state: 'paused'` and the player
  walking around behind the title. `examples/shell-3d` is the whole shell.
- **Scene transition** (level 2, title → game):
  ```ts
  import { loadScene } from 'incanto';
  game.engine.setScene(loadScene(level2Json, { engine: game.engine }));
  ```
  Pass `{ engine }`: `onReady` runs during the LOAD, so a behaviour that reads
  `this.engine` or `this.rng` there — `Wander` draws its first heading in it —
  throws `TREE_VIOLATION` without it.

  `setScene` frees the old root, clears + redeclares the input map from the new
  scene's `input{}`, and emits `sceneChanged` — `createGame2D`'s touch overlay
  rebuilds itself on that signal, the PHYSICS world registers the new scene's
  bodies before its first frame, and the renderer loads the new scene's assets
  on demand. Register any extra behaviors/node types BEFORE the `loadScene` call.

  The input map is CLEARED by the swap, which matters for a headless drive: an
  injected `setActionVector` does not carry into the next level — set it again
  after the transition, the same way a player's held key is re-read.

  So is the CLOCK: `time`, `unscaledTime` and `timeScale` all start over. That
  last one is why the game-over recipe below is safe — a swap out of a frozen
  screen boots the next level running, not frozen.
- **Game over / restart**: swap to a fresh load of the SAME JSON —
  `engine.setScene(loadScene(levelJson, { engine }))`. `loadScene` treats the JSON as
  read-only (everything it keeps is cloned), so reloading the same imported
  object yields a clean run; only clone (`structuredClone(levelJson)`) first if
  your own code mutated that object. (`createGame`'s `scene` option already
  structuredClones internally.)
- **Pause / resume**: `engine.timeScale = 0` is the pause you want — the loop
  keeps running, so menus, banners and the key that closes them stay live (this
  is what `GameFlow.pause()` does). `engine.stop()` halts the loop entirely
  (clock and accumulator reset — no banked time on resume); `engine.start()`
  resumes. Either way a `setScene` puts `timeScale` back to 1.
- **Save / load** (a behavior, localStorage):
  ```ts
  localStorage.setItem('save', JSON.stringify({ score: this.score }));
  const save = JSON.parse(localStorage.getItem('save') ?? '{}');
  ```

## Rendering crispness

`Renderer2D` defaults MATCH PHASER: `antialias: true`, `pixelRatio: 1` (the
browser upscales on hiDPI screens — edges read soft). Texture sampling is
linear by default; declare `"filter": "nearest"` on a texture/spritesheet
asset for pixel art. For crisp retina rendering pass
`pixelRatio: window.devicePixelRatio` to `createGame2D` (or the `Renderer2D`
constructor).

## Particles (effects in one node)

```json
{ "name": "Torch", "type": "Particles2D", "props": { "preset": "fire" } },
{ "name": "Boom", "type": "Particles2D",
  "props": { "preset": "explosion", "rate": 0, "burst": 60 } }
```

Presets: `fire smoke sparks fireworks explosion flash lightning rain snow magic`
(unknown names fail at load listing these). The preset seeds every value;
any prop you write overrides it (`colorStart`, `colorEnd`, `speed: [min,max]`,
`directionDeg` (-90 = up), `spreadDeg`, `gravity`, `sizeStart/End`,
`alphaStart/End`, `blend: "add"|"normal"`, `maxParticles`). Two props add
per-particle variety (each keyed off the particle's stable random seed, so it
never flickers): **`paletteColors: ["#hex", …]`** — when set, every particle
picks ONE palette colour at birth (multi-colour confetti) instead of the single
`colorStart`→`colorEnd` ramp; **`shimmer`** (Hz, 0 = off) — a per-particle alpha
twinkle so sparks glitter on their own rhythm. Both work in 2D and 3D and apply
to any preset (fire embers, magic shimmer, multi-colour fireworks). **Blend vs
backdrop:** `blend: "add"` GLOWS but only over a DARK backdrop (night sky, caves)
— over a BRIGHT scene (daylight grass) additive washes toward white and the
sparks nearly vanish. For colourful particles on a bright scene use
`blend: "normal"` (OPAQUE saturated dots that read on anything) with biggish
sizes (small soft sprites read as pale haloes — the bigger the dot, the more its
solid core carries the colour). `rate: 0` +
`burst: N` makes a one-shot that emits `finished` — connect it to clean up:

```json
{ "signal": "finished", "from": "Boom", "to": ".", "handler": "onBoomDone" }
```

### Where a particle LIVES: `worldSpace`

A particle is born in the emitter's LOCAL space by default, so the whole plume
moves with the node — right for a torch on a moving platform, wrong for almost
everything else:

- dust under a running player is GLUED to the player instead of left behind;
- and the standard way to place a one-shot — move the emitter, `replay()` —
  drags the PREVIOUS burst across the level with it.

`"worldSpace": true` makes a particle keep the place it was born:

```jsonc
{ "name": "Dust", "type": "Particles2D",
  "props": { "preset": "smoke", "worldSpace": true } }
```

It follows a moving PARENT too, not just the node's own position. Default is
`false` so existing scenes look exactly as they did.

### Armed, but not yet: `emitting: false`

A one-shot with `burst` fires on its FIRST FRAME — which is what you want for a
firework that is meant to go off as the level opens, and not at all what you
want for the twenty explosions authored on twenty destructible crates. Set
`"emitting": false` to arm one and hold it:

```jsonc
{ "name": "Boom", "type": "Particles2D",
  "props": { "preset": "explosion", "rate": 0, "burst": 60, "emitting": false } }
```

`node.replay()` fires it — that is the game asking, so `emitting: false` does
not hold it back — and re-arms it for the next time.

`node.replay()` re-arms a one-shot. Simulation is deterministic under the
engine seed — runScript verifies particle gameplay reproducibly.

## `static: true` for big scenes

Mark unchanging subtrees (tile decor, backgrounds) `static: true` — the
renderer syncs them once and skips them every frame. Flip to `false` to
resume live syncing.

The freeze waits for the node to have something worth freezing: a `Sprite2D` or
`TileMap2D` whose texture has not decoded yet keeps syncing until it has, and a
texture that never arrives never freezes at all (`assetErrors()` is what reports
that one). Before 0.62 the latch fired on frame one regardless — and a
`TextureLoader` fills `.image` asynchronously while the renderer loads and syncs
in the same frame, so the state frozen was `visible: false`, permanently. A
static backdrop was invisible for the life of the game with nothing reporting
it: the texture loaded fine, `stats().errors` was 0, and `framing` reads props
rather than pixels and still called the node on-screen.

## Before you hand it back

You cannot see this game. `incanto-verifying-your-game.md` is how you find out
whether it works — run it after every edit, not once at the end:

```bash
bunx incanto-check                       # will it load? is anything lit?
printf 'step 500\nframing\nquit\n' | bunx incanto-play src/game.scene.json
```

`framing` tells you what the camera can actually SEE, in 2D's own terms: the
`viewport.design` window centred on the current `Camera2D` **and clamped by its
`limits`**, which is the view the renderer really draws — a following camera
sits at the player's y, and in a 544 px world that is hundreds of pixels past
anything a 540 px window can show.

```
camera /Game/Camera centred [480, 274] showing 960×540px
11 in view, 2 outside it, 1 hidden (visible: false)
  onScreen  /Game/Level (TileMap2D) [0, 0] screen [0.333, -0.007] 160px
  onScreen  /Game/Gems/Gem1/Icon (Sprite2D) [512, 428] screen [0.067, 0.57] 157px
  offscreen /Game/Goblins/Goblin2/Skin (AnimatedSprite2D) [1002, 512] … 574px
  overlap   /Game/Gems/Gem4 ∩ /Game/Level
```

`screen` is −1…1 across the window, so `1.089` means "just past the right
edge". A node counts as in view when its BOX is — a `ColorRect2D`'s `size`, a
`TileMap2D`'s whole grid (which hangs from its TOP-LEFT, not its centre), or a
collider. A bare `Sprite2D` declares no size, so read `offscreen` there as "its
ORIGIN is outside the view". `hidden` is `visible: false` on the node or on any
ancestor — counted apart from `offscreen` because the fix differs, and printed as
`NOTHING IS DRAWN` when it is every drawable in the scene. Nothing is ever
"behind" a 2D camera and nothing is lit, so neither is reported; `overlap` is
real interpenetration, not resting on a platform. HUD widgets are screen-space
and are left out entirely.

Then read `stats().errors` (something threw and got skipped) and `assetErrors()`
(a texture 404'd). All of these are silent failures otherwise — the screen just
looks wrong, or empty, and nothing throws.

**A game with no jump key is fine.** `jumpAction`, `dashAction` and even
`moveAction` may name an action the scene never declares — a top-down RPG has
nothing to jump over — and the character simply never does that thing. Until
this was fixed the controller THREW every frame, the node was quarantined, and
the character could not move either: a top-down game that declared only `move`
reported `✗ error in 1/1` and stood still, with the reason in a log nobody
prints. An action you TYPED that does not exist is still reported, once, because
that is a typo and not an omission.

## Platformer game feel (CharacterController2D)

A jump that only fires while `isOnFloor()` is true feels BROKEN, and players do
not report it as "the coyote time is missing" — they report the game as
unresponsive. These are the constants `examples/platformer-2d` hand-rolls in a
541-line behavior, as props. **Every one defaults to off**, so an existing scene
behaves exactly as before.

| prop | default | what it buys |
| --- | --- | --- |
| `coyoteSeconds` | `0` | jump for this long AFTER walking off a ledge — the single biggest one |
| `jumpBufferSeconds` | `0` | a press just BEFORE landing still fires on touchdown |
| `jumpCutMultiplier` | `1` | release early → rise less (variable jump height) |
| `maxJumps` | `1` | `2` = double jump. Walking off a ledge spends the ground jump |
| `dashSpeed` / `dashSeconds` / `dashAction` | `0` / `0.15` / `dash` | a straight-line dash |
| `wallSlideSpeed` | `0` | capped fall while against a wall |
| `wallJumpImpulse` | `[0, 0]` | `[away, up]` push off a wall |
| `crouchHeight` | `0` | the collider's height (px) while `crouch` is held — a rect's `size[1]`, a capsule's `height`; the body drops by half the difference so the feet stay put, a low gap is ducked through, and a ceiling keeps you down until you are out from under it. Hold-to-crouch: a jump press under a held key is not a hop |
| `crouchSpeedMultiplier` / `crouchAction` | `0.5` / `crouch` | the pace while crouched; the action (tolerated when missing) |

```json
{ "name": "Controller", "type": "CharacterController2D",
  "props": { "coyoteSeconds": 0.12, "jumpBufferSeconds": 0.15,
             "jumpCutMultiplier": 0.45, "maxJumps": 2,
             "wallSlideSpeed": 60, "wallJumpImpulse": [260, 380] } }
```

**Getting hit is the controller's too.** A `Health` on the body (or a child of
it) with `knockback` / `knockUp` / `staggerSeconds` takes the stick and the jump
for the stagger, and the controller writes the kick as the body's velocity — in
topDown as the whole velocity, in platformer as `x` with `knockUp` as the hop
(pass it positive; the engine flips it to `−y`). The platformer's hand-rolled
`KNOCK_X` / `KNOCK_UP` / `CONTROL_LOCK` are `knockback 420, knockbackSeconds
0.22, knockUp 360, staggerSeconds 0.22`; a controller of your own reads the
kick itself (`applyKnockback: false`) — see "Hit reactions" in
`incanto-gameplay-behaviors.md`.

**`movementStateChanged(state)`** fires on every change — `idle` · `run` ·
`jump` · `fall` · `wallSlide` · `dash` · `crouch` · `sneak` — so an `AnimatedSprite2D` follows the
character from scene JSON instead of a behavior polling velocities. The 3D
sibling has had this signal since it shipped.

```json
{ "signal": "movementStateChanged", "from": "Player/Ctl",
  "to": "Player/Skin", "handler": "play" }
```

**The sheet will not have all six clips — alias the rest.** Library sheets
(including every packaged character) carry `idle`/`move`/`attack`, and none of
them ships a falling pose. An `animations` entry may be the NAME of another
entry, which is what the states you have no artwork for should be:

```json
"animations": {
  "idle":   { "frames": [0, 5],   "fps": 10, "loop": true },
  "move":   { "frames": [6, 11],  "fps": 10, "loop": true },
  "attack": { "frames": [12, 17], "fps": 10 },
  "run": "move", "jump": "idle", "fall": "idle", "dash": "move"
}
```

**The whole map is checked when the scene loads**, not when a clip first plays:
a clip is `{frames, fps, loop}` and nothing else, so `frame`, `frameRate`,
`speed` and `repeat` — all real fields in the sheet formats people convert from
— are load errors naming the field you meant. Only the aliases used to be
checked, so a misspelled `frames` was a hard error the first time the character
walked, twenty minutes in.

`incanto-assets copy` and `spriteFromLibraryMeta` already print these aliases,
so pasting their output next to the wiring above works as-is; point any of them
somewhere better when you have the art. Aliases may chain (`fall` → `jump` →
`idle`), a circle is a hard error, and a name that resolves to nothing fails at
READY — not on the frame the character first jumps. A misspelt clip is still a
hard `UNKNOWN_ANIMATION`: an alias is a rename you wrote, not a silent fallback.

**A cosmetic clip cannot stop the character.** If a `play` wire throws anyway,
the engine turns THAT WIRE off and reports it — the controller keeps running.
Read `stats().errors`, because the game will look fine and be missing an
animation.

Wall moves need to know there IS a wall: `CharacterBody2D` now answers
`isOnWall()`, `wallSide()` (-1 left / +1 right) and `isOnCeiling()` as well as
`isOnFloor()`. Before, the floor was the only surface a character could report,
which is why a wall jump was not awkward to write but impossible.



**And then play it.** `bunx incanto-playtest src/game.scene.json` runs seeded
headless bot sessions and reports what could not be reached, which wires never
fired, and whether anything in the level can hurt the player. See
`incanto-playtesting.md`.

## When the boot FAILS: say so, or the bar sits at 100% forever

`createGame2D`/`createGame2D` reject like any promise, and the loading overlay
every starter shows is removed on the line *after* the await. So a boot that
fails leaves the bar at 100% with no text, and the reason in a console the
player will never open. Measured with WebGL denied on a pristine scaffold: the
overlay was still reading `Emberwood 100%` 8.6 seconds later.

One `.catch` fixes it, and every shipped template now carries it:

```ts
import { createGame2D, showBootFailure } from 'incanto/2d';

const game = await createGame2D({ canvas, scene: sceneJson }).catch((e) => {
  showBootFailure(e);   // paints the reason into #loading (or its own panel)
  throw e;
});

document.querySelector('#loading')?.remove();
```

**No WebGL is the case worth naming** — the 2D renderer is WebGL too. A machine
with acceleration off, a blocklisted driver, a locked-down or headless browser, or a page that already
holds too many contexts cannot make one, and three throws
`Error('Error creating WebGL context.')` with nothing to branch on. The engine
rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
naming the likely causes, so you can show a real screen:

```ts
import { isWebGLAvailable } from 'incanto/2d';

if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen();   // ask BEFORE booting
```

`isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
returns `false` outside a browser.

## Surfaces and a chaser that walks (2D)

A `StaticBody2D` carries the surface it is — `restitution 1` is a trampoline, `friction 0`
is ice — and its value WINS the pair (see `incanto-physics-and-input.md`). A platformer's
ground enemy is a `CharacterBody2D` with `Chase` on a child and `ground: true`, so a player
on a platform is not met by an enemy floating up to it. platformer-2d's course past its last
ground slab is all three, measured by its harness.

