# The SDK, a curated tour

The authoritative reference is the installed types: `node_modules/@voxelparty/sdk/types/sdk/index.d.ts`
lists every export, and each export's doc comment lives with its module under
`types/minigames/kit/`, `types/world/`, `types/textures/`, `types/scene/`, `types/render/` and
`types/soundengine/`. Grep there when you need a detail this page skips.

## Contents
1. Entry points
2. `defineGame`, `GameContext`, `GameStage`
3. The frame: `Flow`
4. Input: `Controls`, `KEYS`, `Keys` (short; see input.md)
5. The link
6. Randomness and maths
7. Netcode (short; see netcode.md)
8. Bots
9. Players: `Avatar`, `PoseSmoother`
10. Stage, island, camera (short; see art.md)
11. Voxels and textures (short; see art.md)
12. Juice: `ease`, `Tweens`, `Spring`, hitstop, `Vfx`, `Fx`, `Popups`, `Bits`, particles, labels
13. UI components (and the page's corner)
14. Sound (short; see sound.md)
15. Tests: `@voxelparty/sdk/test`
16. Saving: `storage`
17. Experimental

---

## 1. Entry points

| Import | What | Where it runs |
|---|---|---|
| `@voxelparty/sdk` | Everything: the contract, kit, voxels, textures, scene, sound. Re-exports all of `/core`. | Only inside the game runtime (`vp dev`, `vp check`, the site). Under bun it throws on purpose. |
| `@voxelparty/sdk/core` | The headless part: rng, maths and `noise2D`, netcode (`Seats`, `PlayerSync`, `HostSync`, …), block worlds (`World`, `WorldSync`, `aimBlock`, `B`), bot helpers and `Stick`, `ranksFromKnockouts`/`scoresFromKnockouts`, `END_EVENT`, `GameFrame`, the link types, `storage` (saved data), **sound data** (`INSTRUMENTS`, `SFX`, `SONGS`, `Patch`, `Song`, …, `parsePattern`, `checkSong`) and **texture painters** (`Px`, `hex`, `flat`, `TexDef`, …) | Anywhere: `rules.ts`, `bot.ts`, `sounds.ts`, `textures.ts`, tests |
| `@voxelparty/sdk/test` | `FakeRoom`, `FakeFlow`, `createSoloLink`, `rankScores`, `resetStorage` | Tests |
| `@voxelparty/sdk/experimental` | Engine internals and unsettled APIs (section 17) | May change in any version: avoid |
| `three` | The runtime's single copy of three.js | Game code only (not headless files) |

## 2. `defineGame`, `GameContext`, `GameStage`

```ts
import { KEYS, defineGame } from '@voxelparty/sdk';
export default defineGame({
  id: 'counting-sheep',          // must equal game.json's id
  name: 'Counting Sheep',
  blurb: 'Sheep leap the fence. Count them! Closest count wins.',   // title card: goal + twist
  controls: [[KEYS.action, 'Count a sheep']],                        // [keys label, what it does]
  round: 45,                     // seconds; the HUD timer counts it down. Omit for untimed session games,
                                 // and for schedule-driven games (use flow.setRound)
  music: MUSIC,                  // theme; soft under the title, full at GO, 12% faster for the last 10 s
  finale: true,                  // false: no last-10-seconds speed-up
  pointerLock: false,            // true: lock the pointer on "Click to play" and on clicks while playing
                                 // (mouse-look games; needs "pointerLock" in game.json's input)
  create: (ctx) => new CountingSheep(ctx),   // returns a GameStage (below); may be async
});
```

`GameContext` (what `create` gets):
- `engine: { mats: VoxelMaterials; env: Texture; icon(object, opts?): string }`: the shared voxel
  materials (`mats.solid` terrain, `mats.actor` characters/props/FX, `mats.cross` plants,
  `mats.water`; never dispose them), the baked sky for `arenaStage`, and `icon`: any `Object3D`
  rendered to a PNG data URL for `<img src>`, lit and finished like the game on a transparent
  background (`IconOptions`: `size` 96, `yaw` 30, `pitch` 25, `pad` 0.08, `fov` 0 = flat, `bounds`,
  `key` to cache). It draws on the spot: once per icon, not every frame (art.md section 10).
  `engine.water` is the water every water block is drawn with (`set` its look, `field` floating
  shapes and river flow, `ring` foam round things bobbing: **water.md**), and `engine.quality` the
  player's graphics setting (`'low' | 'medium' | 'high'`, live: draw less on `'low'`).
- `players: PartyPlayer[]`: `{ name, color, look, human }`, in seat order, index-aligned with
  `link.players`. `color` is the player's UI colour (16 of them; the first four are red `#e23b3b`,
  green `#3fb34f`, yellow `#f2b51f`, purple `#8e4fd6`). `look` goes to
  `new Avatar(mats.actor, look)`. `human` means "this client's keyboard". **Live** in sessions:
  the same array is updated in place when people join and leave (keep the array, not a copy).
- `link: MinigameLink`: networking, clock, seed (section 5).
- `flow: Flow`: the frame around the game (section 3).
- `input: Controls`: this player's input in actions (section 4).
- `time: GameTime`: hitstop and slow motion (section 12). The `dt` and `t` your `update` gets are
  game time; `time.realDt` is the real frame.
- `tween: Tweens`: tweens on game time, updated after your `update`, cleared at the end (section 12).

`GameStage` (what `create` returns):
```ts
interface GameView { readonly scene: Scene; readonly camera: PerspectiveCamera; readonly tilt?: number;  // an ArenaStage is one
  readonly insets?: readonly Inset[] }    // minimaps, mirrors: drawn over the view, read every frame
interface Inset { camera: Camera; rect: InsetRect; scene?: Scene; round?: boolean; resolution?: number; every?: number }
// InsetRect: { left? | right?, top? | bottom?, width, height } in CSS px (a missing pair centres it)
interface GameStage {
  readonly view: GameView;                // what the engine draws; read every frame, so a game may swap cameras
  update(dt: number, t: number): void;    // every frame, after flow.update and before input.endFrame
  resize?(w: number, h: number): void;    // re-fit the camera: this.view.rig.resize()
  onPlayers?(players: readonly LinkPlayer[], joined: readonly LinkPlayer[], left: readonly LinkPlayer[]): void;
                                          // sessions: someone joined or left (ctx.players is already updated)
  dispose(): void;                        // free what you made; the platform disposes flow and input
}

class CountingSheep implements GameStage {
  readonly view: ArenaStage;
  constructor(private readonly ctx: GameContext) {
    this.view = arenaStage(ctx.engine, { rig: { hold: () => ctx.flow.phase === 'intro' } });
  }
  update(dt: number, t: number) { /* … */ this.view.rig.update(dt, t); this.view.update(dt, t); }
  resize() { this.view.rig.resize(); }
  dispose() { this.view.dispose(); }
}
```
`dt` is already capped at `MAX_DT` (0.1 s) by the platform, so a stalled tab doesn't make things
jump: don't clamp it yourself. It's game time: 0 during `ctx.time.hitstop`, smaller in slow motion.

