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/traffic.ts
import { TrafficRouting } from './traffic-routing.js';
import { laneLayer, type City, type District, type Lane, type Signal } from './city.js';
import type { Effects, HitMaterial } from './effects.js';
import { poseAt, type Vec } from './geometry.js';
import { pickLine, type LineKind } from './lines.js';
import { axleGrip, boxContact, boxInertia, forwardSpeed, integrate, resolveContact, resolveStatic, type Body } from './physics.js';
import { GRAVITY, type Truck } from './truck.js';
import { makeLook, type CarDamage, type CarLook, type CarModel } from './vehicles.js';

export interface View {
  x: number;
  y: number;
  w: number;
  h: number;
}

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

/** How each model behaves in a crash. */
interface CrashSpec {
  mass: number;
  /** Multiplies crush and damage: flimsier cars fold up sooner. */
  fragility: number;
  /** Static stability: lower tips over sooner (tall SUVs), higher hugs the road. */
  stability: number;
  fire: number;
  explode: number;
}

const SPECS: Record<CarModel, CrashSpec> = {
  sedan: { mass: 1.5, fragility: 1, stability: 1.2, fire: 0.3, explode: 0.15 },
  taxi: { mass: 1.6, fragility: 0.95, stability: 1.2, fire: 0.3, explode: 0.15 },
  sports: { mass: 1.3, fragility: 1.2, stability: 1.6, fire: 0.45, explode: 0.3 },
  suv: { mass: 2.1, fragility: 0.8, stability: 0.85, fire: 0.25, explode: 0.12 },
  beater: { mass: 1.4, fragility: 1.4, stability: 1.1, fire: 0.6, explode: 0.35 },
  compact: { mass: 1.1, fragility: 1.3, stability: 1.25, fire: 0.3, explode: 0.15 },
  pickup: { mass: 2.2, fragility: 0.85, stability: 1, fire: 0.3, explode: 0.2 },
  van: { mass: 2.4, fragility: 0.95, stability: 0.85, fire: 0.3, explode: 0.15 },
  bus: { mass: 7, fragility: 0.7, stability: 0.9, fire: 0.2, explode: 0.1 },
  motorcycle: { mass: 0.6, fragility: 1.6, stability: 0.6, fire: 0.5, explode: 0.3 },
  police: { mass: 1.7, fragility: 0.85, stability: 1.3, fire: 0.3, explode: 0.2 },
  limo: { mass: 2.6, fragility: 0.9, stability: 1.2, fire: 0.3, explode: 0.2 },
  icecream: { mass: 2.3, fragility: 1, stability: 0.8, fire: 0.35, explode: 0.25 },
  tow: { mass: 4, fragility: 0.6, stability: 1, fire: 0.2, explode: 0.1 },
  ambulance: { mass: 3.2, fragility: 0.8, stability: 0.85, fire: 0.25, explode: 0.1 },
  firetruck: { mass: 8, fragility: 0.6, stability: 0.9, fire: 0.1, explode: 0.05 },
  garbage: { mass: 7, fragility: 0.65, stability: 0.85, fire: 0.2, explode: 0.08 },
  sweeper: { mass: 3.5, fragility: 0.8, stability: 0.9, fire: 0.2, explode: 0.1 },
  works: { mass: 2.6, fragility: 0.85, stability: 1, fire: 0.3, explode: 0.15 },
  dump: { mass: 7.5, fragility: 0.6, stability: 0.9, fire: 0.2, explode: 0.08 },
  roadside: { mass: 2.4, fragility: 0.9, stability: 0.85, fire: 0.3, explode: 0.15 },
};

/** What a car collided with. */
export interface HitSource {
  material: HitMaterial;
  color: string;
}

/** Someone at the curb waving down a taxi. */
export interface Hailer {
  x: number;
  y: number;
}

/** Something that isn't traffic but that drivers must stop for (a service unit). */
export interface Blocker {
  body: Body;
  layer: number;
  parked: boolean;
}

export class Car {
  readonly body: Body;
  readonly spec: CrashSpec;
  /** Per-car build quality: no two cars crumple exactly alike. */
  readonly quality = 0.8 + Math.random() * 0.45;
  lane = 0;
  s = 0;
  next = -1;
  lat = 0;
  speed = 0;
  mode: 'drive' | 'wreck' = 'drive';
  layer = 0;
  temper = 1;
  rude = false;
  drifter = false;
  look: CarLook;
  brakeTimer = 0;
  brakeDelay = 0;
  cooldown = 0;
  laneCooldown = 0;
  waitTime = 0;
  ghost = 0;
  alert = 0;
  bubble = '';
  bubbleTime = 0;
  braking = false;
  skidding = false;
  wreckTime = 0;
  alarm = 0;
  fire = 0;
  /** Seconds until the fuel tank goes up (0 = no fuse lit). */
  fuse = 0;
  exploded = false;
  engine = 0;
  smokeKind: 'none' | 'steam' | 'black' = 'none';
  smokeLevel = 0;
  vz = 0;
  rollVel = 0;
  private quarter = 0;
  drift = 0;
  pathAngle = 0;
  health = 100;
  /** Pull over and slow down: a siren is coming up behind. */
  yieldTime = 0;
  /** Broken down at the roadside, hazards on, waiting for roadside assistance. */
  broken = false;
  brokenTime = 0;
  /** Taxis: seconds left on the current fare (0 = for hire), and the person being picked up. */
  fare = 0;
  hailer: Hailer | null = null;
  stopTimer = 0;
  /** A tow truck is on its way for this wreck: don't clear it away. */
  towClaim = false;
  /** A family car (in their turf): wreck it and they take it personally. */
  mob = false;
  /** The family put a hit on this car. */
  marked = false;
  /** Stopped (or stopping) at a red light, and waiting in the queue behind one. */
  atLight = false;
  queued = false;
  /** Buses: seconds of driving until the next stop. */
  busTimer = 20 + Math.random() * 20;
  /** Waved through a police closure: creep past the scene. */
  waved = 0;
  closedWait = 0;
  routeDelay = 5 + Math.random() * 10;
  routeTimer = Math.random() * 2;
  dmg: CarDamage = { crumple: [0, 0, 0, 0], wheels: [true, true, true, true], flipped: false, z: 0, roll: 0, hazard: false, burnt: 0, glass: false };

  constructor(look: CarLook) {
    this.look = look;
    this.spec = SPECS[look.model];
    const m = this.spec.mass * (0.92 + Math.random() * 0.16);
    this.body = { x: 0, y: 0, vx: 0, vy: 0, angle: 0, av: 0, mass: m, inertia: boxInertia(m, look.length / 2, look.width / 2), halfL: look.length / 2, halfW: look.width / 2 };
  }

  get x(): number {
    return this.body.x;
  }

  get y(): number {
    return this.body.y;
  }

  get angle(): number {
    return this.body.angle;
  }

  get wrecked(): boolean {
    return this.mode === 'wreck';
  }

  /** Which quarter-turn of a roll the car is in; changes each time a side or roof hits the road. */
  rollQuarter(): number {
    return Math.round(this.dmg.roll / (Math.PI / 2));
  }

  takeQuarter(): boolean {
    const q = this.rollQuarter();
    if (q === this.quarter) return false;
    this.quarter = q;
    return true;
  }
}

