Back to Wizard Duel
SOURCE / PINNED RELEASE

Made of little things.

Wizard Duel

Release
48b4ed933a32…
Author-recorded commit
3ed8fcbbf62c…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-86005d75eb4

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

src/game/game.ts
// The duel itself: two wizards, an arena, flying spells and silly effects.
// The game draws onto a small 320×180 canvas that is scaled up as pixel art.

import { drawArrow, drawSprite, drawText, textWidth, type Dir } from './pixels.js';
import {
  CHICKEN,
  CHICKEN_PALETTE,
  TOAD,
  TOAD_PALETTE,
  TURTLE,
  HAT_ONLY,
  ICONS,
  WAND,
  wizardPalette,
  wizardRows,
  type WizardColors,
  type WizardPose,
} from './sprites.js';
import { sfx, unlockAudio } from './sound.js';
import { FAMILIARS, FAMILIAR_LIFE, FIRE_SPARK, MAX_FAMILIARS, type Familiar, type FamiliarKind } from './familiars.js';
import {
  RANDOM_DIR,
  RUBBER_CHICKEN,
  SPELLS,
  STATUS_LABELS,
  findSpell,
  spellById,
  type Spell,
  type StatusName,
  wildMagic,
} from './spells.js';

export const VIEW_W = 320;
export const VIEW_H = 180;

const FLOOR = 162;
const CEILING = 22;
const WALL_L = 6;
const WALL_R = VIEW_W - 6;
const PLATFORM_THICKNESS = 6;
const PLATFORMS = [
  { x: 36, y: 124, w: 60 },
  { x: 224, y: 124, w: 60 },
  { x: 130, y: 92, w: 60 },
];

const GRAVITY = 520;
const MAX_HP = 100;
const MIN_COMBO = 3;
const MAX_COMBO = 6;

interface Controls {
  left: string[];
  right: string[];
  up: string[];
  down: string[];
  cast: string[];
}

const PLAYER_SETUP: Array<{ name: string; colors: WizardColors; controls: Controls; startX: number }> = [
  {
    name: 'Zibble',
    colors: { hat: '#8a4bff', hatDark: '#4f2496', band: '#ffd23f', robe: '#7340d8', robeDark: '#45227f' },
    controls: {
      left: ['KeyA', 'Pad1Left'],
      right: ['KeyD', 'Pad1Right'],
      up: ['KeyW', 'Pad1Up'],
      down: ['KeyS', 'Pad1Down'],
      cast: ['Space', 'ShiftLeft', 'KeyQ', 'Pad1Cast'],
    },
    startX: 60,
  },
  {
    name: 'Fumblewick',
    colors: { hat: '#ff7a2f', hatDark: '#a53d10', band: '#5ad1c4', robe: '#e0582b', robeDark: '#8f2f14' },
    controls: {
      left: ['ArrowLeft', 'Pad2Left'],
      right: ['ArrowRight', 'Pad2Right'],
      up: ['ArrowUp', 'Pad2Up'],
      down: ['ArrowDown', 'Pad2Down'],
      cast: ['Enter', 'NumpadEnter', 'ShiftRight', 'ControlRight', 'Numpad0', 'Pad2Cast'],
    },
    startX: 260,
  },
];

/** Keyboard keys that pause or unpause a fight (controllers use Start). */
const PAUSE_KEYS = ['Escape', 'KeyP'];

export const GAME_KEYS = new Set([...PLAYER_SETUP.flatMap((p) => Object.values(p.controls).flat()), ...PAUSE_KEYS]);

export class Wizard {
  x = 0;
  y = 0;
  vx = 0;
  vy = 0;
  facing: 1 | -1 = 1;
  hp = MAX_HP;
  wins = 0;
  onGround = false;
  dropTimer = 0;
  casting = false;
  castLocked = false;
  combo: Dir[] = [];
  cooldown = 0;
  anim = 0;
  flash = 0;
  jumpQueued = false;
  /** Puppet Strings: the wizard whose movement keys control this one. */
  puppeteer: Wizard | null = null;
  /** Which way Dance Fever is shuffling: -1 left, 1 right, 0 on the spot. */
  danceMove = 0;
  readonly status = new Map<StatusName, number>();
  readonly timers = new Map<string, number>();
  readonly palette: Record<string, string>;

  constructor(
    readonly index: number,
    readonly name: string,
    readonly colors: WizardColors,
    readonly controls: Controls,
    readonly startX: number,
  ) {
    this.palette = wizardPalette(colors);
  }

  reset(): void {
    this.x = this.startX;
    this.y = FLOOR - 8;
    this.vx = 0;
    this.vy = 0;
    this.facing = this.index === 0 ? 1 : -1;
    this.hp = MAX_HP;
    this.casting = false;
    this.castLocked = false;
    this.combo = [];
    this.cooldown = 0;
    this.flash = 0;
    this.status.clear();
    this.timers.clear();
    this.puppeteer = null;
  }

  has(name: StatusName): boolean {
    return (this.status.get(name) ?? 0) > 0;
  }

  get halfW(): number {
    if (this.has('pancake')) return 8;
    return this.has('tiny') ? 3 : 4;
  }

  get halfH(): number {
    if (this.has('pancake')) return 3;
    return this.has('tiny') ? 4 : 8;
  }

  /** 1 when gravity pulls down, -1 when it pulls up. */
  gravitySign(): 1 | -1 {
    let sign: 1 | -1 = this.has('revgrav') ? -1 : 1;
    if (this.has('balloon')) sign = sign === 1 ? -1 : 1;
    if (this.has('bubble')) sign = sign === 1 ? -1 : 1;
    return sign;
  }

  canMove(): boolean {
    return !this.has('frozen') && !this.has('stun') && !this.has('disco') && !this.has('tripped');
  }

  canCast(): boolean {
    return this.canMove() && !this.has('chicken') && !this.has('toad');
  }
}

interface Projectile {
  x: number;
  y: number;
  vx: number;
  vy: number;
  spell: Spell;
  owner: Wizard;
  target: Wizard;
  life: number;
  /** Boomerang Curse: seconds until the spell turns around and flies home. */
  returnAfter?: number;
  /** Falling things (rubber chickens) speed up downwards. */
  gravity?: number;
}

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  maxLife: number;
  color: string;
  gravity: number;
  size: number;
}

interface FloatText {
  x: number;
  y: number;
  text: string;
  color: string;
  life: number;
  scale: number;
}

type Phase = 'title' | 'fight' | 'ko';

export class Duel {
  readonly wizards: Wizard[];
  private readonly ctx: CanvasRenderingContext2D;
  private readonly background: HTMLCanvasElement;
  private readonly keys = new Set<string>();
  private projectiles: Projectile[] = [];
  private particles: Particle[] = [];
  private texts: FloatText[] = [];
  private familiars: Familiar[] = [];
  private phase: Phase = 'title';
  private phaseTime = 0;
  private paused = false;
  private time = 0;
  private shakeTime = 0;
  private winner: Wizard | null = null;
  private lastFrame = 0;
  screenShake = true;
  touchOnly = false;
  /** Which players have a game controller plugged in (shown on the title screen). */
  readonly padConnected: [boolean, boolean] = [false, false];
  /** Runs at the start of every frame, before the game updates (used to read controllers). */
  beforeUpdate: (() => void) | null = null;

  constructor(canvas: HTMLCanvasElement) {
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('Canvas 2D is not available');
    this.ctx = ctx;
    canvas.width = VIEW_W;
    canvas.height = VIEW_H;
    this.wizards = PLAYER_SETUP.map(
      (setup, index) => new Wizard(index, setup.name, setup.colors, setup.controls, setup.startX),
    );
    this.background = buildBackground();
    for (const wizard of this.wizards) wizard.reset();
  }

  start(): void {
    const frame = (now: number) => {
      const dt = this.lastFrame ? Math.min(0.05, (now - this.lastFrame) / 1000) : 0;
      this.lastFrame = now;
      this.beforeUpdate?.();
      this.update(dt);
      this.draw();
      requestAnimationFrame(frame);
    };
    requestAnimationFrame(frame);
  }

  // ---------- input ----------

  keyDown(code: string, repeat: boolean): void {
    unlockAudio();
    if (repeat) return;
    if (PAUSE_KEYS.includes(code)) {
      this.pausePressed();
      return;
    }
    if (this.paused) return;
    this.keys.add(code);

    if (this.phase === 'title') {
      if (this.isAnyCastKey(code)) this.startRound();
      return;
    }
    if (this.phase === 'ko') {
      if (this.isAnyCastKey(code) && this.phaseTime > 1.5) this.startRound();
      return;
    }

    for (const wizard of this.wizards) {
      // Start casting right away so a very quick first tap still counts.
      if (wizard.controls.cast.includes(code) && this.canBeginCast(wizard)) this.beginCast(wizard);
      const dir = this.dirForKey(wizard, code);
      if (dir && this.holdingCast(wizard) && !wizard.casting && this.canBeginCast(wizard)) this.beginCast(wizard);
      if (dir && wizard.casting) {
        this.addRune(wizard, dir);
      } else if (dir && this.holdingCast(wizard)) {
        // Holding the cast key means arrows are runes, never movement.
      } else if (dir === 'up') {
        wizard.jumpQueued = true;
        // Puppet Strings: the puppet jumps when its puppeteer does.
        for (const puppet of this.wizards) {
          if (puppet !== wizard && puppet.has('puppet') && puppet.puppeteer === wizard) puppet.jumpQueued = true;
        }
      } else if (dir === 'down' && wizard.onGround && wizard.gravitySign() === 1) {
        wizard.dropTimer = 0.25;
      }
    }
  }

