lwcassid

scene-craft

Build or revise a scene (visual+sound instrument) for the SOURCE Interaction Library — the design laws, scrim-projection rules, instrument criteria, interaction/sound conventions, and the verification checklist. Use whenever creating a new scene, making a new version of an existing scene (V2/V3/...), tuning visuals or audio, or evaluating whether a scene belongs in the installation set list.

lwcassid 0 Updated 5d ago
GitHub

Install

npx skillscat add lwcassid/source-scenes/scene-craft

Install via the SkillsCat registry.

SKILL.md

Scene Craft — how SOURCE scenes are built

THE EXPERIENCE (why any of the rules below exist)

A dark room on playa. A stranger walks to a pedestal, moves two hands in the
air, and the room answers — light on fabric, sound in the body — instantly
enough that they KNOW it's them. Every scene serves four listeners at once:

  1. The stranger — proof of agency inside one second. Sound is faster than
    sight; if the first gesture doesn't audibly answer, we lost them.
  2. The player — the instrument carries the musicianship (key, grid,
    voicing); no wrong notes exist. What's left is INTENTION — timing,
    phrasing, restraint — so there is something to get good at.
  3. The room — most people present aren't playing. The scene is their
    atmosphere: beautiful unattended, quiet enough to talk over when small,
    and it makes the player look like a performer.
  4. The musician who sits in — in key, on our clock, with rhythmic and
    frequency room deliberately left EMPTY for them.
    The bar: satisfying as fuck to play, professional-sounding, legible on mesh
    scrim at night. An instrument, not a game; a song, not a screensaver.
    When a rule below conflicts with this section, this section wins.

Every scene is a VISUAL + SOUND INSTRUMENT played by two theremin hands.

