Back to Pursuit PIT
SOURCE / PINNED RELEASE

Made of little things.

Pursuit PIT

Release
565f4981bdc3…
Author-recorded commit
197afdbed44b…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7

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

src/game/sim.ts
// Top-down 2D simulation of the highway. World axes: x is lateral (right is +),
// s is distance along the road (forward is +). yaw 0 faces +s; positive yaw turns
// toward +x. JS numbers are doubles, so s can grow forever without rebasing.
// Space stays road-relative through bends and hills (road.ts); cars only feel their forces.
import { ALL_PARTS, nearestPart } from './parts.js';
import { curvature, grade } from './road.js';
import { Birds } from './birds.js';
import { closedAhead, closedLanes, leftWall, trafficAt } from './zones.js';
import { randomSuspectColor, suspectHex, type SuspectColor } from './colors.js';

export const LANE_W = 3.7;
export const LANES = 6;
export const EDGE = (LANES * LANE_W) / 2; // painted edge line
export const SHOULDER = 1.3;
export const ROAD_HALF = EDGE + SHOULDER; // guardrail position
export const laneX = (lane: number): number => (lane - (LANES - 1) / 2) * LANE_W;

// On-ramps join from the right every RAMP_EVERY metres. Ramp traffic comes down a curved
// slip road, then uses an acceleration (merge) lane outside lane 5 before it tapers away.
// For everyone else the right guardrail just steps out around the merge lane, and an
// invisible barrier closes the gore where the slip road meets it, so the player can't leave.
export const RAMP_EVERY = 1100;
export const RAMP_OFFSET = 500;
export const RAMP_CURVE = 300; // length of the curved part of the slip road
export const RAMP_SWING = 45; // lateral distance the curve covers
export const RAMP_GORE = 70;
export const MERGE_LEN = 230;
export const MERGE_TAPER = 70;
export const MERGE_X = laneX(LANES); // the merge lane sits where a 7th lane would
const WALL_OUT = EDGE + LANE_W + SHOULDER;

export const MPS_TO_MPH = 2.23694;
/** Top speed of the patrol car: 160 mph. */
export const TOP_SPEED = 160 / MPS_TO_MPH;
/** The suspect's car tops out a little lower: 155 mph. */
export const SUSPECT_TOP_SPEED = 155 / MPS_TO_MPH;
/** Traffic cruises between 60 and 100 mph. */
export const TRAFFIC_MIN = 60 / MPS_TO_MPH;
export const TRAFFIC_MAX = 100 / MPS_TO_MPH;
const ESCAPE_GAP = 700;
const SPIN_OUT = 1.05; // rad away from the road direction counts as a wreck
const RIG_SPIN_OUT = 0.8; // a rig slewed this far has jackknifed: it's a wreck sooner
const RIG_SHAKEN = 4; // m/s impact that unsettles a rig: it loses grip and starts to swing
const EXPLODE_SPEED = 7; // m/s into the guardrail that makes a (non-player) car explode
const DAMAGE_SPEED = 0.8; // m/s closing speed at contact that knocks a panel off
// Traffic never blocks more than this many lanes in any ROUTE_BAND-long stretch,
// so there is always a way through for the player (and the suspect).
export const MAX_BLOCKED = LANES - 2;
export const ROUTE_BAND = 40;

/** Start of the ramp whose zone (slip road to end of taper) covers s. */
export function rampAt(s: number): number {
  return Math.floor((s - RAMP_OFFSET + RAMP_CURVE + 260) / RAMP_EVERY) * RAMP_EVERY + RAMP_OFFSET;
}

/** Slip-road centreline x for a point d metres before the ramp joins (d >= 0). */
export function rampX(d: number): number {
  if (d <= RAMP_CURVE) return MERGE_X + RAMP_SWING * (d / RAMP_CURVE) ** 2;
  return MERGE_X + RAMP_SWING + ((2 * RAMP_SWING) / RAMP_CURVE) * (d - RAMP_CURVE);
}

/** dx/ds of the slip road (negative: it heads left toward the highway). */
function rampSlope(d: number): number {
  return -((2 * RAMP_SWING) / RAMP_CURVE) * Math.min(1, Math.max(0, d) / RAMP_CURVE);
}

/** Right guardrail (or gore barrier) position at s. */
export function rightWall(s: number): number {
  const d = s - rampAt(s);
  if (d < 0 || d > MERGE_LEN + MERGE_TAPER) return ROAD_HALF;
  if (d <= MERGE_LEN) return WALL_OUT;
  return WALL_OUT + (ROAD_HALF - WALL_OUT) * ((d - MERGE_LEN) / MERGE_TAPER);
}

/** Right painted edge at s (the merge lane moves it out). */
export function rightEdge(s: number): number {
  return rightWall(s) - SHOULDER;
}

export type Kind = 'player' | 'suspect' | 'traffic' | 'log' | 'police';

/** What a vehicle is: sets its size and weight here, and its model in the renderer. */
export type VehicleType = 'sedan' | 'sports' | 'pickup' | 'lifted' | 'box' | 'semi' | 'tanker' | 'logger' | 'log';

/** Size, weight and share of traffic for each vehicle type. Rigs are tractor + trailer. */
export const VEHICLES: Record<VehicleType, { len: number; wid: number; mass: number; share: number }> = {
  sedan: { len: 4.7, wid: 1.9, mass: 1500, share: 40 },
  sports: { len: 4.3, wid: 1.85, mass: 1250, share: 10 },
  pickup: { len: 5.5, wid: 2.0, mass: 2300, share: 15 },
  lifted: { len: 5.6, wid: 2.15, mass: 2800, share: 7 },
  box: { len: 6.5, wid: 2.2, mass: 2600, share: 10 },
  semi: { len: 17, wid: 2.5, mass: 16000, share: 11 }, // rigs need more room to spawn, so ask for more
  tanker: { len: 17, wid: 2.5, mass: 18000, share: 7 },
  logger: { len: 17, wid: 2.5, mass: 17000, share: 7 },
  log: { len: 8.5, wid: 0.6, mass: 900, share: 0 }, // a log fallen off a logging truck
};

/** Tractor-trailer rigs: slow, kept right, and too big for the knock-off panels. */
export const RIGS: ReadonlySet<VehicleType> = new Set<VehicleType>(['semi', 'tanker', 'logger']);
const LOG_DROP_SPEED = 5; // m/s impact that shakes a logging truck's load loose
/**
 * A rig in two parts: a 6.2 m tractor at the front and a 12.2 m trailer hinged on its fifth
 * wheel, 5.2 m back from the front bumper. The trailer reaches 0.4 m ahead of the hinge, and
 * its wheels are 9.4 m behind it.
 */
const RIG_TRACTOR = 6.2;
const RIG_HITCH = 5.2; // from the front
const RIG_TRAILER = 12.2;
const RIG_OVERHANG = 0.4; // trailer ahead of the hitch
const RIG_AXLE = 9.4; // hitch to trailer wheels
const RIG_JACKKNIFE = 1.3; // most the trailer can fold, rad
const TRAILER_KICK = 3; // m/s impact needed to swing a trailer about its hitch

/** The hitch point in world terms. */
function hitchOf(car: Car): [number, number] {
  const back = car.len / 2 - RIG_HITCH;
  return [car.x + Math.sin(car.yaw) * back, car.s + Math.cos(car.yaw) * back];
}

/** A vehicle's outline as boxes: one for most, tractor and trailer for a rig. */
export function shapesOf(car: Car): Box[] {
  if (!RIGS.has(car.type)) return [car];
  const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw);
  const ahead = car.len / 2 - RIG_TRACTOR / 2;
  const [hx, hs] = hitchOf(car);
  const back = RIG_TRAILER / 2 - RIG_OVERHANG;
  return [
    { x: car.x + fx * ahead, s: car.s + fs * ahead, yaw: car.yaw, len: RIG_TRACTOR, wid: car.wid },
    { x: hx - Math.sin(car.trailerYaw) * back, s: hs - Math.cos(car.trailerYaw) * back, yaw: car.trailerYaw, len: RIG_TRAILER, wid: car.wid },
  ];
}

/** Lowest lane a rig will use: rigs stay out of the two left lanes. */
const RIG_LANE = 2;
// Road works close left lanes: no one moves into one that is shut just ahead (zones.ts).
const laneOk = (car: Car, lane: number) => lane >= (RIGS.has(car.type) ? RIG_LANE : 0) && lane < LANES
  && lane >= closedAhead(car.s - 20, 600);
/**
 * At most this many open lanes may be taken in any stretch: two always stay free, or one
 * where the road is down to three lanes (the mountains).
 */
export const maxBlocked = (s: number): number => {
  const open = LANES - closedLanes(s);
  return open <= 3 ? open - 1 : open - 2;
};

function randomType(): VehicleType {
  const types = (Object.keys(VEHICLES) as VehicleType[]).filter((k) => VEHICLES[k].share > 0);
  let r = Math.random() * types.reduce((n, k) => n + VEHICLES[k].share, 0);
  for (const k of types) if ((r -= VEHICLES[k].share) < 0) return k;
  return 'sedan';
}