  keyUp(code: string): void {
    this.keys.delete(code);
    if (this.phase !== 'fight') return;
    for (const wizard of this.wizards) {
      if (!wizard.controls.cast.includes(code)) continue;
      if (this.holdingCast(wizard)) continue;
      wizard.castLocked = false;
      if (wizard.casting) this.releaseCast(wizard);
    }
  }

  /** Called when the game loses keyboard focus: forget held keys, drop half-typed spells. */
  releaseAll(): void {
    this.keys.clear();
    for (const wizard of this.wizards) {
      wizard.casting = false;
      wizard.combo = [];
      wizard.castLocked = false;
    }
  }

  pointerStart(): void {
    unlockAudio();
    if (this.phase === 'title' || (this.phase === 'ko' && this.phaseTime > 1.5)) this.startRound();
  }

  /** Start button, Esc or P: pause or unpause a fight. */
  pausePressed(): void {
    unlockAudio();
    if (this.phase !== 'fight') return;
    this.paused = !this.paused;
    // Forget held buttons so nobody keeps running or typing runes when the duel comes back.
    if (this.paused) this.releaseAll();
  }

  /** A player's controller was unplugged: drop their half-typed spell and say so over their wizard. */
  padUnplugged(player: number): void {
    const wizard = this.wizards[player];
    if (!wizard) return;
    wizard.casting = false;
    wizard.combo = [];
    if (this.phase === 'fight') this.addText(wizard.x, wizard.y - 30, 'CONTROLLER UNPLUGGED', '#ff9f5a');
  }

  private isAnyCastKey(code: string): boolean {
    return this.wizards.some((w) => w.controls.cast.includes(code));
  }

  private holdingCast(wizard: Wizard): boolean {
    return wizard.controls.cast.some((code) => this.keys.has(code));
  }

  private held(codes: string[]): boolean {
    return codes.some((code) => this.keys.has(code));
  }

  private dirForKey(wizard: Wizard, code: string): Dir | null {
    const c = wizard.controls;
    if (c.up.includes(code)) return 'up';
    if (c.down.includes(code)) return 'down';
    if (c.left.includes(code)) return 'left';
    if (c.right.includes(code)) return 'right';
    return null;
  }

  // ---------- round flow ----------

  private startRound(): void {
    this.paused = false;
    for (const wizard of this.wizards) {
      wizard.reset();
      // A cast key used to start the round must be let go before casting.
      wizard.castLocked = this.holdingCast(wizard);
    }
    this.projectiles = [];
    this.particles = [];
    this.texts = [];
    this.familiars = [];
    this.winner = null;
    this.phase = 'fight';
    this.phaseTime = 0;
    this.addText(VIEW_W / 2, 70, 'DUEL!', '#ffd23f', 2);
    sfx.cast();
  }

  // ---------- casting ----------

  private canBeginCast(wizard: Wizard): boolean {
    return !wizard.casting && !wizard.castLocked && wizard.cooldown <= 0 && wizard.canCast();
  }

  private beginCast(wizard: Wizard): void {
    wizard.casting = true;
    wizard.combo = [];
  }

  private addRune(wizard: Wizard, pressed: Dir): void {
    let dir = pressed;
    if (wizard.has('drunk') && (dir === 'left' || dir === 'right')) dir = dir === 'left' ? 'right' : 'left';
    if (wizard.has('butter')) dir = RANDOM_DIR();
    if (wizard.combo.length >= MAX_COMBO + 2) return;
    wizard.combo.push(dir);
    sfx.rune(dir, wizard.combo.length);
    this.burst(wizard.x, wizard.y - wizard.halfH - 10, wizard.colors.band, 3, 30);
  }

  private cancelCast(wizard: Wizard, message: string): void {
    if (wizard.combo.length > 0) {
      this.addText(wizard.x, wizard.y - 22, message, '#bbbbbb');
      sfx.fizzle();
    }
    wizard.casting = false;
    wizard.combo = [];
    wizard.castLocked = true;
  }

  private releaseCast(wizard: Wizard): void {
    const combo = wizard.combo;
    wizard.casting = false;
    wizard.combo = [];
    if (combo.length === 0) return;
    wizard.cooldown = wizard.has('sleepy') ? 1.0 : 0.45;

    if (combo.length < MIN_COMBO) {
      this.addText(wizard.x, wizard.y - 22, 'FIZZLE...', '#9a8fb0');
      this.burst(wizard.x + wizard.facing * 6, wizard.y - 4, '#9a8fb0', 8, 40);
      sfx.fizzle();
      return;
    }

    const known = combo.length <= MAX_COMBO ? findSpell(combo) : undefined;
    if (known) {
      this.shout(wizard, `${known.name}!`, known.colors[0]);
      if (known.familiar) this.summon(wizard, known.familiar);
      else this.fire(wizard, known);
      return;
    }

    // Unknown runes: wild magic. The same runes always make the same spell,
    // and always backfire (or not), so players can learn these too.
    const overload = combo.length > MAX_COMBO;
    const { spell, backfire } = wildMagic(combo);
    if (spell.familiar) {
      // Wild summons work, but a backfire sends the pet to the other wizard!
      this.shout(wizard, backfire ? 'BACKFIRE! WRONG WIZARD!' : `WILD MAGIC: ${spell.name}!`, backfire ? '#ff5a5a' : '#ff9cf5');
      this.summon(backfire ? this.wizards[1 - wizard.index] : wizard, spell.familiar);
    } else if (backfire) {
      this.shout(wizard, overload ? 'OVERLOAD! BACKFIRE!' : 'BACKFIRE!', '#ff5a5a');
      this.addText(wizard.x, wizard.y - 30, spell.name, spell.colors[0]);
      this.burst(wizard.x, wizard.y, spell.colors[0], 16, 80);
      this.applySpell(spell, wizard, wizard);
    } else {
      this.shout(wizard, `WILD MAGIC: ${spell.name}!`, '#ff9cf5');
      this.fire(wizard, spell);
    }
  }

  private fire(caster: Wizard, spell: Spell): void {
    const target = this.wizards[1 - caster.index];
    caster.facing = target.x >= caster.x ? 1 : -1;
    const startX = caster.x + caster.facing * 8;
    const startY = caster.y - 2;
    let angle = Math.atan2(target.y - startY, target.x - startX);
    if (caster.has('pie')) angle += (Math.random() - 0.5) * 1.6;
    // Wet Noodle Arms: spells come out really slowly (and last longer to make up for it).
    const floppy = caster.has('noodlearms');
    const speed = spell.speed * (floppy ? 0.4 : 1);
    // Boomerang Curse: this spell turns around and comes back for the caster.
    const cursed = caster.has('boomerang');
    if (cursed) {
      caster.status.delete('boomerang');
      this.addText(caster.x, caster.y - 26, 'BOOMERANG!', '#b07bff');
    }
    this.projectiles.push({
      x: startX,
      y: startY,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      spell,
      owner: caster,
      target: cursed ? caster : target,
      life: floppy ? 7 : cursed ? 4 : 3,
      returnAfter: cursed ? 0.45 : undefined,
    });
    sfx.cast();
    this.burst(startX, startY, spell.colors[1], 10, 60);
  }

  // ---------- familiars ----------

  private summon(owner: Wizard, kind: FamiliarKind): void {
    const mine = this.familiars.filter((f) => f.owner === owner);
    if (mine.length >= MAX_FAMILIARS) this.dismiss(mine[0]);
    const info = FAMILIARS[kind];
    const x = owner.x - owner.facing * 10;
    const y = owner.y - 12 * owner.gravitySign();
    this.familiars.push({ kind, owner, x, y, life: FAMILIAR_LIFE, timer: info.every });
    sfx.summon();
    this.burst(x, y, info.palette[Object.keys(info.palette)[0]], 16, 70);
    this.addText(x, y - 10, info.hello, '#ffffff');
  }

  /** A familiar poofs away in a puff of sparkles. */
  private dismiss(f: Familiar): void {
    f.life = 0;
    this.familiars = this.familiars.filter((other) => other !== f);
    this.burst(f.x, f.y, '#ffffff', 10, 50);
    sfx.pop();
  }

  private familiarOf(w: Wizard, kind: FamiliarKind): Familiar | undefined {
    return this.familiars.find((f) => f.owner === w && f.kind === kind);
  }

  // ---------- spell effects ----------

  heal(wizard: Wizard, amount: number): void {
    wizard.hp = Math.min(MAX_HP, wizard.hp + amount);
    this.addText(wizard.x, wizard.y - 14, `+${amount}`, '#6bff8f');
  }

  private damage(wizard: Wizard, amount: number): void {
    if (amount <= 0 || this.phase !== 'fight') return;
    wizard.hp = Math.max(0, wizard.hp - amount);
    wizard.flash = 0.2;
    this.addText(wizard.x + (Math.random() - 0.5) * 8, wizard.y - 12, `-${amount}`, '#ff5a5a');
    if (wizard.hp <= 0) this.knockOut(wizard);
  }