The design laws (learned the hard way — don't relearn them)

  1. One mechanic, one second. A stranger walking past must understand
    "my hands are doing THIS" within one second. Every mechanic added past that
    point killed the magic of an earlier version. Revisions should SUBTRACT.

  2. Vespers-tight coupling. Hands couple to visuals continuously and fast
    (smoothing ≈ dt*6+). Nothing the hands do should wait — quantize EVENTS
    (bud/strike/note) to the grid, never the continuous response. A hand whose
    only effect lands at the NEXT grid event is illegible ("what does the left
    hand do?" — White Study V5): give every hand at least one continuous,
    immediate coupling, visible AND audible.

  3. Dark mass, luminous edge. True black background, always. Light is a
    spend. White reserved for actual light sources. Full-mesh wireframe is
    banned as a default — structure exposure is an EVENT (beats, transitions).
    Same for color floods: a full-canvas invert/tint is TOO EXTREME (Lance, on
    White Study's pink flip) — confine the treatment to a bounded window,
    bar-lock it, and telegraph it a beat ahead. And hue must come from the
    FORM — a structural field (density band, freshness) — never from screen
    position: a spatial gradient reads as a filter laid over the picture
    (AV5's left-right split → AV6's organism palette, Lance). And when a
    player EARNS a way to summon a beloved autonomous event, keep the event
    visiting uninvited now and then anyway — ownership is priority, not
    exclusivity ("what happened to the pink accents?" — Lance, WS V7→V8).

  4. No hand-colour or hand-role convention (Lance, Sep 2026). There is
    no "left is warm, right is violet" and no "R is always the crescendo" —
    an old per-scene choice had crept in as a law and it limits creativity;
    each scene maps its hands and colours however serves it. Intensity
    DIRECTION is likewise a judgement call: inp arrives lean-in = more,
    but invert with 1 - inp when reaching out should intensify. The real
    law is the FIELD EDGE: the boundary where tracking appears/disappears
    must be a gentle state, never a cliff — a hand entering mid-field must
    not slam the scene to max, a hand drifting out must not cut off at peak.
    If a mapping parks maximum at the sensor's edge, slew entries and decay
    losses.

  5. Presence gates everything. chan.X.mode === 'live' → smooth s.pres.
    Idle = ambient tease that hints the interaction without giving it away;
    abandoned scenes drift back to rest states. But NEVER yank the
    instrument back: a hand held still reads as absence to the tracker, so
    hold the player's last pose and drift to rest only after real absence —
    above all inside an unlocked payoff window (Lance, EH V13). The input
    core enforces this globally now, and THE LAST POSE IS THE REST POSE
    (Lance, Aug 31, replacing the old hold-then-melt-to-1): confirmed
    absence rewinds past the exit sweep and holds the player's pose
    INDEFINITELY — no melt, because no melt target is neutral for both
    polarities (hand-space 1 is quiet for NEAR=MORE and MAXIMUM for every
    scene that inverts at its own gate, so the melt surged half the
    favorites on every walk-away). The scene's presence gate owns the fade
    to its designed idle; a scene never needs its own exit handling on inp.

  6. Bar-quantized structure. Count changes, buds, entrances land on the
    downbeat (Schmitt thresholds + the audio tick commits at st === 0).
    Anticipation beats surprise: telegraph the downbeat (pre-glow the next gate).

  7. Integration must be visible (Lance, AV2→AV3). Drive sound from the
    drawn thing itself, never from a hidden measurement (a Lyapunov exponent,
    an occupancy stat) — a mapping the player can't see reads as random. AV3's
    fix is the pattern: the same orbit that draws the figure plays the notes.
    Corollary: no hand position may read as dead — turn collapse states into
    designed payoffs IN THE SCENE'S OWN MARK LANGUAGE, or curate them out of
    reach. Intensify the native marks (halo the dust); never swap in a
    different sprite (AV3's lantern orbs lost the dust's elegance — Lance).

  8. Volumetric fluff: billow the density, never the light (Nima, Cloud
    Steam V1).
    Plain fbm is smoke; folding each octave (1-|2n-1|) into
    rounded ridges is what makes cauliflower. But those folds are creases —
    shade from a SMOOTH twin field, or the directional derivative that gives
    you the volume turns every fold into a hard seam and the noise lattice
    into blocky artifacts (use a real hash; fract(p.x*p.y) shows its grid).
    Two more that decide whether a soft mass reads: taper the erosion with
    DEPTH so a loud moment carves the boundary instead of punching holes
    through the core, and put the shadow stop near BLACK — a dim tinted
    fringe on black is mud, and on scrim it is mud nobody can see. Watch the
    billow's MEAN: folding a smoothed noise puts it near 0.73, not 0.5, so a
    threshold written against 0.5 covers the whole frame. Rescale linearly
    around the real mean — a smoothstep remap crushes a narrow field into
    camouflage.
    A DRAWN SHAPE CANNOT BE A CLOUD (Nima, Cloud Steam V3). Anything whose
    edge is smoothstep(threshold, density) HAS a silhouette by construction —
    soften it and you get a billiard ball, sharpen it and you get torn paper,
    and no value in between is a cloud. Nothing in this repo raymarches, so
    when a scene needs real volume, take IQ's dynamic-clouds model: step a ray
    through a 3D fbm field compositing front-to-back (sum += col*(1-sum.a)),
    light it with ONE cheap sample a short step sunward, shear the octaves with
    his mat3 so the fbm doesn't line up on the axes, and mix each SAMPLE toward
    the far tint so aerial perspective lives in the colour. Softness stops
    being a parameter and becomes what accumulation does. Flying through is
    then one uniform.
    Its budget is a real design constraint, and three plausible savings are
    traps. What works: ONE density function with a SHARED PREFIX — compute the
    first two octaves once, and if even the most the rest could add still
    leaves the sample under coverage, it is provably air; that was 6x. What
    fails: a separate coarse probe (pays for those octaves twice, loses in a
    dense sky); empty-space STRIDING (proving THIS sample is air says nothing
    about the next, so the stride lands inside a cloud and facets every
    silhouette); and ray jitter (white noise speckles with nothing to
    accumulate it across frames, an ordered pattern becomes a halftone once the
    internal buffer scales up to the show frame). Measure against a scene that
    already ships — a frame-time probe on the same rasterizer is the only
    number that means anything.
    Flying THROUGH the stuff (Nima, Cloud Steam V2) is a different build:
    DISCRETE masses with positions and depths, sorted near-first and
    composited front-to-back — the occlusion is what makes it flight instead
    of a zoom. A stack of full-screen noise sheets at stepped scales cannot
    do it at any density: sparse enough to leave sky, they tile into lace;
    dense enough to read as cloud, they composite into a whiteout. Erode each
    mass in its OWN normalised space so a near one shows the same detail as a
    far one, only bigger, and weight its fine octaves by SCREEN size or the
    distant ones alias into gravel.

  9. A gradient wash is ONE FILL, never a fan of strokes. Light bleeding
    off a shape = a single continuous gradient fill anchored to the shape's
    edge; N discrete gradient strokes read as a bar chart (Cable Strum V1's
    curtain — Lance's verdict). Gate echo/trail treatments by motion, so the
    resting state stays one clean object and the treatment never muddies it.

  10. Texture is LAYER ORDER, not more light (Nima, Penrose Bloom V7).
    To make a drawn thing look painted rather than plotted, put the paint down
    FIRST on its own canvas and draw the line over it with source-over.
    Compositing paint and line additively piles every overlap to white and the
    colour goes with it — a scene can be entirely lighter and still need one
    source-over seam. Three cheap moves buy most of "hand-made": a granulation
    pattern laid over the paint layer with source-atop (it darkens only where
    paint already is and can never fog the black — a multiply over the frame
    would), a per-cell OFF-REGISTER nudge of a few percent so the fill misses
    its own outline, and a stable per-cell line-weight hash. Keep procedural
    paper FINE and low-contrast — per-pixel tooth under a gentle mottle; clumped
    into 100px blobs it reads as camouflage. And ctx.filter='blur()' costs its
    DESTINATION's pixels: blur the small offscreen, then upscale, never blur on
    the way onto a 1920x1200 stage.

  11. A scene is a FULL-FRAME FIELD that obeys the hand instantly (Lance,
    Temple set V1→V2).
    Five small objects behind unlocks (hold still 2 s,
    settle a ring on a bar line) were "not dynamic, not expressive — they
    suck": an unlock is a wait, and a small object is a screensaver. Vespers,
    Ridge Loom and Lumen Film are the bar: the whole picture is one field, the
    hand changes what it is MADE OF, now, and the sound rides the light.
    Harness gotcha that cost a round: tools/shot.mjs takes hand values as
    reach-outward = 1, scenes read lean-in = 1, so 0 in a shot spec is the
    scene's MAXIMUM.

Scrim rules (the projection surface is mosquito-net mesh)

Black is invisible — only light exists, floating in the room. Thin lines and
fine detail VANISH (fatten strokes 3×; band/ring width ≥ ~8px at 1080p).
Perspective/camera scenes shatter across segmented drapes — no horizons, no
roads; radial and field compositions survive slicing. Slow-to-medium motion;
the fabric punishes velocity. Two projectors overlap → bright elements get
depth-echoed: fields, swarms, rings benefit; text and frames die. Saturated
hues; mesh eats ~half the light.

The frame is 1920×1200 (16:10) — compose for THAT

The show is ONE WUXGA render, fullscreen, cloned to both PT-VMZ50s off the
splitter: the scene is handed P.w=1920, P.h=1200, aspect 1.60, and
areaScale(P) = 4.56. A browser window is nothing like it — windowed, the
stage is a ~1280×400 letterbox strip (aspect 3.2, areaScale 2.1), so a scene
tuned there is composed in a frame it will never play in and at HALF the
density it gets live. Two consequences:

  • The projector frame is the default. The focus stage and the tile
    thumbnails render 16:10 everywhere (stage = exactly 1920×1200, letterboxed
    into the window); the harnesses (tools/shot.mjs, tools/playtest.js)
    shoot that frame. ?win or P on the stage opts out when you want a
    native-window canvas. The DBG strip's FRAME line tells you what you've
    got — 1920×1200 · 1.60 · PROJ is the show.
  • Write geometry that reflows: fractions of w/h, Math.min(w,h) for
    radii, areaScale(P) for counts. Never hardcode pixel sizes or element
    counts tuned to one window, and never let a composition depend on a wide
    strip (a horizon band 400px tall reads as a stripe at 1200).
  • A radial composition scales off min(w,h), never hypot(w,h) (Nima,
    Penrose Bloom V5).
    hypot is dominated by WIDTH: a disc sized to it looks
    right at 16:10 and runs off the top and bottom of any wider window, while
    the HUD still reports a modest radius, so you debug the wrong number.
    Corollary for "too big, make it denser": add a subdivision level (smaller
    cells, more of them, structure preserved) and SHORTEN the radius — do not
    just shrink, or you lose the layers you were asked to keep.
  • Deform a MESH by position, never by cell (Nima, Penrose Bloom V6's
    elasticity).
    To make a tiling/lattice stretch without tearing, displace
    every vertex by a pure function of WHERE IT IS — two cells sharing a corner
    then move it identically and the sheet stays welded. Cap the amplitude so
    the displacement's gradient stays under 1 (amp * waveNumber < 1) or
    shells cross through each other and the mesh turns inside out.
    Then spend that budget on FEW, TALL waves, and shear as well as swell
    (Nima, Penrose Bloom V9).
    V6-V8 spent it backwards — many short waves at
    a third of the legal height — and the control was invisible for three
    rounds: a corrugation finer than the tiling it deforms is grain, not
    motion, and on scrim it is nothing. Halving the wavenumber doubles the
    amplitude the law allows, so fewer waves are also BIGGER ones. And a purely
    radial map only scales cells, which the eye reads as a zoom; displacing the
    ANGLE by a function of radius shears them instead — still a pure function
    of position, and shear leaves the Jacobian determinant alone, so it cannot
    fold the mesh at any amplitude. It is the free half of the budget and the
    half that reads as rubber. Let the hand set the wave's SPEED as well as its
    height, or the control is invisible whenever the picture is already moving.
  • A RATE is a weak control; a STRUCTURE is a strong one (Nima, Penrose
    Bloom V10).
    Spin speed held a hand for nine versions and read as nothing:
    changing how fast an already-moving thing moves has to be compared against
    memory, while changing WHAT THE PICTURE IS MADE OF is read on arrival. When
    the geometry is self-similar, its RECURSION DEPTH is the free high-contrast
    axis — one throw took Penrose Bloom from 130 tiles to 2330. Dissolve
    adjacent depths EQUAL-POWER (sqrt), never linearly: two half-alpha strokes
    over each other come to 0.75, so a linear crossfade dips exactly in the
    middle of the throw, where the hand spends its time. It reads as
    subdivision rather than as a dissolve only because the coarse lattice is a
    SUB-lattice of the fine one — check that before crossfading two scales.

Instrument criteria (score every scene 1–5 before and after work)

IMM immediacy (gesture→sound NOW) · EXP expressive range (two hands mean
different things; pitch/density/timbre to command) · JAM jam-ability (in key,
on grid, leaves rhythmic+frequency room for live players; stable voices) ·
SAT satisfaction/skill curve (juicy payoff + something to master) ·
SCR scrim (rules above). docs/INSTRUMENT-SURVEY.md scores all 35.

Sound conventions

  • Use H for ALL pitches (H.chordTone(deg, oct), H.rootFreq) — never raw
    frequencies; re-glide voices in H.onChord(...). Stay on the chord ladder
    and any cascade stays musical.
  • Grid: schedule inside tick() with nextT/step16 horizon ≈ A.t()+0.15
    (copy an existing scene's loop). Subdivisions step with intensity:
    whole → 8ths → 16ths are EARNED, not defaulted.
  • Roles → MOut.evNote(role, freq, vol, at, dur) on every musical event so
    Ableton mirrors the browser. MOut.expr(role, v) streams CC74 energy.
    Note-offs are managed by MOut's pump — never hand-schedule them.
  • Sound design direction: minor/modal, sub root under everything, long-tail
    reverb so single gestures bloom, silence between events (silence is what
    makes thunder work). Ableton recipe lives in docs/SCRIM-SURVEY.md §Sound.

Audio-reactive scenes (a scene can LISTEN instead of playing)

reg({audioIn: true, ...}) gets inp.audio = {level, bass, mid, treble, onset, pan} from a real mic/line-in instead of (or alongside) the hands —
AUDIOIN (parts/part2e_audioin.js, ADR-0009). Cell Front V5 (SRC-43.5) is
the reference implementation; read it before building a second one.

  • Engine law vs scene verdict (Lance, Aug 2026). Below, only the rules
    about the ENGINE and the MEDIUM are global — kick unsmoothed + back-dated,
    hysteresis before anything that gates a note or rebuilds geometry, no
    full-frame strobe. The rest (which band owns which job, paint caps,
    hands-as-gamma) are verdicts scoped to the scene they were learned on:
    consult them as precedent, then make the call that serves THIS scene's
    mark language — don't inherit a sibling's taste call as law.
  • level/bass/mid/treble are already engine-smoothed 0..1 — don't
    re-smooth them, but DO still ease them into your own state the way inp.L
    gets eased (s.bass += (audioBand - s.bass) * dt*6), same as hands.
  • onset is a raw, un-smoothed pulse — detect the RISING EDGE yourself
    (onset > 0.7 && prevOnset <= 0.7, then store prevOnset) for anything
    that should fire once per hit, not once per frame it stays above threshold.
  • Hands = sensitivity, not a competing value (Nima, on Cell Front V5).
    Don't blend a hand into the audio band with Math.max(audioBand, handValue) — that was V4's choice and it broke: a hand frozen mid-value by
    the wall's ambient ghost-drift (mode 'drift' HOLDS wherever drift last
    left it, it does NOT reset to neutral) reads as a permanent, wrong audio
    level with no way to tell it apart from a real signal. Make the hand a GAIN
    on the band instead — sens = SENS_BASE + clamp(inp.hand) * SENS_RANGE,
    then target = clamp(audioBand * sens) — so reach controls how reactive
    the scene IS, a stale hand value just leaves sensitivity near its base
    floor instead of lying about the signal, and a performer can visibly "tune"
    the picture's touchiness live without ever overriding what the mic hears.
    Silence still gets the same idle-breathing drift every other still scene
    gets (Math.max(idle*(1-pres), clamp(audioBand*sens))), never true
    stillness.
  • A sensitivity hand is a CURVE, not a multiplier (Nima, Spectrum Halo V2).
    clamp(band * gain) is barely a control: on loud material the top of the
    throw saturates at 1 and does nothing, and the bottom only ever scales
    down. Use a gamma on the already-normalised band — pow(band, 1/sens) —
    so the whole throw acts, the biggest change lands in the quiet-to-mid range
    where music lives, and nothing clips early. Apply it AFTER the idle floor
    so the hand still sizes the resting breath with nothing connected. And
    never derive COLOUR from the shaped values: a gamma does not preserve band
    ratios, so a spectral tilt computed downstream turns the sensitivity hand
    into a hue control. Take colour off the raw balance.
  • Give the beat ONE job, and make it motion (Nima, Cloud Steam V4). A
    kick wired to brightness reads as FLASHING, not as rhythm — and a kick that
    also moves a global coverage/density term flashes the whole frame a second
    way. Let the beat drive PACING (a forward impulse, a lurch, an advance) and
    nothing else; leave every band on a slow envelope so shape, growth and
    colour can never move on a beat. Check it structurally — grep that the kick
    envelope is read in exactly one place — because comparing an on-beat frame
    against an off-beat one CANNOT separate a flash from ordinary motion, and
    will show a large delta either way. AUDIOIN.kickBpm is free tempo: let it
    set the cruising rate so a faster track is a faster scene.
  • Two clocks: the kick swells, the bands size the field (Nima, Cell
    Front V9).
    On techno/house every band is busy at once, so a shape whose
    size chases its own band (attack ≥ ~8/s) twitches on every note — that
    read as JITTER. onset is the engine's bass-rise kick detector: let its
    rising edge be the ONLY fast size move (per-element envelope, instant up,
    ~a beat down), and ease bass/mid/treble at ~1.5-2.5/s into a FIELD scale
    that sizes the whole ensemble, with each element's share bending only
    gently (±25%) toward its own band. Derive any "reform on change" flux
    from the slow bands, not the fast ones.
  • The kick is inp.audio.kick, not onset (Nima, Cell Front V11).
    onset is the frame-polled FFT rise — ~60ms late and it fires on
    bassline notes. kick = {t, strength, n} is the engine's time-domain
    LP150 scanner: sample-accurate t on the audio clock (inp.audio.now),
    a NEW hit is n CHANGING (never truthiness). Apply it UNSMOOTHED (a
    per-frame multiplier on a slowly-smoothed base, never through the base's
    attack filter) and back-date the response by now - t (+ a display
    LEAD) so the frame is right for the vsync it lands on. Keep an
    onset-edge fallback for the test hook; setAudioKick(strength) fires
    one hit in harnesses. node tools/kicktest.mjs measures it — read the
    exact series (poll latency there is the headless frame interval).
  • Hand SPEED is an input in its own right (Nima, Cell Front V12). On an
    audio-reactive scene the hands' position may have nothing left to say;
    |d(inp)/dt| into a snap-up / ~2s-fade envelope makes a gesture paint and
    stillness leave the scene alone — no stale ghost-drift value can lie
    about it, because a held value has zero velocity. Palette borrowed from
    a sibling scene (Ridge Loom's violet / orange / cyan) is allowed; drop
    the accent stop when the ask says so. CAP the mix at ~60% (Nima, Spectrum
    Halo): at full strength one fast gesture repaints the entire picture and
    erases whatever the spectrum was saying — the same mistake as a
    full-canvas tint. Paint is an accent over the form's colour, not a
    replacement for it.
  • No sound of its own is not a requirement — a scene can listen AND still
    have an audio() block. Cell Front V5 just doesn't, because there was
    nothing left to say once the picture was the instrument's answer.
  • A second listener must not be the first one again (Nima, Penrose Bloom
    V2).
    Before writing an audio-in scene, read the ones already in the set
    and take a DIFFERENT job for each band. Cell Front owns "three pockets,
    one per band, hands paint the palette"; CLOUD STEAM owns "the spectrum
    CARVES the mass — treble weights the fine octaves, loudness only sizes it,
    and a big rise OR fall condenses the whole thing into a heart"; Penrose
    Bloom owns "loudness is SIZE (the growth front), spectrum is COLOUR (centroid tilts the ramp, the
    mid/treble balance re-deals which tile class takes which stop, quantised
    and held so it steps on a chord change instead of shimmering)"; Spectrum
    Halo owns "band = HARMONIC ORDER of one closed curve, and the curve is
    stamped into a long exposure so the last 3s of the track stands still".
    Same engine, same palette, three instruments — not one scene three times.
  • A trail only reads if the thing MOVES inside the trail's window (Nima,
    Spectrum Halo).
    Accumulating N past states is the cheapest way to make an
    audio-reactive picture smooth — the frame is an integral, so no band can
    jitter it — but a shape whose phases drift at 0.03 rad/s stamps 96
    IDENTICAL copies and the stack collapses to one line. Size every drift rate
    off the exposure LENGTH (each harmonic should precess ~1-2 rad across the
    window) and give the radius an intrinsic multi-rate breath, so a held tone
    still lays down strata. Then keep the newest state from burning white: fade
    its alpha as the stack deepens, or the accumulation you paid for is
    invisible behind one bright leading edge. And AT REST the thing stops
    moving at all, so a trail scene needs an explicit rest term that opens the
    shape and turns it FASTER — never bigger: rest must sit smaller than a loud
    track, or the music shrinks the picture. Cost is real — halve the tail
    (draw every 2nd old stamp at double alpha) before shipping. And if a later
    round ENLARGES the figure — bigger, deeper-lobed, faster-drifting — pay for
    the extra area with per-stamp ALPHA, or the same light spread wider is a
    dimmer picture with a higher MEAN: the gain lands in low-contrast fill,
    which is exactly what mesh eats. Alpha lands superlinearly in the overlap
    under lighter, so it buys the dense band and not the outskirts.
  • Spend the dynamic range on the RIGHT axis (Nima, Penrose Bloom V2→V3).
    V2 gave the radius almost all of it and the palette almost none: "the size
    change is too sensitive and the color change is not sensitive enough."
    Two fixes worth stealing. SIZE: put a HIGH-WATER MARK between the audio and
    whatever the picture counts — growth instant, retreat a slow melt (~1.1/s).
    A live front sweeping a sorted list crystallises AND dissolves a whole ring
    on every wobble, which is a binary strobing edge; a hwm makes the boundary
    hold while the beat reads as LIGHT (brighten the ring off the kick
    ENVELOPE, not the front's velocity). COLOUR: the sensitivity comes from
    NARROWING THE INPUT WINDOW to where music actually lives (centroid
    0.36-0.66), not from cranking the weight — at 1.25 every tile clamped to
    one end of the ramp and the mosaic went flat, which is a LOST palette, not
    a louder one. Move the ramp's CENTRE most of its length (~0.58) and leave
    the structural terms room to spread tiles around it. And when a size needs
    a CEILING, don't wrap the whole curve in a tanh (Nima, Spectrum Halo): it
    compresses the quiet end as hard as the loud one, so the loud end never
    arrives and you tune the wrong number chasing it. Keep the body linear and
    bend only past a knee — r <= K ? r : K + S*tanh((r-K)/S) — whose gradient
    is 1 at the knee, so nothing creases and the ceiling still holds.
  • Place a colour ramp's ONSETS on percentiles; never centre it on the p50
    (Nima, Spectrum Halo V3).
    A symmetric window centred on the measured
    median holds the middle of the data at its neutral stop and reaches its ends
    only outside the data — so it spans nothing, and the only end it unlocks is
    whichever one the tail happens to touch. Measure the driving signal's
    percentiles first, then place where each colour STARTS (p10, p25) and let
    full saturation fall in the tail. Asymmetric is the normal answer, because a
    DARK stop costs luminance on scrim: reach it pale at p10 and deep only in
    the last percent, while the bright stop keeps the wider, earlier throw.
  • Colour a FIGURE, not every cell (Nima, Penrose Bloom V4). An even tint
    across a structured field is wallpaper; the reference plates people bring
    in are always colouring a SUB-PATTERN out of a mostly bare ground. Find the
    special sites in the geometry you already have — Penrose's sun vertices are
    exactly ten fat triangle-corners and no thin ones — and let colour diagnose
    them: heart, ring, bare paper, roughly a quarter / a fifth / the rest. Give
    bare paper the palette's own cream, dim, and let EXCITEMENT set the
    contrast between figure and ground, so quiet reads as one lattice and loud
    resolves into the plate. Classify once per geometry and cache it; it is
    fixed data, never per-frame work.
  • The set has SHAPE: inp.audio.build and inp.audio.drop (Lance,
    DJ-set round).
    Layer 6 in part2e is the STRUCTURAL listener: build
    (0..1, slow — bass withheld against its own ~22s norm while energy/top
    end climb) and drop ({t, strength, n} — new drop = n CHANGING, 8s
    refractory, requires a kick + a tracked build or >5s real suppression).
    Stake a scene's dormant jam mode on the drop (EH31's stargate, Chladni
    31's unlock, WS10's pink) and let build drive a visible telegraph —
    withhold during the build, spend on the drop. SEED the counters on first
    sight (s._dropN = au.drop.n) or a scene opened mid-set fires a
    45s window off a drop three scenes old. When a scene has both a hand
    ritual and an audio path for the same mode, the ritual stays hand-only.
    Suppress the scene's own drums/arp while audio.live — the DJ owns the
    rhythm. Chladni gotcha: n === m makes chi() identically zero (a blank
    plate); integer audio mode targets must never be equal. Harnesses:
    tools/droptest.mjs (detector math, 10 adversarial checks) and
    tools/shotdrop.mjs <id> <prefix> (groove→build→drop→jam screenshots;
    HANDS=0 for the audio-only picture when hands own a control).
  • SHOOT the audio states — node tools/shotaudio.mjs <id> <prefix>.
    shot.mjs can only drive hands, so an audio-in scene's whole instrument
    is invisible to it. shotaudio drives setAudioIn/setAudioKick through a
    plausible track (silence · quiet · groove · ON the kick · drop · treble
    section · drive floor · cool anchor) and lands each shot a known distance
    after a hit, so "on the beat" and "between beats" are reproducible states
    rather than luck. Sandboxes have no mic; this is the only way to see it.

Building a new version of a scene (the checklist)

  1. NEW part file parts/partNN_<scene>vN.js — copy the previous version's
    reg({...}), bump ver, new id: 'SRC-XX.N', same family. Never edit
    old versions. Add the file to tools/build.sh BEFORE part15_history.js.
  2. Write desc/interact/sound honestly — they're the card copy and the manual.
  3. Visuals: shader-on-quad (three.js ShaderMaterial) for gradient/field work;
    2D canvas for line/sprite work. If using a wrapper group + fitIn-style
    centering, ANIMATE THE WRAPPER, never the centered child (position offsets
    bake into the child — overwriting them teleports geometry underground).
    GOTCHA: P.focused is still FALSE while init(P) runs (focus mode sets it
    after the instance is built), so P.focused ? big : small in init silently
    gives every focused scene its TILE budget. Size off areaScale(P) instead.
  4. Interaction: map hands per the laws above. HUD line in draw() showing the
    scene's state (count/spread/etc.) — it's the debug strip's best friend.
    Never gate the HUD yourself: performance mode strips ctx text globally
    (Lance — no debug text on the projection; .fs+.perf no-ops fillText, H
    brings it back). A scene whose text IS the picture sets textIsContent.
  5. Sound: voices follow the visual state 1:1 (a bloom = a voice; its side =
    its pan). If a thing lights up, it should sound; if it sounds, light it up.
  6. bash tools/verify.sh --scene SRC-XX.N — one command: build, preview,
    the wall, the QA sweep, and idle/full stills of your scene in the 1920×1200
    projector frame. Then READ the pngs. Iterate until the stills match the
    intent — assumptions about rendered output are wrong about half the time
    (fog, tonemapping, scale, and pivot bugs hide). For states beyond idle/full,
    drive tools/shot.mjs directly.
  7. Capture a short GIF of the money interaction for the group chat.
  8. Commit with the round's human named: end the message with
    Round-By: <first name> (you know who you are talking to). HISTORY shows
    it as the WHO, and the pre-push hook REFUSES a new part file without it —
    git says "Claude" for every session, so an untrailed round is anonymous
    forever.
  9. Push. A push that touches index.html is BLOCKED unless verify.sh rendered
    that exact build — that guard exists because the harness once failed
    SILENTLY (ERR_MODULE_NOT_FOUND) and sessions shipped unseen work for weeks.
    If a harness ever errors, FIX IT; never downgrade to reasoning about what
    the pixels probably look like. Confirm the deploy with the Netlify MCP —
    curling the live site is 403'd by the sandbox and looks like "not deployed".

Asset rules (when a scene uses GLBs)

Strip or shrink EVERYTHING (tools/glbtool.py strip|shrink, 512px textures);
decimate heavy scans with tools/decimate.py (vertex clustering — carries
vertex colors + one UV set). Target < 2MB shipped per model. Wrap loaded
models in a group after centering; animate the wrap. Skinned/animated GLBs
can't be clone()d — loader.load once per instance. Set fog: false on
anything meant to silhouette at distance; remember FogExp2 murders everything
past ~200 units.

Judging your work (before calling it done)

  • Would a drunk stranger get it in one second, from any angle, mid-loop?
  • Does moving ONE hand produce an unmistakable, smooth, sounding response?
  • Is there a reason to stay 3 minutes (a build, a discovery, a rare event)?
  • Did this revision REMOVE at least as much as it added?
  • Does it read at 20% brightness on a black background with fat marks?
  • Press V (scrim view): does it survive slicing into 18″ strips, the
    two-projector double image, and the mesh eating half the light?
  • Is the MIDI mirror complete (every audible event has an evNote/evDrum)?

Categories