---
name: incanto-building-3d-games
description: Build 3D web games with Incanto — Node3D taxonomy, scene environment, Engine + Renderer3D setup. Use when creating or modifying 3D scenes, meshes, cameras, or lights.
---

# Building 3D 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: read `incanto-scene-json-authoring.md` (this directory) for the scene file format, node paths, and
connections. This skill covers the 3D node taxonomy and runtime setup.

## Model files

GLB/glTF/VRM files load via `ModelInstance3D` — see `incanto-3d-models.md` for the
asset declaration, `targetHeight` sizing, and the `bunx incanto-model` inspector that
reports a file's real bounding box and animation names.

## Fastest start: scaffold a whole game

```bash
bunx incanto new my-game                     # Beacon Isle — 3D flagship template
bunx incanto new my-game --template tps-3d   # third-person shooter starter
bunx incanto new --list
```

Templates are COMPLETE games (world generation, quest, enemies, verify
harness) meant to be reshaped — regenerate the Beacon Isle world with
`bun run world`, keep the game logic. Prefer starting from one over wiring
from scratch.

## Boot sequence (the only TypeScript you need to start)

```ts
import { createGame3D } from 'incanto/3d';
import sceneJson from './my.scene.json';

const game = await createGame3D({
  canvas: document.querySelector('canvas')!,
  scene: sceneJson,          // cloned internally — no cast, no structuredClone
  pointer: true,             // pointer-look + lock-on-click (FPS pattern); default off
  // behaviors: { ... },     // registered for you (hot-replace tolerant)
  // settings: { namespace: 'my-game' },  // ← if anything else Incanto is
  //   served from this domain. Volume, language and the quality tier persist
  //   under `incanto:<namespace>:`, and the default is shared by everyone: a
  //   neighbouring game's player can boot yours at quarter resolution, muted.
});
// game.engine / game.scene / game.renderer / game.physics
// `game.scene` is the LIVE scene, not the one you booted with — it follows every
// swap, including `flow.restart()`. `game.sourceJson` is the authored JSON.
// 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); `pointer: { lockOnClick: false }` for look without lock. `seed` /
`fixedHz` / `pixelRatio` / `adaptiveResolution` pass through (adaptive
resolution is ON by default: slow frames render fewer pixels and recover
automatically — pass `false` for screenshots and pixel-diff tests). `autoStart: false`
hands back a game that has not run a frame — call `game.engine.start()` once
everything that must exist before the first one does (a `NetworkManager`; see
`incanto-multiplayer.md`). The manual `Engine` + `loadScene` +
`Renderer3D` boot still exists for full control.

Imperative per-frame logic can hang off `game.engine.updated.connect((dt) => { ... })`; for
structured game logic attach Behaviors to nodes (see `incanto-behaviors-and-scripts.md`).

Game flow (scene transitions, restart, pause, save) works identically in 3D —
see "Game flow recipes" in `incanto-building-2d-games.md`.

## 3D node taxonomy

All 3D nodes extend `Node3D` and therefore have the transform props:

| Prop | Default | Notes |
|---|---|---|
| `position` | `[0,0,0]` | meters; +Y up |
| `rotation` | `[0,0,0]` | **degrees**, Euler XYZ |
| `scale` | `[1,1,1]` | resizes the DRAWN mesh. `Terrain3D`/`Water3D`/`River3D` reject a non-unit scale at load — they keep a CPU twin (`heightAt`, the heightfield collider, the water surface query) built from `size`, so scaling would move the picture and not the ground. Resize those with `size` |
| `visible` | `true` | hides the whole subtree's rendering |

### `MeshInstance3D`
| Prop | Default | Notes |
|---|---|---|
| `mesh` | `"box"` | `box \| sphere \| capsule \| plane \| cylinder \| gem` — `gem` is a faceted crystal (icosahedron); with `material.flatShading: true` + low `roughness` it reads as a sparkling jewel (spin/tumble it for the sparkle). **This list is the whole list**: for a CURVED hull — a car body, a boat, a canopy, a fuselage — there is no primitive, and stacking boxes never gives you one. Use `LoftMesh3D` (below) |
| `size` | `[1,1,1]` | box: extents · sphere/gem: radius = x · capsule: radius = x, height = y · plane: x·z ground (laid FLAT on XZ) · cylinder: radius = x, height = y |
| `material` | `{}` | `{color: '#hex', metalness: 0..1, roughness: 0..1, opacity: 0..1, clearcoat: 0..1, clearcoatRoughness: 0..1, envMapIntensity: >=0, wireframe, flatShading, depthTest, depthWrite, emissive, emissiveIntensity, map, normalMap, repeat: [u,v]}` — the object IS the material state: omitted keys reset to defaults (`#ffffff`, 0, 1, 1, false, false, depthTest/Write true). `opacity < 1` turns on transparency; `flatShading` gives faceted per-face glints (the gem sparkle). `depthTest: false` (+ a high `renderOrder`) makes a flat `plane`/`cylinder` an ALWAYS-ON-TOP ground decal — AoE telegraphs, selection/spawn rings — that never z-fights with or is hidden by bumpy terrain (`depthWrite: false` keeps it from occluding later effects). `map`/`normalMap` are texture URLs (lazy-loaded, headless-safe; map samples sRGB, normalMap linear), tiled `repeat` times across each face — box/plane UVs span 0..1 per face, so for worldspace density use `repeat: [length/tile, height/tile]` (e.g. a 6×2.5 m brick wall at one tile per 2 m → `[3, 1.25]`). `color` tints the map (near-white keeps the texture's own color). CAR PAINT: `clearcoat: 1` adds a glossy lacquer layer with its own reflection over a metallic base (`clearcoatRoughness` ~0.05-0.1 = mirror lacquer) — THE premium vehicle-paint look; `envMapIntensity` scales how strongly the surface mirrors the scene's sky/HDRI (glass windows want ~2, matte surfaces <1; needs a `sky`/`hdri` environment to reflect — at night with no sky there is nothing to mirror). NB: under ACES a very bright `emissiveIntensity` blows out to white — keep it modest (~1–2) + a saturated color for a vivid GLOWING look. Unknown keys, malformed `repeat`, or `repeat` without a map hard-fail at load |
| `castShadow` / `receiveShadow` | `false` | |