export interface Car {
  id: number;
  kind: Kind;
  x: number;
  s: number;
  yaw: number;
  vx: number;
  vs: number;
  w: number;
  len: number;
  wid: number;
  mass: number;
  inertia: number;
  color: number;
  maxSpeed: number;
  accel: number;
  // AI state
  lane: number; // LANES means the merge lane
  targetX: number;
  cruise: number;
  laneTimer: number;
  ramp: number | null; // ramp start while driving down the slip road
  // driver commands, set each step by the player input or the AI
  cmdAccel: number;
  cmdYawRate: number;
  // lost grip after a hit; wrecked cars never recover
  slide: number;
  // enforceRoute's request to stay below holdSpeed until holdUntil
  holdSpeed: number;
  holdUntil: number;
  // How drivers react to the pursuit (see reactToChase).
  notice: number; // how far ahead of the patrol car this driver hears the siren
  yielding: boolean;
  yieldSide: number;
  hearAt: number; // when a driver who has heard the siren gets round to moving over (-1: not yet heard)
  // Traffic lights: indicator (-1 left, 1 right, 0 off), and a signalled lane change waiting to
  // be made (lane, and when the driver looks again and goes); brake lights.
  signal: number;
  pendingLane: number;
  pendingAt: number;
  braking: boolean;
  routeMovedAt: number; // when enforceRoute last moved this car over (so it isn't bounced straight back)
  panicUntil: number;
  startleUntil: number;
  startleDir: number;
  type: VehicleType;
  cargoLost: boolean; // a logging truck that has dropped its logs
  // Damage: bit i set means panel i (parts.ts) has been knocked off.
  lost: number;
  lastDamage: number;
  exploded: boolean;
  // Rigs: the trailer's own heading, hinged at the fifth wheel (see trailerFollow).
  trailerYaw: number;
  wrecked: boolean;
}

export interface Input {
  throttle: number; // -1 brake/reverse .. 1 gas
  steer: number; // -1 left .. 1 right
}

// Getaway mode: 'gotaway' (police left GETAWAY_GAP behind), 'disabled' (every police car
// wrecked), 'busted' (spun out or boxed in with police around).
export type Outcome = 'running' | 'pit' | 'crash' | 'escaped' | 'gotaway' | 'disabled' | 'busted';

/** Seconds before a driver who hears the siren starts to move over (plus up to the spread). */
const SIREN_REACTION = 1.2, SIREN_REACTION_SPREAD = 2;
/** Seconds a driver indicates before changing lanes (plus up to the spread). */
const SIGNAL_FIRST = 1.1, SIGNAL_FIRST_SPREAD = 1;

/** The patrol car's paint; in a getaway the player drives a civilian colour instead. */
export const PATROL_COLOR = 0x1d2128;
/** Getaway: how far the nearest police car must fall behind for you to get away. */
export const GETAWAY_GAP = 600;
const POLICE_COUNT = 8;
/** Getaway police top speed: 165 mph, a little more than your 160. */
const POLICE_TOP_SPEED = 165 / MPS_TO_MPH;

export interface SimEvent {
  // birdsWarn: a bird strike is coming; birds: the flock arrives; bird: one hit a car
  type: 'pit' | 'bump' | 'wall' | 'crash' | 'damage' | 'explode' | 'logs' | 'birdsWarn' | 'birds' | 'bird';
  strength: number;
  x: number;
  s: number;
  y?: number; // bird: height of the hit
  other?: Car; // the non-player car involved, if any
  // damage / explode: which car, which panel (explode: bitmask of panels still on), its velocity
  car?: number;
  part?: number;
  vx?: number;
  vs?: number;
}

const TRAFFIC_COLORS = [0xd8d8d0, 0x3a5fa8, 0x2e2e33, 0x8a1f24, 0x6f7f86, 0x2f6b45, 0xc9b27c, 0x5a4a7a];

let nextId = 1;

function makeCar(kind: Kind, x: number, s: number, speed: number, type: VehicleType = 'sedan'): Car {
  const { len, wid, mass } = VEHICLES[type];
  const lane = nearestLane(x);
  return {
    id: nextId++, kind, x, s, yaw: 0, vx: 0, vs: speed, w: 0,
    len, wid, mass, inertia: (mass * (len * len + wid * wid)) / 12,
    color: kind === 'player' || kind === 'police' ? PATROL_COLOR
      : TRAFFIC_COLORS[Math.floor(Math.random() * TRAFFIC_COLORS.length)],
    maxSpeed: kind === 'traffic' ? TRAFFIC_MAX : kind === 'suspect' ? SUSPECT_TOP_SPEED : TOP_SPEED,
    accel: kind === 'player' || kind === 'police' ? 9.5 : kind === 'suspect' ? 8 : RIGS.has(type) ? 2.2 : type === 'sports' ? 4.5 : 3,
    lane, targetX: laneX(lane), cruise: speed, laneTimer: 4 + Math.random() * 14, ramp: null,
    cmdAccel: 0, cmdYawRate: 0, slide: 0, holdSpeed: 0, holdUntil: -1, wrecked: false,
    // A few drivers never notice the siren until it's right behind them.
    notice: Math.random() < 0.15 ? 25 : 60 + Math.random() * 160,
    yielding: false, yieldSide: 1, hearAt: -1, signal: 0, pendingLane: -1, pendingAt: 0, braking: false, routeMovedAt: -9, panicUntil: -1, startleUntil: -9, startleDir: 1,
    // Pickups have an open bed where the trunk lid would be.
    type, cargoLost: false, lost: type === 'pickup' || type === 'lifted' ? 1 << 3 : 0, lastDamage: -1, exploded: false, trailerYaw: 0,
  };
}

export function nearestLane(x: number): number {
  return Math.max(0, Math.min(LANES - 1, Math.round(x / LANE_W + (LANES - 1) / 2)));
}

export function wrapAngle(a: number): number {
  return Math.atan2(Math.sin(a), Math.cos(a));
}

const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));

export function forwardSpeed(car: Car): number {
  return car.vx * Math.sin(car.yaw) + car.vs * Math.cos(car.yaw);
}

/** Lateral speed relative to the car's heading: how much it is sliding sideways. */
export function slipSpeed(car: Car): number {
  return car.vx * Math.cos(car.yaw) - car.vs * Math.sin(car.yaw);
}

export class Sim {
  cars: Car[] = [];
  player: Car;
  suspect: Car;
  time = 0;
  outcome: Outcome = 'running';
  density = 1;
  input: Input = { throttle: 0, steer: 0 };
  // Player contact with the guardrail (speed along it) and wheels on the edge-line rumble strip;
  // the frame loop reads and clears these for the audio.
  scrape = 0;
  rumble = false;
  /** Whether the patrol car's siren is on; traffic moves over for it. */
  sirenOn = true;
  lastRearHit = -99; // last time the player struck the back half of the suspect
  private escapeTimer = 0;
  // The suspect getting unstuck: 'reverse' out, then 'around' the obstacle toward x, until past s.
  private escape: { mode: 'drive' | 'reverse' | 'around'; stuck: number; timer: number; x: number; from: number; past: number } =
    { mode: 'drive', stuck: 0, timer: 0, x: 0, from: 0, past: 0 };
  private stepCount = 0;
  private rampTimers = new Map<number, number>();
  events: SimEvent[] = [];

  /** False for free drive: no suspect on the road, nothing to win or lose. */
  readonly pursuit: boolean;
  /** Bird strikes (pursuit and free drive). */
  birds: Birds | null = null;

  /** The suspect's paint, as Dispatch calls it. */
  readonly suspectColor: SuspectColor;

  /** Getaway mode: the player is the one running, with POLICE_COUNT police cars after them. */
  readonly getaway: boolean;
  police: Car[] = [];
  private bustTimer = 0;

  constructor(density = 1, pursuit = true, suspectColor: SuspectColor = randomSuspectColor(), getaway = false) {
    this.density = density;
    this.pursuit = pursuit;
    this.suspectColor = suspectColor;
    this.player = makeCar('player', laneX(2), 0, 30);
    this.suspect = makeCar('suspect', laneX(3), 130, 40);
    this.suspect.color = suspectHex(suspectColor);
    this.suspect.cruise = SUSPECT_TOP_SPEED;
    this.cars.push(this.player);
    if (pursuit) this.cars.push(this.suspect);
    this.getaway = getaway && !pursuit;
    if (this.getaway) {
      // You're in the civilian car now, with three patrol cars close behind.
      this.player.color = suspectHex(suspectColor);
      for (let i = 0; i < POLICE_COUNT; i++) {
        // A head start: they set off 180-240 m back, and pull away a little slower than you do.
        const cop = makeCar('police', laneX([1, 3, 4, 2, 0, 5, 1, 4][i]), -180 - i * 30, 30);
        cop.accel = 8.5;
        cop.maxSpeed = POLICE_TOP_SPEED;
        cop.laneTimer = 0;
        this.police.push(cop);
        this.cars.push(cop);
      }
    }
    this.birds = new Birds(0); // bird strikes in both modes
    this.fillTraffic(true, 0);
  }

  get gap(): number {
    return this.pursuit ? this.suspect.s - this.player.s : 0;
  }

