Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

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

src/game.ts
import { DUST_COLOR, FG_COLORS, SMOKE_COLOR, SPARK_COLORS } from './palette.ts';
import {
  createMole,
  findMoleSpawn,
  MOLE_BLAST,
  MOLE_FUSE,
  MOLE_HALF_H,
  MOLE_TRIGGER,
  stepArmed,
  stepMole,
  type Mole,
  type MoleTarget,
} from './moles.ts';
import { createRng, type Rng } from './rng.ts';
import {
  CAP,
  CELL,
  COLLIDE_NONE,
  COLLIDE_ONEWAY,
  COLLIDE_SOLID,
  generateWorld,
  type Terrain,
} from './terrain.ts';
import { computePose, createPose, PELVIS, Ragdoll, type Pose, type PoseState } from './stickman.ts';

export const GRAVITY = 1400;
export const PLAYER_W = 10;
export const PLAYER_H = 30;
/** Hitbox height while crouched or crawling: fits a four-cell gap. */
export const CROUCH_H = 16;
const RUN_SPEED = 165;
const CRAWL_SPEED = 62;
const JUMP_SPEED = 470;
const BOUNCE_SPEED = 640;
const MAX_FALL = 1000;
const METEOR_GRAVITY = 220;
const METEOR_START_Y = -300;
/** Inside this fraction of the blast radius the stickman dies. */
export const KILL_REACH = 0.7;
/** Out to this fraction of the blast radius the stickman is shoved. */
export const KNOCK_REACH = 2.6;
const MAX_PARTICLES = 1100;
/** Seconds a newly joined player ignores lethal blasts while they find their feet. */
export const SPAWN_SHIELD = 1.5;
/** Seconds a dead player's camera stays on their body before following a teammate. */
export const SPECTATE_DELAY = 2;

export interface Controls {
  left: boolean;
  right: boolean;
  jump: boolean;
  down: boolean;
  /** Held to crouch and crawl through low gaps. */
  crouch: boolean;
  /** True only on the step the jump input was first pressed. */
  jumpPressed: boolean;
}

export const NO_CONTROLS: Controls = { left: false, right: false, jump: false, down: false, crouch: false, jumpPressed: false };

export interface Player {
  x: number;
  /** Feet line. */
  y: number;
  vx: number;
  vy: number;
  facing: 1 | -1;
  grounded: boolean;
  floor: number;
  coyote: number;
  jumpBuffer: number;
  jumpHeld: boolean;
  dropTimer: number;
  stun: number;
  spin: number;
  spinVelocity: number;
  phase: number;
  crouch: number;
  /** Crouched or crawling, with the short hitbox. Stays set while there is no headroom to stand. */
  ducked: boolean;
  /** Visual-only offset that smooths automatic step-ups. */
  stepOffset: number;
  state: PoseState;
}

export interface Meteor {
  x: number;
  y: number;
  vx: number;
  vy: number;
  size: number;
  blast: number;
  /** Estimated seconds until it reaches the ground, from launch. */
  eta: number;
}

export const PARTICLE_DEBRIS = 0;
export const PARTICLE_SPARK = 1;
export const PARTICLE_SMOKE = 2;
export const PARTICLE_DUST = 3;

export interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  max: number;
  size: number;
  color: number;
  kind: number;
}

export interface Blast {
  x: number;
  y: number;
  radius: number;
  age: number;
  /** 0 far away … 1 on top of the nearest player. */
  proximity: number;
  cause: DeathCause;
}

export type DeathCause = 'meteor' | 'mole';

/** One local player: their stickman while alive, their ragdoll once dead. */
export interface PlayerSlot {
  /** Stable 0–3 identity: colour, label and controller routing. */
  readonly slot: number;
  readonly body: Player;
  readonly pose: Pose;
  ragdoll: Ragdoll | null;
  alive: boolean;
  deathCause: DeathCause | null;
  /** World clock at death. */
  diedAt: number;
  /** Seconds of spawn protection left. */
  shield: number;
  /** Screen shake for this player's view. */
  shake: number;
}

/** 0 far away … 1 on top of (x, y). */
export function blastProximity(blast: { x: number; y: number; radius: number }, x: number, y: number): number {
  const distance = Math.hypot(x - blast.x, y - blast.y);
  return Math.max(0, Math.min(1, 1 - distance / (blast.radius * 9)));
}

/** Feedback hooks for sound and rumble; the simulation never depends on them. */
export interface GameListeners {
  explosion?(blast: Blast): void;
  /** One player died; `runOver` follows when they were the last one standing. */
  death?(slot: number, cause: DeathCause): void;
  runOver?(): void;
  /** `proximity` is 0–1 for this player, for per-controller rumble. */
  blastNear?(slot: number, proximity: number, blast: Blast): void;
  jump?(slot: number): void;
  /** `impact` is the downward speed at touchdown. */
  land?(impact: number, slot: number): void;
  bounce?(slot: number): void;
  footstep?(slot: number): void;
  meteor?(meteor: Meteor): void;
  moleSurface?(mole: Mole): void;
  moleDive?(mole: Mole): void;
  moleArmed?(mole: Mole): void;
}

