Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/crash-synth.ts
// Sample-by-sample crash synthesis. Every crash is rendered fresh into a stereo
// buffer, so no two sound alike, and the amount of chaos follows the impact:
//
//   tap        a dull thump, a crack of plastic, a little crunch
//   solid hit  crumpling metal with ringing body panels, glass, a short scrape
//   high speed several overlapping impacts as the car rolls, long metal-on-asphalt
//              scrapes, tire squeal, a storm of glass and bouncing debris, a hubcap
//
// The metal sound is modal: a dense stream of noise grains (the crumple) excites a
// bank of narrow resonators tuned to random panel frequencies, which is what makes
// it ring like a car body instead of hiss like static.

export type ImpactKind = 'car' | 'wall' | 'pileup';

export interface CrashRender {
  left: Float32Array<ArrayBuffer>;
  right: Float32Array<ArrayBuffer>;
  /** 0 (tap) to ~1.25 (carnage), derived from the game's impact intensity. */
  severity: number;
}

const TAU = Math.PI * 2;

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function rnd(lo: number, hi: number): number {
  return lo + Math.random() * (hi - lo);
}

/** Log-uniform: even spread across octaves, useful for frequencies and amplitudes. */
function logRnd(lo: number, hi: number): number {
  return lo * Math.pow(hi / lo, Math.random());
}

// A shared table of white noise is much cheaper than Math.random() per sample.
// Each read walks on from a random point, so repeats are never audible.
const NOISE_SIZE = 1 << 16;
const NOISE_TABLE = new Float32Array(NOISE_SIZE).map(() => Math.random() * 2 - 1);
let noiseAt = 0;

function noise(): number {
  noiseAt = (noiseAt + 1) & (NOISE_SIZE - 1);
  return NOISE_TABLE[noiseAt];
}

function reseedNoise(): void {
  noiseAt = Math.floor(Math.random() * NOISE_SIZE);
}

/** Exponentially distributed delay: most events early, a long tail of stragglers. */
function expDelay(mean: number): number {
  return -mean * Math.log(1 - Math.random() * 0.999);
}

class Canvas {
  readonly left: Float32Array<ArrayBuffer>;
  readonly right: Float32Array<ArrayBuffer>;
  /** Mono excitation for the metal resonator bank. */
  readonly metal: Float32Array<ArrayBuffer>;
  readonly n: number;
  metalEnd = 0;

  constructor(
    readonly sr: number,
    seconds: number,
    readonly width: number,
  ) {
    this.n = Math.ceil(seconds * sr);
    this.left = new Float32Array(new ArrayBuffer(this.n * 4));
    this.right = new Float32Array(new ArrayBuffer(this.n * 4));
    this.metal = new Float32Array(new ArrayBuffer(this.n * 4));
  }

  idx(t: number): number {
    return Math.floor(t * this.sr);
  }

  /** A random stereo position, wider for bigger crashes. */
  spread(): number {
    return noise() * this.width;
  }

  /** Damped sinusoid with an optional inharmonic second partial: glass, debris, hubcap ticks. */
  ping(t: number, freq: number, decay: number, amp: number, pan: number, partial = 0): void {
    const sr = this.sr;
    const start = this.idx(t);
    // Stop at -40 dB.
    const len = Math.min(this.n - start, Math.ceil(decay * 4.6 * sr));
    if (start < 0 || len <= 0) return;
    const gl = Math.sqrt((1 - pan) / 2) * amp;
    const gr = Math.sqrt((1 + pan) / 2) * amp;
    // Recursive oscillators: y[n] = 2r·cos(w)·y[n-1] - r²·y[n-2] rings and decays with no sin() per sample.
    const r = Math.exp(-1 / (decay * sr));
    const w1 = (TAU * Math.min(freq, sr * 0.45)) / sr;
    const c1 = 2 * r * Math.cos(w1);
    const ph = Math.random() * TAU;
    let a1 = Math.sin(ph);
    let a0 = r * Math.sin(ph + w1);
    let p1 = 0;
    let p0 = 0;
    let c2 = 0;
    const mix = partial ? 0.7 : 1;
    if (partial) {
      const w2 = (TAU * Math.min(freq * partial, sr * 0.45)) / sr;
      c2 = 2 * r * Math.cos(w2);
      p0 = r * Math.sin(w2);
    }
    const rr = r * r;
    for (let i = 0; i < len; i++) {
      const v = a1 * mix + p1 * 0.3;
      const a = c1 * a0 - rr * a1;
      a1 = a0;
      a0 = a;
      if (partial) {
        const p = c2 * p0 - rr * p1;
        p1 = p0;
        p0 = p;
      }
      this.left[start + i] += v * gl;
      this.right[start + i] += v * gr;
    }
  }

