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/spells.ts
// Every spell in the game. Most spells have a secret arrow combo that is made up
// once (from a fixed seed) so it is the same every time you play. A few starter
// spells have hand-picked combos that are shown on the title screen.
//
// `len` is how many arrows the combo needs, and also how strong the spell is:
//   3 = silly and mostly useless   4 = annoying
//   5 = useful                     6 = strong
// Silly spells are easy to type and show up often as wild magic; strong spells
// need long combos and are rare.
//
// To add a spell: add an entry to the END of SPELLS (so other combos don't
// change). Give it a `len` and it gets a secret combo automatically, or set
// `combo` to choose one yourself.

import type { Dir } from './pixels.js';
import type { Wizard, Duel } from './game.js';
import { sfx } from './sound.js';
import type { FamiliarKind } from './familiars.js';

export type StatusName =
  | 'burning'
  | 'frozen'
  | 'glue'
  | 'revgrav'
  | 'lowgrav'
  | 'sleepy'
  | 'drunk'
  | 'whistle'
  | 'slippery'
  | 'tiny'
  | 'bighead'
  | 'chicken'
  | 'bouncy'
  | 'hiccups'
  | 'stun'
  | 'rain'
  | 'pie'
  | 'butter'
  | 'sneeze'
  | 'disco'
  | 'hyper'
  | 'balloon'
  | 'slimy'
  | 'dance'
  | 'clown'
  | 'tripped'
  | 'opera'
  | 'mustache'
  | 'invisible'
  | 'afro'
  | 'squeaky'
  | 'pogo'
  | 'noodle'
  | 'fleas'
  | 'scratching'
  | 'snail'
  | 'bubble'
  | 'turtle'
  | 'bees'
  | 'pancake'
  | 'boomerang'
  | 'noodlearms'
  | 'chickrain'
  | 'toad'
  | 'puppet'
  | 'potato';

/** Short labels shown under each health bar while an effect is active. */
export const STATUS_LABELS: Record<StatusName, string> = {
  burning: 'ON FIRE',
  frozen: 'FROZEN',
  glue: 'GLUED',
  revgrav: 'UPSIDE',
  lowgrav: 'FLOATY',
  sleepy: 'SLEEPY',
  drunk: 'WOBBLY',
  whistle: 'WHISTLE',
  slippery: 'SLIPPY',
  tiny: 'TINY',
  bighead: 'BIG HEAD',
  chicken: 'CHICKEN',
  bouncy: 'BOUNCY',
  hiccups: 'HICCUPS',
  stun: 'ZAPPED',
  rain: 'SOGGY',
  pie: 'PIE FACE',
  butter: 'BUTTERY',
  sneeze: 'SNEEZY',
  disco: 'DISCO',
  hyper: 'HYPER',
  balloon: 'BALLOON',
  slimy: 'SLIMY',
  dance: 'DANCING',
  clown: 'CLOWN',
  tripped: 'OOF',
  opera: 'OPERA',
  mustache: 'MUSTACHE',
  invisible: 'INVISIBLE',
  afro: 'AFRO',
  squeaky: 'SQUEAKY',
  pogo: 'POGO',
  noodle: 'NOODLY',
  fleas: 'FLEAS',
  scratching: 'SCRATCH',
  snail: 'SNAIL',
  bubble: 'BUBBLE',
  turtle: 'TURTLE',
  bees: 'BEES',
  pancake: 'PANCAKE',
  boomerang: 'CURSED',
  noodlearms: 'NOODLE ARM',
  chickrain: 'CHICKENS',
  toad: 'TOAD',
  puppet: 'PUPPET',
  potato: 'POTATO',
};

export interface Spell {
  id: string;
  name: string;
  /** What pops up over the wizard who gets hit. */
  hitText: string;
  len: number;
  combo?: Dir[];
  colors: [string, string];
  icon: string;
  speed: number;
  damage: number;
  status?: [StatusName, number];
  sound: () => void;
  apply?: (target: Wizard, caster: Wizard, duel: Duel) => void;
  /** Summon spells don't fly: they call this familiar to the caster's side. */
  familiar?: FamiliarKind;
}