/** Seconds between meteors after `t` seconds of survival. */
export function spawnInterval(t: number): number {
  return Math.max(0.16, 1.9 * Math.pow(0.5, t / 45));
}

/** How many moles the world keeps topped up to after `t` seconds of survival. */
export function moleTarget(t: number): number {
  return Math.min(22, 11 + Math.floor(t / 20));
}

/** Blast radius in world units for a meteor spawned at `t`; `roll` is 0–1. */
export function blastRadius(t: number, roll: number): number {
  return 22 + Math.min(26, t * 0.18) + roll * 14;
}

/** Distance from a point to the player's hitbox (0 when inside). */
export function distanceToBox(px: number, py: number, x: number, feetY: number, height = PLAYER_H): number {
  const left = x - PLAYER_W / 2;
  const right = x + PLAYER_W / 2;
  const top = feetY - height;
  const dx = Math.max(left - px, 0, px - right);
  const dy = Math.max(top - py, 0, py - feetY);
  return Math.hypot(dx, dy);
}

export class Game {
  readonly seed: number;
  readonly terrain: Terrain;
  readonly players: PlayerSlot[] = [];
  readonly meteors: Meteor[] = [];
  readonly particles: Particle[] = [];
  readonly blasts: Blast[] = [];
  readonly moles: Mole[] = [];
  /** How the most recent death happened. */
  deathCause: DeathCause | null = null;
  /** Meteors only fall once a run has started. */
  meteorsEnabled = false;
  /** Seconds survived in this run, by whoever is still standing. */
  survived = 0;
  /** World clock, including time after death. */
  clock = 0;
  listeners: GameListeners = {};
  private readonly rng: Rng;
  /** Moles draw from their own stream so they never change the meteor sequence. */
  private readonly moleRng: Rng;
  private spawnTimer = 2;
  private moleTimer = 5;
  private readonly moleTarget: MoleTarget = { x: 0, y: 0, active: false };
  private readonly spawnX: number;
  private readonly spawnY: number;

  constructor(seed: number, slots: readonly number[] = [0]) {
    this.seed = seed;
    this.rng = createRng(seed ^ 0x5bd1e995);
    this.moleRng = createRng(seed ^ 0x27d4eb2f);
    const world = generateWorld(seed);
    this.terrain = world.terrain;
    this.spawnX = world.spawnX;
    this.spawnY = world.spawnY;
    for (let i = moleTarget(0); i > 0; i--) this.spawnMole([{ x: world.spawnX, y: world.spawnY }]);
    for (const slot of slots) this.addPlayer(slot);
  }

  /** True while anyone is still standing. */
  get alive(): boolean {
    return this.players.some((p) => p.alive);
  }

  player(slot: number): PlayerSlot | undefined {
    return this.players.find((p) => p.slot === slot);
  }

  /**
   * Join a player. The first spawns at the world spawn; later ones drop in at a
   * living teammate's feet, which is always clear ground, with a short spawn shield.
   */
  addPlayer(slot: number): PlayerSlot {
    const existing = this.player(slot);
    if (existing) return existing;
    const anchor = this.players.find((p) => p.alive)?.body;
    const joining = this.players.length > 0;
    const x = anchor?.x ?? this.spawnX;
    const y = anchor?.y ?? this.spawnY;
    const body: Player = {
      x: x + (anchor?.grounded ? this.standingRoom(x, y, slot) : 0),
      y,
      vx: 0,
      vy: 0,
      facing: slot % 2 === 0 ? 1 : -1,
      grounded: anchor?.grounded ?? true,
      floor: anchor?.floor ?? COLLIDE_SOLID,
      coyote: 0,
      jumpBuffer: 0,
      jumpHeld: false,
      dropTimer: 0,
      stun: 0,
      spin: 0,
      spinVelocity: 0,
      phase: 0,
      crouch: 0,
      ducked: false,
      stepOffset: 0,
      state: 'idle',
    };
    const player: PlayerSlot = {
      slot,
      body,
      pose: createPose(),
      ragdoll: null,
      alive: true,
      deathCause: null,
      diedAt: 0,
      shield: joining ? SPAWN_SHIELD : 0,
      shake: 0,
    };
    this.players.push(player);
    this.players.sort((a, b) => a.slot - b.slot);
    this.updatePose(player);
    if (joining) this.puff(body.x, body.y, 8, 70);
    return player;
  }

  /** A small sideways offset onto clear, solid ground, so joiners don't stand exactly on top of each other. */
  private standingRoom(x: number, y: number, slot: number): number {
    const side = slot % 2 === 0 ? 1 : -1;
    for (const offset of [14 * side, -14 * side, 7 * side, -7 * side]) {
      if (!this.bodyBlocked(x + offset, y) && this.bodyBlocked(x + offset, y + CELL)) return offset;
    }
    return 0;
  }

