---
name: build-visualizer
description: Add a new visualizer (scene) to flow-state, or fix one that is failing its gates. Use when asked to build/add/write a visualizer, scene, or shader for this project, when a scene fails lint or the phase gates, or when the scene library needs another entry in some family. Covers the scaffolder, the shader contract, the personality traits, and the single-scene gate loop.
---

# Build a visualizer

A scene is **a fragment shader plus a params block**. Uniform binding, UI sliders,
per-track sampling, arc drift and every gate are derived from the schema — there
is no per-scene wiring to write.

The full reference is [HOWTO-visualizers.md](../../../HOWTO-visualizers.md). Read
it once for the contract details. This file is the *procedure*, and following it
in order is what keeps a new scene from costing several rounds of trial and error.

## The loop

```bash
npm run new:scene -- "Salt Flat" --family=minimal --traits=shape,camera,space,style
```

That writes `src/scenes/shader/salt-flat.js`, registers it, and leaves a skeleton
that already passes every gate — live, animated, seeded, distinct from every
other scene, honouring exactly the traits it declares. Add `--feedback` if the
scene will read `prev()`.

Then, in order:

1. **Write the concept comment first.** Two lines: what it looks like, and what
   makes it different from the scenes it sits beside. "No two scenes render the
   same image" is a gate with a numeric floor, not a guideline. If you cannot
   write the second line, the scene does not exist yet.
2. **Replace the `scene()` body.** Keep the skeleton's trait calls; they are
   what the casting rule is checked against.
3. **Lint.** `npm run lint:scenes` — one second, no browser, catches schema and
   shader disagreeing, undeclared uniforms, missing `rate: true`, a declared
   trait with no evidence, a dead camera, `prev()` with no base image, and
   unbounded loops.
4. **Gate the one scene.** Open
   `http://localhost:5180/checks.html?scene=Salt%20Flat`.
   Ten-ish lines: schema, renders, animates, deterministic, distinct, param
   sweep, flash rate, and one line per declared trait. This is the same battery
   Phases 2, 5 and 7 apply library-wide, filtered to your scene.
5. **Run the library gates** once at the end: `checks.html?slow=1`. Phases 2, 5
   and 7 iterate the registry, so the new scene is covered automatically.

Do not skip 3 before 4, or 4 before 5. Each step is roughly ten times cheaper
than the next and catches a different class of mistake.

## Choosing family and traits

**Family** decides which section kinds can cast the scene — a breakdown never
lands on a strobing glitch scene. Aim for 4-6 scenes per family; check the
current spread with:

```bash
node -e "import('./src/scenes/registry.js').then(({scenes})=>{const b={};for(const m of scenes)(b[m.family]??=[]).push(m.name);console.log(b)})"
```

**Traits** are a contract, not a hint. Each track is built on a signature of one
or two traits and **a scene that does not honour all of them is never cast in
that track**. Declare only what the shader genuinely uses:

| trait | call | what it means for your scene |
|---|---|---|
| `shape` | `sigShape(p)` / `sigForm(p, at, size)` | every element you draw is the track's signature form, not your own circle or box |
| `camera` | `sigCamera(p)` | your coordinate is filmed by the track's operator. **Must feed the image** — assigning it to a `p` you then ignore is a dead camera, and both the lint and the render gate will say so |
| `space` | `sigHorizonY()`, `sigAir(col, p, d)` | your ground is at the track's horizon and your distance haze is the track's |
| `style` | `sigEdge(d)`, `sigGrain(uv)`, `sigFolded(p)`, `u_sigLine/Soft/Texture/Fold` | your lines are drawn in the track's weight |

Prefer thin traits. `space` and `shape` carry the most identity and have the
fewest scenes, so they are usually where another scene is worth most.

## The five mistakes that actually happen

1. **A rate param that is not flagged.** Anything multiplying `u_time` needs
   `rate: true`, or reactivity jumps the phase by `elapsed × Δrate` and the scene
   strobes. Add a bounded term instead: `u_time * u_speed + u_bandLow * 2.0`.
2. **Whole-frame luminance on the beat.** That is the WCAG 2.3.1 failure the
   flash gate exists for. Pulse something local; quantise glitches onto
   `floor(u_barPhase * n)` so they step with the music.
3. **A declared trait the image does not respond to.** Passes the eye, fails the
   gate. Both cost the same to fix before you commit and much more after.
4. **Reading `prev()` with nothing underneath.** Black for the first frames,
   different after a seek than after playback. Always draw a base field.
5. **A contract uniform name reused as a param** (`u_width`, `u_time`, `u_seed`).
   GLSL redefinition; the only symptom is a black frame.

## When a gate fails

- **not distinct** — the closest scene is named in the output. Change the
  structure, not the palette; colour comes from the track.
- **dead/blown in the param sweep** — the sweep pushes each param to its limits
  alone. Usually a range that should not reach 0, or one that saturates.
- **flash rate over 3/s** — find the term that swings the whole frame and make it
  local or smooth.
- **not deterministic** — something is reading wall-clock or unseeded randomness;
  the lint greps for the usual suspects, but `fwidth`-style derivative tricks can
  also differ. Everything must be a function of `u_time`/`u_frame` and `u_seed`.