  private applySpell(spell: Spell, target: Wizard, caster: Wizard): void {
    const oldHalfH = target.halfH;
    if (spell.status) {
      const [name, seconds] = spell.status;
      target.status.set(name, Math.max(target.status.get(name) ?? 0, seconds));
    }
    spell.apply?.(target, caster, this);
    // Shrinking or squashing keeps the feet on the ground.
    target.y += (oldHalfH - target.halfH) * target.gravitySign();
    if (target.casting && !target.canCast()) this.cancelCast(target, 'INTERRUPTED!');
    spell.sound();
    this.addText(target.x, target.y - 26, spell.hitText, spell.colors[0]);
    this.shake(spell.damage >= 10 ? 0.3 : 0.12);
    this.damage(target, spell.damage);
  }

  private explodePotato(w: Wizard): void {
    sfx.boom();
    this.burst(w.x, w.y, '#ff5a1a', 30, 140);
    this.burst(w.x, w.y, '#ffd23f', 20, 100);
    this.addText(w.x, w.y - 26, 'KABOOM!', '#ff5a1a', 2);
    this.shake(0.5);
    w.vy = -220 * w.gravitySign();
    this.damage(w, 15);
  }

  private knockOut(loser: Wizard): void {
    this.phase = 'ko';
    this.phaseTime = 0;
    this.winner = this.wizards[1 - loser.index];
    this.winner.wins += 1;
    this.projectiles = [];
    for (const f of this.familiars.filter((f) => f.owner === loser)) this.dismiss(f);
    for (const wizard of this.wizards) {
      wizard.casting = false;
      wizard.combo = [];
    }
    this.burst(loser.x, loser.y, loser.colors.hat, 40, 120);
    this.shake(0.5);
    sfx.ko();
    sfx.win();
  }

  // ---------- update ----------

  private update(dt: number): void {
    this.time += dt;
    if (this.paused) return;
    this.phaseTime += dt;
    this.shakeTime = Math.max(0, this.shakeTime - dt);

    if (this.phase === 'fight') {
      for (const wizard of this.wizards) this.updateWizard(wizard, dt);
      this.updateFamiliars(dt);
      this.updateProjectiles(dt);
    } else if (this.phase === 'ko' && this.winner) {
      this.winner.anim += dt;
      if (Math.random() < dt * 6) {
        this.burst(this.winner.x + (Math.random() - 0.5) * 40, this.winner.y - 20, randomSparkle(), 6, 50);
      }
    }
    this.updateParticles(dt);
  }

  private tickTimer(wizard: Wizard, key: string, dt: number, every: () => number): boolean {
    const left = (wizard.timers.get(key) ?? every()) - dt;
    if (left <= 0) {
      wizard.timers.set(key, every());
      return true;
    }
    wizard.timers.set(key, left);
    return false;
  }

  private updateWizard(w: Wizard, dt: number): void {
    w.anim += dt;
    w.flash = Math.max(0, w.flash - dt);
    w.cooldown = Math.max(0, w.cooldown - dt);
    w.dropTimer = Math.max(0, w.dropTimer - dt);

    const oldHalfH = w.halfH;
    for (const [name, left] of w.status) {
      const next = left - dt;
      if (next > 0) {
        w.status.set(name, next);
        continue;
      }
      w.status.delete(name);
      w.timers.delete(name);
      if (name === 'balloon') {
        sfx.pop();
        this.burst(w.x, w.y - 20, '#ff6b6b', 10, 60);
        this.addText(w.x, w.y - 26, 'POP!', '#ff6b6b');
      }
      if (name === 'potato') this.explodePotato(w);
      if (name === 'bubble') {
        sfx.pop();
        this.burst(w.x, w.y, '#9fe8ff', 14, 70);
        this.addText(w.x, w.y - 26, 'POP!', '#9fe8ff');
      }
      if (name === 'frozen') this.burst(w.x, w.y, '#9fe8ff', 12, 70);
    }
    w.y += (oldHalfH - w.halfH) * w.gravitySign();

    // Start casting when the cast key is held and the wizard is able to.
    if (this.canBeginCast(w) && this.holdingCast(w)) {
      this.beginCast(w);
    }
    if (w.casting && !w.canCast()) this.cancelCast(w, 'INTERRUPTED!');

    this.updateSillyEffects(w, dt);

    // ---- movement ----
    const sign = w.gravitySign();
    let input = 0;
    if (this.held(w.controls.right)) input += 1;
    if (this.held(w.controls.left)) input -= 1;
    if (w.has('drunk')) input = -input;
    if (!w.canMove() || w.casting) input = 0;
    // Dance Fever takes over the feet, even while casting.
    if (w.has('dance') && w.canMove()) input = w.danceMove;
    // Scratching fleas or floating in a bubble: the feet don't help.
    if (w.has('scratching') || w.has('bubble')) input = 0;
    // Puppet Strings: the other wizard's movement keys walk this one around.
    const puppeteer = w.has('puppet') ? w.puppeteer : null;
    if (puppeteer && puppeteer !== w && w.canMove()) {
      input = puppeteer.casting ? 0 : (this.held(puppeteer.controls.right) ? 1 : 0) - (this.held(puppeteer.controls.left) ? 1 : 0);
    }
    if (input !== 0 && !w.has('disco')) w.facing = input > 0 ? 1 : -1;

    let speed = 78;
    if (w.has('sleepy')) speed *= 0.4;
    if (w.has('tiny')) speed *= 0.75;
    if (w.has('rain')) speed *= 0.8;
    if (w.has('mustache')) speed *= 0.85;
    if (w.has('snail')) speed *= 0.35;
    if (w.has('noodle')) speed *= 0.75;
    if (w.has('turtle')) speed *= 0.5;
    if (w.has('pancake')) speed *= 0.6;
    if (w.has('hyper')) speed *= 2.6;

    let target = input * speed;
    if (w.has('hyper') && w.canMove() && !w.casting && input === 0) target = w.facing * speed;
    if (w.has('drunk')) target += Math.sin(w.anim * 3.1) * 30;
    if (w.has('toad')) {
      // Toads can't walk, they hop.
      if (w.onGround && input !== 0 && w.vy * sign >= 0) {
        w.vx = input * 100;
        w.vy = -170 * sign;
        sfx.croak();
      }
      target = w.onGround ? 0 : w.vx;
    }
    if (w.has('glue') && w.onGround) {
      target = 0;
      w.vx = 0;
    }

    let accel = w.has('slippery') ? 110 : w.onGround ? 900 : 450;
    // Slime: speeding up works, but slowing down or turning is a long slide.
    const slowingDown = Math.abs(target) < Math.abs(w.vx) || Math.sign(target) !== Math.sign(w.vx);
    if (w.has('slimy') && w.onGround && slowingDown) accel = 60;
    // Turtle shell: slides around like a hockey puck.
    if (w.has('turtle')) accel = slowingDown ? 25 : 150;
    if (w.has('bubble')) accel = 60;
    const diff = target - w.vx;
    w.vx += Math.sign(diff) * Math.min(Math.abs(diff), accel * dt);

    if (w.jumpQueued) {
      w.jumpQueued = false;
      if (w.canMove() && !w.casting) {
        if (w.has('chicken')) {
          w.vy = -150 * sign;
          sfx.cluck();
        } else if (w.onGround && !w.has('glue') && !w.has('turtle') && !w.has('bubble') && !w.has('pancake')) {
          let jump = 235;
          if (w.has('tiny')) jump *= 0.8;
          if (w.has('sleepy')) jump *= 0.7;
          if (w.has('snail')) jump *= 0.6;
          w.vy = -jump * sign;
        }
      }
    }

    let gravity = GRAVITY;
    if (w.has('lowgrav')) gravity *= 0.3;
    if (w.has('bighead')) gravity *= 1.35;
    if (w.has('balloon')) gravity *= 0.2;
    if (w.has('bubble')) gravity *= 0.15;
    w.vy += gravity * sign * dt;
    w.vy = Math.max(-420, Math.min(420, w.vy));

    const prevTop = w.y - w.halfH;
    const prevBottom = w.y + w.halfH;
    w.x += w.vx * dt;
    w.y += w.vy * dt;

    this.collide(w, prevTop, prevBottom, sign);
  }

  private collide(w: Wizard, prevTop: number, prevBottom: number, sign: 1 | -1): void {
    const bounce = w.has('bouncy');
    if (w.x - w.halfW < WALL_L) {
      w.x = WALL_L + w.halfW;
      w.vx = bounce || w.has('slippery') || w.has('turtle') ? Math.abs(w.vx) * 0.8 : 0;
    }
    if (w.x + w.halfW > WALL_R) {
      w.x = WALL_R - w.halfW;
      w.vx = bounce || w.has('slippery') || w.has('turtle') ? -Math.abs(w.vx) * 0.8 : 0;
    }

    w.onGround = false;
    const land = (surfaceIsGround: boolean) => {
      if (bounce && Math.abs(w.vy) > 70) {
        w.vy = -w.vy * 0.85;
        if (Math.abs(w.vy) > 120) sfx.boing();
      } else {
        w.vy = 0;
      }
      if (surfaceIsGround) w.onGround = true;
    };

    if (w.y + w.halfH >= FLOOR && w.vy >= 0) {
      w.y = FLOOR - w.halfH;
      land(sign === 1);
    }
    if (w.y - w.halfH <= CEILING && w.vy <= 0) {
      w.y = CEILING + w.halfH;
      land(sign === -1);
    }

    for (const p of PLATFORMS) {
      if (w.x + w.halfW < p.x || w.x - w.halfW > p.x + p.w) continue;
      const top = w.y - w.halfH;
      const bottom = w.y + w.halfH;
      if (sign === 1 && w.vy >= 0 && w.dropTimer <= 0 && prevBottom <= p.y + 1 && bottom >= p.y) {
        w.y = p.y - w.halfH;
        land(true);
      } else if (sign === -1 && w.vy <= 0 && prevTop >= p.y + PLATFORM_THICKNESS - 1 && top <= p.y + PLATFORM_THICKNESS) {
        w.y = p.y + PLATFORM_THICKNESS + w.halfH;
        land(true);
      }
    }
  }