  /** Remove an unplugged player. If they were the last one standing, the run is over. */
  removePlayer(slot: number): void {
    const index = this.players.findIndex((p) => p.slot === slot);
    if (index < 0) return;
    const [gone] = this.players.splice(index, 1);
    if (gone.alive && this.players.length > 0 && !this.alive) this.listeners.runOver?.();
  }

  /** Who a slot's camera follows: itself, its body for a moment after death, then the nearest living teammate. */
  viewTarget(slot: number): PlayerSlot | undefined {
    const own = this.player(slot);
    if (!own) return this.players[0];
    if (own.alive || this.clock - own.diedAt < SPECTATE_DELAY) return own;
    const x = bodyX(own);
    const y = bodyY(own);
    let best: PlayerSlot | undefined;
    let bestDistance = Infinity;
    for (const p of this.players) {
      if (!p.alive) continue;
      const d = Math.hypot(bodyX(p) - x, bodyY(p) - y);
      if (d < bestDistance) {
        best = p;
        bestDistance = d;
      }
    }
    return best ?? own;
  }

  /** World x a slot's camera should follow; the first player's by default. */
  focusX(slot = this.players[0]?.slot ?? 0): number {
    const p = this.viewTarget(slot);
    return p ? bodyX(p) : this.spawnX;
  }

  focusY(slot = this.players[0]?.slot ?? 0): number {
    const p = this.viewTarget(slot);
    return p ? bodyY(p) : this.spawnY - PLAYER_H / 2;
  }

  /** Storm intensity clock: freezes when the last player dies. */
  stormTime(): number {
    return this.survived;
  }

  /** `controls` is one set for every player, or a lookup by slot. */
  update(dt: number, controls: Controls | ((slot: number) => Controls)): void {
    this.clock += dt;
    if (this.alive && this.meteorsEnabled) this.survived += dt;
    for (const p of this.players) {
      if (p.alive) this.updatePlayer(p, dt, typeof controls === 'function' ? controls(p.slot) : controls);
      else p.ragdoll?.step(dt, GRAVITY, this.collidePoint);
      p.shield = Math.max(0, p.shield - dt);
      p.shake *= Math.exp(-dt * 7);
    }
    this.updateMoles(dt);
    this.updateMeteors(dt);
    this.updateParticles(dt);
    for (let i = this.blasts.length - 1; i >= 0; i--) {
      this.blasts[i].age += dt;
      if (this.blasts[i].age > 0.6) this.blasts.splice(i, 1);
    }
  }

  // Player ────────────────────────────────────────────────────────────────

