Back to Pickle Rustlers
SOURCE / PINNED RELEASE

Made of little things.

Pickle Rustlers

Release
039e51f55e34…
Author-recorded commit
40608586a8a1…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35

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

src/game/game.ts
// Pickle Rustlers: rope pickles, peel onions, borrow a tank, beat the sundown.
import * as THREE from 'three';
import { Effects } from './fx.js';
import { Hud } from './hud.js';
import { Input, type InputFrame } from './input.js';
import { CAMPAIGN_DAYS, LEVELS, levelFor, type Level } from './levels.js';
import {
  JAR_RADIUS,
  buildGuideArrow,
  buildHorseAndRider,
  buildKing,
  buildTargetRing,
  buildJar,
  buildOnion,
  buildPickle,
  buildSauce,
  buildTank,
  palette,
  type JarModel,
  type OnionModel,
  type PickleModel,
  type RiderModel,
  type TankModel,
} from './models.js';
import type { RunSummary, ScoreShare } from '../share/types.js';
import { sfx, unlockAudio } from './sound.js';
import { ARENA_RADIUS, World, groundHeight } from './world.js';

const MAX_TEARS = 5;
const MAX_ROPES = 3;
const TANK_TIME = 20;
const SAUCE_TIME = 6;
const SHOTGUN_RANGE = 21;
const LASSO_RANGE = 19;

type PickleState = 'free' | 'roped' | 'carried' | 'flying';

interface Pickle {
  m: PickleModel;
  x: number;
  z: number;
  vx: number;
  vz: number;
  heading: number;
  golden: boolean;
  state: PickleState;
  hop: number;
  wanderT: number;
  wanderA: number;
  carrier: Onion | null;
  fly: { t: number; from: THREE.Vector3; to: THREE.Vector3 } | null;
  launchY: number;
  launchVy: number;
}

interface Onion {
  m: OnionModel;
  x: number;
  z: number;
  heading: number;
  size: number;
  carrying: Pickle | null;
  thinkT: number;
  tx: number;
  tz: number;
  burnCd: number;
  knock: number;
  kx: number;
  kz: number;
  walk: number;
  spawnT: number;
  grace: number; // fresh onions need a moment before they can grab a pickle
  king: boolean;
  hp: number; // the King counts layers; regular onions use size
  maxHp: number;
  summonT: number;
}

interface Shell {
  mesh: THREE.Mesh;
  pos: THREE.Vector3;
  vel: THREE.Vector3;
  enemy: boolean;
  marker: THREE.Mesh | null;
}

interface EnemyTank {
  m: TankModel;
  x: number;
  z: number;
  heading: number;
  turret: number;
  hp: number;
  fireT: number;
  hitFlash: number;
}

type Mode = 'title' | 'playing' | 'paused' | 'dayEnd' | 'gameOver' | 'victory';

const KING_SIZE = 4;
const KING_HP = 30;
const FIREWORK_COLORS = [0xff595e, 0xffca3a, 0x8ac926, 0x1982c4, 0xc77dff, 0xff924c];

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

export interface Progress {
  best: number;
  wins: number;
}

export interface GameOptions {
  canvas: HTMLCanvasElement;
  loadProgress: () => Promise<Progress>;
  saveProgress: (progress: Progress) => Promise<void>;
  /** Optional score-card sharing; absent when the runtime lacks a domain. */
  share?: ScoreShare | null;
}

export class Game {
  private renderer: THREE.WebGLRenderer;
  private scene = new THREE.Scene();
  private camera = new THREE.PerspectiveCamera(60, 1, 0.5, 600);
  private world: World;
  private fx: Effects;
  private hud = new Hud();
  private input: Input;
  private lastTime = performance.now();

  private mode: Mode = 'title';
  private best = 0;
  private wins = 0;
  private endless = false;
  private victoryRun: RunSummary | null = null;
  private composing = false;
  shakeEnabled = true;
  private reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

  // Player
  private rider: RiderModel;
  private px = 0;
  private pz = 18;
  private heading = 0;
  private speed = 0;
  private gait = 0;
  private hoofBeat = 0;
  private jumpY = 0; // height of the horse above the ground while leaping a rock
  private jumpVy = 0;
  private tears = 0;
  private invuln = 0;
  private shells = 2;
  private reloadT = 0;
  private gunKick = 0;
  private sauceT = 0;
  private flameT = 0;
  private tankT = 0;
  private cannonT = 0;
  private roped: Pickle[] = [];
  private lasso: { mesh: THREE.Mesh; t: number; dur: number; from: THREE.Vector3; to: THREE.Vector3; target: Pickle | null } | null = null;
  private lassoCd = 0;
  private ropeSegs: THREE.Mesh[][] = [];

  // World objects
  private jar: JarModel;
  private jarBeam: THREE.Mesh;
  private pickles: Pickle[] = [];
  private onions: Onion[] = [];
  private shellsInAir: Shell[] = [];
  private sauces: { mesh: THREE.Group; x: number; z: number }[] = [];
  private sauceRespawn = 0;
  private tank: TankModel;
  private tankBeam: THREE.Mesh;
  private tankParked = false;
  private tankX = 0;
  private tankZ = 0;
  private tankHeading = 0;
  private enemy: EnemyTank | null = null;
  private enemyModel: TankModel;
  private enemySpawned = false;
  private king: Onion | null = null;
  private kingSpawned = false;
  private kingDefeated = true;
  private guideArrow: THREE.Group;
  private targetRing: THREE.Mesh;
  private fireworkT = 0;
  private quotaToastShown = false;

  // Day
  private day = 1;
  private level: Level = LEVELS[0];
  private dayLength = LEVELS[0].dayLength;
  private timeLeft = LEVELS[0].dayLength;
  private quota = LEVELS[0].quota;
  private brinedToday = 0;
  private brinedTotal = 0;
  private brinedRun = 0;
  private onionsToday = 0;
  private onionsTotal = 0;
  private tearsTotal = 0;
  private score = 0;
  private spawnT = 0;
  private winT = 0;
  private shake = 0;
  private attractT = 0;
  private camPos = new THREE.Vector3(0, 30, 60);
  private camLook = new THREE.Vector3();

  private tmp = new THREE.Vector3();
  private tmp2 = new THREE.Vector3();
  private ropeStart = new THREE.Vector3();
  private ropePrev = new THREE.Vector3();
  private ropeCur = new THREE.Vector3();
  private portrait = false;
  private up = new THREE.Vector3(0, 1, 0);