  /** Getaway: how far behind the nearest police car still in the chase is (Infinity if none). */
  get policeGap(): number {
    let gap = Infinity;
    for (const c of this.police) if (!c.wrecked) gap = Math.min(gap, this.player.s - c.s);
    return gap;
  }

  step(dt: number, input: Input): void {
    this.time += dt;
    this.input = input;
    this.drivePlayer(input);
    for (const car of this.cars) {
      if (car.kind === 'traffic') this.driveTraffic(car, dt);
      else if (car.kind === 'suspect') this.driveSuspect(car, dt);
      else if (car.kind === 'police') this.drivePolice(car, dt);
    }
    for (const car of this.cars) if (car.kind === 'traffic') this.brakeLights(car);
    for (const car of this.cars) integrate(car, dt);
    for (const car of this.cars) if (RIGS.has(car.type)) trailerFollow(car, dt);
    this.collide();
    for (const car of this.cars) if (car.ramp === null) this.wall(car);

    for (const car of this.cars) {
      if (car.kind !== 'player' && car.kind !== 'log' && !car.wrecked && Math.abs(wrapAngle(car.yaw)) > (RIGS.has(car.type) ? RIG_SPIN_OUT : SPIN_OUT)) {
        car.wrecked = true;
        if (car.type === 'logger') this.dropLogs(car, car.x + 1);
        const near = Math.hypot(car.x - this.player.x, car.s - this.player.s) < 120;
        if (near) this.events.push({ type: 'crash', strength: Math.hypot(car.vx, car.vs), x: car.x, s: car.s, other: car });
      }
    }
    const p = this.player;
    for (const [x, s] of cornersOf(p)) {
      if (x < Math.max(-EDGE, leftWall(s) + 0.2) || x > rightEdge(s)) this.rumble = true;
    }
    if (this.pursuit && this.outcome === 'running') {
      if (this.suspect.wrecked) {
        this.outcome = this.time - this.lastRearHit < 4 ? 'pit' : 'crash';
      } else if (this.gap > ESCAPE_GAP) {
        this.escapeTimer += dt;
        if (this.escapeTimer > 3) this.outcome = 'escaped';
      } else {
        this.escapeTimer = 0;
      }
    }
    if (this.getaway && this.outcome === 'running') {
      const active = this.police.filter((c) => !c.wrecked);
      if (!active.length) this.outcome = 'disabled';
      else if (this.policeGap >= GETAWAY_GAP) {
        this.escapeTimer += dt;
        if (this.escapeTimer > 2) this.outcome = 'gotaway';
      } else {
        this.escapeTimer = 0;
      }
      // Busted: spun round, or stopped dead, with a police car right there.
      const near = active.some((c) => Math.hypot(c.x - p.x, c.s - p.s) < 40);
      const spun = Math.abs(wrapAngle(p.yaw)) > 1.3;
      const stopped = Math.abs(forwardSpeed(p)) < 2;
      this.bustTimer = near && (spun || stopped) ? this.bustTimer + dt : 0;
      if (this.bustTimer > (spun ? 1.2 : 3)) this.outcome = 'busted';
    }
    this.fillTraffic(false, dt);
    if (this.stepCount++ % 6 === 0) this.enforceRoute();
    const seen = this.events.length;
    this.birds?.update(dt, this.time, this.cars, this.player, this.events);
    for (let i = seen; i < this.events.length; i++) if (this.events[i].type === 'bird') this.birdHit(this.events[i]);
  }

  /**
   * Keep two lanes open everywhere: predict where traffic will be in 2 s, and if any
   * stretch would have more than MAX_BLOCKED lanes taken, the rearmost moving car of that
   * stretch is told to drop back below the speed of the others.
   */
  private enforceRoute(): void {
    const HORIZON = 2;
    const pts = this.cars
      .filter((c) => c.kind === 'traffic' && c.ramp === null)
      .map((c) => {
        const v = c.wrecked ? 0 : forwardSpeed(c);
        const lane = c.wrecked ? nearestLane(c.x) : c.lane;
        return { car: c, lane, v, at: c.s + Math.max(0, v) * HORIZON };
      })
      .filter((p) => p.lane < LANES)
      .sort((a, b) => a.at - b.at);
    const width = ROUTE_BAND + 15; // a margin past the rule itself
    for (let i = 0; i < pts.length; i++) {
      let j = i;
      const lanes = new Set<number>();
      while (j < pts.length && pts[j].at - pts[i].at <= width) lanes.add(pts[j++].lane);
      if (lanes.size <= maxBlocked(pts[i].at)) continue;
      const group = pts.slice(i, j);
      // Wrecks are an emergency, not traffic flow: cars steer round them instead.
      if (group.some((p) => p.car.wrecked)) continue;
      // First choice: a car alone in an edge-of-group lane slides over into a lane the group
      // already uses, which frees its lane without slowing anyone.
      const count = new Map<number, number>();
      for (const p of group) count.set(p.lane, (count.get(p.lane) ?? 0) + 1);
      const mover = group.find((p) => count.get(p.lane) === 1 && p.car.slide <= 0 && this.time - p.car.routeMovedAt > 3 && [p.lane - 1, p.lane + 1]
        .some((l) => count.has(l) && laneOk(p.car, l) && this.laneClear(p.car, l, 10, 14)));
      if (mover) {
        const l = [mover.lane - 1, mover.lane + 1].find((l) => count.has(l) && laneOk(mover.car, l) && this.laneClear(mover.car, l, 10, 14))!;
        this.signalLane(mover.car, l, true);
        mover.car.laneTimer = 8;
        mover.car.routeMovedAt = this.time;
        continue;
      }
      const rear = group.reduce((a, b) => (b.car.s < a.car.s ? b : a));
      const slowest = Math.min(...group.filter((p) => p !== rear).map((p) => p.v));
      // Never hold a car to a crawl, or held cars hold up the cars behind them and it all jams.
      rear.car.holdSpeed = Math.max(TRAFFIC_MIN * 0.6, slowest - 4);
      rear.car.holdUntil = this.time + 2;
    }
  }

  private drivePlayer(input: Input): void {
    const p = this.player;
    const vf = forwardSpeed(p);
    let a: number;
    // Pull stays strong enough to beat drag right up to the limiter, so the car really hits 160.
    if (input.throttle > 0) a = vf < p.maxSpeed ? p.accel * input.throttle * (1 - 0.55 * Math.max(0, vf) / p.maxSpeed) : 0;
    else if (input.throttle < 0) a = vf > 0.5 ? 16 * input.throttle : vf > -9 ? 5 * input.throttle : 0;
    else a = 0;
    a -= Math.sign(vf) * (0.4 + 0.00035 * vf * vf);
    p.cmdAccel = a;
    const speedFactor = clamp(Math.abs(vf) / 7, 0, 1) / (1 + Math.abs(vf) / 45);
    const steerRate = 1.5 * speedFactor * Math.sign(vf || 1);
    // Steering assist: with no input, gently line the car back up with a straight road.
    // It fades out as a bend starts: the car never turns into a bend by itself, so it
    // runs wide unless the driver steers.
    const roadYaw = wrapAngle(p.yaw);
    const straight = clamp(1 - Math.abs(curvature(p.s)) * 5000, 0, 1);
    p.cmdYawRate = input.steer !== 0 ? input.steer * steerRate
      : Math.abs(roadYaw) < 1.3 ? -roadYaw * 1.6 * clamp(Math.abs(vf) / 8, 0, 1) * straight : 0;
  }

  private driveTraffic(car: Car, dt: number): void {
    if (car.wrecked) {
      car.signal = 0;
      return;
    }
    if (car.ramp === null && car.lane !== LANES) this.updateSignal(car);
    if (car.ramp !== null) {
      this.driveRamp(car);
      return;
    }
    if (car.lane === LANES) {
      this.driveMerge(car);
      return;
    }
    car.laneTimer -= dt;
    const ahead = this.carAhead(car, 30 + Math.max(0, forwardSpeed(car)) * 1.3);
    // Get around wrecks and stopped cars instead of queueing behind them forever.
    const obstacle = ahead && (ahead.wrecked || forwardSpeed(ahead) < 6) && ahead.s - car.s < 45;
    const closing = !laneOk(car, car.lane);
    if (closing) {
      // Step right, even into a lane that is closing too, on the way to an open one: in a
      // proper gap while there's room, taking a tighter one only near the barrels.
      const next = car.lane + 1;
      const late = this.closureStart(car.s) - car.s < 150;
      if (next < LANES && (late ? this.laneClear(car, next, 8, 10) : this.laneClear(car, next, 14, 18))) {
        this.signalLane(car, next, late);
      }
    } else if (car.laneTimer <= 0 && (obstacle || (!car.yielding && car.pendingLane < 0))) {
      car.laneTimer = obstacle ? 0.5 : 6 + Math.random() * 16;
      const dir = Math.random() < 0.5 ? -1 : 1;
      for (const next of [car.lane + dir, car.lane - dir]) {
        if (laneOk(car, next) && this.laneClear(car, next, 14, 18) && (obstacle || this.routeOk(car, next))) {
          this.signalLane(car, next, Boolean(obstacle)); // round a wreck there's no time to wait
          break;
        }
      }
    }
    let target = car.cruise;
    if (ahead) {
      const gap = ahead.s - car.s - (ahead.len + car.len) / 2;
      // Ease off early enough to settle 10 m behind it, braking at no more than ~4 m/s².
      target = Math.min(target, Math.max(0, forwardSpeed(ahead) + Math.sign(gap - 10) * Math.sqrt(2 * 4 * Math.abs(gap - 10))));
    }
    // Told by enforceRoute to drop back so traffic doesn't wall off the road.
    if (car.holdUntil > this.time) target = Math.min(target, car.holdSpeed);
    // Still in a closing lane: stop short of the barrels rather than drive into them.
    if (closing) target = Math.min(target, Math.sqrt(2 * 4 * Math.max(0, this.closureStart(car.s) - car.s - 12)));
    target = this.reactToChase(car, target);
    this.steerAI(car, target);
  }