### `InstancedMesh3D` (hundreds of props, one draw call)

Scattered rocks, fence posts, crates, gravestones — identical meshes should
never be one node each. One instanced node draws them all at once:

```jsonc
{ "name": "Rocks", "type": "InstancedMesh3D",
  "props": { "mesh": "gem", "size": [0.7, 0.5, 0.7],
    "material": { "color": "#8a8f98", "roughness": 0.95, "flatShading": true },
    "transforms": [
      [12.2, 0.1, -8.4, 40, 1.3],
      [-6.0, 0.2, 14.1, 210, 0.8],
      [3.5, 0.0, 22.7, 118, 1.0]
] } }
```

- Each row: `[x, y, z, yawDeg?, scale?]` — dense enough to GENERATE hundreds
  (pair with `snapToGround` or `terrain.heightAt` for y).
- Same `mesh`/`size`/`material` surface as MeshInstance3D. Replace the whole
  `transforms` array to update.
- **`collider` makes the whole field SOLID**: `"collider": {}` gives every row
  its own static collider SHAPED LIKE THE MESH — a faceted `gem` stone collides
  as that stone's own hull, not as a ball tucked inside it, so you can't clip
  through the corners that stick out. Sized from `size` × that row's scale and
  turned by its yaw. All of them hang off one fixed body, so a fifty-stone
  scatter costs fifty shapes and no per-frame work. A boulder field the player
  walks straight through is the tell that a world is scenery rather than a
  place.
  `{ "shape": "sphere", "radius": r }` / `{ "shape": "box", "size": [x,y,z] }`
  opt out into a cheaper approximation when a rough blocker is all you want.
  The field follows its stones: replace `transforms`, move the node, or change
  `mesh`, `size` or the `collider` spec at runtime and the hulls are rebuilt on the
  next physics step.

### `LoftMesh3D` (smooth swept hulls — vehicle bodies, canopies)

A SMOOTH curved surface swept through cross-sections — what box/capsule assemblies can
never give you. Superellipse sections are Catmull-Rom-interpolated along Z with per-vertex
normals, so highlights and sky reflections SWEEP across it like sheet metal. Use it for car
bodies, glass canopies, boat hulls, fuselages, submarines.

| Prop | Default | Notes |
|---|---|---|
| `sections` | `[]` | ≥2 stations, ascending `z`: `{z, width, height, y, corner?}` — `width`/`height` are the full cross-section extents, `y` is the section CENTRE height, `corner` 0 = boxy slab … 1 = full ellipse (default 0.7; the lower half always stays flatter — vehicle floors are flat). Malformed sections hard-fail at load |
| `slices` | `24` | vertices around each ring (8..64) |
| `smooth` | `4` | interpolated rings per section gap (1..16) |
| `material` | `{}` | same object as MeshInstance3D **minus textures** (`map`/`normalMap`/`repeat` hard-fail — a loft has no UVs) |
| `castShadow` / `receiveShadow` | `false` | |

Supercar recipe (hand-tuned against McLaren F1 proportions — see `examples/racing-3d`):
one hull loft (low rounded nose ~`corner 0.9` → wide fender-crown stations at the axles →
coke-bottle waist pinch → tall rear haunches → squared kamm tail `corner ~0.25`), plus a
second small loft as the GLASS bubble canopy (`metalness 1, roughness 0.03,
envMapIntensity ~1.7`), plus a dark interior loft inside it, with rectilinear
`MeshInstance3D` details (splitter, wing, light bars, mirrors) on top. Paint that stays
saturated in sunlight: `metalness ~0.45, roughness ~0.35, clearcoat 1,
clearcoatRoughness 0.06, envMapIntensity ~0.9` — high metalness washes the base colour
out into the sky reflection.

**A 3D node with no art draws nothing, and `incanto-check` says so.** A
`ModelInstance3D` with no `model`, a `Sprite3D` with no `texture`, an
`AnimatedSprite3D` with no `sheet`, an `InstancedMesh3D` with no `transforms`
each load clean, validate, take a row in the editor's tree, and put no pixel on
screen. The audit names them (a template on a `PrefabShelf` and a node authored
`visible: false` are left alone) — the same rule `TileMap2D` got the day the
tilemap example shipped a floor nobody could see.

### `Sprite3D` / `AnimatedSprite3D` (2D billboard sprites — the 2.5D look)
A 2D image drawn as a camera-facing quad inside the 3D world (Octopath / Don't
Starve / classic MapleStory in a 3D scene). Unlit — the art shows as-is. The
visual is an inner quad, so child nodes inherit neither its sizing nor its
billboard spin. `Sprite3D`:

| Prop | Default | Notes |
|---|---|---|
| `texture` | `""` | image URL (sampled sRGB, tinted by `tint`); empty = hidden |
| `size` | `[1,1]` | quad `[width, height]` in world METERS (not pixels) |
| `billboard` | `"y"` | `"y"` upright — screen-aligned yaw: every sprite shares the camera heading, so off-center sprites never ROLL under a pitched camera (characters, props) · `"full"` always faces the camera (items, FX) · `"none"` fixed to the node's own rotation |
| `anchor` | `[0.5,0]` | which point of the sprite sits at the node origin (0..1). `[0.5,0]` = bottom-center → the FEET stand on the ground; `[0.5,0.5]` = center |
| `tint` | `"#ffffff"` | multiplies the texture (near-white keeps original colors) |
| `opacity` | `1` | `< 1` turns on alpha blending |
| `flipX` | `false` | mirror horizontally (face left vs right) |
| `pixelArt` | `true` | nearest-filter for crisp pixels (`false` = smooth linear) |
| `alphaTest` | `0.5` | discard texels below this alpha — a crisp cutout that depth-sorts against 3D geometry (a tree occludes it; it occludes the ground). Use `0` + `opacity < 1` for soft/feathered sprites (smoke, glow) |
| `castShadow` | `false` | cast a **sprite-shaped** (alpha-tested) shadow — the shadow follows the texture's cutout + current animation frame, not the whole quad. The caster auto-aims at the light during the depth pass, so the shadow stays solid from **every camera angle** (a camera-facing billboard otherwise turns edge-on to the light and the shadow vanishes). Needs a shadow-casting light + a `receiveShadow` ground |
| `shadowMode` | `"silhouette"` | `"silhouette"` = physical projected alpha-cutout shadow through the real shadow map; `"blob"` = simple soft oval under the feet |