export const SPELLS: Spell[] = [
  {
    id: 'fireball', name: 'Fireball', hitText: 'TOASTY!', len: 6, combo: ['down', 'right', 'right', 'down', 'right', 'right'],
    colors: ['#ff7a1a', '#ffd23f'], icon: 'orb', speed: 170, damage: 14, status: ['burning', 2],
    sound: sfx.fire,
    apply: (target) => target.status.delete('frozen'),
  },
  {
    id: 'knockback', name: 'Knockback', hitText: 'BONK!', len: 5, combo: ['left', 'right', 'left', 'right', 'left'],
    colors: ['#7fe0ff', '#ffffff'], icon: 'swirl', speed: 190, damage: 8,
    sound: sfx.boing,
    apply: (target, caster) => {
      const away = target.x >= caster.x ? 1 : -1;
      target.vx = away * 330;
      target.vy = -170 * target.gravitySign();
    },
  },
  {
    id: 'glue', name: 'Floor Glue', hitText: 'STUCK!', len: 5,
    colors: ['#c8e86b', '#8fb339'], icon: 'drop', speed: 150, damage: 3, status: ['glue', 3.5],
    sound: sfx.splat,
  },
  {
    id: 'freeze', name: 'Freeze', hitText: 'BRRRR!', len: 6,
    colors: ['#9fe8ff', '#ffffff'], icon: 'snow', speed: 150, damage: 5, status: ['frozen', 2.5],
    sound: sfx.freeze,
    apply: (target) => target.status.delete('burning'),
  },
  {
    id: 'hiccups', name: 'Hiccup Hex', hitText: 'HIC!', len: 3, combo: ['up', 'down', 'up'],
    colors: ['#ff9fd0', '#ffffff'], icon: 'orb', speed: 160, damage: 2, status: ['hiccups', 6],
    sound: sfx.hic,
  },
  {
    id: 'pie', name: 'Pie in the Face', hitText: 'SPLAT!', len: 5,
    colors: ['#d9a066', '#ff9fc8'], icon: 'pie', speed: 170, damage: 6, status: ['pie', 4],
    sound: sfx.splat,
  },
  {
    id: 'sneeze', name: 'Sneeze Storm', hitText: 'ACHOO!', len: 3,
    colors: ['#b8f2a0', '#ffffff'], icon: 'orb', speed: 150, damage: 2, status: ['sneeze', 5],
    sound: sfx.sneeze,
  },
  {
    id: 'tiny', name: 'Shrink Ray', hitText: 'EEP!', len: 3,
    colors: ['#9be7ff', '#2f7bff'], icon: 'orb', speed: 200, damage: 3, status: ['tiny', 6],
    sound: sfx.boing,
  },
  {
    id: 'banana', name: 'Banana Peel', hitText: 'WHOOPS!', len: 4, combo: ['down', 'down', 'right', 'right'],
    colors: ['#ffe14d', '#5a4320'], icon: 'banana', speed: 150, damage: 3, status: ['slippery', 4.5],
    sound: sfx.splat,
  },
  {
    id: 'drunk', name: 'Drunken Wobble', hitText: 'HIC... WHA?', len: 4,
    colors: ['#c070ff', '#ffb3f0'], icon: 'swirl', speed: 150, damage: 3, status: ['drunk', 5],
    sound: sfx.boing,
  },
  {
    id: 'sleepy', name: 'Sleepy Time', hitText: 'YAWN...', len: 4,
    colors: ['#a8b8ff', '#ffffff'], icon: 'zzz', speed: 120, damage: 3, status: ['sleepy', 5],
    sound: sfx.snore,
  },
  {
    id: 'bighead', name: 'Big Head Mode', hitText: 'WHOA!', len: 3,
    colors: ['#ffb36b', '#ffffff'], icon: 'orb', speed: 160, damage: 3, status: ['bighead', 6],
    sound: sfx.boing,
  },
  {
    id: 'bouncy', name: 'Boing Boing', hitText: 'BOING!', len: 3,
    colors: ['#ff5fa2', '#ffd0e6'], icon: 'orb', speed: 170, damage: 3, status: ['bouncy', 5],
    sound: sfx.boing,
  },
  {
    id: 'lowgrav', name: 'Low Gravity', hitText: 'FLOATY!', len: 3,
    colors: ['#c9b6ff', '#ffffff'], icon: 'orb', speed: 140, damage: 3, status: ['lowgrav', 6],
    sound: sfx.freeze,
  },
  {
    id: 'magnet', name: 'Magnet Yank', hitText: 'COME HERE!', len: 5,
    colors: ['#ff4a4a', '#cccccc'], icon: 'swirl', speed: 200, damage: 4,
    sound: sfx.boing,
    apply: (target, caster) => {
      target.vx = (caster.x >= target.x ? 1 : -1) * 380;
      target.vy = -120 * target.gravitySign();
    },
  },
  {
    id: 'chicken', name: 'Chickenify', hitText: 'BAWK!', len: 6,
    colors: ['#fbf5e6', '#f7b733'], icon: 'egg', speed: 160, damage: 3, status: ['chicken', 4.5],
    sound: sfx.cluck,
  },
  {
    id: 'revgrav', name: 'Reverse Gravity', hitText: 'UP IS DOWN!', len: 5,
    colors: ['#5ad1c4', '#ffffff'], icon: 'swirl', speed: 150, damage: 3, status: ['revgrav', 4.5],
    sound: sfx.boing,
  },
  {
    id: 'whistle', name: 'Nose Whistle of the North', hitText: 'TWEEEE!', len: 3,
    colors: ['#e0f7ff', '#8fd3ff'], icon: 'note', speed: 140, damage: 2, status: ['whistle', 7],
    sound: sfx.whistle,
  },
  {
    id: 'rain', name: 'Personal Rain Cloud', hitText: 'SOGGY!', len: 4,
    colors: ['#9aa7c7', '#4aa3ff'], icon: 'cloud', speed: 130, damage: 2, status: ['rain', 5],
    sound: sfx.splat,
  },
  {
    id: 'disco', name: 'Disco Fever', hitText: 'GROOVY!', len: 6,
    colors: ['#ff4af0', '#4affef'], icon: 'orb', speed: 160, damage: 3, status: ['disco', 3],
    sound: sfx.disco,
  },
  {
    id: 'butter', name: 'Butterfingers', hitText: 'SLIPPY HANDS!', len: 5,
    colors: ['#ffe08a', '#ffb000'], icon: 'drop', speed: 160, damage: 3, status: ['butter', 5],
    sound: sfx.splat,
  },
  {
    id: 'hyper', name: 'Hyper Speed', hitText: 'ZOOOM!', len: 4,
    colors: ['#7dff6b', '#ffffff'], icon: 'bolt', speed: 220, damage: 3, status: ['hyper', 4],
    sound: sfx.zap,
  },
  {
    id: 'balloon', name: 'Up Up and Away', hitText: 'WHEEE!', len: 4,
    colors: ['#ff6b6b', '#ffffff'], icon: 'balloon', speed: 130, damage: 2, status: ['balloon', 4],
    sound: sfx.boing,
  },
  {
    id: 'lightning', name: 'Lightning Bolt', hitText: 'ZAP!', len: 6,
    colors: ['#fff36b', '#ffffff'], icon: 'bolt', speed: 260, damage: 18, status: ['stun', 0.8],
    sound: sfx.zap,
  },
  {
    id: 'swap', name: 'Switcheroo', hitText: 'HUH?!', len: 5,
    colors: ['#ffffff', '#b07bff'], icon: 'swirl', speed: 200, damage: 2,
    sound: sfx.pop,
    apply: (target, caster) => {
      [target.x, caster.x] = [caster.x, target.x];
      [target.y, caster.y] = [caster.y, target.y];
    },
  },
  {
    id: 'vampire', name: 'Vampire Tickle', hitText: 'HEHEHE!', len: 6,
    colors: ['#ff3a6b', '#ffffff'], icon: 'heart', speed: 170, damage: 10,
    sound: sfx.laugh,
    apply: (_target, caster, duel) => duel.heal(caster, 10),
  },
  // New spells go at the end so the secret combos above stay the same.
  {
    id: 'slime', name: 'Slime Ball', hitText: 'SQUELCH!', len: 4,
    colors: ['#5dff8a', '#1f9e4a'], icon: 'orb', speed: 160, damage: 3, status: ['slimy', 5],
    sound: sfx.splat,
  },
  {
    id: 'dance', name: 'Dance Fever', hitText: 'CANT STOP!', len: 4,
    colors: ['#ffd23f', '#ff4af0'], icon: 'note', speed: 160, damage: 3, status: ['dance', 5],
    sound: sfx.dance,
  },
  {
    id: 'clownshoes', name: 'Clown Shoes', hitText: 'HONK HONK!', len: 3,
    colors: ['#ff3a3a', '#ffd23f'], icon: 'orb', speed: 160, damage: 2, status: ['clown', 6],
    sound: sfx.honk,
  },
  {
    id: 'opera', name: 'Opera Voice', hitText: 'LA LA LAAA!', len: 3,
    colors: ['#ff9cf5', '#ffffff'], icon: 'note', speed: 150, damage: 2, status: ['opera', 6],
    sound: sfx.opera,
  },
  {
    id: 'mustache', name: 'Magnificent Mustache', hitText: 'FANCY!', len: 3,
    colors: ['#d9a066', '#5a3a1a'], icon: 'orb', speed: 160, damage: 2, status: ['mustache', 8],
    sound: sfx.boing,
  },
  {
    id: 'invisible', name: 'Invisible Robe', hitText: 'WHERE AM I?', len: 3,
    colors: ['#e8f0ff', '#9aa7c7'], icon: 'swirl', speed: 160, damage: 2, status: ['invisible', 6],
    sound: sfx.freeze,
  },
  {
    id: 'afro', name: 'Bad Hair Day', hitText: 'POOF!', len: 3,
    colors: ['#e0a060', '#5a3a1a'], icon: 'orb', speed: 160, damage: 2, status: ['afro', 7],
    sound: sfx.pop,
  },
  {
    id: 'squeaky', name: 'Squeaky Steps', hitText: 'SQUEAK!', len: 3,
    colors: ['#ffe14d', '#ff9f1a'], icon: 'orb', speed: 170, damage: 2, status: ['squeaky', 6],
    sound: sfx.squeak,
  },
  {
    id: 'pogo', name: 'Pogo Stick', hitText: 'BOINGY!', len: 4,
    colors: ['#7fe0ff', '#ff5fa2'], icon: 'swirl', speed: 160, damage: 3, status: ['pogo', 5],
    sound: sfx.boing,
  },
  {
    id: 'noodle', name: 'Spaghetti Legs', hitText: 'NOODLY!', len: 4,
    colors: ['#ffe08a', '#ff5a3a'], icon: 'orb', speed: 160, damage: 3, status: ['noodle', 5],
    sound: sfx.boing,
  },
  {
    id: 'fleas', name: 'Itchy Fleas', hitText: 'SO ITCHY!', len: 4,
    colors: ['#e0b080', '#3b2414'], icon: 'orb', speed: 160, damage: 3, status: ['fleas', 6],
    sound: sfx.scratch,
  },
  {
    id: 'snail', name: 'Snail Pace', hitText: 'SLOOOW...', len: 4,
    colors: ['#d9a066', '#b8f2a0'], icon: 'swirl', speed: 140, damage: 3, status: ['snail', 6],
    sound: sfx.snore,
  },
  {
    id: 'bubble', name: 'Bubble Trap', hitText: 'BLOOP!', len: 4,
    colors: ['#9fe8ff', '#ffffff'], icon: 'orb', speed: 150, damage: 3, status: ['bubble', 4],
    sound: sfx.bloop,
  },
  {
    id: 'turtle', name: 'Turtle Shell', hitText: 'FLIPPED!', len: 4,
    colors: ['#5fd06a', '#e8d27a'], icon: 'orb', speed: 160, damage: 3, status: ['turtle', 4],
    sound: sfx.pop,
  },
  {
    id: 'bees', name: 'Bee Swarm', hitText: 'BZZZZ!', len: 5,
    colors: ['#ffd23f', '#120a1f'], icon: 'orb', speed: 170, damage: 3, status: ['bees', 5],
    sound: sfx.buzz,
  },
  {
    id: 'pancake', name: 'Pancake', hitText: 'SQUISH!', len: 5,
    colors: ['#e8b060', '#ffe08a'], icon: 'orb', speed: 160, damage: 5, status: ['pancake', 5],
    sound: sfx.splat,
  },
  {
    id: 'boomerang', name: 'Boomerang Curse', hitText: 'UH OH...', len: 5,
    colors: ['#b07bff', '#ffd23f'], icon: 'swirl', speed: 170, damage: 3, status: ['boomerang', 8],
    sound: sfx.boing,
  },
  {
    id: 'noodlearms', name: 'Wet Noodle Arms', hitText: 'FLOPPY!', len: 5,
    colors: ['#ffe08a', '#9fe8ff'], icon: 'drop', speed: 160, damage: 3, status: ['noodlearms', 6],
    sound: sfx.splat,
  },
  {
    id: 'chickrain', name: 'Rubber Chicken Rain', hitText: 'INCOMING!', len: 5,
    colors: ['#ffe14d', '#ff9f1a'], icon: 'rubberchick', speed: 150, damage: 2, status: ['chickrain', 5],
    sound: sfx.squeak,
  },
  {
    id: 'toad', name: 'Toad Form', hitText: 'RIBBIT!', len: 6,
    colors: ['#4fbf4a', '#c8e86b'], icon: 'orb', speed: 170, damage: 4, status: ['toad', 5],
    sound: sfx.croak,
  },
  {
    id: 'puppet', name: 'Puppet Strings', hitText: 'DANCE, PUPPET!', len: 6,
    colors: ['#d9a066', '#ffffff'], icon: 'swirl', speed: 180, damage: 4, status: ['puppet', 4],
    sound: sfx.boing,
    apply: (target, caster) => {
      target.puppeteer = caster;
    },
  },
  {
    id: 'potato', name: 'Hot Potato', hitText: 'HOT HOT HOT!', len: 6,
    colors: ['#c8894a', '#ff5a1a'], icon: 'orb', speed: 160, damage: 2, status: ['potato', 3],
    sound: sfx.fire,
  },
  // Summon spells: these call a familiar instead of flying at the other wizard.
  {
    id: 'fairy', name: 'Summon Healing Fairy', hitText: 'TWINKLE!', len: 5,
    colors: ['#ff9cf5', '#c8f4ff'], icon: 'orb', speed: 0, damage: 0, familiar: 'fairy',
    sound: sfx.summon,
  },
  {
    id: 'imp', name: 'Summon Fire Imp', hitText: 'HEHEHE!', len: 5,
    colors: ['#e0402a', '#ffd23f'], icon: 'orb', speed: 0, damage: 0, familiar: 'imp',
    sound: sfx.summon,
  },
  {
    id: 'owl', name: 'Summon Guard Owl', hitText: 'HOOT!', len: 5,
    colors: ['#d9a066', '#fff4e0'], icon: 'orb', speed: 0, damage: 0, familiar: 'owl',
    sound: sfx.summon,
  },
];