  /** Everything the funny spells do over time: sneezes, hiccups, whistles... */
  private updateSillyEffects(w: Wizard, dt: number): void {
    const sign = w.gravitySign();
    if (w.has('burning')) {
      if (Math.random() < dt * 30) this.spark(w.x + (Math.random() - 0.5) * 8, w.y + 4, Math.random() < 0.5 ? '#ff7a1a' : '#ffd23f', -40);
      if (this.tickTimer(w, 'burning', dt, () => 0.5)) this.damage(w, 1);
    }
    if (w.has('hiccups') && this.tickTimer(w, 'hiccups', dt, () => 0.6 + Math.random() * 0.7)) {
      w.vy = -150 * sign;
      sfx.hic();
      this.addText(w.x, w.y - 20, 'HIC!', '#ff9fd0');
    }
    if (w.has('sneeze') && this.tickTimer(w, 'sneeze', dt, () => 1.1 + Math.random() * 0.6)) {
      w.vx = -w.facing * 260;
      w.vy = -110 * sign;
      sfx.sneeze();
      this.addText(w.x, w.y - 20, 'ACHOO!', '#b8f2a0');
      this.burst(w.x + w.facing * 6, w.y - 6, '#d8ffd0', 10, 90);
    }
    if (w.has('whistle') && this.tickTimer(w, 'whistle', dt, () => 1.2)) {
      sfx.whistle();
      this.addText(w.x, w.y - 20, 'TWEEE!', '#e0f7ff');
      // Whistling nose makes it hard to concentrate: a stray rune sneaks in.
      if (w.casting) this.addRune(w, RANDOM_DIR());
    }
    if (w.has('whistle') && Math.random() < dt * 4) {
      this.spark(w.x + w.facing * 5, w.y - 8, '#e0f7ff', -25, 1.2);
    }
    if (w.has('rain')) {
      if (Math.random() < dt * 25) this.spark(w.x + (Math.random() - 0.5) * 12, w.y - w.halfH - 16 * sign, '#4aa3ff', 0, 0.6, 140 * sign);
      if (this.tickTimer(w, 'rain', dt, () => 0.7)) this.damage(w, 1);
    }
    if (w.has('sleepy') && this.tickTimer(w, 'sleepy', dt, () => 1.4)) {
      sfx.snore();
      this.addText(w.x + 6, w.y - 18, 'Z', '#a8b8ff');
    }
    if (w.has('chicken') && w.onGround && this.tickTimer(w, 'chicken', dt, () => 0.8 + Math.random())) {
      w.vy = -110 * sign;
      sfx.cluck();
    }
    if (w.has('disco')) {
      if (this.tickTimer(w, 'disco', dt, () => 0.25)) w.facing = w.facing === 1 ? -1 : 1;
      if (this.tickTimer(w, 'discoBeat', dt, () => 1)) sfx.disco();
      if (Math.random() < dt * 20) this.spark(w.x + (Math.random() - 0.5) * 30, w.y - Math.random() * 20, randomSparkle(), 0, 0.3, 0);
    }
    if (w.has('hyper') && Math.random() < dt * 30) {
      this.spark(w.x - w.facing * 6, w.y + (Math.random() - 0.5) * 12, '#7dff6b', 0, 0.25, 0);
    }
    if (w.has('lowgrav') && Math.random() < dt * 6) {
      this.spark(w.x + (Math.random() - 0.5) * 12, w.y + 8, '#c9b6ff', -15, 1);
    }
    if (w.has('dance')) {
      if (this.tickTimer(w, 'dance', dt, () => 0.35)) {
        const move = Math.floor(Math.random() * 4);
        w.danceMove = move === 0 ? -1 : move === 1 ? 1 : 0;
        if (move === 2 && w.onGround) w.vy = -170 * w.gravitySign();
        if (move === 3) w.facing = w.facing === 1 ? -1 : 1;
      }
      if (this.tickTimer(w, 'danceBeat', dt, () => 0.9)) sfx.dance();
      if (Math.random() < dt * 5) this.spark(w.x + (Math.random() - 0.5) * 14, w.y - 10, Math.random() < 0.5 ? '#ffd23f' : '#ff4af0', -30, 0.8);
    }
    if (w.has('slimy')) {
      if (Math.random() < dt * 6) this.spark(w.x + (Math.random() - 0.5) * 8, w.y + 2, '#5dff8a', 0, 0.8, 90);
      // A slime trail is left behind while sliding.
      if (w.onGround && Math.abs(w.vx) > 20 && Math.random() < dt * 30) {
        this.spark(w.x, w.y + w.halfH * w.gravitySign() - (w.gravitySign() === 1 ? 1 : 0), '#1f9e4a', 0, 1.2, 0);
      }
    }
    const walking = w.onGround && Math.abs(w.vx) > 25;
    if (w.has('clown') && walking && this.tickTimer(w, 'clown', dt, () => 0.9 + Math.random() * 0.8)) {
      w.status.set('tripped', 0.6);
      w.vx *= 0.3;
      sfx.honk();
      this.addText(w.x, w.y - 20, 'OOF!', '#ff3a3a');
    }
    if (w.has('opera')) {
      if (this.tickTimer(w, 'opera', dt, () => 1.8)) {
        sfx.opera();
        const songs = ['LA LA LAAA!', 'FIGARO!', 'MI MI MIII!', 'OH SOLE MIO!'];
        this.addText(w.x, w.y - 20, songs[Math.floor(Math.random() * songs.length)], '#ff9cf5');
      }
      if (Math.random() < dt * 6) this.spark(w.x + w.facing * 4, w.y - 6, Math.random() < 0.5 ? '#ff9cf5' : '#ffffff', -30, 1);
    }
    if (w.has('squeaky') && walking && this.tickTimer(w, 'squeaky', dt, () => 0.28)) sfx.squeak();
    if (w.has('invisible') && Math.random() < dt * 4) {
      this.spark(w.x + (Math.random() - 0.5) * 10, w.y + (Math.random() - 0.5) * 14, '#e8f0ff', 0, 0.4, 0);
    }
    if (w.has('pogo') && w.onGround && !w.has('turtle')) {
      w.vy = -210 * sign;
      sfx.boing();
    }
    if (w.has('noodle') && walking && this.tickTimer(w, 'noodle', dt, () => 0.6 + Math.random() * 0.6)) {
      w.vx = (Math.random() - 0.5) * 220;
      w.vy = -80 * sign;
      this.addText(w.x, w.y - 20, 'WOBBLE!', '#ffe08a');
    }
    if (w.has('fleas')) {
      if (this.tickTimer(w, 'fleas', dt, () => 1 + Math.random() * 0.7)) {
        w.status.set('scratching', 0.7);
        sfx.scratch();
        this.addText(w.x, w.y - 20, 'ITCHY!', '#e0b080');
      }
      if (Math.random() < dt * 10) this.spark(w.x + (Math.random() - 0.5) * 12, w.y + (Math.random() - 0.5) * 12, '#120a1f', -50, 0.4, 250);
    }
    if (w.has('snail') && walking && Math.random() < dt * 20) {
      this.spark(w.x, w.y + w.halfH * sign - (sign === 1 ? 1 : 0), '#b8f2a0', 0, 1, 0);
    }
    // A bubble pops when it bumps the ceiling.
    if (w.has('bubble') && w.y - w.halfH <= CEILING + 1) w.status.set('bubble', 0.001);
    if (w.has('bees')) {
      if (this.tickTimer(w, 'bees', dt, () => 0.5)) this.damage(w, 1);
      if (this.tickTimer(w, 'beeBuzz', dt, () => 1.2)) {
        sfx.buzz();
        if (Math.random() < 0.5) this.addText(w.x, w.y - 20, 'OW!', '#ffd23f');
      }
    }
    if (w.has('noodlearms') && Math.random() < dt * 4) {
      this.spark(w.x + w.facing * 5, w.y, '#ffe08a', 0, 0.6, 120);
    }
    if (w.has('chickrain') && this.tickTimer(w, 'chickrain', dt, () => 0.55)) {
      // A rubber chicken drops from the sky right above the wizard. Dodge it!
      this.projectiles.push({
        x: w.x + (Math.random() - 0.5) * 30,
        y: CEILING + 3,
        vx: 0,
        vy: 20,
        spell: RUBBER_CHICKEN,
        owner: this.wizards[1 - w.index],
        target: w,
        life: 3,
        gravity: 260,
      });
    }
    if (w.has('potato')) {
      const left = w.status.get('potato') ?? 0;
      w.timers.set('potatoHeld', (w.timers.get('potatoHeld') ?? 0) + dt);
      if (Math.random() < dt * (12 + (3 - left) * 15)) this.spark(w.x + w.facing * 6, w.y - 2, Math.random() < 0.5 ? '#ff5a1a' : '#ffd23f', -40, 0.5);
      if (this.tickTimer(w, 'potatoCount', dt, () => 1)) this.addText(w.x, w.y - 20, `${Math.max(1, Math.ceil(left))}...`, '#ff5a1a');
      // Bump into the other wizard to pass the potato to them!
      const other = this.wizards[1 - w.index];
      const touching = Math.abs(other.x - w.x) < 12 && Math.abs(other.y - w.y) < 14;
      if (touching && !other.has('potato') && (w.timers.get('potatoHeld') ?? 0) > 0.4) {
        other.status.set('potato', Math.max(left, 1) + 0.3);
        other.timers.set('potatoHeld', 0);
        w.status.delete('potato');
        w.timers.delete('potatoCount');
        const away = other.x >= w.x ? 1 : -1;
        w.vx = -away * 150;
        other.vx = away * 150;
        sfx.pop();
        this.addText(other.x, other.y - 26, 'PASSED!', '#ffd23f');
      }
    }
    if (w.has('butter') && Math.random() < dt * 5) {
      this.spark(w.x + w.facing * 5, w.y + 2, '#ffe08a', 0, 0.6, 120);
    }
  }