`AnimatedSprite3D` adds spritesheet animation (ALL Sprite3D props, plus):

| Prop | Default | Notes |
|---|---|---|
| `sheet` | `""` | spritesheet image URL (used instead of `texture`) |
| `frameWidth` / `frameHeight` | `0` | one frame cell's pixel size in the sheet |
| `animations` | `{}` | `{ name: { frames, fps, loop } }` — `frames` is an inclusive `[start, end]` RANGE or an explicit list `[0,2,5]`. e.g. `{ "walk": {"frames":[0,7],"fps":10,"loop":true}, "attack": {"frames":[8,11],"fps":14,"loop":false} }` |
| `autoplay` | `""` | animation name to start on load |

- API: `node.play('walk')`, `node.stop()`. A non-looping animation clamps on its last frame and emits **`animationFinished(name)`** — chain `attack → idle` from it. Frames index left→right, top→bottom (`cols = floor(sheetWidth / frameWidth)`).

```json
{ "name": "Slime", "type": "AnimatedSprite3D",
  "props": {
    "position": [4, 0, -2], "size": [1.5, 1.5],
    "sheet": "https://example.com/slime.png", "frameWidth": 32, "frameHeight": 32,
    "animations": { "idle": { "frames": [0, 3], "fps": 4, "loop": true } },
    "autoplay": "idle" } }
```
Drop sprites onto terrain height like any 3D node (their feet are the anchor). In
a top-down / quarter view, `billboard: "y"` keeps characters readable from every
camera yaw.

### `Billboard3D` (a group that turns toward the camera — children follow)
A transform container that orients itself toward the camera every rendered frame;
**children inherit the rotation** through the scene graph. Hang world-space UI
under it — HP bars, pickup markers, floating icons — instead of hand-rolling
camera math in game code.

### `Label3D` — text standing in the world

A nameplate, a sign, a damage number:

```json
{ "name": "Name", "type": "Label3D",
  "props": { "text": "Goblin", "height": 0.25, "position": [0, 2, 0] } }
```

- **`height`** is the cap height in METRES, and the quad follows what the text
  measures — a sign is as wide as its words.
- It **faces the camera** by default (`billboard: "full"`): a damage number read
  from behind is a bug. `"y"` keeps it upright for a nameplate over a character.
- **`outline`** is on by default, because white text over a bright wall is
  unreadable and a damage number lands anywhere. `""` turns it off.
- It IS a `Sprite3D`, so `anchor`, `tint`, `opacity`, `alphaTest`, `renderOrder`
  and `orderGroup` all work exactly as they do there.

A **damage number** is this node plus the `FloatAway` behavior, and no
TypeScript of your own:

```json
{ "name": "Hit", "type": "Label3D",
  "props": { "text": "12", "height": 0.35, "color": "#ff5a5a" },
  "script": { "name": "FloatAway", "props": { "rise": 1.2, "seconds": 0.7, "drift": 0.3 } } }
```

It rises with an ease-out (constant speed reads as a lift, not a hit), holds at
full opacity for `hold` of its life so the number is readable, fades, and frees
itself — so a hundred numbers a fight leave nothing in the tree. `drift` keeps
ten at once from stacking into one. Spawning is `duplicateNode` + set `text` and
`position`.

Re-rasterising is handled: the texture is rebuilt only when the text (or
size/colour/font) changes, and the old one is disposed, so a number per hit does
not leak a texture per hit.

| Prop | Default | Notes |
|---|---|---|
| `mode` | `"screen"` | `"screen"` copies the camera's orientation wholesale (screen-aligned) — a child rectangle projects as an **upright rectangle anywhere on screen** · `"y"` stays vertical with a screen-aligned yaw — no off-center roll (standing content) · `"none"` billboarding off — the authored rotation applies again |

- Use `"screen"` for flat UI shapes (bars, plates). A yaw-only look-at — the
  tempting hand-rolled version — visibly TILTS off-center shapes when the camera
  pitches down; `"screen"` is immune. (This is deliberately not the same as
  Sprite3D's `"full"`, which faces the camera POSITION and keeps that tilt.)
- Unlike `Sprite3D` (which spins only its own textured quad, never its children),
  `Billboard3D` rotates the whole group. Parent one under a character and offset
  it upward. The node's own `rotation` prop is overwritten while billboarding —
  set `mode: "none"` to pose children with the editor gizmo, then switch back.
- A typo'd `mode` (e.g. Sprite3D's `"full"`) fails HARD at scene load — it never
  silently falls back to another mode.
- It keeps orienting while `visible: false` on purpose: a hidden HP bar revealed
  on first hit is already correctly oriented (no one-frame pop).

```json
{ "name": "Slime", "type": "AnimatedSprite3D",
  "props": {
    "position": [4, 0, -2], "size": [1.5, 1.5],
    "sheet": "https://example.com/slime.png", "frameWidth": 32, "frameHeight": 32,
    "animations": { "idle": { "frames": [0, 3], "fps": 4, "loop": true } },
    "autoplay": "idle" },
  "children": [
    { "name": "HpBar", "type": "Billboard3D", "props": { "position": [0, 1.8, 0] },
      "children": [
        { "name": "Back", "type": "MeshInstance3D",
          "props": { "mesh": "box", "size": [1, 0.12, 0.04],
                     "material": { "color": "#150a0a" } } },
        { "name": "Fill", "type": "MeshInstance3D",
          "props": { "mesh": "box", "size": [1, 0.12, 0.06], "position": [0, 0, 0.01],
                     "material": { "color": "#ff3b3b", "emissive": "#ff3b3b" } } }
      ] }
  ] }
```
Drain the fill from game code with **`scale`, not `size`** — resizing a mesh
rebuilds its geometry every call, scaling is free: `Fill.scale = [frac, 1, 1]`
and `Fill.position = [-(1 - frac) / 2, 0, 0.01]`. The offsets stay local to the
(rotated) bar, so the drain always hugs the bar's left edge.