const PALETTES: Record<District, string[]> = {
  downtown: ['#3d7dd8', '#e6e6e6', '#2e2f38', '#9aa3ad', '#b33a3a', '#3a6e5a', '#5c4a8a'],
  uptown: ['#111318', '#f2f2f2', '#c0c4ca', '#7a1f2b', '#1f3b6e', '#d9b25a'],
  hood: ['#7a6a55', '#5e7b8a', '#8a4a3a', '#6b6f3a', '#a08a5a', '#4a4f5a'],
  port: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#b33a3a', '#2e2f38'],
  country: ['#8a3b2e', '#3f5e3a', '#c9b28a', '#5b6a7a', '#e6e6e6', '#7a5a3a'],
  village: ['#c0392b', '#2e86c1', '#e6e6e6', '#5d6d7e', '#d4ac0d'],
  industrial: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#ca6f1e', '#2e2f38', '#d4ac0d'],
  highway: ['#3d7dd8', '#e6e6e6', '#2e2f38', '#b33a3a', '#c0c4ca', '#e07a2f', '#2fb5a8'],
  docks: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#b33a3a', '#2e2f38', '#d4ac0d'],
  factory: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#ca6f1e', '#2e2f38'],
  rail: ['#9aa3ad', '#e6e6e6', '#5e7b8a', '#ca6f1e', '#2e2f38'],
  suburb: ['#3d7dd8', '#e6e6e6', '#c0c4ca', '#7a1f2b', '#1f3b6e', '#3a6e5a', '#d9b25a'],
  commercial: ['#3d7dd8', '#e6e6e6', '#2e2f38', '#9aa3ad', '#b33a3a', '#e07a2f'],
  military: ['#4b5320', '#5a5f45', '#3f4a2e', '#6b6f3a', '#2e2f38'],
  rural: ['#8a3b2e', '#3f5e3a', '#c9b28a', '#5b6a7a', '#e6e6e6', '#7a5a3a'],
  town: ['#c0392b', '#2e86c1', '#e6e6e6', '#5d6d7e', '#d4ac0d'],
};
const MODELS: Record<District, [CarModel, number][]> = {
  downtown: [['taxi', 4.5], ['sedan', 4], ['suv', 1.5], ['compact', 2], ['van', 1.5], ['bus', 1], ['motorcycle', 1], ['police', 0.6], ['limo', 0.4]],
  uptown: [['taxi', 1.5], ['sports', 4], ['suv', 3], ['sedan', 1.5], ['limo', 1.2], ['police', 0.4], ['icecream', 0.6]],
  hood: [['taxi', 1], ['beater', 5], ['sedan', 2], ['compact', 2], ['icecream', 0.8], ['motorcycle', 1], ['police', 0.6], ['pickup', 0.6]],
  port: [['van', 3], ['pickup', 2], ['suv', 2], ['bus', 0.5], ['beater', 1]],
  highway: [['sedan', 4], ['suv', 3], ['sports', 2], ['taxi', 1], ['van', 1.5], ['pickup', 1.5], ['motorcycle', 1.5], ['bus', 0.6], ['police', 0.5], ['limo', 0.3]],
  country: [['pickup', 5], ['beater', 2], ['suv', 2], ['sedan', 1.5], ['motorcycle', 1], ['van', 0.6], ['icecream', 0.3]],
  village: [['taxi', 0.6], ['compact', 3], ['sedan', 3], ['pickup', 2], ['van', 1], ['icecream', 0.6], ['police', 0.4]],
  industrial: [['van', 3], ['pickup', 3], ['suv', 1], ['beater', 1.5], ['bus', 0.4]],
  docks: [['van', 3], ['pickup', 3], ['suv', 1], ['beater', 1], ['bus', 0.3]],
  factory: [['van', 3], ['pickup', 3], ['suv', 1], ['beater', 1.5], ['bus', 0.5]],
  rail: [['van', 2], ['pickup', 3], ['suv', 1], ['beater', 1]],
  suburb: [['suv', 4], ['sedan', 3], ['compact', 2], ['sports', 1], ['van', 1], ['icecream', 0.5], ['police', 0.4], ['bus', 0.3]],
  commercial: [['sedan', 4], ['suv', 3], ['compact', 2], ['van', 1.5], ['taxi', 1], ['bus', 0.8], ['police', 0.5], ['motorcycle', 1]],
  military: [['suv', 3], ['pickup', 4], ['van', 2], ['bus', 0.5]],
  rural: [['pickup', 5], ['beater', 2], ['suv', 2], ['sedan', 1], ['motorcycle', 1], ['van', 0.5]],
  town: [['compact', 3], ['sedan', 3], ['pickup', 2], ['van', 1], ['icecream', 0.6], ['police', 0.4], ['taxi', 0.4]],
};

const FIXED_COLORS: Partial<Record<CarModel, string[]>> = {
  taxi: ['#f2c230'],
  bus: ['#f2b705', '#e6e6e6', '#2f6fc0'],
  police: ['#15161c'],
  icecream: ['#fdfdfd'],
  limo: ['#111318', '#f2f2f2'],
};
const RUDE_COLORS = ['#ff2e88', '#a4e021', '#ff6a2a', '#16c8f0', '#ffd400'];
const MAX_WRECKS = 60;

const range = (a: number, b: number) => a + Math.random() * (b - a);
const pick = <T>(items: readonly T[]): T => items[Math.floor(Math.random() * items.length)];
const dot = (a: Vec, b: Vec) => a.x * b.x + a.y * b.y;

function angleDelta(a: number, b: number): number {
  let d = a - b;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  return d;
}

function weighted<T>(items: [T, number][]): T {
  let roll = Math.random() * items.reduce((sum, [, w]) => sum + w, 0);
  for (const [item, w] of items) {
    roll -= w;
    if (roll <= 0) return item;
  }
  return items[0][0];
}

export function inView(x: number, y: number, view: View, margin: number): boolean {
  return x > view.x - margin && x < view.x + view.w + margin && y > view.y - margin && y < view.y + view.h + margin;
}

export class Traffic {
  readonly cars: Car[] = [];
  readonly wrecks: Car[] = [];
  onCrash: ((x: number, y: number, intensity: number, kind: CrashKind, car: Car) => void) | null = null;
  /** A car's fuel tank exploded. */
  onExplode: ((x: number, y: number, size: number) => void) | null = null;
  /** A car was just wrecked (for the A-10's destruction tally). */
  onWrecked: ((car: Car) => void) | null = null;
  /** A motorcyclist is thrown from the bike. */
  onEject: ((car: Car, vx: number, vy: number) => void) | null = null;
  /** A rolling car's roof or side slammed into the road. */
  onRollHit: ((x: number, y: number, energy: number) => void) | null = null;
  onSay: ((car: Car, kind: LineKind) => void) | null = null;
  /** Points other drivers should stop for (pedestrians in the road). */
  obstacles: Vec[] = [];
  /** City service units on the road. */
  blockers: Blocker[] = [];
  /** People hailing a cab. */
  hailers: Hailer[] = [];
  /** Roads closed by the police round a scene. */
  closures: { x: number; y: number; r: number; layer?: number }[] = [];
  private closed = new Set<number>();
  private readonly routing: TrafficRouting;
  private hazardTimer = 0;
  private spawnRetryAt = 0;
  /** A taxi stopped for someone: they get in. */
  onBoard: ((car: Car, who: Hailer) => void) | null = null;
  /** A taxi dropping its fare off at the curb. */
  onDropOff: ((car: Car, x: number, y: number) => void) | null = null;
  private time = 0;

  constructor(
    private readonly city: City,
    private readonly effects: Effects,
  ) { this.routing = new TrafficRouting(city.lanes); }

  all(): Car[] {
    return [...this.cars, ...this.wrecks];
  }

  setCount(count: number, truck: Truck, view: View, rudeness: number): void {
    while (this.cars.length < count && this.time >= this.spawnRetryAt) {
      const car = new Car(makeLook('sedan', '#888888', '#888888', false));
      if (!this.place(car, truck, view, rudeness, this.cars.length < count / 2)) { this.spawnRetryAt = this.time + 0.5; break; }
      this.cars.push(car);
    }
    for (let i = this.cars.length - 1; i >= 0 && this.cars.length > count; i--) {
      if (!inView(this.cars[i].x, this.cars[i].y, view, 30)) this.cars.splice(i, 1);
    }
  }