  /**
   * How real traffic behaves around a police pursuit:
   * - Hearing the siren closing from behind, most drivers move over and slow down: left
   *   lanes go left and right lanes go right, opening the middle (the end lanes hug the shoulder).
   *   Some only notice late, and a few panic and stand on the brakes.
   * - The fleeing car blasting past makes drivers flinch away and lift off.
   * - Everyone slows to look at wrecks and fires.
   */
  /**
   * A bird smacks into a windscreen: the driver flinches. Most stand on the brakes for a moment,
   * about half jerk the wheel to one side (reactToChase carries both out). The patrol car is
   * yours to drive, so it only gets a jolt of camera shake.
   */
  private birdHit(e: SimEvent): void {
    const car = this.cars.find((c) => c.id === e.car);
    if (!car || car.kind === 'player' || car.wrecked || car.ramp !== null) return;
    if (Math.random() < 0.7) car.panicUntil = this.time + 0.8 + Math.random();
    if (Math.random() < 0.5) {
      car.startleUntil = this.time + 0.6 + Math.random() * 0.5;
      car.startleDir = Math.random() < 0.5 ? -1 : 1;
    }
  }

  /**
   * Move a traffic car over a lane. Drivers indicate first and look again before they go; in a
   * hurry (round a wreck, out of a closing lane at the barrels) they indicate as they move.
   */
  private signalLane(car: Car, next: number, now = false): void {
    car.signal = Math.sign(next - car.lane);
    if (now) {
      car.lane = next;
      car.targetX = laneX(next);
      car.pendingLane = -1;
    } else if (car.pendingLane !== next) {
      car.pendingLane = next;
      car.pendingAt = this.time + SIGNAL_FIRST + Math.random() * SIGNAL_FIRST_SPREAD;
    }
  }

  /** Make a signalled lane change once the driver has looked again, and cancel the indicator once across. */
  private updateSignal(car: Car): void {
    if (car.pendingLane >= 0) {
      if (this.time < car.pendingAt) return;
      // Still a gap (that leaves the way through open)? Go. Otherwise think better of it.
      const forced = car.yielding || !laneOk(car, car.lane); // moving over for the siren, or out of a closing lane
      if (laneOk(car, car.pendingLane) && this.laneClear(car, car.pendingLane, 10, 12)
        && (forced || this.routeOk(car, car.pendingLane))) {
        car.lane = car.pendingLane;
        car.targetX = laneX(car.lane);
      } else {
        car.signal = 0;
      }
      car.pendingLane = -1;
      return;
    }
    if (car.signal !== 0 && Math.abs(car.x - car.targetX) < 0.35) car.signal = 0;
  }

  /** Brake lights: on while the driver is braking (with a little hysteresis) or held at a stop. */
  private brakeLights(car: Car): void {
    if (car.wrecked) {
      car.braking = false;
      return;
    }
    const stopped = forwardSpeed(car) < 0.5 && car.cmdAccel <= 0.05;
    car.braking = stopped || car.cmdAccel < (car.braking ? -0.3 : -1);
  }

  private reactToChase(car: Car, target: number): number {
    const p = this.player;
    const behind = car.s - p.s;
    const closing = forwardSpeed(p) - forwardSpeed(car);
    const hears = this.sirenOn && behind > 0 && behind < car.notice && closing > 3;
    if (!car.yielding) {
      // It takes a moment to place the siren and decide which way to go before they move.
      if (!hears) car.hearAt = -1;
      else if (car.hearAt < 0) car.hearAt = this.time + SIREN_REACTION + Math.random() * SIREN_REACTION_SPREAD;
      else if (this.time >= car.hearAt) {
        car.yielding = true;
        car.hearAt = -1;
        // Left lanes move left, right lanes right; a rig always moves right (it's kept there).
        car.yieldSide = car.lane <= (LANES - 1) / 2 && !RIGS.has(car.type) ? -1 : 1;
        if (Math.random() < 0.1) car.panicUntil = this.time + 0.8 + Math.random();
      }
    } else if (behind < -12 || !this.sirenOn || behind > car.notice + 80) {
      car.yielding = false;
      car.targetX = laneX(car.lane);
    }
    if (car.yielding) {
      const edge = car.yieldSide < 0 ? 0 : LANES - 1;
      if (car.lane !== edge) {
        const next = car.lane + car.yieldSide;
        if (car.pendingLane < 0 && this.laneClear(car, next, 8, 10)) this.signalLane(car, next);
      } else if (car.pendingLane < 0) {
        car.targetX = laneX(edge) + car.yieldSide * 1.3; // hug the shoulder
        if (Math.abs(car.x - car.targetX) > 0.5) car.signal = car.yieldSide;
      }
      target = Math.min(target, car.cruise * 0.72);
    }
    if (car.panicUntil > this.time) target = Math.min(target, car.cruise * 0.35);

    const t = this.suspect;
    const ahead = car.s - t.s;
    if (this.pursuit && !t.wrecked && ahead > 0 && ahead < 25 && Math.abs(t.x - car.x) < 3.2
      && forwardSpeed(t) - forwardSpeed(car) > 12 && car.startleUntil < this.time - 3) {
      car.startleUntil = this.time + 0.9;
      car.startleDir = t.x < car.x ? 1 : -1;
    }
    if (car.startleUntil > this.time) {
      car.targetX = laneX(car.lane) + car.startleDir * 1.1;
      target = Math.min(target, car.cruise * 0.8);
    } else if (!car.yielding) {
      car.targetX = laneX(car.lane);
    }

    for (const o of this.cars) {
      const d = o.s - car.s;
      if (o !== car && o.wrecked && d > -10 && d < 90) {
        target = Math.min(target, car.cruise * (o.exploded ? 0.5 : 0.65));
        break;
      }
    }
    return target;
  }

  private driveRamp(car: Car): void {
    const r0 = car.ramp!;
    const d = r0 - car.s;
    if (d <= 0) {
      car.ramp = null;
      car.lane = LANES;
      car.targetX = MERGE_X;
      this.driveMerge(car);
      return;
    }
    // Follow the slip road, queueing behind the ramp car ahead.
    car.targetX = rampX(Math.max(0, d - 6));
    car.signal = d < 150 ? -1 : 0; // indicating to join, coming down to the merge
    let target = 27; // ~60 mph down the slip road
    const ahead = this.cars
      .filter((o) => o !== car && o.ramp === r0 && o.s > car.s)
      .reduce<Car | undefined>((best, o) => (!best || o.s < best.s ? o : best), undefined);
    if (ahead) target = Math.min(target, Math.max(0, forwardSpeed(ahead) + (ahead.s - car.s - 14) * 0.4));
    this.steerAI(car, target, rampSlope(d));
  }

  private driveMerge(car: Car): void {
    const left = LANES - 1;
    car.signal = -1;
    car.pendingLane = -1;
    if (this.laneClear(car, left, 12, 16) && this.routeOk(car, left)) {
      car.lane = left;
      car.targetX = laneX(left);
      this.steerAI(car, car.cruise);
      return;
    }
    // Run out of merge lane: stop and wait for a gap.
    const remaining = rampAt(car.s) + MERGE_LEN - car.s;
    let target = Math.min(car.cruise, Math.max(0, (remaining - 8) * 0.6));
    const ahead = this.carAhead(car, 35);
    if (ahead) target = Math.min(target, Math.max(0, forwardSpeed(ahead) + (ahead.s - car.s - 12) * 0.35));
    this.steerAI(car, target);
  }

