# Art: building the look

Any look and any mood: a sunny party island, a pitch-black corridor, a desert town, deep space.
The one rule is the pixel art itself: 16×16 textures on voxel-sized geometry (section 8), which is
what makes every game feel like it belongs. The runtime owns the renderer and the post effects
(tone mapping, bloom, tilt-shift); you build the scene from voxels, set the mood and add lights.

## Contents
1. Style guide
2. The stage: `arenaStage`
3. The ground: `Island`
4. The camera: `CameraRig`
5. Players: `Avatar`, dressing up, and crowds (`Crowd`)
6. Voxel models: `Volume` + `meshVolume`
7. Blocks and your own textures
8. The texture rules (condensed)
9. Juice and UI
10. Icons and insets: `engine.icon`, `view.insets`, `Minimap`
11. Performance

---

## 1. Style guide

- The platform's own UI uses these; borrow them when they suit your game.
- Colours: ink `#1d2340` (outlines, text), yellow `#ffcc33`, red `#ff4b4b`, blue `#2f6fed`,
  green `#7ee081`, paper `rgba(255,252,245,.82)`. Player colours come from `players[i].color`.
- Fonts (for your own DOM): **Press Start 2P** for big words and numbers, **Nunito 800/900** for
  the rest. The runtime ships both.
- UI shapes: a 3px ink border, 12–16px corners, a hard drop shadow `0 5px 0 #1d2340`, frosted paper.
- A party arena for a fixed camera works best compact (about 13×11 to 17×15 units), so players
  stay big on screen, with a visible edge (a cobble rim, fences, water). Bigger games (8–16
  players, a shooter's map) use a follow camera (`rig.follow`) or first person (`vp docs input`
  §6). The floating `Island` is a ready-made ground, not a requirement: build any world from
  volumes and blocks.