  /** Short noise burst straight to the stereo mix. tone 0 = dark, 1 = bright. */
  grit(t: number, length: number, amp: number, pan: number, tone: number): void {
    const start = this.idx(t);
    const len = Math.min(this.n - start, Math.ceil(length * this.sr));
    if (start < 0 || len <= 0) return;
    const gl = Math.sqrt((1 - pan) / 2) * amp;
    const gr = Math.sqrt((1 + pan) / 2) * amp;
    const a = 1 - tone * 0.95;
    let lp = 0;
    let prev = 0;
    for (let i = 0; i < len; i++) {
      const x = noise();
      lp += (x - lp) * (1 - a);
      // Blend lowpassed and differentiated noise for the requested brightness.
      const v = (tone > 0.5 ? x - prev : lp) * Math.exp((-6 * i) / len);
      prev = x;
      this.left[start + i] += v * gl;
      this.right[start + i] += v * gr;
    }
  }

  /** Noise grain into the metal excitation (rings through the panel resonators). */
  crumpleGrain(t: number, length: number, amp: number): void {
    const start = this.idx(t);
    const len = Math.min(this.n - start, Math.ceil(length * this.sr));
    if (start < 0 || len <= 0) return;
    const k = Math.exp(-5 / len);
    let env = amp;
    for (let i = 0; i < len; i++) {
      this.metal[start + i] += noise() * env;
      env *= k;
    }
    this.metalEnd = Math.max(this.metalEnd, start + len);
  }

  /** Low-frequency body blow: a falling sine plus a darkened noise thud. */
  thump(t: number, freq: number, length: number, amp: number): void {
    const sr = this.sr;
    const start = this.idx(t);
    const len = Math.min(this.n - start, Math.ceil(length * sr));
    if (start < 0 || len <= 0) return;
    const lpA = Math.exp((-TAU * 160) / sr);
    let lp1 = 0;
    let lp2 = 0;
    let phase = 0;
    for (let i = 0; i < len; i++) {
      const x = i / len;
      const f = freq * (1 - 0.45 * x);
      phase += (TAU * f) / sr;
      lp1 = lp1 * lpA + noise() * (1 - lpA);
      lp2 = lp2 * lpA + lp1 * (1 - lpA);
      const env = Math.exp(-5 * x) * Math.min(1, i / (0.002 * sr));
      const v = (Math.sin(phase) * 0.8 + lp2 * 9) * env * amp;
      this.left[start + i] += v;
      this.right[start + i] += v;
    }
  }

  /**
   * Metal sliding on asphalt: modulated noise through a bandpass that falls in
   * pitch as the wreck slows, with stick-slip chatter feeding the panel resonators.
   */
  scrape(t: number, length: number, amp: number, pan: number, f0: number): void {
    const sr = this.sr;
    const start = this.idx(t);
    const len = Math.min(this.n - start, Math.ceil(length * sr));
    if (start < 0 || len <= 0) return;
    const gl = Math.sqrt((1 - pan) / 2) * amp;
    const gr = Math.sqrt((1 + pan) / 2) * amp;
    let y1 = 0;
    let y2 = 0;
    let a1 = 0;
    let a2 = 0;
    let b0 = 0;
    let mod = 0.5;
    let modTarget = 0.5;
    let chatter = 0;
    for (let i = 0; i < len; i++) {
      const x = i / len;
      if (i % 128 === 0) {
        // Retune the bandpass and pick a new roughness target every few ms.
        const f = f0 * (1 - 0.6 * x);
        const r = Math.exp((-Math.PI * f * 0.5) / sr);
        a1 = 2 * r * Math.cos((TAU * f) / sr);
        a2 = r * r;
        b0 = 1 - r;
        modTarget = 0.25 + Math.random() * 0.75;
      }
      mod += (modTarget - mod) * 0.002;
      const env = Math.min(1, i / (0.01 * sr)) * Math.pow(1 - x, 1.5);
      const y = b0 * noise() + a1 * y1 - a2 * y2;
      y2 = y1;
      y1 = y;
      const v = y * 3 * mod * env;
      this.left[start + i] += v * gl;
      this.right[start + i] += v * gr;
      // Stick-slip: the body catches and releases, slower as it loses speed.
      chatter -= (30 + 70 * (1 - x)) / sr;
      if (chatter <= 0) {
        chatter = rnd(0.6, 1.4);
        this.metal[start + i] += noise() * amp * 0.6 * env;
      }
    }
    this.metalEnd = Math.max(this.metalEnd, start + len);
  }