  private updatePlayer(player: PlayerSlot, dt: number, c: Controls): void {
    const p = player.body;
    this.unstick(p);
    const wish = (c.right ? 1 : 0) - (c.left ? 1 : 0);
    const stunned = p.stun > 0;
    p.stun = Math.max(0, p.stun - dt);

    // Duck from the ground (or keep ducking mid-air); stand only where there is headroom.
    if (c.crouch && (p.grounded || p.ducked)) p.ducked = true;
    else if (p.ducked && !this.bodyBlocked(p.x, p.y, PLAYER_H)) p.ducked = false;
    const maxSpeed = p.ducked ? CRAWL_SPEED : RUN_SPEED;

    if (wish !== 0) {
      const control = stunned ? 0.3 : 1;
      if (Math.sign(p.vx) === wish && Math.abs(p.vx) > maxSpeed) {
        // Don't cancel a knockback just because the player runs the same way.
        p.vx = approach(p.vx, wish * maxSpeed, (p.grounded ? 900 : 120) * dt);
      } else {
        p.vx = approach(p.vx, wish * maxSpeed, (p.grounded ? 1800 : 1100) * control * dt);
      }
      if (!stunned) p.facing = wish > 0 ? 1 : -1;
    } else {
      const friction = p.grounded ? (stunned ? 500 : 1500) : 120;
      p.vx = approach(p.vx, 0, friction * dt);
    }

    p.jumpBuffer = c.jumpPressed ? 0.13 : Math.max(0, p.jumpBuffer - dt);
    p.coyote = p.grounded ? 0.1 : Math.max(0, p.coyote - dt);
    p.dropTimer = Math.max(0, p.dropTimer - dt);

    if (c.down && p.grounded && p.floor === COLLIDE_ONEWAY) {
      p.dropTimer = 0.22;
      p.grounded = false;
      p.coyote = 0;
    } else if (p.jumpBuffer > 0 && p.coyote > 0 && !p.ducked) {
      p.vy = -JUMP_SPEED;
      p.jumpHeld = true;
      p.jumpBuffer = 0;
      p.coyote = 0;
      p.grounded = false;
      this.puff(p.x, p.y, 5, 60);
      this.listeners.jump?.(player.slot);
    }
    if (p.jumpHeld && !c.jump && p.vy < 0) {
      p.vy *= 0.5;
      p.jumpHeld = false;
    }
    if (p.vy >= 0) p.jumpHeld = false;

    p.vy = Math.min(p.vy + GRAVITY * dt, MAX_FALL);
    const wasGrounded = p.grounded;
    const impact = p.vy;
    this.moveX(p, p.vx * dt);
    p.grounded = false;
    const landedOn = this.moveY(p, p.vy * dt);

    if (landedOn !== -1) {
      if (landedOn === CAP && !c.down && impact > 60) {
        p.vy = -Math.max(BOUNCE_SPEED, impact * 0.9);
        p.grounded = false;
        p.jumpHeld = false;
        p.crouch = 0.6;
        this.puff(p.x, p.y, 6, 90);
        this.listeners.bounce?.(player.slot);
      } else {
        p.vy = 0;
        p.grounded = true;
        if (!wasGrounded) {
          p.crouch = Math.min(1, impact / 750);
          if (impact > 320) this.puff(p.x, p.y, Math.round(impact / 80), impact * 0.12);
          if (impact > 120) this.listeners.land?.(impact, player.slot);
        }
      }
    }

    if (p.grounded) {
      p.spin = wrapAngle(p.spin) * Math.exp(-dt * 20);
      p.spinVelocity = 0;
      const stride = Math.floor(p.phase / Math.PI);
      p.phase += (Math.abs(p.vx) * dt) / (p.ducked ? 5 : 8.5);
      if (Math.floor(p.phase / Math.PI) !== stride && Math.abs(p.vx) > 40) this.listeners.footstep?.(player.slot);
    } else if (p.spinVelocity !== 0) {
      p.spin += p.spinVelocity * dt;
      p.spinVelocity *= Math.exp(-dt * 1.5);
    } else {
      p.spin = wrapAngle(p.spin) * Math.exp(-dt * 10);
    }
    p.crouch = Math.max(0, p.crouch - dt * 4);
    p.stepOffset = approach(p.stepOffset, 0, 110 * dt);

    if (p.stun > 0 && !p.grounded && Math.abs(p.spinVelocity) > 1) p.state = 'tumble';
    else if (p.ducked) p.state = 'crawl';
    else if (p.grounded) p.state = Math.abs(p.vx) > 18 ? 'run' : 'idle';
    else p.state = p.vy < 0 ? 'jump' : 'fall';
    this.updatePose(player);
  }

  private updatePose(player: PlayerSlot): void {
    const p = player.body;
    computePose(
      {
        x: p.x,
        y: p.y + p.stepOffset,
        facing: p.facing,
        state: p.state,
        phase: p.phase,
        time: this.clock,
        speed: Math.min(1, Math.abs(p.vx) / (p.ducked ? CRAWL_SPEED : RUN_SPEED)),
        crouch: p.crouch,
        spin: p.spin,
      },
      player.pose,
    );
  }

  /** Solid cells (not one-way) overlapping a hitbox of `height` at (x, feetY). */
  bodyBlocked(x: number, feetY: number, height = PLAYER_H): boolean {
    const t = this.terrain;
    const c0 = Math.floor((x - PLAYER_W / 2) / CELL);
    const c1 = Math.floor((x + PLAYER_W / 2 - 1e-4) / CELL);
    const r0 = Math.floor((feetY - height) / CELL);
    const r1 = Math.floor((feetY - 1e-4) / CELL);
    for (let r = r0; r <= r1; r++) {
      for (let cx = c0; cx <= c1; cx++) if (t.collisionAt(cx, r) === COLLIDE_SOLID) return true;
    }
    return false;
  }

  private unstick(p: Player): void {
    const h = heightOf(p);
    if (!this.bodyBlocked(p.x, p.y, h)) return;
    for (let k = 1; k <= 6; k++) {
      if (!this.bodyBlocked(p.x, p.y - k * CELL, h)) {
        p.y -= k * CELL;
        return;
      }
    }
  }

  private moveX(p: Player, dx: number): void {
    const h = heightOf(p);
    let remaining = dx;
    while (Math.abs(remaining) > 1e-4) {
      const s = Math.max(-1, Math.min(1, remaining));
      remaining -= s;
      const nx = p.x + s;
      if (!this.bodyBlocked(nx, p.y, h)) {
        p.x = nx;
        continue;
      }
      let stepped = false;
      if (p.grounded || p.coyote > 0) {
        for (let k = 1; k <= 2; k++) {
          const ny = p.y - k * CELL;
          if (!this.bodyBlocked(nx, ny, h) && !this.bodyBlocked(p.x, ny, h)) {
            p.x = nx;
            p.y = ny;
            p.stepOffset += k * CELL;
            stepped = true;
            break;
          }
        }
      }
      if (!stepped) {
        p.vx = 0;
        return;
      }
    }
  }

