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.
// 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)];
