Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

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

src/game/game.ts
import * as THREE from 'three';
import { buildRoom } from './rooms.js';
import { buildMachines } from './machines.js';
import type { Body, Mover, MoverEvents } from './movers.js';
import { HEIGHT, RADIUS } from './world.js';
import { Fighter, noControls, type Controls, type FighterHost } from './fighter.js';
import { Brain, NavGraph, type Difficulty } from './ai.js';
import { Effects } from './fx.js';
import { FightCamera } from './camera.js';
import { ITEMS, KATANA, buildPickupMarker, itemById } from './items.js';
import { World } from './world.js';
import type { Livery } from './rig.js';
import type { RawInput } from './input.js';
import type { DripDef, PoolDef, RoomData, RoomId, Solid, ThrowDef, WeaponDef } from './types.js';
import { POWERUPS, THROWABLES, THROWS_BY_ROOM, throwById } from './throwables.js';
import { Roller } from './rollers.js';
import { Car, CRATE_SIZE, Crate } from './cars.js';
import * as sfx from './sound.js';

export interface MatchOptions {
  room: RoomId;
  /**
   * 'explore' has no enemies or rounds: just climb and try out items.
   * 'waves' puts the players on one team against ever bigger waves of enemies.
   * 'ffa' is free for all: up to four samurai, each on their own.
   */
  mode: '1v1' | '2v2' | 'ffa' | 'waves' | 'explore';
  /** Human players, 1-4 (1v1 allows 2). */
  players: number;
  /** 2v2: humans on one team against CPUs ('together') or spread across both teams ('split'). */
  teams: 'together' | 'split';
  /** Free for all: CPUs take the places no player has joined ('cpus'), or they stay empty. */
  fill: 'cpus' | 'empty';
  difficulty: Difficulty;
  /** Menu backdrop: CPUs spar while the camera orbits. */
  attract?: boolean;
}

export const TEAM_NAMES = ['Red', 'Blue', 'Yellow', 'Green'];
const LIVERIES: Livery[][] = [
  [
    { armor: 0xc8372d, trim: 0xe3b23c, cloth: 0x2b2d42, ribbon: 0xf4efe4 },
    { armor: 0xdd7a2c, trim: 0xe3b23c, cloth: 0x3a2f2f, ribbon: 0xffe29a },
    // Extra co-op colours for players 3 and 4 in Waves and Explore.
    { armor: 0xd9a92b, trim: 0xf4efe4, cloth: 0x2f2a20, ribbon: 0xfff3c4 },
    { armor: 0xa83c7c, trim: 0xe3b23c, cloth: 0x2a2030, ribbon: 0xf7c8e4 },
  ],
  [
    { armor: 0x2f56a8, trim: 0xd3dae3, cloth: 0x22252d, ribbon: 0x9fd6ff },
    { armor: 0x23897f, trim: 0xd3dae3, cloth: 0x1f2b2a, ribbon: 0xc4f0e2 },
  ],
];
/** Team colours; in free for all every samurai is a team of one, in these four colours. */
export const TEAM_CSS = ['#d8443a', '#3b6fd1', '#d9a92b', '#2a9a8e'];

/** Free for all: four samurai who can't be confused with each other. */
const FFA_LIVERIES: Livery[] = [
  { armor: 0xc8372d, trim: 0xe3b23c, cloth: 0x2b2d42, ribbon: 0xf4efe4 },
  { armor: 0x2f56a8, trim: 0xd3dae3, cloth: 0x22252d, ribbon: 0x9fd6ff },
  { armor: 0xd9a92b, trim: 0xf4efe4, cloth: 0x2f2a20, ribbon: 0xfff3c4 },
  { armor: 0x23897f, trim: 0xd3dae3, cloth: 0x1f2b2a, ribbon: 0xc4f0e2 },
];

/** Waves mode enemies: a mixed band of ronin in cool and dark lacquer. */
const RONIN: Livery[] = [
  { armor: 0x2f56a8, trim: 0xd3dae3, cloth: 0x22252d, ribbon: 0x9fd6ff },
  { armor: 0x5b3f8f, trim: 0xc9b6e8, cloth: 0x231f2b, ribbon: 0xd9c7ff },
  { armor: 0x23897f, trim: 0xd3dae3, cloth: 0x1f2b2a, ribbon: 0xc4f0e2 },
  { armor: 0x3c4048, trim: 0xa9b0ba, cloth: 0x18191c, ribbon: 0xe8e8e8 },
  { armor: 0x4f7a3a, trim: 0xd9cf8a, cloth: 0x202619, ribbon: 0xe6efb0 },
];

const ROUNDS_TO_WIN = 2;
const PROJECTILE_GRAVITY = 18;
const _spinQ = new THREE.Quaternion();
const _spinAxis = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
const _shadowX = new THREE.Vector3();
const _shadowY = new THREE.Vector3();
const _shadowZ = new THREE.Vector3();
const _shadowC = new THREE.Vector3();
const _moverPos = new THREE.Vector3();
const _moverVel = new THREE.Vector3();
const _restQ = new THREE.Quaternion();

/** Thrown objects are told apart from weapons by their weight. */
function isThrow(def: WeaponDef | ThrowDef): def is ThrowDef {
  return 'weight' in def;
}

/** A household object flying through the air after a throw. */
interface Projectile {
  def: ThrowDef;
  obj: THREE.Group;
  pos: THREE.Vector3;
  vel: THREE.Vector3;
  spin: THREE.Vector3;
  owner: Fighter;
  /** Already struck someone: it can't hurt again, it just tumbles to the floor. */
  spent: boolean;
  gravity: number;
}

/** A slippery puddle: from the dripping tap (it grows) or a burst water balloon (it dries up). */
interface Puddle {
  x: number;
  y: number;
  z: number;
  r: number;
  max: number;
  /** Units of radius gained per second while growing. */
  grow: number;
  /** Seconds left before it dries up (Infinity for the tap's). */
  ttl: number;
  mesh: THREE.Mesh;
}

/** The bath filling (and draining) with water. */
interface Pool {
  def: PoolDef;
  level: number;
  clock: number;
  water: THREE.Mesh;
  stream: THREE.Mesh;
}

/** A dripping tap and its falling drops. */
interface Drip {
  def: DripDef;
  floor: number;
  puddle: Puddle;
  clock: number;
  drops: { mesh: THREE.Mesh; vy: number; live: boolean }[];
}

const WATER_MAT = new THREE.MeshStandardMaterial({ color: 0x2f9ad8, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0.1, depthWrite: false });
const PUDDLE_MAT = new THREE.MeshStandardMaterial({ color: 0x8fd4f0, transparent: true, opacity: 0.55, roughness: 0.05, metalness: 0.2, depthWrite: false });
const PUDDLE_GEO = new THREE.CircleGeometry(1, 20).rotateX(-Math.PI / 2);

/**
 * Something lying around (or tumbling) that can be picked up. `obj` holds the full
 * orientation and sits at the item's centre (`pos`); the mesh inside is offset so
 * it turns about that centre. Collisions use the corners of the real mesh.
 */
class Pickup {
  readonly obj = new THREE.Group();
  readonly item: THREE.Object3D;
  readonly marker: THREE.Object3D;
  /** Centre of the item. */
  readonly pos = new THREE.Vector3();
  readonly vel = new THREE.Vector3();
  /** Angular velocity (axis × radians per second). */
  readonly spin = new THREE.Vector3();
  resting = false;
  /** Touching something underneath this frame (rolls toward lying still). */
  grounded = false;
  /** Has touched down once: no more bouncing, it stays on the surface from now on. */
  landed = false;
  /** Height of the surface it rests on (for the marker ring). */
  floorY = 0;
  owner: null = null;
  age = 0;

  constructor(
    readonly def: WeaponDef | ThrowDef,
    public durability: number,
    scene: THREE.Object3D,
  ) {
    this.item = def.build();
    // Turn about the middle of the object, not its grip/base.
    if (isThrow(def)) this.item.position.set(0, -def.height / 2, 0);
    else this.item.position.set(0, -def.length * 0.4, 0);
    this.obj.add(this.item);
    scene.add(this.obj);
    this.marker = buildPickupMarker();
    scene.add(this.marker);
    this.marker.visible = false;
  }

  get throwable(): boolean {
    return isThrow(this.def);
  }

  /**
   * How it lies when still, keeping its current heading: household objects stand
   * upright; weapons lie flat on their side along the way they point.
   */
  restQuaternion(out: THREE.Quaternion): THREE.Quaternion {
    if (isThrow(this.def) && this.def.anyFace) {
      // A die settles flat on the face that is already lowest, keeping how it's turned.
      const q = this.obj.quaternion;
      let low: THREE.Vector3 | null = null;
      for (const axis of CUBE_AXES) {
        const v = _pickV.copy(axis).applyQuaternion(q);
        if (!low || v.y < low.y) low = _pickLow.copy(v);
      }
      return out.setFromUnitVectors(low!, _down).multiply(q);
    }
    if (isThrow(this.def)) {
      const e = _pickEuler.setFromQuaternion(this.obj.quaternion, 'YXZ');
      return out.setFromEuler(_pickEuler.set(0, e.y, 0, 'YXZ'));
    }
    // Flat weapon: Yaw(ψ)·(π/2, 0, π/2) points the blade along (-cos ψ, 0, sin ψ).
    const blade = _pickV.set(0, 1, 0).applyQuaternion(this.obj.quaternion);
    const psi = Math.hypot(blade.x, blade.z) > 0.05 ? Math.atan2(blade.z, -blade.x) : 0;
    return out.setFromEuler(_pickEuler.set(0, psi, 0, 'YXZ')).multiply(_flatQ);
  }

  dispose(): void {
    this.obj.removeFromParent();
    this.marker.removeFromParent();
  }
}

const _pickEuler = new THREE.Euler();
const _pickV = new THREE.Vector3();
const _flatQ = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, 0, Math.PI / 2));
const _pickLow = new THREE.Vector3();
const _down = new THREE.Vector3(0, -1, 0);
const CUBE_AXES = [
  new THREE.Vector3(1, 0, 0),
  new THREE.Vector3(-1, 0, 0),
  new THREE.Vector3(0, 1, 0),
  new THREE.Vector3(0, -1, 0),
  new THREE.Vector3(0, 0, 1),
  new THREE.Vector3(0, 0, -1),
];

/** A floating word over a fighter ("Parry!", "Disarmed!"). */
export interface Popup {
  text: string;
  at: THREE.Vector3;
  color: string;
  age: number;
}

export interface HudState {
  fighters: Fighter[];
  wins: number[];
  round: number;
  banner: { text: string; sub?: string; age: number } | null;
  popups: Popup[];
  /** Waves mode: current wave, enemies still to beat in it, and total KOs. */
  wave: number;
  enemiesLeft: number;
  kills: number;
}

export class Game implements FighterHost {
  readonly renderer: THREE.WebGLRenderer;
  readonly scene = new THREE.Scene();
  readonly cam = new FightCamera();
  /** Player 2's own camera in split screen. */
  readonly cam2 = new FightCamera();
  readonly cam3 = new FightCamera();
  readonly cam4 = new FightCamera();
  /** One camera per player slot (the 4th doubles as the overview with 3 players). */
  get cams(): FightCamera[] {
    return [this.cam, this.cam2, this.cam3, this.cam4];
  }
  readonly fx: Effects;
  readonly wind = new THREE.Vector3(0.3, 0, 0.2);
  world = new World([]);
  nav = new NavGraph([]);
  room: RoomData | null = null;
  fighters: Fighter[] = [];
  brains = new Map<Fighter, Brain>();
  pickups: Pickup[] = [];
  options: MatchOptions | null = null;
  phase: 'intro' | 'fight' | 'roundEnd' | 'matchEnd' = 'intro';
  phaseTime = 0;
  wins = [0, 0];
  round = 1;
  paused = false;
  slowmo = 1;
  private slowTime = 0;
  banner: HudState['banner'] = null;
  readonly popups: Popup[] = [];
  private readonly sun: THREE.DirectionalLight;
  private readonly hemi: THREE.HemisphereLight;
  private width = 1;
  private height = 1;
  /** Called when the match is over. */
  onMatchEnd: (winnerTeam: number) => void = () => undefined;
  private readonly fwd = new THREE.Vector3();
  private readonly right = new THREE.Vector3();

  constructor(readonly canvas: HTMLCanvasElement) {
    this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
    this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.05;
    this.hemi = new THREE.HemisphereLight(0xfff1dc, 0x7a6450, 1.25);
    this.scene.add(this.hemi);
    this.sun = new THREE.DirectionalLight(0xffe2b8, 2.6);
    this.sun.castShadow = true;
    this.sun.shadow.mapSize.set(2048, 2048);
    this.sun.shadow.bias = -0.0004;
    this.sun.shadow.normalBias = 0.02;
    this.scene.add(this.sun, this.sun.target);
    this.fx = new Effects(this.scene);
  }

  resize(w: number, h: number): void {
    this.width = Math.max(1, w);
    this.height = Math.max(1, h);
    this.renderer.setSize(this.width, this.height, false);
  }

  // ---- Match setup ----------------------------------------------------------------