  /** Returns the landed-on material, or -1 if nothing was landed on. */
  private moveY(p: Player, dy: number): number {
    const t = this.terrain;
    let remaining = dy;
    while (Math.abs(remaining) > 1e-4) {
      const s = Math.max(-1, Math.min(1, remaining));
      remaining -= s;
      const ny = p.y + s;
      if (s > 0) {
        const oldRow = Math.floor((p.y - 1e-4) / CELL);
        const newRow = Math.floor((ny - 1e-4) / CELL);
        if (newRow > oldRow) {
          const c0 = Math.floor((p.x - PLAYER_W / 2) / CELL);
          const c1 = Math.floor((p.x + PLAYER_W / 2 - 1e-4) / CELL);
          let floor = COLLIDE_NONE;
          let material = -1;
          for (let cx = c0; cx <= c1; cx++) {
            const kind = t.collisionAt(cx, newRow);
            if (kind === COLLIDE_SOLID || (kind === COLLIDE_ONEWAY && p.dropTimer <= 0)) {
              if (floor !== COLLIDE_SOLID) floor = kind;
              if (material === -1 || t.fgAt(cx, newRow) === CAP) material = t.fgAt(cx, newRow);
            }
          }
          if (floor !== COLLIDE_NONE) {
            p.y = newRow * CELL;
            p.floor = floor;
            return material;
          }
        }
      } else {
        const oldTop = Math.floor((p.y - heightOf(p)) / CELL);
        const newTop = Math.floor((ny - heightOf(p)) / CELL);
        if (newTop < oldTop) {
          const c0 = Math.floor((p.x - PLAYER_W / 2) / CELL);
          const c1 = Math.floor((p.x + PLAYER_W / 2 - 1e-4) / CELL);
          for (let cx = c0; cx <= c1; cx++) {
            if (t.collisionAt(cx, newTop) === COLLIDE_SOLID) {
              p.vy = 0;
              return -1;
            }
          }
        }
      }
      p.y = ny;
    }
    return -1;
  }

  /** Ragdoll joints collide with solids, and with one-way tops from above. */
  readonly collidePoint = (_ox: number, oy: number, x: number, y: number): boolean => {
    const kind = this.terrain.collisionAtPoint(x, y);
    if (kind === COLLIDE_SOLID) return true;
    return kind === COLLIDE_ONEWAY && y > oy && Math.floor(oy / CELL) < Math.floor(y / CELL);
  };

  // Meteors and explosions ─────────────────────────────────────────────────

  private updateMeteors(dt: number): void {
    if (this.meteorsEnabled) {
      this.spawnTimer -= dt;
      while (this.spawnTimer <= 0) {
        const t = this.stormTime();
        this.spawnMeteor();
        if (this.rng.chance(Math.min(0.35, t / 300))) this.spawnMeteor();
        this.spawnTimer += spawnInterval(t) * this.rng.range(0.6, 1.3);
      }
    }

    const t = this.terrain;
    for (let i = this.meteors.length - 1; i >= 0; i--) {
      const m = this.meteors[i];
      m.vy += METEOR_GRAVITY * dt;
      const steps = Math.max(1, Math.ceil((Math.max(Math.abs(m.vx), Math.abs(m.vy)) * dt) / 2));
      let exploded = false;
      for (let s = 0; s < steps && !exploded; s++) {
        m.x += (m.vx * dt) / steps;
        m.y += (m.vy * dt) / steps;
        if (m.x < 0 || m.x >= t.width || m.y >= t.height) break;
        const hitsTerrain = m.y > 0 && t.collisionAtPoint(m.x, m.y) !== COLLIDE_NONE;
        if (hitsTerrain || this.players.some((p) => meteorHits(p, m))) {
          this.explode(m.x, m.y, m.blast);
          exploded = true;
        }
      }
      if (exploded || m.x < -60 || m.x > t.width + 60 || m.y > t.height) {
        this.meteors.splice(i, 1);
        continue;
      }
      if (this.particles.length < MAX_PARTICLES) {
        this.addParticle(m.x, m.y, -m.vx * 0.1 + this.rng.range(-20, 20), -m.vy * 0.1, 0.35, m.size * 0.9,
          SPARK_COLORS[this.rng.int(0, SPARK_COLORS.length - 1)], PARTICLE_SPARK);
        if (this.rng.chance(0.5)) {
          this.addParticle(m.x, m.y, this.rng.range(-15, 15), -10, 0.9, m.size * 0.8, SMOKE_COLOR, PARTICLE_SMOKE);
        }
      }
    }
  }