  clear(): void {
    this.cars.length = 0;
    this.wrecks.length = 0;
    this.routing.clear();
    this.closed.clear();
    this.hazardTimer = 0;
    this.time = 0;
    this.spawnRetryAt = 0;
  }

  update(dt: number, truck: Truck, view: View, rudeness: number): void {
    this.time += dt;
    this.hazardTimer -= dt;
    if (this.hazardTimer <= 0) {
      this.hazardTimer = 0.5;
      this.routing.refresh([
        ...this.wrecks.map((w) => ({ x: w.x, y: w.y, r: Math.hypot(w.body.halfL, w.body.halfW) + 4, layer: w.layer, hard: true })),
        ...this.cars.filter((c) => c.fire > 0 || c.fuse > 0).map((c) => ({ x: c.x, y: c.y, r: 18, layer: c.layer, hard: true })),
        ...this.effects.roadFires().map((f) => ({ ...f, layer: 0, hard: true })),
        ...this.closures.map((c) => ({ ...c, layer: c.layer ?? 0, hard: false })),
      ], this.time);
      this.refreshClosures();
    }
    for (const car of this.cars) {
      car.bubbleTime -= dt;
      car.alert -= dt;
      car.yieldTime -= dt;
      const far = Math.hypot(car.x - truck.x, car.y - truck.y);
      if (far > (car.broken ? 1700 : 1100) && !inView(car.x, car.y, view, 40)) {
        this.place(car, truck, view, rudeness, false);
        continue;
      }
      this.maybeBreakDown(car, dt);
      this.think(car, dt, truck);
      this.advance(car, dt, truck, view, rudeness);
    }
    for (const car of this.wrecks) {
      car.bubbleTime -= dt;
      this.updateWreck(car, dt);
    }
    this.collidePairs();
    // Wrecked cars leave the traffic pool and stay where they died; a fresh car replaces them.
    for (let i = this.cars.length - 1; i >= 0; i--) {
      const car = this.cars[i];
      if (car.mode !== 'wreck') continue;
      this.cars.splice(i, 1);
      this.wrecks.push(car);
      const fresh = new Car(makeLook('sedan', '#888888', '#888888', false));
      if (this.place(fresh, truck, view, rudeness, false)) this.cars.push(fresh);
    }
    while (this.wrecks.length > MAX_WRECKS) {
      const idx = this.wrecks.findIndex((w) => !w.towClaim && !inView(w.x, w.y, view, 60));
      this.wrecks.splice(idx < 0 ? 0 : idx, 1);
    }
  }

  // --- Spawning.

  private place(car: Car, truck: Truck, view: View, rudeness: number, initial: boolean): boolean {
    let spawn: { lane: number; s: number } | null = null;
    for (let attempt = 0; attempt < 90; attempt++) {
      const candidate = this.city.randomSpawn();
      const p = poseAt(this.city.lanes[candidate.lane], candidate.s);
      const d = Math.hypot(p.x - truck.x, p.y - truck.y);
      const ok = initial ? d > 120 && d < 1200 : d > 220 && d < 950 && !inView(p.x, p.y, view, 30);
      if (!ok || !this.routing.safeSpawn(candidate.lane, candidate.s, this.time)) continue;
      if (this.all().some((o) => o !== car && Math.abs(o.x - p.x) < 30 && Math.abs(o.y - p.y) < 30)) continue;
      spawn = candidate;
      break;
    }
    if (!spawn) return false;
    const lane = this.city.lanes[spawn.lane];
    const pose = poseAt(lane, spawn.s);
    const district = this.city.districtAt(pose.x, pose.y);
    // The family's black cars cruise their turf.
    const mob = district === 'hood' && Math.random() < 0.18;
    // Rude drivers are the exception: the setting scales how many, and even they're toned down.
    const rude = !mob && Math.random() * 100 < rudeness * 0.35;
    const model = mob ? pick<CarModel>(['sedan', 'limo', 'suv']) : weighted(MODELS[district]);
    const fixed = FIXED_COLORS[model];
    const color = mob ? '#111318' : rude && !fixed ? pick(RUDE_COLORS) : fixed ? pick(fixed) : pick(PALETTES[district]);
    const fresh = new Car(makeLook(model, color, pick(['#9a9a9a', '#6f7a83', '#b5a58a', '#2f6fc0', '#c0392b']), rude && !fixed));
    fresh.mob = mob;
    Object.assign(car, fresh);
    (car as { body: Body }).body = fresh.body;
    car.lane = spawn.lane;
    car.s = spawn.s;
    car.body.x = pose.x;
    car.body.y = pose.y;
    car.body.angle = pose.angle;
    car.rude = rude;
    car.mob = mob;
    car.drifter = (rude && model !== 'bus' && Math.random() < 0.3) || (model === 'sports' && Math.random() < 0.15);
    car.temper = rude ? range(0.98, 1.1) : range(0.78, 0.95);
    car.cooldown = range(2, 5);
    car.next = this.chooseNext(lane, car);
    car.speed = lane.speed * car.temper * 0.8;
    car.layer = laneLayer(lane, spawn.s);
    return true;
  }

  /** A signal's light for one phase: 9 s green, 2.5 s amber, 1.5 s all-red, then the cross street's turn. */
  /** Is the junction's power on? (A blackout leaves the lights dark.) */
  powered: (x: number, y: number) => boolean = () => true;

  light(signal: Signal, phase: number): 'green' | 'amber' | 'red' | 'dark' {
    if (!this.powered(signal.x, signal.y)) return 'dark';
    const t = (this.time + signal.offset) % 26;
    const local = phase === 0 ? t : (t + 13) % 26;
    return local < 9 ? 'green' : local < 11.5 ? 'amber' : 'red';
  }

  /** Lanes carrying known scene risk, for local police handling. */
  private refreshClosures(): void {
    this.closed.clear();
    // Route decisions use the layer-aware hazard model; closure stop/wave behavior
    // below remains local, and must never override a physical disaster barrier.
    for (const lane of this.city.lanes) {
      if (this.routing.cost(lane.id, this.time, 0, 0, 0) > 0) this.closed.add(lane.id);
    }
  }

  private chooseNext(lane: Lane, car: Car): number {
    return this.routing.choose(lane, this.time, car.routeDelay + (car.rude ? 5 : 0), car.x, car.y, -1);
  }

  // --- Damage.