### `Camera3D`
`fov: 60`, `near: 0.1`, `far: 1000`, `current: false`, `lookAt: ""`, `blendSeconds: 0`,
`minHorizontalFov: 0`.
Mark exactly ONE camera `current: true` (otherwise the first camera in tree order is used).

**`fov` is VERTICAL**, and the aspect comes from the canvas — so what the player
sees ACROSS is `2·atan(tan(fov/2)·aspect)`. A `fov: 60` camera shows 91° across a
16:9 window and **30° across a 390×844 phone held in portrait**.
`minHorizontalFov` is the floor: ask for a width in degrees and the vertical fov
widens until the screen carries it, while any window already wider keeps the
authored fov. `52` suits a game meant to be playable in one hand — and note what
the geometry costs: 52° across a 9:19.5 screen is a 93° vertical fov, so ask for
a width, not for everything.

`cam.viewFov(aspect)` answers what the camera will actually render with at a
given shape of screen — the authored `fov`, or the widened one. Use it in a
harness rather than copying the formula:

```ts
const cam = game.scene.root.getNode('Camera') as Camera3D;
cam.viewFov(390 / 844);   // 93 — the vertical fov a portrait phone gets
cam.viewFov(16 / 9);      // 60 — the authored one, on a desktop window
```
To SWITCH at runtime call `cam.makeCurrent()` — it clears every other camera's
`current`, which setting your own never did, so a cutscene camera that set
itself `current` changed nothing visible. It is a handler a connection can name:
`triggerEnter → DoorCam.makeCurrent`. Aspect ratio is automatic.

**A switch is a CUT unless the camera asks otherwise.** `blendSeconds` on the
camera being switched TO makes the picture FLY from the last drawn pose to that
camera over so many seconds of real time — position, orientation and fov, eased
in and out — and `makeCurrent(0.6)` asks for it once. The nodes never move:
picking, framing and the editor read the node; only the picture takes the long
way round. A second switch mid-flight starts from where the picture IS.
`examples/heist-3d`'s door camera and gameplay camera declare `0.6`; a camera
that is the FIRST frame (an intro rail) has nothing to fly from and cuts.

**Point it with `lookAt`, not with a `rotation` you worked out.**

```json
{ "name": "Cam", "type": "Camera3D",
  "props": { "current": true, "position": [7, 6, 9], "lookAt": "%Player" } }
```

`lookAt` is a node path, re-aimed every frame, so it also tracks a target that
moves. It orients with WORLD UP, which is the part you cannot write yourself in
scene JSON — and cannot write in game code either without importing three, which
this engine's own guardrail forbids ("mutate node props — never touch three.js
objects").

This is not a convenience. A hand-written `rotation` for a camera is
**wrong at every heading but one**, and the way it is wrong is invisible from
inside the frame:

- The one published direction→rotation formula (`incanto-3d-character.md`, "the
  +Z-FORWARD rule", *"There is ONE facing formula"*) is derived for a SKIN — a rod,
  which has no meaningful up. Measured over 72 look headings, applying it to a
  camera aims perfectly (dot 1.000 in all 72) and puts the world **upside down in
  36 of them**. Its own published check, `rodAxis · (target − eye) ≈ 1`, scores
  1.000 on every upside-down one.
- **No two-angle recipe can be right.** `rotation` is Euler XYZ, so
  `[pitch, yaw, 0]` is Rx·Ry while a level camera is Ry·Rx: an upright camera's
  third angle is NON-ZERO. The `[-26, 36, 0]` this document recommended for years
  aims at 0.9924 and tilts the horizon **16°**; the level answer is
  `[-33.69, 32.91, 19.91]`, which nobody would write by hand.
- A camera looks down its own local **−Z**, unlike every skin in the engine.

`framing()` now prints `up [x, y, z]` and the roll in degrees, so a tilted or
inverted camera is something you can SEE in a report rather than something you
notice in a screenshot.

### Lights
- `DirectionalLight3D` — `color: '#ffffff'`, `intensity: 1`, `castShadow: false`.
  Sun-style; its `position` sets the incoming direction (it aims at the origin).
  Shadow knobs: `shadowArea` (half-extent of the shadow box, default ±75),
  `shadowMapSize` (default 2048), and `shadowFollowsCamera` (default false). In a
  big roaming world a fixed shadow box must be huge → low-res → the player's own
  shadow nearly vanishes; set `shadowFollowsCamera: true` with a SMALL `shadowArea`
  (~60–80) so the box tracks the camera and contact shadows stay crisp everywhere.
- `OmniLight3D` — `color`, `intensity` (physical units — values like 20–40 are normal),
  `range` (0 = unlimited).
- `SpotLight3D` — a CONE that aims: a flashlight, a searchlight sweeping an arena,
  headlights, a stage spot, a guard's torch. `color`, `intensity` (candela, physical
  decay — see the numbers below), `range` (0 = unlimited), `coneDeg` (the FULL cone
  angle, default 60 — a "30° beam" is 30; 0 and 180 are load errors), `penumbra`
  (0 hard edge … 1 all falloff, default 0.3), `castShadow` + `shadowMapSize` (1024;
  a perspective shadow from the apex — one extra render per frame per light), and
  `lookAt` (a node path: aim at it every frame, exactly as `Camera3D.lookAt` does).
  **It aims down its −Z like a camera**: at rest it points along −Z,
  `rotation: [-90, 0, 0]` points it at the floor, and a headlamp on a +Z-forward
  skin wants `rotation: [0, 180, 0]` (tilt it down with `[20, 180, 0]`).

  **Intensity is candela with physical falloff**, so the number is about
  DISTANCE: measured on tps-3d-neon's dark deck, a headlamp 2 m off the floor
  reads well at ~500, and a searchlight 14 m up needs ~12 000 to leave a pool you
  can see — 260 was invisible. Start ten times bigger than feels reasonable.

  **Do not yaw a pitched light on the same node.** Rotation is Euler XYZ, so a
  yaw applied to a node that already pitches −55° swings the cone sideways and,
  at yaw 180, straight UP (measured: dir `(0, 0.82, 0.57)`). To SWEEP, put the
  yaw on a parent pivot and the pitch on the light; to TRACK, use `lookAt`:

  ```json
  { "name": "SearchlightRig", "type": "Node3D", "props": { "position": [0, 14, 0] },
    "script": { "name": "Oscillate", "props": { "mode": "rotation", "axis": "y", "amplitude": 70, "frequency": 0.08 } },
    "children": [
      { "name": "Searchlight", "type": "SpotLight3D",
        "props": { "rotation": [-55, 0, 0], "color": "#ffe9b0", "intensity": 12000, "range": 60,
                   "coneDeg": 24, "penumbra": 0.4, "castShadow": true } }
    ] }
  ```
  ```json
  { "name": "Tracker", "type": "SpotLight3D",
    "props": { "position": [0, 14, 0], "intensity": 12000, "coneDeg": 20, "lookAt": "%Player" } }
  ```