  spawnMeteor(): void {
    const t = this.stormTime();
    const rng = this.rng;
    const width = this.terrain.width;
    // Early meteors mostly land elsewhere; the storm homes in as it grows.
    const near = rng.chance(Math.min(0.7, 0.25 + t / 60));
    const target = near
      ? this.meteorAim() + (rng.next() - 0.5) * 2 * rng.range(0, 260)
      : rng.range(30, width - 30);
    const x = Math.max(30, Math.min(width - 30, target));
    const vx = rng.range(-110, 110);
    const vy = 180 + Math.min(260, t * 2.4) + rng.range(0, 80);
    const col = Math.max(0, Math.min(this.terrain.cols - 1, Math.floor(x / CELL)));
    const drop = this.terrain.surfaceRow(col) * CELL - METEOR_START_Y;
    const fallTime = drop / vy;
    const meteor: Meteor = {
      x: x - vx * fallTime * 0.85,
      y: METEOR_START_Y,
      vx,
      vy,
      size: 3 + rng.range(0, 2.5) + Math.min(3, t / 50),
      blast: blastRadius(t, rng.next()),
      eta: (-vy + Math.sqrt(vy * vy + 2 * METEOR_GRAVITY * drop)) / METEOR_GRAVITY,
    };
    this.meteors.push(meteor);
    this.listeners.meteor?.(meteor);
  }

  /** Near meteors home in on one living player, chosen at random when there are several. */
  private meteorAim(): number {
    const living = this.players.filter((p) => p.alive);
    if (living.length === 0) return this.focusX();
    const chosen = living.length === 1 ? living[0] : living[this.rng.int(0, living.length - 1)];
    return chosen.body.x;
  }

  explode(x: number, y: number, radius: number, cause: DeathCause = 'meteor'): void {
    const rng = this.rng;
    const result = this.terrain.carve(x, y, radius);
    const debris = Math.min(80, 8 + Math.floor(result.removed / 3));
    for (let i = 0; i < debris; i++) {
      const material = result.sample.length ? result.sample[rng.int(0, result.sample.length - 1)] : 0;
      const a = rng.range(0, Math.PI * 2);
      const speed = rng.range(120, 460);
      this.addParticle(x + Math.cos(a) * radius * 0.4, y + Math.sin(a) * radius * 0.4,
        Math.cos(a) * speed, Math.sin(a) * speed - 160, rng.range(0.8, 1.8), rng.range(1.5, 3.5),
        FG_COLORS[material] ?? DUST_COLOR, PARTICLE_DEBRIS);
    }
    for (let i = 0; i < 30; i++) {
      const a = rng.range(0, Math.PI * 2);
      const speed = rng.range(60, 380);
      this.addParticle(x, y, Math.cos(a) * speed, Math.sin(a) * speed - 80, rng.range(0.25, 0.7),
        rng.range(1.5, 3.5), SPARK_COLORS[rng.int(0, SPARK_COLORS.length - 1)], PARTICLE_SPARK);
    }
    for (let i = 0; i < 12; i++) {
      this.addParticle(x + rng.range(-radius, radius) * 0.5, y + rng.range(-radius, radius) * 0.4,
        rng.range(-40, 40), rng.range(-70, -10), rng.range(1.2, 2.4), radius * rng.range(0.3, 0.6),
        SMOKE_COLOR, PARTICLE_SMOKE);
    }

    const blast: Blast = { x, y, radius, age: 0, proximity: 0, cause };
    for (const p of this.players) {
      const near = blastProximity(blast, bodyX(p), bodyY(p));
      blast.proximity = Math.max(blast.proximity, near);
      p.shake = Math.min(24, p.shake + (2 + radius * 0.3) * (0.2 + 0.8 * near));
    }
    this.blasts.push(blast);

    const wasAlive = this.alive;
    for (const p of this.players) {
      if (p.alive) {
        const d = distanceToBox(x, y, p.body.x, p.body.y, heightOf(p.body));
        if (d < radius * KILL_REACH && p.shield <= 0) this.kill(p, x, y, radius, cause);
        else if (d < radius * KNOCK_REACH) this.knockBack(p.body, x, y, radius, Math.max(d, radius * KILL_REACH));
      } else if (p.ragdoll) {
        this.throwBody(p.ragdoll, x, y, radius * KNOCK_REACH, 650);
      }
    }
    for (const p of this.players) {
      const near = blastProximity(blast, bodyX(p), bodyY(p));
      if (near > 0) this.listeners.blastNear?.(p.slot, near, blast);
    }
    if (wasAlive && !this.alive) this.listeners.runOver?.();
    // Blasts set off any mole nearby, buried or not: chain reactions.
    for (const m of this.moles) {
      if (m.state !== 'armed' && Math.hypot(m.x - x, m.y - y) < radius * 1.5) {
        this.armMole(m, this.moleRng.range(0.06, 0.24));
      }
    }
    this.listeners.explosion?.(blast);
  }