  /**
   * Apply a hit to a car. `closing` is the impact speed (px/s); (nx, ny) points from
   * the car toward whatever hit it. The outcome depends on the car's model, its
   * build quality, what it hit, where it was hit and a roll of the dice.
   */
  applyHit(car: Car, closing: number, px: number, py: number, nx: number, ny: number, src: HitSource, kind: CrashKind): number {
    if (closing < 6) return 0;
    const b = car.body;
    const matScale = src.material === 'truck' ? 1.15 : src.material === 'concrete' ? 1.1 : src.material === 'barrier' ? 0.8 : src.material === 'shore' ? 0.9 : 1;
    const eff = closing * car.spec.fragility * car.quality * matScale * range(0.85, 1.15);
    if (car.mode === 'drive') {
      if (eff < 14 && car.dmg.roll === 0) {
        car.lat -= (nx * -Math.sin(b.angle) + ny * Math.cos(b.angle)) * Math.min(4, closing * 0.3);
        if (car.cooldown <= 0) this.say(car, car.rude ? 'taunt' : 'polite', 3);
        this.effects.crash(px, py, 0.1, [car.look.color], { x: -nx, y: -ny }, { material: src.material, glass: false });
        return 0.1;
      }
      this.toWreck(car);
      if (car.look.model === 'motorcycle' && !car.dmg.riderGone) {
        car.dmg.riderGone = true;
        this.onEject?.(car, b.vx - nx * closing * 0.8, b.vy - ny * closing * 0.8);
      }
    }
    const c = Math.cos(b.angle);
    const s = Math.sin(b.angle);
    const lx = (px - b.x) * c + (py - b.y) * s;
    const ly = -(px - b.x) * s + (py - b.y) * c;
    const zone = Math.abs(lx) / b.halfL > Math.abs(ly) / b.halfW ? (lx > 0 ? 0 : 1) : ly < 0 ? 2 : 3;
    const crumple = car.dmg.crumple;
    crumple[zone] = Math.min(zone < 2 ? 7 : 4, crumple[zone] + eff * range(0.025, 0.045));
    car.health -= eff * 0.6;
    const glassBroke = !car.dmg.glass && eff > range(18, 34);
    if (glassBroke) car.dmg.glass = true;

    // Engine damage decides the smoke: none, radiator steam, or an oily black plume.
    car.engine += eff * (zone === 0 ? range(0.7, 1.1) : range(0.1, 0.35));
    const smokeBefore = car.smokeKind;
    if (car.engine > 75 || car.fire > 0) car.smokeKind = 'black';
    else if (car.engine > 35 && car.smokeKind === 'none') car.smokeKind = Math.random() < 0.7 ? 'steam' : 'black';
    if (car.smokeKind !== 'none') car.smokeLevel = Math.min(1, car.smokeLevel + eff / 140);

    // Wheels torn off near the impact.
    let wheels = 0;
    if (eff > range(40, 65)) {
      const order = [0, 1, 2, 3].sort((i, j) => {
        const wx = (k: number) => (k < 2 ? 1 : -1) * b.halfL * 0.6;
        const wy = (k: number) => (k % 2 === 0 ? -1 : 1) * b.halfW;
        return Math.hypot(wx(i) - lx, wy(i) - ly) - Math.hypot(wx(j) - lx, wy(j) - ly);
      });
      const count = Math.min(4, Math.floor((eff - 30) / range(30, 55)));
      for (const w of order) {
        if (wheels >= count) break;
        if (!car.dmg.wheels[w]) continue;
        car.dmg.wheels[w] = false;
        wheels++;
      }
    }

    // Sideways shove starts a roll; the harder and higher the hit, the more it spins.
    const side = { x: -s, y: c };
    const lateral = -(nx * side.x + ny * side.y);
    const rollKick = (lateral * eff * range(0.012, 0.03)) / car.spec.stability;
    car.rollVel += rollKick;
    if (src.material === 'truck' && eff > range(90, 140) && car.dmg.z <= 0) {
      // Scooped up by the truck's nose: launched into the air.
      car.vz = 30 + eff * range(0.35, 0.65);
      car.dmg.z = 0.5;
      car.rollVel += (Math.random() < 0.5 ? -1 : 1) * range(4, 12) / car.spec.stability;
    }

    // Fire and fuel-tank explosions get likelier with speed (and with flimsy cars).
    if (eff > 55 && car.fire <= 0 && (eff > 120 || Math.random() < car.spec.fire * Math.min(1, (eff - 55) / 70))) car.fire = range(10, 25);
    if (eff > 150 && !car.exploded) car.fuse = Math.min(car.fuse > 0 ? car.fuse : 9, range(0.05, 0.6));
    if (!car.exploded && car.fuse <= 0 && eff > 90 && Math.random() < car.spec.explode * Math.min(1.6, (eff - 90) / 60)) {
      car.fuse = eff > 160 ? 0.05 : range(1, 4);
      car.fire = Math.max(car.fire, 8);
    }
    if (eff > 18 && car.alarm <= 0 && Math.random() < 0.7) car.alarm = range(18, 35);
    if (eff > 15 && car.bubbleTime < 0.5) this.say(car, 'wreck', 0);

    const k = closing / 100;
    const colors = [car.look.color, car.look.color, '#1b1b1f', '#c9ccd1', src.color];
    this.effects.crash(px, py, (eff / 100) * range(0.85, 1.2), colors, { x: -nx, y: -ny }, {
      wheels,
      panels: eff > range(55, 90) ? 1 + Math.floor(Math.random() * 2) : 0,
      material: src.material,
      glass: glassBroke,
      smoke: car.smokeKind !== smokeBefore ? car.smokeKind : 'none',
      flavor: car.look.model,
    });
    this.onCrash?.(px, py, k, kind, car);
    return k;
  }

  private toWreck(car: Car): void {
    car.mode = 'wreck';
    this.onWrecked?.(car);
    car.wreckTime = 0;
    car.dmg.hazard = false;
    car.bubbleTime = 0;
  }

  /** The fuel tank goes up: fireball, shockwave and a burnt-out shell. */
  private explode(car: Car): void {
    const b = car.body;
    car.exploded = true;
    car.fuse = 0;
    car.fire = Math.max(car.fire, 25);
    car.smokeKind = 'black';
    car.smokeLevel = 1;
    car.dmg.burnt = Math.max(car.dmg.burnt, 0.6);
    car.dmg.glass = true;
    const size = range(0.8, 1.3);
    this.effects.fireball(b.x, b.y, size);
    this.effects.crash(b.x, b.y, 1.2, [car.look.color, '#1b1b1f'], { x: 0, y: 0 }, { panels: 2, wheels: car.dmg.wheels.filter(Boolean).length > 1 ? 1 : 0, glass: true, material: 'car' });
    // The blast throws the car and anything near it.
    car.vz = range(40, 90);
    car.dmg.z = Math.max(car.dmg.z, 0.5);
    car.rollVel += range(-8, 8);
    b.av += range(-4, 4);
    for (const o of this.all()) {
      if (o === car || o.layer !== car.layer) continue;
      const dx = o.x - b.x;
      const dy = o.y - b.y;
      const d = Math.hypot(dx, dy);
      if (d > 50 || d < 0.1) continue;
      const push = (1 - d / 50) * 120 * size;
      if (o.mode === 'drive') this.syncBody(o);
      o.body.vx += (dx / d) * push;
      o.body.vy += (dy / d) * push;
      this.applyHit(o, push, o.x - (dx / d) * o.body.halfW, o.y - (dy / d) * o.body.halfW, -dx / d, -dy / d, { material: 'car', color: '#1b1b1f' }, 'pileup');
    }
    this.onExplode?.(b.x, b.y, size);
  }

  // --- Wreck physics: drifting, rolling over and burning.