### Vegetation & water

`Foliage3D` (curved-blade shader grass: clumps, rolling wind, distance LOD,
character bend), `Flowers3D` (instanced flower PLANTS — stems, leaves,
multi-petal heads; lush/sparse/none density dial, Voronoi patches),
`Tree3D` (ez-tree
groves: branchy trunks + textured leaf billboards — conifer/broadleaf/dead ×
simple/medium/high cost tiers, count×tris budget-checked at load) and
`Water3D` (animated plane) dress a level in a handful of nodes. Full prop
tables and the generator CLI: `incanto-environment.md` (this directory).

### Scene-level `environment` header (not a node)
```json
"environment": {
  "ambient": { "color": "#ffffff", "intensity": 0.45 },
  "background": "#0e1220"
}
```
Without `ambient`, unlit faces are pitch black — almost always set it (0.3–0.6;
drop to ~0.15–0.2 when a `sky` provides image-based ambience — see the
atmosphere recipe below).

## Composition rules

- Group moving parts under a plain `Node3D` pivot and rotate the pivot — children inherit
  the transform (a spinning `Pivot` parent makes child meshes orbit — no math in scripts).
- Plain `Node` (non-3D) children are fine anywhere; they're transparent to rendering
  (their 3D descendants attach to the nearest `Node3D` ancestor).
- Mutate node props (plain JSON values) — never touch three.js objects. The renderer
  pushes props each frame. Replace arrays whole (`node.rotation = [0, a, 0]`) or mutate
  in place; both work.

## Reference example

The smallest 3D scene that renders something you can see. Every line matters:
without the light it is black, without `current: true` the camera is a guess,
and without `ambient` the unlit faces are pitch dark.

```json
{
  "format": 1,
  "type": "scene",
  "dimension": "3d",
  "name": "Hello",
  "environment": {
    "sky": { "type": "atmosphere", "elevationDeg": 35, "azimuthDeg": 140 },
    "ambient": { "color": "#ffffff", "intensity": 0.2 },
    "shadows": true
  },
  "root": {
    "name": "World",
    "type": "Node3D",
    "children": [
      { "name": "Sun", "type": "DirectionalLight3D",
        "props": { "position": [6, 10, 4], "intensity": 2, "castShadow": true } },
      { "name": "Camera", "type": "Camera3D",
        "props": { "position": [7, 6, 9], "rotation": [-26, 36, 0], "current": true } },
      { "name": "Ground", "type": "MeshInstance3D",
        "props": { "mesh": "plane", "size": [40, 1, 40], "receiveShadow": true,
                   "material": { "color": "#6b8f3a", "roughness": 1 } } },
      { "name": "Pivot", "type": "Node3D", "script": { "name": "Spin" },
        "children": [
          { "name": "Cube", "type": "MeshInstance3D",
            "props": { "mesh": "box", "position": [2, 0.5, 0], "castShadow": true,
                       "material": { "color": "#e0563f", "roughness": 0.4 } } },
          { "name": "Ball", "type": "MeshInstance3D",
            "props": { "mesh": "sphere", "size": [0.6, 0.6, 0.6], "position": [-2, 0.6, 0],
                       "castShadow": true,
                       "material": { "color": "#4f8fe0", "metalness": 0.6, "roughness": 0.2 } } },
          { "name": "Gem", "type": "MeshInstance3D",
            "props": { "mesh": "gem", "size": [0.5, 0.5, 0.5], "position": [0, 0.8, 2],
                       "castShadow": true,
                       "material": { "color": "#ffd166", "flatShading": true, "roughness": 0.1 } } }
        ] }
    ]
  }
}
```

Rotating the PIVOT orbits all three children — no maths in the behaviour:

```ts
import { Behavior } from 'incanto';

export class Spin extends Behavior {
  override update(dt: number): void {
    const node = this.node as unknown as { rotation: number[] };
    node.rotation = [0, (node.rotation[1] ?? 0) + 40 * dt, 0];
  }
}
```

Check it before you open a browser: `bunx incanto-check` says the scene is legal
and lit, and `bunx incanto-verify` walks the whole ladder.

## Atmosphere (sky, fog, shadows, exposure)

The whole rendering stage is scene JSON — this is THE recipe that turns a flat
demo into a commercial-looking outdoor scene:

```json
"environment": {
  "sky": { "type": "atmosphere", "elevationDeg": 35, "azimuthDeg": 140, "turbidity": 2.4 },
  "fog": { "near": 120, "far": 700 },
  "shadows": true,
  "exposure": 1,
  "ambient": { "color": "#ffffff", "intensity": 0.18 },
  "background": "#9bc4e2"
}
```

- `sky` (physical atmosphere, three Sky shader): sun via `elevationDeg`
  (−90..90 above horizon) + `azimuthDeg` (0 = +Z, 90 = +X) **or** a raw
  `sunPosition: [x, y, z]` — never both. `turbidity` (haze, 2 ≈ clear day,
  ~7 dusty) and `rayleigh` (blue scattering, 1 ≈ earth). Defaults are a
  pleasant mid-morning. The sky does three jobs at once: backdrop, PBR
  image-based ambience (PMREM, regenerated only when the sky changes), and the
  scene's unified SUN — it aims the main `DirectionalLight3D` (highest
  intensity; lights are never auto-created) and feeds `Water3D` /
  `Foliage3D` `sunDirection` uniforms while those props sit at their defaults
  (an explicitly authored prop wins).
- `fog`: linear `THREE.Fog` — `{ color?, near?, far? }` meters. Without
  `color` under a sky, a horizon-ish haze is derived from the sky itself.
  Water and mesh-grass shaders fog identically to standard materials.