- First person: `arenaStage` with `fov: 75, tilt: 0`, players as `Avatar`s (they're what others see).
- A soft checkerboard `tint` on the floor makes movement readable.
- Glowing things (fire, lava, magic, gems, lanterns) get `glow` on their texture; that feeds the
  bloom. For light that falls on the world around them, use `view.light` (a flashlight, a muzzle
  flash) or block light (lanterns, lava): section 2. Icons bring their own (section 10).
- Dark and scary is fine: `mood: 'dark'` (or your own), a short fog (`fog: [6, 30]`), block light
  from lanterns, a flashlight (`view.light`), glowing eyes and a heavy vignette (`view.grade`).

## 2. The stage: `arenaStage`

```ts
readonly view: ArenaStage;   // the GameStage's `view`: the engine draws view.scene through view.camera, with view.tilt
…
this.view = arenaStage(engine, {
  shadowExtent: 10,          // half-size of the sun's shadow box: fit it to the field (smaller = crisper)
  pollen: false,             // or leave on (120 motes from the stage's own rng)
  rig: { hold: () => flow.phase === 'intro', look: [0, 0, 0.5] },   // orbit during the title card, then settle
});
this.view.rig.fit(fieldWidth + 2, 58, { min: 17 });  // frame a field this wide from 58° above the horizon
// every frame, after moving things:
this.view.rig.update(dt, t);
this.view.update(dt, t);   // clouds, and the sky's easing
// resize(): this.view.rig.resize();   dispose(): this.view.dispose() (frees the scene, keeps the shared materials)
```
(If you'd rather keep the name `stage`, hold it privately and add `get view() { return this.stage; }`.)
`StageOptions`: `fov` (32), `near`, `far`, `tilt` (0.5), `shadowExtent` (14), `shadowMapSize`,
`fog` ([140, 700]), `clouds` (true, or `{ y, seed }`), `mood` ('day'), `pollen` (options or false), `seed`, `rig`,
`grade`, `mist` (below) and `sun`: the direction towards the sun or moon, `[x, y, z]` (default high in
the south-east). A low one (`[0.6, 0.5, -0.6]`: about 30° up) lays long shadows; it's fixed for the
stage's life, since still shadows are drawn once and cached.
`view.pollen({ count, area, height, centre, colors, size, speed, amp }, rand)` adds more motes
(dust, fireflies, snow). `view.sunAt(point)` moves the shadow box (follow cams do it for you).

**Ground mist** (`view.mist`, read every frame): mist lying low over the world, thickest near the
ground under the world height `top`, thinning to nothing there, broken into drifting patches, and
lit from inside: block light glows in it, and so do the stage's lit point lights (the first four:
a lantern carried through a bog makes a warm halo in the fog). Every voxel material is misted, the
ground, props, characters and water alike; hollows fill up and rises stand out of it. Off by
default (`density` 0), and a disposed stage leaves none behind.
```ts
this.view = arenaStage(engine, { mist: { density: 0.3, top: 6.2, fade: 1.6, color: '#1d2534' } });
this.view.mist.density = inBog ? 0.45 : 0.25;          // live
```
`Mist`: `density` (per unit of path through it: 0.15 a haze, 0.4 a bog at night), `top` and `fade`
(world y it thins out at, and how far under that it's full), `color` (its own colour, unlit),
`breakup` (0 even … 1 holes, default 0.5) and `size` (patches in units, default 9), `wind` (drift,
units a second, default `[0.3, 0.12]`), `glow` and `lightGlow` (how much it catches block light and
the point lights, default 1 each).

**The mood** (`view.sky`): the sky dome, the sun's colour and strength, the bounce light, the fog
and the clouds, as one thing to set or ease. Never tint the scene's lights or the dome yourself.
```ts
this.view.sky.set('dusk', 4);             // ease into a preset over 4 s: day dawn dusk night storm lava dark (MOODS)
this.view.sky.set('night');               // at once
const EMBERS: MoodSpec = { from: 'dusk', horizon: '#ff6a3a', sunIntensity: 2 };   // yours: a preset with changes
this.view.sky.set(EMBERS, 2);             // (keep your moods in constants)
this.view.sky.timeOfDay(hour, 1);         // 0..24: night, dawn at 6, day 8–17, dusk at 19, night from 20:30
this.view.sky.mix('day', ['dusk', warm], ['night', dark]);   // blend yourself, every frame if you like
this.view.clouds?.hide();                 // show(), opacity, tint, group; a mood's `clouds: 0` fades them
```
A `Mood` has `top horizon bottom` (the dome), `glow` (the sun in the dome), `sun sunIntensity`,
`sky ground bounce` (the bounce light), `env` (reflections), `fog` (default: the horizon),
`clouds` (0..1) and `cloudTint`. Moods change colours only: the sun doesn't move, because still
shadows are drawn once and cached. `'dark'` is pitch black but for a faint moon: what players see
comes from your lights, glowing textures and block light.

**Lights** (`view.light`): real lights for what moves or flickers: a flashlight, a torch someone
carries, a muzzle flash, an alarm lamp. Make them all while building the stage and switch them with
`intensity` (0 is off): adding or removing lights mid-game makes every shader recompile (a hitch).
At most 8, 2 of them with shadows (section 11).
```ts
this.torch = this.view.light({ kind: 'spot', shadow: true, color: '#fff2d8' });   // a SpotLight: range 24, angle 24°
this.flash = this.view.light({ color: '#ffb347', intensity: 0, range: 8 });         // a PointLight, off until a shot
// every frame: a flashlight held by the camera
this.torch.position.copy(camera.position);
this.torch.target.position.copy(camera.position).addScaledVector(camera.getWorldDirection(_dir), 10);
```
`LightOptions`: `kind` ('point' | 'spot'), `color`, `intensity` (16 point, 60 spot: about the sun's
strength a few units away), `range`, `angle` (degrees) and `softness` (0..1) for a spot, `shadow`, `at`.

**Block light** (`view.blockLight(vol)`): for lots of lights that stay put (lanterns down a
corridor, lava, glowing crystals, a campfire). Light floods out through the volume's air from cell
to cell: around corners, never through walls, fading over its reach. Every voxel surface
in the volume is lit by it, the players and props walking past included, and it costs nothing a
frame, however many lights there are.
```ts
const light = this.view.blockLight(this.vol);          // bakes on the next view.update
// glowing blocks light up by themselves: B.LANTERN, B.EMBER, and game blocks with `light` (section 7)
const fire = light.add({ x: 12, y: 3, z: 8, color: '#ff8a3a', reach: 7 });   // a light that isn't a block (cells)
fire.strength = 0; light.refresh();                     // put it out: a re-bake (ms), not every frame
this.vol.set(x, y, z, 0); light.refresh();              // the world changed: bake again
light.at(x, y, z);                                       // [r, g, b] in that cell: can a monster see you here?
```
One field at a time (a new call replaces it; `view.dispose()` frees it). Pass `voxel`, `origin` and
`at` (where its mesh sits) if the volume isn't meshed at 1 unit a voxel from the world's origin, and
`blocks: false` to light only with what you `add`. It's a 3D texture of 8 bytes a cell: keep the
volume under about 2 million cells. A light's strength 1 is as bright as the sun next to it.

A map built in pieces (a house, a garden, a tower of sections) is lit as one: pass the pieces, each
with its mesh's `origin` and `at`, and one `voxel`. Light crosses from one piece into the next, and
`refresh()` copies them all in again. Place lights by world position with `cell`:
```ts
const light = this.view.blockLight([{ vol: house, at: houseAt }, { vol: garden, at: gardenAt }], { voxel: 0.5 });
light.add({ ...light.cell(lamp.x, lamp.y, lamp.z), color: '#ffd27a', reach: 9 });
```

Changing it every frame costs nothing, with no re-bake:
```ts
light.gain = this.flickOff ? 0 : 1;                      // all of it: a blackout, a brown-out, a flicker
light.add({ ...light.cell(x, y, z), color: '#dff3ff', reach: 9, group: 1 });   // lights in dimmer groups 1..4
light.dim(1, 0.5 + 0.5 * Math.sin(t * 31));              // group 1 buzzes; the rest stay steady
light.dim(2, lighthouse);                                // group 2 sweeps, switches, fades up at dusk
```
`at(x, y, z)` reports the light as it's shown now (gain and dimmers applied). Where two groups' light
overlaps, each dims its own share of the cell. A `WorldView`'s light (`world.light`) has `gain` too;
a light there that flickers on its own is a `strength` change and `refresh(l)`.

**The grade** (`view.grade`), read every frame: `exposure` (0 black, 1 as is), `vignette` (0.7 the
usual, 2–3 a tunnel), `saturation` (1.1 the usual, 0 grey), `tint` (a colour that multiplies the
picture), `contrast` (round a mid grey: 1 as is, 1.2 punchier), and split toning: `shadows` and
`highlights` (colours multiplied into the dark and the bright parts; white is none) with `split`
(the linear brightness halfway between them, default 0.08). Set fields, `delete` them to go back,
or start with `arenaStage(engine, { grade })`. For a picture that isn't a toy diorama, `tilt: 0`
turns the tilt-shift blur off.
```ts
this.view.grade.vignette = 1.8;                          // a dark game: close in the corners
this.view.grade.tint = hurt > 0 ? '#ff6060' : undefined;  // a red flash when hit
this.view.grade.exposure = fade;                         // 1 → 0: fade to black
Object.assign(this.view.grade, { contrast: 1.12, shadows: '#a9b9ff', highlights: '#ffdcae', split: 0.05 });   // cold night, warm fire
```

**See-through** (`view.xray`): for a camera that looks at a hero over trees, walls or roofs, cut a
dithered hole through the scenery (`mats.solid`, `mats.cross`; never `mats.actor`) around them:
```ts
this.view.rig.update(dt, t);
this.view.xray(hero.visible ? hero.position : null, { radius: 2.4, floor: 0.25 });   // world units; null closes it
```
`floor` is a world y at or below which nothing is cut (the ground they stand on). The hole only
opens while scenery in the stage's scene really stands between the camera and the hero (a few
body-sized rays), and fades in and out; blocks beside or under them never open it
(`occluders: false`: always open). Scenery in an `InstancedMesh` or a `BatchedMesh` (a `WorldView`'s
chunks, props drawn in one batch) counts copy by copy, each where its matrix puts it. There's one hole at a time (the materials are shared);
`view.dispose()` closes it. `xray(mats, camera, at, { ..., occluders: scene })` does the same for a
camera of your own.

## 3. The ground: `Island`

```ts
const W = 13, H = 11, M = 4;   // field and meadow margin, in voxels (1 voxel = 1 unit)
const island = new Island({
  rand: mulberry32(link.seed),             // same seed → the same island on every client
  mats: engine.mats,
  size: [W + 2 * M, H + 2 * M],
  land: rectLand({ w: W, h: H, margin: M }),   // or roundLand(n, radius)
  top: (v, x, z, r) => {                   // fill layers 1+ of each land column (layer 0 is dirt)
    const cx = x - M, cz = z - M, inside = cx >= 0 && cz >= 0 && cx < W && cz < H;
    v.set(x, 1, z, inside ? ids.FLOOR : B.GRASS);   // layer 1 is the floor: its top face is world y = 0
    if (!inside) meadow(v, x, 2, z, r);             // flowers and tall grass outside
    else if (cx === 0 || cz === 0 || cx === W - 1 || cz === H - 1) v.set(x, 2, z, B.COBBLE);  // a rim
  },
  decorate: (v) => { v.set(M, 3, M, B.LOG); v.set(M, 4, M, B.LANTERN); },   // posts, props
  tint: (x, y, z, id) => (id === ids.FLOOR && (x + z) % 2 ? [1.06, 1.06, 1] : [1, 1, 1]),
  underside: { ore: [[0.025, B.ORE_GOLD], [0.035, B.ORE_GEM]] },           // or false for a flat slab
});
this.view.scene.add(island.group);
```
`top(v, x, z, r, isLand)` and `decorate(v, isLand)` also get `isLand(x, z)` (false off the grid),
for edge-aware scenery: a fence only where a neighbour is sea, a tree only on land:
```ts
decorate: (v, isLand) => { for (const [x, z] of POSTS) if (isLand(x, z)) { v.set(x, 3, z, B.LOG); v.set(x, 4, z, B.LANTERN); } },
```
The land edge is shaped by a noise drawn from `rand`. To share one noise between the land, the
underside and your own terrain, make it with `noise2D` (from `/core`) and pass it as `noise`; the
underside can sample it differently:
```ts
const n = noise2D(mulberry32(link.seed ^ 0x15a));
const island = new Island({
  rand: mulberry32(link.seed), mats: engine.mats, size: [22, 22], noise: n,
  land: roundLand(22, 9),                                     // round, radius 9, on the 22 × 22 grid
  underside: { bumps: 2, noise: (a, b) => n(a + 9, b) },      // the underside samples at x·0.25, z·0.25
});
const height = (x: number, z: number) => 1 + Math.round(n(x * 0.1, z * 0.1) * 2);   // your terrain, same noise
```
`island.noise` is the noise it used; `island.isLand(x, z)` asks the finished island.

Voxel column `(x, z)` spans world `origin[0] + x` to `+1` (the grid is centred on the origin by
default). `y` raises the whole island (the floor's top face is at world y `y`, default 0), for
satellites and floating platforms around the main one. **The top slab is only `layers` voxels tall (default 5, so y 0–4): anything `top` or
`decorate` sets at y ≥ `layers` is silently dropped.** For taller scenery (a barn, a tower) pass
`layers: 12` or so, or build it as its own `Volume` + `meshVolume` model on top. To change the ground mid-game (crumbling tiles, paint), edit `island.top` and call
`island.refresh()`: fine now and then, not every frame. For many small changes, draw the
changing part as an `InstancedMesh` instead. For a world players build and break (blocks placed and
broken all game), use a `World` and `WorldView`: chunks that re-mesh only where something changed,
with the same look (`vp docs world`).

The underside tapers to at least 2 voxels at the edge; `underside: { minDepth: 0 }` lets it thin
out to nothing, so a thin shape (a winding lane, a bridge) keeps a keel only where it's wide.

## 4. The camera: `CameraRig`

`view.rig` is a `CameraRig`. Every frame it goes: play pose (fitted or followed) → intro orbit
blend → `adjust` → zoom → shake → look.
```ts
rig.fit(width, pitchDeg, { pad = 6, min = 0, depth = 0, heightPad = 0 });   // a fixed view that fits any window
rig.fit(W, 54, { pad: 1, depth: D, heightPad: 2 });                         // a W × D field: whichever needs more room
rig.follow(target, [0, 4.6, 11.5], { aim: [0, 1, 0], rate: 7 });           // chase cams: call every frame before update
rig.zoomOn(winner ? spot.set(f.x, 0.6, f.z + 2.4) : null);                 // ease in on the winner at the end; every frame
rig.shake(0.14);                   // bumps 0.1–0.2, big hits 0.3–0.5: jolts add up, to a cap (shake(amount, cap = 0.5))
rig.shakeAtLeast(0.4);             // a gunshot, a slam: the biggest jolt wins, they don't add up
rig.snap();                        // after a cut, a respawn or a teleport: jump to the pose, don't glide
rig.look.set(x, y, z);             // move the fitted view's centre
```
`depth` is the field's extent along z on the ground; the rig foreshortens it for the pitch
(depth × sin(pitch) on screen). `height` instead fits a height that already faces the camera.

**Around the HUD: `hud`.** Everyone's chips and the timer take the top centre (more with many
players, when they wrap). `hud: true` frames the field in the band under them, exactly (the near
edge's perspective included), centred there, and follows the chips as they wrap, the window as it
changes and a phone as it turns:
```ts
rig.fit(W, 58, { depth: D, hud: true });                           // under the chips
rig.fit(W, 58, { depth: D, hud: { bottom: 80 } });                 // and above an 80 px bar of your own
rig.fit(W, 58, { depth: D, hud: { touch: true } });                // and above the touch controls on a phone
```
The touch controls sit in the bottom corners, so most games let the field run between them;
`touch: true` is for fields that must be seen whole. For a camera of your own, `hudInsets()` says
what the HUD takes in px and `screenBand(hudInsets())` the free band in NDC rows.
`CameraRigOptions`: `hold`, `look`, `intro` (`{ time, spin, reach, rise, swing }` or false; side-on
games use `swing: 0.9, spin: 0.15`), `zoom` (`{ offset, time, amount, release }`), `shakeDecay`,
`adjust(pos, look, dt)` for custom moves, `onFollow`, and `smooth` (a rate in 1/s, e.g. 6): the
final pose (after `adjust` and zoom) glides instead of jumping, so camera moves made in `adjust`
or by changing `fit` ease in. It still jumps on the first frame, during the intro orbit and after
`snap()`; follow cams glide at `follow`'s own `rate` either way.
- Fixed three-quarter view (58°) for arenas. Side-on (15–25°) for lineups like Quick Draw.
  `follow` for courses and races, on your own runner (or a bot's when spectating).
- In a bots-only run there's no "you": frame the whole field, or follow the leader.

## 5. Players: `Avatar` and animation

```ts
this.avatars = players.map((p, i) => {
  const av = new Avatar(engine.mats.actor, p.look, { scale: 0.62, turn: 16, tag: i === seats.you ? { text: `P${i + 1}`, color: p.color } : undefined });
  const ring = new Mesh(new RingGeometry(0.5, 0.72, 24), new MeshBasicMaterial({ color: p.color, transparent: true, opacity: 0.85, depthWrite: false }));
  ring.rotation.x = -Math.PI / 2; ring.position.y = 0.04;
  av.char.root.add(ring);          // a coloured ring under each player's feet
  av.teleport(start.x, 0, start.z, 0);
  scene.add(av.root);
  return av;
});
```
Bring them to life in `update` (the avatar owns `root`'s position and yaw; the rest is yours):
- walk bob: `body.position.y = Math.abs(Math.sin(walk)) * 0.35` with `walk += dt * 16` while `av.speed > 0.3`;
- sway `body.rotation.z = Math.sin(walk) * 0.12`; breathing when idle (scale.y ± 0.025);
- a hop on pickups, a lean and stretch on dashes, a dizzy spin (`char.root.rotation.y`) when hit;
- knockouts: spin up and shrink, or tumble off the edge; `setOut(i, true)` on the HUD;
- the finish: winners bounce (`Math.abs(Math.sin(t * 7)) * 1.2`), and the camera zooms on them.

**Names over heads: `nameTag`.** A `textSprite` is sized in the world, so it's huge up close and
unreadable far away. A `nameTag` stays readable: the same size on screen, or a world size kept
between two sizes. Set its text any time; it redraws in place.
```ts
const tag = nameTag(p.name, { color: p.color, far: 40 });            // 18 px, seen through walls, gone past 40
tag.position.y = av.char.height + 0.3;
av.char.body.add(tag);                                               // bobs with the body
tag.text = `${p.name} ♥${hp}`;                                      // later, as often as it changes
nameTag('Moss', { height: 0.5, minPx: 12, maxPx: 22 });              // shrinks with distance, within limits
nameTag('IT', { depthTest: true, background: '#ffcc33', color: '#1d2340' });   // hidden by walls, on a pill
new Avatar(mats.actor, look, { tag: { text: 'P1', color, px: 16 } });            // Avatar's tag, as a name tag
```

### Held, worn, and other looks

Every avatar has four anchors in its body, so held and worn things bob and squash with it:
`head` (the top of the head), `hand` (the free hand, the character's right), `offhand` (the other,
where the character's own prop is), `back` (between the shoulders, on the back's face).
```ts
const HAT = new Volume(9, 4, 9);  /* … */                // modelled at 9 voxels a unit, like the characters
av.wear(HAT, 'head');                                   // sits on the head
av.wear(SWORD, 'hand');                                 // held by its lowest voxels: build it hilt-down
av.wear(PACK, 'back');                                  // hangs behind
av.wear(gunMesh, 'hand', { offset: [0, 0.05, 0.2], turn: Math.PI });   // any Object3D, as it is
av.anchors.hand.add(torch, light);                       // or hang your own things there
```
Volumes are meshed on the avatar's material, so a tint or flash reaches them too. `unwear(item)`
takes one off, `unwear()` everything.

Looks, per avatar, costing nothing until used (the avatar then draws with its own copy of the
material, the same shader):
- `av.tint = color` multiplies the body (a team shade, an ink splat, frozen blue); `null` restores it;
- `av.opacity = 0.35` for ghosts, spectators and fade-outs (0 hides it);
- `av.flash(color = white, time = 0.15, strength = 1)` for hits, heals and pickups.

Other bodies: `new Avatar(mats.actor, volume, { voxel: 1 / 12 })` uses any voxel model as a player
(a killer, a monster, a prop to hide as); it keeps the smoothing, tag, anchors (worked out from the
model: head on top, hands at the sides) and looks. `av.setLook(lookOrVolume, voxel?)` swaps the
body in place mid-game (a disguise, a transformation): the pose, tag and worn things stay, the
anchors move. `characterVolume(look)` gives a built-in character's voxels to edit.

Rebuilding avatars? `av.dispose()` takes one out of the scene and frees its body, worn Volumes, tag
and own material (not the shared materials, nor meshes of yours it wore).

### Crowds: `Crowd`

Armies, hordes, creeps, flocks, an audience: hundreds of animated units at two draw calls a kind
(the body and its team part), with no animation code of your own. One `Crowd` per kind; each frame
draw the units that are there, by id, then `update`:
```ts
const ids = useGameAssets(TEXTURES, BLOCKS);
const army = new Crowd(engine.mats.actor, footmanVolume(ids), { team: [ids.TEAM] });
scene.add(army.root);

// every frame
for (const u of world.units) army.draw(u.id, u.x, 0, u.z, { team: TEAMS[u.team] });
army.update(dt);

// on events
army.attack(u.id);        // a lunge forward (negative `lunge` option: a recoil for archers)
army.hit(u.id, '#f44');   // a flash
army.remove(u.id);        // gone now, no death
```
- **Animated for you:** units face where they walk (or the `yaw` you give), bob and lean with each
  stride, pop in when first drawn, and die when they stop being drawn (`die`: `'topple'`, `'sink'`,
  `'shrink'` or false; `dieTime`). Tune `bob`, `lean`, `lunge`, `pop`, `turn`, `stride`.
- **Team colours:** the model's `team` blocks go into a second mesh, tinted per unit. Paint them
  light (white or a pale cloth): the colour multiplies. `color` tints a whole unit; `scale` sizes it.
- **Models:** a `Volume` (`voxel`, 1/9 by default), a character look (`{ char: 3, … }`: a crowd of
  frogs), or geometry you made (`{ body, team?, height }`).
- **Your own motion:** `draw(id, …, { matrix })` or `put(matrix, team?, color?)` draws a copy exactly
  where you say, no animation; mix them freely with animated units. Footman Frenzy poses its own.
- **Cost:** about 0.2 ms of CPU for 500 animated units; two draw calls (plus shadows: `shadows:
  'body'` skips the team part's) whatever the count. The meshes are uploaded and the shader
  compiled when the crowd is made (no hitch when the first unit appears); the pool doubles when it
  runs out; a crowd that holds still uploads nothing, so its shadows stay cached.
- Units are on `mats.actor`, so they stay whole behind scenery; the x-ray cuts `mats.solid` (put
  buildings and walls there, instanced ones too). `crowd.dispose()` frees it (geometry you passed
  stays yours).

## 6. Voxel models: `Volume` + `meshVolume`

Props, pickups and obstacles are small voxel models, built in code:
```ts
export function coinGeometry(ids: { GOLD: number; RIM: number }): BufferGeometry {
  const N = 11, T = 3, c = (N - 1) / 2;
  const v = new Volume(N, N, T);                         // sx, sy, sz; ids 0 = air
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
    const d = Math.hypot(x - c, y - c);
    if (d > 5.3) continue;
    if (d > 3.9) for (let z = 0; z < T; z++) v.set(x, y, z, ids.RIM);
    else v.set(x, y, 1, ids.GOLD);
  }
  // 13 voxels per unit, origin at the model's centre: about 0.85 units across
  return meshVolume(v, { voxel: 1 / 13, origin: [N / 2, N / 2, T / 2] }).opaque!;
}
const mesh = new Mesh(coinGeometry(ids), engine.mats.actor);   // props, characters and FX use mats.actor
mesh.castShadow = true;
```
- `meshVolume(vol, { voxel, origin?, tint? })` returns `{ opaque, water, cross }` (each may be
  null). `voxel` is the world size of one voxel: `1` for terrain, `1 / 9` to `1 / 14` for props
  (the characters are 1/9), or `[x, y, z]` for a stretched model. `origin` is the voxel-space point
  that becomes the geometry's origin.
- Terrain-scale volumes (1 voxel = 1 unit): `addVoxelMeshes(group, vol, mats, [x, y, z], tint?)`
  meshes them on `mats.solid`/`water`/`cross` with shadows.
- Water blocks come out as real water (depth, foam at the banks, glints, flow, caustics on the
  bed), baked from the volume when it's meshed. Its colours, clarity, foam and flow are
  `ctx.engine.water.set(…)`; a water block's meta is its flow. All of it: `vp docs water`.
- Maps, arenas and terrain are easiest drawn as text: `textGrid` gives a `Volume` (and the spawns
  and goals as marks) to hand to `addVoxelMeshes`, a `VoxelGrid` and a `World` alike (`vp docs levels`).
- `Volume` also has `get`, `setIfAir`, `inside`, and a `meta` byte per voxel.
- Shape carries the detail: a lighter voxel on a top edge, a darker band, a 1-voxel outline in a
  deeper colour.

## 7. Blocks and your own textures

Built-in blocks (`B`): `GRASS DIRT STONE COBBLE MOSSY SAND PATH PLANKS LOG LEAVES_OAK LEAVES_PINE
LEAVES_CHERRY SNOW WATER CAP STEM PIPE PIPE_RIM GIFT GOLD CLOUD LANTERN ORE_GOLD ORE_GEM DEEPSTONE
EYE GEM EMBER TALL_GRASS FLOWER_RED FLOWER_YELLOW FLOWER_BLUE FLOWER_WHITE MUSHROOM VINE ROOTS SKIN
CLOTH_RED CLOTH_BLUE CLOTH_GREEN CLOTH_YELLOW CLOTH_PURPLE HAIR SHOE WHITE BLACK CHEEK METAL
METAL_DARK SCREEN FUR FUR_LIGHT FROG FROG_LIGHT FOX PIG PIG_DARK BEAK CAPY CAPY_DARK QUILL KAIJU
KAIJU_LIGHT NAVY DOG` (plus the board's `PAD_*`). Check `types/world/blocks.d.ts` for the list in
your SDK version.

Your own textures are 16×16 pixel art painted in code, in `textures.ts`. The painters are in
`/core`, so a test can paint every texture headless (`GameBlockDef` is a type from the main
entry; a type-only import is erased, so bun can still load the file):
```ts
import { Px, S, hex, pal, shade, starMask, stamp, flat, type Rng, type TexDef } from '@voxelparty/sdk/core';
import type { GameBlockDef } from '@voxelparty/sdk';
const INK = hex('#1d2340');

function wool(p: Px, r: Rng) { p.noise(pal('#f4f1ea', '#ebe6dc', '#fbf9f4'), r, 0.05); }   // a material: tiles seamlessly
function sign(p: Px, r: Rng) {                                                              // a decal: a framed picture
  const base = hex('#ffc629');
  p.each((x, y) => {
    let c = shade(base, 0.95 + r() * 0.1);
    if (x === 0 || y === 0 || x === S - 1 || y === S - 1) c = INK;            // 1px ink frame
    else if (x === 1 || y === 1) c = shade(base, 1.3);                        // bevel: light top/left
    else if (x === S - 2 || y === S - 2) c = shade(base, 0.64);               // dark bottom/right
    p.set(x, y, c);
  });
  stamp(p, starMask(7.5, 8, 5.6, 2.5), hex('#fffbe6'), INK, hex('#c98a0c'));  // glyph with ink outline and shadow
}

export const TEXTURES: TexDef[] = [
  { name: 'cs_wool', paint: wool },
  { name: 'cs_sign', decal: true, paint: sign },
  { name: 'cs_fleece', paint: flat('#d9d2c3', '#cfc7b6') },   // near-flat: for small props
  { name: 'cs_ember', glow: 2, paint: flat('#ff9a3c', '#ffb85c') },
];
export const BLOCKS = { WOOL: 'cs_wool', SIGN: 'cs_sign', FLEECE: 'cs_fleece', EMBER: 'cs_ember',
  CRATE: ['cs_crate_top', 'cs_crate_side'],       // one texture, or [top, side, bottom?]
  ARROW: { top: 'cs_arrow', side: 'cs_wool', turns: true },   // a top that turns: see below
  LAMP: { top: 'cs_ember', light: { color: '#ff9a3c', reach: 8 } },   // gives off block light (section 2)
} satisfies Record<string, GameBlockDef>;

// game.ts, in the constructor, once, before anything uses the ids (useGameAssets is from '@voxelparty/sdk'):
const ids = useGameAssets(TEXTURES, BLOCKS);   // → { WOOL: 128, SIGN: 129, … }
```
- `TexDef`: `name`, `paint(p, r)`, `glow?` (0..3, self-lit), `cutout?` (transparent pixels: plants),
  `decal?` (`true`, or `'always'` for the rare face that is the picture, like eyes).
- Prefix every texture name with 2–3 letters from your id (`cs_`); names must be unique.
- Budget: 160 game textures and 128 game blocks.
- `Px`: `set(x, y, rgb, a?)`, `get`, `each(fn)`, `noise(palette, r, jitter = 0.07)`, `sprinkle(r, n, colors)`,
  `tone(x, y, factor)`. Colour helpers: `hex`, `pal`, `shade`, `mix`, `pick`, `ri`. Glyphs:
  `maskRows(['..##..', …], ox, oy)`, `maskFrom(fn)`, `starMask(cx, cy, outer, inner)`,
  `stamp(p, mask, fill, outline, shadow?)`. Ready-made painters: `flat(...hexes)`, `planks`,
  `stone`, `cobble`, `dirt`, and the palettes `GRASS DIRT STONE SAND PATH WOOD BARK SNOW WATER`.
- `textureDataURL('cs_sign')` gives a PNG data URL for HUD icons (`hudStat(icon, n)`).
- Pictures on the floor that point somewhere (speed pads, arrows, one-way signs): give the block
  `turns: true` and set each voxel's meta to `turnTop(dx, dz)`, e.g.
  `vol.set(x, 0, z, ids.ARROW, turnTop(1, 0))`: the top edge of the picture as painted then faces
  +x. Paint it pointing up; as painted (meta 0) it faces +z, towards the usual camera.

## 8. The texture rules (condensed)

The world runs at 16 texels per unit. Small props are voxel models at 1/9–1/14 of a unit; if each
tiny face showed a whole texture, props would shimmer with noise.

1. **Size voxel models with `meshVolume(vol, { voxel: 1 / N, origin })`.** The mesher scales
   positions *and* texture coordinates, so one voxel shows about one texel. Never
   `geometry.scale(...)` a voxel geometry, and never shrink a voxel mesh with a constant
   `mesh.scale` at build time. Runtime scale for animation (pop-ins, squash, 0.5×–2×) and
   instance matrices is fine.
2. **One block as a cube:** `blockGeometry(id, size)` (`size` a number or `[x, y, z]`).
   **Particles:** `bitGeometry(id)`: every face shows the texture's middle texel, so pick a block
   whose middle is the colour you want (`B.EMBER` for fire, not the framed `B.LANTERN`). `Bits`
   throws if given a full-texture cube.
3. **Materials vs decals.** A material tiles seamlessly (grass, stone, wool, metal, fire). A
   decal (`decal: true`) is a picture of one whole face: a glyph, an icon, a framed panel, a sign.
   If it would look wrong cut in half or tiled next to itself, it's a decal. Decals show whole
   on voxels ≥ 1/4 unit and are sampled like materials on smaller ones.
4. **Small props use near-flat materials** (`flat(...)`, ±5% jitter) and let the voxel layout
   draw the highlights. Don't use terrain textures on props for their look.
5. **Keep glow small on small props:** one or two glowing voxels (a gem tip), not the whole surface.
6. Texture look: tiling materials get subtle per-pixel noise (±5%); panels on block-sized voxels
   get a light bevel (top/left ×1.25–1.3, bottom/right ×0.62–0.75), a 1px ink frame and glyphs
   stamped with the `#1d2340` outline.

## 9. Juice and UI

Every meaningful event gets a visual and a sound: a popup (`+1`, `BUMP!`, `-3`), an effect, a
hop or spin on the character, and a shake for big hits. **Spells, impacts, projectiles, auras,
beams, buffs and deaths are `Vfx` effects** (vfx.md). They work like sounds: the game's own, one
per moment, sized to it, in its palette, in `vfx.ts`, seen in `vp gallery`. The `VFX` library is
for learning and copying. The look is big and juicy: glow, bloom and soft light, with
particles, debris and patterns as chunky pixels and voxels, like the 16×16 textures. Not a
`RingGeometry` mesh and a few sparks. Pulse anything about to go
off; pop new things in with an overshoot (`ctx.tween.from(m.scale, { x: 0, y: 0, z: 0 }, { ease:
'outBack' })`, or `ease.outBack(k)` in your own animation). Give hits weight with a short
`ctx.time.hitstop(0.05)` plus a shake, and landings a `Spring` squash; `fx.scaled(2)` makes the
same burst bigger for a bigger event. Never hand-write easing curves: `ease` has them all. One `Fx` covers most juice: five pools
(spark, puff, dust, chip, confetti), each with its own block, cap, motions and floor, and `false`
for the ones you don't use (`new Fx(scene, mats.actor, { spark: ids.SHINE, chip: { block: B.COBBLE,
cap: 160 }, dust: false })`); `fx.spawn(pool, pos, vel, life, size)` for custom bursts. Bursts take
a colour too (`fx.chips(x, y, z, 10, 3, 0.14, team.color)`, or `fx.spawn(...)?.color.set(c)`): it
multiplies the block's, so tint bits made from a light block. Numbers that fire many times a
second (damage, gold) go through `popups.number(x, y, z, -dmg, '#ff5a4a', { key })`, not `show`:
instanced glyphs, no canvas per number, and hits with the same `key` add up. Signatures
for `Fx`, `Popups`, `Bits`, `createParticles`, `textSprite`, `nameTag` and the `Ui` components are in api.md
sections 12–13. Prefer `flow.hud.setStat` and `flow.hud.banner` over custom UI; use `Ui` for
meters, hints and signs. The top-right corner (`var(--vp-corner-w)` × `var(--vp-corner-h)`) is the
page's, for its gear and session buttons: put your own UI anywhere else (api.md section 13).

## 10. Icons and insets

**Icons: a model as a picture for your HTML.** Tower portraits on a command card, a shop's
wares, a character picker. `engine.icon(object, opts?)` renders any `Object3D` to a PNG data URL,
lit like the world (the sun, the sky's bounce and reflections, the same voxel materials) and
finished like the main view (tone mapping, colour), transparent around the model:
```ts
// A command card: one icon per tower, made once in the constructor (never every frame).
const card = document.createElement('div');
card.className = 'td-card';               // your CSS: bottom centre, pointer-events: auto (ui.el is click-through)
card.innerHTML = TOWERS.map((t) => {
  const url = engine.icon(new Mesh(t.geometry, engine.mats.actor), { size: 56, key: t.id });
  return `<button data-tower="${t.id}"><img src="${url}" width="56" height="56" alt="">${esc(t.name)}</button>`;
}).join('');
ui.el.append(card);
flow.hud.setStat(me, hudStat(engine.icon(coinMesh, { size: 24, key: 'coin' }), gold));   // a HUD chip's stat icon
```
- `IconOptions`: `size` (CSS px, default 96, or `[w, h]` for a portrait; the image has up to 2
  pixels per CSS px, so give the `<img>` its CSS size), `yaw` (degrees round the model: 0 looks
  at its +z side; default 30, a three-quarter view), `pitch` (degrees above the horizon, default
  25), `pad` (margin as a fraction of the picture, default 0.08), `fov` (0, the default, is a flat
  orthographic picture; 20–40 for perspective), `bounds` (a `Box3` in the model's own coordinates:
  frame just the head for a portrait), `key` (cache: the same key returns the first picture).
- The model is framed to fit, as posed: its own position, rotation and scale count, its parents'
  don't. It may live in your scene (an `Avatar`'s `root`, a placed tower) or nowhere; it's
  borrowed for the picture and put back. Build throwaway models from shared geometry.
- The light turns with `yaw`, so every icon is lit the same way. No bloom, vignette or tilt-shift.
- An icon draws on the spot (a few ms; the first also compiles shaders): make each once, in the
  constructor, or pass `key`, and keep the string.

**Insets: a second view.** A minimap, a rear-view mirror, a spectator picture-in-picture. List
them in the view's `insets` (an `ArenaStage` has an empty array to push to; it's read every frame,
like `view.camera`). The engine draws each over the main view, in its screen rect, finished like
the main view (no tilt-shift, bloom or vignette):
```ts
// A round minimap in the bottom-left corner, following you.
this.map = new Minimap({ area: [W + 4, H + 4], rect: { left: 16, bottom: 16, width: 180, height: 180 }, round: true });
this.view.insets.push(this.map);
this.map.follow(this.avatars[me].root.position);   // or leave it fixed on the area
// your own HTML ring over it: position: fixed; left: 16px; bottom: 16px; 180px; border-radius: 50%; 3px ink border

// A rear-view mirror: any camera, any rect (a perspective camera's aspect is kept at the rect's).
const mirror = new PerspectiveCamera(50, 1, 0.5, 400);
this.view.insets.push({ camera: mirror, rect: { top: 16, left: 16, width: 240, height: 100 }, resolution: 0.5, every: 2 });
// every frame: mirror.position.copy(kart.position).add(up); mirror.lookAt(behind);
```
- `Inset`: `camera`, `rect` (CSS px: one of `left`/`right`, one of `top`/`bottom`, a missing pair
  centres it; `width`, `height`), `scene?` (default the view's; a separate stylised map scene
  works too, transparent where it draws nothing), `round?` (clip to the circle inside the rect),
  `resolution?` (0.25..1: draw fewer pixels), `every?` (redraw every Nth frame, showing the last
  picture in between).
- `Minimap(opts)`: an orthographic top-down camera, north (−z) up. Options `rect` (default bottom
  left, 180 × 180), `area` ([x, z] world units kept in view, default [30, 30]), `centre`, `heights`
  (`[bottom, top]`, default [-12, 12]: whatever's above `top` or below `bottom` is left off the
  map, so a roof, or the floor above the one you're on, goes by `top`), `scene`, `round`,
  `resolution`, `every`, `rotation`. Methods `fit(w, d)`, `centreOn(x, z)`, `follow(point | null)`;
  set `map.rect` to move it.
- `map.rotation`: which way is up, radians clockwise from north (0 north, π/2 east). A map that
  turns with the player: `map.rotation = Math.PI - body.yaw` every frame (first person: where you
  look is up); third person, the camera's heading.
- **Layers** pick what each camera sees. Everything starts on layer 0, which every camera sees:
  `blip.layers.set(1); map.camera.layers.enable(1)` puts a marker only on the map (a big bright
  block over each player reads better than their model), and `roof.layers.set(2);
  view.camera.layers.enable(2)` keeps a roof off it. Keep lights on layer 0.
- Cost: an inset draws its scene a second time (the view's shadows are reused, not redrawn). A
  180 px map is cheap; for a big one or a busy scene, use `resolution: 0.5` or `every: 2`.
- Keep insets out of the page's top-right corner (section 9).

## 11. Performance

- Target 60 fps on a laptop. Use `InstancedMesh` for anything repeated (coins, tiles, sheep):
  set `count` and `setMatrixAt` each frame, then `instanceMatrix.needsUpdate = true`. It works
  on every voxel material (`mats.solid`, `actor`, `water`, `cross`), like a plain `Mesh`.
  `setColorAt(i, color)` gives each copy its own colour, multiplying its textures (team colours,
  hit flashes, frozen tints) on every voxel material: one geometry, not one per colour. Set a
  colour on every slot when you build the mesh (so the shader is compiled with them from the
  start), and `instanceColor.needsUpdate = true` when they change.
- No allocation in hot loops: module-level scratch `const _v = new Vector3(), _m = new Matrix4()`.
- Build geometries once; re-mesh volumes only when they change.
- Keep draw calls under ~300 a frame (`vp check --long` counts them). Every mesh is one, and a
  moving mesh that casts a shadow is two. Shadows of things that hold still are drawn once and
  cached, so don't make scenery bob or sway by moving meshes: leave it still, or animate it in
  one `InstancedMesh`.
- Put every mesh and material in the scene when the stage is built (hidden, or an
  `InstancedMesh` with `count = 0`), not mid-round: shaders compile before the first frame, and a
  material type that first shows up mid-round freezes the game while it compiles.
- Lights (`view.light`): each one is extra shading on every pixel it reaches, so it allows 8. Make
  them all when the stage is built and switch them with `intensity`, never by adding, removing or
  hiding them: a change in the number of lights recompiles every shader and freezes the game. A
  light with a shadow draws the scene again every frame (a point light's six times), so at most 2,
  and a `spot` for a flashlight. Block light (section 2) is free a frame: use it for everything
  that stays put.
- Free what you create in `dispose()`: `this.view.dispose()` frees everything in the scene except the
  shared materials; also call `fx.dispose()`, `popups.dispose()`, `ui.dispose()`, unsubscribe
  `onEvent`/`HostSync`, stop sustained sounds, clear timers.