  private driveSuspect(car: Car, dt: number): void {
    if (car.wrecked) return;
    const vf = forwardSpeed(car);
    if (car.lane >= LANES) car.lane = LANES - 1;

    // Stuck (boxed in, behind a wreck, pinned by the patrol car): back out, then floor it round.
    if (this.escape.mode === 'drive') {
      this.escape.stuck = vf < 2.5 && car.slide <= 0 ? this.escape.stuck + dt : 0;
      if (this.escape.stuck > 0.5) this.startEscape(car);
    }
    if (this.escape.mode === 'reverse') {
      this.escape.timer -= dt;
      // Back up hard, swinging the nose toward the gap (reversing with the heading turned
      // that way moves the front out toward it).
      const wantYaw = clamp((this.escape.x - car.x) * 0.1, -0.32, 0.32);
      car.cmdAccel = vf > -6 ? -9 : 0;
      car.cmdYawRate = clamp(3 * (wantYaw - wrapAngle(car.yaw)), -1.4, 1.4) * Math.min(1, Math.abs(vf) / 3 + 0.25);
      const blockedBehind = this.cars.some((o) => o !== car && o.ramp === null && Math.abs(o.x - car.x) < 2.2
        && car.s - o.s > 0 && car.s - o.s < (car.len + o.len) / 2 + 1.5);
      if (this.escape.timer <= 0 || car.s < this.escape.from - 5 || blockedBehind) {
        this.escape.mode = 'around';
        this.escape.timer = 4;
      }
      return;
    }
    if (this.escape.mode === 'around') {
      this.escape.timer -= dt;
      car.targetX = this.escape.x;
      car.lane = nearestLane(this.escape.x);
      this.steerAI(car, car.cruise, 0, 2); // flat out, steering hard for the gap
      if (this.escape.timer <= 0 || car.s > this.escape.past) {
        this.escape.mode = 'drive';
        this.escape.stuck = 0;
        car.targetX = laneX(car.lane);
      }
      return;
    }

    car.laneTimer -= dt;
    const gapToPlayer = car.s - this.player.s;
    const pressured = gapToPlayer < 30 && gapToPlayer > -10;
    const ahead = this.carAhead(car, 40 + Math.max(0, vf) * 1.6);
    const gap = ahead ? ahead.s - car.s - (ahead.len + car.len) / 2 : Infinity;
    // Something slower in its path: decide now, not when the lane timer next comes round.
    const threat = ahead !== undefined && forwardSpeed(ahead) < vf - 2 && gap < vf * 1.4 + 20;
    if (car.laneTimer <= 0 || threat) {
      // Pick the lane with the longest open road ahead; a pursuer close behind makes it jumpier.
      let best = car.lane;
      const shut = closedAhead(car.s, 250);
      let bestFree = car.lane < shut ? -100 - (shut - car.lane) * 10 : this.freeAhead(car, car.lane, 200) + (pressured || threat ? 0 : 15);
      for (const next of [car.lane - 1, car.lane + 1]) {
        // Out of a closing lane only rightward, toward the open ones.
        if ((next < shut && !(car.lane < shut && next > car.lane)) || next >= LANES || !this.laneClear(car, next, 5, 7)) continue;
        const free = this.freeAhead(car, next, 200) + (pressured ? Math.random() * 40 : 0);
        if (free > bestFree + 5) {
          best = next;
          bestFree = free;
        }
      }
      if (best !== car.lane) {
        car.lane = best;
        car.targetX = laneX(best);
        car.laneTimer = pressured ? 0.6 + Math.random() * 0.6 : 0.6;
      } else {
        car.laneTimer = 0.15;
      }
    }
    let target = car.cruise;
    if (ahead) {
      // Brake hard but late, like someone with nothing to lose: v = v_ahead + sqrt(2 * a * gap).
      target = Math.min(target, Math.max(0, forwardSpeed(ahead) + Math.sqrt(2 * 10 * Math.max(0, gap - 5))));
    }
    this.steerAI(car, target, 0, 1.8);
  }

  /**
   * Getaway police: close in flat out (160 mph), switching lanes the moment anything blocks
   * them, then pull alongside a rear quarter of the player's car and turn in for the PIT.
   * The first goes for the left side, the second the right, the third whichever is open.
   */
  private drivePolice(car: Car, dt: number): void {
    if (car.wrecked) return;
    const p = this.player;
    const vf = forwardSpeed(car), pv = forwardSpeed(p);
    const role = this.police.indexOf(car);
    const behind = p.s - car.s;
    const shut = closedAhead(car.s, 300);
    car.laneTimer -= dt;
    // Anything other than the player in our path, or where we're steering.
    let obstacle: Car | undefined, gap = Infinity;
    const range = 40 + Math.max(0, vf) * 2.4;
    for (const o of this.cars) {
      if (o === car || o === p || o.ramp !== null) continue;
      const d = o.s - car.s;
      if (d <= 0 || d > range) continue;
      const clear = (o.wid + car.wid) / 2 + 0.6;
      if (Math.abs(o.x - car.x) > clear && Math.abs(o.x - car.targetX) > clear) continue;
      const g = d - (o.len + car.len) / 2;
      if (g < gap) {
        gap = g;
        obstacle = o;
      }
    }
    let target: number;
    let agile = 2.6;
    if (behind > 35 || behind < -10) {
      // Chasing: the open lane nearest the player's, picked again the moment something blocks it.
      if (car.laneTimer <= 0 || (obstacle && gap < vf * 2 + 25)) {
        const here = nearestLane(car.x);
        let best = Math.max(here, shut), bestScore = -Infinity;
        for (const l of [here - 1, here, here + 1]) {
          if (l < shut || l < 0 || l >= LANES) continue;
          if (l !== here && !this.laneClear(car, l, 3, 4)) continue;
          const score = this.freeAhead(car, l, 160) - Math.abs(laneX(l) - p.x) * 2 - (l === here ? 0 : 3);
          if (score > bestScore) {
            bestScore = score;
            best = l;
          }
        }
        car.lane = best;
        car.targetX = laneX(best);
        car.laneTimer = 0.05;
      }
      // Flat out to catch up; if it has got ahead, it drops back to box the player in.
      target = behind < -10 ? Math.max(0, pv - 5) : car.maxSpeed;
    } else {
      // Alongside: nose level with a rear quarter, then turn in hard.
      const side = role === 0 ? -1 : role === 1 ? 1 : car.x < p.x ? -1 : 1;
      const besideX = p.x + side * ((p.wid + car.wid) / 2 + 0.5);
      const lineS = p.s - p.len * 0.32 - car.len * 0.5;
      const lined = Math.abs(car.s - lineS) < 1.2 && Math.abs(car.x - besideX) < 0.9;
      car.targetX = lined ? p.x + side * p.wid * 0.5 : besideX;
      car.targetX = clamp(car.targetX, leftWall(car.s) + 1.2, rightWall(car.s) - 1.2);
      car.lane = nearestLane(car.targetX);
      target = lined ? pv + 2 : pv + clamp((lineS - car.s) * 1.5, -10, 12);
      agile = lined ? 3 : 2.6;
    }
    // Brake hard and late for whatever's in the way.
    if (obstacle) target = Math.min(target, Math.max(0, forwardSpeed(obstacle) + Math.sqrt(2 * 14 * Math.max(0, gap - 4))));
    this.steerAI(car, target, 0, agile);
    // Police brakes bite harder than everyone else's (steerAI stops at 11 m/s²).
    if (target < vf) car.cmdAccel = Math.max(-14, Math.min(car.cmdAccel, (target - vf) * 1.6 * agile));
  }

  /** The suspect is stuck: pick the best way round (a lane either side, or the shoulder) and back out. */
  private startEscape(car: Car): void {
    const options: number[] = [car.lane - 1, car.lane + 1].filter((l) => l >= 0 && l < LANES).map(laneX);
    // On an outside lane the shoulder is a way round too.
    if (car.lane === 0) options.push(laneX(0) - 1.8);
    if (car.lane === LANES - 1) options.push(laneX(LANES - 1) + 1.8);
    const room = (x: number) => {
      // Clear road ahead along that line, ignoring anything well behind.
      let free = 80;
      for (const o of this.cars) {
        if (o === car || o.ramp !== null || Math.abs(o.x - x) > 2.4) continue;
        const d = o.s - car.s;
        if (d > -4 && d < free) free = Math.max(0, d);
      }
      return free;
    };
    const best = options.reduce((a, b) => (room(b) > room(a) ? b : a), options[0]);
    const block = this.carAhead(car, 15);
    this.escape = {
      mode: 'reverse', stuck: 0, timer: 1.2, x: best, from: car.s,
      past: (block ? block.s + block.len / 2 : car.s) + car.len + 6,
    };
  }

  /** slope: the road's dx/ds where the car is (slip roads), so it steers ahead of the curve. */
  /** agile > 1 steers harder and quicker (the suspect). */
  private steerAI(car: Car, targetSpeed: number, slope = 0, agile = 1): void {
    const vf = forwardSpeed(car);
    const roadVel = slope * vf;
    const wantLat = clamp(roadVel + 1.1 * agile * (car.targetX - car.x) - 0.35 * (car.vx - roadVel), -9 * agile, 9 * agile);
    const wantYaw = Math.asin(clamp(wantLat / Math.max(vf, 6), -0.4 * Math.min(agile, 1.4), 0.4 * Math.min(agile, 1.4)));
    car.cmdAccel = clamp((targetSpeed - vf) * 1.6 * agile, -11, car.accel);
    // Plus the turn rate that holds the line through a bend.
    car.cmdYawRate = clamp(3.2 * agile * (wantYaw - wrapAngle(car.yaw)) + curvature(car.s) * vf, -1.2 * agile, 1.2 * agile);
  }

  private bandCars(car: Car, x: number, halfWidth: number): Car[] {
    return this.cars.filter((o) => o !== car && o.ramp === null && Math.abs(o.x - x) < halfWidth);
  }