- `clouds`: a VOLUMETRIC cloud deck — the renderer raymarches a world-space
  altitude slab and composites real, parallax-correct clouds you fly through
  (a fullscreen pass, like the underwater caustics — no node, no geometry).
  `{ coverage?, density?, base?, top?, color?, shadeColor?, speed?, scale? }`:
  `coverage` 0..1 (how much sky is cloudy, ~0.5), `density` optical thickness
  (~1–2.2), `base`/`top` the deck's world-Y band (e.g. 95→340 — cruise above
  it and dive through), `color` sunlit + `shadeColor` shaded underside, `speed`
  wind drift, `scale` feature size in world units (~230, bigger = larger soft
  masses). Lit by the scene sun; melts into the `fog` color at distance. COST:
  one extra fullscreen pass per frame — keep `Water3D` at `quality:"simple"`
  (zero re-renders) when pairing with clouds to hold 60fps. Absent = zero cost.
  Ideal for flight/sky scenes; see `examples/dogfight-3d`.
- `bloom`: a glow post pass — bright pixels bleed light, so emissive
  materials (tail-lights, neon, lava, muzzle flashes) and sun glints actually
  GLOW. `{ threshold?, strength? }`: `threshold` 0..1 post-tonemap brightness
  above which pixels bloom (default 0.75 — keeps it to lights/glints; lower =
  dreamier), `strength` how strongly the blurred glow is added back (default
  0.85; ~1.2 = neon night). One extra half-res blur pass per frame — cheap.
  Caustics/clouds composites take precedence (pipelines don't stack yet).
- `post`: the color-grade tail — `{ "vignette"?, "saturation"?, "contrast"? }`.
  `vignette` 0..1 darkens the corners (0.3 = cinematic, 0.6 = heavy mood);
  `saturation` 0..4 (0 = grayscale, 1 = neutral, 1.2 = vivid); `contrast`
  0.2..3 around mid-gray (1 = neutral). Applied after tone mapping on the same
  composite pass as bloom — declaring `post` WITHOUT `bloom` costs just one
  offscreen pass. The fastest way to give a scene a LOOK: dusk = vignette 0.35
  + saturation 0.85, candy = saturation 1.25 + contrast 1.08. Caustics/clouds
  composites take precedence (pipelines don't stack yet).
- **Runtime changes**: the renderer re-reads `environment` every frame — call
  `setEnvironment3D(engine, { sky: { elevationDeg: 4 }, exposure: 0.8 })`
  (from `incanto/3d`) to change the look LIVE. One-level deep merge, `null`
  deletes a key, and a bad patch throws in YOUR call stack, never mid-render.
  For a full day/night loop, attach the built-in `DayNight` behavior instead:
  `{ "script": { "name": "DayNight", "props": { "daySeconds": 240 } } }` —
  sun arc + ambient/exposure ramps + a `dayPhaseChanged(dawn|day|dusk|night)`
  signal to hook spawners/music to. Its `hour` field is writable (jump to a
  time, or `paused: true` and animate it yourself).
- `shadows`: `true` | `false` | `{ "mapSize": 1024|2048, "radius": n }`.
  Truthy makes the main DirectionalLight cast (PCF soft, terrain + trees +
  meshes cast/receive, mesh grass RECEIVES only). One static ortho box (±75 m
  default; lights with `castShadow`+`shadowArea` keep their own) — no
  cascaded shadow maps yet, so giant worlds get soft, not razor, shadows.
  `false` force-disables shadow maps.
- `exposure`: ACES filmic tone mapping is always on (exposure 1 default) —
  raise/lower for bright beach vs moody dusk.
- `iblIntensity`: multiplies the sky's image-based ambient fill (≥0, default
  1; the sky base is 0.55). Lower it (~0.5) for a hot-sun-vs-small-ambient
  stage that carves a deep shadow side on crowns — the "moody" look — while
  a higher exposure keeps the lit side bright.
- All keys hard-validate at apply time with errors listing valid options;
  scenes without them render exactly as before (flat `background`, no fog).
- Sun balance that reads well under ACES + sky ambience: sun intensity
  ~1.5–2, ambient ~0.15–0.2 (a strong flat ambient washes shadows out).
- `runGenerator('terrain', …)` worlds: pair with `terrainEnvironment(theme)`
  from `incanto/env` — the themed sky/fog/shadows header in one call.
- `runGenerator('maze', …)` likewise pairs with `mazeEnvironment(theme)` —
  a moody low-sun/fog grade (dungeon stone, overcast hedge, dusk canyon).

## HDRI environments (image-based lighting)

`environment.preset: "sunset"` (any drei preset name: apartment/city/dawn/
forest/lobby/night/park/studio/sunset/warehouse) or `environment.hdri: <url>`
loads an equirect HDR for lighting/reflections; add `"skybox": true` to also
show it as the background (otherwise `background` color applies). Standard
materials need this OR explicit lights to be visible. An explicit HDRI wins
over the `sky`'s image-based ambience.

`hdri` is **`.hdr` only**, on purpose: that texture is the scene's LIGHT, and an
8-bit jpg fed to it clips the sun to flat white — chrome loses its highlight,
`metalness: 1` reads as plastic, `clearcoat` paint goes matte, `Water3D` loses
its glint. A jpg cannot get that range back; it was never in the file.

### A picture as the sky (`skybox: "<url>"`)

What is VISIBLE behind the scene is a different question from what lights it,
and for the visible half a picture is the right tool — a 2k `.hdr` is ~10 MB, a
2k `.jpg` is ~300 KB. So `skybox` also takes a url:

```json
"environment": {
  "sky": { "type": "atmosphere", "elevationDeg": 30, "azimuthDeg": 150 },
  "skybox": "sky_gradient.png"
}
```

| `skybox` | you SEE | the scene is LIT by |
|---|---|---|
| `true` | the `hdri`/`preset` | the `hdri`/`preset` |
| `"<url>"` (`.jpg` `.png` `.webp`, or `.hdr`) | that picture | `hdri`/`preset` if set → else the `sky`'s PMREM → else `ambient` only |
| absent | `background` colour | same |

The picture is assigned to the background **only** — it never reaches
`scene.environment` — so the composition above is the intended one: a free
procedural `sky` doing the lighting and the sun, and a cheap painted panorama
doing the looking. The `sky` mesh stops drawing while a picture is up (it is a
450 km sphere and would paint over it) but keeps aiming the sun and feeding the
PMREM. Underwater still wins. `fog` does not tint a background image (three
does not fog `scene.background`) — pick a picture whose horizon already matches.

The url must look like one — a path (`sky.png`, `/textures/night.hdr`) or an
https url. A bare word (`"yes"`) is refused at load: it would be fetched
verbatim and 404 into a black sky with nothing to grep for. A picture that
does 404 is reported by `game.assetErrors()` under the url you wrote, like any
texture. The built-in `sky_gradient` (`bunx incanto-assets copy sky_gradient
--out public`) is the zero-setup starting point.

## NEVER compute a Y against the terrain by hand

This is the single most repeated mistake in scene authoring, and it is not a
careless one: the number is right when you write it and wrong the moment
anything reshapes the ground under it — a river carving its bed, a basin, a new
seed, a generator that mirrors a carve slightly differently. Boulders in
mid-air, a player buried to the waist, bridge posts hanging over a creek: all
the same bug.

**Say what you mean instead.** Every `Node3D` takes `snapToGround`:

```json
{ "name": "Boulder", "type": "MeshInstance3D",
  "props": { "mesh": "gem", "size": [1.2, 0.8, 1.1],
             "position": [12, 0, -30],      // y is IGNORED
             "snapToGround": 0.4 } }        // half its height → it rests on the ground
```

- `true` — put the node's origin on the ground.
- a number — the same, lifted that many metres (a prop whose origin is at its
  middle wants half its height).
- `false` — "this hangs in the air ON PURPOSE" (a bridge deck, a lantern, a
  cloud). The grounding check below skips these, so mark them.
- omitted — `y` is used exactly as authored.

**On a `CharacterBody2D`/`3D` the ground-STICK is `stickToGround`** (default
true — hold the capsule against the surface while it moves). It used to be
called `snapToGround`, which shadowed the placement prop above: a character
body was silently teleported onto the terrain at load, a lift value was
rejected, and the only escape also turned the ground-stick off. A boolean
`snapToGround` on a character body is now a hard error naming both props.

Resolved at load, in the same frame the terrain is built, and it honours an
ancestor's yaw, so props under a turned rig land where they look.

**And check it.** `findFloatingProps` from `incanto/test` reports every prop
whose underside is off the ground, instanced fields row by row:

```ts
import { findFloatingProps } from 'incanto/test';
const off = findFloatingProps(gameJson);
ok(`every prop meets the ground (${off.length})`, off.length === 0);
for (const o of off) console.error(`  ${o.path}: ${o.gap > 0 ? 'floats' : 'buried'} ${o.gap} m`);
```

Put it in your `verify.ts` next to `auditScene`. It costs nothing and it is the
difference between shipping a floating bridge and not.

**Mark what is not meant to touch the ground.** `snapToGround: false` is the
escape and it INHERITS, so one mark exempts a whole assembly: a cloud made of
puffs, a palm made of trunk segments, a lamp at the top of a lighthouse, a rock
sunk into the slope on purpose. Put it on the group, not on its parts — before
0.73 it read the node's own prop only, and this repo's own flagship template
reported 64 floating cloud puffs.

## The loading screen has to cover the WHOLE boot

Downloading the assets is the first half. Building a dressed scene and
compiling its GPU programs is the second, and on a big outdoor scene the second
is the longer one (measured on the creek template: 1.5 s of assets, then **7 s
of shader compile** — 49 programs). A bar that reaches 100% on the last
downloaded byte and then leaves the player looking at a blank canvas is worse
than no bar at all.

So run ONE bar across both, with `createGame3D`'s `onProgress`:

```ts
import { assetUrls, preloadUrls } from 'incanto';

const progress = (fraction: number, label: string) => {
  bar.style.width = `${Math.round(fraction * 100)}%`;
  text.textContent = `${Math.round(fraction * 100)}% · ${label}`;
};
await preloadUrls(assetUrls(sceneJson.assets), (done, total) =>
  progress((done / total) * 0.5, 'assets'));
const game = await createGame3D({
  canvas, scene: sceneJson,
  onProgress: (f, label) => progress(0.5 + f * 0.5, label),
});
document.querySelector('#loading')?.remove();   // now it is really ready
```

`onProgress` reports `scene` → `physics` → `renderer` → `shaders` → `ready`,
and yields a frame at each step so the bar actually repaints (a callback fired
inside one synchronous block updates nothing a player can see). `createGame3D`
also draws its FIRST FRAME before it resolves, so the compile lands while your
screen is still up rather than after you remove it.

Preloading warms the HTTP cache before the engine starts; failures warn, never
block.

## Particles

`Particles3D` mirrors `Particles2D` (same presets — `fire`, `explosion`,
`magic`… the same one-shot `rate: 0` + `burst` → `finished` pattern, and the
same `worldSpace` prop, which is what stops a moved one-shot dragging the
previous explosion across the level) as
point sprites in meters: preset distances auto-scale ÷100 (presets are authored
in 2D px). See incanto-building-2d-games for the full prop list.

**Firing one from a behaviour is `replay()`** — move the emitter, then replay:

```ts
const dust = this.node.getNode('Dust') as Particles3D;
dust.position = [x, 0.2, z];
dust.replay();                 // `emitting: false` stays false; this is the game asking
```

Not `play`, not `burst`, not `emit` (that one belongs to signals). The 2D skill
has always named it and this one did not, so a 3D game built from this page
alone had a one-shot it could not fire. `particles()` is the same node's test
hook — the live particle list, for a harness asking whether anything came out.
`engine.effects.countOf('explosion')` is the cheaper question.

Points render through a built-in soft sprite with a SOLID opaque core (not hard
squares), so colours read even at small sizes, and `'normal'` blend fades cleanly
to transparent (per-particle RGBA alpha). `paletteColors` (per-particle
multi-colour) + `shimmer` (per-particle twinkle) work in 3D too — see the 2D skill.

- **Size gotcha:** on-screen size is `(sizeStart + sizeEnd) / 2 ÷ 100` meters
  (sizeAttenuation), so the 2D-px `custom` defaults (8/2 → 0.05 m) are
  near-invisible a few meters away — author sizes in the TENS (e.g. 64/14 → ~0.4 m).
- **Blend vs backdrop:** `'add'` only GLOWS over a DARK backdrop; over a BRIGHT
  scene (daylight grass) it washes to white — use `'normal'` (opaque) for colourful
  daytime particles.
- **VFX over grass / transparent scenery — raise `renderOrder` (e.g. 100).**
  Foliage3D grass is a TRANSPARENT material (renderOrder ~2) drawn LATER in the
  transparent pass, so a default particle (renderOrder 0) gets painted OVER even
  when geometrically ABOVE the blades — it's draw ORDER, not depth (so toggling
  `depthTest` does nothing about it). `renderOrder:100` draws the VFX after the
  grass; that alone fixes it. Leave `depthTest` at its default `true` so the VFX is
  still correctly hidden by SOLID objects (tree trunks, the player). Only set
  `depthTest:false` for a deliberately always-on-top effect (it then draws through
  solid cover too).