  /** Tires sliding sideways: a jittery, raspy squeal. */
  squeal(t: number, length: number, amp: number, pan: number): void {
    const sr = this.sr;
    const start = this.idx(t);
    const len = Math.min(this.n - start, Math.ceil(length * sr));
    if (start < 0 || len <= 0) return;
    const gl = Math.sqrt((1 - pan) / 2) * amp;
    const gr = Math.sqrt((1 + pan) / 2) * amp;
    const base = rnd(620, 980);
    const lpA = Math.exp((-TAU * 2800) / sr);
    let phase = 0;
    let lp = 0;
    let wander = 0;
    for (let i = 0; i < len; i++) {
      const x = i / len;
      wander += (noise() * 0.5 - wander) * 0.0008;
      const f = base * (1 + 0.025 * Math.sin((TAU * 7.3 * i) / sr) + wander * 0.08 - 0.15 * x);
      phase = (phase + f / sr) % 1;
      const saw = 2 * phase - 1;
      const raw = saw * (0.65 + 0.35 * noise());
      lp = lp * lpA + raw * (1 - lpA);
      const env = Math.min(1, i / (0.02 * sr)) * (1 - x) * (1 - x);
      const v = lp * env;
      this.left[start + i] += v * gl;
      this.right[start + i] += v * gr;
    }
  }

  /** A hubcap spinning down: a ringing wobble that speeds up, then clatters flat. */
  hubcap(t: number, length: number, amp: number, pan: number): void {
    const sr = this.sr;
    const start = this.idx(t);
    const len = Math.min(this.n - start, Math.ceil(length * sr));
    if (start < 0 || len <= 0) return;
    const gl = Math.sqrt((1 - pan) / 2) * amp;
    const gr = Math.sqrt((1 + pan) / 2) * amp;
    const f = rnd(1050, 1500);
    const w1 = (TAU * f) / sr;
    const w2 = (TAU * f * 2.71) / sr;
    let wob = 0;
    for (let i = 0; i < len; i++) {
      const x = i / len;
      wob += (TAU * 4 * Math.pow(9, x)) / sr;
      const am = Math.pow(Math.abs(Math.sin(wob)), 3);
      const env = Math.min(1, i / (0.01 * sr)) * (x < 0.8 ? 1 : (1 - x) * 5);
      const v = (Math.sin(w1 * i) * 0.75 + Math.sin(w2 * i) * 0.25) * am * env;
      this.left[start + i] += v * gl;
      this.right[start + i] += v * gr;
    }
    for (let i = 0; i < 4; i++) this.ping(t + length + i * 0.035, f * rnd(0.8, 1.2), 0.02, amp * 0.9, pan, 2.3);
  }