  private updateWreck(car: Car, dt: number): void {
    const b = car.body;
    car.wreckTime += dt;
    car.alarm = Math.max(0, car.alarm - dt);
    car.dmg.hazard = car.alarm > 0 && Math.floor(car.wreckTime * 2.5) % 2 === 0;
    if (car.fuse > 0) {
      car.fuse -= dt;
      if (car.fuse <= 0) this.explode(car);
    }
    if (car.fire > 0) {
      car.fire -= dt;
      car.dmg.burnt = Math.min(1, car.dmg.burnt + dt * 0.06);
      if (Math.random() < dt * 24) this.effects.puff(b.x + range(-3, 3), b.y + range(-3, 3), false, true, range(1, 1.9));
      if (Math.random() < dt * 14) this.effects.puff(b.x, b.y, true, false, range(1.6, 2.8));
    } else if (car.smokeKind !== 'none' && car.smokeLevel > 0.02) {
      if (Math.random() < dt * 9 * car.smokeLevel) {
        if (car.smokeKind === 'steam') this.effects.steam(b.x + Math.cos(b.angle) * b.halfL * 0.6, b.y + Math.sin(b.angle) * b.halfL * 0.6, 0.8 + car.smokeLevel);
        else this.effects.puff(b.x + range(-2, 2), b.y + range(-2, 2), true, false, 1 + car.smokeLevel);
      }
      // Radiator steam runs out quickly; oil smoke lingers.
      car.smokeLevel = Math.max(0, car.smokeLevel - dt * (car.smokeKind === 'steam' ? 0.05 : 0.008));
    }

    const steps = 3;
    const h = dt / steps;
    const G = GRAVITY;
    for (let i = 0; i < steps; i++) {
      const airborne = car.dmg.z > 0 || car.vz > 0;
      const r = car.dmg.roll;
      if (airborne) {
        car.vz -= 340 * h;
        car.dmg.z += car.vz * h;
        if (car.dmg.z <= 0) {
          const landing = Math.abs(car.vz);
          car.dmg.z = 0;
          car.vz = 0;
          if (landing > 30) this.slam(car, landing);
        }
      } else {
        const upright = Math.cos(r) > 0.7;
        const sideX = -Math.sin(b.angle);
        const sideY = Math.cos(b.angle);
        const vl = b.vx * sideX + b.vy * sideY;
        if (upright) {
          // Locked or missing wheels: low sideways grip, so the car drifts and spins.
          const missing = car.dmg.wheels.filter((w) => !w).length;
          const mu = 0.55 + missing * 0.12;
          const n = (b.mass * G) / 2;
          for (const ax of [b.halfL * 0.6, -b.halfL * 0.6]) {
            const p = { x: b.x + Math.cos(b.angle) * ax, y: b.y + Math.sin(b.angle) * ax };
            axleGrip(b, p.x, p.y, b.angle, mu * n * h);
          }
          // Tripping: sliding sideways faster than the car's stability can take tips it over.
          const tip = 22 * car.spec.stability;
          if (Math.abs(vl) > tip && Math.abs(r) < 0.6) car.rollVel += Math.sign(vl) * (Math.abs(vl) - tip) * 0.018 / car.spec.stability;
          const v = Math.hypot(b.vx, b.vy);
          if (v > 0) {
            const dv = Math.min(v, mu * 0.6 * G * h);
            b.vx -= (b.vx / v) * dv;
            b.vy -= (b.vy / v) * dv;
          }
          b.av *= Math.exp(-1.1 * h);
          car.skidding = v > 12;
          if (missing && v > 25 && Math.random() < 0.5) this.effects.scrape(b.x, b.y, b.vx, b.vy);
        } else {
          // On its side or roof: metal on asphalt, lots of friction and sparks.
          const v = Math.hypot(b.vx, b.vy);
          if (v > 0) {
            const dv = Math.min(v, 0.85 * G * h);
            b.vx -= (b.vx / v) * dv;
            b.vy -= (b.vy / v) * dv;
          }
          b.av *= Math.exp(-2.5 * h);
          car.skidding = false;
          if (v > 18 && Math.random() < 0.7) this.effects.scrape(b.x, b.y, b.vx, b.vy);
          // A rolling car keeps tumbling while it's still moving sideways.
          if (Math.abs(car.rollVel) > 1.5) car.rollVel += Math.sign(car.rollVel) * Math.min(Math.abs(vl), 60) * 0.004;
        }
        // Gravity settles the body on its wheels, a side or its roof.
        car.rollVel += -Math.sin(4 * r) * 18 * h;
        car.rollVel *= Math.exp(-1.6 * h);
        if (Math.abs(car.rollVel) < 0.25 && Math.abs(Math.sin(2 * r)) < 0.05) {
          car.rollVel = 0;
          car.dmg.roll = Math.round(r / (Math.PI / 2)) * (Math.PI / 2);
        }
      }
      car.dmg.roll += car.rollVel * h;
      if (car.takeQuarter() && !airborne) this.slam(car, Math.abs(car.rollVel) * 9 + Math.hypot(b.vx, b.vy) * 0.3);
      integrate(b, h);
      this.collideWorld(car);
    }
    car.dmg.flipped = Math.cos(car.dmg.roll) < 0;
    car.speed = forwardSpeed(b);
  }

  /** A side, roof or landing hits the road during a roll. */
  private slam(car: Car, energy: number): void {
    if (energy < 12) return;
    const b = car.body;
    car.health -= energy * 0.3;
    const zone = 2 + Math.floor(Math.random() * 2);
    car.dmg.crumple[zone] = Math.min(4, car.dmg.crumple[zone] + energy * 0.02);
    if (energy > 30) car.dmg.glass = true;
    let wheels = 0;
    if (energy > 45 && Math.random() < 0.5) {
      const i = car.dmg.wheels.findIndex(Boolean);
      if (i >= 0) {
        car.dmg.wheels[i] = false;
        wheels = 1;
      }
    }
    this.effects.crash(b.x, b.y, energy / 160, [car.look.color, '#1b1b1f'], { x: b.vx / 200, y: b.vy / 200 }, { wheels, glass: energy > 30, material: 'ground' });
    this.onRollHit?.(b.x, b.y, energy);
  }

  private collideWorld(car: Car): void {
    const b = car.body;
    for (const d of [b.halfL - 4.5, -(b.halfL - 4.5)]) {
      const px = b.x + Math.cos(b.angle) * d;
      const py = b.y + Math.sin(b.angle) * d;
      const push = this.city.resolveCircle(px, py, b.halfW, car.layer);
      if (!push.x && !push.y) continue;
      const material = this.city.lastMaterial;
      const l = Math.hypot(push.x, push.y);
      const cx = px - (push.x / l) * b.halfW;
      const cy = py - (push.y / l) * b.halfW;
      const impact = resolveStatic(b, push, cx, cy, material === 'barrier' ? 0.15 : 0.3, material === 'barrier' ? 0.2 : 0.5);
      if (impact > 20 && car.mode === 'wreck') this.applyHit(car, impact * 0.85, cx, cy, -push.x / l, -push.y / l, { material, color: material === 'barrier' ? '#d4d0c6' : '#8d9096' }, 'wall');
    }
  }

  /** Car-on-car contacts: wrecks slam into traffic and each other. */
  private collidePairs(): void {
    const list = this.all();
    for (let i = 0; i < list.length; i++) {
      const a = list[i];
      for (let j = i + 1; j < list.length; j++) {
        const b = list[j];
        if (a.mode === 'drive' && b.mode === 'drive') continue;
        if (a.layer !== b.layer || a.ghost > 0 || b.ghost > 0) continue;
        if (Math.abs(a.x - b.x) > 22 || Math.abs(a.y - b.y) > 22) continue;
        if (Math.abs(a.dmg.z - b.dmg.z) > 6) continue;
        const c = boxContact(a.body, b.body);
        if (!c) continue;
        // Careful drivers creep into a wreck that's lying still and stop: a bump, not a pile-up.
        const driver = a.mode === 'drive' ? a : b.mode === 'drive' ? b : null;
        const other = driver === a ? b : a;
        if (driver && Math.hypot(other.body.vx, other.body.vy) < 15) {
          this.bump(driver, c.depth);
          continue;
        }
        if (a.mode === 'drive') this.syncBody(a);
        if (b.mode === 'drive') this.syncBody(b);
        const hit = resolveContact(a.body, b.body, c, 0.15, 0.4);
        if (hit.closing > 8) {
          this.applyHit(a, hit.closing, c.px, c.py, c.nx, c.ny, { material: 'car', color: b.look.color }, 'pileup');
          this.applyHit(b, hit.closing, c.px, c.py, -c.nx, -c.ny, { material: 'car', color: a.look.color }, 'pileup');
        }
      }
    }
  }

  /** A driving car nudged something at low speed: back off a touch and wait. */
  bump(car: Car, depth: number): void {
    car.s = Math.max(0, car.s - depth - 0.5);
    car.speed = 0;
    car.brakeTimer = Math.max(car.brakeTimer, 1.2);
  }

  /** Give a lane-following car a real velocity before it takes part in a collision. */
  syncBody(car: Car): void {
    const b = car.body;
    b.vx = Math.cos(b.angle) * car.speed;
    b.vy = Math.sin(b.angle) * car.speed;
  }

  // --- Driving.