  /** Nearest car ahead in our path or where we're steering (one pass, no lists: it runs for every car, every step). */
  private carAhead(car: Car, range: number): Car | undefined {
    let best: Car | undefined, bestD = range;
    const steering = car.targetX !== car.x;
    for (const o of this.cars) {
      if (o === car || o.ramp !== null) continue;
      const d = o.s - car.s;
      if (d <= 0 || d >= bestD) continue;
      if (Math.abs(o.x - car.x) < 2.6 || (steering && Math.abs(o.x - car.targetX) < 2.4)) {
        best = o;
        bestD = d;
      }
    }
    return best;
  }

  private freeAhead(car: Car, lane: number, range: number): number {
    let free = range;
    for (const o of this.bandCars(car, laneX(lane), 2.5)) {
      const d = o.s - car.s;
      if (d > -2 && d < free) {
        // A car moving at our speed is not much of an obstacle.
        const closing = forwardSpeed(car) - forwardSpeed(o);
        free = Math.min(free, closing < 2 ? d + 30 : d);
      }
    }
    return free;
  }

  /** Where the next lane closure's barrels start (or far ahead if there is none). */
  private closureStart(s: number): number {
    for (let d = 0; d <= 400; d += 5) if (closedLanes(s + d)) return s + d;
    return s + 1000;
  }

  /** No one in the lane near us, counting extra room for faster cars coming up from behind. */
  private laneClear(car: Car, lane: number, behind: number, ahead: number): boolean {
    const v = forwardSpeed(car);
    // Includes cars already heading into that lane, so two cars don't merge into the same gap.
    const x = laneX(lane);
    const near = this.cars.filter((o) => o !== car && o.ramp === null && (Math.abs(o.x - x) < 2.8 || o.lane === lane));
    return !near.some((o) => {
      const d = o.s - car.s;
      const extra = (o.len + car.len) / 2 - 4.7; // room for long vehicles beyond a car's length
      return d > -(behind + extra + Math.max(0, forwardSpeed(o) - v) * 2.5) && d < ahead + extra + Math.max(0, v - forwardSpeed(o)) * 2.5;
    });
  }

  /** Highway lanes taken by traffic (moving or wrecked) between s0 and s1. */
  lanesUsed(s0: number, s1: number, skip?: Car): Set<number> {
    const used = new Set<number>();
    for (const o of this.cars) {
      if (o === skip || o.kind !== 'traffic' || o.ramp !== null || o.s < s0 || o.s > s1) continue;
      const lane = o.wrecked ? nearestLane(o.x) : o.lane;
      if (lane < LANES) used.add(lane);
    }
    return used;
  }

  /** Would traffic still leave two lanes open in every stretch containing car at (lane, s)? */
  private routeOk(car: Car, lane: number, s = car.s): boolean {
    const pts: [number, number][] = [[s, lane]];
    for (const o of this.cars) {
      if (o === car || o.kind !== 'traffic' || o.ramp !== null || Math.abs(o.s - s) > ROUTE_BAND) continue;
      const l = o.wrecked ? nearestLane(o.x) : o.lane;
      if (l < LANES) pts.push([o.s, l]);
    }
    if (new Set(pts.map((p) => p[1])).size <= maxBlocked(s)) return true;
    for (const [start] of pts) {
      if (start > s || start < s - ROUTE_BAND) continue;
      const used = new Set(pts.filter((p) => p[0] >= start && p[0] <= start + ROUTE_BAND).map((p) => p[1]));
      if (used.size > maxBlocked(start)) return false;
    }
    return true;
  }

  private fillTraffic(initial: boolean, dt: number): void {
    // Traffic lives around the chase: the player and (in a pursuit) the suspect.
    const ends = this.pursuit ? [this.player.s, this.suspect.s] : [this.player.s];
    // Getaway: the police within 600 m drive through traffic too.
    for (const c of this.police) if (!c.wrecked && this.player.s - c.s < 600) ends.push(c.s);
    const lo = Math.min(...ends) - 440;
    const hi = Math.max(...ends) + 1600; // traffic out toward the end of the view
    this.cars = this.cars.filter((c) => (c.kind !== 'traffic' && c.kind !== 'log') || (c.s > lo - (c.ramp !== null ? 400 : 0) && c.s < hi));
    // Towed away: wrecks vanish once well out of the player's sight (behind, or past the fog).
    this.cars = this.cars.filter((c) => !(c.kind === 'traffic' && c.wrecked && (c.s < this.player.s - 150 || c.s > this.player.s + 450)));
    let area = 0;
    for (let k = 0; k < 8; k++) area += trafficAt(lo + ((hi - lo) * (k + 0.5)) / 8) / 8;
    const wanted = Math.round(((hi - lo) / 12) * this.density * area);
    let traffic = this.cars.filter((c) => c.kind === 'traffic' && c.ramp === null && c.lane < LANES).length;
    let tries = 0;
    while (traffic < wanted && tries++ < 12) {
      let s: number;
      if (initial) s = lo + 40 + Math.random() * (hi - lo - 40);
      else if (Math.random() < 0.82) s = hi - 200 + Math.random() * 190;
      else s = lo + 10 + Math.random() * 80;
      const type = randomType();
      const rig = RIGS.has(type);
      const lane = rig ? RIG_LANE + Math.floor(Math.random() * (LANES - RIG_LANE)) : Math.floor(Math.random() * LANES);
      const x = laneX(lane);
      const len = VEHICLES[type].len;
      const blocked = this.cars.some((o) => Math.abs(o.x - x) < 3
        && Math.abs(o.s - s) < (o.len + len) / 2 + (o.kind === 'traffic' ? 17 : 40));
      if (blocked) continue;
      // Slower traffic keeps right, like a real highway.
      // 60-100 mph: slower traffic keeps right, faster traffic uses the left lanes.
      const speed = TRAFFIC_MIN + ((LANES - 1 - lane) / (LANES - 1)) * (TRAFFIC_MAX - TRAFFIC_MIN) * 0.75
        + Math.random() * (TRAFFIC_MAX - TRAFFIC_MIN) * 0.25;
      // Rigs run at truck speeds (55-68 mph); sports cars a bit faster than the traffic around them.
      const cruise = rig ? Math.min(speed, (55 + Math.random() * 13) / MPS_TO_MPH)
        : type === 'sports' ? Math.min(TRAFFIC_MAX, speed + 8 / MPS_TO_MPH) : speed;
      const car = makeCar('traffic', x, s, cruise, type);
      if (rig) car.maxSpeed = 70 / MPS_TO_MPH;
      if (!laneOk(car, lane) || !this.routeOk(car, lane)) continue;
      this.cars.push(car);
      traffic++;
    }

    // On-ramp traffic for ramps around the chase.
    for (let r0 = rampAt(lo + 200); r0 < hi + 300; r0 += RAMP_EVERY) {
      const spawnD = RAMP_CURVE + 120;
      let timer = this.rampTimers.get(r0) ?? (initial ? 0 : Math.random() * 3);
      timer -= dt;
      if (timer <= 0) {
        const s = r0 - spawnD;
        const clear = !this.cars.some((o) => o.ramp === r0 && o.s - s < 30);
        if (clear) {
          // Anything but a rig comes down the ramp.
          let type = randomType();
          while (RIGS.has(type)) type = randomType();
          const car = makeCar('traffic', rampX(spawnD), s, 22, type);
          car.ramp = r0;
          car.lane = LANES;
          car.yaw = Math.atan(rampSlope(spawnD));
          car.vx = Math.sin(car.yaw) * 22;
          car.vs = Math.cos(car.yaw) * 22;
          car.cruise = TRAFFIC_MIN + Math.random() * 7; // merges into the slow lane at 60-75 mph
          car.targetX = rampX(spawnD);
          this.cars.push(car);
          timer = (5 + Math.random() * 7) / this.density;
        } else {
          timer = 1;
        }
      }
      this.rampTimers.set(r0, timer);
    }
    for (const r0 of this.rampTimers.keys()) if (r0 < lo - 500) this.rampTimers.delete(r0);
  }

  private collide(): void {
    const cars = this.cars;
    for (let i = 0; i < cars.length; i++) {
      for (let j = i + 1; j < cars.length; j++) {
        const a = cars[i];
        const b = cars[j];
        const reach = (Math.max(a.len, a.wid) + Math.max(b.len, b.wid)) / 2 + 0.5;
        if (Math.abs(a.s - b.s) > reach || Math.abs(a.x - b.x) > reach) continue;
        for (const sa of shapesOf(a)) {
          for (const sb of shapesOf(b)) {
            const hit = obb(sa, sb);
            if (hit) this.resolve(a, b, hit);
          }
        }
      }
    }
  }

