diff --git a/HANDOFF.md b/HANDOFF.md
index c8823bf..40b1a41 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1180,6 +1180,13 @@ this on any new pose with a raised prop.
index, never a search — which is why the laser looked fine while the other four did not.
Note also that spending re-indexes the rack (`spendSlot` splices), so a slot number goes stale the
moment any tin empties. Look weapons up by ID, not by a slot you cached a frame ago.
+- **Every sound in audio.js swallows its own errors** (`try { ... } catch (e) {}`), so a clean
+ console proves NOTHING about whether one played. To verify a sound really built its graph, count
+ nodes — patch `AudioContext.prototype.createBufferSource` / `createOscillator` /
+ `createBiquadFilter` and diff around the event. To verify a PARAMETER reached the synthesis, patch
+ `AudioParam.prototype.setValueAtTime` and look for the frequencies you expect (that is how the
+ size scaling on `thud`/`splat` was checked: 150 Hz body thump on a shambler, 100 on a brute,
+ 59 on a chapter-4 boss).
- **A test that walks the game forward will quietly kill the player.** Three separate diagnostic runs
above returned nonsense (`stillCarried: true`, no laser fired at all) purely because `G.mode` had
become `'eaten'` several steps earlier and every gameplay entry point bails on that. When a probe
diff --git a/audio.js b/audio.js
index 90e8961..affe1e3 100644
--- a/audio.js
+++ b/audio.js
@@ -185,19 +185,72 @@ export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
} catch (e) {}
}
-export function splat(vol = 0.7) { // wet burst — a zombie meets a first serve
+// Ball meets BODY, without killing it. This was borrowing pock() — the racket thock — which is a
+// bright, dry, stationary-band click: exactly right for gut on felt and exactly wrong for felt on a
+// torso, so every non-fatal hit sounded like the ball had struck a wall. `size` is zombieH / 1.8, so
+// a shambler is 1, a brute ~1.5 and a late chapter boss ~2.4: the thump under it drops from 150 Hz
+// to around 60, and the whole hit slows down with it.
+export function thud(vol = 0.6, size = 1) {
try {
const ac = audio(), t = ac.currentTime;
- const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.22);
- const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
- lp.frequency.setValueAtTime(1400, t); lp.frequency.exponentialRampToValueAtTime(180, t + 0.2);
+ const k = Math.max(0.7, Math.min(2.4, size));
+ const dur = 0.15 * k;
+ // the slap. The band FALLS, where pock's sits still — a collapsing resonance is the wet part,
+ // and it is the same trick splat() uses, just shorter and much less of it.
+ const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur);
+ const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.4;
+ bp.frequency.setValueAtTime(780 / k, t);
+ bp.frequency.exponentialRampToValueAtTime(190 / k, t + dur);
const g = ac.createGain();
- g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
- src.connect(lp).connect(g).connect(ac.destination); src.start();
- const o = ac.createOscillator(), og = ac.createGain(); // body thud
- o.frequency.setValueAtTime(120, t); o.frequency.exponentialRampToValueAtTime(45, t + 0.15);
- og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
- o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18);
+ g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ src.connect(bp).connect(g).connect(ac.destination); src.start(t);
+ // the weight behind it — THIS is what actually carries the size difference
+ const o = ac.createOscillator(), og = ac.createGain();
+ o.frequency.setValueAtTime(150 / k, t); o.frequency.exponentialRampToValueAtTime(52 / k, t + 0.11 * k);
+ og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.13 * k);
+ o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.15 * k);
+ // one short bright tick just behind the slap: something gave. Quiet on purpose — this is a hit,
+ // not a burst, and at splat()'s spatter level every rally would sound like a kill.
+ const d = 0.03 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac, 0.07 * k);
+ const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1100;
+ const spg = ac.createGain();
+ spg.gain.setValueAtTime(vol * 0.18, t + d);
+ spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.07 * k);
+ sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d);
+ } catch (e) {}
+}
+
+// Wet burst — a body meeting a first serve. `size` is roughly zombieH / 1.8, so 1 is a shambler,
+// ~1.5 a brute and ~2.4 a late chapter boss: everything below pitches DOWN and lasts LONGER with it.
+// A runner pops; a boss comes apart. Same sound, and that is the point — it should read as the same
+// event happening to a bigger body, not as a different event.
+export function splat(vol = 0.7, size = 1) {
+ try {
+ const ac = audio(), t = ac.currentTime;
+ const k = Math.max(0.7, Math.min(2.4, size)); // clamp: nothing gets so big it stops being a splat
+ const dur = 0.22 * k;
+ // The wetness lives HERE. The old filter was a plain lowpass with no resonance, which is a dry
+ // hiss with the top taken off; a RESONANT band collapsing down the spectrum is a squelch.
+ const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur);
+ const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.2;
+ bp.frequency.setValueAtTime(1100 / k, t);
+ bp.frequency.exponentialRampToValueAtTime(130 / k, t + dur * 0.85);
+ const g = ac.createGain();
+ g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ src.connect(bp).connect(g).connect(ac.destination); src.start(t);
+ const o = ac.createOscillator(), og = ac.createGain(); // body thud, heavier on a heavier body
+ o.frequency.setValueAtTime(120 / k, t); o.frequency.exponentialRampToValueAtTime(45 / k, t + 0.15 * k);
+ og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16 * k);
+ o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18 * k);
+ // ...and what came out of it lands a moment later. This second, brighter burst is most of what
+ // reads as "wet" — one clean hit is a thud, a thud followed by spatter is a body.
+ const d = 0.06 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac, 0.18 * k);
+ const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 900;
+ const spg = ac.createGain();
+ spg.gain.setValueAtTime(0.001, t + d);
+ spg.gain.exponentialRampToValueAtTime(vol * 0.3, t + d + 0.02);
+ spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.18 * k);
+ sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d);
} catch (e) {}
}
@@ -217,6 +270,43 @@ export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filter
} catch (e) {}
}
+// The laser on release. It was borrowing boom() — the BOMB — which is why the showiest weapon in
+// the game sounded like the least showy one. This is a plane of light cutting a crowd in half, so:
+// a crack, a dive, weight underneath it, and a ring after it has gone.
+export function zap(vol = 0.5) {
+ try {
+ const ac = audio(), t = ac.currentTime, dur = 0.42;
+ const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.09); // the crack: air leaving
+ const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2200;
+ const ng = ac.createGain();
+ ng.gain.setValueAtTime(vol * 0.85, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
+ src.connect(hp).connect(ng).connect(ac.destination); src.start(t);
+ // the body: two detuned saws DIVING through a resonant lowpass. The direction is the whole
+ // reading — a rising sweep is a charge-up, and this thing never charges, it discharges.
+ const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 12;
+ lp.frequency.setValueAtTime(5200, t); lp.frequency.exponentialRampToValueAtTime(220, t + dur * 0.8);
+ const bg = ac.createGain();
+ bg.gain.setValueAtTime(vol, t); bg.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ lp.connect(bg).connect(ac.destination);
+ for (const det of [1, 1.008]) { // the detune IS the width: one saw alone is a game-show buzzer
+ const o = ac.createOscillator(); o.type = 'sawtooth';
+ o.frequency.setValueAtTime(1300 * det, t);
+ o.frequency.exponentialRampToValueAtTime(120 * det, t + dur * 0.7);
+ o.connect(lp); o.start(t); o.stop(t + dur);
+ }
+ const s = ac.createOscillator(), sg = ac.createGain(); // weight, or it is only hiss on a phone
+ s.frequency.setValueAtTime(150, t); s.frequency.exponentialRampToValueAtTime(48, t + 0.3);
+ sg.gain.setValueAtTime(vol * 0.7, t); sg.gain.exponentialRampToValueAtTime(0.001, t + 0.34);
+ s.connect(sg).connect(ac.destination); s.start(t); s.stop(t + 0.36);
+ const r = ac.createOscillator(), rg = ac.createGain(); // ...and it rings after it has gone
+ r.type = 'triangle'; r.frequency.setValueAtTime(3100, t);
+ r.frequency.exponentialRampToValueAtTime(1900, t + dur);
+ rg.gain.setValueAtTime(0.001, t); rg.gain.exponentialRampToValueAtTime(vol * 0.2, t + 0.05);
+ rg.gain.exponentialRampToValueAtTime(0.001, t + dur + 0.12);
+ r.connect(rg).connect(ac.destination); r.start(t); r.stop(t + dur + 0.14);
+ } catch (e) {}
+}
+
export function shriek(vol = 0.35) { // a runner announces itself — harsh rising cry, the fairness contract
try {
const ac = audio(), t = ac.currentTime, dur = 0.7;
diff --git a/index.html b/index.html
index fa47f93..2d48429 100644
--- a/index.html
+++ b/index.html
@@ -184,6 +184,6 @@
-
+