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.
Install
npx skillscat add lwcassid/source-scenes/scene-craft Install via the SkillsCat registry.
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:
- The stranger — proof of agency inside one second. Sound is faster than
sight; if the first gesture doesn't audibly answer, we lost them. - 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. - 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. - 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)
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.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.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).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:inparrives lean-in = more,
but invert with1 - inpwhen 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.Presence gates everything.
chan.X.mode === 'live'→ smooths.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.Bar-quantized structure. Count changes, buds, entrances land on the
downbeat (Schmitt thresholds + the audio tick commits atst === 0).
Anticipation beats surprise: telegraph the downbeat (pre-glow the next gate).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).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 issmoothstep(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.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.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 withsource-over.
Compositing paint and line additively piles every overlap to white and the
colour goes with it — a scene can be entirelylighterand still need onesource-overseam. Three cheap moves buy most of "hand-made": a granulation
pattern laid over the paint layer withsource-atop(it darkens only where
paint already is and can never fog the black — amultiplyover 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. Andctx.filter='blur()'costs its
DESTINATION's pixels: blur the small offscreen, then upscale, never blur on
the way onto a 1920x1200 stage.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.mjstakes hand values as
reach-outward = 1, scenes read lean-in = 1, so0in 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, andareaScale(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.?winorPon the stage opts out when you want a
native-window canvas. The DBG strip'sFRAMEline tells you what you've
got —1920×1200 · 1.60 · PROJis 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), neverhypot(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
Hfor ALL pitches (H.chordTone(deg, oct),H.rootFreq) — never raw
frequencies; re-glide voices inH.onChord(...). Stay on the chord ladder
and any cascade stays musical. - Grid: schedule inside
tick()withnextT/step16horizon ≈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 indocs/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/trebleare already engine-smoothed 0..1 — don't
re-smooth them, but DO still ease them into your own state the wayinp.L
gets eased (s.bass += (audioBand - s.bass) * dt*6), same as hands.onsetis a raw, un-smoothed pulse — detect the RISING EDGE yourself
(onset > 0.7 && prevOnset <= 0.7, then storeprevOnset) 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 withMath.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,
thentarget = 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.kickBpmis 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.onsetis 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, notonset(Nima, Cell Front V11).onsetis 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-accurateton the audio clock (inp.audio.now),
a NEW hit isnCHANGING (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 bynow - t(+ a display
LEAD) so the frame is right for the vsync it lands on. Keep anonset-edge fallback for the test hook;setAudioKick(strength)fires
one hit in harnesses.node tools/kicktest.mjsmeasures it — read theexactseries (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 anaudio()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
underlighter, 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.buildandinp.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) anddrop({t, strength, n} — new drop =nCHANGING, 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 letbuilddrive 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 whileaudio.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) andtools/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.mjscan only drive hands, so an audio-in scene's whole instrument
is invisible to it. shotaudio drivessetAudioIn/setAudioKickthrough 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)
- NEW part file
parts/partNN_<scene>vN.js— copy the previous version'sreg({...}), bumpver, newid: 'SRC-XX.N', samefamily. Never edit
old versions. Add the file totools/build.shBEFOREpart15_history.js. - Write desc/interact/sound honestly — they're the card copy and the manual.
- 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.focusedis still FALSE whileinit(P)runs (focus mode sets it
after the instance is built), soP.focused ? big : smallin init silently
gives every focused scene its TILE budget. Size offareaScale(P)instead. - 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 setstextIsContent. - 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. 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,
drivetools/shot.mjsdirectly.- Capture a short GIF of the money interaction for the group chat.
- 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. - 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)?