  private updateFamiliars(dt: number): void {
    for (const owner of this.wizards) {
      const mine = this.familiars.filter((f) => f.owner === owner);
      mine.forEach((f, slot) => {
        // Float behind the wizard in a little line, bobbing up and down.
        const sign = owner.gravitySign();
        const bob = Math.sin(this.time * 4 + slot * 1.7) * 2;
        const tx = owner.x - owner.facing * (11 + slot * 9);
        const ty = owner.y - (10 + bob) * sign;
        const follow = Math.min(1, dt * (7 - slot * 1.5));
        f.x += (tx - f.x) * follow;
        f.y += (ty - f.y) * follow;
        f.x = Math.max(WALL_L + 3, Math.min(WALL_R - 3, f.x));

        f.life -= dt;
        if (f.life <= 0) {
          this.dismiss(f);
          return;
        }
        if (FAMILIARS[f.kind].every <= 0) return;
        f.timer -= dt;
        if (f.timer > 0) return;
        f.timer = FAMILIARS[f.kind].every;
        this.useFamiliarPower(f);
      });
    }
  }

  /** Each familiar's one special thing (the Guard Owl's is in updateProjectiles). */
  private useFamiliarPower(f: Familiar): void {
    const owner = f.owner;
    const enemy = this.wizards[1 - owner.index];
    if (f.kind === 'fairy') {
      if (owner.hp >= MAX_HP) return;
      this.heal(owner, 2);
      this.burst(owner.x, owner.y - 4, '#ff9cf5', 6, 30);
    } else if (f.kind === 'imp') {
      const angle = Math.atan2(enemy.y - f.y, enemy.x - f.x);
      this.projectiles.push({
        x: f.x,
        y: f.y,
        vx: Math.cos(angle) * FIRE_SPARK.speed,
        vy: Math.sin(angle) * FIRE_SPARK.speed,
        spell: FIRE_SPARK,
        owner,
        target: enemy,
        life: 3,
      });
      sfx.fire();
    }
  }

  private updateProjectiles(dt: number): void {
    for (const p of this.projectiles) {
      if (p.gravity) p.vy += p.gravity * dt;
      if (p.returnAfter !== undefined) {
        p.returnAfter -= dt;
        if (p.returnAfter <= 0) {
          // Fly back home to the cursed caster.
          const speed = Math.hypot(p.vx, p.vy);
          const angle = Math.atan2(p.target.y - p.y, p.target.x - p.x);
          p.vx = Math.cos(angle) * speed;
          p.vy = Math.sin(angle) * speed;
        }
      }
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.life -= dt;
      if (Math.random() < dt * 40) this.spark(p.x, p.y, p.spell.colors[Math.random() < 0.5 ? 0 : 1], 0, 0.35, 0);
      const t = p.target;
      const hair = t.has('afro') ? 6 : 0;
      const outbound = p.returnAfter !== undefined && p.returnAfter > 0;
      const owl = p.owner !== t ? this.familiarOf(t, 'owl') : undefined;
      if (owl && !outbound && Math.abs(p.x - t.x) <= t.halfW + 14 && Math.abs(p.y - t.y) <= t.halfH + 14) {
        // The Guard Owl swoops in and catches the spell, then flies off with it.
        p.life = -1;
        owl.x = p.x;
        owl.y = p.y;
        this.addText(p.x, p.y - 10, 'CAUGHT!', '#d9a066');
        this.burst(p.x, p.y, p.spell.colors[0], 12, 70);
        this.dismiss(owl);
      } else if (!outbound && Math.abs(p.x - t.x) <= t.halfW + 3 + hair && Math.abs(p.y - t.y) <= t.halfH + 3 + hair) {
        p.life = -1;
        this.burst(p.x, p.y, p.spell.colors[0], 14, 90);
        this.applySpell(p.spell, t, p.owner);
        if (this.phase !== 'fight') return;
      } else if (p.x < 0 || p.x > VIEW_W || p.y < CEILING - 8 || p.y > FLOOR + 6) {
        p.life = -1;
        this.burst(p.x, p.y, p.spell.colors[0], 6, 40);
      }
    }

    // Spells that meet in the middle cancel each other out with a big CLASH.
    for (const a of this.projectiles) {
      for (const b of this.projectiles) {
        if (a === b || a.owner === b.owner || a.life < 0 || b.life < 0) continue;
        if (Math.abs(a.x - b.x) < 7 && Math.abs(a.y - b.y) < 7) {
          a.life = -1;
          b.life = -1;
          this.burst((a.x + b.x) / 2, (a.y + b.y) / 2, '#ffffff', 24, 120);
          this.addText((a.x + b.x) / 2, a.y - 10, 'CLASH!', '#ffffff', 2);
          this.shake(0.25);
          sfx.clash();
        }
      }
    }
    this.projectiles = this.projectiles.filter((p) => p.life > 0);
  }

  private updateParticles(dt: number): void {
    for (const p of this.particles) {
      p.vy += p.gravity * dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.life -= dt;
    }
    this.particles = this.particles.filter((p) => p.life > 0);
    for (const t of this.texts) {
      t.y -= 14 * dt;
      t.life -= dt;
    }
    this.texts = this.texts.filter((t) => t.life > 0);
  }

  // ---------- effects helpers ----------

  private spark(x: number, y: number, color: string, vy = 0, life = 0.5, gravity = 0): void {
    this.particles.push({ x, y, vx: (Math.random() - 0.5) * 10, vy, life, maxLife: life, color, gravity, size: 1 });
  }

  private burst(x: number, y: number, color: string, count: number, speed: number): void {
    for (let i = 0; i < count; i++) {
      const angle = Math.random() * Math.PI * 2;
      const s = speed * (0.3 + Math.random() * 0.7);
      const life = 0.3 + Math.random() * 0.4;
      this.particles.push({
        x, y, vx: Math.cos(angle) * s, vy: Math.sin(angle) * s, life, maxLife: life, color,
        gravity: 120, size: Math.random() < 0.3 ? 2 : 1,
      });
    }
  }

  private addText(x: number, y: number, text: string, color: string, scale = 1): void {
    this.texts.push({ x, y, text, color, life: 1.1, scale });
  }

  private shout(wizard: Wizard, text: string, color: string): void {
    this.addText(Math.max(textWidth(text) / 2 + 2, Math.min(VIEW_W - textWidth(text) / 2 - 2, wizard.x)), wizard.y - 34, text, color);
  }

  private shake(seconds: number): void {
    this.shakeTime = Math.max(this.shakeTime, seconds);
  }

  // ---------- drawing ----------

  private draw(): void {
    const ctx = this.ctx;
    ctx.save();
    if (this.screenShake && this.shakeTime > 0) {
      ctx.translate(Math.round((Math.random() - 0.5) * 4), Math.round((Math.random() - 0.5) * 4));
    }
    ctx.drawImage(this.background, 0, 0);
    this.drawTorches();
    for (const wizard of this.wizards) this.drawWizard(wizard);
    for (const f of this.familiars) this.drawFamiliar(f);
    for (const p of this.projectiles) this.drawProjectile(p);
    for (const p of this.particles) {
      ctx.globalAlpha = Math.min(1, p.life / p.maxLife + 0.2);
      ctx.fillStyle = p.color;
      ctx.fillRect(Math.round(p.x), Math.round(p.y), p.size, p.size);
    }
    ctx.globalAlpha = 1;
    for (const t of this.texts) {
      if (t.life < 0.25 && Math.floor(t.life * 20) % 2 === 0) continue;
      drawText(ctx, t.text, clampText(t.x, t.text, t.scale), t.y, t.color, { align: 'center', scale: t.scale });
    }
    ctx.restore();

    this.drawHud();
    if (this.phase === 'title') this.drawTitle();
    if (this.phase === 'ko') this.drawWinner();
    if (this.paused) this.drawPaused();
  }