/** Not a castable spell: the rubber chickens that fall during Rubber Chicken Rain. */
export const RUBBER_CHICKEN: Spell = {
  id: 'rubberchicken', name: 'Rubber Chicken', hitText: 'BONK!', len: 0,
  colors: ['#ffe14d', '#ff9f1a'], icon: 'rubberchick', speed: 0, damage: 2,
  sound: sfx.squeak,
};

// Small, repeatable random numbers so secret combos never change between games.
function seededRandom(seed: number): () => number {
  let a = seed;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const DIRS: Dir[] = ['up', 'down', 'left', 'right'];

function assignCombos(): void {
  const used = new Set(SPELLS.filter((s) => s.combo).map((s) => s.combo!.join(',')));
  const random = seededRandom(0x5be11);
  for (const spell of SPELLS) {
    while (!spell.combo) {
      const combo = Array.from({ length: spell.len }, () => DIRS[Math.floor(random() * 4)]);
      const key = combo.join(',');
      if (!used.has(key)) {
        used.add(key);
        spell.combo = combo;
      }
    }
  }
}

assignCombos();

const BY_COMBO = new Map(SPELLS.map((spell) => [spell.combo!.join(','), spell]));

export function findSpell(combo: Dir[]): Spell | undefined {
  return BY_COMBO.get(combo.join(','));
}

/** Out of every 100 wild combos, how many hit the caster instead. */
const BACKFIRE_PERCENT = 10;

/** How often each kind of spell comes out of wild magic: silly ones far more often. */
const WILD_WEIGHT: Record<number, number> = { 3: 8, 4: 4, 5: 2, 6: 1 };

// A fixed number made from some text (the same text always gives the same number).
function hash(text: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < text.length; i++) {
    h ^= text.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  h ^= h >>> 15;
  h = Math.imul(h, 0x2c1b3c6d);
  return (h ^ (h >>> 12)) >>> 0;
}

/**
 * Wild magic for a combo that isn't a real spell. The same runes always give the
 * same spell, and always do (or don't) backfire, so players can learn them too.
 * Each spell "bids" on the combo and the highest bid wins, so adding a new spell
 * only takes over a few wild combos and leaves the rest the same.
 */
export function wildMagic(combo: Dir[]): { spell: Spell; backfire: boolean } {
  const key = combo.join(',');
  let best = SPELLS[0];
  let bestBid = -Infinity;
  for (const spell of SPELLS) {
    // A short wild combo can only make spells that are just as short (just as silly).
    if (spell.len > combo.length) continue;
    // Silly spells bid harder, so they come up much more often.
    const roll = (hash(`${key}|${spell.id}`) + 1) / 4294967297;
    const bid = Math.log(roll) / WILD_WEIGHT[spell.len];
    if (bid > bestBid) {
      bestBid = bid;
      best = spell;
    }
  }
  // About 1 in 10 wild combos backfire; the other 9 fly at the other wizard.
  return { spell: best, backfire: hash(`${key}|backfire`) % 100 < BACKFIRE_PERCENT };
}

export function spellById(id: string): Spell {
  const spell = SPELLS.find((s) => s.id === id);
  if (!spell) throw new Error(`Unknown spell ${id}`);
  return spell;
}

export const RANDOM_DIR = (): Dir => DIRS[Math.floor(Math.random() * 4)];