  private knockBack(p: Player, x: number, y: number, radius: number, d: number): void {
    const f = 1 - (d - radius * KILL_REACH) / (radius * (KNOCK_REACH - KILL_REACH));
    let dx = p.x - x;
    let dy = p.y - heightOf(p) / 2 - y - radius * 0.35;
    const len = Math.hypot(dx, dy) || 1;
    dx /= len;
    dy /= len;
    const magnitude = 240 + 560 * f;
    p.vx += dx * magnitude;
    p.vy = Math.min(p.vy, 0) + dy * magnitude - 150 * f;
    p.grounded = false;
    p.coyote = 0;
    p.stun = 0.2 + 0.6 * f;
    p.spinVelocity = f > 0.3 ? (dx >= 0 ? 1 : -1) * (5 + 11 * f) : 0;
  }

  private kill(player: PlayerSlot, x: number, y: number, radius: number, cause: DeathCause): void {
    const p = player.body;
    player.alive = false;
    player.deathCause = cause;
    player.diedAt = this.clock;
    this.deathCause = cause;
    player.ragdoll = new Ragdoll(player.pose, p.vx, p.vy);
    this.throwBody(player.ragdoll, x, y, radius * 3.2, 1050);
    this.listeners.death?.(player.slot, cause);
  }

  /** Fling a ragdoll away from a blast, with a tumble. */
  private throwBody(body: Ragdoll, x: number, y: number, reach: number, power: number): void {
    let dx = body.centerX() - x;
    let dy = body.centerY() - y;
    const d = Math.hypot(dx, dy);
    if (d >= reach) return;
    if (d < 3) {
      dx = this.rng.sign() * 0.4;
      dy = -1;
    }
    // Blasts always lift: bodies go up and away rather than into the ground.
    const n = Math.hypot(dx, dy);
    let ux = dx / n;
    let uy = Math.min(dy / n, -0.45);
    const un = Math.hypot(ux, uy);
    ux /= un;
    uy /= un;
    const f = 1 - d / reach;
    const speed = power * (0.45 + 0.55 * f);
    const spin = (ux >= 0 ? 1 : -1) * this.rng.range(8, 16) * (0.4 + 0.6 * f);
    body.launch(ux * speed, uy * speed, spin);
    body.impulse(x, y, reach, power * 0.25);
  }

  // Moles ──────────────────────────────────────────────────────────────────

  private spawnMole(away: readonly { x: number; y: number }[]): void {
    const spot = findMoleSpawn(this.terrain, this.moleRng, away, 300);
    if (spot) this.moles.push(createMole(spot.x, spot.y, this.moleRng));
  }

  private armMole(m: Mole, fuse: number): void {
    m.state = 'armed';
    m.fuse = fuse;
    m.fuseLength = fuse;
    this.listeners.moleArmed?.(m);
  }

  private updateMoles(dt: number): void {
    const target = this.moleTarget;
    const spots = this.players.map((p) => ({ x: bodyX(p), y: bodyY(p) }));

    this.moleTimer -= dt;
    if (this.moleTimer <= 0) {
      this.moleTimer = 4;
      if (this.moles.length < moleTarget(this.survived)) this.spawnMole(spots);
    }

    const rng = this.moleRng;
    for (let i = this.moles.length - 1; i >= 0; i--) {
      const m = this.moles[i];
      // Each mole hunts whichever living player is closest to it.
      target.active = false;
      let nearest = Infinity;
      for (const p of this.players) {
        if (!p.alive) continue;
        const d = Math.hypot(p.body.x - m.x, bodyY(p) - m.y);
        if (d < nearest) {
          nearest = d;
          target.x = p.body.x;
          target.y = bodyY(p);
          target.active = true;
        }
      }
      if (m.state === 'armed') {
        stepArmed(m, this.terrain, dt, target);
        m.fuse -= dt;
        if (rng.chance(dt * 40)) {
          // The lit tail fizzes.
          this.addParticle(m.x - m.facing * 7, m.y - 3, rng.range(-40, 40), rng.range(-90, -20), rng.range(0.15, 0.35),
            rng.range(1, 2), SPARK_COLORS[rng.int(0, SPARK_COLORS.length - 1)], PARTICLE_SPARK);
        }
        if (m.fuse <= 0) {
          this.moles.splice(i, 1);
          this.explode(m.x, m.y, MOLE_BLAST, 'mole');
        }
        continue;
      }

      const event = stepMole(m, this.terrain, rng, dt, target);
      if (event === 'gone') {
        this.moles.splice(i, 1);
        continue;
      }
      const near = spots.some((spot) => Math.hypot(m.x - spot.x, m.y - spot.y) < 360);
      if (event === 'surface') this.listeners.moleSurface?.(m);
      else if (event === 'dive') this.listeners.moleDive?.(m);
      if (event === 'surface' || event === 'dive') {
        if (near) this.dirtBurst(m.x, m.y + (event === 'dive' ? MOLE_HALF_H : 0), event === 'surface' ? 14 : 7);
      } else if (m.state === 'burrow' && near) {
        // Loose grains trickle where something moves inside the earth.
        m.dust += dt * 6;
        while (m.dust >= 1) {
          m.dust -= 1;
          const material = this.terrain.fgAt(Math.floor(m.x / CELL), Math.floor(m.y / CELL));
          this.addParticle(m.x + rng.range(-4, 4), m.y + rng.range(-3, 3), rng.range(-12, 12), rng.range(-14, 4),
            rng.range(0.25, 0.5), rng.range(1, 1.8), FG_COLORS[material] ?? DUST_COLOR, PARTICLE_DUST);
        }
      }

      if (m.state !== 'walk') continue;
      for (const p of this.players) {
        if (p.alive && distanceToBox(m.x, m.y, p.body.x, p.body.y, heightOf(p.body)) < MOLE_TRIGGER) {
          m.facing = p.body.x >= m.x ? 1 : -1;
          this.armMole(m, MOLE_FUSE);
          break;
        }
      }
    }
  }