  private think(car: Car, dt: number, truck: Truck): void {
    const lane = this.city.lanes[car.lane];
    car.cooldown -= dt;
    car.laneCooldown -= dt;
    car.brakeTimer -= dt;
    car.ghost -= dt;
    if (car.brakeDelay > 0) {
      car.brakeDelay -= dt;
      if (car.brakeDelay <= 0) car.brakeTimer = 0.8;
    }
    car.routeTimer -= dt;
    if (car.routeTimer <= 0 && lane.kind !== 'connector') {
      car.routeTimer = 1.5 + Math.random();
      car.next = this.routing.choose(lane, this.time, car.routeDelay + (car.rude ? 5 : 0), car.x, car.y, car.next);
    }
    const disasterStop = this.routing.stopAt(car.lane, car.s, this.time);
    if (disasterStop < Infinity) { car.ghost = 0; car.waved = 0; }
    const f = { x: Math.cos(car.angle), y: Math.sin(car.angle) };
    const side = { x: -f.y, y: f.x };

    let gap = disasterStop - car.s - car.body.halfL;
    let carGap = Infinity;
    let aheadCar: Car | null = null;
    let blockedByTruck = false;
    let hazard = disasterStop < Infinity;
    // Careful drivers look further ahead and keep a wider berth.
    const look = lane.kind === 'highway' ? 150 : car.rude ? 75 : 100;
    if (truck.layer === car.layer) {
      for (const c of truck.circles()) {
        const rel = { x: c.x - car.x, y: c.y - car.y };
        const along = dot(rel, f);
        if (along > 0 && along < look && Math.abs(dot(rel, side)) < c.r + 7 && along - c.r < gap) {
          gap = along - c.r;
          blockedByTruck = true;
        }
      }
    }
    if (car.ghost <= 0) {
      for (const o of [...this.cars, ...this.wrecks]) {
        if (o === car || o.layer !== car.layer || Math.abs(o.x - car.x) > look || Math.abs(o.y - car.y) > look) continue;
        const rel = { x: o.x - car.x, y: o.y - car.y };
        const along = dot(rel, f);
        // A wreck can lie at any angle: use its real footprint across our path.
        const ra = o.angle - car.angle;
        const wide = o.mode === 'wreck' ? Math.abs(o.body.halfL * Math.sin(ra)) + Math.abs(o.body.halfW * Math.cos(ra)) + car.body.halfW + 1.5 : 9;
        const deep = o.mode === 'wreck' ? Math.abs(o.body.halfL * Math.cos(ra)) + Math.abs(o.body.halfW * Math.sin(ra)) : o.body.halfL;
        if (along > 0 && along < look + deep && Math.abs(dot(rel, side)) < wide) {
          const g = along - (deep + car.body.halfL + 2);
          carGap = Math.min(carGap, g);
          if (o.mode === 'wreck' || o.broken || o.stopTimer > 0) hazard = true;
          if (g < gap) {
            gap = g;
            blockedByTruck = false;
            aheadCar = o;
          }
        }
      }
      // Service units: pass parked ones if there's room, otherwise wait (then squeeze by like any jam).
      for (const o of this.blockers) {
        if (o.layer !== car.layer || Math.abs(o.body.x - car.x) > look || Math.abs(o.body.y - car.y) > look) continue;
        const rel = { x: o.body.x - car.x, y: o.body.y - car.y };
        const along = dot(rel, f);
        if (along > 0 && along < look + o.body.halfL && Math.abs(dot(rel, side)) < o.body.halfW + 5) {
          const g = along - (o.body.halfL + car.body.halfL + 2);
          if (g < gap) {
            gap = g;
            blockedByTruck = false;
            if (o.parked) hazard = true;
          }
        }
      }
    }

    // People and animals: never ghost through them. Spot them early and leave room.
    if (car.layer === 0) {
      for (const p of this.obstacles) {
        const rel = { x: p.x - car.x, y: p.y - car.y };
        const along = dot(rel, f);
        if (along > 0 && along < 70 && Math.abs(dot(rel, side)) < 10 && along - 10 < gap) {
          gap = along - 10;
          hazard = true;
          blockedByTruck = true;
        }
      }
    }

    // Police closures: detour, switch lanes, or wait at the line until an officer waves us past.
    if (this.closed.size && car.waved <= 0) {
      for (const c of this.closures) {
        if ((c.layer ?? 0) !== car.layer) continue;
        const rel = { x: c.x - car.x, y: c.y - car.y };
        const d = Math.hypot(rel.x, rel.y);
        if (d > 160) continue;
        if (d < c.r) {
          car.waved = 6;
          car.ghost = 6;
          break;
        }
        const heading = this.closed.has(car.lane) || (car.next >= 0 && this.closed.has(car.next) && lane.len - car.s < 70);
        const along = dot(rel, f);
        if (!heading || along <= 0) continue;
        if (car.laneCooldown <= 0) {
          car.laneCooldown = 1.5;
          const nb = [lane.left, lane.right].find((n) => n >= 0 && !this.closed.has(n));
          if (nb !== undefined && this.laneClear(nb, (car.s / lane.len) * this.city.lanes[nb].len, car, truck)) this.changeLane(car, nb);
        }
        const g = along - c.r - car.body.halfL;
        if (g < gap) {
          gap = g;
          blockedByTruck = true;
          hazard = true;
        }
        if (car.speed < 2 && g < 25) {
          car.closedWait += dt;
          if (car.closedWait > 4.5) {
            car.closedWait = 0;
            car.waved = 8;
            car.ghost = 8;
          }
        }
      }
    }

    if (disasterStop < Infinity) { car.ghost = 0; car.waved = 0; }

    const turnSpeed = car.drifter ? 36 : 26;
    let target = lane.speed * car.temper;
    if (lane.turn) target = Math.min(target, turnSpeed);
    const upcoming = car.next >= 0 ? this.city.lanes[car.next] : null;
    if (upcoming) {
      const nextSpeed = upcoming.turn ? turnSpeed : upcoming.speed * car.temper;
      target = Math.min(target, nextSpeed + (lane.len - car.s) * 1.2);
    }
    if (car.brakeTimer > 0) target = 0;
    // A siren is coming: pull right over and (nearly) stop until it's by.
    if (car.yieldTime > 0) target = Math.min(target, 4);
    if (car.waved > 0) {
      car.waved -= dt;
      target = Math.min(target, 14);
    }
    if (car.broken) {
      target = 0;
      car.brokenTime += dt;
      blockedByTruck = true;
    }
    if (car.look.model === 'taxi') target = Math.min(target, this.taxi(car, lane, dt, f, side));
    if (car.look.model === 'bus') target = Math.min(target, this.busStop(car, lane, dt));
    if (gap < Infinity) target = Math.min(target, Math.max(0, (gap - 15) * (lane.kind === 'highway' ? 1.4 : 1.7)));
    // Traffic lights: stop at the line on red, and on amber if there's room to stop.
    car.atLight = false;
    const sig = this.city.laneSignal.get(car.lane);
    if (sig && car.waved <= 0) {
      const toLine = lane.len - car.s - 3;
      const light = this.light(sig.signal, sig.phase);
      const canStop = toLine > (car.speed * car.speed) / (2 * 160);
      if (toLine > -2 && (light === 'red' || (light === 'amber' && canStop))) {
        car.atLight = true;
        target = Math.min(target, Math.max(0, toLine * 1.6));
      } else if (light === 'dark' && toLine > -2 && toLine < 45) {
        // A dead signal: treat the junction as an all-way stop and creep across.
        target = Math.min(target, Math.max(10, toLine * 0.9));
      }
    }
    // Yield at junctions: wait if someone is already crossing where we're headed.
    // Don't block the box: only go into a junction if there's room on the far side.
    let boxFull = false;
    if (upcoming?.kind === 'connector' && lane.kind !== 'highway' && car.waved <= 0) {
      const toEnd = lane.len - car.s;
      if (toEnd < 30) {
        const exit = upcoming.next[0];
        boxFull = this.cars.some((o) => o !== car && ((o.lane === exit && o.s < o.body.halfL * 2 + 10 && o.speed < 4) || (o.lane === upcoming.id && o.speed < 3)));
        if (boxFull) target = Math.min(target, Math.max(0, (toEnd - 3) * 1.6));
      }
    }
    if (!sig && upcoming?.kind === 'connector' && lane.len - car.s < 22 && lane.kind !== 'highway') {
      const mid = upcoming.pts[Math.floor(upcoming.pts.length / 2)];
      const crossing = this.cars.some((o) => {
        if (o === car || o.mode !== 'drive' || this.city.lanes[o.lane].kind !== 'connector') return false;
        if (Math.abs(o.x - mid.x) > 26 || Math.abs(o.y - mid.y) > 26) return false;
        let da = Math.abs(o.angle - car.angle) % (Math.PI * 2);
        if (da > Math.PI) da = Math.PI * 2 - da;
        return da > 0.5;
      });
      if (crossing) target = Math.min(target, Math.max(0, (lane.len - car.s - 6) * 2));
    }

    // Waiting in line at a light (or behind someone who is) is normal: no squeezing through.
    car.queued = car.atLight || boxFull || (!!aheadCar && aheadCar.queued && !aheadCar.broken && aheadCar.speed < 4);
    if (car.speed < 4 && target < 4) {
      car.waitTime += dt;
      // Squeeze past a jam: soon if it isn't a proper queue, eventually even if it is (never through our own red).
      if (disasterStop === Infinity && ((car.waitTime > 8 && !blockedByTruck && !car.queued) || (car.waitTime > 25 && !car.atLight))) {
        car.ghost = 1.5;
        car.waitTime = 0;
      }
      if (blockedByTruck && !car.queued && car.waitTime > 2.5 && car.cooldown <= 0) this.say(car, car.rude ? 'taunt' : 'waiting', car.rude ? 3 : 8);
    } else {
      car.waitTime = 0;
    }

    const before = car.speed;
    car.speed += Math.max(-(hazard ? 330 : 260) * dt, Math.min(52 * dt, target - car.speed));
    const decel = (before - car.speed) / Math.max(dt, 1e-3);
    car.braking = decel > 40;
    car.skidding = (decel > 170 && car.speed > 18) || (lane.turn && car.drifter && car.speed > 40);

    if (lane.kind === 'highway' && carGap < 80 && car.laneCooldown <= 0 && car.speed < lane.speed * car.temper - 10) this.tryOvertake(car, lane, truck);
    // Steer around a wreck, a person or an animal if the next lane is free.
    else if (hazard && gap < 45 && car.laneCooldown <= 0 && lane.kind !== 'connector' && (lane.left >= 0 || lane.right >= 0)) this.tryOvertake(car, lane, truck);
    if (car.rude && (lane.kind === 'street' || lane.kind === 'highway')) this.beRude(car, lane, truck, f, side, dt);
  }