  private resolve(a: Car, b: Car, hit: Hit): void {
    const { nx, ns, px, ps, depth } = hit;
    const wa = a.w, wb = b.w;
    const rax = px - a.x, ras = ps - a.s;
    const rbx = px - b.x, rbs = ps - b.s;
    const va = pointVel(a, rax, ras);
    const vb = pointVel(b, rbx, rbs);
    const rvx = vb[0] - va[0], rvs = vb[1] - va[1];
    const vn = rvx * nx + rvs * ns;

    // Push apart, heavier car moves less.
    const total = a.mass + b.mass;
    a.x -= nx * depth * (b.mass / total);
    a.s -= ns * depth * (b.mass / total);
    b.x += nx * depth * (a.mass / total);
    b.s += ns * depth * (a.mass / total);
    if (vn >= 0) return;

    const ca = perpDot(rax, ras, nx, ns);
    const cb = perpDot(rbx, rbs, nx, ns);
    const denom = 1 / a.mass + 1 / b.mass + (ca * ca) / a.inertia + (cb * cb) / b.inertia;
    const jn = (-(1 + 0.25) * vn) / denom;
    applyImpulse(a, -jn * nx, -jn * ns, rax, ras);
    applyImpulse(b, jn * nx, jn * ns, rbx, rbs);
    // Only a solid hit swings a trailer: brushes in traffic would set off a chain of
    // swinging trailers clipping the next lane.
    if (-vn > TRAILER_KICK) {
      kickTrailer(a, px, ps, -jn * nx, -jn * ns);
      kickTrailer(b, px, ps, jn * nx, jn * ns);
    }

    // Tangential friction scrapes cars along each other.
    const tx = -ns, ts = nx;
    const vt = rvx * tx + rvs * ts;
    const cta = perpDot(rax, ras, tx, ts);
    const ctb = perpDot(rbx, rbs, tx, ts);
    const denomT = 1 / a.mass + 1 / b.mass + (cta * cta) / a.inertia + (ctb * ctb) / b.inertia;
    const jt = clamp(-vt / denomT, -0.35 * jn, 0.35 * jn);
    applyImpulse(a, -jt * tx, -jt * ts, rax, ras);
    applyImpulse(b, jt * tx, jt * ts, rbx, rbs);

    const strength = -vn;
    for (const [car, w0] of [[a, wa], [b, wb]] as const) {
      if (!RIGS.has(car.type) || car.wrecked || strength < RIG_SHAKEN) continue;
      // A heavy rig hardly turns from the impulse itself, but its driver loses it: grip goes
      // and the trailer swings the way the hit pushed it.
      car.slide = Math.max(car.slide, 0.4 + strength * 0.1);
      const dir = Math.sign(car.w - w0) || (Math.random() < 0.5 ? -1 : 1);
      car.w += dir * Math.min(0.55, 0.12 + strength * 0.035);
    }
    this.damage(a, px, ps, strength);
    this.damage(b, px, ps, strength);
    for (const car of [a, b]) if (car.type === 'logger' && strength > LOG_DROP_SPEED) this.dropLogs(car, px);
    const player = a.kind === 'player' ? a : b.kind === 'player' ? b : undefined;
    const other = player === a ? b : a;
    const suspect = a.kind === 'suspect' ? a : b.kind === 'suspect' ? b : undefined;
    const police = a.kind === 'police' ? a : b.kind === 'police' ? b : undefined;
    if (player && police) {
      // Getaway: whoever was struck on a rear quarter by the other spins: the police PIT you,
      // or you PIT them.
      let pit = false;
      for (const victim of [player, police]) {
        if (victim.wrecked || pit) continue;
        const r = victim === a ? [rax, ras] : [rbx, rbs];
        const lon = r[0] * Math.sin(victim.yaw) + r[1] * Math.cos(victim.yaw);
        const sideness = Math.abs(nx * Math.cos(victim.yaw) - ns * Math.sin(victim.yaw));
        if (lon < -0.12 * victim.len && sideness > 0.4 && strength > 0.8) {
          const cs = victim === a ? -ca : cb;
          const attacker = victim === player ? police : player;
          const dir = Math.sign(cs * jn) || Math.sign(victim.x - attacker.x) * -1;
          victim.w += dir * Math.min(2.6, 0.9 + strength * 0.35);
          victim.slide = 1.8;
          this.events.push({ type: 'pit', strength, x: px, s: ps, other: police, car: victim.id });
          pit = true;
        }
      }
      if (!pit) this.events.push({ type: 'bump', strength, x: px, s: ps, other: police });
    } else if (player && suspect && !suspect.wrecked) {
      const r = suspect === a ? [rax, ras] : [rbx, rbs];
      // Contact in the suspect's local frame: lon < 0 is behind its center.
      const lon = r[0] * Math.sin(suspect.yaw) + r[1] * Math.cos(suspect.yaw);
      const sideness = Math.abs(nx * Math.cos(suspect.yaw) - ns * Math.sin(suspect.yaw));
      if (lon < 0) this.lastRearHit = this.time;
      if (lon < -0.12 * suspect.len && sideness > 0.4 && strength > 0.8) {
        // PIT: the rear steps out and the suspect loses grip long enough to spin.
        const cs = suspect === a ? -ca : cb; // sign of the torque the impulse put on the suspect
        const dir = Math.sign(cs * jn) || Math.sign(suspect.x - player.x) * -1;
        suspect.w += dir * Math.min(2.6, 0.9 + strength * 0.35);
        suspect.slide = 1.8;
        this.events.push({ type: 'pit', strength, x: px, s: ps, other: suspect });
      } else {
        suspect.slide = Math.max(suspect.slide, 0.2);
        this.events.push({ type: 'bump', strength, x: px, s: ps, other: suspect });
      }
    } else {
      for (const car of [a, b]) {
        if (car.kind === 'traffic' && strength > 4) car.slide = Math.max(car.slide, 0.3 + strength * 0.08);
      }
      const near = Math.hypot(px - this.player.x, ps - this.player.s) < 100;
      if (player || (near && strength > 2)) this.events.push({ type: 'bump', strength, x: px, s: ps, other: player ? other : undefined });
    }
  }

  private wall(car: Car): void {
    for (let i = 0; i < 4; i++) {
      // Deepest corner past either guardrail; recomputed after each push-back.
      let worst: [number, number] | undefined;
      let over = 0;
      for (const [cx, cs] of shapesOf(car).flatMap(cornersOf)) {
        const o = Math.max(leftWall(cs) - cx, cx - rightWall(cs));
        if (o > over) {
          over = o;
          worst = [cx, cs];
        }
      }
      if (!worst) return;
      const [cx, cs] = worst;
      const nx = cx < 0 ? 1 : -1; // pushes back onto the road
      const rx = cx - car.x, rs = cs - car.s;
      car.x += nx * over;
      const [vx, vs] = pointVel(car, rx, rs);
      const vn = vx * nx;
      if (car.kind === 'player') this.scrape = Math.max(this.scrape, Math.abs(vs));
      if (vn >= 0) continue;
      const c = perpDot(rx, rs, nx, 0);
      const jn = (-(1 + 0.3) * vn) / (1 / car.mass + (c * c) / car.inertia);
      applyImpulse(car, jn * nx, 0, rx, rs);
      const ct = perpDot(rx, rs, 0, 1);
      const jt = clamp(-vs / (1 / car.mass + (ct * ct) / car.inertia), -0.3 * jn, 0.3 * jn);
      applyImpulse(car, 0, jt, rx, rs);
      if (car.kind === 'player' && -vn > 1.5) this.events.push({ type: 'wall', strength: -vn, x: cx, s: cs });
      if (car.kind !== 'player' && -vn > (RIGS.has(car.type) ? RIG_SHAKEN : 6)) car.slide = Math.max(car.slide, RIGS.has(car.type) ? 0.4 - vn * 0.1 : 0.5);
      if (car.type === 'logger' && -vn > LOG_DROP_SPEED) this.dropLogs(car, cx);
      if (car.kind !== 'player' && car.kind !== 'log' && !car.exploded && -vn > EXPLODE_SPEED) this.explode(car, -vn);
      else this.damage(car, cx, cs, -vn);
    }
  }

  /**
   * Knock the panel nearest the contact point off the car (two on a hard hit). The player's
   * car keeps track too, but its losses only show in the replay (see Renderer).
   */
  private damage(car: Car, px: number, ps: number, strength: number): void {
    if (RIGS.has(car.type) || car.kind === 'log') return;
    if (car.exploded || strength < DAMAGE_SPEED || this.time - car.lastDamage < 0.25) return;
    car.lastDamage = this.time;
    const rx = px - car.x, rs = ps - car.s;
    const lat = rx * Math.cos(car.yaw) - rs * Math.sin(car.yaw);
    const lon = rx * Math.sin(car.yaw) + rs * Math.cos(car.yaw);
    for (let k = strength > 9 ? 2 : 1; k > 0; k--) {
      const part = nearestPart(car.len, car.wid, car.lost, lat, lon);
      if (part < 0) return;
      car.lost |= 1 << part;
      this.events.push({ type: 'damage', strength, x: px, s: ps, car: car.id, part, vx: car.vx, vs: car.vs });
    }
  }