  /** Run the metal excitation through the panel resonator bank into the mix. */
  ringPanels(modes: { f: number; bw: number; g: number }[], amp: number, dry: number): void {
    const sr = this.sr;
    const end = Math.min(this.n, this.metalEnd + Math.ceil(0.6 * sr));
    const src = this.metal;
    let first = 0;
    while (first < end && src[first] === 0) first++;
    modes.forEach((m, k) => {
      const r = Math.exp((-Math.PI * m.bw) / sr);
      const w = (TAU * m.f) / sr;
      const a1 = 2 * r * Math.cos(w);
      const a2 = r * r;
      const g = (1 - r) * 2 * Math.sin(w) * m.g * amp * 40;
      // Alternate modes left and right with some bleed, for a wide, solid body.
      const gl = k % 2 ? 0.35 : 1;
      const gr = k % 2 ? 1 : 0.35;
      let y1 = 0;
      let y2 = 0;
      for (let i = first; i < end; i++) {
        const y = g * src[i] + a1 * y1 - a2 * y2;
        y2 = y1;
        y1 = y;
        this.left[i] += y * gl;
        this.right[i] += y * gr;
      }
    });
    // Some unfiltered crunch on top keeps the grit of tearing metal.
    let prev = 0;
    for (let i = first; i < end; i++) {
      const v = (src[i] - prev) * dry;
      prev = src[i];
      this.left[i] += v;
      this.right[i] += v;
    }
  }

  lowpass(cutoff: number): void {
    const a = Math.exp((-TAU * cutoff) / this.sr);
    for (const ch of [this.left, this.right]) {
      let y = 0;
      for (let i = 0; i < this.n; i++) ch[i] = y = y * a + ch[i] * (1 - a);
    }
  }

  normalize(peak: number): void {
    let max = 0;
    for (let i = 0; i < this.n; i++) max = Math.max(max, Math.abs(this.left[i]), Math.abs(this.right[i]));
    if (max === 0) return;
    const g = peak / max;
    for (let i = 0; i < this.n; i++) {
      this.left[i] *= g;
      this.right[i] *= g;
    }
  }
}

interface Hit {
  t: number;
  e: number;
}

/**
 * Render one crash. intensity is the game's impact intensity, closing speed / 100:
 * about 0.1 for a bump, 0.5 to 1 for a solid hit, 1.1 to 1.7 at highway speed. kind picks the body: a car's thin
 * panels ring higher; the truck hitting a wall is heavier and lower.
 */