  private laneClear(laneId: number, s: number, self: Car, truck: Truck): boolean {
    const p = poseAt(this.city.lanes[laneId], s);
    for (const o of this.all()) if (o !== self && Math.abs(o.x - p.x) < 32 && Math.abs(o.y - p.y) < 32) return false;
    return truck.circles().every((c) => Math.hypot(c.x - p.x, c.y - p.y) > c.r + 14);
  }

  private tryOvertake(car: Car, lane: Lane, truck: Truck): void {
    for (const nb of [lane.left, lane.right]) {
      if (nb < 0) continue;
      const s = (car.s / lane.len) * this.city.lanes[nb].len;
      if (this.routing.safeSpawn(nb, s, this.time) && this.laneClear(nb, s, car, truck)) {
        this.changeLane(car, nb);
        car.laneCooldown = 3;
        return;
      }
    }
    car.laneCooldown = 1;
  }

  private changeLane(car: Car, target: number): void {
    const from = this.city.lanes[car.lane];
    const to = this.city.lanes[target];
    const s = (car.s / from.len) * to.len;
    const p = poseAt(to, s);
    const side = { x: -Math.sin(p.angle), y: Math.cos(p.angle) };
    car.lat = (car.x - p.x) * side.x + (car.y - p.y) * side.y;
    car.lane = target;
    car.s = s;
    car.next = this.chooseNext(to, car);
  }

  private beRude(car: Car, lane: Lane, truck: Truck, f: Vec, side: Vec, dt: number): void {
    if (truck.layer !== car.layer) return;
    const td = { x: Math.cos(truck.angle), y: Math.sin(truck.angle) };
    const aligned = dot(td, f) > 0.85;
    const rel = { x: truck.x - car.x, y: truck.y - car.y };
    const along = dot(rel, f);
    const lat = dot(rel, side);
    if (car.cooldown <= 0 && aligned && truck.speed > 30 && Math.abs(car.lat) < 1) {
      for (const nb of [lane.left, lane.right]) {
        if (nb < 0) continue;
        const q = poseAt(this.city.lanes[nb], (car.s / lane.len) * this.city.lanes[nb].len);
        const truckToLane = (truck.x - q.x) * side.x + (truck.y - q.y) * side.y;
        // Only cut in when there's actually room ahead of the truck.
        if (Math.abs(truckToLane) < 8 && along > -36 && along < -8 && lane.len - car.s > 70 && Math.random() < 0.15) {
          this.changeLane(car, nb);
          car.brakeDelay = 0.9;
          this.say(car, 'cutoff', 14);
          return;
        }
      }
      if (Math.abs(lat) < 8 && along < -30 && along > -75 && truck.speed > 40 && Math.random() < 0.06) {
        car.brakeTimer = 0.7;
        this.say(car, 'brakecheck', 18);
        return;
      }
      car.cooldown = 9;
    }
    if (car.cooldown <= 0 && Math.hypot(rel.x, rel.y) < 60 && Math.random() < dt * 0.5) this.say(car, 'taunt', 4);
  }

  say(car: Car, kind: LineKind, cooldown: number): void {
    car.bubble = pickLine(kind, this.city.lanes[car.lane]?.district ?? 'highway');
    car.bubbleTime = 2.2;
    car.cooldown = Math.max(car.cooldown, cooldown);
    car.alert = 0.8;
    this.onSay?.(car, kind);
  }

  /** Now and then a car breaks down (beaters most of all) and pulls over with its hazards on. */
  private maybeBreakDown(car: Car, dt: number): void {
    if (car.broken) {
      car.dmg.hazard = Math.floor(this.time * 2.5) % 2 === 0;
      if (Math.random() < dt * 1.5) this.effects.steam(car.x + Math.cos(car.angle) * car.body.halfL * 0.6, car.y + Math.sin(car.angle) * car.body.halfL * 0.6, 0.6);
      return;
    }
    const lane = this.city.lanes[car.lane];
    if (lane.kind === 'connector' || lane.turn || car.look.model === 'motorcycle' || car.fare > 0 || lane.len - car.s < 45 || car.s < 15) return;
    const rate = car.look.model === 'beater' ? 1 / 900 : 1 / 5000;
    if (Math.random() >= dt * rate) return;
    car.broken = true;
    car.brokenTime = 0;
    car.bubble = pick(['UGH, NOT AGAIN', 'IS THAT SMOKE?', 'COME ON, BABY...', 'CALLING ROADSIDE']);
    car.bubbleTime = 2.4;
  }

  /** Broken-down cars waiting for help. */
  brokenDown(): Car[] {
    return this.cars.filter((c) => c.broken && c.mode === 'drive');
  }

  /** Roadside assistance got it going again. */
  repair(car: Car): void {
    if (!car.broken) return;
    car.broken = false;
    car.dmg.hazard = false;
    car.bubble = pick(['YOU ARE A LIFESAVER!', 'PURRS LIKE A KITTEN', 'THANKS, MECHANIC!']);
    car.bubbleTime = 2.4;
  }