  constructor(private opts: GameOptions) {
    const { canvas } = opts;
    this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFShadowMap;
    this.world = new World(this.scene, true);
    this.fx = new Effects(this.scene);

    this.jar = buildJar();
    this.scene.add(this.jar.root);
    this.jarBeam = this.beam(0x9be564);
    this.jarBeam.position.set(0, 0, 0);
    this.scene.add(this.jarBeam);

    this.rider = buildHorseAndRider();
    this.scene.add(this.rider.root);

    this.tank = buildTank(false);
    this.tank.root.visible = false;
    this.scene.add(this.tank.root);
    this.tankBeam = this.beam(0xffd27a);
    this.tankBeam.visible = false;
    this.scene.add(this.tankBeam);

    this.enemyModel = buildTank(true);
    this.enemyModel.root.visible = false;
    this.scene.add(this.enemyModel.root);

    this.guideArrow = buildGuideArrow();
    this.guideArrow.visible = false;
    this.scene.add(this.guideArrow);
    this.targetRing = buildTargetRing();
    this.targetRing.visible = false;
    this.scene.add(this.targetRing);

    const ropeGeo = new THREE.CylinderGeometry(0.06, 0.06, 1, 4);
    ropeGeo.translate(0, 0.5, 0);
    const ropeMat = new THREE.MeshLambertMaterial({ color: palette.rope, flatShading: true });
    for (let r = 0; r < MAX_ROPES + 1; r++) {
      const segs: THREE.Mesh[] = [];
      for (let i = 0; i < 10; i++) {
        const seg = new THREE.Mesh(ropeGeo, ropeMat);
        seg.visible = false;
        this.scene.add(seg);
        segs.push(seg);
      }
      this.ropeSegs.push(segs);
    }

    this.input = new Input(
      document.getElementById('stick')!,
      document.getElementById('knob')!,
      document.getElementById('shootBtn')!,
      document.getElementById('lassoBtn')!,
      (kind) => {
        unlockAudio();
        if (kind === 'touch') this.hud.setTouch(this.mode === 'playing');
      },
      (action) => {
        if (action === 'pause') return this.mode === 'playing' || this.mode === 'paused';
        if (action === 'shoot') return this.mode === 'playing' || this.hud.posterOpen;
        return this.mode === 'playing';
      },
    );
    window.addEventListener('pointerdown', () => unlockAudio());
    document.getElementById('pauseBtn')!.addEventListener('click', () => this.pause());
    window.addEventListener('blur', () => this.pause());
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) this.pause();
    });

    const resize = () => this.resize();
    new ResizeObserver(resize).observe(canvas.parentElement ?? canvas);
    resize();

    this.populateAttract();
    this.hud.showHud(false);
    this.showTitle();
    opts
      .loadProgress()
      .then((progress) => {
        this.best = progress.best;
        this.wins = progress.wins;
        if (this.mode === 'title') this.showTitle();
      })
      .catch(() => undefined);

    // Sign-in changes add or remove the share button on an open victory poster.
    opts.share?.onChange(() => {
      if (this.mode === 'victory' && !this.composing) this.showVictoryPoster();
    });

    this.renderer.setAnimationLoop(() => this.frame());
  }

  dispose(): void {
    this.renderer.setAnimationLoop(null);
    this.input.dispose();
    this.renderer.dispose();
  }

  private showTitle(): void {
    this.hud.showPoster('title', { best: this.best, wins: this.wins, days: CAMPAIGN_DAYS }, () => this.startRun());
  }

  private beam(color: number): THREE.Mesh {
    // fog: false keeps the beams visible through the dust storm.
    const m = new THREE.Mesh(
      new THREE.CylinderGeometry(0.7, 1.4, 60, 6, 1, true),
      new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.25, depthWrite: false, side: THREE.DoubleSide, fog: false }),
    );
    m.geometry.translate(0, 30, 0);
    return m;
  }

  private resize(): void {
    const parent = this.opts.canvas.parentElement ?? this.opts.canvas;
    const w = Math.max(1, parent.clientWidth);
    const h = Math.max(1, parent.clientHeight);
    // Keep fill-rate sane on big or high-density screens.
    const dpr = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(2_400_000 / (w * h)));
    this.renderer.setPixelRatio(Math.max(0.75, dpr));
    this.renderer.setSize(w, h, false);
    this.portrait = h > w;
    this.camera.aspect = w / h;
    this.camera.fov = w / h < 1 ? 72 : 60;
    this.camera.updateProjectionMatrix();
  }

  // ---------- run / day lifecycle ----------

  private retireMesh(mesh: THREE.Mesh): void {
    this.scene.remove(mesh);
    mesh.geometry.dispose();
    const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
    for (const material of materials) material.dispose();
  }

  private clearDay(): void {
    for (const p of this.pickles) this.scene.remove(p.m.root);
    for (const o of this.onions) this.scene.remove(o.m.root);
    for (const s of this.shellsInAir) {
      this.retireMesh(s.mesh);
      if (s.marker) this.retireMesh(s.marker);
    }
    for (const s of this.sauces) this.scene.remove(s.mesh);
    this.pickles = [];
    this.onions = [];
    this.shellsInAir = [];
    this.sauces = [];
    this.roped = [];
    if (this.lasso) this.retireMesh(this.lasso.mesh);
    this.lasso = null;
    this.enemy = null;
    this.enemyModel.root.visible = false;
    this.enemySpawned = false;
    this.king = null;
    this.kingSpawned = false;
    this.targetRing.visible = false;
    this.guideArrow.visible = false;
  }

  private populateAttract(): void {
    this.clearDay();
    for (let i = 0; i < 9; i++) this.spawnPickle(i === 0);
    for (let i = 0; i < 4; i++) this.spawnOnion(2);
    this.placeTank();
  }

  private startRun(): void {
    unlockAudio();
    this.score = 0;
    this.day = 1;
    this.tears = 0;
    this.endless = false;
    this.onionsTotal = 0;
    this.tearsTotal = 0;
    this.brinedTotal = 0;
    this.brinedRun = 0;
    for (const f of [...this.jar.floaters.children]) this.jar.floaters.remove(f);
    this.startDay();
  }

  private startDay(): void {
    this.clearDay();
    this.mode = 'playing';
    this.hud.hidePoster();
    this.hud.showHud(true);
    this.hud.setTouch(this.input.touchSeen || matchMedia('(pointer: coarse)').matches);
    this.level = levelFor(this.day);
    this.dayLength = this.level.dayLength;
    this.timeLeft = this.dayLength;
    this.quota = this.level.quota;
    this.kingDefeated = !this.level.boss;
    this.quotaToastShown = false;
    this.world.dustTarget = this.level.dust ? 1 : 0;
    this.brinedToday = 0;
    this.onionsToday = 0;
    this.winT = 0;
    this.px = 0;
    this.pz = JAR_RADIUS + 20;
    this.heading = 0;
    this.speed = 0;
    this.jumpY = this.jumpVy = 0;
    this.shells = 2;
    this.reloadT = 0;
    this.sauceT = 0;
    this.tankT = 0;
    this.invuln = 2;
    this.spawnT = 4;
    this.rider.root.visible = true;
    for (let i = 0; i < 10; i++) this.spawnPickle(Math.random() < this.level.goldenChance);
    for (let i = 0; i < this.level.startOnions; i++) this.spawnOnion(this.randomOnionSize());
    for (let i = 0; i < 2; i++) this.spawnSauce();
    this.sauceRespawn = 0;
    this.placeTank();
    this.input.clear();
    const label = this.day <= CAMPAIGN_DAYS && !this.endless ? `DAY ${this.day} OF ${CAMPAIGN_DAYS}` : `DAY ${this.day} \u00b7 ENDLESS`;
    this.hud.banner(label, this.level.name, this.level.boss ? this.level.blurb : `${this.level.blurb} Brine ${this.quota} pickles.`);
  }

  private dayLabel(): string {
    return this.day <= CAMPAIGN_DAYS && !this.endless ? `${this.day}/${CAMPAIGN_DAYS}` : String(this.day);
  }

  private endDay(): void {
    const bonus = Math.ceil(this.timeLeft) * 10;
    this.score += bonus;
    this.onionsTotal += this.onionsToday;
    this.exitTank(false);
    if (this.day === CAMPAIGN_DAYS && !this.endless) {
      this.victory();
      return;
    }
    this.mode = 'dayEnd';
    const healed = Math.min(2, this.tears);
    this.tears -= healed;
    sfx.dayWon();
    this.hud.setTouch(false);
    const nextDay = this.day + 1;
    const next = levelFor(nextDay);
    this.hud.showPoster(
      'dayEnd',
      {
        day: this.day,
        brined: this.brinedToday,
        onions: this.onionsToday,
        bonus,
        healed,
        score: this.score,
        nextLabel: nextDay <= CAMPAIGN_DAYS && !this.endless ? `NEXT: DAY ${nextDay} OF ${CAMPAIGN_DAYS}` : `NEXT: DAY ${nextDay}`,
        nextName: next.name,
        nextBlurb: next.blurb,
      },
      () => {
        this.day++;
        this.startDay();
      },
    );
  }

  /** Beat the final day: fireworks over the jar, dancing pickles, a reward poster. */
  private victory(): void {
    this.mode = 'victory';
    this.wins++;
    const newBest = this.score > this.best;
    if (newBest) this.best = this.score;
    this.opts.saveProgress({ best: this.best, wins: this.wins }).catch(() => undefined);
    sfx.victory();
    this.hud.setTouch(false);
    this.hud.showHud(false);
    this.world.dustTarget = 0;
    // Clear the outlaws and line up a pickle conga around the jar.
    this.clearDay();
    for (let i = 0; i < 14; i++) this.spawnPickle(i % 4 === 0);
    this.attractT = 0;
    this.fireworkT = 0.3;
    this.victoryRun = { score: this.score, brined: this.brinedRun, onions: this.onionsTotal, tears: this.tearsTotal, wins: this.wins, newBest };
    this.showVictoryPoster();
  }

  private showVictoryPoster(): void {
    const run = this.victoryRun;
    if (!run) return;
    this.composing = false;
    const alts = [
      {
        label: 'Keep riding',
        run: () => {
          this.endless = true;
          this.tears = 0;
          this.day++;
          this.startDay();
        },
      },
    ];
    if (this.opts.share?.available) alts.push({ label: 'Post score card', run: () => this.openComposer(run) });
    this.hud.showPoster('victory', { ...run, best: this.best }, () => this.startRun(), alts);
  }

  private openComposer(run: RunSummary): void {
    const share = this.opts.share;
    if (!share) return;
    this.composing = true;
    const card = share.makeCard(run, this.captureScene(1200, 675));
    this.hud.showComposer({
      card,
      text:
        `I tamed the West in Pickle Rustlers! 🥒🤠\n` +
        `$${run.score.toLocaleString('en-US')} bounty · ${run.brined} pickles brined · ${run.onions} onions peeled · ${run.tears} tears cried\n` +
        `#PickleRustlers`,
      post: (png, text, onStatus) => share.post(png, text, onStatus),
      back: () => this.showVictoryPoster(),
    });
  }

  /** Render one frame at a fixed size and copy it before the drawing buffer clears. */
  private captureScene(w: number, h: number): HTMLCanvasElement {
    const out = document.createElement('canvas');
    out.width = w;
    out.height = h;
    this.renderer.setPixelRatio(1);
    this.renderer.setSize(w, h, false);
    this.camera.aspect = w / h;
    this.camera.fov = 60;
    this.camera.updateProjectionMatrix();
    this.renderer.render(this.scene, this.camera);
    out.getContext('2d')?.drawImage(this.renderer.domElement, 0, 0, w, h);
    this.resize();
    return out;
  }

  private celebrate(dt: number): void {
    this.attractT += dt;
    const t = this.attractT;
    const a = t * 0.3;
    this.px = Math.cos(a) * 17;
    this.pz = Math.sin(a) * 17;
    this.heading = Math.atan2(-Math.sin(a), Math.cos(a));
    this.speed = 7;
    this.placeRider(dt);
    this.pickles.forEach((p, i) => {
      const ang = (i / this.pickles.length) * Math.PI * 2 - t * 0.5;
      const r = 11 + Math.sin(t * 2 + i) * 0.6;
      p.x = Math.cos(ang) * r;
      p.z = Math.sin(ang) * r;
      const y = groundHeight(p.x, p.z) + Math.abs(Math.sin(t * 6 + i * 0.7)) * 1.4;
      p.m.root.position.set(p.x, y, p.z);
      p.m.root.rotation.set(0, Math.atan2(p.x, p.z) + Math.sin(t * 3 + i) * 0.6, 0);
      p.m.body.rotation.set(0, 0, Math.sin(t * 6 + i) * 0.35);
    });
    this.fireworkT -= dt;
    if (this.fireworkT <= 0) {
      this.fireworkT = 0.35 + Math.random() * 0.45;
      const pos = this.tmp.set((Math.random() - 0.5) * 36, 18 + Math.random() * 12, (Math.random() - 0.5) * 36);
      this.fx.firework(pos, FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)]);
      sfx.firework();
    }
  }

  private gameOver(reason: 'tears' | 'sundown'): void {
    this.mode = 'gameOver';
    this.exitTank(false);
    this.targetRing.visible = false;
    this.guideArrow.visible = false;
    sfx.lose();
    this.hud.setTouch(false);
    const newBest = this.score > this.best;
    if (newBest) {
      this.best = this.score;
      this.opts.saveProgress({ best: this.best, wins: this.wins }).catch(() => undefined);
    }
    this.hud.showPoster('gameOver', { day: this.day, score: this.score, best: this.best, wins: this.wins, newBest, reason }, () =>
      this.startRun(),
    );
  }

  private pause(): void {
    if (this.mode !== 'playing') return;
    this.mode = 'paused';
    this.input.clear();
    this.hud.setTouch(false);
    this.hud.showPoster('paused', {}, () => this.resume());
  }

  private resume(): void {
    this.mode = 'playing';
    this.hud.hidePoster();
    this.hud.setTouch(this.input.touchSeen || matchMedia('(pointer: coarse)').matches);
    this.input.clear();
    this.lastTime = performance.now();
  }

  // ---------- spawning ----------

  private randomPoint(min: number, max: number, awayFromPlayer = 0): { x: number; z: number } {
    for (let tries = 0; tries < 40; tries++) {
      const a = Math.random() * Math.PI * 2;
      const d = min + Math.random() * (max - min);
      const x = Math.cos(a) * d;
      const z = Math.sin(a) * d;
      if (Math.hypot(x - this.px, z - this.pz) < awayFromPlayer) continue;
      if (this.world.colliders.some((c) => Math.hypot(c.x - x, c.z - z) < c.r + 2)) continue;
      return { x, z };
    }
    return { x: min, z: 0 };
  }

  private spawnPickle(golden: boolean): void {
    const m = buildPickle(golden);
    const p = this.randomPoint(20, ARENA_RADIUS - 18, 18);
    this.scene.add(m.root);
    this.pickles.push({
      m,
      x: p.x,
      z: p.z,
      vx: 0,
      vz: 0,
      heading: Math.random() * 6,
      golden,
      state: 'free',
      hop: Math.random() * 6,
      wanderT: 0,
      wanderA: Math.random() * 6,
      carrier: null,
      fly: null,
      launchY: 0,
      launchVy: 0,
    });
  }

  private randomOnionSize(): number {
    const [small, medium] = this.level.sizeMix;
    const r = Math.random();
    return r < small ? 1 : r < small + medium ? 2 : 3;
  }

  private spawnOnion(size: number, x?: number, z?: number): Onion {
    const m = buildOnion();
    let ox = x;
    let oz = z;
    if (ox === undefined || oz === undefined) {
      const a = Math.random() * Math.PI * 2;
      ox = Math.cos(a) * (ARENA_RADIUS - 2);
      oz = Math.sin(a) * (ARENA_RADIUS - 2);
    }
    this.scene.add(m.root);
    const o: Onion = {
      m,
      x: ox,
      z: oz,
      heading: Math.atan2(-ox, -oz),
      size,
      carrying: null,
      thinkT: 0,
      tx: 0,
      tz: 0,
      burnCd: 0,
      knock: 0,
      kx: 0,
      kz: 0,
      walk: Math.random() * 6,
      spawnT: 0.35,
      grace: 4,
      king: false,
      hp: 0,
      maxHp: 0,
      summonT: 0,
    };
    this.onions.push(o);
    return o;
  }

  private spawnKing(): void {
    const a = Math.atan2(this.pz, this.px) + Math.PI;
    const o = this.spawnOnion(KING_SIZE, Math.cos(a) * (ARENA_RADIUS - 6), Math.sin(a) * (ARENA_RADIUS - 6));
    this.scene.remove(o.m.root);
    o.m = buildKing();
    this.scene.add(o.m.root);
    o.king = true;
    o.hp = o.maxHp = KING_HP;
    o.summonT = 10;
    o.grace = Infinity;
    this.king = o;
    this.kingSpawned = true;
    sfx.kingRoar();
    this.addShake(0.8);
    this.hud.toast('THE ONION KING RIDES IN!', 'bad');
  }

  private summonGuards(o: Onion, count: number): void {
    for (let i = 0; i < count; i++) {
      const a = Math.random() * Math.PI * 2;
      const guard = this.spawnOnion(Math.random() < 0.5 ? 1 : 2, o.x + Math.sin(a) * 4, o.z + Math.cos(a) * 4);
      guard.grace = 2;
    }
    this.hud.toast('The King summons his guard!', 'bad');
  }

  private killKing(o: Onion): void {
    const pos = new THREE.Vector3(o.x, groundHeight(o.x, o.z) + 3, o.z);
    this.fx.flash(pos, 16, 0xffffff, 0.6);
    this.fx.burst(pos, palette.onion, 40, 16, 0.6, 1.4, 10);
    this.fx.burst(pos, palette.onionLight, 30, 14, 0.5, 1.4, 10);
    this.fx.burst(pos, palette.gold, 16, 12, 0.4, 1.8, 14);
    this.fx.burst(pos, 0x6ec6ff, 20, 10, 0.3, 1.2, 6);
    sfx.kingRoar();
    sfx.boom();
    this.addShake(1.2);
    this.score += 1000;
    this.kingDefeated = true;
    this.king = null;
    this.removeOnion(o);
    // Without their King, the guards lose their nerve and pop.
    for (const guard of [...this.onions]) this.damageOnion(guard, guard.size, 0);
    this.hud.toast('THE ONION KING IS PEELED! +1000', 'good');
  }

  private spawnSauce(): void {
    const p = this.randomPoint(18, ARENA_RADIUS - 10, 15);
    const mesh = buildSauce();
    mesh.position.set(p.x, groundHeight(p.x, p.z), p.z);
    this.scene.add(mesh);
    this.sauces.push({ mesh, x: p.x, z: p.z });
  }

  private placeTank(): void {
    const p = this.randomPoint(40, 72, 25);
    this.tankX = p.x;
    this.tankZ = p.z;
    this.tankHeading = Math.random() * Math.PI * 2;
    this.tankParked = true;
    this.tank.root.visible = true;
    this.tankBeam.visible = true;
  }

  private spawnEnemyTank(): void {
    const a = Math.atan2(this.pz, this.px) + Math.PI + (Math.random() - 0.5);
    const x = Math.cos(a) * (ARENA_RADIUS - 4);
    const z = Math.sin(a) * (ARENA_RADIUS - 4);
    this.enemy = {
      m: this.enemyModel,
      x,
      z,
      heading: Math.atan2(-x, -z),
      turret: 0,
      hp: 10 + Math.min(this.day, 8) * 2,
      fireT: 4,
      hitFlash: 0,
    };
    this.enemyModel.root.visible = true;
    this.enemySpawned = true;
    this.hud.toast('The onions brought a TANK!', 'bad');
  }

  // ---------- main loop ----------

  private frame(): void {
    const now = performance.now();
    const dt = Math.min(0.05, (now - this.lastTime) / 1000);
    this.lastTime = now;
    const input = this.input.read();

    if (this.hud.posterOpen && input.confirm) {
      this.hud.confirm();
    } else if (this.mode === 'playing') {
      if (input.pause) this.pause();
      else this.step(dt, input);
    } else if (this.mode === 'paused' && input.pause) {
      this.hud.confirm();
    } else if (this.mode === 'title') {
      this.attract(dt);
    } else if (this.mode === 'victory') {
      this.celebrate(dt);
    }

    const progress =
      this.mode === 'title' ? 0.6 : this.mode === 'victory' ? 0.8 : 1 - this.timeLeft / this.dayLength;
    this.world.update(dt, progress, this.tmp.set(this.px, 0, this.pz));
    if (this.mode !== 'paused') this.fx.update(dt);
    this.animateProps(dt);
    this.updateCamera(dt);
    this.fx.syncInstances();
    this.renderer.render(this.scene, this.camera);

    if (this.mode !== 'title' && this.mode !== 'victory') {
      this.hud.update({
        dayLabel: this.dayLabel(),
        brined: this.brinedToday,
        quota: this.quota,
        score: this.score,
        timeLeft: this.timeLeft,
        dayLength: this.dayLength,
        tears: this.tears,
        maxTears: MAX_TEARS,
        shells: this.shells,
        reloading: this.reloadT > 0,
        ropes: this.roped.length,
        maxRopes: MAX_ROPES,
        tankTime: this.tankT,
        sauceTime: this.sauceT,
        bossHp: this.king ? this.king.hp : 0,
        bossMax: KING_HP,
      });
    }
  }

  /** Title screen: the horse ambles around the jar while pickles and onions mill about. */
  private attract(dt: number): void {
    this.attractT += dt;
    const a = this.attractT * 0.25;
    this.px = Math.cos(a) * 16;
    this.pz = Math.sin(a) * 16;
    this.heading = Math.atan2(-Math.sin(a), Math.cos(a));
    this.speed = 5;
    this.placeRider(dt);
    for (const p of this.pickles) this.updateFreePickle(p, dt);
    for (const o of this.onions) {
      o.walk += dt * 6;
      o.heading += dt * 0.6;
      o.x += Math.sin(o.heading) * dt * 3;
      o.z += Math.cos(o.heading) * dt * 3;
      this.collide(1, (x, z) => ((o.x = x), (o.z = z)), () => (o.heading += 1), o.x, o.z);
      this.placeOnion(o);
    }
  }

  private step(dt: number, input: InputFrame): void {
    const quotaMet = this.brinedToday >= this.quota && this.kingDefeated;
    if (!quotaMet) this.timeLeft -= dt;
    if (this.brinedToday >= this.quota && !this.kingDefeated && !this.quotaToastShown) {
      this.quotaToastShown = true;
      this.hud.toast('Quota met! Now peel the King!', 'info');
    }
    if (this.level.boss && !this.kingSpawned && this.timeLeft < this.dayLength - 4) this.spawnKing();
    if (this.level.dust) this.blowDust();
    if (this.timeLeft <= 0) {
      this.timeLeft = 0;
      this.gameOver('sundown');
      return;
    }
    this.invuln = Math.max(0, this.invuln - dt);
    this.lassoCd = Math.max(0, this.lassoCd - dt);
    this.cannonT = Math.max(0, this.cannonT - dt);
    this.gunKick = Math.max(0, this.gunKick - dt * 6);
    if (this.reloadT > 0) {
      this.reloadT -= dt;
      if (this.reloadT <= 0) {
        this.shells = 2;
        sfx.pump();
      }
    }
    if (this.sauceT > 0) {
      this.sauceT = Math.max(0, this.sauceT - dt);
      this.flameT -= dt;
      const inTank = this.tankT > 0;
      if (this.flameT <= 0 && inTank) {
        this.flameT = 0.04;
        const fx = Math.sin(this.heading);
        const fz = Math.cos(this.heading);
        if (Math.abs(this.speed) > 2) {
          for (const side of [1, -1]) {
            const bx = this.px - fx * 3.8 + fz * side * 1.8;
            const bz = this.pz - fz * 3.8 - fx * side * 1.8;
            this.fx.flame(bx, bz, groundHeight(bx, bz));
          }
        }
        // Flamethrower: fire pours out of the cannon barrel, moving or not.
        const muzzle = new THREE.Vector3();
        this.tank.muzzle.getWorldPosition(muzzle);
        this.fx.breath(muzzle, this.tmp2.set(fx * (Math.max(0, this.speed) + 22), 0, fz * (Math.max(0, this.speed) + 22)));
        for (const d of [6.5, 9.5, 12.5]) this.fx.flames.push({ x: this.px + fx * d, z: this.pz + fz * d, life: 0.3 });
      } else if (this.flameT <= 0 && Math.abs(this.speed) > 2) {
        this.flameT = 0.04;
        const fx = Math.sin(this.heading);
        const fz = Math.cos(this.heading);
        const bx = this.px - fx * 1.6;
        const bz = this.pz - fz * 1.6;
        this.fx.flame(bx, bz, groundHeight(bx, bz));
        // The horse breathes fire, too. It scorches onions just ahead.
        const hx = this.px + fx * 2.4;
        const hz = this.pz + fz * 2.4;
        this.fx.breath(
          this.tmp.set(hx, groundHeight(hx, hz) + 2.8, hz),
          this.tmp2.set(fx * (this.speed + 14), -1.5, fz * (this.speed + 14)),
        );
        const ax = this.px + fx * 6;
        const az = this.pz + fz * 6;
        this.fx.flames.push({ x: ax, z: az, life: 0.3 });
      }
    }
    if (this.tankT > 0) {
      this.tankT -= dt;
      if (this.tankT <= 0) this.exitTank(true);
    }

    this.movePlayer(dt, input);
    if (input.shoot) this.tankT > 0 ? this.fireCannon() : this.fireShotgun();
    if (input.lasso) this.throwLasso();

    this.updateLasso(dt);
    this.updateGuides();
    this.updatePickles(dt);
    this.updateOnions(dt);
    this.updateEnemyTank(dt);
    this.updateShells(dt);
    this.updatePickups(dt);
    this.checkJar();
    this.spawnLogic(dt);

    if (this.tears >= MAX_TEARS) this.gameOver('tears');
    else if (quotaMet) {
      // Let the last pickles splash into the jar before the poster.
      this.winT += dt;
      if (this.winT > 1.3) this.endDay();
    }
  }

  // ---------- player ----------

  private movePlayer(dt: number, input: InputFrame): void {
    const inTank = this.tankT > 0;
    const hot = this.sauceT > 0;
    const max = inTank ? (hot ? 20 : 12) : hot ? 28 : 16;
    const accel = inTank ? (hot ? 18 : 10) : hot ? 30 : 18;
    const turn = inTank ? 1.5 : 2.6 - Math.min(1, Math.abs(this.speed) / 30);
    if (input.throttle > 0.05) this.speed = Math.min(max * input.throttle, this.speed + accel * dt);
    else if (input.throttle < -0.05) this.speed = Math.max(-6, this.speed - accel * 1.4 * dt);
    if (input.throttle <= 0.05 || this.speed > max) {
      const drag = (input.throttle < -0.05 ? 0 : 7) * dt;
      if (this.speed > 0) this.speed = Math.max(0, this.speed - drag);
      else if (this.speed < 0 && input.throttle >= -0.05) this.speed = Math.min(0, this.speed + drag);
    }
    // Turning works even at a standstill so you can always aim.
    const steerDir = this.speed < -0.5 ? -1 : 1;
    this.heading -= input.steer * turn * dt * steerDir;
    this.px += Math.sin(this.heading) * this.speed * dt;
    this.pz += Math.cos(this.heading) * this.speed * dt;
    if (!inTank) this.updateJump(dt);
    this.collide(
      inTank ? 3.2 : 1.3,
      (x, z) => {
        this.px = x;
        this.pz = z;
      },
      () => (this.speed *= 0.6),
      undefined,
      undefined,
      !inTank && this.airborne(),
    );
    this.placeRider(dt);
  }

  private airborne(): boolean {
    return this.jumpY > 0 || this.jumpVy > 0;
  }

  /** Gallop into a small rock and the horse leaps it; land with a thud and a puff of dust. */
  private updateJump(dt: number): void {
    if (this.airborne()) {
      this.jumpVy -= 32 * dt;
      this.jumpY += this.jumpVy * dt;
      if (this.jumpY <= 0) {
        this.jumpY = 0;
        this.jumpVy = 0;
        sfx.land();
        this.addShake(0.15);
        const y = groundHeight(this.px, this.pz);
        this.fx.burst(this.tmp.set(this.px, y + 0.3, this.pz), 0xd9b382, 12, 5, 0.35, 0.7, 1.5, 4);
      }
      return;
    }
    if (this.speed < 4) return;
    const fx = Math.sin(this.heading);
    const fz = Math.cos(this.heading);
    for (const c of this.world.colliders) {
      if (c.jump === undefined) continue;
      const dx = c.x - this.px;
      const dz = c.z - this.pz;
      const d = Math.hypot(dx, dz);
      // Take off just before touching a rock that lies ahead.
      if (d > c.r + 1.3 + 0.6 + this.speed * 0.05 || (dx * fx + dz * fz) / (d || 1) < 0.35) continue;
      this.jumpVy = 9 + c.jump * 1.5;
      this.jumpY = 0.001;
      sfx.jump();
      break;
    }
  }

  /** Push a circle out of scenery, the jar and the arena edge. skipSmall lets a leaping horse pass over small rocks. */
  private collide(
    r: number,
    set: (x: number, z: number) => void,
    bumped?: () => void,
    xIn?: number,
    zIn?: number,
    skipSmall = false,
  ): void {
    let x = xIn ?? this.px;
    let z = zIn ?? this.pz;
    let hit = false;
    for (const c of this.world.colliders) {
      if (skipSmall && c.jump !== undefined) continue;
      const dx = x - c.x;
      const dz = z - c.z;
      const min = c.r + r;
      const d2 = dx * dx + dz * dz;
      if (d2 < min * min && d2 > 0.0001 ** 2) {
        const d = Math.sqrt(d2);
        x = c.x + (dx / d) * min;
        z = c.z + (dz / d) * min;
        hit = true;
      }
    }
    const dj = Math.hypot(x, z);
    if (dj < JAR_RADIUS + r && dj > 0.0001) {
      x = (x / dj) * (JAR_RADIUS + r);
      z = (z / dj) * (JAR_RADIUS + r);
      hit = true;
    }
    if (dj > ARENA_RADIUS) {
      x = (x / dj) * ARENA_RADIUS;
      z = (z / dj) * ARENA_RADIUS;
      hit = true;
    }
    set(x, z);
    if (hit) bumped?.();
  }

  private placeRider(dt: number): void {
    const inTank = this.tankT > 0;
    const y = groundHeight(this.px, this.pz);
    if (inTank) {
      this.tank.root.position.set(this.px, y, this.pz);
      this.tank.root.rotation.y = this.heading;
      this.tankBeam.visible = false;
      for (const w of this.tank.wheels) w.rotation.x += this.speed * dt * 1.5;
      this.tank.turret.rotation.y = Math.sin(performance.now() / 900) * 0.04;
      this.tank.barrel.position.z = 2.0 - Math.max(0, this.cannonT - 0.9) * 2;
      return;
    }
    const r = this.rider;
    r.root.position.set(this.px, y + this.jumpY, this.pz);
    r.root.rotation.y = this.heading;
    const spd = Math.abs(this.speed);
    this.gait += dt * (2 + spd * 0.65);
    const amp = Math.min(0.9, spd / 14);
    if (this.airborne()) {
      // Flying-gallop pose: front legs reach forward, hind legs kick back; nose up, then down.
      r.legs[0].rotation.x = r.legs[1].rotation.x = -0.85;
      r.legs[2].rotation.x = r.legs[3].rotation.x = 0.8;
      r.body.position.y = 0;
      r.body.rotation.x = Math.max(-0.3, Math.min(0.3, -this.jumpVy * 0.03));
    } else {
      r.legs[0].rotation.x = Math.sin(this.gait) * amp;
      r.legs[1].rotation.x = Math.sin(this.gait + 0.6) * amp;
      r.legs[2].rotation.x = Math.sin(this.gait + Math.PI) * amp;
      r.legs[3].rotation.x = Math.sin(this.gait + Math.PI + 0.6) * amp;
      r.body.position.y = Math.abs(Math.sin(this.gait)) * amp * 0.35;
      r.body.rotation.x = Math.sin(this.gait * 2) * amp * 0.05;
    }
    r.tail.rotation.x = -0.4 - amp * 0.6 + Math.sin(this.gait * 2) * 0.15;
    r.head.rotation.x = Math.sin(this.gait) * amp * 0.15;
    r.gun.rotation.x = -this.gunKick * 0.6;
    r.flash.visible = this.gunKick > 0.75;
    r.rider.rotation.z = Math.sin(this.gait) * amp * 0.05;
    // Blink while invulnerable after a hit.
    r.root.visible = !(this.invuln > 0 && this.mode === 'playing' && Math.floor(this.invuln * 12) % 2 === 0 && this.tears > 0);
    if (this.mode === 'playing' && spd > 3 && !this.airborne()) {
      const beat = Math.floor(this.gait / Math.PI);
      if (beat !== this.hoofBeat) {
        this.hoofBeat = beat;
        sfx.hoof();
        this.fx.spawn(
          this.tmp.set(this.px + (Math.random() - 0.5), y + 0.2, this.pz + (Math.random() - 0.5)),
          this.tmp2.set((Math.random() - 0.5) * 2, 1.5 + Math.random(), (Math.random() - 0.5) * 2),
          0xd9b382,
          0.7,
          0.3,
          2,
        );
      }
    }
  }

  private playerHeadPos(out: THREE.Vector3): THREE.Vector3 {
    return out.set(this.px, groundHeight(this.px, this.pz) + 3.2 + this.jumpY, this.pz);
  }

  private ropeAnchor(out: THREE.Vector3): THREE.Vector3 {
    if (this.tankT > 0) {
      const back = 3.6;
      return out.set(this.px - Math.sin(this.heading) * back, groundHeight(this.px, this.pz) + 1.6, this.pz - Math.cos(this.heading) * back);
    }
    this.rider.ropeAnchor.getWorldPosition(out);
    return out;
  }

  private hurt(amount: number, from: { x: number; z: number }): void {
    if (this.invuln > 0) return;
    if (this.tankT > 0) {
      this.tankT = Math.max(0.5, this.tankT - amount * 3);
      this.addShake(0.6);
      return;
    }
    this.tearsTotal += Math.min(amount, MAX_TEARS - this.tears);
    this.tears = Math.min(MAX_TEARS, this.tears + amount);
    this.invuln = 1.3;
    this.hud.hurtFlash();
    this.addShake(0.7);
    sfx.hurt();
    const dx = this.px - from.x;
    const dz = this.pz - from.z;
    const d = Math.hypot(dx, dz) || 1;
    this.px += (dx / d) * 2;
    this.pz += (dz / d) * 2;
    this.speed *= 0.3;
    const head = this.playerHeadPos(this.tmp);
    for (let i = 0; i < 10; i++) {
      this.fx.spawn(head, this.tmp2.set((Math.random() - 0.5) * 6, 3 + Math.random() * 3, (Math.random() - 0.5) * 6), 0x6ec6ff, 0.9, 0.22);
    }
    const lines = ['*sniff*', 'Onion fumes!', 'My eyes!', 'Not the layers!', 'Who’s cutting onions?!'];
    this.hud.toast(lines[Math.floor(Math.random() * lines.length)], 'bad');
  }

  private addShake(v: number): void {
    if (this.shakeEnabled && !this.reducedMotion) this.shake = Math.min(1.2, this.shake + v);
  }

  private fireShotgun(): void {
    if (this.reloadT > 0) return;
    if (this.shells <= 0) {
      sfx.empty();
      return;
    }
    this.shells--;
    if (this.shells === 0) this.reloadT = 1.0;
    this.gunKick = 1;
    sfx.shotgun();
    this.addShake(0.25);
    const muzzle = this.tmp.set(0, 0, 1.3);
    this.rider.gun.localToWorld(muzzle);

    // Gentle auto-aim toward the best onion in a wide cone.
    let aim = this.heading;
    let bestScore = Infinity;
    for (const o of this.onions) {
      const d = Math.hypot(o.x - this.px, o.z - this.pz);
      if (d > SHOTGUN_RANGE) continue;
      const da = Math.abs(angleDiff(Math.atan2(o.x - this.px, o.z - this.pz), this.heading));
      if (da < 0.6 && d + da * 20 < bestScore) {
        bestScore = d + da * 20;
        aim = Math.atan2(o.x - this.px, o.z - this.pz);
      }
    }
    for (let i = 0; i < 8; i++) {
      const a = aim + (Math.random() - 0.5) * 0.45;
      this.fx.streak(muzzle, this.tmp2.set(Math.sin(a), (Math.random() - 0.4) * 0.08, Math.cos(a)).normalize(), 70);
    }
    this.fx.flash(muzzle, 1.5, 0xffd166, 0.12);

    for (const o of [...this.onions]) {
      const d = Math.hypot(o.x - this.px, o.z - this.pz);
      if (d > SHOTGUN_RANGE) continue;
      const da = Math.abs(angleDiff(Math.atan2(o.x - this.px, o.z - this.pz), aim));
      if (da < 0.28 + Math.atan((1.3 * this.onionScale(o.size)) / Math.max(1, d))) this.damageOnion(o, 1, aim);
    }
    if (this.enemy) {
      const e = this.enemy;
      const d = Math.hypot(e.x - this.px, e.z - this.pz);
      const da = Math.abs(angleDiff(Math.atan2(e.x - this.px, e.z - this.pz), aim));
      if (d < SHOTGUN_RANGE + 3 && da < 0.3 + Math.atan(3.5 / Math.max(1, d))) this.damageEnemyTank(1);
    }
  }

  private fireCannon(): void {
    if (this.cannonT > 0) return;
    this.cannonT = 1.1;
    sfx.cannon();
    this.addShake(0.6);
    const muzzle = new THREE.Vector3();
    this.tank.muzzle.getWorldPosition(muzzle);
    this.fx.flash(muzzle, 3, 0xffb703, 0.2);
    this.fx.burst(muzzle, 0xbbbbbb, 10, 6, 0.5, 0.8, 2);
    const vel = new THREE.Vector3(Math.sin(this.heading) * 55, -0.5, Math.cos(this.heading) * 55);
    this.addShell(muzzle, vel, false, null);
  }

  private addShell(pos: THREE.Vector3, vel: THREE.Vector3, enemy: boolean, target: THREE.Vector3 | null): void {
    const mesh = new THREE.Mesh(
      new THREE.IcosahedronGeometry(enemy ? 0.5 : 0.35, 0),
      new THREE.MeshBasicMaterial({ color: enemy ? 0xc77dff : 0xffe066 }),
    );
    mesh.position.copy(pos);
    this.scene.add(mesh);
    let marker: THREE.Mesh | null = null;
    if (target) {
      marker = new THREE.Mesh(
        new THREE.RingGeometry(3.2, 4, 16),
        new THREE.MeshBasicMaterial({ color: 0xff3b30, transparent: true, opacity: 0.7, side: THREE.DoubleSide, depthWrite: false }),
      );
      marker.rotation.x = -Math.PI / 2;
      marker.position.set(target.x, groundHeight(target.x, target.z) + 0.15, target.z);
      this.scene.add(marker);
    }
    this.shellsInAir.push({ mesh, pos: pos.clone(), vel, enemy, marker });
  }

  private enterTank(): void {
    this.tankT = TANK_TIME;
    this.tankParked = false;
    this.px = this.tankX;
    this.pz = this.tankZ;
    this.heading = this.tankHeading;
    this.speed = 0;
    this.jumpY = this.jumpVy = 0;
    this.rider.root.visible = false;
    sfx.mount();
    this.hud.toast('You commandeered an M1A1 Abrams!', 'good');
  }

  private exitTank(announce: boolean): void {
    if (this.tankT <= 0 && !this.tank.root.visible) return;
    const wasDriving = !this.tankParked && this.tank.root.visible;
    this.tankT = 0;
    if (!wasDriving) return;
    this.tank.root.visible = false;
    this.rider.root.visible = true;
    const p = this.tmp.set(this.px, groundHeight(this.px, this.pz) + 2, this.pz);
    this.fx.burst(p, 0x999999, 24, 10, 0.7, 1.2, 4);
    this.fx.flash(p, 6, 0xffffff, 0.3);
    if (announce) {
      sfx.boom();
      this.hud.toast('Out of gas! Back on the horse.', 'info');
    }
    this.invuln = 1.5;
  }

  // ---------- lasso & ropes ----------

  private throwLasso(): void {
    if (this.lasso || this.lassoCd > 0) return;
    if (this.roped.length >= MAX_ROPES) {
      this.hud.toast('Ropes full. Head to the jar!', 'info');
      return;
    }
    const target = this.lassoTarget();
    const from = this.playerHeadPos(new THREE.Vector3());
    const to = target
      ? new THREE.Vector3(target.x, groundHeight(target.x, target.z) + 1.2, target.z)
      : new THREE.Vector3(this.px + Math.sin(this.heading) * 12, groundHeight(this.px, this.pz) + 0.3, this.pz + Math.cos(this.heading) * 12);
    const mesh = new THREE.Mesh(new THREE.TorusGeometry(0.9, 0.07, 4, 10), new THREE.MeshLambertMaterial({ color: palette.rope, flatShading: true }));
    mesh.rotation.x = Math.PI / 2;
    this.scene.add(mesh);
    const dur = Math.max(0.18, from.distanceTo(to) / 45);
    this.lasso = { mesh, t: 0, dur, from, to, target };
    this.lassoCd = 0.35;
    sfx.lasso();
  }

  /** The pickle a lasso throw would grab right now: close, in front, stolen ones first. */
  private lassoTarget(): Pickle | null {
    let target: Pickle | null = null;
    let bestScore = Infinity;
    for (const p of this.pickles) {
      if (p.state !== 'free' && p.state !== 'carried') continue;
      const d = Math.hypot(p.x - this.px, p.z - this.pz);
      if (d > LASSO_RANGE) continue;
      const da = Math.abs(angleDiff(Math.atan2(p.x - this.px, p.z - this.pz), this.heading));
      if (da > 1.25 && d > 5) continue;
      const s = d + da * 8 - (p.state === 'carried' ? 4 : 0);
      if (s < bestScore) {
        bestScore = s;
        target = p;
      }
    }
    return target;
  }

  /** Target ring under the next lasso pickle, and an arrow to the jar while towing. */
  private updateGuides(): void {
    const t = performance.now() / 1000;
    const target = !this.lasso && this.roped.length < MAX_ROPES ? this.lassoTarget() : null;
    this.targetRing.visible = !!target;
    if (target) {
      this.targetRing.position.set(target.x, groundHeight(target.x, target.z) + 0.12, target.z);
      this.targetRing.scale.setScalar(1 + Math.sin(t * 8) * 0.12);
    }
    const jarDist = Math.hypot(this.px, this.pz);
    this.guideArrow.visible = this.roped.length > 0 && jarDist > 18;
    if (this.guideArrow.visible) {
      const lift = this.tankT > 0 ? 7 : 5.4;
      this.guideArrow.position.set(this.px, groundHeight(this.px, this.pz) + lift + Math.sin(t * 4) * 0.25, this.pz);
      this.guideArrow.rotation.y = Math.atan2(-this.px, -this.pz);
    }
  }

  /** Dust storm: grit streaks past on the wind around the rider. */
  private blowDust(): void {
    for (let i = 0; i < 2; i++) {
      const x = this.px + (Math.random() - 0.5) * 40;
      const z = this.pz + (Math.random() - 0.5) * 40;
      this.fx.spawn(
        this.tmp.set(x, groundHeight(x, z) + 0.3 + Math.random() * 4, z),
        this.tmp2.set(14 + Math.random() * 6, Math.random() - 0.3, 5 + Math.random() * 3),
        Math.random() < 0.5 ? 0xc9a46a : 0xe3c896,
        1.3,
        0.22,
        0,
      );
    }
  }

  private updateLasso(dt: number): void {
    const l = this.lasso;
    if (l) {
      l.t += dt;
      if (l.target && (l.target.state === 'free' || l.target.state === 'carried')) {
        l.to.set(l.target.x, groundHeight(l.target.x, l.target.z) + 1.2, l.target.z);
      }
      const k = Math.min(1, l.t / l.dur);
      const pos = this.tmp.copy(l.from).lerp(l.to, k);
      pos.y += Math.sin(k * Math.PI) * 2.5;
      l.mesh.position.copy(pos);
      l.mesh.rotation.z += dt * 18;
      this.drawRope(MAX_ROPES, this.playerHeadPos(this.tmp2), pos, 0.3);
      if (k >= 1) {
        this.retireMesh(l.mesh);
        this.lasso = null;
        this.hideRope(MAX_ROPES);
        const p = l.target;
        if (p && (p.state === 'free' || p.state === 'carried') && this.roped.length < MAX_ROPES) {
          if (p.carrier) {
            const o = p.carrier;
            o.carrying = null;
            o.knock = 0.5;
            this.hud.toast('Yoinked it back!', 'good');
          }
          p.carrier = null;
          p.state = 'roped';
          this.roped.push(p);
          sfx.roped();
        } else {
          sfx.miss();
        }
      }
    }
    const anchor = this.ropeAnchor(this.ropeStart);
    for (let i = 0; i < MAX_ROPES; i++) {
      const p = this.roped[i];
      if (!p) {
        this.hideRope(i);
        continue;
      }
      this.drawRope(i, anchor, this.tmp.set(p.x, groundHeight(p.x, p.z) + 1.5 + p.launchY, p.z), 0.6);
    }
  }

  private drawRope(index: number, a: THREE.Vector3, b: THREE.Vector3, sag: number): void {
    const segs = this.ropeSegs[index];
    const n = segs.length;
    const dist = a.distanceTo(b);
    const prev = this.ropePrev.copy(a);
    const cur = this.ropeCur;
    for (let i = 0; i < n; i++) {
      const t = (i + 1) / n;
      cur.copy(a).lerp(b, t);
      cur.y -= Math.sin(t * Math.PI) * sag * Math.min(2, dist * 0.12);
      const seg = segs[i];
      const dir = this.tmp2.copy(cur).sub(prev);
      const len = dir.length();
      seg.position.copy(prev);
      seg.scale.set(1, len, 1);
      if (len > 0.0001) seg.quaternion.setFromUnitVectors(this.up, dir.multiplyScalar(1 / len));
      seg.visible = true;
      prev.copy(cur);
    }
  }

  private hideRope(index: number): void {
    for (const s of this.ropeSegs[index]) s.visible = false;
  }

  // ---------- pickles ----------

  private updatePickles(dt: number): void {
    const anchor = this.ropeAnchor(this.ropeStart);
    for (let i = this.pickles.length - 1; i >= 0; i--) {
      const p = this.pickles[i];
      if (p.state === 'free') this.updateFreePickle(p, dt);
      else if (p.state === 'roped') {
        const idx = this.roped.indexOf(p);
        const len = 4.5 + idx * 1.6;
        const dx = p.x - anchor.x;
        const dz = p.z - anchor.z;
        const d = Math.hypot(dx, dz) || 1;
        if (d > len) {
          p.x = anchor.x + (dx / d) * len;
          p.z = anchor.z + (dz / d) * len;
        }
        // Keep towed pickles from stacking on each other.
        for (const q of this.roped) {
          if (q === p) continue;
          const ex = p.x - q.x;
          const ez = p.z - q.z;
          const e = Math.hypot(ex, ez);
          if (e < 1.4 && e > 0.001) {
            p.x += (ex / e) * (1.4 - e) * 0.5;
            p.z += (ez / e) * (1.4 - e) * 0.5;
          }
        }
        this.collide(0.6, (x, z) => ((p.x = x), (p.z = z)), undefined, p.x, p.z);
        p.heading = Math.atan2(anchor.x - p.x, anchor.z - p.z);
        p.hop += dt * (4 + Math.abs(this.speed));
        this.updateLaunch(p, dt);
        const y = groundHeight(p.x, p.z) + p.launchY + Math.abs(Math.sin(p.hop)) * 0.3;
        p.m.root.position.set(p.x, y, p.z);
        p.m.root.rotation.set(0, p.heading, 0);
        p.m.body.rotation.x = Math.min(1.2, 0.3 + Math.abs(this.speed) * 0.05); // leaning into the drag
        p.m.body.rotation.z = Math.sin(p.hop * 0.5) * 0.25;
      } else if (p.state === 'carried' && p.carrier) {
        const o = p.carrier;
        const s = this.onionScale(o.size);
        p.x = o.x;
        p.z = o.z;
        p.m.root.position.set(o.x, groundHeight(o.x, o.z) + 2.3 * s + 0.6, o.z);
        p.m.root.rotation.set(0, o.heading + Math.PI / 2, 0);
        p.m.body.rotation.set(0, 0, Math.PI / 2 + Math.sin(performance.now() / 90) * 0.3);
      } else if (p.state === 'flying' && p.fly) {
        p.fly.t += dt / 0.7;
        const k = Math.min(1, p.fly.t);
        const pos = this.tmp.copy(p.fly.from).lerp(p.fly.to, k);
        pos.y += Math.sin(k * Math.PI) * 9;
        p.m.root.position.copy(pos);
        p.m.body.rotation.x += dt * 12;
        if (k >= 1) {
          this.scene.remove(p.m.root);
          this.pickles.splice(i, 1);
          this.addFloater(p.golden);
          this.fx.burst(p.fly.to, palette.brine, 14, 6, 0.3, 0.8, 6);
        }
      }
    }
  }

  private updateLaunch(p: Pickle, dt: number): void {
    if (p.launchY > 0 || p.launchVy > 0) {
      p.launchVy -= 30 * dt;
      p.launchY = Math.max(0, p.launchY + p.launchVy * dt);
      if (p.launchY === 0) p.launchVy = 0;
    }
  }

  private updateFreePickle(p: Pickle, dt: number): void {
    const dx = p.x - this.px;
    const dz = p.z - this.pz;
    const d = Math.hypot(dx, dz);
    let speed = 1.6;
    let dir: number;
    if (d < 14 && this.mode === 'playing') {
      dir = Math.atan2(dx, dz) + Math.sin(performance.now() / 400 + p.wanderA) * 0.6;
      speed = p.golden ? 7.5 : 5.5;
    } else {
      p.wanderT -= dt;
      if (p.wanderT <= 0) {
        p.wanderT = 2 + Math.random() * 3;
        p.wanderA = Math.random() * Math.PI * 2;
      }
      dir = p.wanderA;
    }
    // Stay in the valley and out of the jar area.
    const r = Math.hypot(p.x, p.z);
    if (r > ARENA_RADIUS - 14) dir = Math.atan2(-p.x, -p.z);
    else if (r < JAR_RADIUS + 10) dir = Math.atan2(p.x, p.z);
    p.heading += angleDiff(dir, p.heading) * Math.min(1, dt * 5);
    p.x += Math.sin(p.heading) * speed * dt;
    p.z += Math.cos(p.heading) * speed * dt;
    this.collide(0.6, (x, z) => ((p.x = x), (p.z = z)), () => (p.wanderA += 1.5), p.x, p.z);
    p.hop += dt * (3 + speed * 1.6);
    this.updateLaunch(p, dt);
    const y = groundHeight(p.x, p.z) + p.launchY + Math.abs(Math.sin(p.hop)) * (0.25 + speed * 0.09);
    p.m.root.position.set(p.x, y, p.z);
    p.m.root.rotation.set(0, p.heading, 0);
    p.m.body.rotation.set(Math.sin(p.hop * 2) * 0.08, 0, Math.sin(p.hop) * 0.12);
  }

  private addFloater(golden: boolean): void {
    if (this.jar.floaters.children.length >= 36) return;
    const m = buildPickle(golden);
    m.root.scale.setScalar(0.85);
    const a = Math.random() * Math.PI * 2;
    const r = Math.random() * (JAR_RADIUS - 1.8);
    m.root.position.set(Math.cos(a) * r, 0, Math.sin(a) * r);
    m.root.rotation.set(Math.random() * 0.6, Math.random() * 6, Math.random() * 0.6);
    m.root.userData.phase = Math.random() * 6;
    this.jar.floaters.add(m.root);
  }

  private checkJar(): void {
    if (this.roped.length === 0) return;
    if (Math.hypot(this.px, this.pz) > JAR_RADIUS + (this.tankT > 0 ? 7 : 5)) return;
    let gained = 0;
    let golden = 0;
    this.roped.forEach((p, i) => {
      p.state = 'flying';
      const a = Math.random() * Math.PI * 2;
      p.fly = {
        t: -i * 0.15,
        from: p.m.root.position.clone(),
        to: new THREE.Vector3(Math.cos(a) * 2.5, this.brineTop() + 1, Math.sin(a) * 2.5),
      };
      const value = p.golden ? 3 : 1;
      gained += value;
      if (p.golden) golden++;
      this.score += p.golden ? 300 : 100;
      sfx.plop(i);
    });
    this.brinedTotal += this.roped.length;
    this.roped = [];
    this.brinedToday += gained;
    this.brinedRun += gained;
    if (golden) this.tears = Math.max(0, this.tears - golden);
    const cheers = ['YEEHAW!', 'Brined!', 'Into the jar!', 'Dill-ightful!', 'Pickle party!'];
    this.hud.toast(
      `${cheers[Math.floor(Math.random() * cheers.length)]} +${gained}${golden ? ' (golden dill heals a tear)' : ''}`,
      'good',
    );
  }

  private brineTop(): number {
    return 1 + Math.min(8, this.brinedTotal * 0.22);
  }

  // ---------- onions ----------

  private onionScale(size: number): number {
    return [0, 0.6, 0.85, 1.15, 2.6][size] ?? 1;
  }

  private updateOnions(dt: number): void {
    const inTank = this.tankT > 0;
    for (let i = this.onions.length - 1; i >= 0; i--) {
      const o = this.onions[i];
      if (!this.onions.includes(o)) continue;
      o.spawnT = Math.max(0, o.spawnT - dt);
      o.grace = Math.max(0, o.grace - dt);
      o.burnCd = Math.max(0, o.burnCd - dt);
      o.thinkT -= dt;
      const s = this.onionScale(o.size);
      if (o.carrying) {
        // Run for the hills with the loot.
        const r = Math.hypot(o.x, o.z) || 1;
        o.tx = (o.x / r) * (ARENA_RADIUS + 5);
        o.tz = (o.z / r) * (ARENA_RADIUS + 5);
        if (r > ARENA_RADIUS + 2) {
          const p = o.carrying;
          this.removePickle(p);
          this.removeOnion(o);
          this.hud.toast('An onion rustled a pickle!', 'bad');
          sfx.stolen();
          continue;
        }
      } else if (o.king) {
        // The King hunts you personally and calls in guards.
        o.tx = this.px;
        o.tz = this.pz;
        o.summonT -= dt;
        if (o.summonT <= 0) {
          o.summonT = 11;
          this.summonGuards(o, 2);
        }
      } else if (o.thinkT <= 0) {
        o.thinkT = 0.4 + Math.random() * 0.3;
        let best = Math.hypot(this.px - o.x, this.pz - o.z) * 1.3;
        o.tx = this.px;
        o.tz = this.pz;
        for (const p of this.pickles) {
          if (p.state !== 'free' && p.state !== 'roped') continue;
          const d = Math.hypot(p.x - o.x, p.z - o.z) * (p.state === 'roped' ? 0.7 : 1);
          if (d < best) {
            best = d;
            o.tx = p.x;
            o.tz = p.z;
          }
        }
      }
      const speed = o.king ? 4.2 + this.level.onionSpeed * 0.4 : ([0, 8.5, 6.5, 5][o.size] ?? 5) + this.level.onionSpeed;
      const want = Math.atan2(o.tx - o.x, o.tz - o.z);
      o.heading += angleDiff(want, o.heading) * Math.min(1, dt * 4);
      if (o.knock > 0) {
        o.knock -= dt;
        o.x += o.kx * dt;
        o.z += o.kz * dt;
        o.kx *= 0.9;
        o.kz *= 0.9;
      } else {
        const pace = o.carrying ? 0.6 : 1;
        o.x += Math.sin(o.heading) * speed * pace * dt;
        o.z += Math.cos(o.heading) * speed * pace * dt;
      }
      o.walk += dt * speed * 1.4;
      // Onions jostle each other instead of stacking.
      for (const q of this.onions) {
        if (q === o) continue;
        const dx = o.x - q.x;
        const dz = o.z - q.z;
        const min = (s + this.onionScale(q.size)) * 0.85;
        const d2 = dx * dx + dz * dz;
        if (d2 < min * min && d2 > 0.001 ** 2) {
          const d = Math.sqrt(d2);
          o.x += (dx / d) * (min - d) * 0.5;
          o.z += (dz / d) * (min - d) * 0.5;
        }
      }
      if (!o.carrying) this.collide(s * 0.8, (x, z) => ((o.x = x), (o.z = z)), undefined, o.x, o.z);
      else {
        // Escaping onions may leave the arena; only avoid scenery.
        const r = Math.hypot(o.x, o.z);
        if (r < ARENA_RADIUS - 2) this.collide(s * 0.8, (x, z) => ((o.x = x), (o.z = z)), undefined, o.x, o.z);
      }
      this.placeOnion(o);

      // Fire trail from hot sauce.
      if (o.burnCd <= 0) {
        for (const f of this.fx.flames) {
          if (Math.hypot(f.x - o.x, f.z - o.z) < 1.2 + s * 0.6) {
            o.burnCd = 0.7;
            this.damageOnion(o, 1, o.heading + Math.PI);
            break;
          }
        }
        if (!this.onions.includes(o)) continue;
      }

      // Contacts.
      const dPlayer = Math.hypot(this.px - o.x, this.pz - o.z);
      if (o.king) {
        if (dPlayer < (inTank ? 3.4 : 1.5) + s * 0.6) {
          const a = Math.atan2(o.x - this.px, o.z - this.pz);
          if ((inTank || this.sauceT > 0) && o.burnCd <= 0) {
            o.burnCd = 0.8;
            this.damageOnion(o, 2, a);
          } else if (!inTank && this.sauceT <= 0) this.hurt(2, o);
          o.knock = 0.4;
          o.kx = Math.sin(a) * 8;
          o.kz = Math.cos(a) * 8;
        }
        continue;
      }
      if (inTank && dPlayer < 3.4 + s * 0.6) {
        this.damageOnion(o, 3, this.heading);
        this.hud.toast('SQUISH!', 'good');
        continue;
      }
      if (!inTank && dPlayer < 1.5 + s * 0.6 && o.spawnT <= 0) {
        if (this.sauceT > 0) {
          this.damageOnion(o, 1, Math.atan2(o.x - this.px, o.z - this.pz));
          continue;
        }
        this.hurt(1, o);
        const a = Math.atan2(o.x - this.px, o.z - this.pz);
        o.knock = 0.4;
        o.kx = Math.sin(a) * 14;
        o.kz = Math.cos(a) * 14;
      }
      if (!o.carrying && o.knock <= 0 && o.grace <= 0) {
        for (const p of this.pickles) {
          if (p.state !== 'free' && p.state !== 'roped') continue;
          if (Math.hypot(p.x - o.x, p.z - o.z) > 1.2 + s * 0.6) continue;
          if (p.state === 'roped') {
            this.roped.splice(this.roped.indexOf(p), 1);
            this.hud.toast('Rope snapped! Onion’s got your pickle!', 'bad');
          } else this.hud.toast('Pickle snatched!', 'bad');
          sfx.stolen();
          p.state = 'carried';
          p.carrier = o;
          o.carrying = p;
          break;
        }
      }
    }
  }

  private placeOnion(o: Onion): void {
    const s = this.onionScale(o.size) * (1 - o.spawnT * 1.5);
    o.m.root.position.set(o.x, groundHeight(o.x, o.z), o.z);
    o.m.root.rotation.y = o.heading;
    o.m.root.scale.setScalar(Math.max(0.1, s));
    o.m.body.rotation.z = Math.sin(o.walk) * 0.18;
    o.m.body.position.y = Math.abs(Math.sin(o.walk)) * 0.25;
    o.m.hat.rotation.z = 0.15 + Math.sin(o.walk * 0.5) * 0.1;
  }

  private damageOnion(o: Onion, layers: number, fromAngle: number): void {
    if (!this.onions.includes(o)) return;
    const pos = this.tmp.set(o.x, groundHeight(o.x, o.z) + this.onionScale(o.size), o.z);
    if (o.carrying) {
      const p = o.carrying;
      p.state = 'free';
      p.carrier = null;
      p.launchVy = 9;
      p.launchY = 0.01;
      o.carrying = null;
    }
    if (o.king) {
      const before = o.hp;
      o.hp = Math.max(0, o.hp - layers);
      this.score += 25 * (before - o.hp);
      this.onionsToday += before - o.hp;
      sfx.peel();
      this.fx.burst(pos, palette.onionLight, 14, 10, 0.5, 0.9);
      if (o.hp <= 0) this.killKing(o);
      else if (Math.floor(before / 10) > Math.floor(o.hp / 10)) {
        sfx.kingRoar();
        this.summonGuards(o, 3);
      }
      return;
    }
    if (layers >= o.size) {
      this.score += 25 * o.size;
      this.onionsToday += o.size;
      this.fx.burst(pos, palette.onion, 16, 9, 0.35, 0.9);
      this.fx.burst(pos, palette.onionLight, 10, 7, 0.3, 0.9);
      this.fx.burst(pos, 0x6ec6ff, 6, 5, 0.18, 0.7); // onion tears
      this.fx.flash(pos, 2.5, 0xffffff, 0.2);
      sfx.pop();
      this.removeOnion(o);
      return;
    }
    // Peel a layer and split in two.
    this.score += 25 * layers;
    this.onionsToday += layers;
    o.size -= layers;
    sfx.peel();
    this.fx.burst(pos, palette.onionLight, 12, 8, 0.4, 0.8);
    const side = fromAngle + Math.PI / 2;
    const twin = this.spawnOnion(o.size, o.x - Math.sin(side) * 0.8, o.z - Math.cos(side) * 0.8);
    twin.spawnT = 0;
    twin.heading = o.heading;
    o.x += Math.sin(side) * 0.8;
    o.z += Math.cos(side) * 0.8;
    for (const [onion, dir] of [
      [o, side],
      [twin, side + Math.PI],
    ] as const) {
      onion.knock = 0.35;
      onion.kx = Math.sin(dir) * 9 + Math.sin(fromAngle) * 6;
      onion.kz = Math.cos(dir) * 9 + Math.cos(fromAngle) * 6;
    }
  }

  private removeOnion(o: Onion): void {
    const i = this.onions.indexOf(o);
    if (i >= 0) this.onions.splice(i, 1);
    this.scene.remove(o.m.root);
  }

  private removePickle(p: Pickle): void {
    const i = this.pickles.indexOf(p);
    if (i >= 0) this.pickles.splice(i, 1);
    const r = this.roped.indexOf(p);
    if (r >= 0) this.roped.splice(r, 1);
    this.scene.remove(p.m.root);
  }

  // ---------- enemy tank ----------

  private updateEnemyTank(dt: number): void {
    if (!this.enemySpawned && this.level.enemyTank && this.timeLeft < this.dayLength - 25) this.spawnEnemyTank();
    const e = this.enemy;
    if (!e) return;
    const dx = this.px - e.x;
    const dz = this.pz - e.z;
    const d = Math.hypot(dx, dz);
    const toPlayer = Math.atan2(dx, dz);
    let drive = 0;
    if (d > 30) drive = 5;
    else if (d < 16) drive = -3;
    e.heading += angleDiff(toPlayer, e.heading) * Math.min(1, dt * 0.7);
    e.x += Math.sin(e.heading) * drive * dt;
    e.z += Math.cos(e.heading) * drive * dt;
    this.collide(3.2, (x, z) => ((e.x = x), (e.z = z)), undefined, e.x, e.z);
    // Don't let the two tanks overlap.
    const gap = Math.hypot(this.px - e.x, this.pz - e.z);
    const min = this.tankT > 0 ? 6.5 : 4;
    if (gap < min && gap > 0.01) {
      e.x -= ((this.px - e.x) / gap) * (min - gap);
      e.z -= ((this.pz - e.z) / gap) * (min - gap);
    }
    e.turret += angleDiff(toPlayer - e.heading, e.turret) * Math.min(1, dt * 2);
    e.fireT -= dt;
    if (e.fireT <= 0 && d < 60) {
      e.fireT = Math.max(2.4, 4.2 - Math.max(0, this.day - 3) * 0.3);
      const muzzle = new THREE.Vector3();
      e.m.muzzle.getWorldPosition(muzzle);
      // Lob a shell at where the player is heading, with a warning ring.
      const lead = Math.min(1.2, d / 30);
      const target = new THREE.Vector3(
        this.px + Math.sin(this.heading) * this.speed * lead * 0.6,
        0,
        this.pz + Math.cos(this.heading) * this.speed * lead * 0.6,
      );
      target.y = groundHeight(target.x, target.z);
      const T = 1.1 + d / 40;
      const vel = new THREE.Vector3((target.x - muzzle.x) / T, 0, (target.z - muzzle.z) / T);
      vel.y = (target.y - muzzle.y + 0.5 * 18 * T * T) / T;
      this.addShell(muzzle, vel, true, target);
      this.fx.flash(muzzle, 2.5, 0xc77dff, 0.2);
      sfx.cannon();
    }
    e.hitFlash = Math.max(0, e.hitFlash - dt);
    e.m.root.position.set(e.x, groundHeight(e.x, e.z), e.z);
    e.m.root.rotation.y = e.heading;
    e.m.turret.rotation.y = e.turret;
    for (const w of e.m.wheels) w.rotation.x += drive * dt * 1.5;
    e.m.root.position.y += e.hitFlash > 0 ? Math.sin(e.hitFlash * 60) * 0.1 : 0;
  }

  private damageEnemyTank(amount: number): void {
    const e = this.enemy;
    if (!e) return;
    e.hp -= amount;
    e.hitFlash = 0.25;
    const pos = this.tmp.set(e.x, groundHeight(e.x, e.z) + 2.5, e.z);
    this.fx.burst(pos, 0xffd166, 6, 8, 0.25, 0.4);
    if (e.hp <= 0) {
      this.fx.flash(pos, 12, 0xff9f1c, 0.7);
      this.fx.burst(pos, palette.enemyTank, 30, 16, 0.8, 1.6, 10);
      this.fx.burst(pos, 0x333333, 20, 10, 1, 2, 6);
      sfx.boom();
      this.addShake(1.2);
      this.score += 500;
      this.enemy = null;
      e.m.root.visible = false;
      this.hud.toast('Onion tank DESTROYED! +500', 'good');
    }
  }

  // ---------- shells ----------

  private updateShells(dt: number): void {
    for (let i = this.shellsInAir.length - 1; i >= 0; i--) {
      const s = this.shellsInAir[i];
      s.vel.y -= (s.enemy ? 18 : 9) * dt;
      s.pos.addScaledVector(s.vel, dt);
      s.mesh.position.copy(s.pos);
      if (s.marker) {
        const mm = s.marker.material as THREE.MeshBasicMaterial;
        mm.opacity = 0.4 + Math.abs(Math.sin(performance.now() / 80)) * 0.5;
      }
      let explode = s.pos.y <= groundHeight(s.pos.x, s.pos.z) + 0.2 || Math.hypot(s.pos.x, s.pos.z) > ARENA_RADIUS + 20;
      if (!s.enemy && !explode) {
        for (const o of this.onions) {
          const near = Math.hypot(o.x - s.pos.x, o.z - s.pos.z) < 1.5 + this.onionScale(o.size);
          if (near && s.pos.y < groundHeight(o.x, o.z) + 3.5) explode = true;
        }
        if (this.enemy && Math.hypot(this.enemy.x - s.pos.x, this.enemy.z - s.pos.z) < 3.5 && s.pos.y < groundHeight(s.pos.x, s.pos.z) + 4)
          explode = true;
      }
      if (!explode) continue;
      this.retireMesh(s.mesh);
      if (s.marker) this.retireMesh(s.marker);
      this.shellsInAir.splice(i, 1);
      this.explode(s.pos, s.enemy);
    }
  }

  private explode(pos: THREE.Vector3, enemy: boolean): void {
    const radius = 7;
    this.fx.flash(pos, radius, enemy ? 0xc77dff : 0xff9f1c, 0.5);
    this.fx.burst(pos, 0x7a5a3a, 18, 12, 0.5, 1.2, 8);
    this.fx.burst(pos, enemy ? 0xc77dff : 0xffb703, 14, 10, 0.4, 0.8, 4);
    sfx.boom();
    this.addShake(enemy ? 0.5 : 0.8);
    if (enemy) {
      if (Math.hypot(this.px - pos.x, this.pz - pos.z) < 4.5) this.hurt(2, pos);
    } else {
      for (const o of [...this.onions]) {
        if (Math.hypot(o.x - pos.x, o.z - pos.z) < radius) this.damageOnion(o, 3, Math.atan2(o.x - pos.x, o.z - pos.z));
      }
      if (this.enemy && Math.hypot(this.enemy.x - pos.x, this.enemy.z - pos.z) < radius) this.damageEnemyTank(4);
    }
    // Pickles caught in a blast get launched, unharmed.
    for (const p of this.pickles) {
      if ((p.state === 'free' || p.state === 'roped') && Math.hypot(p.x - pos.x, p.z - pos.z) < radius) {
        p.launchVy = 14;
        p.launchY = 0.01;
      }
    }
  }

  // ---------- pickups & spawning ----------

  private updatePickups(dt: number): void {
    if (this.tankParked && this.tankT <= 0) {
      const y = groundHeight(this.tankX, this.tankZ);
      this.tank.root.position.set(this.tankX, y, this.tankZ);
      this.tank.root.rotation.y = this.tankHeading;
      this.tankBeam.position.set(this.tankX, y, this.tankZ);
      if (Math.hypot(this.px - this.tankX, this.pz - this.tankZ) < 4.8) this.enterTank();
    }
    const inTank = this.tankT > 0;
    for (let i = this.sauces.length - 1; i >= 0; i--) {
      const s = this.sauces[i];
      if (Math.hypot(this.px - s.x, this.pz - s.z) < (inTank ? 4.8 : 2.6)) {
        this.scene.remove(s.mesh);
        this.sauces.splice(i, 1);
        this.sauceT = SAUCE_TIME;
        this.sauceRespawn = 22;
        sfx.sauce();
        this.fx.flash(this.tmp.set(this.px, groundHeight(this.px, this.pz) + 2, this.pz), inTank ? 7 : 4, 0xff3d00, 0.35);
        this.hud.toast(
          inTank ? 'HOT SAUCE in the fuel tank! Flamethrower online!' : 'HOT SAUCE! The horse is on fire (it’s fine)',
          'good',
        );
      }
    }
    if (this.sauces.length < 2 && this.sauceRespawn > 0) {
      this.sauceRespawn -= dt;
      if (this.sauceRespawn <= 0) this.spawnSauce();
    }
  }

  private spawnLogic(dt: number): void {
    this.spawnT -= dt;
    const maxOnions = this.level.onionCap;
    if (this.spawnT <= 0) {
      this.spawnT = this.level.spawnEvery * (0.7 + Math.random() * 0.6);
      if (this.onions.length < maxOnions) this.spawnOnion(this.randomOnionSize());
    }
    // Keep enough pickles around to make quota possible.
    let live = 0;
    for (const p of this.pickles) if (p.state !== 'flying') live++;
    const needed = Math.max(8, this.quota - this.brinedToday + 4);
    if (live < needed) this.spawnPickle(Math.random() < this.level.goldenChance);
  }

  // ---------- presentation ----------

  private animateProps(dt: number): void {
    const t = performance.now() / 1000;
    for (const s of this.sauces) {
      s.mesh.rotation.y += dt * 2;
      s.mesh.children[0].position.y = 1.2 + Math.sin(t * 3 + s.x) * 0.25;
    }
    const top = this.brineTop();
    this.jar.brine.scale.y += (top - this.jar.brine.scale.y) * Math.min(1, dt * 2);
    this.jar.brine.position.y = this.jar.brine.scale.y / 2;
    for (const f of this.jar.floaters.children) {
      const ph = f.userData.phase as number;
      f.position.y = Math.max(0.4, this.jar.brine.scale.y - 1.6 + Math.sin(t * 1.5 + ph) * 0.25 - (ph % 1.5));
      f.rotation.y += dt * 0.2;
    }
    const beamOn = this.mode === 'playing' && this.roped.length > 0;
    this.jarBeam.visible = beamOn;
    (this.jarBeam.material as THREE.MeshBasicMaterial).opacity = 0.18 + Math.abs(Math.sin(t * 3)) * 0.15;
    (this.tankBeam.material as THREE.MeshBasicMaterial).opacity = 0.15 + Math.abs(Math.sin(t * 2)) * 0.12;
    this.tankBeam.visible = this.tankParked && this.tank.root.visible;
    if (this.tankParked && this.mode === 'title') {
      this.tank.root.position.set(this.tankX, groundHeight(this.tankX, this.tankZ), this.tankZ);
      this.tank.root.rotation.y = this.tankHeading;
      this.tankBeam.position.copy(this.tank.root.position);
    }
  }

  private updateCamera(dt: number): void {
    const portrait = this.portrait;
    const inTank = this.tankT > 0;
    let dist = inTank ? 17 : 12.5;
    let height = inTank ? 10 : 6.5;
    if (portrait) {
      dist *= 1.3;
      height *= 1.55;
    }
    const fx = Math.sin(this.heading);
    const fz = Math.cos(this.heading);
    const y = groundHeight(this.px, this.pz);
    let target: THREE.Vector3;
    let look: THREE.Vector3;
    if (this.mode === 'title') {
      const a = this.attractT * 0.08;
      target = this.tmp.set(Math.cos(a) * 42, 20, Math.sin(a) * 42);
      look = this.tmp2.set(0, 4, 0);
    } else if (this.mode === 'victory') {
      const a = this.attractT * 0.12 + 0.8;
      target = this.tmp.set(Math.cos(a) * 30, 10, Math.sin(a) * 30);
      const offset = this.camera.aspect > 1.3 ? 9 : 0;
      look = this.tmp2.set(-Math.sin(a) * offset, 10, Math.cos(a) * offset);
    } else {
      target = this.tmp.set(this.px - fx * dist, y + height, this.pz - fz * dist);
      look = this.tmp2.set(this.px + fx * 5, y + 1.8, this.pz + fz * 5);
    }
    const orbit = this.mode === 'title' || this.mode === 'victory';
    const k = 1 - Math.exp(-dt * (orbit ? 1.5 : 4.5));
    this.camPos.lerp(target, k);
    this.camLook.lerp(look, k);
    // Never look through the jar glass: if the chase camera ends up over the jar, lift it above the rim.
    if (Math.hypot(this.camPos.x, this.camPos.z) < JAR_RADIUS + 2) this.camPos.y = Math.max(this.camPos.y, 13);
    const minY = groundHeight(this.camPos.x, this.camPos.z) + 2;
    if (this.camPos.y < minY) this.camPos.y = minY;
    this.camera.position.copy(this.camPos);
    if (this.shake > 0) {
      this.camera.position.x += (Math.random() - 0.5) * this.shake;
      this.camera.position.y += (Math.random() - 0.5) * this.shake;
      this.shake = Math.max(0, this.shake - dt * 3);
    }
    this.camera.lookAt(this.camLook);
  }
}