  private drawPaused(): void {
    const ctx = this.ctx;
    ctx.fillStyle = 'rgba(18,10,31,0.7)';
    ctx.fillRect(0, 0, VIEW_W, VIEW_H);
    drawText(ctx, 'PAUSED', VIEW_W / 2, 62, '#ffd23f', { align: 'center', scale: 3 });
    if (Math.floor(this.time * 2) % 2 === 0) {
      drawText(ctx, 'PRESS START, ESC OR P TO KEEP DUELING', VIEW_W / 2, 90, '#ffffff', { align: 'center' });
    }
  }

  private drawTorches(): void {
    const ctx = this.ctx;
    for (const x of [14, VIEW_W - 17]) {
      const flicker = Math.sin(this.time * 13 + x) > 0 ? 1 : 0;
      ctx.fillStyle = '#ff7a1a';
      ctx.fillRect(x, 88 - flicker, 3, 3 + flicker);
      ctx.fillStyle = '#ffd23f';
      ctx.fillRect(x + 1, 89, 1, 2);
    }
  }

  private drawWizard(w: Wizard): void {
    const ctx = this.ctx;
    // Only Reverse Gravity turns the wizard upside down; a balloon lifts them upright.
    const sign = w.has('revgrav') ? -1 : 1;
    const flipY = sign === -1;

    if (this.phase === 'ko' && w !== this.winner) {
      // The loser is gone in a puff of smoke... only the hat remains.
      drawSprite(ctx, HAT_ONLY, w.palette, w.x - 5, FLOOR - HAT_ONLY.length);
      return;
    }

    // Glue puddle under the feet.
    if (w.has('glue')) {
      ctx.fillStyle = '#8fb339';
      const gy = sign === 1 ? w.y + w.halfH - 1 : w.y - w.halfH - 1;
      ctx.fillRect(Math.round(w.x - 8), Math.round(gy), 16, 2);
      ctx.fillStyle = '#c8e86b';
      ctx.fillRect(Math.round(w.x - 6), Math.round(gy), 12, 1);
    }

    // Casting aura.
    if (w.casting) {
      const r = 11 + Math.round(Math.sin(this.time * 12));
      ctx.fillStyle = w.colors.band;
      for (let i = 0; i < 12; i++) {
        const a = this.time * 3 + (i / 12) * Math.PI * 2;
        ctx.fillRect(Math.round(w.x + Math.cos(a) * r), Math.round(w.y + Math.sin(a) * r), 1, 1);
      }
    }

    // Balloon tied above the head.
    if (w.has('balloon')) {
      const top = sign === 1 ? w.y - w.halfH - 22 : w.y + w.halfH + 8;
      drawSprite(ctx, ICONS.balloon, { a: '#ff6b6b', b: '#ffffff', w: '#ffffff' }, w.x - 7, top, { scale: 2, flipY });
    }

    const moving = Math.abs(w.vx) > 10 && w.onGround;
    let pose: WizardPose = 'idle';
    if (w.casting) pose = 'cast';
    else if (w.has('scratching')) pose = Math.floor(w.anim * 12) % 2 === 0 ? 'cast' : 'idle';
    else if (w.has('dance')) pose = Math.floor(w.anim * 6) % 2 === 0 ? 'cast' : moving ? 'walkA' : 'idle';
    else if (moving) pose = Math.floor(w.anim * 8) % 2 === 0 ? 'walkA' : 'walkB';
    else if (w.has('disco')) pose = Math.floor(w.anim * 4) % 2 === 0 ? 'cast' : 'idle';

    const flash = w.flash > 0 && Math.floor(w.flash * 30) % 2 === 0;
    const palette = flash || w.has('stun') && Math.floor(this.time * 20) % 2 === 0 ? whitePalette(w.palette) : w.palette;
    const flipX = w.facing === -1;

    if (w.has('toad')) {
      const top = sign === 1 ? w.y + w.halfH - TOAD.length : w.y - w.halfH;
      drawSprite(ctx, TOAD, flash ? whitePalette(TOAD_PALETTE) : TOAD_PALETTE, w.x - 5, top, { flipX, flipY });
    } else if (w.has('chicken')) {
      const top = sign === 1 ? w.y + w.halfH - CHICKEN.length : w.y - w.halfH;
      drawSprite(ctx, CHICKEN, CHICKEN_PALETTE, w.x - 5, top, { flipX, flipY });
    } else if (w.has('tiny')) {
      const rows = shrink(wizardRows(pose));
      const top = sign === 1 ? w.y + w.halfH - rows.length : w.y - w.halfH;
      drawSprite(ctx, rows, palette, w.x - 2, top, { flipX, flipY });
    } else if (w.has('pancake')) {
      // Squashed flat: every third row of the sprite, twice as wide.
      const rows = wizardRows('idle')
        .filter((_, r) => r % 3 === 0)
        .map((row) => row.replace(/./g, (c) => c + c));
      const top = sign === 1 ? w.y + w.halfH - rows.length : w.y - w.halfH;
      drawSprite(ctx, rows, palette, w.x - 10, top, { flipX, flipY });
    } else if (w.has('turtle')) {
      const top = sign === 1 ? w.y + w.halfH - TURTLE.length : w.y - w.halfH;
      drawSprite(ctx, TURTLE, { ...w.palette, G: '#3fae4a', g: '#1f6b2a', y: '#e8d27a' }, w.x - 7, top, { flipX, flipY });
    } else if (w.has('tripped')) {
      // Face-plant: the whole wizard lies flat on the floor.
      ctx.save();
      ctx.translate(Math.round(w.x), Math.round(sign === 1 ? w.y + w.halfH - 5 : w.y - w.halfH + 5));
      ctx.rotate((flipX ? -1 : 1) * sign * (Math.PI / 2));
      drawSprite(ctx, wizardRows('idle'), palette, -5, sign === 1 ? -16 : 0, { flipX, flipY });
      ctx.restore();
      if (w.has('clown')) this.drawClownShoes(w, sign, w.x - w.facing * 7);
    } else {
      const rows = wizardRows(pose);
      const bob = pose === 'idle' && Math.floor(w.anim * 2) % 2 === 0 ? 1 : 0;
      const head = rows.slice(0, 10);
      const body = rows.slice(10);
      const bodyTop = sign === 1 ? w.y + w.halfH - body.length : w.y - w.halfH;
      const big = w.has('bighead');
      const headScale = big ? 2 : 1;
      const headH = head.length * headScale;
      const headTop = sign === 1 ? bodyTop - headH + bob : bodyTop + body.length - bob;
      const headX = w.x - 5 - (big ? 5 : 0);
      // Paint a rectangle in head-pixel units, following the head's flips and size.
      const headPx = (col: number, row: number, wide: number, tall: number, color: string) => {
        const c = flipX ? 10 - col - wide : col;
        const r = flipY ? 10 - row - tall : row;
        ctx.fillStyle = color;
        ctx.fillRect(Math.round(headX + c * headScale), Math.round(headTop + r * headScale), wide * headScale, tall * headScale);
      };
      if (w.has('afro')) {
        // A huge round puff of hair behind the head (the hat sits on top).
        const rows = [[2, 6], [0, 10], [-2, 14], [-3, 16], [-4, 18], [-4, 18], [-4, 18], [-3, 16], [-2, 14], [0, 10]];
        rows.forEach(([col, wide], i) => headPx(col, i, wide, 1, i % 3 === 0 ? '#5a3a1a' : '#3b2414'));
      }
      const invisible = w.has('invisible');
      // Spaghetti legs wobble the body from side to side.
      const jiggle = w.has('noodle') ? Math.round(Math.sin(w.anim * 14)) : 0;
      if (!invisible) drawSprite(ctx, body, palette, w.x - 5 + jiggle, bodyTop, { flipX, flipY });
      drawSprite(ctx, invisible ? head.slice(0, 7) : head, palette, headX, flipY && invisible ? headTop + 3 * headScale : headTop, {
        flipX,
        flipY,
        scale: headScale,
      });
      if (w.has('mustache') && !invisible) {
        headPx(1, 8, 8, 2, '#5a3a1a');
        headPx(3, 8, 4, 1, '#8a5a2b');
        // The two ends droop and flare out all the way down to the floor.
        const feetY = sign === 1 ? w.y + w.halfH : w.y - w.halfH;
        const startY = flipY ? headTop : headTop + 10 * headScale;
        const drop = Math.abs(feetY - startY);
        for (const side of [-1, 1]) {
          const baseX = w.x + side * 4 * headScale;
          for (let d = 0; d < drop; d++) {
            const flare = Math.round(Math.sqrt(d / drop) * 6) * side;
            ctx.fillStyle = d % 4 === 0 ? '#8a5a2b' : '#5a3a1a';
            ctx.fillRect(Math.round(baseX + flare), Math.round(startY + d * sign), 2, 1);
          }
        }
      }
      if (w.has('clown')) this.drawClownShoes(w, sign, w.x);
      if (w.has('snail') && !invisible) {
        drawSprite(ctx, ICONS.shell, { a: '#8a5a2b', b: '#d9a066' }, w.x - w.facing * 5 - 3, w.y - 3, { flipX, flipY });
      }
      if (w.has('pogo')) {
        // The pogo stick: a pole held in front, with a spring under the feet.
        const feetY = sign === 1 ? w.y + w.halfH : w.y - w.halfH;
        const poleX = Math.round(w.x + w.facing * 4);
        ctx.fillStyle = '#c0c0d0';
        ctx.fillRect(poleX, Math.round(Math.min(w.y - 2 * sign, feetY + 4 * sign)), 1, 12);
        ctx.fillStyle = '#ff5fa2';
        ctx.fillRect(poleX - 2, Math.round(w.y - 2 * sign), 5, 1);
        for (let i = 0; i < 4; i++) ctx.fillRect(poleX - 1 + (i % 2), Math.round(feetY + i * sign), 2, 1);
      }
      if (pose === 'cast') {
        const wandX = flipX ? w.x - 6 - 3 : w.x + 5;
        const wandY = sign === 1 ? bodyTop - 3 : bodyTop + body.length;
        drawSprite(ctx, WAND, palette, wandX, wandY, { flipX, flipY });
      }
      if (w.has('pie')) {
        const faceY = sign === 1 ? headTop + 7 * headScale : headTop + 1 * headScale;
        ctx.fillStyle = '#fff4e0';
        ctx.fillRect(Math.round(w.x - 3 * headScale), Math.round(faceY), 7 * headScale, 3 * headScale);
        ctx.fillStyle = '#ff9fc8';
        ctx.fillRect(Math.round(w.x), Math.round(faceY), headScale, headScale);
      }
    }

    if (w.has('puppet')) {
      // Strings from the head up to a wooden cross near the ceiling.
      const headY = Math.round(w.y - w.halfH);
      const crossY = CEILING + 4;
      ctx.fillStyle = '#d8d8e8';
      for (const dx of [-3, 3]) ctx.fillRect(Math.round(w.x + dx), crossY, 1, Math.max(0, headY - crossY));
      ctx.fillStyle = '#8a5a2b';
      ctx.fillRect(Math.round(w.x - 5), crossY - 1, 11, 2);
      ctx.fillRect(Math.round(w.x), crossY - 3, 1, 6);
    }

    if (w.has('potato')) {
      // The hot potato glows redder as it gets closer to exploding.
      const left = w.status.get('potato') ?? 0;
      const blink = left < 1 && Math.floor(this.time * 12) % 2 === 0;
      ctx.fillStyle = blink ? '#ffd23f' : left > 2 ? '#c8894a' : left > 1 ? '#e06a2a' : '#ff3a1a';
      const px = Math.round(w.x + w.facing * 6 - 2);
      const py = Math.round(w.y - 3);
      ctx.fillRect(px, py + 1, 5, 2);
      ctx.fillRect(px + 1, py, 3, 4);
    }

    if (w.has('bees')) {
      // A buzzing swarm circling the head.
      for (let i = 0; i < 7; i++) {
        const a = this.time * (4 + i * 0.7) + i * 2.1;
        const bx = Math.round(w.x + Math.cos(a) * (6 + (i % 3) * 3));
        const by = Math.round(w.y - 6 + Math.sin(a * 1.3) * 5);
        ctx.fillStyle = '#ffd23f';
        ctx.fillRect(bx, by, 1, 1);
        ctx.fillStyle = '#120a1f';
        ctx.fillRect(bx + 1, by, 1, 1);
      }
    }

    if (w.has('bubble')) {
      // A wobbly soap bubble around the whole wizard.
      const r = 12 + Math.round(Math.sin(this.time * 6) * 0.6);
      ctx.globalAlpha = 0.15;
      ctx.fillStyle = '#9fe8ff';
      ctx.fillRect(Math.round(w.x - r + 3), Math.round(w.y - r + 3), r * 2 - 6, r * 2 - 6);
      ctx.globalAlpha = 0.9;
      for (let i = 0; i < 56; i++) {
        const a = (i / 56) * Math.PI * 2;
        ctx.fillStyle = i % 9 === 0 ? '#ff9cf5' : '#c8f4ff';
        ctx.fillRect(Math.round(w.x + Math.cos(a) * r), Math.round(w.y + Math.sin(a) * r), 1, 1);
      }
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(Math.round(w.x - 6), Math.round(w.y - 7), 2, 1);
      ctx.fillRect(Math.round(w.x - 7), Math.round(w.y - 6), 1, 2);
      ctx.globalAlpha = 1;
    }

    if (w.has('frozen')) {
      ctx.globalAlpha = 0.55;
      ctx.fillStyle = '#9fe8ff';
      ctx.fillRect(Math.round(w.x - 8), Math.round(w.y - w.halfH - 4), 16, w.halfH * 2 + 5);
      ctx.globalAlpha = 1;
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(Math.round(w.x - 6), Math.round(w.y - w.halfH - 2), 1, 6);
      ctx.fillRect(Math.round(w.x - 5), Math.round(w.y - w.halfH - 2), 3, 1);
    }

    if (w.has('rain')) {
      const cloudY = sign === 1 ? w.y - w.halfH - 22 : w.y + w.halfH + 12;
      drawSprite(ctx, ICONS.cloud.slice(0, 4), { a: '#9aa7c7' }, w.x - 7, cloudY, { scale: 2, flipY });
    }

    if (w.has('drunk') || w.has('stun')) {
      const headY = sign === 1 ? w.y - w.halfH - 6 : w.y + w.halfH + 5;
      for (let i = 0; i < 3; i++) {
        const a = this.time * 5 + (i / 3) * Math.PI * 2;
        ctx.fillStyle = '#fff36b';
        ctx.fillRect(Math.round(w.x + Math.cos(a) * 7), Math.round(headY + Math.sin(a) * 2), 1, 1);
      }
    }

    // Runes typed so far float above the head.
    if (w.casting && w.combo.length > 0) {
      const overload = w.combo.length > MAX_COMBO;
      const total = w.combo.length * 8 - 1;
      const runeY = sign === 1 ? w.y - w.halfH - (w.has('bighead') ? 34 : 24) : w.y + w.halfH + 18;
      let left = w.x - total / 2;
      left = Math.max(2, Math.min(VIEW_W - total - 2, left));
      w.combo.forEach((dir, i) => {
        drawArrow(ctx, dir, left + i * 8 + 1, runeY, overload ? '#ff5a5a' : w.colors.band);
      });
    }
  }