A celebratory FIREWORK: a few stacked one-shots choreographed by their lifetimes —
a brief BIG white flash (`blend:'add'`, the pop), a multi-jewel confetti shell
(`blend:'normal', burst:60, speed:[150,360], drag:1.9, sizeStart:96/sizeEnd:22,
paletteColors:[…saturated…], shimmer:8`), a lingering ember glitter
(`blend:'normal', gravity:[0,0], shimmer:5`) — all bursting UP (`directionDeg:-90,
spreadDeg:180`) with `renderOrder:100`, each parented to a stable
node (NOT the collectible's container), self-removing on `finished`.

## Trails (`Trail3D` — motion ribbons)

A fading world-space ribbon behind whatever it's parented to — wingtip trails,
sword arcs, tyre streaks, dash effects. Cheaper and crisper than particles for
"where did it move" lines:

```jsonc
{ "name": "WingTrail", "type": "Trail3D",
  "props": { "width": 0.4, "color": "#aaddff", "seconds": 0.8, "additive": true } }
```

Parent it to the MOVING node (it records that node's world position). The strip
faces the camera and tapers + fades toward the tail over `seconds`.

- `width` (m, default 0.3), `color`, `opacity` (0.8), `seconds` (0.6 lifetime).
- `additive: true` for glowing energy trails (dark backdrops); default normal
  blending reads better in daylight.
- `emitting: false` stops laying NEW ribbon while the old tail fades out — flip
  it from script for dash/boost effects (`trail.emitting = boosting`).
- `minDistance` (0.05 m) filters jitter when the parent is near-stationary.

## Terrain

`Terrain3D` is procedural heightfield terrain with biome texture splatting —
one node, themed (`island`/`alpine`/`plains`/`desert`/`custom`), deterministic
from a `seed`, with `heightAt(x, z)` for spawning/placement and a
`{shape: 'heightfield'}` collider recipe (Terrain3D as the CHILD of a
`StaticBody3D`). Full themes table, custom layer format and the water/beach
pairing live in the **incanto-environment** skill.

### Navigation on terrain (`buildTerrainNav` + findPath)

Enemies that chase across an open world need paths that respect the surface —
`buildTerrainNav` samples the heightfield into a walkable grid for the core
A* (`findPath`), and lifts waypoints back ONTO the surface:

```ts
import { buildTerrainNav } from 'incanto/3d';
import { findPath } from 'incanto';

const nav = buildTerrainNav(terrain, {
  cellSize: 2,        // meters per cell (character-scale corridors)
  maxSlopeDeg: 40,    // steeper cells are unwalkable
  minHeight: 0.5,     // below sea level = unwalkable (pair with your Water3D y)
});
const cells = findPath(nav.grid, nav.toCell(ex, ez), nav.toCell(px, pz));
if (cells) follow.setPath(cells.map(([cx, cy]) => nav.toWorld(cx, cy)));
// nav.toWorld's y IS the terrain height — PathFollow rides the surface.
```

- Build it ONCE per scene (onReady) — sampling is O(cells); re-pathing with
  `findPath` per second is cheap (binary-heap A*).
- Trees/rocks/buildings aren't in the heightfield: stamp them out with
  `nav.setSolid(...nav.toCell(x, z))` after building.
- Walkability is judged inside each cell (corner samples), so a flat trail
  beside a cliff stays walkable — only genuinely steep cells go solid.
- **See what the enemies see**: `root.addChild(createNavDebugNode(nav))`
  paints every walkable cell as a translucent quad on the surface (one
  instanced draw call) — add while tuning `cellSize`/`maxSlopeDeg`, drop when
  the routes look right.

## Performance levers for big scenes

- **`static: true`** (any Node3D): the renderer syncs that subtree ONCE and
  skips it every frame after — mark terrain, buildings, rocks, fences,
  whole decor groups. Do NOT mark animated nodes (Water3D, Particles3D,
  animated models, cameras): they freeze (the engine warns once). Set
  `static = false` at runtime to resume live syncing after an edit.
- **`environment.shadows.static: true`**: the shadow pass renders once per
  scene/environment change instead of every frame (measured ~42% of a
  dense-forest frame). Use when shadow CASTERS don't move — pair moving
  characters with sprite/blob shadows. After a one-off world edit call
  `renderer.refreshShadows()`.

## 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 (on screen / off screen /
behind it), what lights the scene, and which colliders intersect. Then read
`stats().errors` (something threw and got skipped) and `assetErrors()` (a model
404'd). All four are silent failures otherwise — the screen just looks wrong,
or empty, and nothing throws.


**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`.


### `findFloatingProps` throws rather than reporting a clean scene it never read

It answers "which props hover above the terrain", and it needs the scene to LOAD
and a `Terrain3D` to measure against. It used to return `[]` for both failures —
so a scene that would not load, a scene with no terrain and a genuinely clean
scene all printed the same green.

It stubs unregistered behaviors (it measures geometry, not logic; a scene with
any custom behavior used to read as clean — measured 0 floating before
`registerBehavior`, 26 after) and throws when it cannot measure. A level built
from mesh floors has no terrain height to read: use a physics raycast instead.

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

`createGame2D`/`createGame3D` 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 { createGame3D, showBootFailure } from 'incanto/3d';

const game = await createGame3D({ 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.** 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/3d';

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

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