  start(options: MatchOptions): void {
    this.options = options;
    this.loadRoom(options.room);
    for (const f of this.fighters) f.rig.dispose();
    this.fighters = [];
    this.brains.clear();
    const humans: [number, number][] = [];
    const roster: { team: number; human: number | null }[] = [];
    if (options.attract) {
      roster.push({ team: 0, human: null }, { team: 1, human: null });
    } else {
      const n = Math.max(1, Math.min(4, options.players));
      const human = (i: number) => (i < n ? i : null);
      if (options.mode === 'explore' || options.mode === 'waves') {
        // Everyone on one team.
        for (let i = 0; i < n; i++) roster.push({ team: 0, human: i });
      } else if (options.mode === 'ffa') {
        // Everyone for themselves: each samurai is their own team.
        for (let i = 0; i < n; i++) roster.push({ team: i, human: i });
        const total = options.fill === 'cpus' ? 4 : Math.max(2, n);
        for (let i = n; i < total; i++) roster.push({ team: i, human: null });
      } else if (options.mode === '1v1') {
        roster.push({ team: 0, human: 0 }, { team: 1, human: n >= 2 ? 1 : null });
      } else if (options.teams === 'together' && n <= 2) {
        // Players side by side against CPUs. Roster order matches the spawns: [A1, B1, A2, B2].
        roster.push({ team: 0, human: 0 }, { team: 1, human: null }, { team: 0, human: human(1) }, { team: 1, human: null });
      } else {
        // Split: P1 & P3 red, P2 & P4 blue; empty places are CPUs.
        roster.push({ team: 0, human: 0 }, { team: 1, human: human(1) }, { team: 0, human: human(2) }, { team: 1, human: human(3) });
      }
    }
    const perTeam = [0, 0, 0, 0];
    const ffa = options.mode === 'ffa' && !options.attract;
    const cpuCount = roster.filter((r) => r.human === null).length;
    let cpuNo = 0;
    roster.forEach((r, i) => {
      const slot = perTeam[r.team]!++;
      // Several CPUs in free for all are told apart by number.
      const name = r.human !== null ? `P${r.human + 1}` : ffa && cpuCount > 1 ? `CPU ${++cpuNo}` : 'CPU';
      const livery = ffa ? FFA_LIVERIES[r.team]! : LIVERIES[r.team]![slot]!;
      const f = new Fighter(i, r.team, name, livery, r.human !== null, this.scene, this);
      if (r.human !== null) humans.push([i, r.human]);
      this.fighters.push(f);
      if (r.human === null) this.brains.set(f, new Brain(f, this, options.attract ? 'normal' : options.difficulty));
    });
    this.humanSlots = new Map(humans);
    this.wins = new Array<number>(Math.max(2, ...roster.map((r) => r.team + 1))).fill(0);
    this.round = 1;
    this.startRound();
  }

  /** Fighter index → human player number. */
  private humanSlots = new Map<number, number>();

  private loadRoom(id: RoomId): void {
    if (this.room?.id === id) return;
    if (this.room) this.scene.remove(this.room.root);
    const room = buildRoom(id);
    this.room = room;
    this.scene.add(room.root);
    // Moving machines (the train, the fan...) are solids that move; the AI's map leaves them out.
    this.movers = buildMachines(room);
    this.bodyOf.clear();
    this.airFrom.clear();
    for (const m of this.movers) {
      room.root.add(m.root);
      for (const b of m.bodies) {
        room.solids.push(...b.solids);
        for (const sol of b.solids) this.bodyOf.set(sol, b);
      }
    }
    this.world = new World(room.solids);
    this.statics = room.solids.filter((sol) => !sol.moving);
    this.buildHazards(room);
    // Edges of moving things (cars come and go, so look them up each time).
    this.world.movingLedges = (pos, dir) => {
      let best: ReturnType<Body['ledge']> = null;
      for (const m of this.movers) {
        for (const b of m.bodies) {
          const l = b.ledge(pos, dir);
          if (l && (!best || l.score < best.score)) best = l;
        }
      }
      return best;
    };
    this.nav = new NavGraph(room.solids.filter((s) => !s.moving));
    this.scene.background = new THREE.Color(room.sky);
    this.scene.fog = new THREE.Fog(room.sky, 40, 110);
    // Shadow camera covers the whole room.
    const b = room.bounds;
    const cx = (b.minX + b.maxX) / 2;
    const cz = (b.minZ + b.maxZ) / 2;
    const span = Math.max(b.maxX - b.minX, b.maxZ - b.minZ) * 0.62;
    this.sun.target.position.set(cx, 0, cz);
    this.sun.position.set(cx, 0, cz).addScaledVector(room.sunDir, -40);
    const sc = this.sun.shadow.camera;
    sc.left = -span;
    sc.right = span;
    sc.top = span;
    sc.bottom = -span;
    sc.near = 1;
    sc.far = 90;
    sc.updateProjectionMatrix();
    this.shadowHalf = 0;
    const s0 = room.spawns[0]!;
    const s1 = room.spawns[1]!;
    this.cam.setRoom(room, Math.atan2(s1.x - s0.x, s1.z - s0.z) + Math.PI / 2);
    this.cam2.setRoom(room, Math.atan2(s0.x - s1.x, s0.z - s1.z) + Math.PI / 2);
    this.cam3.setRoom(room, Math.atan2(s1.x - s0.x, s1.z - s0.z) + Math.PI / 2);
    this.cam4.setRoom(room, Math.atan2(s0.x - s1.x, s0.z - s1.z) + Math.PI / 2);
  }

  private startRound(): void {
    const room = this.room!;
    for (const p of this.pickups) p.dispose();
    this.pickups = [];
    this.fx.clear();
    this.popups.length = 0;
    this.fighters.forEach((f, i) => {
      const s = room.spawns[i]!;
      f.equip(KATANA);
      f.reset(s.x, s.y, s.z, s.facing);
    });
    // Scatter household items (more of them in the whole house).
    const many = room.areas ? 3 : 1;
    const spots = [...room.itemSpawns].sort(() => Math.random() - 0.5).slice(0, this.exploring || this.waveMode ? 99 : 7 * many);
    for (const spot of spots) {
      const kinds = spot.kinds ?? room.itemKinds;
      const def = itemById(kinds[Math.floor(Math.random() * kinds.length)]!);
      const p = new Pickup(def, def.durability, this.scene);
      this.placePickup(p, spot.x, spot.y, spot.z);
      this.pickups.push(p);
    }
    for (const p of [...this.projectiles]) this.removeProjectile(p);
    this.scatterThrowables((this.exploring || this.waveMode ? 10 : 6) * many);
    this.scatterThrowables(2 * many, POWERUPS.map((d) => d.id));
    // The whole house always has at least one of every weapon, throwable and pick-up somewhere.
    if (room.areas && !this.options?.attract) this.ensureEveryItem();
    this.powerClock = 0;
    this.resetHazards();
    this.resetCars();
    this.phase = 'intro';
    this.phaseTime = 0;
    this.slowmo = 1;
    this.slowTime = 0;
    const attract = this.options?.attract;
    this.banner = attract ? null : { text: this.isFinalRound() ? 'Final Round' : `Round ${this.round}`, age: 0 };
    if (this.exploring) {
      // Straight into free play.
      this.phase = 'fight';
      this.banner = { text: 'Explore', sub: 'No enemies. Climb anything, try every item.', age: 0 };
    }
    if (this.waveMode) {
      this.phase = 'fight';
      this.banner = null;
      this.wave = 0;
      this.waveLeft = 0;
      this.waveBreak = 1.2;
      this.spawnTimer = 0;
      this.kills = 0;
    }
    if (!attract) sfx.sfxGong();
    this.cam.orbit = !!attract;
    // Each match starts with the automatic side-on camera and default tilt.
    if (this.round === 1) {
      for (const cam of this.cams) {
        cam.manual = false;
        cam.pitch = 0.6;
      }
    }
    for (const cam of this.cams) cam.minDist = this.exploring ? 10 : 6.5;
    this.viewSlots = [];
    this.refreshViews();
    if (this.split) {
      // Each samurai's camera starts behind them, looking toward the nearest foe.
      for (const view of this.views()) {
        const me = view.fighter;
        if (!me) {
          view.cam.snap(this.focusPoints(), view.w / view.h);
          continue;
        }
        const duel = this.viewDuel(me);
        view.cam.snap(this.viewPoints(me), view.w / view.h, duel?.[0], duel?.[1]);
      }
    } else {
      const duel = this.duelPair();
      this.cam.snap(this.focusPoints(), this.width / this.height, duel?.[0], duel?.[1]);
    }
  }

  get waveMode(): boolean {
    return this.options?.mode === 'waves' && !this.options.attract;
  }

  get exploring(): boolean {
    return this.options?.mode === 'explore' && !this.options.attract;
  }

  private isFinalRound(): boolean {
    return this.wins[0] === ROUNDS_TO_WIN - 1 && this.wins[1] === ROUNDS_TO_WIN - 1;
  }

  stop(): void {
    for (const f of this.fighters) f.rig.dispose();
    this.fighters = [];
    this.brains.clear();
    for (const p of this.pickups) p.dispose();
    this.pickups = [];
    for (const p of [...this.projectiles]) this.removeProjectile(p);
  }

  // ---- FighterHost ------------------------------------------------------------------

  nearestEnemy(f: Fighter, range: number): Fighter | null {
    let best: Fighter | null = null;
    let bestD = range;
    for (const o of this.fighters) {
      if (o.team === f.team || !o.alive) continue;
      const d = o.pos.distanceTo(f.pos);
      if (d < bestD && Math.abs(o.pos.y - f.pos.y) < 0.8) {
        bestD = d;
        best = o;
      }
    }
    return best;
  }

  pickupNear(f: Fighter): Pickup | null {
    let best: Pickup | null = null;
    let bestD = 0.8;
    for (const p of this.pickups) {
      if (!p.resting) continue;
      const d = Math.hypot(p.pos.x - f.pos.x, p.pos.z - f.pos.z);
      if (d < bestD && Math.abs(p.pos.y - f.pos.y) < 0.7) {
        bestD = d;
        best = p;
      }
    }
    return best;
  }

  takePickup(f: Fighter): void {
    const p = this.pickupNear(f);
    if (!p) return;
    if (isThrow(p.def) && p.def.power) {
      f.applyPower(p.def.power);
      p.dispose();
      this.pickups.splice(this.pickups.indexOf(p), 1);
      sfx.sfxPickup();
      this.fx.sparks(p.pos, 10, 0.6, 0xfff6d0);
      return;
    }
    if (isThrow(p.def)) {
      // Household objects are heaved overhead; the weapon goes on the back meanwhile.
      f.carry(p.def);
    } else {
      if (f.weapon) this.dropWeapon(f, new THREE.Vector3(0, 0, 0), 0.6);
      f.equip(p.def, p.durability);
    }
    p.dispose();
    this.pickups.splice(this.pickups.indexOf(p), 1);
    sfx.sfxPickup();
    this.popup(f, p.def.name, '#ffe7a8');
  }

  hearing(p: THREE.Vector3): number {
    const d = Math.min(...this.views().map((v) => p.distanceTo(v.cam.camera.position)));
    return Math.max(0, Math.min(1, 14 / (d + 4)));
  }