  private drawClownShoes(w: Wizard, sign: 1 | -1, x: number): void {
    const ctx = this.ctx;
    const y = Math.round(sign === 1 ? w.y + w.halfH - 3 : w.y - w.halfH);
    const toe = w.facing;
    ctx.fillStyle = '#ff3a3a';
    ctx.fillRect(Math.round(x - 4 + (toe === 1 ? 0 : -6)), y, 14, 3);
    ctx.fillStyle = '#ffd23f';
    ctx.fillRect(Math.round(x + toe * 7), sign === 1 ? y : y + 2, 2, 1);
  }

  private drawFamiliar(f: Familiar): void {
    // Blink during the last 2 seconds so players know it's about to leave.
    if (f.life < 2 && Math.floor(f.life * 8) % 2 === 0) return;
    const info = FAMILIARS[f.kind];
    drawSprite(this.ctx, info.sprite, info.palette, f.x - 3, f.y - 3, { flipX: f.owner.facing === -1 });
  }

  private drawProjectile(p: Projectile): void {
    const icon = ICONS[p.spell.icon] ?? ICONS.orb;
    const wobble = Math.round(Math.sin(this.time * 20) * 0.6);
    drawSprite(
      this.ctx,
      icon,
      { a: p.spell.colors[0], b: p.spell.colors[1], w: '#ffffff' },
      p.x - 3,
      p.y - 3 + wobble,
      { flipX: p.vx < 0 },
    );
  }

  private drawHud(): void {
    const ctx = this.ctx;
    ctx.fillStyle = '#120a1f';
    ctx.fillRect(0, 0, VIEW_W, 20);
    ctx.fillStyle = '#2b1c45';
    ctx.fillRect(0, 19, VIEW_W, 1);

    this.wizards.forEach((w, i) => {
      const left = i === 0;
      const barX = left ? 4 : VIEW_W - 4 - 100;
      drawText(ctx, w.name, left ? 4 : VIEW_W - 4, 2, w.colors.hat, { align: left ? 'left' : 'right' });
      for (let s = 0; s < w.wins; s++) {
        const starX = left ? 4 + textWidth(w.name) + 4 + s * 5 : VIEW_W - 4 - textWidth(w.name) - 8 - s * 5;
        ctx.fillStyle = '#ffd23f';
        ctx.fillRect(starX + 1, 2, 1, 5);
        ctx.fillRect(starX, 3, 3, 3);
      }
      ctx.fillStyle = '#3a2350';
      ctx.fillRect(barX, 9, 100, 4);
      const hpW = Math.round(w.hp);
      ctx.fillStyle = w.hp > 30 ? w.colors.hat : '#ff3a3a';
      ctx.fillRect(left ? barX : barX + 100 - hpW, 9, hpW, 4);
      ctx.fillStyle = 'rgba(255,255,255,0.35)';
      ctx.fillRect(left ? barX : barX + 100 - hpW, 9, hpW, 1);

      const labels = [...w.status.keys()].map((s) => STATUS_LABELS[s]);
      const line = labels.slice(0, 3).join(' ') + (labels.length > 3 ? ' +' : '');
      drawText(ctx, line, left ? 4 : VIEW_W - 4, 14, '#d8c8ff', { align: left ? 'left' : 'right', shadow: null });
    });
  }