  /**
   * Cabs for hire pull over for anyone hailing one ahead on their side of the road,
   * carry the fare a while, then drop them off at a curb. Returns the cab's speed cap.
   */
  private taxi(car: Car, lane: Lane, dt: number, f: Vec, side: Vec): number {
    if (car.mode !== 'drive' || car.broken || car.layer !== 0) return Infinity;
    if (car.stopTimer > 0) {
      car.stopTimer -= dt;
      if (car.stopTimer <= 0) {
        if (car.hailer) {
          this.onBoard?.(car, car.hailer);
          car.hailer = null;
          car.fare = range(25, 70);
          car.bubble = pick(['WHERE TO?', 'HOP IN!', 'METER IS RUNNING']);
          car.bubbleTime = 2;
        } else {
          this.onDropOff?.(car, car.x - Math.sin(car.angle) * (car.body.halfW + 5), car.y + Math.cos(car.angle) * (car.body.halfW + 5));
          car.fare = -range(10, 25);
          car.bubble = pick(['HAVE A NICE DAY!', 'KEEP THE CHANGE?', 'FIVE STARS PLEASE']);
          car.bubbleTime = 2;
        }
      }
      return 0;
    }
    if (car.fare > 0) {
      car.fare -= dt;
      if (car.fare <= 0 && lane.kind === 'street' && !lane.turn && lane.len - car.s > 30) {
        car.stopTimer = 1.6;
        return 0;
      }
      if (car.fare <= 0) car.fare = 2;
      return Infinity;
    }
    if (car.fare < 0) {
      car.fare = Math.min(0, car.fare + dt);
      return Infinity;
    }
    if (lane.kind !== 'street') return Infinity;
    if (car.hailer && !this.hailers.includes(car.hailer)) car.hailer = null;
    if (!car.hailer) {
      for (const h of this.hailers) {
        const rel = { x: h.x - car.x, y: h.y - car.y };
        const along = dot(rel, f);
        const lat = dot(rel, side);
        if (along > 12 && along < 70 && lat > 3 && lat < 18 && !this.cars.some((o) => o !== car && o.hailer === h)) {
          car.hailer = h;
          break;
        }
      }
    }
    if (!car.hailer) return Infinity;
    const along = dot({ x: car.hailer.x - car.x, y: car.hailer.y - car.y }, f);
    if (along < 4 && car.speed < 3) {
      car.stopTimer = 1.4;
      return 0;
    }
    if (along < -10) {
      car.hailer = null;
      return Infinity;
    }
    car.stopTimer = 0;
    return Math.max(0, along * 1.6);
  }

  /** City buses pull in at stops along the streets to let people off and take on anyone waiting. */
  private busStop(car: Car, lane: Lane, dt: number): number {
    if (car.mode !== 'drive' || car.broken || car.layer !== 0) return Infinity;
    if (car.stopTimer > 0) {
      car.stopTimer -= dt;
      if (car.stopTimer <= 0) {
        const off = 1 + Math.floor(Math.random() * 3);
        for (let i = 0; i < off; i++) this.onDropOff?.(car, car.x - Math.sin(car.angle) * (car.body.halfW + 5) + Math.cos(car.angle) * (i * 4 - 4), car.y + Math.cos(car.angle) * (car.body.halfW + 5) + Math.sin(car.angle) * (i * 4 - 4));
        for (const h of this.hailers) if (Math.hypot(h.x - car.x, h.y - car.y) < 30) this.onBoard?.(car, h);
        car.busTimer = range(25, 45);
      }
      return 0;
    }
    if (lane.kind !== 'street' || lane.turn) return Infinity;
    car.busTimer -= dt;
    if (car.busTimer <= 0 && lane.len - car.s > 40 && car.s > 20) {
      car.stopTimer = range(2.5, 4);
      car.bubble = pick(['NEXT STOP!', 'MIND THE DOORS', 'EXACT CHANGE ONLY']);
      car.bubbleTime = 1.8;
      return 0;
    }
    return Infinity;
  }

  /** A city service unit written off in a crash joins the other wrecks. */
  adoptWreck(look: CarLook, body: Body, layer: number, dmg: CarDamage): Car {
    const car = new Car(look);
    Object.assign(car.body, { x: body.x, y: body.y, vx: body.vx, vy: body.vy, angle: body.angle, av: body.av });
    car.layer = layer;
    car.mode = 'wreck';
    car.dmg = { ...dmg, crumple: [...dmg.crumple], wheels: [...dmg.wheels], hazard: false };
    car.health = 0;
    this.wrecks.push(car);
    return car;
  }

  /** A tow truck hauls a wreck away. */
  removeWreck(car: Car): void {
    const i = this.wrecks.indexOf(car);
    if (i >= 0) this.wrecks.splice(i, 1);
  }

  /** Burning wrecks near a point (for the fire brigade). */
  burningNear(x: number, y: number, r: number): Vec[] {
    return this.wrecks.filter((w) => (w.fire > 0 || w.fuse > 0) && Math.hypot(w.x - x, w.y - y) < r).map((w) => ({ x: w.x, y: w.y }));
  }

  /** Hose down burning wrecks near a point. */
  extinguish(x: number, y: number, r: number): void {
    for (const w of this.wrecks) {
      if (Math.hypot(w.x - x, w.y - y) > r) continue;
      w.fire = 0;
      w.fuse = 0;
      if (w.smokeKind === 'black') w.smokeKind = 'steam';
      w.smokeLevel = Math.min(w.smokeLevel, 0.5);
    }
  }

  /** Nearby drivers react to the truck's air horn. */
  hearHorn(x: number, y: number): void {
    for (const car of this.cars) {
      if (Math.hypot(car.x - x, car.y - y) > 110 || car.cooldown > 1) continue;
      if (Math.random() < 0.6) this.say(car, car.rude ? 'taunt' : 'polite', 4);
    }
  }

  private advance(car: Car, dt: number, truck: Truck, view: View, rudeness: number): void {
    let lane = this.city.lanes[car.lane];
    const b = car.body;
    const prevAngle = b.angle;
    car.s += car.speed * dt;
    while (car.s >= lane.len) {
      const next = car.next >= 0 ? car.next : this.chooseNext(lane, car);
      if (next < 0) {
        if (!this.place(car, truck, view, rudeness, false)) { car.s = lane.len; car.speed = 0; }
        return;
      }
      car.s -= lane.len;
      car.lane = next;
      lane = this.city.lanes[next];
      car.next = this.chooseNext(lane, car);
    }
    const p = poseAt(lane, car.s);
    const prevLat = car.lat;
    // Pulled over to the right (broken down, letting a siren by, or a taxi at the curb).
    const pull = (car.broken || car.yieldTime > 0 || car.stopTimer > 0) && lane.kind !== 'connector' ? (lane.kind === 'highway' ? 5 : 4) : 0;
    if (pull) car.lat += (pull - car.lat) * Math.min(1, dt * 2.5);
    else car.lat *= Math.exp(-4.5 * dt);
    if (Math.abs(car.lat) < 0.05) car.lat = 0;
    const sx = -Math.sin(p.angle);
    const sy = Math.cos(p.angle);
    b.x = p.x + sx * car.lat;
    b.y = p.y + sy * car.lat;
    const latSpeed = (car.lat - prevLat) / Math.max(dt, 1e-3);
    // Drifters hang the tail out through corners.
    const turnRate = lane.turn ? angleDelta(p.angle, car.pathAngle) / Math.max(dt, 1e-3) : 0;
    car.drift += ((car.drifter && lane.turn && car.speed > 38 ? Math.max(-0.7, Math.min(0.7, turnRate * 0.35)) : 0) - car.drift) * Math.min(1, dt * 6);
    car.pathAngle = p.angle;
    b.angle = p.angle + Math.atan2(latSpeed, Math.max(car.speed, 15)) + car.drift;
    b.vx = Math.cos(b.angle) * car.speed;
    b.vy = Math.sin(b.angle) * car.speed;
    b.av = (b.angle - prevAngle) / Math.max(dt, 1e-3);
    car.layer = laneLayer(lane, car.s);
  }
}