  surfaceVelocity(support: Solid | null, at: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 {
    const body = support ? this.bodyOf.get(support) : undefined;
    return body ? body.pointVelocity(at, out).setY(0) : out.set(0, 0, 0);
  }

  dust(p: THREE.Vector3, amount: number): void {
    this.fx.dust(p, amount);
  }

  // ---- Weapons in the world ------------------------------------------------------------

  /** Throw the fighter's item out of their hands. */
  private dropWeapon(f: Fighter, dir: THREE.Vector3, power: number): void {
    if (!f.weapon) return;
    const { def, durability } = f.weapon;
    const p = new Pickup(def, durability, this.scene);
    f.rig.handR.updateMatrixWorld(true);
    p.pos.setFromMatrixPosition(f.rig.handR.matrixWorld);
    p.vel.set(dir.x * 2.5 * power + (Math.random() - 0.5), 3 + 4 * power, dir.z * 2.5 * power + (Math.random() - 0.5));
    p.spin.set((Math.random() - 0.5) * 6, (Math.random() - 0.5) * 4, 10 + Math.random() * 8);
    this.pickups.push(p);
    f.equip(null);
  }

  private disarm(f: Fighter, away: THREE.Vector3): void {
    if (!f.weapon) return;
    this.dropWeapon(f, away, 1.4);
    if (f.state !== 'down' && f.state !== 'ko') f.disarmed();
    sfx.sfxDisarm();
    this.popup(f, 'Disarmed!', '#ffd166');
  }

  /** World-space corners of every mesh in an item. */
  private itemCorners(obj: THREE.Object3D): THREE.Vector3[] {
    obj.updateMatrixWorld(true);
    const out: THREE.Vector3[] = [];
    obj.traverse((o) => {
      if (!(o instanceof THREE.Mesh)) return;
      if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
      const b = o.geometry.boundingBox!;
      for (let i = 0; i < 8; i++) {
        out.push(new THREE.Vector3(i & 1 ? b.max.x : b.min.x, i & 2 ? b.max.y : b.min.y, i & 4 ? b.max.z : b.min.z).applyMatrix4(o.matrixWorld));
      }
    });
    return out;
  }

  /**
   * How far to move an item so none of its corners are inside the floor or furniture:
   * up onto a surface it is landing on, otherwise out sideways on its own side.
   */
  private itemContact(obj: THREE.Object3D, centre: THREE.Vector3): { up: number; x: number; z: number } {
    const corners = this.itemCorners(obj);
    const res = { up: 0, x: 0, z: 0 };
    const near = this.world.solids.filter(
      (s) => s.maxX > centre.x - 2 && s.minX < centre.x + 2 && s.maxZ > centre.z - 2 && s.minZ < centre.z + 2 && s.maxY > centre.y - 2 && s.minY < centre.y + 2,
    );
    for (const c of corners) {
      for (const s of near) {
        if (c.x <= s.minX || c.x >= s.maxX || c.z <= s.minZ || c.z >= s.maxZ || c.y <= s.minY || c.y >= s.maxY) continue;
        const options: [number, 'up' | 'x' | 'z'][] = [];
        const centreOver = centre.x > s.minX && centre.x < s.maxX && centre.z > s.minZ && centre.z < s.maxZ;
        if (centre.y >= s.maxY - 0.2 || centreOver) options.push([s.maxY - c.y, 'up']);
        if (centre.x <= s.minX) options.push([s.minX - c.x, 'x']);
        if (centre.x >= s.maxX) options.push([s.maxX - c.x, 'x']);
        if (centre.z <= s.minZ) options.push([s.minZ - c.z, 'z']);
        if (centre.z >= s.maxZ) options.push([s.maxZ - c.z, 'z']);
        if (!options.length) options.push([s.maxY - c.y, 'up']);
        options.sort((a, b) => Math.abs(a[0]) - Math.abs(b[0]));
        const [d, axis] = options[0]!;
        if (axis === 'up') res.up = Math.max(res.up, d);
        else if (Math.abs(d) > Math.abs(res[axis])) res[axis] = d;
      }
    }
    return res;
  }

  private updatePickups(dt: number): void {
    const b = this.room!.bounds;
    for (const p of this.pickups) {
      p.age += dt;
      if (!p.resting) {
        p.vel.y -= 24 * dt;
        p.pos.addScaledVector(p.vel, dt);
        p.pos.x = Math.min(b.maxX - 0.3, Math.max(b.minX + 0.3, p.pos.x));
        p.pos.z = Math.min(b.maxZ - 0.3, Math.max(b.minZ + 0.3, p.pos.z));
        // Tumble about the centre; once touching down, roll over onto its side/base.
        const w = p.spin.length();
        if (w > 1e-4) p.obj.quaternion.premultiply(_spinQ.setFromAxisAngle(_spinAxis.copy(p.spin).divideScalar(w), w * dt));
        const tumbles = isThrow(p.def) && p.def.anyFace;
        const rolls = isThrow(p.def) && p.def.rolls;
        if (rolls && p.grounded) {
          // A marble rolls: it turns as it goes and hardly slows.
          const sp = Math.hypot(p.vel.x, p.vel.z);
          if (sp > 1e-3) p.obj.quaternion.premultiply(_spinQ.setFromAxisAngle(_spinAxis.set(p.vel.z, 0, -p.vel.x).divideScalar(sp), (sp * dt) / p.def.radius));
          p.spin.set(0, 0, 0);
        }
        p.spin.multiplyScalar(Math.exp(-(p.grounded ? (tumbles ? 3.5 : 10) : 0.5) * dt));
        if (p.grounded && !rolls) p.obj.quaternion.slerp(p.restQuaternion(_restQ), 1 - Math.exp(-9 * dt));
        p.obj.position.copy(p.pos);

        // Keep every corner of the mesh out of the floor and furniture.
        p.grounded = false;
        for (let iter = 0; iter < 6; iter++) {
          const hit = this.itemContact(p.obj, p.pos);
          if (!hit.up && !hit.x && !hit.z) break;
          p.pos.x += hit.x;
          p.pos.z += hit.z;
          p.pos.y += hit.up;
          if (hit.x) p.vel.x *= -0.3;
          if (hit.z) p.vel.z *= -0.3;
          if (hit.up > 0) {
            if (!p.landed && p.vel.y <= 0.5) {
              // Touching down: land with a thud (no bouncing), losing most of the spin.
              if (p.vel.y < 0) p.vel.y = 0;
              if (isThrow(p.def) && p.def.anyFace) {
                // A die tumbles over a face or two when it hits, so any face can end up on top.
                const a = Math.random() * Math.PI * 2;
                p.spin.set(Math.cos(a), 0, Math.sin(a)).multiplyScalar(9 + Math.random() * 9);
              } else p.spin.multiplyScalar(0.4);
              if (p.throwable) sfx.sfxLand(false);
              else {
                sfx.sfxClang();
                this.fx.sparks(p.pos, 4, 0.5);
              }
              p.landed = true;
            } else if (p.vel.y < 0) p.vel.y = 0;
            p.grounded = true;
          }
          p.obj.position.copy(p.pos);
        }
        // Once down, stay down: rolling onto its side can lift it a hair off the surface,
        // so set it straight back onto it instead of letting it fall and hop again.
        if (p.landed && !p.grounded && p.vel.y <= 0.5) {
          let low = Infinity;
          for (const c of this.itemCorners(p.obj)) low = Math.min(low, c.y);
          const surface = this.world.heightAt(p.pos.x, low + 0.3, p.pos.z);
          const gap = low - surface;
          if (gap > 0 && gap < 0.35) {
            p.pos.y -= gap - 0.001;
            p.vel.y = 0;
            p.obj.position.copy(p.pos);
            const hit = this.itemContact(p.obj, p.pos);
            p.pos.x += hit.x;
            p.pos.z += hit.z;
            p.pos.y += hit.up;
            p.obj.position.copy(p.pos);
            p.grounded = true;
          }
        }
        // Wedged between two things that push opposite ways (a gap too narrow for it):
        // move to the nearest spot nearby where it fits.
        const left = this.itemContact(p.obj, p.pos);
        if (left.up || left.x || left.z) {
          if (this.freeItem(p.obj, p.pos)) {
            p.vel.x *= 0.3;
            p.vel.z *= 0.3;
            p.grounded = true;
          }
        }
        if (p.grounded) {
          const rollsOn = isThrow(p.def) && p.def.rolls;
          const f = Math.exp(-(rollsOn ? 0.7 : 7) * dt);
          p.vel.x *= f;
          p.vel.z *= f;
          // Lying still in its resting pose: done.
          if (rollsOn && Math.hypot(p.vel.x, p.vel.z) < 0.15 && Math.abs(p.vel.y) < 0.5) this.restPickup(p);
          else if (Math.hypot(p.vel.x, p.vel.z) < 0.25 && Math.abs(p.vel.y) < 0.5 && p.obj.quaternion.angleTo(p.restQuaternion(_restQ)) < 0.06) {
            this.restPickup(p);
          }
        }
      }
      // Resting items glint with a ring so they're easy to spot.
      p.marker.visible = p.resting;
      if (p.resting) {
        const pulse = 1 + Math.sin(p.age * 4) * 0.08;
        p.marker.position.set(p.pos.x, p.floorY + 0.02, p.pos.z);
        p.marker.scale.setScalar(pulse);
        p.obj.position.y = p.pos.y + Math.max(0, Math.sin(p.age * 3)) * 0.02;
      }
    }
  }

  /** Find the nearest nearby position (around and a little above) where an item overlaps nothing. */
  private freeItem(obj: THREE.Object3D, pos: THREE.Vector3): boolean {
    const start = pos.clone();
    for (const r of [0.12, 0.25, 0.4, 0.6, 0.9, 1.3, 1.8, 2.4, 3]) {
      for (const lift of [0, 0.25, 0.6, 1.2, 2.2]) {
        for (let k = 0; k < 8; k++) {
          const a = (k / 8) * Math.PI * 2;
          pos.set(start.x + Math.cos(a) * r, start.y + lift, start.z + Math.sin(a) * r);
          obj.position.copy(pos);
          const hit = this.itemContact(obj, pos);
          if (!hit.up && !hit.x && !hit.z) return true;
        }
      }
    }
    pos.copy(start);
    obj.position.copy(pos);
    return false;
  }

  /** Snap into the exact resting pose, sitting on whatever is underneath. */
  private restPickup(p: Pickup): void {
    p.obj.quaternion.copy(p.restQuaternion(_restQ));
    p.obj.position.copy(p.pos);
    for (let iter = 0; iter < 6; iter++) {
      const hit = this.itemContact(p.obj, p.pos);
      if (!hit.up && !hit.x && !hit.z) break;
      p.pos.x += hit.x;
      p.pos.z += hit.z;
      p.pos.y += hit.up;
      p.obj.position.copy(p.pos);
    }
    const left = this.itemContact(p.obj, p.pos);
    if (left.up || left.x || left.z) this.freeItem(p.obj, p.pos);
    let low = Infinity;
    for (const c of this.itemCorners(p.obj)) low = Math.min(low, c.y);
    p.floorY = low;
    p.vel.set(0, 0, 0);
    p.spin.set(0, 0, 0);
    p.resting = true;
  }

  /** Put an item down at rest on a surface (round start, scattered objects). */
  private placePickup(p: Pickup, x: number, surfaceY: number, z: number): void {
    // Dice lie on a random face; everything else gets a random heading.
    const quarter = () => Math.floor(Math.random() * 4) * (Math.PI / 2);
    const anyFace = isThrow(p.def) && p.def.anyFace;
    p.obj.quaternion.setFromEuler(new THREE.Euler(anyFace ? quarter() : 0, Math.random() * Math.PI * 2, anyFace ? quarter() : 0, 'YXZ'));
    p.obj.quaternion.copy(p.restQuaternion(_restQ));
    p.pos.set(x, surfaceY + 2, z);
    p.obj.position.copy(p.pos);
    let low = Infinity;
    for (const c of this.itemCorners(p.obj)) low = Math.min(low, c.y);
    p.pos.y += surfaceY + 0.002 - low;
    this.restPickup(p);
  }

  // ---- Thrown objects ---------------------------------------------------------------------

  private projectiles: Projectile[] = [];

  dropCarry(f: Fighter, away?: THREE.Vector3): void {
    const from = f.carryPos(new THREE.Vector3());
    const def = f.releaseCarry();
    if (!def) return;
    const p = new Pickup(def, 0, this.scene);
    // Let go in front of the samurai, or away from a wall/ledge they just grabbed.
    const dir = away ? away.clone().setY(0).normalize() : f.forward();
    p.pos.copy(from);
    if (away) p.pos.addScaledVector(dir, def.radius + 0.15);
    this.pushOutOfSolids(p.pos, def.radius);
    p.vel.set(dir.x * 1.2, 1.5, dir.z * 1.2);
    p.spin.set((Math.random() - 0.5) * 4, (Math.random() - 0.5) * 3, (Math.random() - 0.5) * 4);
    this.pickups.push(p);
  }

  /**
   * Move a small object out of any solid it overlaps: onto the top if it's near the
   * top, otherwise sideways the shortest way. Returns the axis it was pushed along.
   */
  private pushOutOfSolids(pos: THREE.Vector3, radius: number): 'x' | 'z' | 'y' | null {
    let axis: 'x' | 'z' | 'y' | null = null;
    for (let iter = 0; iter < 3; iter++) {
      const s = this.world.solids.find(
        (o) => pos.x > o.minX - radius && pos.x < o.maxX + radius && pos.z > o.minZ - radius && pos.z < o.maxZ + radius && pos.y > o.minY && pos.y < o.maxY,
      );
      if (!s) break;
      const exits: [number, 'x' | 'z' | 'y', number][] = [
        [pos.x - (s.minX - radius), 'x', s.minX - radius - 0.01],
        [s.maxX + radius - pos.x, 'x', s.maxX + radius + 0.01],
        [pos.z - (s.minZ - radius), 'z', s.minZ - radius - 0.01],
        [s.maxZ + radius - pos.z, 'z', s.maxZ + radius + 0.01],
        [s.maxY - pos.y < 0.35 && s.grab !== false ? s.maxY - pos.y : Infinity, 'y', s.maxY + 0.01],
      ];
      exits.sort((a, b) => a[0] - b[0]);
      const [, which, value] = exits[0]!;
      pos[which] = value;
      axis = which;
    }
    return axis;
  }

  /** Nearest foe roughly in front of the thrower, at any height, within throwing range. */
  throwTarget(f: Fighter): Fighter | null {
    const fwd = f.forward();
    let best: Fighter | null = null;
    let bestScore = Infinity;
    for (const o of this.fighters) {
      if (o.team === f.team || !o.alive) continue;
      const dx = o.pos.x - f.pos.x;
      const dz = o.pos.z - f.pos.z;
      const d = Math.hypot(dx, dz);
      if (d > 14 || d < 0.01) continue;
      const facing = (dx * fwd.x + dz * fwd.z) / d;
      if (facing < 0.25) continue;
      const score = d * (2 - facing);
      if (score < bestScore) {
        bestScore = score;
        best = o;
      }
    }
    return best;
  }

  throwItem(f: Fighter, def: ThrowDef, from: THREE.Vector3): void {
    // Rubber bands are flicked fast and flat; everything else is lobbed.
    const speed = def.flick ?? 11 - def.weight * 2.5;
    const gravity = def.flick ? 2.5 : PROJECTILE_GRAVITY;
    const target = this.throwTarget(f);
    const vel = new THREE.Vector3();
    if (target) {
      // Lob it so it arrives at the target's chest.
      const aim = target.pos.clone().setY(target.pos.y + 0.55);
      const dx = aim.x - from.x;
      const dz = aim.z - from.z;
      const d = Math.hypot(dx, dz);
      const t = Math.max(0.15, d / speed);
      vel.set((dx / d) * speed, (aim.y - from.y + 0.5 * gravity * t * t) / t, (dz / d) * speed);
    } else {
      const fwd = f.forward();
      vel.set(fwd.x * speed, def.flick ? 0.4 : 3.2, fwd.z * speed);
    }
    const obj = new THREE.Group();
    const mesh = def.build();
    mesh.position.y = -def.height / 2;
    obj.add(mesh);
    obj.position.copy(from);
    this.scene.add(obj);
    this.projectiles.push({
      def,
      obj,
      pos: from.clone().setY(from.y + def.height / 2),
      vel,
      // Tumbling end over end; a die also gets a random twist on every axis, so it lands any way up.
      spin: def.anyFace
        ? new THREE.Vector3(6 + Math.random() * 10, (Math.random() - 0.5) * 16, (Math.random() - 0.5) * 16)
        : new THREE.Vector3(8 + Math.random() * 6, (Math.random() - 0.5) * 6, (Math.random() - 0.5) * 4),
      owner: f,
      spent: false,
      gravity,
    });
  }

  private updateProjectiles(dt: number): void {
    const b = this.room!.bounds;
    for (let i = this.projectiles.length - 1; i >= 0; i--) {
      const p = this.projectiles[i]!;
      const prev = p.pos.clone();
      p.vel.y -= p.gravity * dt;
      p.pos.addScaledVector(p.vel, dt);
      p.obj.position.copy(p.pos);
      p.obj.rotation.x += p.spin.x * dt;
      p.obj.rotation.y += p.spin.y * dt;
      p.obj.rotation.z += p.spin.z * dt;

      // Hit a fighter?
      if (!p.spent) {
        for (const e of this.fighters) {
          if (e.team === p.owner.team || !e.vulnerable) continue;
          const dy = p.pos.y - e.pos.y;
          if (dy < -p.def.radius || dy > e.hurtHeight + p.def.radius) continue;
          if (Math.hypot(p.pos.x - e.pos.x, p.pos.z - e.pos.z) > 0.3 + p.def.radius * 0.8) continue;
          this.projectileHit(p, e);
          break;
        }
        if (!this.projectiles.includes(p)) continue;
      }

      // Hit the room? Any corner of the spinning object entering the floor, a wall or furniture.
      const out = p.pos.x < b.minX || p.pos.x > b.maxX || p.pos.z < b.minZ || p.pos.z > b.maxZ;
      const hit = this.itemContact(p.obj, p.pos);
      if (!out && !hit.up && !hit.x && !hit.z) continue;
      if (p.def.breaks && p.vel.length() > 4) {
        this.shatter(p);
        continue;
      }
      // From here it tumbles like any dropped item, keeping its motion and spin.
      const pick = new Pickup(p.def, 0, this.scene);
      pick.obj.quaternion.copy(p.obj.quaternion);
      pick.pos.copy(out ? prev : p.pos);
      pick.vel.copy(p.vel);
      pick.spin.copy(p.spin);
      pick.obj.position.copy(pick.pos);
      this.pickups.push(pick);
      sfx.sfxLand(false);
      this.removeProjectile(p);
    }
  }

  private projectileHit(p: Projectile, e: Fighter): void {
    const def = p.def;
    const from = p.pos.clone().addScaledVector(p.vel, -0.1);
    const toThrower = new THREE.Vector3(p.owner.pos.x - e.pos.x, 0, p.owner.pos.z - e.pos.z).normalize();
    p.spent = true;
    if (e.blocking && e.forward().dot(toThrower) > 0.1) {
      // Batted away by the guard.
      e.blocked(from, 1 + def.knock * 0.3);
      sfx.sfxClang();
      this.fx.sparks(p.pos, 8, 0.7);
      this.popup(e, 'Blocked!', '#d6ecff');
    } else {
      const wasAlive = e.alive;
      const lethal = e.health - def.damage <= 0;
      if (!e.hurt(def.damage, from, def.knock, def.knock >= 5 || lethal)) {
        sfx.sfxClang();
        p.vel.x *= -0.3;
        p.vel.z *= -0.3;
        return;
      }
      e.freeze = e.shake = 0.07;
      this.fx.impact(p.pos, 0.8, 0xfff1c8);
      this.fx.chipsBurst(p.pos, p.vel.clone().setY(0).normalize(), [e.livery.armor, e.livery.cloth], 6, e.pos.y);
      this.shake(def.knock >= 5 ? 0.35 : 0.18);
      sfx.sfxHit(def.knock >= 5);
      this.popup(e, `${def.name}!`, '#ffcf8a');
      if (wasAlive && !e.alive) this.onKO(e, p.vel.clone().setY(0).normalize());
    }
    if (def.breaks) {
      this.shatter(p);
      return;
    }
    // Sturdy things rebound and drop.
    p.vel.x *= -0.3;
    p.vel.z *= -0.3;
    p.vel.y = Math.max(p.vel.y, 2);
  }

  private shatter(p: Projectile): void {
    const floor = this.world.heightAt(p.pos.x, p.pos.y + 0.1, p.pos.z);
    this.fx.chipsBurst(p.pos, p.vel.clone().setY(0).normalize().multiplyScalar(0.5), p.def.chips, 16, floor);
    this.fx.sparks(p.pos, 6, 0.6, 0xffffff);
    sfx.sfxBreak();
    for (const id of p.def.burst ?? []) {
      const def = itemById(id);
      const spill = new Pickup(def, def.durability, this.scene);
      spill.pos.copy(p.pos);
      this.pushOutOfSolids(spill.pos, 0.2);
      spill.vel.set((Math.random() - 0.5) * 5, 3 + Math.random() * 2, (Math.random() - 0.5) * 5);
      spill.spin.set((Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14);
      this.pickups.push(spill);
    }
    if (p.def.splash) {
      this.addPuddle(p.pos.x, floor, p.pos.z, p.def.splash, 14);
      this.effect('splash', p.pos);
    }
    this.removeProjectile(p);
  }

  private removeProjectile(p: Projectile): void {
    p.obj.removeFromParent();
    this.projectiles.splice(this.projectiles.indexOf(p), 1);
  }

  /** Scatter throwable objects on open spots of the floor and furniture tops. */
  private scatterThrowables(count: number, only?: string[]): void {
    const room = this.room!;
    let placed = 0;
    for (let i = 0; i < 300 + count * 20 && placed < count; i++) {
      const { x, z, area } = this.randomSpot(1.5);
      const kinds = only ?? THROWS_BY_ROOM[area ?? room.id];
      const top = this.world.groundBelow(x, 14, z, 0.4);
      if (!top || top.maxY > 12 || top.slide || top.moving) continue;
      const y = top.maxY;
      if (this.world.blocked(x, y, z, 1.2, 0.4)) continue;
      if (this.pickups.some((p) => p.pos.distanceTo(new THREE.Vector3(x, y, z)) < 1.2)) continue;
      if (this.fighters.some((f) => Math.hypot(f.pos.x - x, f.pos.z - z) < 1.5)) continue;
      const id = kinds[Math.floor(Math.random() * kinds.length)]!;
      const def = POWERUPS.find((d) => d.id === id) ?? throwById(id);
      const p = new Pickup(def, 0, this.scene);
      this.placePickup(p, x, y, z);
      this.pickups.push(p);
      placed++;
    }
  }

  /** Put down one of each weapon, throwable and pick-up that isn't lying somewhere already. */
  private ensureEveryItem(): void {
    const have = new Set(this.pickups.map((p) => p.def.id));
    const missing: (WeaponDef | ThrowDef)[] = [
      ...Object.values(ITEMS).filter((d) => d.id !== KATANA.id),
      ...Object.values(THROWABLES),
      ...POWERUPS,
    ].filter((d) => !have.has(d.id));
    for (const def of missing) {
      for (let i = 0; i < 200; i++) {
        const { x, z } = this.randomSpot(1.5);
        const top = this.world.groundBelow(x, 14, z, 0.4);
        if (!top || top.maxY > 12 || top.slide || top.moving) continue;
        const y = top.maxY;
        if (this.world.blocked(x, y, z, 1.2, 0.4)) continue;
        if (this.pickups.some((p) => p.pos.distanceTo(_moverPos.set(x, y, z)) < 1.2)) continue;
        if (this.fighters.some((f) => Math.hypot(f.pos.x - x, f.pos.z - z) < 1.5)) continue;
        const p = new Pickup(def, isThrow(def) ? 0 : def.durability, this.scene);
        this.placePickup(p, x, y, z);
        this.pickups.push(p);
        break;
      }
    }
  }

  // ---- Frame ---------------------------------------------------------------------------

  update(rawDt: number, raw: RawInput[]): void {
    if (!this.options || !this.room) return;
    const dt = Math.min(1 / 30, rawDt) * this.slowmo;
    if (this.paused) {
      this.render();
      return;
    }
    if (this.slowTime > 0) {
      this.slowTime -= rawDt;
      if (this.slowTime <= 0) this.slowmo = 1;
    }
    this.phaseTime += dt;
    if (this.banner) this.banner.age += rawDt;

    // Players steer their camera: mouse look (player 1), Z/X, numpad 7/9 or a right stick.
    // In split screen each player turns only their own camera.
    if (!this.options.attract) {
      raw.forEach((r, i) => {
        if (i >= this.humanSlots.size && i > 0) return;
        const cam = this.split ? this.cams[i]! : this.cam;
        if (r.turn) cam.turn(-Math.max(-1, Math.min(1, r.turn)) * rawDt * 2.2);
        if (r.tilt) cam.tilt(Math.max(-1, Math.min(1, r.tilt)) * rawDt * 1.3);
        if (r.lookX || r.lookY) cam.look(r.lookX, r.lookY);
      });
    }
    if (this.movers.length) this.updateMovers(dt);
    this.updateCars(dt);
    this.updateRollers(dt);
    this.updateHazards(dt);
    this.updatePowerups(dt);
    if (this.room.areas) this.fitHouseShadow();
    const acting = this.phase === 'fight' || this.phase === 'roundEnd' || this.options.attract;
    if (this.options.attract && this.phase === 'intro') this.phase = 'fight';
    for (const f of this.fighters) {
      let c: Controls;
      const human = this.humanSlots.get(f.index);
      if (!acting || (this.phase === 'roundEnd' && f.alive && f.state === 'victory')) c = noControls();
      else if (human !== undefined) c = this.controlsFrom(raw[human] ?? null, this.split ? this.cams[human]! : this.cam);
      else c = this.brains.get(f)!.think(dt);
      if (this.phase === 'roundEnd' && !this.options.attract) c = noControls();
      // At the wheel: W/S (stick up/down) are the pedals, A/D (stick left/right) steer, jump hops out.
      const car = this.driving.get(f);
      if (car) {
        const r = human !== undefined ? raw[human] : null;
        car.throttle = r?.y ?? 0;
        car.turning = r?.x ?? 0;
        car.handbrake = r?.handbrake ?? false;
        if (c.jump) this.leaveCar(f, car);
      }
      f.update(dt, c);
    }
    this.separate();
    this.keepInside();
    for (const f of this.fighters) f.animate(dt);
    this.resolveStrikes();
    for (const f of this.fighters) this.fx.trail(f.index, f.bladeBase, f.bladeTip, f.state === 'attack' && f.striking && !f.attack?.limb && !!f.weapon, f.team ? 0xcfe6ff : 0xffe2d0);
    this.updatePickups(dt);
    this.updateProjectiles(dt);
    this.updatePhase(dt);
    for (let i = this.popups.length - 1; i >= 0; i--) {
      const p = this.popups[i]!;
      p.age += rawDt;
      p.at.y += rawDt * 0.6;
      if (p.age > 1.1) this.popups.splice(i, 1);
    }
    this.refreshViews();
    if (this.split) {
      for (const view of this.views()) {
        const me = view.fighter;
        // The spare quarter with three samurai shows the whole fight.
        if (me) view.cam.update(rawDt, this.viewPoints(me), this.viewDuel(me), view.w / view.h);
        else view.cam.update(rawDt, this.focusPoints(), this.duelPair(), view.w / view.h);
      }
    } else this.cam.update(rawDt, this.focusPoints(), this.duelPair(), this.width / this.height);
    this.fx.update(dt, this.cam.camera);
    this.render();
  }

  render(): void {
    const r = this.renderer;
    if (!this.split) {
      this.cam.beginRender();
      r.render(this.scene, this.cam.camera);
      this.cam.endRender();
      return;
    }
    // One viewport per player; each camera applies its own wall cutaway and see-through furniture.
    r.setScissorTest(true);
    for (const v of this.views()) {
      const y = this.height - v.y - v.h;
      r.setViewport(v.x, y, v.w, v.h);
      r.setScissor(v.x, y, v.w, v.h);
      v.cam.beginRender();
      r.render(this.scene, v.cam.camera);
      v.cam.endRender();
    }
    r.setScissorTest(false);
    r.setViewport(0, 0, this.width, this.height);
  }

  // ---- Split screen ----------------------------------------------------------------------

  /** Human players in this match. */
  get humanCount(): number {
    return this.humanSlots.size;
  }

  /**
   * Who each screen section follows: every samurai in the fight gets one, players first
   * (in player order) and then CPUs, up to four. In Waves, enemies take a free section
   * as they arrive and give it up once they're gone.
   */
  private viewSlots: Fighter[] = [];

  private refreshViews(): void {
    if (!this.options || this.options.attract) {
      this.viewSlots = [];
      return;
    }
    const before = this.viewSlots;
    const keep = before.filter((f) => this.fighters.includes(f));
    const order = [...this.fighters].sort((a, b) => this.viewRank(a) - this.viewRank(b));
    for (const f of order) {
      if (keep.length >= 4) break;
      if (!keep.includes(f) && (f.alive || f.human)) keep.push(f);
    }
    keep.sort((a, b) => this.viewRank(a) - this.viewRank(b) || before.indexOf(a) - before.indexOf(b));
    // A section that now follows someone new jumps straight to them.
    const changed = keep.some((f, i) => before[i] !== f);
    this.viewSlots = keep;
    if (changed && this.phase !== 'intro') {
      for (const view of this.views()) {
        if (!view.fighter || before[this.cams.indexOf(view.cam)] === view.fighter) continue;
        const duel = this.viewDuel(view.fighter);
        view.cam.snap(this.viewPoints(view.fighter), view.w / view.h, duel?.[0], duel?.[1]);
      }
    }
  }

  /** Players (in player order) come before CPUs. */
  private viewRank(f: Fighter): number {
    const player = this.humanSlots.get(f.index);
    return player !== undefined ? player : 10 + this.fighters.indexOf(f);
  }

  /** Every samurai gets their own camera whenever there's more than one (not in the menu backdrop). */
  get split(): boolean {
    return !!this.options && !this.options.attract && this.viewSlots.length > 1;
  }

  /**
   * Screen areas in CSS pixels (top-left origin): the whole canvas; two halves (side
   * by side on wide frames, stacked on tall ones); or quarters for 3-4 samurai, where
   * the 4th quarter is an overview of the whole fight when only three are fighting.
   * `human` is the player number a view belongs to (-1 for a CPU or the overview).
   */
  views(): { cam: FightCamera; human: number; fighter: Fighter | null; x: number; y: number; w: number; h: number }[] {
    const humanOf = (f: Fighter | null | undefined) => (f ? (this.humanSlots.get(f.index) ?? -1) : -1);
    if (!this.split) {
      const solo = this.viewSlots[0] ?? this.humanFighter(0) ?? null;
      return [{ cam: this.cam, human: 0, fighter: solo, x: 0, y: 0, w: this.width, h: this.height }];
    }
    const slots = this.viewSlots;
    if (slots.length === 2) {
      const wide = this.width >= this.height;
      const w = wide ? Math.floor(this.width / 2) : this.width;
      const h = wide ? this.height : Math.floor(this.height / 2);
      return [
        { cam: this.cam, human: humanOf(slots[0]), fighter: slots[0]!, x: 0, y: 0, w, h },
        { cam: this.cam2, human: humanOf(slots[1]), fighter: slots[1]!, x: wide ? this.width - w : 0, y: wide ? 0 : this.height - h, w, h },
      ];
    }
    const w = Math.floor(this.width / 2);
    const h = Math.floor(this.height / 2);
    return [0, 1, 2, 3].map((i) => ({
      cam: this.cams[i]!,
      human: humanOf(slots[i]),
      fighter: slots[i] ?? null,
      x: i % 2 ? this.width - w : 0,
      y: i >= 2 ? this.height - h : 0,
      w,
      h,
    }));
  }

  humanFighter(player: number): Fighter | undefined {
    for (const [index, human] of this.humanSlots) if (human === player) return this.fighters.find((f) => f.index === index);
    return undefined;
  }

  /** Nearest living foe anywhere (HUD: whose health a player sees beside their own). */
  nearestFoe(me: Fighter): Fighter | null {
    let best: Fighter | null = null;
    let bestD = Infinity;
    for (const f of this.fighters) {
      if (f.team === me.team || !f.alive) continue;
      const d = f.pos.distanceTo(me.pos);
      if (d < bestD) {
        bestD = d;
        best = f;
      }
    }
    return best;
  }

  /** Nearest living foe close enough to share a player's view. */
  private viewFoe(me: Fighter): Fighter | null {
    let best: Fighter | null = null;
    let bestD = 7;
    for (const f of this.fighters) {
      if (f.team === me.team || !f.alive) continue;
      const d = f.pos.distanceTo(me.pos);
      if (d < bestD) {
        bestD = d;
        best = f;
      }
    }
    return best;
  }

  /** A split-screen camera follows its player, widening to include a nearby foe. */
  private viewPoints(me: Fighter): THREE.Vector3[] {
    const foe = this.viewFoe(me);
    return foe ? [me.pos, foe.pos] : [me.pos];
  }

  private viewDuel(me: Fighter): [THREE.Vector3, THREE.Vector3] | null {
    const foe = this.viewFoe(me);
    return foe ? [me.pos, foe.pos] : null;
  }

  /** Camera shake on every camera that's showing. */
  private shake(amount: number): void {
    if (!this.cam.shakeEnabled) return;
    for (const v of this.views()) v.cam.shake(amount);
  }

  /** Raw stick input → camera-relative world controls. */
  private controlsFrom(r: RawInput | null, cam: FightCamera): Controls {
    const c = noControls();
    if (!r) return c;
    cam.basis(this.fwd, this.right);
    c.moveX = this.right.x * r.x + this.fwd.x * r.y;
    c.moveZ = this.right.z * r.x + this.fwd.z * r.y;
    c.run = r.run;
    c.crouch = r.crouch;
    c.jump = r.jump;
    c.climb = r.up;
    c.drop = r.down;
    c.light = r.light;
    c.heavy = r.heavy;
    c.block = r.block;
    c.grab = r.grab;
    c.roll = r.roll;
    return c;
  }

  private focusPoints(): THREE.Vector3[] {
    const living = this.fighters.filter((f) => f.alive || this.phase !== 'fight');
    return (living.length ? living : this.fighters).map((f) => f.pos);
  }

  /** The duel the camera stays side-on to: the first human and their nearest foe. */
  private duelPair(): [THREE.Vector3, THREE.Vector3] | null {
    const lead = this.fighters.find((f) => f.human && f.alive) ?? this.fighters.find((f) => f.alive);
    if (!lead) return null;
    let foe: Fighter | null = null;
    let best = Infinity;
    for (const f of this.fighters) {
      if (f.team === lead.team || !f.alive) continue;
      const d = f.pos.distanceTo(lead.pos);
      if (d < best) {
        best = d;
        foe = f;
      }
    }
    return foe ? [lead.pos, foe.pos] : null;
  }

  /** Safety net: anyone outside the walls, on top of a wall or stuck inside furniture goes back to safe ground. */
  private keepInside(): void {
    const room = this.room!;
    const b = room.bounds;
    for (const f of this.fighters) {
      const p = f.pos;
      const away = room.areas ? !room.areas.some((a) => p.x >= a.minX && p.x <= a.maxX && p.z >= a.minZ && p.z <= a.maxZ) : p.x < b.minX || p.x > b.maxX || p.z < b.minZ || p.z > b.maxZ;
      const outside = away || p.y > room.ceiling + 4 || p.y < -1;
      const stuck = f.state !== 'hang' && f.state !== 'climb' && this.world.blockedStatic(p.x, p.y + 0.05, p.z, 0.3, 0.1);
      if (outside || stuck) f.rescue();
    }
  }

  /** A random point on the floor: anywhere in a single room, or in one of the house's rooms (bigger ones more often). */
  private randomSpot(margin: number): { x: number; z: number; area?: RoomId } {
    const room = this.room!;
    const areas = room.areas?.filter((a) => !a.doorway) ?? [{ ...room.bounds, room: undefined }];
    const size = (a: { minX: number; maxX: number; minZ: number; maxZ: number }) => (a.maxX - a.minX) * (a.maxZ - a.minZ);
    let pick = Math.random() * areas.reduce((t, a) => t + size(a), 0);
    let area = areas[0]!;
    for (const a of areas) {
      area = a;
      pick -= size(a);
      if (pick <= 0) break;
    }
    return {
      x: area.minX + margin + Math.random() * Math.max(0, area.maxX - area.minX - 2 * margin),
      z: area.minZ + margin + Math.random() * Math.max(0, area.maxZ - area.minZ - 2 * margin),
      area: area.room,
    };
  }

  // ---- Shadows in the house --------------------------------------------------------------

  private shadowHalf = 0;

  /**
   * The house is too big for one sharp shadow map, so the sun's shadow box covers just
   * the fighters (growing when they are far apart). Its centre moves in whole shadow
   * texels, so shadows don't shimmer as it follows them.
   */
  private fitHouseShadow(): void {
    const room = this.room!;
    const watched = this.viewSlots.filter((f) => f.alive);
    const who = watched.length ? watched : this.fighters;
    if (!who.length) return;
    let minX = Infinity;
    let maxX = -Infinity;
    let minZ = Infinity;
    let maxZ = -Infinity;
    for (const f of who) {
      minX = Math.min(minX, f.pos.x);
      maxX = Math.max(maxX, f.pos.x);
      minZ = Math.min(minZ, f.pos.z);
      maxZ = Math.max(maxZ, f.pos.z);
    }
    const half = Math.min(90, Math.ceil((Math.max(maxX - minX, maxZ - minZ) / 2 + 26) / 8) * 8);
    const sc = this.sun.shadow.camera;
    if (half !== this.shadowHalf) {
      this.shadowHalf = half;
      sc.left = sc.bottom = -half;
      sc.right = sc.top = half;
      sc.near = 1;
      sc.far = 160;
      sc.updateProjectionMatrix();
    }
    const texel = (2 * half) / this.sun.shadow.mapSize.x;
    const dir = room.sunDir;
    const back = _shadowZ.copy(dir).negate();
    const right = _shadowX.crossVectors(_up, back).normalize();
    const up = _shadowY.crossVectors(back, right);
    const c = _shadowC.set((minX + maxX) / 2, 3, (minZ + maxZ) / 2);
    const a = Math.round(c.dot(right) / texel) * texel;
    const b = Math.round(c.dot(up) / texel) * texel;
    const d = c.dot(back);
    c.copy(right).multiplyScalar(a).addScaledVector(up, b).addScaledVector(back, d);
    this.sun.target.position.copy(c);
    this.sun.position.copy(c).addScaledVector(dir, -70);
  }

  // ---- Moving machines ----------------------------------------------------------------

  private movers: Mover[] = [];
  private readonly bodyOf = new Map<Solid, Body>();
  /** Samurai who were riding (or hanging from) a moving body last frame. */
  private readonly airFrom = new Map<Fighter, Body>();

  /**
   * Run the machines and carry anyone (and anything) standing on them or hanging from
   * their edges. Leaving one keeps its motion: off the train you go on moving with it
   * until you land; off a fan blade or a pendulum you're flung the way it was going.
   * The train nudges samurai in its way off the track, or scoops them up if there's
   * no room beside it.
   */
  private updateMovers(dt: number): void {
    const launches: { solids: Solid[]; vel: THREE.Vector3 }[] = [];
    const events: MoverEvents = {
      launch: (solids, vel) => launches.push({ solids, vel: vel.clone() }),
      sound: (kind, at) => this.machineSound(kind, at),
    };
    for (const m of this.movers) m.update(dt, events);

    for (const f of this.fighters) {
      const body = f.body;
      f.carryVel.set(0, 0, 0);
      const on = body.onGround && body.support ? this.bodyOf.get(body.support) : undefined;
      if (on) {
        f.facing += on.carry(body.pos);
        this.airFrom.set(f, on);
        continue;
      }
      // Hanging from (or climbing onto) a moving edge: go where it goes.
      const ledge = f.heldLedge;
      const held = ledge ? this.bodyOf.get(ledge.solid) : undefined;
      if (held) {
        f.followLedge((p) => held.carry(p), held.turn);
        this.airFrom.set(f, held);
        continue;
      }
      const left = this.airFrom.get(f);
      if (left) {
        if (left.options.pushes && !body.onGround && f.state !== 'hang' && f.state !== 'climb') {
          // Off the train: still moving with it, so a hop lands back on the same spot.
          f.facing += left.carry(body.pos);
          f.carryVel.copy(left.pointVelocity(body.pos, _moverVel)).setY(0);
          continue;
        }
        if (!left.options.pushes) {
          // Let go of something swinging or spinning: flung the way it was going.
          left.pointVelocity(body.pos, _moverVel);
          body.vel.x += _moverVel.x;
          body.vel.z += _moverVel.z;
          body.vel.y += Math.max(0, _moverVel.y);
        }
        this.airFrom.delete(f);
      }
      if (f.state === 'hang' || f.state === 'climb') continue;
      for (const m of this.movers) {
        for (const b of m.bodies) {
          if (!b.options.pushes) continue;
          const part = b.partAt(body.pos.x, body.pos.z, RADIUS);
          if (!part || body.pos.y >= b.y + part.top - 0.25 || body.pos.y + HEIGHT < b.y + 0.05) continue;
          const { side, along } = b.local(body.pos.x, body.pos.z);
          const clear = part.hw + RADIUS + 0.04;
          const sign = side >= 0 ? 1 : -1;
          // Slide out sideways; while still in front of it, get pushed along ahead.
          let sd = side + sign * Math.min(clear - Math.abs(side), 7 * dt);
          let a = along;
          if (Math.abs(sd) < clear) a = along >= (part.z0 + part.z1) / 2 ? Math.max(along, part.z1 + RADIUS + 0.02) : Math.min(along, part.z0 - RADIUS - 0.02);
          _moverPos.y = body.pos.y;
          b.world(sd, a, _moverPos);
          if (this.world.blockedStatic(_moverPos.x, body.pos.y, _moverPos.z, body.height)) {
            // Hemmed in (chair legs, a doorway): ride along on top instead.
            sd = Math.max(-part.hw + 0.3, Math.min(part.hw - 0.3, side));
            a = Math.max(part.z0 + 0.3, Math.min(part.z1 - 0.3, along));
            _moverPos.y = b.y + part.top + 0.02;
            b.world(sd, a, _moverPos);
            body.vel.set(0, 0, 0);
            body.onGround = false;
          }
          body.pos.copy(_moverPos);
          break;
        }
      }
    }

    // Things lying on a moving body ride along; things in the train's way get knocked aside.
    for (const p of this.pickups) {
      if (!p.resting) continue;
      outer: for (const m of this.movers) {
        for (const b of m.bodies) {
          const part = b.partAt(p.pos.x, p.pos.z, 0.15);
          if (!part) continue;
          const top = b.y + part.top;
          if (Math.abs(p.floorY - top) < 0.06 || (p.floorY > top - 0.1 && p.floorY < top + 0.4 && b.vel.y > 0)) {
            const turn = b.carry(p.pos);
            p.floorY = top;
            p.obj.quaternion.premultiply(_spinQ.setFromAxisAngle(_up, turn));
            p.obj.position.copy(p.pos);
            break outer;
          }
          if (b.options.pushes && p.floorY < top - 0.2 && p.floorY > b.y - 0.2) {
            const { side } = b.local(p.pos.x, p.pos.z);
            b.world(side >= 0 ? 1 : -1, 0, _moverPos.set(0, 0, 0));
            const out = _moverPos.sub(_moverVel.set(b.x, 0, b.z)).normalize();
            p.vel.set(out.x * 3.5, 2.5, out.z * 3.5);
            p.spin.set(Math.random() * 4, Math.random() * 4, Math.random() * 4);
            p.resting = false;
            p.landed = false;
            break outer;
          }
        }
      }
    }

    // The toaster pops: fling whoever (and whatever) is standing on it.
    for (const l of launches) {
      for (const f of this.fighters) {
        if (!f.body.onGround || !f.body.support || !l.solids.includes(f.body.support)) continue;
        f.launch(l.vel.x + (Math.random() - 0.5) * 3, l.vel.y, l.vel.z + (Math.random() - 0.5) * 3);
        this.airFrom.delete(f);
        this.popup(f, 'Boing!', '#ffd27a');
      }
      for (const p of this.pickups) {
        if (!p.resting) continue;
        const under = this.world.groundBelow(p.pos.x, p.floorY + 0.05, p.pos.z, 0.1);
        if (!under || !l.solids.includes(under)) continue;
        p.vel.set((Math.random() - 0.5) * 3, l.vel.y * 0.8, (Math.random() - 0.5) * 3);
        p.spin.set(Math.random() * 10, Math.random() * 10, Math.random() * 10);
        p.resting = false;
        p.landed = false;
      }
    }
  }

  /** A machine's sound, as loud as the nearest player is close to it. */
  private machineSound(kind: 'chuff' | 'whistle' | 'pop' | 'tick' | 'whirr', at: THREE.Vector3): void {
    let near = Infinity;
    const listeners = this.fighters.filter((f) => f.human);
    for (const f of listeners.length ? listeners : this.fighters) near = Math.min(near, f.pos.distanceTo(at));
    const v = Math.max(0, 1 - near / 28);
    if (v <= 0) return;
    if (kind === 'chuff') sfx.sfxChuff(v);
    else if (kind === 'whistle') sfx.sfxWhistle(v);
    else if (kind === 'pop') sfx.sfxPop(v);
    else if (kind === 'tick') sfx.sfxTick(v);
  }

  // ---- Rolling things -----------------------------------------------------------------

  private rollers: Roller[] = [];
  private statics: Solid[] = [];

  /**
   * Balls, cans and reels: walk into one and it rolls away, hit one and it shoots off,
   * and one rolling fast bowls over anyone in its way.
   */
  private updateRollers(dt: number): void {
    for (const r of this.rollers) {
      r.update(dt, this.statics);
      for (const f of this.fighters) {
        const dy = f.pos.y - r.pos.y;
        if (dy > r.r - 0.1 || dy < -r.r - HEIGHT) continue;
        // A strike sends it flying.
        if (f.striking && (r.lastHitBy !== f || r.sinceHit > 0.3)) {
          for (const tip of [f.bladeTip, f.bladeBase]) {
            if (tip.distanceTo(r.pos) > r.r + (r.len ? r.len * 0.5 : 0) + 0.25) continue;
            const fwd = f.forward();
            const power = f.attack?.kind === 'heavy' ? 12 : 8;
            r.push(fwd.x * power - r.vel.x * 0.5, fwd.z * power - r.vel.z * 0.5, r.def.kind === 'ball' ? 4 : 2.5);
            r.lastHitBy = f;
            r.sinceHit = 0;
            sfx.sfxHit(false);
            this.fx.impact(tip, 0.5, 0xfff1c8);
            break;
          }
        }
        // Bumping into it.
        const dx = r.pos.x - f.pos.x;
        const dz = r.pos.z - f.pos.z;
        const reach = (r.len ? Math.max(r.r, r.len * 0.45) : r.r) + RADIUS;
        const d = Math.hypot(dx, dz);
        if (d >= reach || d < 1e-4) continue;
        const nx = dx / d;
        const nz = dz / d;
        const fast = r.speed > 3.2 && !(r.lastHitBy === f && r.sinceHit < 0.6);
        if (fast && f.vulnerable && f.alive) {
          // Bowled over.
          if (f.hurt(4, r.pos, 5, true)) this.popup(f, 'Bowled over!', '#ffd27a');
          r.vel.x *= 0.35;
          r.vel.z *= 0.35;
          sfx.sfxHit(true);
          continue;
        }
        // Pushed along: it gets out of the way at the speed you walked into it.
        const into = Math.max(0, f.body.vel.x * nx + f.body.vel.z * nz);
        const along = r.vel.x * nx + r.vel.z * nz;
        if (along < into * 1.05 + 0.6) r.push(nx * (into * 1.05 + 0.6 - along), nz * (into * 1.05 + 0.6 - along));
        const ox = r.pos.x;
        const oz = r.pos.z;
        r.pos.x = f.pos.x + nx * reach;
        r.pos.z = f.pos.z + nz * reach;
        if (r.stuck(this.statics)) {
          // Jammed against something: it stays put and the samurai stops against it instead.
          r.pos.x = ox;
          r.pos.z = oz;
          r.vel.x = r.vel.z = 0;
          f.pos.x = r.pos.x - nx * reach;
          f.pos.z = r.pos.z - nz * reach;
        }
        r.lastHitBy = f;
        r.sinceHit = 0;
      }
    }
  }

  // ---- Car boxes and toy cars ---------------------------------------------------------------

  private crates: Crate[] = [];
  private driftClock = 0;
  private skidClock = 0;
  private cars: Car[] = [];
  /** Who is driving which car. */
  private readonly driving = new Map<Fighter, Car>();

  /** Fresh crates, no cars: as the round starts. */
  private resetCars(): void {
    const room = this.room!;
    for (const c of this.crates) {
      this.removeSolid(c.solid);
      c.root.removeFromParent();
    }
    for (const car of this.cars) this.removeCar(car);
    this.crates = [];
    this.cars = [];
    this.driving.clear();
    for (const d of room.crates ?? []) {
      const c = new Crate(d.x, d.y, d.z);
      room.root.add(c.root);
      room.solids.push(c.solid);
      this.crates.push(c);
    }
    this.statics = room.solids.filter((sol) => !sol.moving);
    if (this.crates.length) this.nav = new NavGraph(room.solids.filter((sol) => !sol.moving));
  }

  private removeSolid(s: Solid): void {
    const list = this.room!.solids;
    const i = list.indexOf(s);
    if (i >= 0) list.splice(i, 1);
  }

  private removeCar(car: Car): void {
    for (const sol of car.body.solids) {
      this.removeSolid(sol);
      this.bodyOf.delete(sol);
    }
    car.root.removeFromParent();
    const m = this.movers.findIndex((mv) => mv.bodies[0] === car.body);
    if (m >= 0) this.movers.splice(m, 1);
  }

  tryDrive(f: Fighter): boolean {
    if (this.driving.has(f) || !f.alive) return false;
    for (const car of this.cars) {
      if (car.driver || !car.touches(f.pos.x, f.pos.z, 0.9) || Math.abs(f.pos.y - car.pos.y) > 0.8) continue;
      car.driver = f;
      this.driving.set(f, car);
      f.startDriving();
      return true;
    }
    return false;
  }

  private leaveCar(f: Fighter, car: Car, hop = true): void {
    car.driver = null;
    car.throttle = car.turning = 0;
    car.handbrake = false;
    this.driving.delete(f);
    if (!hop) return;
    const side = _moverPos.set(Math.cos(car.yaw), 0, -Math.sin(car.yaw));
    f.pos.addScaledVector(side, 0.9);
    f.pos.y = car.pos.y + 0.8;
    f.leaveCar(Math.sin(car.yaw) * car.speed, Math.cos(car.yaw) * car.speed, side);
  }

  /**
   * Crates splinter under blows and let their car out; cars drive where their drivers
   * steer, carry anyone standing on them, and bowl over anyone they hit at speed.
   */
  private updateCars(dt: number): void {
    for (const c of [...this.crates]) {
      c.update(dt);
      for (const f of this.fighters) {
        if (!f.striking || (c.lastHitBy === f && c.sinceHit < 0.35)) continue;
        const s = c.solid;
        const inside = [f.bladeTip, f.bladeBase].some((p) => p.x > s.minX - 0.25 && p.x < s.maxX + 0.25 && p.z > s.minZ - 0.25 && p.z < s.maxZ + 0.25 && p.y > s.minY - 0.2 && p.y < s.maxY + 0.2);
        if (!inside) continue;
        c.lastHitBy = f;
        c.sinceHit = 0;
        const at = f.bladeTip.clone();
        this.fx.chipsBurst(at, f.forward(), [0xc99a5e, 0x8a6238], 8, s.minY);
        sfx.sfxHit(false);
        if (c.hit(f.attack?.kind === 'heavy' ? 2 : 1)) this.breakCrate(c, f);
        break;
      }
    }
    for (const car of this.cars) {
      const f = car.driver as Fighter | null;
      if (f && (this.driving.get(f) !== car || f.state !== 'drive' || !f.alive)) {
        // Knocked out of the seat.
        this.leaveCar(f, car, false);
      }
    }
    for (const car of this.cars) {
      const f = car.driver as Fighter | null;
      if (f) {
        car.seat(f.pos);
        f.facing = car.yaw;
        f.body.vel.set(Math.sin(car.yaw) * car.speed, 0, Math.cos(car.yaw) * car.speed);
        f.body.onGround = true;
        f.body.support = null;
      }
      // Drifting: grey smoke and bits of rubber off the back wheels, and a squeal.
      if (car.drifting) {
        this.driftClock += dt;
        if (this.driftClock > 0.03) {
          this.driftClock = 0;
          const back = _moverVel.set(-Math.sin(car.yaw), 0, -Math.cos(car.yaw));
          for (const s of [-0.6, 0.6]) {
            car.body.world(s, -0.65, _moverPos.set(0, car.pos.y, 0));
            this.fx.tyreSmoke(_moverPos, back, car.pos.y, car.body.vel);
          }
        }
        this.skidClock -= dt;
        if (this.skidClock <= 0) {
          this.skidClock = 0.16;
          sfx.sfxSkid(Math.min(1, Math.abs(car.slip) / 8) * this.hearing(car.pos));
        }
      }
      // Shove balls, cans and reels out of the way.
      for (const r of this.rollers) {
        if (Math.abs(r.pos.y - r.r - car.pos.y) > 0.6) continue;
        const d = car.nearest(r.pos.x, r.pos.z, _moverPos);
        const reach = r.r + 0.05;
        if (d >= reach) continue;
        const nx = d > 1e-4 ? (r.pos.x - _moverPos.x) / d : Math.sin(car.yaw);
        const nz = d > 1e-4 ? (r.pos.z - _moverPos.z) / d : Math.cos(car.yaw);
        r.pos.x = _moverPos.x + nx * reach;
        r.pos.z = _moverPos.z + nz * reach;
        const along = r.vel.x * nx + r.vel.z * nz;
        const want = Math.abs(car.speed) * 1.15 + 0.5;
        if (along < want) r.push(nx * (want - along), nz * (want - along), Math.abs(car.speed) > 4 ? 2 : 0);
        r.lastHitBy = f;
        r.sinceHit = 0;
      }
      // Running people over at speed; nudging them aside when creeping.
      for (const o of this.fighters) {
        if (o === f || !o.alive) continue;
        if (o.pos.y >= car.pos.y + 0.6 || o.pos.y + HEIGHT < car.pos.y) continue;
        const d = car.nearest(o.pos.x, o.pos.z, _moverPos);
        if (d >= RADIUS) continue;
        if (Math.hypot(car.speed, car.slip) >= 3 && o.vulnerable) {
          if (o.hurt(6, car.pos, 6, true)) this.popup(o, 'Run over!', '#ff9a8a');
          car.speed *= 0.5;
          sfx.sfxHit(true);
          continue;
        }
        const nx = d > 1e-4 ? (o.pos.x - _moverPos.x) / d : Math.cos(car.yaw);
        const nz = d > 1e-4 ? (o.pos.z - _moverPos.z) / d : -Math.sin(car.yaw);
        o.pos.x = _moverPos.x + nx * (RADIUS + 0.02);
        o.pos.z = _moverPos.z + nz * (RADIUS + 0.02);
      }
    }
  }

  private breakCrate(c: Crate, by: Fighter): void {
    const room = this.room!;
    this.crates.splice(this.crates.indexOf(c), 1);
    this.removeSolid(c.solid);
    c.root.removeFromParent();
    const mid = new THREE.Vector3(c.x, c.y + CRATE_SIZE / 2, c.z);
    this.fx.chipsBurst(mid, new THREE.Vector3(0, 1, 0), [0xc99a5e, 0x8a6238, 0xd8262b], 26, c.y);
    this.shake(0.2);
    sfx.sfxBreak();
    // The car inside faces away from whoever broke it open.
    const car = new Car(c.x, c.y, c.z, Math.atan2(c.x - by.pos.x, c.z - by.pos.z));
    room.root.add(car.root);
    room.solids.push(...car.body.solids);
    for (const sol of car.body.solids) this.bodyOf.set(sol, car.body);
    const own = new Set(car.body.solids);
    this.movers.push({ root: car.root, bodies: [car.body], update: (dt) => car.update(dt, this.world, own) });
    this.cars.push(car);
    this.statics = room.solids.filter((sol) => !sol.moving);
    this.nav = new NavGraph(room.solids.filter((sol) => !sol.moving));
    this.popup(by, 'A car!', '#ffcf8a');
  }

  // ---- Water and puddles ----------------------------------------------------------------

  private pools: Pool[] = [];
  private drips: Drip[] = [];
  private puddles: Puddle[] = [];

  private buildHazards(room: RoomData): void {
    for (const r of this.rollers) r.root.removeFromParent();
    this.rollers = (room.rollers ?? []).map((d) => new Roller(d));
    for (const r of this.rollers) room.root.add(r.root);
    for (const p of this.puddles) p.mesh.removeFromParent();
    this.puddles = [];
    this.pools = (room.pools ?? []).map((def) => {
      const water = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), WATER_MAT);
      water.position.set((def.minX + def.maxX) / 2, def.floor, (def.minZ + def.maxZ) / 2);
      water.scale.set(def.maxX - def.minX - 0.04, 0.001, def.maxZ - def.minZ - 0.04);
      water.renderOrder = 2;
      room.root.add(water);
      const stream = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 1, 8), WATER_MAT);
      room.root.add(stream);
      return { def, level: def.low, clock: 0, water, stream };
    });
    this.drips = (room.drips ?? []).map((def) => {
      const floor = this.world.heightAt(def.x, def.y - 0.05, def.z);
      const puddle = this.addPuddle(def.x, floor, def.z, def.r, Infinity, 0.02);
      const drops = Array.from({ length: 5 }, () => {
        const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.07, 0), WATER_MAT);
        mesh.visible = false;
        room.root.add(mesh);
        return { mesh, vy: 0, live: false };
      });
      return { def, floor, puddle, clock: 0, drops };
    });
  }

  /** Back to how they start: an empty bath, a dry floor, rollers in their places. */
  private resetHazards(): void {
    for (const p of this.pools) {
      p.clock = 0;
      p.level = p.def.low;
    }
    for (const d of this.drips) {
      d.puddle.r = 0.3;
      d.clock = 0;
    }
    for (const p of [...this.puddles]) if (p.ttl !== Infinity) this.removePuddle(p);
    for (const r of this.rollers) {
      r.pos.set(r.def.x, r.def.y + r.r, r.def.z);
      r.vel.set(0, 0, 0);
    }
  }

  private addPuddle(x: number, y: number, z: number, max: number, ttl: number, grow = 6): Puddle {
    const mesh = new THREE.Mesh(PUDDLE_GEO, PUDDLE_MAT);
    mesh.position.set(x, y + 0.03, z);
    mesh.renderOrder = 1;
    this.room!.root.add(mesh);
    const p: Puddle = { x, y, z, r: 0.3, max, grow, ttl, mesh };
    mesh.scale.setScalar(p.r);
    this.puddles.push(p);
    return p;
  }

  private removePuddle(p: Puddle): void {
    p.mesh.removeFromParent();
    this.puddles.splice(this.puddles.indexOf(p), 1);
  }

  private updateHazards(dt: number): void {
    // The bath fills slowly, stays full a while, drains, and starts again.
    for (const p of this.pools) {
      const { fill, low, high, floor, tap } = p.def;
      const hold = 12;
      const drain = 18;
      const empty = 10;
      p.clock = (p.clock + dt) % (fill + hold + drain + empty);
      const t = p.clock;
      p.level = t < fill ? low + ((high - low) * t) / fill : t < fill + hold ? high : t < fill + hold + drain ? high - ((high - low) * (t - fill - hold)) / drain : low;
      const depth = Math.max(0.001, p.level - floor);
      p.water.scale.y = depth;
      p.water.position.y = floor + depth / 2;
      p.water.visible = p.level > floor + 0.02;
      // The tap runs while it fills.
      const pouring = t < fill;
      p.stream.visible = pouring;
      if (pouring) {
        const len = Math.max(0.1, tap.y - p.level);
        p.stream.position.set(tap.x, p.level + len / 2, tap.z);
        p.stream.scale.set(1, len, 1);
      }
    }
    // The tap drips and the puddle under it spreads.
    for (const d of this.drips) {
      d.clock += dt;
      d.puddle.r = Math.min(d.def.r, d.puddle.r + dt * 0.05);
      d.puddle.mesh.scale.setScalar(d.puddle.r);
      if (d.clock > 0.8) {
        d.clock = 0;
        const drop = d.drops.find((o) => !o.live);
        if (drop) {
          drop.live = true;
          drop.vy = 0;
          drop.mesh.visible = true;
          drop.mesh.position.set(d.def.x, d.def.y, d.def.z);
        }
      }
      for (const drop of d.drops) {
        if (!drop.live) continue;
        drop.vy -= 24 * dt;
        drop.mesh.position.y += drop.vy * dt;
        if (drop.mesh.position.y <= d.floor + 0.05) {
          drop.live = false;
          drop.mesh.visible = false;
        }
      }
    }
    // Splashes dry up.
    for (const p of [...this.puddles]) {
      if (p.r < p.max) {
        p.r = Math.min(p.max, p.r + p.grow * dt);
        p.mesh.scale.setScalar(p.r);
      }
      if (p.ttl === Infinity) continue;
      p.ttl -= dt;
      if (p.ttl < 2) p.mesh.scale.setScalar(p.r * Math.max(0.05, p.ttl / 2));
      if (p.ttl <= 0) this.removePuddle(p);
    }
  }

  slipAt(p: THREE.Vector3): number {
    let slip = 0;
    for (const q of this.puddles) {
      if (Math.abs(p.y - q.y) > 0.3) continue;
      const d = Math.hypot(p.x - q.x, p.z - q.z);
      if (d < q.r) slip = Math.max(slip, Math.min(1, (q.r - d) / 0.4));
    }
    return slip;
  }

  waterAt(p: THREE.Vector3): number | null {
    for (const pool of this.pools) {
      const d = pool.def;
      if (p.x < d.minX || p.x > d.maxX || p.z < d.minZ || p.z > d.maxZ) continue;
      if (pool.level <= d.floor + 0.05 || p.y > pool.level + 0.5) continue;
      return pool.level;
    }
    return null;
  }

  effect(kind: 'splash' | 'sparkle' | 'guard', p: THREE.Vector3): void {
    if (kind === 'splash') {
      this.fx.chipsBurst(p, new THREE.Vector3(0, 1, 0), [0x9fd8f5, 0xffffff, 0x6cc2e6], 12, p.y);
      sfx.sfxLand(false);
    } else if (kind === 'sparkle') this.fx.sparks(_moverPos.copy(p).setY(p.y + 0.4 + Math.random() * 0.5), 1, 0.35, 0xffffff);
    else {
      this.fx.sparks(p, 14, 1.0, 0xfff0b0);
      sfx.sfxClang();
    }
  }

  // ---- Pick-ups ------------------------------------------------------------------------

  private powerClock = 0;

  /** Every so often a new pick-up turns up, so there are always a few about. */
  private updatePowerups(dt: number): void {
    if (this.phase !== 'fight' || this.options?.attract) return;
    this.powerClock += dt;
    if (this.powerClock < 18) return;
    this.powerClock = 0;
    const lying = this.pickups.filter((p) => isThrow(p.def) && p.def.power).length;
    if (lying < (this.room?.areas ? 5 : 2)) this.scatterThrowables(1, POWERUPS.map((d) => d.id));
  }

  /** Fighters push each other apart instead of overlapping. */
  private separate(): void {
    const fs = this.fighters;
    for (let i = 0; i < fs.length; i++) {
      for (let j = i + 1; j < fs.length; j++) {
        const a = fs[i]!;
        const b = fs[j]!;
        if (!a.alive || !b.alive || a.state === 'hang' || b.state === 'hang' || a.state === 'climb' || b.state === 'climb') continue;
        if (Math.abs(a.pos.y - b.pos.y) > 0.8) continue;
        const dx = b.pos.x - a.pos.x;
        const dz = b.pos.z - a.pos.z;
        const d = Math.hypot(dx, dz);
        const min = 0.42;
        if (d < min && d > 1e-4) {
          const push = (min - d) / 2;
          const nx = dx / d;
          const nz = dz / d;
          const pa = a.pos.clone();
          const pb = b.pos.clone();
          a.pos.x -= nx * push;
          a.pos.z -= nz * push;
          b.pos.x += nx * push;
          b.pos.z += nz * push;
          // Don't shove anyone into furniture.
          if (this.world.blocked(a.pos.x, a.pos.y, a.pos.z, a.body.height)) a.pos.copy(pa);
          if (this.world.blocked(b.pos.x, b.pos.y, b.pos.z, b.body.height)) b.pos.copy(pb);
        }
      }
    }
  }

  // ---- Combat ----------------------------------------------------------------------------

  private resolveStrikes(): void {
    for (const a of this.fighters) {
      if (!a.striking || a.freeze > 0) continue;
      const att = a.attack!;
      for (const e of this.fighters) {
        if (e === a || e.team === a.team || a.struck.has(e) || !e.vulnerable) continue;
        const contact = att.radius ? this.radialHit(a, e, att.radius) : this.bladeHit(a, e, att.limb ? 0.13 : 0.05);
        if (contact) this.resolveHit(a, e, contact);
      }
    }
  }

  private radialHit(a: Fighter, e: Fighter, radius: number): THREE.Vector3 | null {
    const d = Math.hypot(e.pos.x - a.pos.x, e.pos.z - a.pos.z);
    if (d > radius || Math.abs(e.pos.y - a.pos.y) > 0.5) return null;
    return e.pos.clone().setY(e.pos.y + 0.3);
  }

  /** Sweep the blade from last frame to this frame against the victim's body capsule. */
  private bladeHit(a: Fighter, e: Fighter, pad: number): THREE.Vector3 | null {
    const r = 0.27 + pad;
    const y0 = e.pos.y + 0.05;
    const y1 = e.pos.y + e.hurtHeight;
    const p = new THREE.Vector3();
    const q = new THREE.Vector3();
    for (let m = 0; m <= 3; m++) {
      const mt = m / 3;
      for (let s = 0; s <= 6; s++) {
        const st = s / 6;
        p.lerpVectors(a.prevBase, a.prevTip, st);
        q.lerpVectors(a.bladeBase, a.bladeTip, st);
        p.lerp(q, mt);
        if (p.y < y0 - pad || p.y > y1 + pad) continue;
        if (Math.hypot(p.x - e.pos.x, p.z - e.pos.z) < r) return p.clone();
      }
    }
    return null;
  }

  private resolveHit(a: Fighter, e: Fighter, at: THREE.Vector3): void {
    const att = a.attack!;
    a.struck.add(e);
    const toA = new THREE.Vector3(a.pos.x - e.pos.x, 0, a.pos.z - e.pos.z).normalize();
    const away = toA.clone().negate();
    const frontal = e.forward().dot(toA) > 0.1;
    const hasBlade = !!a.weapon && !att.limb;
    const dmgMul = att.limb ? 1 : (a.weapon?.def.damage ?? 1);
    const knockMul = att.limb ? 1 : (a.weapon?.def.knockback ?? 1);

    // A button shield covers the sides too, and soaks up blows that would break a guard.
    const covered = e.hasShield ? e.forward().dot(toA) > -0.3 : frontal;
    if (e.blocking && covered) {
      if (e.hasShield && !e.parryWindow()) {
        e.blocked(a.pos, att.knock * 0.4 + 0.6);
        a.freeze = e.freeze = 0.07;
        this.fx.sparks(at, 12, 0.9, 0xffd0c0);
        sfx.sfxClang();
        if (att.guardBreak || att.kind === 'heavy') e.shieldHit();
        this.wear(a);
        return;
      }
      if (e.parryWindow()) {
        // Perfect timing: the attacker is thrown wide open and may lose their weapon.
        a.stagger();
        a.freeze = e.freeze = 0.13;
        this.fx.sparks(at, 26, 1.4, 0xfff0b0);
        this.fx.impact(at, 0.9, 0xfff6d0);
        this.shake(0.25);
        sfx.sfxParry();
        this.popup(e, 'Parry!', '#fff3b0');
        if (a.weapon && (att.kind === 'heavy' || Math.random() < 0.3)) this.disarm(a, toA.clone().negate());
        this.wear(e);
        return;
      }
      if (att.guardBreak) {
        e.health = Math.max(1, e.health - att.damage * dmgMul * 0.25);
        e.stagger();
        e.blocked(a.pos, 3);
        a.freeze = e.freeze = 0.12;
        e.shake = 0.12;
        this.fx.sparks(at, 18, 1.2);
        this.fx.impact(at, 0.7);
        this.shake(0.3);
        sfx.sfxClang();
        this.popup(e, 'Guard break!', '#ffb36b');
        if (e.weapon && Math.random() < 0.35) this.disarm(e, away);
        this.wear(a);
        this.wear(e);
        return;
      }
      e.blocked(a.pos, att.knock * 0.5 + 0.8);
      a.freeze = e.freeze = 0.06;
      this.fx.sparks(at, 10, 0.9);
      sfx.sfxClang();
      this.wear(a);
      this.wear(e);
      return;
    }

    const counter = e.state === 'attack';
    const damage = att.damage * dmgMul * (counter ? 1.2 : 1);
    const lethal = e.health - damage <= 0;
    const wasAlive = e.alive;
    if (!e.hurt(damage, a.pos, att.knock * knockMul, !!att.knockdown || lethal)) {
      a.freeze = e.freeze = 0.07;
      this.fx.sparks(at, 10, 0.9);
      sfx.sfxClang();
      this.wear(a);
      return;
    }
    const stop = att.hitstop * (lethal ? 1.6 : 1);
    a.freeze = stop;
    e.freeze = stop;
    e.shake = stop;
    this.fx.impact(at, att.kind === 'heavy' || lethal ? 1.1 : 0.65, hasBlade ? 0xffffff : 0xfff1c8);
    this.fx.chipsBurst(at, away, [e.livery.armor, e.livery.cloth, e.livery.trim], att.kind === 'heavy' ? 12 : 6, e.pos.y);
    if (hasBlade) this.fx.sparks(at, 6, 0.8);
    this.shake(att.kind === 'heavy' || lethal ? 0.4 : 0.14);
    sfx.sfxHit(att.kind === 'heavy' || !!att.knockdown);
    if (counter) this.popup(e, 'Counter!', '#ff8f6b');

    if (wasAlive && !e.alive) this.onKO(e, away);
    else if (e.weapon && att.disarm && Math.random() < att.disarm + (counter ? 0.2 : 0)) this.disarm(e, away);
    this.wear(a);
  }

  /** Improvised items wear out and snap. */
  private wear(f: Fighter): void {
    const w = f.weapon;
    if (!w || !Number.isFinite(w.durability)) return;
    w.durability -= 1;
    if (w.durability > 0) return;
    const at = f.bladeTip.clone().lerp(f.bladeBase, 0.5);
    this.fx.chipsBurst(at, f.forward(), [0xd8d0c0, 0x8d8a85, 0xe0b25c], 12, f.pos.y);
    this.fx.sparks(at, 8, 0.6, 0xfff2cc);
    sfx.sfxBreak();
    this.popup(f, `${w.def.name} broke!`, '#ffd9a0');
    f.equip(null);
  }

  private onKO(e: Fighter, away: THREE.Vector3): void {
    sfx.sfxKO();
    if (e.weapon) this.dropWeapon(e, away, 0.8);
    if (this.waveMode && e.team === 1) this.kills++;
    const teamLeft = this.fighters.some((f) => f.team === e.team && f.alive) || (this.waveMode && e.team === 1 && this.waveLeft > 0);
    // The deciding blow: in waves the last enemy of a wave, otherwise when only one side is left standing.
    const decided = this.waveMode ? !teamLeft : new Set(this.fighters.filter((f) => f.alive).map((f) => f.team)).size <= 1;
    if (decided) {
      // Final blow: dramatic slow motion.
      this.slowmo = 0.25;
      this.slowTime = 1.1;
      this.shake(0.5);
    }
  }

  popup(f: Fighter, text: string, color: string): void {
    const at = new THREE.Vector3();
    f.headPos(at);
    this.popups.push({ text, at, color, age: 0 });
  }

  // ---- Rounds ------------------------------------------------------------------------------

  private updatePhase(dt: number): void {
    const opts = this.options!;
    if (this.phase === 'intro') {
      if (this.phaseTime > 1.3 && this.banner?.text !== 'Fight!') {
        this.banner = { text: 'Fight!', age: 0 };
        sfx.sfxTaiko();
      }
      if (this.phaseTime > 1.7) {
        this.phase = 'fight';
        this.phaseTime = 0;
      }
      return;
    }
    if (this.waveMode) {
      if (this.phase === 'fight') this.updateWaves(dt);
      return;
    }
    if (this.phase === 'fight') {
      if (this.exploring) return;
      // No time limit: a round lasts until only one side (a team, or one samurai in free for all) is left.
      const standing = [...new Set(this.fighters.filter((f) => f.alive).map((f) => f.team))];
      if (standing.length <= 1) {
        const winner = standing[0] ?? -1;
        this.phase = 'roundEnd';
        this.phaseTime = 0;
        if (winner >= 0) {
          this.wins[winner]!++;
          for (const f of this.fighters) if (f.team === winner) f.win();
        }
        if (!opts.attract) {
          this.banner = {
            text: 'Ippon!',
            sub: winner >= 0 ? this.winnerName(winner) : 'Draw',
            age: 0,
          };
        }
      }
      return;
    }
    if (this.phase === 'roundEnd' && this.phaseTime > (opts.attract ? 4 : 3.4)) {
      if (opts.attract) {
        this.startRound();
        return;
      }
      const champion = this.wins.findIndex((w) => w >= ROUNDS_TO_WIN);
      if (champion >= 0) {
        this.phase = 'matchEnd';
        this.phaseTime = 0;
        this.banner = null;
        this.onMatchEnd(champion);
      } else {
        this.round++;
        this.startRound();
      }
    }
  }

  // ---- Waves ---------------------------------------------------------------------------------

  wave = 0;
  kills = 0;
  /** Enemies of the current wave that have not entered yet. */
  private waveLeft = 0;
  private waveBreak = 0;
  private spawnTimer = 0;
  private nextIndex = 100;

  private updateWaves(dt: number): void {
    // Fallen enemies are cleared away so the room does not fill with bodies.
    for (const f of [...this.fighters]) {
      if (f.team === 1 && f.state === 'ko' && f.stateTime > 3.5) this.removeFighter(f);
    }
    if (!this.fighters.some((f) => f.human && f.alive)) {
      this.phase = 'matchEnd';
      this.banner = null;
      this.onMatchEnd(1);
      return;
    }
    const enemies = this.fighters.filter((f) => f.team === 1 && f.alive).length;
    if (this.waveBreak > 0) {
      this.waveBreak -= dt;
      if (this.waveBreak <= 0) {
        this.wave++;
        this.waveLeft = 2 + this.wave;
        this.spawnTimer = 0.4;
        this.banner = { text: `Wave ${this.wave}`, sub: `${this.waveLeft} enemies incoming`, age: 0 };
        sfx.sfxGong();
      }
      return;
    }
    // Enemies drop in a few at a time; more can be in the room at once in later waves.
    const maxAlive = Math.min(5, 2 + Math.floor(this.wave / 2));
    if (this.waveLeft > 0 && enemies < maxAlive) {
      this.spawnTimer -= dt;
      if (this.spawnTimer <= 0) {
        this.spawnEnemy();
        this.waveLeft--;
        this.spawnTimer = 0.9;
      }
    }
    if (this.waveLeft === 0 && enemies === 0) {
      // Wave cleared: heal up, get fallen friends back on their feet, short breather.
      for (const f of this.fighters) {
        if (!f.human) continue;
        if (!f.alive) {
          f.reset(f.lastSafe.x, f.lastSafe.y, f.lastSafe.z, f.facing);
          f.health = 40;
          if (!f.weapon) f.equip(KATANA);
        } else f.health = Math.min(100, f.health + 30);
      }
      this.banner = { text: 'Wave cleared!', sub: 'Health restored. Catch your breath.', age: 0 };
      sfx.sfxTaiko();
      this.waveBreak = 3.5;
    }
  }

  private spawnEnemy(): void {
    const room = this.room!;
    const players = this.fighters.filter((f) => f.human && f.alive);
    // Somewhere open on the floor, away from the players, with nothing overhead to land on.
    let x = room.spawns[1]!.x;
    let z = room.spawns[1]!.z;
    for (let i = 0; i < 60; i++) {
      const { x: tx, z: tz } = this.randomSpot(2);
      if (this.world.blocked(tx, 0.01, tz, 9)) continue;
      if (players.some((p) => Math.hypot(p.pos.x - tx, p.pos.z - tz) < 7)) continue;
      x = tx;
      z = tz;
      break;
    }
    const n = this.nextIndex++;
    const f = new Fighter(n, 1, 'Ronin', RONIN[n % RONIN.length]!, false, this.scene, this);
    const target = players[0];
    f.reset(x, 6, z, target ? Math.atan2(target.pos.x - x, target.pos.z - z) : 0);
    f.body.onGround = false;
    // Early waves fight with whatever they found lying around; later ones bring katanas.
    if (Math.random() < Math.min(0.85, 0.15 + this.wave * 0.12)) f.equip(KATANA);
    else {
      const kinds = room.itemKinds;
      f.equip(itemById(kinds[Math.floor(Math.random() * kinds.length)]!));
    }
    this.fighters.push(f);
    this.brains.set(f, new Brain(f, this, this.wave <= 2 ? 'easy' : this.wave <= 5 ? 'normal' : 'hard'));
  }

  private removeFighter(f: Fighter): void {
    f.rig.dispose();
    this.fx.removeTrail(f.index);
    this.brains.delete(f);
    this.fighters.splice(this.fighters.indexOf(f), 1);
  }

  winnerName(team: number): string {
    const names = this.fighters.filter((f) => f.team === team).map((f) => f.name);
    const humans = names.filter((n) => !n.startsWith('CPU'));
    if (this.fighters.length === 2 || names.length === 1) return `${names[0]} wins`;
    return humans.length ? `${TEAM_NAMES[team]} team (${humans.join(' & ')}) wins` : `${TEAM_NAMES[team]} team wins`;
  }

  hudState(): HudState {
    const enemiesLeft = this.waveLeft + this.fighters.filter((f) => f.team === 1 && f.alive).length;
    return {
      fighters: this.fighters,
      wins: this.wins,
      round: this.round,
      banner: this.banner,
      popups: this.popups,
      wave: this.wave,
      enemiesLeft,
      kills: this.kills,
    };
  }

  /**
   * Project a world point to canvas pixels (null when off-screen). In split screen it
   * lands in the given player's view, or else the view of whichever player is closer.
   */
  project(p: THREE.Vector3, player?: number): { x: number; y: number } | null {
    const views = this.views();
    let view = views[0]!;
    if (views.length > 1) {
      if (player !== undefined) view = views.find((v) => v.human === player) ?? view;
      else {
        let best = Infinity;
        for (const v of views) {
          const me = v.fighter;
          const d = me ? me.pos.distanceTo(p) : Infinity;
          if (d < best) {
            best = d;
            view = v;
          }
        }
      }
    }
    const n = p.clone().project(view.cam.camera);
    if (n.z > 1 || Math.abs(n.x) > 1.05 || Math.abs(n.y) > 1.05) return null;
    return { x: view.x + ((n.x + 1) / 2) * view.w, y: view.y + ((1 - n.y) / 2) * view.h };
  }
}