  /**
   * A logging truck sheds its load: five logs roll off the trailer on the side away from the
   * hit (at hitX), carried along at the truck's speed. They're heavy, slide to a stop, and
   * knock panels off and spin anything they hit.
   */
  private dropLogs(truck: Car, hitX: number): void {
    if (truck.cargoLost) return;
    truck.cargoLost = true;
    const fx = Math.sin(truck.yaw), fs = Math.cos(truck.yaw), rx = Math.cos(truck.yaw), rs = -Math.sin(truck.yaw);
    const hitLat = (hitX - truck.x) * rx;
    const side = hitLat === 0 ? (Math.random() < 0.5 ? -1 : 1) : -Math.sign(hitLat);
    const lon = -truck.len / 2 + 6.2; // middle of the trailer (the tractor is at the front)
    for (let i = 0; i < 5; i++) {
      const log = makeCar('log', 0, 0, 0, 'log');
      const off = side * (truck.wid / 2 + 0.45 + i * 0.75);
      const along = lon + (Math.random() - 0.5) * 1.5;
      log.x = truck.x + rx * off + fx * along;
      log.s = truck.s + rs * off + fs * along;
      log.yaw = truck.yaw + (Math.random() - 0.5) * 0.3;
      const out = side * (2 + Math.random() * 3);
      log.vx = truck.vx * 0.85 + rx * out;
      log.vs = truck.vs * 0.85 + rs * out;
      log.w = (Math.random() - 0.5) * 0.6;
      log.wrecked = true; // no driver: it just slides
      log.color = 0x6b4a2b;
      this.cars.push(log);
    }
    this.events.push({ type: 'logs', strength: 10, x: truck.x, s: truck.s, car: truck.id });
  }

  private explode(car: Car, strength: number): void {
    const remaining = RIGS.has(car.type) ? 0 : ALL_PARTS & ~car.lost; // rigs have no panels to fling
    car.exploded = true;
    car.wrecked = true;
    car.lost = ALL_PARTS;
    car.w += (Math.random() - 0.5) * 3;
    this.events.push({ type: 'explode', strength, x: car.x, s: car.s, car: car.id, part: remaining, vx: car.vx, vs: car.vs });
    if (car.type === 'logger') this.dropLogs(car, car.x + 1);
    // The blast shoves nearby cars away; a gas tanker's reaches much further and harder.
    const reach = car.type === 'tanker' ? 26 : 9;
    const force = car.type === 'tanker' ? 1.1 : 0.6;
    for (const o of this.cars) {
      const dx = o.x - car.x, ds = o.s - car.s;
      const d = Math.hypot(dx, ds);
      if (o === car || d > reach || d < 0.1) continue;
      const push = (reach - d) * force;
      o.vx += (dx / d) * push;
      o.vs += (ds / d) * push;
      if (o.kind !== 'player') o.slide = Math.max(o.slide, 0.6);
    }
  }
}

/**
 * The trailer is towed by its hitch and rolls on its own wheels, so it turns toward wherever
 * the hitch is going sideways (relative to the trailer), over its wheelbase: in a swerve the
 * tractor turns first and the trailer swings after it. It can only fold so far.
 */
function trailerFollow(car: Car, dt: number): void {
  const [hx, hs] = hitchOf(car);
  const [vx, vs] = pointVel(car, hx - car.x, hs - car.s);
  const sideways = vx * Math.cos(car.trailerYaw) - vs * Math.sin(car.trailerYaw);
  car.trailerYaw += (sideways / RIG_AXLE) * dt;
  const fold = wrapAngle(car.trailerYaw - car.yaw);
  if (Math.abs(fold) > RIG_JACKKNIFE) car.trailerYaw = car.yaw + Math.sign(fold) * RIG_JACKKNIFE;
}

function integrate(car: Car, dt: number): void {
  const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw);
  const rx = Math.cos(car.yaw), rs = -Math.sin(car.yaw);
  let vf = car.vx * fx + car.vs * fs;
  let vr = car.vx * rx + car.vs * rs;

  if (car.wrecked) {
    // Skidding to a stop: friction on the whole velocity, spin bleeds off.
    const speed = Math.hypot(vf, vr);
    const k = speed > 0 ? Math.max(0, speed - (car.kind === 'log' ? 3.5 : 7) * dt) / speed : 0;
    vf *= k;
    vr *= k;
    car.w *= Math.exp(-1.2 * dt);
  } else {
    vf += car.cmdAccel * dt;
    // Hills: slower up, faster down (not at a standstill, so a stopped car doesn't creep).
    if (Math.abs(vf) > 1) vf -= 9.81 * grade(car.s) * dt;
    vf = Math.min(vf, car.maxSpeed);
    const sliding = car.slide > 0;
    vr *= Math.exp(-(sliding ? 1.6 : 9) * dt);
    if (sliding) {
      car.slide -= dt;
      car.w *= Math.exp(-0.4 * dt);
      vf -= Math.sign(vf) * 2 * dt;
    } else {
      car.w += (car.cmdYawRate - car.w) * Math.min(1, 9 * dt);
    }
  }

  car.vx = vf * fx + vr * rx;
  car.vs = vf * fs + vr * rs;
  car.x += car.vx * dt;
  car.s += car.vs * dt;
  car.yaw += car.w * dt;
  // The road bends under the car: in road space its heading and velocity turn the other way.
  const turn = -curvature(car.s) * car.vs * dt;
  if (turn !== 0) {
    const c = Math.cos(turn), sn = Math.sin(turn);
    const vx = car.vx;
    car.vx = vx * c + car.vs * sn;
    car.vs = -vx * sn + car.vs * c;
    car.yaw += turn;
    car.trailerYaw += turn;
  }
}

interface Hit {
  nx: number;
  ns: number;
  px: number;
  ps: number;
  depth: number;
}

/** An oriented box: a whole vehicle, or one part of a rig. */
export interface Box {
  x: number;
  s: number;
  yaw: number;
  len: number;
  wid: number;
}

function axesOf(car: Box): [number, number][] {
  return [
    [Math.sin(car.yaw), Math.cos(car.yaw)],
    [Math.cos(car.yaw), -Math.sin(car.yaw)],
  ];
}

export function cornersOf(car: Box): [number, number][] {
  const [f, r] = axesOf(car);
  const hl = car.len / 2, hw = car.wid / 2;
  return [
    [1, 1], [1, -1], [-1, -1], [-1, 1],
  ].map(([a, b]) => [car.x + f[0] * hl * a + r[0] * hw * b, car.s + f[1] * hl * a + r[1] * hw * b]);
}

/** Separating-axis test for two oriented boxes. Normal points from a to b. */
function obb(a: Box, b: Box): Hit | undefined {
  const ca = cornersOf(a), cb = cornersOf(b);
  let best: { depth: number; nx: number; ns: number; owner: Box } | undefined;
  for (const [owner, axes] of [[a, axesOf(a)], [b, axesOf(b)]] as const) {
    for (const [ax, as] of axes) {
      let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
      for (const [x, s] of ca) { const p = x * ax + s * as; minA = Math.min(minA, p); maxA = Math.max(maxA, p); }
      for (const [x, s] of cb) { const p = x * ax + s * as; minB = Math.min(minB, p); maxB = Math.max(maxB, p); }
      const depth = Math.min(maxA - minB, maxB - minA);
      if (depth <= 0) return undefined;
      if (!best || depth < best.depth) {
        const dir = (b.x - a.x) * ax + (b.s - a.s) * as >= 0 ? 1 : -1;
        best = { depth, nx: ax * dir, ns: as * dir, owner };
      }
    }
  }
  if (!best) return undefined;
  const { nx, ns } = best;
  // Contact: the deepest corner of the body that does not own the separating face.
  let pts: [number, number][];
  if (best.owner === a) {
    const proj = cb.map(([x, s]) => x * nx + s * ns);
    const m = Math.min(...proj);
    pts = cb.filter((_, i) => proj[i] < m + 0.15);
  } else {
    const proj = ca.map(([x, s]) => x * nx + s * ns);
    const m = Math.max(...proj);
    pts = ca.filter((_, i) => proj[i] > m - 0.15);
  }
  const px = pts.reduce((t, p) => t + p[0], 0) / pts.length;
  const ps = pts.reduce((t, p) => t + p[1], 0) / pts.length;
  return { nx, ns, px, ps, depth: best.depth };
}

// d(position of a body point)/d(yaw) for offset (rx, rs) is (rs, -rx).
function pointVel(car: Car, rx: number, rs: number): [number, number] {
  return [car.vx + car.w * rs, car.vs - car.w * rx];
}

function perpDot(rx: number, rs: number, jx: number, js: number): number {
  return rs * jx - rx * js;
}

/** An impulse landing behind a rig's hitch also swings the trailer about the hitch. */
function kickTrailer(car: Car, px: number, ps: number, jx: number, js: number): void {
  if (!RIGS.has(car.type)) return;
  const [hx, hs] = hitchOf(car);
  const behind = (px - hx) * Math.sin(car.trailerYaw) + (ps - hs) * Math.cos(car.trailerYaw);
  if (behind > 0) return; // that's the tractor
  // Trailer about its hitch: roughly 10 t over 12 m, I = m L^2 / 3.
  const swing = perpDot(px - hx, ps - hs, jx, js) / 650000;
  car.trailerYaw += Math.max(-0.3, Math.min(0.3, swing));
}

function applyImpulse(car: Car, jx: number, js: number, rx: number, rs: number): void {
  car.vx += jx / car.mass;
  car.vs += js / car.mass;
  car.w += perpDot(rx, rs, jx, js) / car.inertia;
}