  /** Clods thrown out where a mole breaks through a cave wall. */
  private dirtBurst(x: number, y: number, count: number): void {
    const rng = this.moleRng;
    for (let i = 0; i < count; i++) {
      const cx = Math.floor((x + rng.range(-8, 8)) / CELL);
      const cy = Math.floor((y + rng.range(-8, 8)) / CELL);
      const material = this.terrain.fgAt(cx, cy);
      const a = rng.range(-Math.PI, 0);
      const speed = rng.range(40, 150);
      this.addParticle(x, y, Math.cos(a) * speed, Math.sin(a) * speed, rng.range(0.4, 0.9), rng.range(1.2, 2.6),
        FG_COLORS[material] ?? DUST_COLOR, PARTICLE_DEBRIS);
    }
  }

  // Particles ──────────────────────────────────────────────────────────────

  private addParticle(
    x: number, y: number, vx: number, vy: number, life: number, size: number, color: number, kind: number,
  ): void {
    if (this.particles.length >= MAX_PARTICLES) this.particles.shift();
    this.particles.push({ x, y, vx, vy, life, max: life, size, color, kind });
  }

  private puff(x: number, y: number, count: number, speed: number): void {
    for (let i = 0; i < count; i++) {
      const dir = i % 2 === 0 ? 1 : -1;
      this.addParticle(x + dir * 3, y - 1, dir * this.rng.range(0.4, 1) * speed, -this.rng.range(10, 40),
        this.rng.range(0.3, 0.6), this.rng.range(1.5, 3), DUST_COLOR, PARTICLE_DUST);
    }
  }

  private updateParticles(dt: number): void {
    const t = this.terrain;
    const list = this.particles;
    for (let i = list.length - 1; i >= 0; i--) {
      const q = list[i];
      q.life -= dt;
      if (q.life <= 0) {
        list[i] = list[list.length - 1];
        list.pop();
        continue;
      }
      switch (q.kind) {
        case PARTICLE_DEBRIS:
          q.vy += 900 * dt;
          if (t.collisionAtPoint(q.x + q.vx * dt, q.y + q.vy * dt) === COLLIDE_SOLID) {
            q.vx *= 0.3;
            q.vy = 0;
            q.life = Math.min(q.life, 0.5);
          }
          break;
        case PARTICLE_SPARK:
          q.vy += 320 * dt;
          q.vx *= Math.exp(-dt * 2);
          break;
        case PARTICLE_SMOKE:
          q.vy -= 18 * dt;
          q.vx *= Math.exp(-dt * 1.5);
          q.vy *= Math.exp(-dt * 1.5);
          q.size += dt * 6;
          break;
        default:
          q.vx *= Math.exp(-dt * 5);
          q.vy += 60 * dt;
      }
      q.x += q.vx * dt;
      q.y += q.vy * dt;
    }
  }
}

/** Centre of a player: their hitbox while alive, their pelvis once they are a ragdoll. */
export function bodyX(p: PlayerSlot): number {
  return p.ragdoll ? p.ragdoll.pos[PELVIS * 2] : p.body.x;
}

export function bodyY(p: PlayerSlot): number {
  return p.ragdoll ? p.ragdoll.pos[PELVIS * 2 + 1] : p.body.y - heightOf(p.body) / 2;
}

/** Live hitbox height: shorter while crouched or crawling. */
export function heightOf(p: Player): number {
  return p.ducked ? CROUCH_H : PLAYER_H;
}

function meteorHits(p: PlayerSlot, m: Meteor): boolean {
  if (p.alive) return distanceToBox(m.x, m.y, p.body.x, p.body.y, heightOf(p.body)) < m.size;
  return p.ragdoll !== null && Math.hypot(m.x - bodyX(p), m.y - bodyY(p)) < m.size + 6;
}

function approach(value: number, target: number, delta: number): number {
  return value < target ? Math.min(target, value + delta) : Math.max(target, value - delta);
}

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