## 3. The frame: `Flow`

`ctx.flow` runs title card → ready-up → 3-2-1-GO → play → FINISH → results. Don't build your own.
In a session the title card says "Click to play"; an **untimed** game (no `board` block) goes
live on that click with no countdown and no timer (`timeLeft` is `Infinity`), and the HUD follows
joins and leaves (up to 16 chips). `end()` / `finish()` end the run; the platform then starts a
new one (`vp docs sessions`).

| Member | Meaning |
|---|---|
| `flow.phase` | `'intro' \| 'countdown' \| 'play' \| 'finish' \| 'results'`. Drive intro orbits and victory poses from it. |
| `flow.live` | `phase === 'play'`. Simulate and read input only while true. |
| `flow.clock` | Seconds since GO on the synced clock (identical on every client). |
| `flow.timeLeft` | Seconds left: min(your round, the server's hard stop). End the game at `<= 0`. `Infinity` in an untimed session. |
| `flow.onPlay(cb)` | `cb` runs on this player's "Click to play" / Ready click, inside the gesture (lock the pointer, start audio). Returns an unsubscribe. |
| `flow.setRound(seconds)` | Change the round length (schedule-driven games). |
| `flow.end(scores, headline = 'FINISH!')` | Host-authoritative: the host decides for everyone. Reports every score and shows the finish on every client. |
| `flow.finish(scores \| null, headline = 'FINISH!')` | This client is done: reports the seats it owns (`NaN` for the rest), or null to just stop. |
| `flow.hud.setStat(i, html)` | The line under player i's HUD chip (i: the current roster index; the line stays with that player through joins and leaves). Use `hudStat(icon, value)` (the chip is light paper: a pale icon texture disappears on it; use a dark-edged icon or plain text like `${score} ★`). Update only when it changes. |
| `flow.hud.setOut(i, true)` | Grey out a knocked-out player's chip. |
| `flow.hud.banner(text, color = '#ffcc33')` | Big transient text with a whoosh: 'FRENZY!', 'SUDDEN DEATH!'. Long text shrinks to fit the screen on one line; keep it short all the same. |
| `flow.music(song, { finale })` | Start (or swap) the theme. `defineGame({ music })` already does it. |
| `flow.musicVolume(v, time = 0.4)` | Scale the theme: 0 for a tense silence, back to 1 after. |
| `flow.intensify(factor?)` | Kick the theme into its fast version now (sudden death). |

Scores: higher is better, ties share a place, non-finite scores are ignored. Examples: items
collected → the count; a race → `-finishMs`; last one standing → `scoresFromKnockouts(n, groups)`
(from `/core`: `groups` are seat indices in knockout order, first out first, players out on the
same frame in one group; it returns `-rank` per seat, ready for `flow.end`);
`ranksFromKnockouts` gives the ranks (1 is best) instead; survival → ms survived.

## 4. Input: `Controls`, `KEYS`, `Keys`

Actions (every game; all a board minigame may use), the game's own buttons, then the mouse and raw
keys for games that list `mouse` / `pointerLock` / `keyboard` in game.json's `input`. Gamepads and
touch screens drive all of it with no device code. The full guide, with a first-person camera
recipe: **input.md**.

```ts
type Action = 'action' | 'up' | 'down' | 'left' | 'right';
input.move();               // Stick { x, z }, each -1..1: x right, z towards the camera. Same shape as the bot helpers
input.dir();                // grid games: the most recently pressed direction still held, or null
input.down('action');       // held (SPACE / X / ENTER, a pad's A, the big touch button)
input.pressed('action');    // went down this frame (a left click/tap anywhere counts as 'action', except in pointerLock games)
input.pressed('reload');    // a button of the game's own: defineGame({ buttons: { reload: { keys: ['KeyR'], pad?, label?, icon?, touch? } } })
input.presses('action');    // presses since the game began: stream this counter, not a flag
input.pressedAt('action');  // the last press, on link.now()'s clock, accurate to the key event
input.pressTimes('action'); // every press since the last frame, oldest first: readonly { at, local }[]
                            // `at` on the link clock (compare across players), `local` on performance.now()
// the mouse ("mouse"; "pointerLock" for the lock)
input.pointer();            // { x, y } NDC -1..1, y up; the centre while locked
input.mouseDown(b = 0);     // held: 0 left, 1 middle, 2 right
input.mousePressed(b = 0);  // went down this frame
input.wheel();              // px this frame, + = towards you
input.look();               // { dx, dy } px this frame, locked or not
input.ray(camera, out?);    // a three.js Ray through the pointer (the centre when locked)
input.lockPointer(); input.unlockPointer(); input.locked
input.pointerLock = false   // pause defineGame's automatic lock (a pointer-driven mode); true locks on the next click
input.aiming                // look is live: locked, or a pad / touch screen. Gate look and fire on this
input.blocked               // a modal Menu is open: everything above (and input.keys) reads idle (menus.md)
// every device
input.device                // 'keyboard' | 'touch' | 'pad'
input.label(KEYS.action)    // "SPACE", "A" or "TAP" (any KEYS, or a button's name or key label)
input.rumble(0.6, 120)      // shake the pad / buzz the phone
input.pad                   // down('lt'), pressed('y'), stick('right'), trigger('rt'), connected
input.touch                 // the touch controls: enabled, shown, setButtons([...]), root; defineGame({ touch }) to lay them out
input.bind(el, 'dash')      // any element of yours is a button (or 'click' / 'rightClick'); returns the unbind
// raw keys ("keyboard")
input.keys.down('ShiftLeft'); input.keys.pressed('KeyR'); input.keys.latest(['Digit1', 'Digit2'])
input.keys.down('Shift'); input.keys.shift / .ctrl / .alt / .meta   // either side
KEYS.move / .action / .upDown / .leftRight / .mouse / .click / .rightClick / .wheel   // title-card labels
```
Reaction and rhythm games judge every press this frame on its exact time, not the frame's:
```ts
for (const p of input.pressTimes('action')) judge(p.at);
```
The platform calls `input.endFrame()` after your `update`. Space/Enter (a pad's A or MENU) also ready up on the
title card; that's fine because you only read input while `flow.live`.

## 5. The link

```ts
link.mode         // 'minigame' (a board party's round) or 'session' (a room that plays just this game)
link.players[i]   // { id, name, colorIdx, control: 'local' | 'cpu' | 'remote' }, same order as ctx.players
                  // live in sessions: a new array whenever someone joins or leaves
link.onPlayers((players, joined, left) => …) → unsubscribe   // or the GameStage.onPlayers hook
link.you          // your player id, or null (spectator, or a bots-only run)
link.isHost       // runs CPUs (and the world in host-authoritative games); true offline; can change mid-game
link.online       // false offline, in vp dev and in vp check
link.seed         // identical on every client
link.now()        // synced server clock, ms
link.startAt, link.endsAt   // endsAt is Infinity in an untimed session
link.mg           // { id, mode, maxMs? (absent = untimed), players: { min, max }, input, name?, pkg? }
link.sendState(pid, blob) / link.latest(pid) / link.sample(pid, { delayMs, angles })   // prefer PlayerSync
link.sendEvent(type, data, to?) / link.onEvent((from, type, data, at) => …) → unsubscribe  // prefer HostSync for host one-offs
                                              // to: a player id or ids: only their clients get it (a joiner's world)
link.reportScore(pid, score)   // prefer flow.end / flow.finish
link.keep(world) → size        // host: a full copy of the world (JSON, ≤ KEEP_MAX = 64 KB) for whoever hosts next; latest wins, sent ≤ 1/s
                               // returns its size in characters of JSON: over KEEP_MAX it isn't kept (packInts/packBytes shrink it)
link.onKept((world, at) => …) → unsubscribe   // taking over as host mid-run: restore from it (validate it)
                                              // prefer HostSync's keep option (netcode.md §8)
```

## 6. Randomness and maths (`/core`)

```ts
type Rng = () => number;                 // 0..1
mulberry32(seed): Rng                    // the seeded PRNG: mulberry32(link.seed ^ 0x51ed) for a named stream
hash3(x, y, z): number                   // stable 0..1 per integer coordinate (texture/terrain variety)
noise2D(rand): Noise2D                   // seeded smooth noise, (x, y) => about -1..1: terrain, wobbly edges, Island's `noise`
clamp(v, a, b), lerp(a, b, t), smoothstep(a, b, x), lerpAngle(a, b, k), r100(v)
```
Use `noise2D` rather than adding `simplex-noise` yourself: it's the same noise, seeded from your rng.

## 7. Netcode (`/core`)

`Seats`, `PlayerSync<T extends object, E>` (with `event`/`onEvent` one-offs, `onReset` and `onReport`), `HostSync<S, E, A, X>` (with `read()` → `{ fresh, latest, at }`;
actions `act`/`pending`/`predict`/`onReject`, secrets `tell`/`secret`/`onSecret`/`secretOf`, timed events
`schedule`/`due`, and `flush`: netcode.md §11),
`followReport(from, report, maxSpeed, dt, slack?)`, `Reconciler({ trail, dist, time, canSnap })`
(with `check`, `record`, `reset`), `Predictions<K>`, `END_EVENT`, `GameFrame`, `Lockstep<W, O>`
(deterministic lockstep for order-driven games: RTS, tower defense, snakes; netcode.md §10), and
`packInts`/`unpackInts`/`packBytes`/`unpackBytes`/`KEEP_MAX` (compact JSON for kept worlds and
snapshots). Signatures and patterns are in **netcode.md**. A world of blocks everyone builds and
breaks syncs with `WorldSync`: **world.md** §7.

## 8. Bots (`/core`)

Pieces to compose your own `Bot` class; not a base class. Bots run only where
`seats.role(i) === 'bot'` (the host) and take a seeded rng, never `Math.random`, so a test replays
the same match.

```ts
botRng(seed, salt = 0xb07): Rng          // the bots' own stream: botRng(link.seed)
forkRng(rand): Rng                       // a child stream, e.g. one per bot
skillFor(skills, n)                      // round-robin: skillFor(BOT_SKILLS, bots++)
jitter(rand, base, spread)               // base ± spread·base
between(rand, min, max)

new ThinkTimer(period, rand, spread = 0.2)   // .tick(dt) → true when it's time to re-plan; .now(); .delay(s); .left
new Reaction(rand, min = 0.18, max = 0.45)   // .update(dt, cueVisible) → true once the cue has shown for its reaction time
new Wobble(rand, rate = 2.1)                 // .update(dt); .offset(amount) → sideways weave for steerTo; .phase

// Steering returns a Stick { x, z }, the same shape as input.move(); pass `out` to write into your own object
steerTo(x, z, tx, tz, { weave?, stop?, slow? }, out?): Stick        // unit stick towards a target
driftToCentre(x, z, phase, { cx?, cz?, pull?, wander?, rest? }, out?): Stick
avoidRim(x, z, stick, radius, margin = 1.1, strength = 1.6): Stick  // push inward near a round edge
bestDirection(x, z, score, { dirs?, step?, stay?, stick?, current?: Stick, resolve?, substeps?, shortfall? }, out?): Stick
  // try a ring of short steps, score where each lands (higher is better): obstacles, fleeing, edges, no pathfinder
nearest(items, cost): T | null
new StickyTarget<T>(rand, { ratio = 1.3, hold? })   // .pick(items, score) keeps its target unless something is clearly better; .current; .drop(); .update(dt)

// grids (cells are x + z·w)
gridSearch(w, h, start, { enter(to, from, t), step?, jumps?, cap? }): GridSearch   // BFS with arrival times
firstStep(search, goal) / pathTo(search, goal) / floodCount(w, h, start, open, cap?) / DIRS4
ranksFromKnockouts(count, knockedOut: number[][]): number[]      // 1 is best
scoresFromKnockouts(count, knockedOut: number[][]): number[]     // -rank: pass straight to flow.end
```

A typical bot (from Star Catch):
```ts
import { ThinkTimer, Wobble, steerTo, type Rng, type Stick } from '@voxelparty/sdk/core';

export class Bot {
  private think: ThinkTimer; private wobble: Wobble; private target: Star | null = null;
  constructor(rand: Rng, private skill = 1) { this.think = new ThinkTimer(0.35 / skill, rand); this.wobble = new Wobble(rand); }
  update(dt: number, me: Fighter, stars: readonly Star[]): Stick {
    this.wobble.update(dt);
    if (this.think.tick(dt) || (this.target && !stars.includes(this.target))) this.target = this.pick(me, stars);
    const t = this.target;
    return t ? steerTo(me.x, me.z, t.x, t.z, { weave: this.wobble.offset(0.25), stop: 0.15 }) : { x: 0, z: 0 };
  }
  private pick(me: Fighter, stars: readonly Star[]): Star | null { /* the star it can reach soonest */ }
}
```
The world's `move(f: Fighter, stick: Stick, dt)` then takes a human's `input.move()` and a bot's
stick alike. If an intent carries more than a stick, spread it:
`{ ...input.move(), dash: input.pressed('action') }` and `{ x: 0, z: 0, dash: false }` for a bot.

First-person CPUs walk voxel maps with `NavGrid` and `PathFollower` (and return an `FpsIntent`,
the shape `readIntent` gives a human): **fps.md**. `new NavGrid(grid, { tuning, spacing?, radius?,
clearance?, step?, jumpUp?, drop?, links? })` or `await NavGrid.build(grid, o)` (a slice at a time;
`NavGrid.start` + `work(ms)` to drive it); `nearest(x, y, z, { above?, below?, radius? })`,
`path(a, b, { budget?, partial? })`, `ready`, `partial`; `new PathFollower(nav, { reach?, flight?,
budget?, partial? })`; `stickFor(yaw, x, z, { unit? })`.

## 9. Players

```ts
const av = new Avatar(mats.actor, player.look, { scale: 0.62, turn: 16, tag: { text: 'P1', color: player.color } });
scene.add(av.root);
av.teleport(x, 0, z, yaw);     // start, respawn, reset: jump instead of gliding
// every frame:
av.set(f.x, f.y, f.z, f.yaw);  // the simulated pose (yours, a bot's, or a remote report)
av.update(dt);                 // glides av.root there, adapting to how often updates arrive
av.shown                       // the pose on screen: aim effects at it; send it in host snapshots for remote players
av.speed                       // ground speed on screen: drive walk bobs with it
av.char.body                   // animate hops, squash, sway, lean here; av.char.root for spins, KO shrink, rings; av.char.height ≈ 1.78 (unscaled)
```
`AvatarOptions`: `scale` (arena games 0.62), `tag` (a label over the head: give it only to your
own seat), `teleport` (jump distance that snaps, default 3), `turn` (yaw easing rate; 16 feels good).
`PoseSmoother` is the same smoothing without a character (three-free).

Dressing up, looks and bodies (all optional; details in **art.md** §5):
```ts
av.wear(HAT, 'head');                   // a Volume (or any Object3D) at 'head' | 'hand' | 'offhand' | 'back'
av.anchors.hand.add(knife);             // or hang your own; anchors bob with the body, move with setLook
av.unwear(item) / av.unwear();          // one thing, or everything
av.tint = '#6a4cff'; av.tint = null;    // multiply the whole body (worn Volumes too)
av.opacity = 0.35;                      // ghosts, spectators (0 hides it)
av.flash('#ff4040', 0.15);              // a hit: a glow that fades
av.setLook(look | volume, voxel?);      // a new body in place: disguises, props, a werewolf
new Avatar(mats.actor, volume, { voxel: 1 / 12 });   // any voxel model as a player's body
av.dispose();                           // out of the scene; frees its body, worn Volumes, tag, own material
```
`characterVolume(look)` is a built-in character's voxels (edit one, or crowd them).

Crowds (**art.md** §5): hundreds of animated units, two draw calls a kind.
```ts
const army = new Crowd(mats.actor, FOOTMAN, { team: [ids.TEAM] });   // a Volume, a Look, or { body, team?, height }
scene.add(army.root);
for (const u of units) army.draw(u.id, u.x, 0, u.z, { team: TEAM_COLORS[u.team] });   // every frame
army.update(dt);                        // walk bob + lean, facing, pop-in, deaths of the undrawn, upload
army.attack(id); army.hit(id, color?); army.remove(id); army.has(id); army.count
army.put(matrix, team?, color?)         // one copy this frame, posed by you (no id, no animation)
```
`CrowdOptions`: `voxel`, `team` (block ids or a test), `cap`, `shadows` (`'body'`: not the team
part), `die` (`'topple'` | `'sink'` | `'shrink'` | false), `dieTime`, `bob`, `lean`, `lunge`, `pop`,
`turn`, `stride`. `CrowdDraw`: `yaw`, `team`, `color`, `scale`, `matrix`.

## 10. Stage, island, camera

`arenaStage(engine, StageOptions)` → `{ scene, camera, tilt, insets, grade, sun, rig, sky, clouds, pollen(), sunAt(), xray(at, { radius, floor }), light(LightOptions), blockLight(vol, BlockLightOptions), update(dt, t), dispose() }`:
a `GameView`, so it's what `view` holds (`insets` starts empty: push a `Minimap` or any `Inset`);
`new Minimap({ rect?, area?, centre?, heights?, scene?, round?, resolution?, every?, rotation? })`, a top-down
`Inset` with `fit(w, d)`, `centreOn(x, z)`, `follow(point | null)` and a settable `rect` and `rotation` (which way is up, radians clockwise from north);
`engine.icon(object, IconOptions)` for pictures of models;
`new Island({ rand, mats, size, land, noise?, top?(v, x, z, r, isLand), decorate?(v, isLand), layers?, tint?, origin?, underside? })`
with `island.noise`, `island.isLand(x, z)`, `rectLand`, `roundLand`, `meadow`, `underside`,
`landMask(sx, sz, shape, noise)`, `addVoxelMeshes`;
`stage.sky` (a `StageSky`): `set(mood, seconds?)`, `mix(base, ...[mood, amount])`, `timeOfDay(hours, seconds?)`, `easing`;
moods are `MoodName`s (`MOODS`: day dawn dusk night storm lava dark) or `{ from?, ...Partial<Mood> }`;
`stage.light({ kind?: 'point' | 'spot', color?, intensity?, range?, angle?, softness?, shadow?, at? })` → a `PointLight` or `SpotLight` in the scene (at most `MAX_LIGHTS` 8, `MAX_SHADOW_LIGHTS` 2; make them while building);
`stage.blockLight(vol | LightPiece[], { voxel?, origin?, at?, blocks? })` → a `BlockLight`: `add(CellLight)` (`group?: 1..4` for a dimmer), `remove(l)`, `refresh()`, `at(x, y, z)`, `cell(x, y, z)` (world → cell), `gain` and `dim(group, level)` (live, no re-bake), `vol`, `field`; several volumes: `[{ vol, origin?, at? }, …]`; glowing blocks via `light` in a `GameBlockDef` (`BlockLightDef`: a colour or `{ color, reach?, strength? }`); `bakeLight(vol, lights?, blocks?)`, `lightFalloff(d, reach)`;
`stage.grade` (a `Grade`: `exposure`, `vignette`, `saturation`, `tint`), read every frame, also `GameView.grade`;
`stage.clouds` (a `CloudLayer` or null): `show()`, `hide()`, `visible`, `opacity`, `tint`, `group`;
`xray(mats, camera, at | null, { radius = 2.6, floor, occluders })` for your own camera (pass your scene as `occluders` so it only opens when something is in the way);
`underside` options include `minDepth` (default 2; 0 thins the rock out at the edge);
`new CameraRig(camera, opts)` (`opts.smooth` to glide) with `fit(width, pitch, { pad, min, depth, height, heightPad, hud })`, `follow`, `zoomOn`, `shake`,
`shakeAtLeast`, `snap`, `update`. Details and recipes: **art.md**.

## 11. Voxels and textures

`Volume`, `meshVolume(vol, { voxel, origin?, tint? })` → `{ opaque, water, cross }`,
`blockGeometry(id, size)`, `bitGeometry(id)`, `B` (built-in blocks), `GameBlockDef` (a name, `[top, side, bottom?]`, or
`{ top, side?, bottom?, turns? }`: `turns` tops follow the voxel's meta, `turnTop(dx, dz)`),
`useGameAssets(TEXTURES, BLOCKS)`, `textureDataURL(name)`. The painters are in `/core`, so a
`textures.ts` can be tested headless: `TexDef`, `Px`, `hex`, `pal`, `shade`, `mix`, `pick`, `ri`,
`maskRows`, `maskFrom`, `starMask`, `pointInPoly`, `stamp`, `flat(...)`, `planks`, `stone`,
`cobble`, `voronoi`, `dirt`, palettes (`GRASS`, `DIRT`, `STONE`, `SAND`, `PATH`, `WOOD`, `BARK`,
`SNOW`, `WATER`), `S` (16). Details and the rules: **art.md**.

**Levels as text** (`/core`, all of it in **levels.md**): `textGrid(layers, legend, { seed?, mirror?, swap?, shareSeam? })`
→ a `TextGrid` (a `Volume` and a `Structure`: `world.stamp(level, x, y, z)`, `grid.addVolume(level, at)`,
`meshVolume(level, …)`) with `marks` (typed from the legend), `mark(name)`, `char(x, y, z)`, `layers`. Legend
entries: a block id, a mark name, a column `[bottom, …, top]`, a `Structure` (a prefab), `{ block, height?, top?,
meta?, mark?, prefab?, turns? }`, or a seeded function of the cell. `gridText(anyGrid, { view?: 'top' | 'heights' |
'all' | y, legend?, area? })` prints any grid back as text. The first line is z 0 (far), x runs right, layers go up.

**Block worlds** (players place and break blocks, synced: bed-defence team battles, build contests, a mining game):
`World` (`/core`: a `VoxelGrid` of blocks with rules, damage, structures and compact forms),
`WorldSync` (`/core`), `aimBlock` (`/core`: the block you look at, reach, bridging), `blockIds`
(your blocks' ids headless), and on screen `WorldView` (chunked, re-meshed where it changed),
`BlockCursor` (outline, ghost, cracks) and `BlockFx` (block bits and sounds). The long form of a
`GameBlockDef` takes the block's rules (`hp`, `drop`, `unbreakable`, `placedOnly`, `tint`,
`solid`, `opaque`). All of it: **world.md**, and `vp init --blocks` for a working game.

## 12. Juice

Motion first: eases, tweens, springs and game time (all but `ctx.time`/`ctx.tween` are in `/core`).

```ts
// Easing: plain (t) => number curves over 0..1. By name in tweens, or call them yourself.
ease.outBack(k)            // pop in with a little overshoot; ease.outBack(k, 2.5) overshoots more
ease.smooth(k)             // smoothstep, gentle at both ends (what games hand-write as `ease`)
ease.arc(k)                // 0 → 1 → 0: a hop, a pulse, a flash
// linear smooth smoother arc, and in/out/inOut × Quad Cubic Quart Quint Sine Expo Circ Back Elastic Bounce
// (outElastic(k, period = 0.3)); makers: ease.steps(4), ease.bezier(x1, y1, x2, y2),
// ease.out(f), ease.inOut(f), ease.yoyo(f) (there and back in one run)

// Tweens: ctx.tween runs on game time (updated for you after update(), cleared at the end).
const tw = ctx.tween;
tw.to(mesh.position, { y: 2 }, { time: 0.5, ease: 'outBack' });       // any numeric fields: Vector3, Color, opacity
tw.to(mesh.scale, { x: 1.3, y: 0.7, z: 1.3 }, { time: 0.08, yoyo: true, repeat: 1 });   // a squash, out and back
tw.from(card.scale, { x: 0, y: 0, z: 0 }, { ease: 'outBack' });     // from these values to where it is now
tw.to(mat, { opacity: 0 }, 1).then(() => mesh.removeFromParent());   // options can be just the time
tw.value(0, score, 1.2, (v) => (el.textContent = String(Math.round(v))));   // anything that isn't a field
await tw.to(door.position, { y: 3 }, 0.6); await tw.wait(0.2); tw.to(...);   // sequences read top to bottom
tw.stop(mesh.position); tw.busy(target?); tw.clear();
// options: { time = 0.3, ease = 'outCubic', delay, repeat (Infinity: forever), yoyo, onUpdate(k), onDone() }
// A newer tween on the same target takes over the fields it moves (no fights). A stopped tween
// never resolves (so an interrupted sequence goes no further); stop(true) jumps to the end and resolves.
// new Tweens() for your own clock: update(dt) it yourself (e.g. with ctx.time.realDt for a HUD during a hitstop).

// Springs and feel maths
const squash = new Spring(1, { hz: 4, bounce: 0.5 });   // hz: wobbles a second; bounce 0 (none) .. 0.9
squash.kick(-6);                                          // a landing: dips and wobbles back to 1
const s = squash.update(dt); mesh.scale.set(1 / s, s, 1 / s);   // every frame; .to(target), .set(v), .settled()
damp(a, b, rate, dt)        // frame-rate-independent `a += (b - a) * k`: rate 5 gentle, 10 brisk, 20 snappy
dampAngle(a, b, rate, dt)   // the short way round
approach(a, b, maxStep)     // speed = approach(speed, max, accel * dt)
remap(v, a0, a1, b0, b1, ease?)   // clamped: remap(dist, 2, 12, 1, 0) = full up close, none from 12 away
pingPong(t, length = 1); wrapAngle(a); angleDiff(a, b)

// Game time: hitstop and slow motion, on ctx.time.
ctx.time.hitstop(0.07);                    // freeze 70 ms of real time: 0.03–0.05 light hits, 0.08–0.15 heavy
ctx.time.hitstop(0.1, 0.05);               // or nearly freeze (5% speed)
ctx.time.slow(0.25, 1.5);                  // quarter speed, easing back to normal over 1.5 s
ctx.time.slow(0.2, 1, { hold: 0.8, ease: 'inCubic' });   // hold first
ctx.time.scale = 0.5;                      // yours: half speed until you set it back
ctx.time.speed; ctx.time.frozen; ctx.time.realDt; ctx.time.cancel();
```
**What game time touches.** Your `update(dt, t)` gets game time (dt 0 in a hitstop, `t` stands
still) and `ctx.tween` follows it. The flow (title card, countdown, round timer), `link.now()`,
input and sound keep real time. It's a local effect: a host whose simulation runs on `dt` slows
it for everyone. For a freeze only this player sees, run the netcode/core on `ctx.time.realDt` and
draw with `dt`: `this.core.update(ctx.time.realDt); this.draw(dt);`.

**Effects: `Vfx`.** Spells, impacts, projectiles, auras and beams (the whole guide is vfx.md):

```ts
const vfx = new Vfx(view, { sprites?, cubes?, shells?, beams?, camera?, seed? });   // a GameView or a scene
vfx.update(dt);                                  // every frame (game time); vfx.dispose() at the end
const h = vfx.play(effect, { at?, to?, dir?, color?, scale?, ground?, follow?, followTo? });
h.move(at, to?); h.aim(dir); h.stop(); h.alive;  // loops (streams, holds) run until stop()
vfx.clear(); vfx.stats();                        // { sprites, cubes, shells, beams, draws, playing, layers }
// Headless (@voxelparty/sdk/core): VFX (the library), Effect and its layer types,
// checkEffect(e) → EffectIssue[], effectCost(e), effectLength(e), effectRadius(e).
```

`Fx` is for voxel juice: block chips, dust, confetti made of your blocks.

Then the bursts:

```ts
// Five pools, all on by default: spark (B.GOLD, cap 200), puff (B.CLOUD, 200), dust (B.SAND, 160),
// chip (B.PLANKS, 140) and confetti (the six cloth colours). Each takes a block id,
// { block?, cap?, motions?, floor?, shadows? }, or false (not built; its bursts and spawns do nothing).
const fx = new Fx(scene, mats.actor, {
  spark: { block: ids.SPARK, cap: 300 },  // or just `spark: ids.SPARK`
  chip: ids.WOOD,
  dust: false, confetti: false,           // switch off what you don't use
  // floor?: number | ((x, z, y) => number)  for every pool (a pool's own `floor` wins); y: the bit's height,
  //                                          so a floor function can return the ground below it
  // motions?: readonly Motion[]          for every pool (keep FX_MOTIONS' 4 slots, add yours after)
});
fx.sparkle(x, y, z, n, force, size = 0.09, color?)   // pickups, hits
fx.ringPuff(x, y, z, n, speed, size = 0.24, color?)  // landings, thumps
fx.dust(x, y, z, power = 0.7, color?)                // footfalls
fx.chips(x, y, z, n, speed, size = 0.14, color?)     // splinters, crumbs (color multiplies the block's)
fx.confetti(x, y, z, n, spread = 5)          // a win
fx.scaled(2.5).chips(x, y, z, 10, 3)         // the next burst only, 2.5× as big: sizes and spread × k,
                                             // speeds and lives × √k (the same arcs, bigger); counts as given
fx.spawn('puff', pos, vel, life, size, kind?)  // one custom particle: pos/vel any { x, y, z }; kind defaults
                                               // to the pool's FX_KIND; returns null when the pool is off
fx.update(dt); fx.dispose();                 // every frame / at the end
fx.pools.spark / .puff / .dust / .chip       // the Bits pools; fx.pools.confetti is one Bits per colour

const popups = new Popups(scene, { numbers: 64 });   // numbers: most on screen at once; in a flood the oldest go
popups.show(x, y, z, 'BUMP!', '#ffe066', { height: 0.7, life: 1.1, rise: 1.1, delay: 0, follow? });   // any text: a canvas label, reused
popups.number(x, y, z, -12, '#ff5a4a', { ...the same, key?, merge = 0.35, prefix? });   // damage/gold: instanced glyphs, ASCII,
                                                     // no canvas per number; the same key within `merge` s adds up
popups.update(dt); popups.dispose();

const bits = new Bits(bitGeometry(id), mats.actor, 240, { shadows?, floor?: number | ((x, z, y) => number), motions: [debris(), puff(), ember()] });
bits.spawn(pos: Vector3, vel: Vector3, life, size, kind = 0, floor?); bits.update(dt); scene.add(bits.mesh);
bits.spawn(...).color.set('#ff4040');   // a bit in its own colour (multiplies the block's; white on spawn)
// Try an Fx pool with its own motions first: chip: { block, cap, motions: [...FX_MOTIONS, myMotion] },
// then fx.spawn('chip', pos, vel, life, size, 4).
// motions: debris({ gravity, friction, bounce, fadeFrom, airDrag, lands, landBand }), puff({ drag, rise, wind }),
// ember({ lift, drag }), spark(), confetti({ drag, gravity, maxFall, flutter, flat }); custom: (p, dt, k, scale, env) => void
// with the helpers tumble(p, dt, factor), drag(p, dt, rate, horizontalOnly), fadeOut(k, from)

createParticles({ mode: 'drift' | 'fall' | 'orbit' | 'spray', points: Vector3[], colors, size: [a, b], additive?, speed?, amp? }, rand)  // GPU ambient particles
textSprite(text, color = '#fff', height = 1, { depthTest? }) / disposeSprite(sprite)   // pixel-font labels, world-sized
nameTag(text, { color?, px = 18, height?, minPx = 10, maxPx = 24, depthTest?, near?, far?, background?, anchor? })
                                  // a label that stays readable at any distance: `px` on screen, or `height`
                                  // in the world clamped to minPx..maxPx; through walls unless depthTest
tag.text = `${name} ♥${hp}`; tag.color = '#ff5a4a';   // both return a TextSprite: redrawn in place
```

## 13. UI components

**The screen is yours, except the top-right corner.** Over a game, everything the page shows
lives in one box at the top right: the settings gear, a session's Lobby and Invite buttons, and
under them, between rounds, when the next one starts, or a "did you like it?" prompt. The game's
name, its author and the Report button are inside the gear's panel. Nothing else is drawn over
your game. The box is `var(--vp-corner-w)` × `var(--vp-corner-h)` from the screen's top-right
corner: 300 × 72 px, or 240 × 64 px on screens up to 480 px wide, and 64 × 64 px on a phone (either
way up: up to 700 px wide or 500 px tall), where the page folds its buttons into one ☰ that opens
them as a panel. Between rounds of a session it can grow a line or two downwards. Both are CSS custom properties on `:root`, so your own CSS can use
them. Keep your HUD, minimap, kill feed and ammo out of that box (the top left, bottom corners and
edges are all free). Flow's HUD chips stay out of it already.

**The top centre is Flow's HUD**: everyone's chips and the timer. With many players the chips wrap
onto a second row (7+ players on a laptop, fewer on a phone), so don't hard-code how tall they are:
`var(--vp-hud-h)` on `:root` is how far down from the top of the screen they reach, in px, kept up
to date as they wrap and the window changes (0 before there's a HUD). Put your own top-centre UI
(a purse, lane labels, a round banner) under it:
```css
.my-purse { position: fixed; top: calc(var(--vp-hud-h, 0px) + 8px); left: 50%; transform: translateX(-50%); }
```
On screens up to 1000 px wide the chips reach the left edge too (put top-left panels under
`--vp-hud-h` there); up to 600 px they sit under the corner box, full width; on short screens (a
phone held sideways) they go small to keep to one row. A fitted camera can keep the field out from
under them: `rig.fit(W, 58, { depth: D, hud: true })` (art.md §4).

**Phones.** Two classes on `:root`, for two different things:
- `html.vp-phone`: the screen is phone-sized, either way up (up to 700 px wide or 500 px tall),
  whatever plays it. Lay the HUD out for a small screen under it: shrink panels, keep them to the
  edges and corners, hide the ones a phone player can do without. The chips are small badges at
  the top left then, and the page's corner is one ☰.
- `html.vp-touching`: someone plays by touch, and the touch controls take the bottom corners;
  `--vp-touch-h` says how far up they reach. Move bottom-corner HUD out of the thumbs' way:
  `html.vp-touching .my-ammo { bottom: calc(var(--vp-touch-h, 0px) + 10px); }`, and hide keyboard
  hints (`html.vp-touching .my-keys { display: none; }`).

**Your own menu button.** On a phone the page's ☰ sits in the top-right corner. A game can take that
corner too and draw the button in its own style: `ctx.flow.menuButton(false)` hides the page's ☰,
and your button calls `ctx.flow.openMenu()` to open the page's menu (New game, Lobby, Invite,
fullscreen, controls, settings). Give it a real `<button>` (taps on it aren't the game's).

**The frame's CSS is yours.** The game frame loads only a small base in the `vp-frame` cascade
layer (the colour variables `--ink`, `--paper`, `--edge`, `--shadow`, `--yellow`, `--red`, `--blue`,
`--gutter`, a box-sizing reset, the body font, `kbd` as a key cap), and nothing from the site. Any
rule a game writes wins over it, whatever its specificity, and any class name is free.

Flow already draws the HUD chips, the timer, the title and the results. For anything else:
```ts
const ui = new Ui();                          // a fixed, click-through layer; ui.dispose() removes all of it
ui.sign({ top?: '28%' | 'top', size? }).say('CATCH!', { color: '#ffcc33', sub: '2.4 kg', kind: 'big' | 'huge' | 'small' | 'wait' | 'pop', hold: 1.2 });
ui.flash(color?).go();                        // a full-screen flash
ui.hint().set('Hold [SPACE] to cast');        // [KEY] becomes a key cap
ui.meter({ variant: 'gauge' | 'bar' | 'line', zones?, needle?, fill?, danger?, label?, low?, hot? }).set(0.62);
const panel = ui.panel(); panel.hint(); panel.meter({...});   // a bottom-centre stack
ui.pill({ at: 'bottom' | 'top', bar? })       // .set(html, 'watch' | 'turn' | 'nice' | 'wait'), .setHead(html), .setCount(n, low), .setBar(v)
ui.track({ players: [{ color, me }], ticks?, look: 'dots' | 'rail' })   // race progress: .set(i, 0..1), .out(i)
ui.wait().set('1:23.4', 'Waiting for others…')
ui.board({ at: 'side' | 'centre' }).set([{ name, color, value?, bar?, tag?, me?, pop?, bad?, out? }])
hudStat(icon | null, value, bar?)             // HTML for flow.hud.setStat; icon = textureDataURL('xx_coin')
esc(text), keys('Press [SPACE]'), kbd('SPACE')
```
In-game menus (shops, upgrade screens, command cards) and a synced match setup (host options,
each player's picks, teams): `Menu`, `Setup` (`/core`) and `SetupMenu`, in `vp docs menus`. A
shop you use while playing is `new Menu({ modal: false, key: 'KeyB' })` (the game keeps its input;
B opens and closes it); `compact` cards, `columns` and `className` fit and restyle a big one.

Your own DOM: one root element with a class prefix unique to your game, styles in a `.css` file
imported from your folder (it's bundled), removed in `dispose()`.

## 14. Sound

`sound.play(def, opts)` → `Voice | null`, `sound.playSong`, `sound.panFor`, `sound.duckMusic`, and
the `Sfx` helper. The data (`INSTRUMENTS`, `SFX`, `SONGS`, `Patch`, `Song`, `Layer`, `SoundDef`,
`midi`, `mtof`, `noteFreq`, `noteName`, `parsePattern`, `checkSong`) is in `/core`, so
`sounds.ts` and `sounds.test.ts` run under bun.
```ts
const sfx = new Sfx({ you: seats.you, halfWidth: 8, rival: 0.6, rivalPitch: -2, live: () => flow.live });
sfx.at(SOUNDS.land, x);           // an arena sound, panned by world x
sfx.for(SOUNDS.hop, i, x);        // player i's sound: full for you, quieter (and lower) for rivals
sfx.play(SOUNDS.frenzy);          // gated by `live`: silent once play ends
const fire = sfx.loop(SOUNDS.crackle, x, y, z, { vol?, pitch?, fadeIn?, near?, reach? });   // keeps going at a place, levelled to an ambient bed
sfx.update();                     // every frame with loops: loudness and pan from where they are now
fire.at(x, y, z); fire.follow = kart.position; fire.bend(3); fire.stop(1);   // voice.set({ vol, pan }, glide) under it
```
Everything `Sfx` plays is gated by `live`, so a sting at the finish or on the results card goes
through `sound.play(def)` directly. `FxOptions.confetti` is a list of block ids to colour the
confetti (default: the six cloth colours), pool options with `blocks`, or `false` for none. An `Avatar` look's `shirt` and
`overalls` can be any block id, including your own game blocks from `useGameAssets` (costumes).
Props attach to `avatar.char.body`; heights scale with `avatar.char.height` (the top of the head
on the unscaled model), so check held props in the screenshots on several characters.
Details: **sound.md**.

## 15. Tests (`@voxelparty/sdk/test`)

`FakeRoom` (with `join` / `leave` for sessions), `FakeFlow`, `FakeLink`: see netcode.md.
`createSoloLink({ mg, players, mode?, seed?, countdownMs? })` is an offline link (humans 'local',
the rest 'cpu'; schedules on `readyUp()`; untimed when `mg.maxMs` is absent).
`rankScores(ids, scores)` → `{ ranking, payouts }` is the server's ranking.
`resetStorage(data?)` empties the game's saved data, or seeds it like a browser that saved `data`
last time (section 16).

Content is testable too, because its data lives in `/core`. Tests import `describe`, `expect` and
`test` from `bun:test`:
```ts
import { expect, test } from 'bun:test';
import { checkSong } from '@voxelparty/sdk/core';
import { MUSIC } from './sounds';

test('the theme loops cleanly', () => expect(checkSong(MUSIC)).toEqual([]));   // SongIssue[]: { track, steps, problem }
```
`parsePattern(pattern)` → `{ events, length }` (length in steps) is there for your own checks,
such as "the melody is 8 bars": `length === 128`.

## 16. Saving: `storage` (`/core`)

A small key/value store for your game, kept in the player's browser between plays and across new
versions of the game: personal bests, lifetime stats, unlocked cosmetics, settings, a saved level.
Sessions and board minigames alike (a personal best in a 45 s round is fine).
```ts
import { storage } from '@voxelparty/sdk';   // or '@voxelparty/sdk/core' in headless files
storage.get('best', 0)               // the saved value, or the fallback when there's none (typed from it)
storage.get<Settings>('settings')    // or undefined
storage.set('best', 42)              // anything JSON can hold; changes at once
storage.delete('best')               // true if it was there
storage.has(key); storage.keys(); storage.clear();
storage.used; storage.quota          // characters of JSON in use, and allowed
```
- **Synchronous.** The saved data arrives before your game's code loads, so read it anywhere: at
  module level, in `create()`, in a constructor. `set` changes it at once; the page keeps it
  shortly after (changes go out in batches, at most four a second, and right away when the game
  ends). Save at moments (a run ends, a setting changes), not every frame.
- **JSON.** `get` returns a fresh copy each time: change it, then `set` it back. NaN and Infinity
  come back as null, a Date as a string, a Map or Set as `{}`.
- **Limits:** 256 K characters of JSON per game (keys included; `storage.used` / `storage.quota`),
  1000 keys, keys 1–64 characters. `set` throws a `TypeError` for a bad key or a value JSON can't
  hold (undefined, a function, a BigInt, a cycle; use `delete` to forget a key), and a `RangeError`
  when it would go over a limit. Either way nothing changes. If your data grows (a list of runs),
  trim it yourself.
- **Whose it is: this player, in this browser.** Each browser keeps its own. It isn't tied to an
  account, synced between devices, or shared with the other players: in an online game every
  client reads and writes its own (so save your own player's things, `link.you`). Every version of
  your game reads the same data (published from the same account; a game shared while signed out
  gets one per version), and no other game can read it, even one with the same id. A private
  window may keep it only until the page closes. Players can delete it from the game's page (⋯).
- **Never trust it.** It's in the player's browser, so they can read and edit it. Don't use it for
  anything other players see or depend on: scores and ranks (the server ranks what you report),
  or unlocks that give an edge online. Cosmetics and your own records are fine.
- **Old data.** A new version of your game reads what the old one saved, so check the shape of
  what you get, and put a version number in anything whose shape may change.
- **Starts empty in checks.** Bots-only runs (`vp check`, the store's screenshot runs) start with
  nothing saved and keep nothing, so the game must work with an empty `storage`. `vp dev` keeps
  it, like the site does: **Clear saves** (or `?fresh=1`) starts over. Under `bun test` it's in
  memory: `resetStorage()` / `resetStorage({ best: 40 })` from `@voxelparty/sdk/test`; there's one
  per process, so every client of a `FakeRoom` shares it.

A personal best and a run counter:
```ts
import { storage, type GameStage } from '@voxelparty/sdk';

interface Stats { v: 1; runs: number; best: number }

class Game implements GameStage {
  // What this browser saw before (anything else, like an older shape, starts over).
  private stats: Stats = load();
  // …
  /** Once, when your own player's run is over. */
  private saveRun(score: number) {
    const record = score > this.stats.best;
    this.stats = { v: 1, runs: this.stats.runs + 1, best: Math.max(this.stats.best, score) };
    storage.set('stats', this.stats);
    if (record) this.ctx.flow.hud.banner('NEW BEST!');
  }
}

function load(): Stats {
  const s = storage.get<Partial<Stats>>('stats');
  return s?.v === 1 && typeof s.best === 'number' && typeof s.runs === 'number' ? (s as Stats) : { v: 1, runs: 0, best: 0 };
}
```
Show it in your own UI (`ui.hint().set('Best: ' + stats.best)`), not in a HUD chip: it's this
player's record, not something the others share.

## 17. Experimental

`@voxelparty/sdk/experimental` is engine internals and unsettled APIs. They may change in any
version, so a game that imports them may break on a later runtime. These live there (3.0 moved
`Keys` back to the stable entry, since `input.keys` is one):
- raw bindings: `BINDINGS` (use `Controls` and `KEYS`);
- the frame by hand: `Flow` (the class), `MinigameDef`, `MinigameContext`, `Minigame`,
  `MinigameResult`, `Stage`, `KIT_SOUNDS`, `MinigameMusic`, `playResults` (use `defineGame`);
- block and atlas internals: `KIND`, `K_AIR`, `K_SOLID`, `K_WATER`, `K_CROSS`, `TEX_*`, `META_TOP`,
  `HANGING`, `PAD_BLOCK`, `meta`, `GAME_BLOCK0`, `defineGameBlocks`, `GAME_LAYER0`, `MAX_LAYERS`,
  `LAYER`, `DECAL`, `layer`, `TEXTURES`, `PAD_COLORS`, `st`, `SMALL_VOXEL` (use `useGameAssets`);
- scene internals: `createCharacter`, `CHARACTERS`, `DEFAULT_LOOK`, `createClouds`, `SUN_DIR`,
  `addSky`, `particleUniforms`, `disposeTree` (use `Avatar` and `arenaStage`);
- the `SoundEngine` class, `renderOffline` and the procedural composer (`compose`, `STYLES`, …):
  write your own theme (sound.md).

If you think you need one, look for the stable way first; if there really isn't one, tell the user
(it may belong in the stable SDK).