export function renderCrash(sampleRate: number, intensity: number, kind: ImpactKind): CrashRender {
  reseedNoise();
  const sev = clamp(intensity / 1.4, 0.08, 1.25);
  const chaos = sev * sev;
  const heavy = kind === 'wall';
  const rolls = !heavy && sev > 0.62;
  const seconds = 0.55 + sev * 2.6 + (rolls ? 0.9 : 0);
  const c = new Canvas(sampleRate, seconds, 0.15 + 0.8 * Math.min(1, sev));

  // The impact sequence: the main hit, then the car tumbling (or the trailer
  // slamming in behind the truck). Each bounce is a little later and weaker.
  const hits: Hit[] = [{ t: 0.003, e: 1 }];
  if (rolls) {
    const count = Math.min(7, Math.floor((sev - 0.5) * 9) + (Math.random() < 0.5 ? 1 : 0));
    let t = 0.003;
    let gap = rnd(0.2, 0.34);
    let e = 1;
    for (let i = 0; i < count; i++) {
      t += gap * rnd(0.8, 1.2);
      gap *= rnd(0.72, 0.95);
      e *= rnd(0.45, 0.75);
      hits.push({ t, e });
    }
  } else if (heavy && sev > 0.45) {
    hits.push({ t: rnd(0.12, 0.22), e: rnd(0.5, 0.7) });
  }

  let glassLeft = sev > 0.22 ? 1 : 0;
  hits.forEach((hit, n) => {
    const { t, e } = hit;
    const pan = n === 0 ? 0 : c.spread();

    // Thump and the sharp crack of first contact.
    c.thump(t, (heavy ? 48 : 64) * rnd(0.9, 1.1), 0.1 + 0.3 * e * sev, e * (0.45 + 0.6 * sev));
    c.grit(t, 0.0025, e * (0.5 + 0.7 * sev), pan * 0.3, 1);

    // Crumpling metal: a burst of grains, denser and longer the harder the hit.
    const grains = Math.round((12 + 160 * sev) * e * (heavy ? 1.25 : 1));
    const tau = 0.025 + 0.13 * sev * e;
    for (let i = 0; i < grains; i++) {
      const late = Math.random() < chaos * 0.25;
      const at = t + (late ? rnd(0.05, 0.45) : expDelay(tau));
      c.crumpleGrain(at, logRnd(0.001, 0.02), e * logRnd(0.04, 1));
    }
    // A few big buckling pops.
    for (let i = 0, pops = Math.round((1 + 6 * sev) * e); i < pops; i++) {
      c.crumpleGrain(t + expDelay(tau * 1.5), rnd(0.015, 0.045), e * rnd(1.2, 2));
    }

    // Plastic bumper and trim cracking.
    for (let i = 0, cracks = Math.round((3 + 22 * sev) * e); i < cracks; i++) {
      c.ping(t + expDelay(0.02), rnd(900, 2600), rnd(0.002, 0.007), e * rnd(0.1, 0.35), c.spread(), 1.6);
    }

    // Glass: the first hard hit breaks the windshield, later bounces break the rest.
    if (glassLeft > 0 && e * sev > 0.18) {
      const share = n === 0 ? (rolls ? 0.55 : 1) : glassLeft;
      glassLeft -= share * 0.5;
      const shards = Math.round((40 + 950 * sev) * e * share);
      c.grit(t + 0.002, 0.004, e * 0.5, pan, 1);
      c.grit(t + 0.004, 0.08 + 0.1 * sev, e * 0.18 * sev, pan, 1);
      for (let i = 0; i < shards; i++) {
        const falling = Math.random() < 0.4;
        const at = falling ? t + rnd(0.1, 0.25 + 0.7 * sev) : t + expDelay(0.035);
        const amp = e * (falling ? logRnd(0.01, 0.1) : logRnd(0.02, 0.22));
        c.ping(at, logRnd(2400, 12500), logRnd(0.002, falling ? 0.012 : 0.03), amp, c.spread(), 2.32);
      }
    }

    // Scraping along the ground after each hit, longest after the main impact.
    if (sev > 0.18) {
      const next = hits[n + 1]?.t ?? t + 0.2 + 1.6 * sev + (heavy ? 0.4 : 0);
      const length = n === 0 && !rolls ? next - t : (next - t) * 0.85;
      c.scrape(t + 0.03, length, e * (0.12 + 0.3 * sev), pan, heavy ? rnd(1400, 2200) : rnd(2200, 3600));
    }
  });

  // Tires squealing as the car is shoved sideways.
  if (!heavy && sev > 0.32) c.squeal(0.015, 0.25 + 0.8 * sev, 0.07 + 0.08 * sev, c.spread());

  // Debris: pieces flying off, landing and bouncing to a stop.
  const pieces = Math.round(2 + 8 * sev + 34 * chaos);
  for (let i = 0; i < pieces; i++) {
    const metal = Math.random() < 0.6;
    const freq = metal ? logRnd(700, 5200) : rnd(300, 900);
    const pan = c.spread();
    let at = rnd(0.08, 0.3 + 0.9 * sev);
    let amp = rnd(0.06, 0.28) * (0.5 + 0.5 * sev);
    let gap = rnd(0.07, 0.16);
    for (let b = 0, bounces = 1 + Math.floor(Math.random() * 4); b < bounces; b++) {
      if (metal) c.ping(at, freq * rnd(0.97, 1.03), rnd(0.01, 0.045), amp, pan, 2.76);
      else c.grit(at, 0.006, amp * 1.4, pan, 0.3);
      at += gap;
      gap *= 0.6;
      amp *= 0.55;
    }
  }

  if (!heavy && sev > 0.6 && Math.random() < 0.6) c.hubcap(rnd(0.3, 0.6), rnd(1.1, 1.8), 0.1, c.spread());

  // Ring the crumple through the body panels.
  const modeCount = 8 + Math.round(6 * sev);
  const modes = Array.from({ length: modeCount }, () => ({
    f: heavy ? logRnd(95, 2600) : logRnd(220, 4800),
    bw: logRnd(3, heavy ? 18 : 28),
    g: logRnd(0.3, 1),
  }));
  c.ringPanels(modes, 0.5, 0.35);

  if (kind === 'pileup') c.lowpass(2600);
  c.normalize(0.5 + 0.45 * Math.min(1, sev));
  return { left: c.left, right: c.right, severity: sev };
}