  private drawTitle(): void {
    const ctx = this.ctx;
    ctx.fillStyle = 'rgba(18,10,31,0.86)';
    ctx.fillRect(0, 0, VIEW_W, VIEW_H);
    const bounce = Math.round(Math.sin(this.time * 3) * 1.5);
    drawText(ctx, 'WIZARD DUEL', VIEW_W / 2, 8 + bounce, '#ffd23f', { align: 'center', scale: 3 });
    drawText(ctx, 'CAST SILLY SPELLS AT YOUR FRIEND!', VIEW_W / 2, 28, '#d8c8ff', { align: 'center' });

    const [p1, p2] = this.wizards;
    const col = (w: Wizard, x: number, keys: string[]) => {
      drawText(ctx, w.name, x, 40, w.colors.hat, { scale: 2 });
      keys.forEach((line, i) => drawText(ctx, line, x, 54 + i * 8, '#ffffff'));
    };
    col(p1, 14, ['MOVE: A D   JUMP: W', 'HOLD SPACE, TAP W A S D', 'LET GO OF SPACE TO CAST!']);
    col(p2, 170, ['MOVE: ARROWS  JUMP: UP', 'HOLD ENTER, TAP ARROWS', 'LET GO OF ENTER TO CAST!']);
    [p1, p2].forEach((_, i) => {
      const ready = this.padConnected[i];
      const line = ready ? 'PAD READY! HOLD A TRIGGER' : 'OR PLUG IN A CONTROLLER';
      drawText(ctx, line, i === 0 ? 14 : 170, 77, ready ? '#7dff9a' : '#9a8fb0');
    });

    drawText(ctx, 'SPELL SCROLL', VIEW_W / 2, 84, '#ffd23f', { align: 'center' });
    const starters = ['hiccups', 'banana', 'knockback', 'fireball'].map(spellById);
    starters.forEach((spell, i) => {
      const x = i % 2 === 0 ? 30 : 176;
      const y = 94 + Math.floor(i / 2) * 10;
      drawText(ctx, spell.name, x, y + 1, spell.colors[0]);
      spell.combo!.forEach((dir, j) => drawArrow(ctx, dir, x + 62 + j * 8, y, '#ffffff', '#3a2350'));
    });
    drawText(ctx, `${SPELLS.length} SPELLS HIDE IN THE RUNES (3 TO 6 TAPS).`, VIEW_W / 2, 120, '#d8c8ff', { align: 'center' });
    drawText(ctx, 'SHORT COMBOS ARE SILLY. LONG COMBOS ARE STRONG!', VIEW_W / 2, 129, '#ffd23f', { align: 'center' });
    drawText(ctx, 'UNKNOWN RUNES MAKE WILD MAGIC - IT MIGHT BACKFIRE!', VIEW_W / 2, 138, '#ff9cf5', { align: 'center' });

    if (this.touchOnly) {
      drawText(ctx, 'NEEDS A KEYBOARD OR GAME CONTROLLERS', VIEW_W / 2, 148, '#ff9f5a', { align: 'center' });
    }
    if (Math.floor(this.time * 2) % 2 === 0) {
      drawText(ctx, 'PRESS SPACE, ENTER OR A TO DUEL!', VIEW_W / 2, 160, '#ffffff', { align: 'center' });
    }
  }

  private drawWinner(): void {
    if (!this.winner) return;
    const ctx = this.ctx;
    ctx.fillStyle = 'rgba(18,10,31,0.55)';
    ctx.fillRect(0, 48, VIEW_W, 44);
    drawText(ctx, `${this.winner.name} WINS!`, VIEW_W / 2, 56, this.winner.colors.hat, { align: 'center', scale: 3 });
    if (this.phaseTime > 1.5 && Math.floor(this.time * 2) % 2 === 0) {
      drawText(ctx, 'PRESS SPACE, ENTER OR A FOR A REMATCH', VIEW_W / 2, 80, '#ffffff', { align: 'center' });
    }
  }
}

// ---------- small helpers ----------

function clampText(x: number, text: string, scale: number): number {
  const half = textWidth(text.toUpperCase(), scale) / 2;
  return Math.max(half + 2, Math.min(VIEW_W - half - 2, x));
}

function randomSparkle(): string {
  const colors = ['#ffd23f', '#ff9cf5', '#7fe0ff', '#7dff6b', '#ffffff'];
  return colors[Math.floor(Math.random() * colors.length)];
}

/** Halve a sprite by keeping every other pixel (used by the Shrink Ray). */
function shrink(rows: string[]): string[] {
  return rows
    .filter((_, r) => r % 2 === 0)
    .map((row) => row.split('').filter((_, c) => c % 2 === 0).join(''));
}

const whiteCache = new WeakMap<Record<string, string>, Record<string, string>>();
function whitePalette(palette: Record<string, string>): Record<string, string> {
  let white = whiteCache.get(palette);
  if (!white) {
    white = Object.fromEntries(Object.keys(palette).map((k) => [k, '#ffffff']));
    whiteCache.set(palette, white);
  }
  return white;
}

/** Paint the castle arena once: night sky, moon, stars, stone floor and platforms. */
function buildBackground(): HTMLCanvasElement {
  const canvas = document.createElement('canvas');
  canvas.width = VIEW_W;
  canvas.height = VIEW_H;
  const ctx = canvas.getContext('2d')!;

  const sky = ['#1a0f33', '#21123f', '#2a174d', '#33205a', '#3d2866'];
  const band = Math.ceil(FLOOR / sky.length);
  sky.forEach((color, i) => {
    ctx.fillStyle = color;
    ctx.fillRect(0, i * band, VIEW_W, band);
  });

  let seed = 7;
  const rand = () => {
    seed = (seed * 16807) % 2147483647;
    return seed / 2147483647;
  };
  for (let i = 0; i < 60; i++) {
    ctx.fillStyle = rand() < 0.2 ? '#ffd23f' : '#c8b8ff';
    ctx.fillRect(Math.floor(rand() * VIEW_W), 20 + Math.floor(rand() * 90), 1, 1);
  }

  // Moon
  ctx.fillStyle = '#fff4c8';
  ctx.fillRect(250, 32, 14, 14);
  ctx.fillRect(248, 34, 18, 10);
  ctx.fillRect(252, 30, 10, 18);
  ctx.fillStyle = '#e8d890';
  ctx.fillRect(254, 36, 3, 3);
  ctx.fillRect(259, 41, 2, 2);

  // Distant castle towers
  ctx.fillStyle = '#170d2b';
  const towers = [[20, 70, 24], [70, 96, 16], [110, 60, 20], [190, 80, 22], [280, 66, 18]];
  for (const [x, top, w] of towers) {
    ctx.fillRect(x, top, w, FLOOR - top);
    for (let b = 0; b < w; b += 4) ctx.fillRect(x + b, top - 3, 2, 3);
  }
  ctx.fillRect(0, 120, VIEW_W, FLOOR - 120);
  ctx.fillStyle = '#ffd23f';
  ctx.fillRect(116, 74, 2, 3);
  ctx.fillRect(198, 92, 2, 3);
  ctx.fillRect(286, 80, 2, 3);

  // Side walls
  ctx.fillStyle = '#2b1c45';
  ctx.fillRect(0, 20, WALL_L, FLOOR - 20);
  ctx.fillRect(WALL_R, 20, VIEW_W - WALL_R, FLOOR - 20);
  // Torch holders
  ctx.fillStyle = '#5a4320';
  ctx.fillRect(14, 91, 3, 4);
  ctx.fillRect(VIEW_W - 17, 91, 3, 4);

  // Stone floor
  ctx.fillStyle = '#4a3a63';
  ctx.fillRect(0, FLOOR, VIEW_W, VIEW_H - FLOOR);
  ctx.fillStyle = '#6b5a85';
  ctx.fillRect(0, FLOOR, VIEW_W, 1);
  ctx.fillStyle = '#2f2342';
  for (let row = 0; row < 3; row++) {
    const y = FLOOR + 1 + row * 6;
    ctx.fillRect(0, y + 5, VIEW_W, 1);
    for (let x = (row % 2) * 8; x < VIEW_W; x += 16) ctx.fillRect(x, y, 1, 5);
  }

  // Ceiling beam (where Reverse Gravity sends you)
  ctx.fillStyle = '#2b1c45';
  ctx.fillRect(0, 20, VIEW_W, CEILING - 20);
  ctx.fillStyle = '#4a3a63';
  ctx.fillRect(0, CEILING - 1, VIEW_W, 1);

  // Floating platforms
  for (const p of PLATFORMS) {
    ctx.fillStyle = '#5c4a78';
    ctx.fillRect(p.x, p.y, p.w, PLATFORM_THICKNESS);
    ctx.fillStyle = '#8a76a8';
    ctx.fillRect(p.x, p.y, p.w, 1);
    ctx.fillStyle = '#2f2342';
    ctx.fillRect(p.x, p.y + PLATFORM_THICKNESS - 1, p.w, 1);
    for (let x = p.x + 6; x < p.x + p.w; x += 10) ctx.fillRect(x, p.y + 1, 1, 4);
    ctx.fillStyle = '#7dff6b';
    ctx.fillRect(p.x + 3, p.y + PLATFORM_THICKNESS, 1, 2);
    ctx.fillRect(p.x + p.w - 8, p.y + PLATFORM_THICKNESS, 1, 3);
  }

  return canvas;
}