Files
botfights/frontend/src/game/FightScene.ts
T

6515 lines
306 KiB
TypeScript
Raw Normal View History

import kaplay from 'kaplay'
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp, LoadSpriteOpt } from 'kaplay'
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, generateHumanFighterSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
import type { Fighter, FightSceneConfig, RoundEvent, FightContext, ChoreoContext } from './fight/types'
2026-03-08 22:16:50 +00:00
export type { FightSceneConfig, RoundEvent } from './fight/types'
import { ARENA_THEMES, spriteAnims, CHALLENGE_THEMED, TIER_ULTIMATES, CREATOR_MOVES, CREATOR_ULTIMATES } from './fight/constants'
import { spawnSparks as _spawnSparks, spawnBulletHoles as _spawnBulletHoles, spawnExhaust as _spawnExhaust } from './fight/particles'
import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects'
import { spawnProjectile as _spawnProjectile } from './fight/projectiles'
import { createChoreographyMap } from './fight/choreography'
import { createFactories } from './fight/choreography/factories'
import {
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
sfxRoundStart, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxBoing,
sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh, sfxRapidPunch,
sfxSplat, sfxWomp, sfxPowerUp, sfxCoin, sfxFail, sfxDrumRoll,
fanfareRound, fanfareFight, fanfareDevastating, fanfareCritical, fanfareCombo,
startMusic, stopMusic, announce,
announceRandom, announceDramatic, announceHype, announceSilly, announceCool,
announceRobot, announceScream, announceFast,
announceFinishHim, announceFatality, announceFlawlessVictory,
announceRandomHype, announceDeepIntro, announceRoundHype, announceCrowdReaction,
announceCreatorEntrance, announceCreatorRound, announceCreatorKO,
announceCreatorWin, announceCreatorLose, announceCreatorMorph,
announceCreatorCameo, announceCreatorTaunt, announceCreatorDevastating,
sfxRandomComedy, sfxRandomFail,
sfxVineBoom, sfxAirHorn, sfxBruh, sfxFart, sfxRecordScratch,
sfxRubberChicken, sfxSqueakyToy, sfxWetSlap, sfxBoneCrack, sfxCartoonRun,
sfxRimShot, sfxSlideWhistleUp, sfxSlideWhistleDown, sfxYippee, sfxDing,
sfxTacoBellBong, sfxWindowsError, sfxMemeThud,
sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer,
sfxMissionFailed, sfxCrickets, sfxSadViolin, sfxEmotionalDamage, sfxDunDunDun,
} from './audio'
function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string {
// THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal
if (attackerArchetype === 'the_creator') {
const ultimateChance = isCritical ? 1.0 : 0.5
if (Math.random() < ultimateChance) {
// Creator gets ALL ultimates + their 4 exclusive ones
const all: string[] = [...CREATOR_ULTIMATES]
for (let t = 2; t <= 5; t++) {
if (TIER_ULTIMATES[t]) all.push(...TIER_ULTIMATES[t])
}
return all[Math.floor(Math.random() * all.length)]
}
// 60% creator-themed, 40% normal selection
if (Math.random() < 0.6) {
return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)]
}
// Fall through to normal selection
}
// Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves
// Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40%
if (attackerTier >= 2) {
const ultimateChance = attackerTier === 2 ? 0.15 : attackerTier === 3 ? 0.20 : attackerTier === 4 ? 0.30 : 0.40
// On crits, double the chance
const roll = isCritical ? ultimateChance * 2 : ultimateChance
if (Math.random() < roll) {
// Collect all ultimates available at this tier (cumulative — higher tiers get lower tier moves too)
const available: string[] = []
for (let t = 2; t <= attackerTier; t++) {
if (TIER_ULTIMATES[t]) available.push(...TIER_ULTIMATES[t])
}
if (available.length > 0) {
return available[Math.floor(Math.random() * available.length)]
}
}
}
// Critical hits always get BIG moves (but can still be themed)
if (isCritical) {
const entry = CHALLENGE_THEMED[challengeType]
// 50% themed, 50% epic generic for crits
if (entry && Math.random() < 0.5) {
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
}
const critMoves = [
'groundPound', 'jetpackDive', 'gunBurst', 'fullScreenDash', 'multiHit', 'fireBreath',
'lightningRush', 'zoomRush', 'rapidFlurry', 'rocketLauncher', 'laserBeam', 'katanaCombo',
'hammerSmash', 'sniperShot', 'minigunSpray', 'chainsawRev', 'carSmash', 'motorbikeCharge',
'anvilDrop', 'pocketCannon', 'bodySlam', 'suplex', 'pinballCombo', 'grappleFlurry',
// New epic crits
'kamehameha', 'nukeStrike', 'tankRoll', 'helicopterStrike', 'chokeslam', 'tombstone',
'rko', 'piledriver', 'powerbomb', 'meteorStrike', 'spiritBomb', 'blackHole',
'alienAbduction', 'tsunamiWave', 'earthquakeStrike', 'dragonBreath', 'pianoDrop',
'fridgeDrop', 'volcanoErupt', 'warpDrive', 'dubstepCannon', 'tornadoSpin',
'trampolineBounce', 'watermelonBomb', 'nftRugPull', 'blueScreen', 'yeetThrow',
'bulletTimeAttack',
]
return critMoves[Math.floor(Math.random() * critMoves.length)]
}
const entry = CHALLENGE_THEMED[challengeType]
if (!entry) {
const fallback = ['dashPunch', 'flyingKick', 'aerialSlam', 'backflipKick', 'fingerGuns', 'rubberChicken', 'pizzaSlam', 'fryingPan', 'snowballFight']
return fallback[Math.floor(Math.random() * fallback.length)]
}
// 60% themed, 25% generic fallback, 15% wild card
const roll = Math.random()
if (roll < 0.6) {
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
} else if (roll < 0.85) {
return entry.generic[Math.floor(Math.random() * entry.generic.length)]
} else {
const wild = [
'jetpackDive', 'gunBurst', 'groundPound', 'teleportStrike', 'fullScreenDash', 'fireBreath',
'golfClubSmash', 'zoomRush', 'rapidFlurry', 'swordSlash', 'hammerSmash', 'laserBeam',
'rocketLauncher', 'bombThrow', 'minigunSpray', 'sniperShot', 'whipCrack', 'katanaCombo',
'chainsawRev', 'motorbikeCharge', 'carSmash', 'boatCannon', 'anvilDrop', 'pocketCannon',
'grappleFlurry', 'bodySlam', 'pinballCombo', 'suplex',
// New wild cards — food
'pizzaSlam', 'bananaFling', 'pieSmash', 'burgerToss', 'tomatoBarrage', 'fishSlap',
// New wild cards — weapons
'fryingPan', 'baseballBat', 'guitarSmash', 'umbrellaWhack', 'plungerSlam', 'wrenchSmash',
// New wild cards — drops
'pianoDrop', 'toiletDrop', 'tvDrop', 'chandelierDrop', 'micDrop',
// New wild cards — vehicles
'shoppingCart', 'zamboniCrush', 'unicycleRun', 'tankRoll',
// New wild cards — spins/flips
'tornadoSpin', 'backflipKick', 'corkscrewDive', 'barrelRoll', 'skateTrick',
// New wild cards — beams
'kamehameha', 'freezeRay', 'heatVision', 'rainbowBeam', 'glitchBeam',
// New wild cards — silly
'rubberChicken', 'selfieStrike', 'fingerGuns', 'yeetThrow', 'emojiBarrage', 'touchGrass',
// New wild cards — wrestling
'clothesline', 'rko', 'stunner', 'piledriver',
// New wild cards — magic
'fireball', 'portalPunch', 'crystalShards', 'spiritBomb',
// New wild cards — tech
'stackOverflow', 'blueScreen', 'aiUprising', 'popupSpam',
// New wild cards — music
'bassDrop', 'dubstepCannon', 'vinylScratch',
// New wild cards — nature
'avalanche', 'lavaSplash', 'tornadoFling',
]
return wild[Math.floor(Math.random() * wild.length)]
}
}
// Convert any CSS color string to a kaplay Color object safely
function safeColor(k: ReturnType<typeof kaplay>, color: string) {
if (color.startsWith('#')) {
try { return k.Color.fromHex(color) } catch { /* fall through */ }
}
// HSL / rgb / named color — render to a 1px canvas to extract RGB
const cv = document.createElement('canvas')
cv.width = 1; cv.height = 1
const cx = cv.getContext('2d')!
cx.fillStyle = color
cx.fillRect(0, 0, 1, 1)
const [r, g, b] = cx.getImageData(0, 0, 1, 1).data
return k.Color.fromArray([r, g, b])
}
export async function createFightScene(config: FightSceneConfig) {
const { canvas, botA, botB, arena } = config
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
const k = kaplay({
canvas,
width: canvas.width || 800,
height: canvas.height || 500,
background: theme.bg,
global: false,
scale: 1,
crisp: true,
texFilter: 'nearest',
})
const colorsA = getBotColors(botA.seed)
const colorsB = getBotColors(botB.seed)
let sheetA: string
let sheetB: string
// Human fighters use their profile human character, not a bot sprite
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
try {
sheetA = botA.archetype === 'human'
? generateHumanFighterSpriteSheet(botA.seed, botA.archetype, colorsA.primary, colorsA.secondary, winRateA)
: generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
} catch (err) {
console.error('[FightScene] Failed to generate sprite for botA:', err)
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
}
try {
sheetB = botB.archetype === 'human'
? generateHumanFighterSpriteSheet(botB.seed, botB.archetype, colorsB.primary, colorsB.secondary, winRateB)
: generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
} catch (err) {
console.error('[FightScene] Failed to generate sprite for botB:', err)
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
}
const isHumanA = botA.archetype === 'human'
const isHumanB = botB.archetype === 'human'
// Helper: load sprite with a 5s timeout so mobile never hangs forever
async function loadSpriteWithTimeout(name: string, src: string, opts: LoadSpriteOpt): Promise<void> {
try {
await Promise.race([
k.loadSprite(name, src, opts),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
])
} catch (err) {
console.warn(`[FightScene] Sprite '${name}' failed/timed out:`, err)
}
}
// Await sprite loading — timeout prevents mobile hangs
await Promise.all([
loadSpriteWithTimeout('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
loadSpriteWithTimeout('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
])
// Preload bot sprites for human fighters (used when humans morph into bots)
if (isHumanA) {
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
loadSpriteWithTimeout('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
if (isHumanB) {
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
loadSpriteWithTimeout('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
// Judge sprite (red lobster referee)
const judgeSheet = generateJudgeSpriteSheet()
const judgeAnims = {
idle: { from: 0, to: JUDGE_ANIMATIONS.idle.frames - 1, loop: true, speed: 4 },
call_left: { from: JUDGE_MAX_FRAMES, to: JUDGE_MAX_FRAMES + JUDGE_ANIMATIONS.call_left.frames - 1, loop: false, speed: 8 },
call_right: { from: JUDGE_MAX_FRAMES * 2, to: JUDGE_MAX_FRAMES * 2 + JUDGE_ANIMATIONS.call_right.frames - 1, loop: false, speed: 8 },
shocked: { from: JUDGE_MAX_FRAMES * 3, to: JUDGE_MAX_FRAMES * 3 + JUDGE_ANIMATIONS.shocked.frames - 1, loop: false, speed: 10 },
}
await loadSpriteWithTimeout('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
// Human sprites (the bot owner's silly human avatar for crowd)
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
const humanSheetB = generateHumanSpriteSheet(botB.seed, botB.archetype || 'standard', colorsB.primary, colorsB.secondary, winRateB)
const humanAnims: Record<string, { from: number; to: number; loop: boolean; speed: number }> = {}
for (const [name, cfg] of Object.entries(HUMAN_ANIMATIONS)) {
humanAnims[name] = { from: cfg.row * HUMAN_MAX_FRAMES, to: cfg.row * HUMAN_MAX_FRAMES + cfg.frames - 1, loop: true, speed: 6 }
}
await Promise.all([
loadSpriteWithTimeout('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
loadSpriteWithTimeout('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
])
const W = k.width()
const H = k.height()
// Track all intervals/timeouts for cleanup on scene destroy
const cleanupTimers = new Set<ReturnType<typeof setTimeout>>()
function trackedTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms)
cleanupTimers.add(id)
return id
}
function trackedInterval(fn: () => void, ms: number): ReturnType<typeof setInterval> {
const id = setInterval(fn, ms)
cleanupTimers.add(id)
return id
}
function clearTracked(id: ReturnType<typeof setTimeout>) {
clearInterval(id)
clearTimeout(id)
cleanupTimers.delete(id)
}
// Scale factor so sprites shrink on small canvases (reference: 500px tall)
// Fight context for extracted modules
const fightCtx: FightContext = {
k, W, H, theme,
trackedTimeout, trackedInterval, clearTracked,
safeColor: (color: string) => safeColor(k, color),
}
// Wrappers for extracted particle/effect/projectile functions
const spawnSparks = (x: number, y: number, count: number, color: string) => _spawnSparks(fightCtx, x, y, count, color)
const spawnBulletHoles = (x: number, y: number, count: number) => _spawnBulletHoles(fightCtx, x, y, count)
const spawnExhaust = (x: number, y: number, duration: number) => _spawnExhaust(fightCtx, x, y, duration)
const spawnProjectile = (fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8) => _spawnProjectile(fightCtx, fromX, fromY, toX, toY, color, size)
const glitchRGB = (duration: number = 0.2) => _glitchRGB(fightCtx, duration)
const scanlineGlitch = (duration: number = 0.3) => _scanlineGlitch(fightCtx, duration)
const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration)
const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration)
const SF = Math.min(1, H / 500)
const GROUND_Y = H * 0.78
const HOME_A = W * 0.28
const HOME_B = W * 0.72
function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise<void> {
return new Promise(resolve => {
const bullet = k.add([
k.rect(6, 2),
k.pos(fromX, fromY),
k.color(safeColor(k,'#ffee00')),
k.opacity(1),
k.z(15),
k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI),
])
const dur = 0.08 + Math.random() * 0.04
k.tween(0, 1, dur, (t) => {
bullet.pos.x = fromX + (toX - fromX) * t
bullet.pos.y = fromY + (toY - fromY) * t + (Math.random() - 0.5) * 4
}, k.easings.linear).then(() => {
bullet.destroy()
// Muzzle flash at impact
spawnSparks(toX, toY, 3, '#ffcc00')
resolve()
})
})
}
function spawnShockwave(x: number, y: number, color: string) {
const wave = k.add([
k.circle(5),
k.pos(x, y),
k.color(safeColor(k,color)),
k.opacity(0.7),
k.z(5),
k.scale(1),
])
k.tween(1, 15, 0.4, (v) => { wave.scaleTo(v, v * 0.3) }, k.easings.easeOutQuad)
k.tween(0.7, 0, 0.4, (v) => { wave.opacity = v }, k.easings.easeOutQuad).then(() => wave.destroy())
}
// === VISUAL CHAOS EFFECTS ===
// Schizo cut — rapid zoom/position jitter simulating jump cuts
async function schizoCut() {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const origAX = fA.pos.x, origBX = fB.pos.x
const origAY = fA.pos.y, origBY = fB.pos.y
// 3-5 rapid cuts
const cuts = 3 + Math.floor(Math.random() * 3)
for (let i = 0; i < cuts; i++) {
// Random offset both fighters
fA.pos.x = origAX + (Math.random() - 0.5) * 30
fA.pos.y = origAY + (Math.random() - 0.5) * 15
fB.pos.x = origBX + (Math.random() - 0.5) * 30
fB.pos.y = origBY + (Math.random() - 0.5) * 15
screenFlash(Math.random() > 0.5 ? '#000000' : '#ffffff', 0.03)
await k.wait(0.04 + Math.random() * 0.03)
}
fA.pos.x = origAX; fA.pos.y = origAY
fB.pos.x = origBX; fB.pos.y = origBY
}
// Hyperspeed lines — converging toward a point
function hyperSpeedLines(targetX: number, targetY: number, duration: number = 0.3) {
const lines: GameObj[] = []
for (let i = 0; i < 16; i++) {
const angle = (i / 16) * Math.PI * 2
const dist = 300 + Math.random() * 200
const sx = targetX + Math.cos(angle) * dist
const sy = targetY + Math.sin(angle) * dist
const len = 30 + Math.random() * 60
const line = k.add([
k.rect(len, 1.5), k.pos(sx, sy),
k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(52),
k.rotate(angle * 180 / Math.PI + 180),
])
line.onUpdate(() => {
const dx = targetX - line.pos.x
const dy = targetY - line.pos.y
const d = Math.sqrt(dx * dx + dy * dy)
if (d > 10) {
line.pos.x += (dx / d) * 600 * k.dt()
line.pos.y += (dy / d) * 600 * k.dt()
}
line.opacity -= 0.8 * k.dt()
if (line.opacity <= 0 && line.exists()) line.destroy()
})
lines.push(line)
}
setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
}
// Flash the whole screen
function screenFlash(color: string, duration: number = 0.1) {
const flash = k.add([
k.rect(W, H),
k.pos(0, 0),
k.color(safeColor(k,color)),
k.opacity(0.4),
k.z(50),
])
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
}
// Impact freeze-frame: brief pause with white flash on devastating crits
async function impactFreeze(duration: number = 0.08) {
const overlay = k.add([
k.rect(W, H), k.pos(0, 0),
k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(55),
])
// Kaplay has no built-in pause, so we just hold with a wait + overlay
await k.wait(duration)
k.tween(0.5, 0, 0.06, (v) => { overlay.opacity = v }).then(() => overlay.destroy())
}
// Spawn afterimages: trailing ghost sprites behind a moving fighter
function spawnAfterimages(fighter: Fighter, count: number = 3) {
if (!fighter?.exists()) return
for (let i = 0; i < count; i++) {
const ghost = k.add([
k.rect(FRAME_SIZE * Math.abs(fighter.scale.x), FRAME_SIZE * Math.abs(fighter.scale.y)),
k.pos(fighter.pos.x - i * 8 * Math.sign(fighter.scale.x), fighter.pos.y),
k.color(safeColor(k, theme.accent)),
k.opacity(0.3 - i * 0.08),
k.z(fighter.z - 1),
k.anchor('bot'),
])
k.tween(ghost.opacity, 0, 0.15 + i * 0.05, (v) => { ghost.opacity = v }).then(() => ghost.destroy())
}
}
// Camera zoom: scale fighters toward/away from a focal point for impact emphasis
async function cameraZoom(
fighters: Fighter[],
savedScales: { x: number; y: number }[],
zoomFactor: number,
duration: number,
easing: (t: number) => number = k.easings.easeOutQuad,
) {
await Promise.all(fighters.map((f, i) => {
if (!f?.exists()) return Promise.resolve()
const sx = savedScales[i].x
const sy = savedScales[i].y
return k.tween(Math.abs(f.scale.y), Math.abs(sy) * zoomFactor, duration, (v) => {
f.scale.x = sx > 0 ? v : -v
f.scale.y = v
}, easing)
}))
}
// Grotesque close-up overlays removed — noops retained for call-site compatibility
function spawnGrotesqueDetails(_fighter: Fighter, _scaleFactor: number) {}
function destroyGrotesqueDetails() {}
// Arena-specific background decoration
function drawArenaDecor() {
const a = arena
const acc = theme.accent
// Parallax stars/particles in the sky
for (let i = 0; i < 40; i++) {
const star = k.add([
k.rect(1 + Math.random() * 2, 1 + Math.random() * 2),
k.pos(Math.random() * W, Math.random() * (GROUND_Y - 20)),
k.color(safeColor(k, i % 5 === 0 ? '#ffffff' : acc)),
k.opacity(0.08 + Math.random() * 0.2),
k.z(1),
])
const speed = 0.5 + Math.random() * 1.5
const baseOp = star.opacity
star.onUpdate(() => {
star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.08
})
}
if (a === 'datacenter' || a === 'localhost') {
// City skyline silhouette behind the racks
const buildingHeights = [0.55, 0.4, 0.65, 0.35, 0.7, 0.45, 0.6, 0.5, 0.72, 0.38, 0.58, 0.42]
for (let i = 0; i < buildingHeights.length; i++) {
const bw = W / buildingHeights.length + 4
const bh = GROUND_Y * buildingHeights[i]
const bx = i * (W / buildingHeights.length)
k.add([k.rect(bw, bh), k.pos(bx, GROUND_Y - bh), k.color(safeColor(k, '#060610')), k.opacity(0.5), k.z(0)])
for (let wy = 0; wy < bh - 10; wy += 8) {
for (let wx = 4; wx < bw - 6; wx += 7) {
const win = k.add([
k.rect(3, 3), k.pos(bx + wx, GROUND_Y - bh + 6 + wy),
k.color(safeColor(k, Math.random() > 0.4 ? '#223355' : '#ffcc44')),
k.opacity(0.15 + Math.random() * 0.2), k.z(0),
])
if (Math.random() < 0.15) {
const blink = 0.03 + Math.random() * 0.06
win.onUpdate(() => { win.opacity = Math.random() > blink ? win.opacity : (Math.random() > 0.5 ? 0.3 : 0.05) })
}
}
}
}
// Server rack wall
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 10; col++) {
const lx = 15 + col * (W / 10)
const ly = 15 + row * 55
const rackW = W / 10 - 8
const rackH = 48
k.add([k.rect(rackW, rackH), k.pos(lx, ly), k.color(safeColor(k, '#06060f')), k.opacity(0.6), k.z(1)])
k.add([k.rect(rackW, 1), k.pos(lx, ly), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.4), k.z(1)])
k.add([k.rect(rackW, 1), k.pos(lx, ly + rackH), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.4), k.z(1)])
k.add([k.rect(1, rackH), k.pos(lx, ly), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.3), k.z(1)])
for (let s = 0; s < 5; s++) {
k.add([k.rect(rackW - 6, 1), k.pos(lx + 3, ly + 8 + s * 8), k.color(safeColor(k, '#111122')), k.opacity(0.5), k.z(1)])
}
for (let led = 0; led < 3; led++) {
const ledEl = k.add([
k.rect(2, 2), k.pos(lx + 4 + led * 6, ly + 4),
k.color(safeColor(k, Math.random() > 0.3 ? '#00ff41' : '#ff2d2d')),
k.opacity(0.7), k.z(2),
])
const blinkRate = 0.02 + Math.random() * 0.08
ledEl.onUpdate(() => { ledEl.opacity = Math.random() > blinkRate ? 0.7 : 0.15 })
}
if (Math.random() < 0.4) {
const actLed = k.add([
k.rect(2, 2), k.pos(lx + rackW - 8, ly + 4),
k.color(safeColor(k, '#ffaa00')), k.opacity(0.6), k.z(2),
])
actLed.onUpdate(() => { actLed.opacity = Math.random() > 0.5 ? 0.7 : 0.2 })
}
}
}
// Cable bundles
for (let c = 0; c < 6; c++) {
const cy = 3 + c * 2
k.add([k.rect(W, 2), k.pos(0, cy), k.color(safeColor(k, c % 3 === 0 ? '#111133' : c % 3 === 1 ? '#0a0a22' : '#0f0f2a')), k.opacity(0.5), k.z(1)])
}
// Scan line sweep
const scanLine = k.add([k.rect(W, 1), k.pos(0, 0), k.color(safeColor(k, '#00f0ff')), k.opacity(0.06), k.z(3)])
scanLine.onUpdate(() => { scanLine.pos.y = (scanLine.pos.y + 40 * k.dt()) % GROUND_Y })
} else if (a === 'gpu_graveyard') {
// Floating circuit board fragments
for (let i = 0; i < 16; i++) {
const cx = Math.random() * W
const cy = 20 + Math.random() * (GROUND_Y - 60)
const cw = 15 + Math.random() * 25
const ch = 10 + Math.random() * 18
const chip = k.add([
k.rect(cw, ch), k.pos(cx, cy),
k.color(safeColor(k, Math.random() > 0.5 ? '#1a2a1a' : '#0f1a0f')),
k.opacity(0.35), k.z(1), k.rotate(Math.random() * 45 - 22),
])
for (let t = 0; t < 3; t++) {
k.add([k.rect(cw * 0.6, 1), k.pos(cx + 2, cy + 3 + t * 4), k.color(safeColor(k, '#76b900')), k.opacity(0.2), k.z(1)])
}
k.add([k.circle(1.5), k.pos(cx + cw / 2, cy + ch / 2), k.color(safeColor(k, '#aabb44')), k.opacity(0.25), k.z(1)])
const baseY = cy
chip.onUpdate(() => { chip.pos.y = baseY + Math.sin(k.time() * 0.3 + i) * 5 })
}
// Dead GPU fans — some slowly spinning
for (const fx of [W * 0.15, W * 0.5, W * 0.85]) {
const fy = GROUND_Y - 30 - Math.random() * 40
k.add([k.circle(18), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#0a0a0a')), k.opacity(0.4), k.z(1)])
k.add([k.circle(5), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#1a1a1a')), k.opacity(0.3), k.z(1)])
for (let b = 0; b < 4; b++) {
const ba = b * Math.PI / 2
const blade = k.add([k.rect(14, 2), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#222222')), k.opacity(0.3), k.z(1), k.rotate(ba * 180 / Math.PI)])
if (fx === W * 0.5) {
blade.onUpdate(() => { blade.angle += 8 * k.dt() })
}
}
}
// Sparking wires
for (let i = 0; i < 4; i++) {
const sx = W * (0.2 + i * 0.2)
const sy = 10 + Math.random() * 30
k.add([k.rect(1, 20 + Math.random() * 40), k.pos(sx, sy), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(1)])
const spark = k.add([k.circle(3), k.pos(sx, sy + 20), k.anchor('center'), k.color(safeColor(k, '#ffff00')), k.opacity(0), k.z(3)])
spark.onUpdate(() => { spark.opacity = Math.random() > 0.97 ? 0.6 : 0 })
}
// Smoke wisps rising
for (let i = 0; i < 6; i++) {
const smoke = k.add([
k.circle(8 + Math.random() * 12), k.pos(Math.random() * W, GROUND_Y - 5),
k.color(safeColor(k, '#1a1a1a')), k.opacity(0.15), k.z(2),
])
const bx = smoke.pos.x
smoke.onUpdate(() => {
smoke.pos.y -= 6 * k.dt()
smoke.pos.x = bx + Math.sin(k.time() + i) * 8
smoke.opacity *= 0.998
if (smoke.pos.y < 0) { smoke.pos.y = GROUND_Y - 5; smoke.opacity = 0.15 }
})
}
} else if (a === 'prompt_dungeon') {
// Stone wall bricks
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 12; col++) {
const brickW = W / 12 + (Math.random() - 0.5) * 10
const brickH = GROUND_Y / 5
const bx = col * (W / 12) + (row % 2 === 0 ? 0 : W / 24)
k.add([k.rect(brickW - 2, brickH - 2), k.pos(bx, row * brickH), k.color(safeColor(k, '#1a1520')), k.opacity(0.3), k.z(1)])
}
}
// Glowing runes with halos
for (let i = 0; i < 10; i++) {
const rx = 30 + Math.random() * (W - 60)
const ry = 20 + Math.random() * (GROUND_Y - 60)
const rune = k.add([
k.circle(6 + Math.random() * 8), k.pos(rx, ry),
k.color(safeColor(k, '#b83dff')), k.opacity(0.12), k.z(1),
])
k.add([k.circle(12 + Math.random() * 8), k.pos(rx, ry), k.color(safeColor(k, '#6622aa')), k.opacity(0.06), k.z(0)])
rune.onUpdate(() => { rune.opacity = 0.08 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.08 })
}
// Torches with flickering flames and light cones
for (const tx of [W * 0.1, W * 0.35, W * 0.65, W * 0.9]) {
const ty = GROUND_Y * 0.3
k.add([k.rect(4, 20), k.pos(tx, ty), k.color(safeColor(k, '#553311')), k.opacity(0.5), k.z(1)])
const flame = k.add([k.circle(6), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff6600')), k.opacity(0.5), k.z(2)])
const flameGlow = k.add([k.circle(15), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff4400')), k.opacity(0.08), k.z(1)])
const lightCone = k.add([k.rect(30, GROUND_Y * 0.5), k.pos(tx - 13, ty + 20), k.color(safeColor(k, '#ff6600')), k.opacity(0.02), k.z(0)])
flame.onUpdate(() => {
flame.opacity = 0.4 + Math.sin(k.time() * 6 + tx) * 0.15
flame.pos.y = ty - 3 + Math.sin(k.time() * 8 + tx) * 1.5
flameGlow.opacity = 0.05 + Math.sin(k.time() * 6 + tx) * 0.04
lightCone.opacity = 0.015 + Math.sin(k.time() * 6 + tx) * 0.008
})
}
// Chains hanging from ceiling
for (let i = 0; i < 5; i++) {
const cx = W * (0.15 + i * 0.18)
const chainLen = 25 + Math.random() * 50
for (let s = 0; s < chainLen; s += 5) {
const link = k.add([k.rect(3, 4), k.pos(cx, s), k.color(safeColor(k, '#444444')), k.opacity(0.25), k.z(1)])
const baseCx = cx
link.onUpdate(() => { link.pos.x = baseCx + Math.sin(k.time() * 0.4 + i + s * 0.1) * 3 })
}
}
// Ground fog
for (let i = 0; i < 8; i++) {
const fog = k.add([
k.circle(35 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 8),
k.color(safeColor(k, '#1f1a2a')), k.opacity(0.25), k.z(3),
])
const baseX = fog.pos.x
fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 18 })
}
// Dripping water
for (let i = 0; i < 3; i++) {
const dx = W * (0.25 + i * 0.25)
const drop = k.add([k.circle(1.5), k.pos(dx, 0), k.color(safeColor(k, '#4466aa')), k.opacity(0.3), k.z(2)])
drop.onUpdate(() => {
drop.pos.y += 50 * k.dt()
if (drop.pos.y > GROUND_Y) { drop.pos.y = 0; drop.opacity = 0.3 }
drop.opacity *= 0.997
})
}
} else if (a === 'the_cloud') {
// Layered cloud formations
for (let layer = 0; layer < 3; layer++) {
for (let i = 0; i < 4; i++) {
const cx = Math.random() * W
const cy = 20 + layer * 50 + Math.random() * 30
const cloudOp = 0.25 - layer * 0.05
for (let j = 0; j < 4 + Math.floor(Math.random() * 3); j++) {
const cloud = k.add([
k.circle(15 + Math.random() * 20),
k.pos(cx + j * 14 - 20, cy + (Math.random() - 0.5) * 12),
k.color(safeColor(k, layer === 0 ? '#1a2030' : '#151a28')),
k.opacity(cloudOp), k.z(1),
])
const bx = cloud.pos.x
cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * (0.08 + layer * 0.04) + i) * (8 + layer * 4) })
}
}
}
// Data rain streams
for (let i = 0; i < 15; i++) {
const stream = k.add([
k.rect(1, 8 + Math.random() * 15), k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k, '#4488ff')), k.opacity(0.12), k.z(2),
])
stream.onUpdate(() => {
stream.pos.y += 30 * k.dt()
if (stream.pos.y > GROUND_Y) { stream.pos.y = -20; stream.pos.x = Math.random() * W }
})
}
// Lightning flashes
const lightning = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(3)])
lightning.onUpdate(() => { lightning.opacity = Math.random() > 0.998 ? 0.06 : 0 })
// Rainbow arc (subtle)
const rainbowColors = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff']
for (let i = 0; i < rainbowColors.length; i++) {
k.add([
k.circle(120 + i * 6), k.pos(W * 0.3, GROUND_Y + 60), k.anchor('center'),
k.color(safeColor(k, rainbowColors[i])), k.opacity(0.03), k.z(0),
])
}
} else if (a === 'the_singularity') {
// Central swirling vortex with multiple rings
for (let ring = 0; ring < 4; ring++) {
const ringDots = 12 + ring * 6
for (let i = 0; i < ringDots; i++) {
const angle = (i / ringDots) * Math.PI * 2
const baseDist = 20 + ring * 30
const dot = k.add([
k.circle(1.5 + ring * 0.5), k.pos(W / 2, GROUND_Y * 0.4),
k.color(safeColor(k, ring === 0 ? '#ffffff' : ring === 1 ? '#ff44ff' : ring === 2 ? '#ff00ff' : '#aa00aa')),
k.opacity(0.3 - ring * 0.05), k.z(1),
])
dot.onUpdate(() => {
const a2 = angle + k.time() * (0.8 - ring * 0.15)
const d2 = baseDist + Math.sin(k.time() * 0.8 + i) * 12
dot.pos.x = W / 2 + Math.cos(a2) * d2
dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5
})
}
}
// Central core with pulsing glow
const core = k.add([k.circle(12), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.15), k.z(1)])
k.add([k.circle(30), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#ff00ff')), k.opacity(0.06), k.z(0)])
k.add([k.circle(60), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#880088')), k.opacity(0.03), k.z(0)])
core.onUpdate(() => { core.opacity = 0.1 + Math.sin(k.time() * 2) * 0.08 })
// Energy tendrils reaching outward
for (let i = 0; i < 8; i++) {
const tendril = k.add([
k.rect(2, 40 + Math.random() * 60), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('top'),
k.color(safeColor(k, '#ff00ff')), k.opacity(0.06), k.z(0), k.rotate(i * 45),
])
tendril.onUpdate(() => { tendril.angle = i * 45 + k.time() * 15 })
}
// Warped space grid lines
for (let i = 0; i < 6; i++) {
const gridLine = k.add([
k.rect(W, 1), k.pos(0, GROUND_Y * (0.15 + i * 0.12)),
k.color(safeColor(k, '#440066')), k.opacity(0.08), k.z(0),
])
gridLine.onUpdate(() => { gridLine.pos.x = Math.sin(k.time() * 0.3 + i) * 10 - 5 })
}
} else if (a === 'silicon_valley_dojo') {
// Bamboo stalks with segments and leaves
for (const side of [0.04, 0.08, 0.12, 0.86, 0.91, 0.96]) {
const px = W * side
const segments = 5 + Math.floor(Math.random() * 3)
const segH = (GROUND_Y - 15) / segments
for (let s = 0; s < segments; s++) {
k.add([k.rect(5, segH - 2), k.pos(px, 15 + s * segH), k.color(safeColor(k, '#1a3a1a')), k.opacity(0.45), k.z(1)])
k.add([k.rect(7, 2), k.pos(px - 1, 15 + s * segH), k.color(safeColor(k, '#2a4a2a')), k.opacity(0.4), k.z(1)])
}
for (let l = 0; l < 3; l++) {
const ly = 20 + l * (GROUND_Y / 4)
const dir = Math.random() > 0.5 ? 1 : -1
const leaf = k.add([k.rect(18, 3), k.pos(px + dir * 4, ly), k.color(safeColor(k, '#22aa33')), k.opacity(0.2), k.z(1), k.rotate(dir * 25)])
leaf.onUpdate(() => { leaf.angle = dir * 25 + Math.sin(k.time() * 1.5 + l) * 3 })
k.add([k.rect(14, 2), k.pos(px + dir * 8, ly + 4), k.color(safeColor(k, '#1a8822')), k.opacity(0.15), k.z(1), k.rotate(dir * 35)])
}
}
// Zen garden raked lines
for (let i = 0; i < 8; i++) {
const gy = GROUND_Y + 5 + i * 8
k.add([k.rect(W * 0.6, 1), k.pos(W * 0.2, gy), k.color(safeColor(k, '#00ff41')), k.opacity(0.06), k.z(0)])
}
// Stone lanterns with warm glow
for (const lx of [W * 0.2, W * 0.8]) {
k.add([k.rect(10, 6), k.pos(lx, GROUND_Y - 20), k.color(safeColor(k, '#444444')), k.opacity(0.35), k.z(1)])
k.add([k.rect(6, 14), k.pos(lx + 2, GROUND_Y - 34), k.color(safeColor(k, '#555555')), k.opacity(0.3), k.z(1)])
k.add([k.rect(12, 3), k.pos(lx - 1, GROUND_Y - 37), k.color(safeColor(k, '#555555')), k.opacity(0.3), k.z(1)])
const glow = k.add([k.circle(8), k.pos(lx + 5, GROUND_Y - 27), k.anchor('center'), k.color(safeColor(k, '#ffaa33')), k.opacity(0.15), k.z(2)])
glow.onUpdate(() => { glow.opacity = 0.1 + Math.sin(k.time() * 2 + lx) * 0.06 })
}
// Cherry blossom petals falling
for (let i = 0; i < 12; i++) {
const petal = k.add([
k.circle(1.5 + Math.random()), k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k, Math.random() > 0.5 ? '#ffaacc' : '#ff88aa')), k.opacity(0.2), k.z(3),
])
const startX = petal.pos.x
petal.onUpdate(() => {
petal.pos.y += 8 * k.dt()
petal.pos.x = startX + Math.sin(k.time() * 1.2 + i) * 15
if (petal.pos.y > GROUND_Y) { petal.pos.y = -5 }
})
}
} else if (a === 'hacker_news' || a === 'stackoverflow_ruins') {
// Ruined building walls with cracks
for (let i = 0; i < 3; i++) {
const wallX = W * (0.15 + i * 0.3)
const wallH = GROUND_Y * (0.4 + Math.random() * 0.3)
k.add([k.rect(W * 0.2, wallH), k.pos(wallX, GROUND_Y - wallH), k.color(safeColor(k, '#1a1408')), k.opacity(0.3), k.z(0)])
for (let c = 0; c < 3; c++) {
k.add([k.rect(1, 10 + Math.random() * 15), k.pos(wallX + Math.random() * W * 0.18, GROUND_Y - wallH + Math.random() * wallH), k.color(safeColor(k, '#0a0800')), k.opacity(0.3), k.z(0), k.rotate(Math.random() * 30 - 15)])
}
}
// Code blocks floating
for (let i = 0; i < 14; i++) {
const bw = 25 + Math.random() * 60
const bh = 3 + Math.random() * 4
const bx = 15 + Math.random() * (W - 50)
const by = 15 + Math.random() * (GROUND_Y - 50)
k.add([k.rect(bw, bh), k.pos(bx, by), k.color(safeColor(k, acc)), k.opacity(0.06 + Math.random() * 0.06), k.z(1)])
if (Math.random() < 0.4) {
k.add([k.rect(2, bh), k.pos(bx - 4, by), k.color(safeColor(k, acc)), k.opacity(0.1), k.z(1)])
}
}
// Vote arrows
for (let i = 0; i < 5; i++) {
const ax = 20 + Math.random() * (W - 40)
const ay = 30 + Math.random() * (GROUND_Y - 70)
k.add([k.rect(4, 6), k.pos(ax, ay), k.color(safeColor(k, a === 'stackoverflow_ruins' ? '#f48024' : '#ff6600')), k.opacity(0.12), k.z(1)])
}
// Broken monitors with static
for (let i = 0; i < 3; i++) {
const mx = W * (0.2 + i * 0.3)
const my = GROUND_Y * 0.3 + Math.random() * 30
k.add([k.rect(22, 16), k.pos(mx, my), k.color(safeColor(k, '#0a0a0a')), k.opacity(0.5), k.z(1)])
k.add([k.rect(20, 14), k.pos(mx + 1, my + 1), k.color(safeColor(k, '#111111')), k.opacity(0.4), k.z(1)])
const staticEl = k.add([k.rect(18, 12), k.pos(mx + 2, my + 2), k.color(safeColor(k, '#888888')), k.opacity(0), k.z(2)])
staticEl.onUpdate(() => { staticEl.opacity = Math.random() > 0.92 ? 0.08 : 0 })
}
// Scrolling text fragments
for (let i = 0; i < 4; i++) {
const txt = k.add([
k.rect(30 + Math.random() * 40, 2), k.pos(Math.random() * W, 20 + i * (GROUND_Y / 5)),
k.color(safeColor(k, acc)), k.opacity(0.08), k.z(2),
])
txt.onUpdate(() => {
txt.pos.x -= 12 * k.dt()
if (txt.pos.x < -60) txt.pos.x = W + 20
})
}
} else if (a === 'beach') {
// Sun with rays and glow
const sunX = W * 0.8, sunY = 35
k.add([k.circle(45), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffdd44')), k.opacity(0.06), k.z(0)])
k.add([k.circle(30), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffdd44')), k.opacity(0.1), k.z(0)])
const sun = k.add([k.circle(18), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.5), k.z(1)])
sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 })
for (let r = 0; r < 8; r++) {
const angle = r * Math.PI / 4
const ray = k.add([k.rect(50, 2), k.pos(sunX, sunY), k.anchor('left'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.08), k.z(0), k.rotate(angle * 180 / Math.PI)])
ray.onUpdate(() => { ray.opacity = 0.05 + Math.sin(k.time() * 0.8 + r) * 0.04 })
}
// Ocean with layered waves
k.add([k.rect(W, 25), k.pos(0, GROUND_Y - 35), k.color(safeColor(k, '#1a4488')), k.opacity(0.2), k.z(1)])
for (let layer = 0; layer < 4; layer++) {
for (let i = 0; i < 5; i++) {
const wave = k.add([
k.rect(W * 0.3, 3 + layer), k.pos(Math.random() * W, GROUND_Y - 22 - layer * 6 - i * 2),
k.color(safeColor(k, layer < 2 ? '#3377bb' : '#2266aa')), k.opacity(0.18 - layer * 0.03), k.z(2),
])
const baseX = wave.pos.x
wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * (0.6 + layer * 0.2) + i * 1.5) * (15 + layer * 5) })
}
}
// Surf foam at shore
for (let i = 0; i < 6; i++) {
const foam = k.add([
k.rect(15 + Math.random() * 25, 2), k.pos(Math.random() * W, GROUND_Y - 6 + Math.random() * 4),
k.color(safeColor(k, '#ffffff')), k.opacity(0.12), k.z(3),
])
const baseX = foam.pos.x
foam.onUpdate(() => { foam.pos.x = baseX + Math.sin(k.time() * 0.8 + i) * 8; foam.opacity = 0.06 + Math.sin(k.time() + i) * 0.06 })
}
// Palm trees with segmented trunks, coconuts, swaying fronds
for (const px of [W * 0.05, W * 0.14, W * 0.88, W * 0.95]) {
const lean = px < W / 2 ? 8 : -8
const height = 8 + Math.floor(Math.random() * 3)
for (let s = 0; s < height; s++) {
const sy = GROUND_Y - 10 - s * 12
const segW = 7 - s * 0.4
k.add([k.rect(segW, 13), k.pos(px + s * lean / height, sy), k.color(safeColor(k, s % 2 === 0 ? '#3a2510' : '#2f1f0d')), k.opacity(0.5), k.z(1)])
k.add([k.rect(segW + 1, 1), k.pos(px + s * lean / height - 0.5, sy + 6), k.color(safeColor(k, '#4a3520')), k.opacity(0.2), k.z(1)])
}
const topX = px + lean, topY = GROUND_Y - 10 - height * 12
for (let c = 0; c < 2 + Math.floor(Math.random() * 2); c++) {
k.add([k.circle(2.5), k.pos(topX + (c - 1) * 4, topY + 8), k.anchor('center'), k.color(safeColor(k, '#553311')), k.opacity(0.4), k.z(1)])
}
for (let f = 0; f < 7; f++) {
const angle = -70 + f * 22
const frond = k.add([k.rect(35 + Math.random() * 10, 3), k.pos(topX, topY), k.anchor('left'), k.color(safeColor(k, f % 2 === 0 ? '#1a6600' : '#228800')), k.opacity(0.35), k.z(1), k.rotate(angle)])
frond.onUpdate(() => { frond.angle = angle + Math.sin(k.time() * 0.8 + f) * 3 })
}
}
// Seagulls drifting
for (let i = 0; i < 4; i++) {
const bx = W * 0.2 + Math.random() * W * 0.5
const by = 12 + Math.random() * 25
const wing1 = k.add([k.rect(6, 1), k.pos(bx, by), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1), k.rotate(-15)])
const wing2 = k.add([k.rect(6, 1), k.pos(bx + 5, by), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1), k.rotate(15)])
const startBx = bx
wing1.onUpdate(() => { wing1.pos.x = startBx + Math.sin(k.time() * 0.2 + i) * 30; wing2.pos.x = wing1.pos.x + 5 })
}
// Beach umbrella
const umbX = W * 0.35
k.add([k.rect(2, 30), k.pos(umbX, GROUND_Y - 30), k.color(safeColor(k, '#aa8844')), k.opacity(0.4), k.z(1)])
k.add([k.circle(16), k.pos(umbX + 1, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k, '#ff4444')), k.opacity(0.2), k.z(1)])
k.add([k.circle(14), k.pos(umbX + 1, GROUND_Y - 31), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.08), k.z(1)])
} else if (a === 'desert') {
// Layered sand dunes
for (let d = 0; d < 5; d++) {
const dx = W * (0.1 + d * 0.2) + (Math.random() - 0.5) * 40
const dr = 50 + d * 15 + Math.random() * 20
k.add([k.circle(dr), k.pos(dx, GROUND_Y + dr * 0.6), k.anchor('center'), k.color(safeColor(k, '#aa8844')), k.opacity(0.15 + d * 0.03), k.z(1)])
}
// Road with dashed center line
k.add([k.rect(W * 0.25, GROUND_Y * 0.5), k.pos(W * 0.38, GROUND_Y * 0.3), k.color(safeColor(k, '#222222')), k.opacity(0.15), k.z(0)])
k.add([k.rect(2, GROUND_Y * 0.5), k.pos(W * 0.38, GROUND_Y * 0.3), k.color(safeColor(k, '#aaaa44')), k.opacity(0.08), k.z(0)])
k.add([k.rect(2, GROUND_Y * 0.5), k.pos(W * 0.63, GROUND_Y * 0.3), k.color(safeColor(k, '#aaaa44')), k.opacity(0.08), k.z(0)])
for (let d = 0; d < 8; d++) {
k.add([k.rect(2, 8), k.pos(W * 0.505, GROUND_Y * 0.32 + d * 16), k.color(safeColor(k, '#ffff44')), k.opacity(0.1), k.z(0)])
}
// Cacti with arms and flowers
for (const cx of [W * 0.08, W * 0.25, W * 0.75, W * 0.9]) {
const ch = 35 + Math.random() * 20
k.add([k.rect(7, ch), k.pos(cx, GROUND_Y - ch), k.color(safeColor(k, '#226622')), k.opacity(0.45), k.z(1)])
const armY = GROUND_Y - ch * 0.6
k.add([k.rect(12, 5), k.pos(cx - 12, armY), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
k.add([k.rect(5, 15), k.pos(cx - 12, armY - 15), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
if (Math.random() > 0.3) {
const armY2 = GROUND_Y - ch * 0.4
k.add([k.rect(10, 5), k.pos(cx + 7, armY2), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
k.add([k.rect(5, 12), k.pos(cx + 12, armY2 - 12), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
}
if (Math.random() > 0.5) {
k.add([k.circle(2.5), k.pos(cx + 3, GROUND_Y - ch - 2), k.anchor('center'), k.color(safeColor(k, '#ff4488')), k.opacity(0.3), k.z(2)])
}
}
// Tumbleweeds rolling
for (let i = 0; i < 3; i++) {
const tumble = k.add([k.circle(6 + Math.random() * 4), k.pos(-20 - i * 100, GROUND_Y - 8 - Math.random() * 5), k.color(safeColor(k, '#886644')), k.opacity(0.3), k.z(2)])
const speed = 15 + Math.random() * 15
tumble.onUpdate(() => { tumble.pos.x = ((tumble.pos.x + speed * k.dt()) % (W + 60)) - 30 })
}
// Heat shimmer
for (let i = 0; i < 4; i++) {
const shimmer = k.add([k.rect(W, 1), k.pos(0, GROUND_Y - 3 - i * 3), k.color(safeColor(k, '#ff8800')), k.opacity(0.06), k.z(2)])
shimmer.onUpdate(() => { shimmer.opacity = 0.03 + Math.sin(k.time() * 3 + i * 2) * 0.03 })
}
// Vulture silhouettes circling
for (let i = 0; i < 2; i++) {
const vx = W * 0.4 + i * W * 0.2
const vy = 25 + i * 15
const v1 = k.add([k.rect(8, 1), k.pos(vx, vy), k.color(safeColor(k, '#222222')), k.opacity(0.25), k.z(1), k.rotate(-10)])
const v2 = k.add([k.rect(8, 1), k.pos(vx + 7, vy), k.color(safeColor(k, '#222222')), k.opacity(0.25), k.z(1), k.rotate(10)])
const startVx = vx
v1.onUpdate(() => {
const cx = startVx + Math.cos(k.time() * 0.15 + i) * 40
const cy = vy + Math.sin(k.time() * 0.15 + i) * 15
v1.pos.x = cx; v1.pos.y = cy; v2.pos.x = cx + 7; v2.pos.y = cy
})
}
} else if (a === 'forest') {
// Layered trees (3 depth layers)
for (let layer = 0; layer < 3; layer++) {
const treeCount = layer === 0 ? 14 : layer === 1 ? 8 : 5
const treeOp = layer === 0 ? 0.15 : layer === 1 ? 0.3 : 0.4
for (let i = 0; i < treeCount; i++) {
const tx = 10 + (i / treeCount) * (W - 20) + (Math.random() - 0.5) * 15
const th = (25 + Math.random() * 35) * (layer === 0 ? 0.6 : layer === 1 ? 0.85 : 1)
const trunkW = layer === 0 ? 4 : layer === 1 ? 6 : 8
k.add([k.rect(trunkW, th), k.pos(tx, GROUND_Y - th), k.color(safeColor(k, layer === 0 ? '#0f1a08' : layer === 1 ? '#152010' : '#1a2a10')), k.opacity(treeOp), k.z(layer)])
for (let c = 0; c < 3 + layer; c++) {
const cr = (10 + Math.random() * 14) * (layer === 0 ? 0.6 : layer === 1 ? 0.8 : 1)
k.add([
k.circle(cr), k.pos(tx + trunkW / 2 + (Math.random() - 0.5) * 10, GROUND_Y - th - cr * 0.5 + c * 4),
k.anchor('center'), k.color(safeColor(k, layer === 0 ? '#0a1a08' : layer === 1 ? '#122a0e' : '#1a3a10')), k.opacity(treeOp * 0.8), k.z(layer),
])
}
}
}
// Undergrowth
for (let i = 0; i < 10; i++) {
k.add([k.circle(6 + Math.random() * 8), k.pos(Math.random() * W, GROUND_Y - 3), k.anchor('bot'), k.color(safeColor(k, '#1a3a10')), k.opacity(0.3), k.z(3)])
}
// Mushrooms
for (let i = 0; i < 5; i++) {
const mx = Math.random() * W
k.add([k.rect(2, 5), k.pos(mx, GROUND_Y - 5), k.color(safeColor(k, '#aa8866')), k.opacity(0.25), k.z(3)])
k.add([k.circle(4), k.pos(mx + 1, GROUND_Y - 6), k.anchor('center'), k.color(safeColor(k, Math.random() > 0.5 ? '#cc3333' : '#ffaa22')), k.opacity(0.2), k.z(3)])
}
// Fireflies
for (let i = 0; i < 15; i++) {
const ff = k.add([
k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)),
k.color(safeColor(k, '#ccff44')), k.opacity(0), k.z(3),
])
const bx = ff.pos.x, by = ff.pos.y
ff.onUpdate(() => {
ff.opacity = Math.max(0, Math.sin(k.time() * 1.5 + i * 1.8) * 0.5)
ff.pos.x = bx + Math.sin(k.time() * 0.5 + i) * 15
ff.pos.y = by + Math.cos(k.time() * 0.4 + i) * 10
})
}
// Light rays through canopy
for (let i = 0; i < 5; i++) {
const rx = W * (0.15 + i * 0.18) + (Math.random() - 0.5) * 20
const ray = k.add([k.rect(8, GROUND_Y * 0.7), k.pos(rx, 0), k.color(safeColor(k, '#aaff44')), k.opacity(0.03), k.z(2), k.rotate(3 - Math.random() * 6)])
ray.onUpdate(() => { ray.opacity = 0.02 + Math.sin(k.time() * 0.3 + i) * 0.015 })
}
// Fallen log
k.add([k.rect(45, 6), k.pos(W * 0.55, GROUND_Y - 4), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(3), k.rotate(3)])
} else if (a === 'jungle') {
// Dense vine canopy
for (let i = 0; i < 12; i++) {
const vx = 15 + i * (W / 12) + (Math.random() - 0.5) * 12
const vineLen = 30 + Math.random() * 90
for (let s = 0; s < vineLen; s += 4) {
const vineEl = k.add([k.rect(2, 5), k.pos(vx + Math.sin(s * 0.15) * 4, s), k.color(safeColor(k, '#1a4a10')), k.opacity(0.3), k.z(1)])
if (Math.random() < 0.08) {
const basePx = vineEl.pos.x
vineEl.onUpdate(() => { vineEl.pos.x = basePx + Math.sin(k.time() * 0.5 + s) * 2 })
}
}
for (let l = 0; l < 2; l++) {
k.add([k.circle(5 + Math.random() * 4), k.pos(vx + (Math.random() - 0.5) * 8, vineLen + l * 6), k.color(safeColor(k, '#22aa22')), k.opacity(0.22), k.z(1)])
}
}
// Large tropical leaves at edges
for (const side of [0, W - 30]) {
for (let l = 0; l < 4; l++) {
const ly = 15 + l * (GROUND_Y / 5)
const leaf = k.add([k.rect(35, 8), k.pos(side, ly), k.color(safeColor(k, '#1a5510')), k.opacity(0.25), k.z(3), k.rotate(side === 0 ? 15 : -15)])
leaf.onUpdate(() => { leaf.angle = (side === 0 ? 15 : -15) + Math.sin(k.time() * 0.6 + l) * 2 })
}
}
// Exotic flowers
for (let i = 0; i < 6; i++) {
const fx = Math.random() * W
const fy = 20 + Math.random() * (GROUND_Y - 40)
k.add([k.circle(3), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, ['#ff4488', '#ff8844', '#ffcc00', '#ff44ff', '#44ffcc', '#ff2222'][i])), k.opacity(0.2), k.z(2)])
}
// Ancient stone ruins peeking through
const ruinX = W * 0.5
k.add([k.rect(8, 40), k.pos(ruinX, GROUND_Y - 38), k.color(safeColor(k, '#444444')), k.opacity(0.15), k.z(0)])
k.add([k.rect(8, 35), k.pos(ruinX + 25, GROUND_Y - 33), k.color(safeColor(k, '#444444')), k.opacity(0.12), k.z(0)])
k.add([k.rect(35, 5), k.pos(ruinX - 2, GROUND_Y - 40), k.color(safeColor(k, '#444444')), k.opacity(0.1), k.z(0)])
// Thick ground fog
for (let i = 0; i < 10; i++) {
const mist = k.add([
k.circle(40 + Math.random() * 35), k.pos(Math.random() * W, GROUND_Y - 12),
k.color(safeColor(k, '#1a3818')), k.opacity(0.22), k.z(3),
])
const bx = mist.pos.x
mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.12 + i) * 18 })
}
// Butterfly particles
for (let i = 0; i < 4; i++) {
const bf = k.add([
k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 60)),
k.color(safeColor(k, ['#4488ff', '#ff8844', '#ffcc00', '#ff44ff'][i])), k.opacity(0.25), k.z(3),
])
const bfx = bf.pos.x, bfy = bf.pos.y
bf.onUpdate(() => {
bf.pos.x = bfx + Math.sin(k.time() * 1.2 + i * 3) * 30
bf.pos.y = bfy + Math.cos(k.time() * 0.8 + i * 2) * 20
})
}
} else if (a === 'outer_space') {
// Dense star field
for (let i = 0; i < 100; i++) {
const star = k.add([
k.circle(0.5 + Math.random() * 1.5),
k.pos(Math.random() * W, Math.random() * H),
k.color(safeColor(k, i % 8 === 0 ? '#ffccaa' : i % 5 === 0 ? '#aaaaff' : '#ffffff')),
k.opacity(0.15 + Math.random() * 0.4), k.z(1),
])
star.onUpdate(() => { star.opacity = 0.12 + Math.sin(k.time() * (0.8 + Math.random()) + i) * 0.15 })
}
// Nebula clouds
const nebulaColors = ['#4400aa', '#aa0066', '#0044aa', '#006644', '#660044']
for (let i = 0; i < 5; i++) {
for (let blob = 0; blob < 4; blob++) {
k.add([
k.circle(30 + Math.random() * 50),
k.pos(W * (0.1 + i * 0.2) + blob * 18, H * 0.25 + Math.random() * 60 + blob * 12),
k.color(safeColor(k, nebulaColors[i % nebulaColors.length])),
k.opacity(0.04 + blob * 0.012), k.z(0),
])
}
}
// Floating asteroids
for (let i = 0; i < 6; i++) {
const ast = k.add([
k.circle(4 + Math.random() * 10),
k.pos(Math.random() * W, 15 + Math.random() * (GROUND_Y - 30)),
k.color(safeColor(k, Math.random() > 0.5 ? '#555555' : '#3a3a3a')), k.opacity(0.35), k.z(1),
])
k.add([k.circle(2), k.pos(ast.pos.x + 2, ast.pos.y - 1), k.color(safeColor(k, '#2a2a2a')), k.opacity(0.2), k.z(1)])
const baseX = ast.pos.x, baseY = ast.pos.y
ast.onUpdate(() => {
ast.pos.x = baseX + Math.sin(k.time() * 0.15 + i * 2) * 18
ast.pos.y = baseY + Math.cos(k.time() * 0.12 + i) * 12
})
}
// Distant planet with rings
const planetX = W * 0.75, planetY = GROUND_Y * 0.25
k.add([k.circle(22), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k, '#334466')), k.opacity(0.25), k.z(0)])
k.add([k.circle(24), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k, '#223344')), k.opacity(0.1), k.z(0)])
for (let r = 0; r < 3; r++) {
k.add([k.rect(50 + r * 8, 2), k.pos(planetX - 25 - r * 4, planetY - 1 + r), k.color(safeColor(k, '#556688')), k.opacity(0.1 - r * 0.02), k.z(0), k.rotate(15)])
}
// Shooting stars
for (let i = 0; i < 2; i++) {
const shootStar = k.add([
k.rect(12, 1), k.pos(-20, Math.random() * GROUND_Y * 0.5),
k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(2), k.rotate(30),
])
let timer = Math.random() * 10
shootStar.onUpdate(() => {
timer -= k.dt()
if (timer <= 0) {
shootStar.pos.x = -20
shootStar.pos.y = Math.random() * GROUND_Y * 0.4
shootStar.opacity = 0.5
timer = 5 + Math.random() * 10
}
if (shootStar.opacity > 0) {
shootStar.pos.x += 200 * k.dt()
shootStar.pos.y += 80 * k.dt()
shootStar.opacity -= 0.8 * k.dt()
}
})
}
} else if (a === 'colosseum') {
// Roman arches along the back
for (let i = 0; i < 8; i++) {
const ax = 20 + i * (W / 8)
const aw = W / 8 - 8
k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k, '#3a2a18')), k.opacity(0.4), k.z(1)])
k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax + aw - 6, GROUND_Y * 0.2), k.color(safeColor(k, '#3a2a18')), k.opacity(0.4), k.z(1)])
k.add([k.rect(aw, 6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k, '#4a3a20')), k.opacity(0.4), k.z(1)])
k.add([k.rect(aw - 12, GROUND_Y * 0.5), k.pos(ax + 6, GROUND_Y * 0.25), k.color(safeColor(k, '#0a0800')), k.opacity(0.3), k.z(0)])
k.add([k.rect(10, 3), k.pos(ax - 2, GROUND_Y * 0.2), k.color(safeColor(k, '#5a4a30')), k.opacity(0.35), k.z(1)])
k.add([k.rect(10, 3), k.pos(ax + aw - 10, GROUND_Y * 0.2), k.color(safeColor(k, '#5a4a30')), k.opacity(0.35), k.z(1)])
}
// Torch brackets on pillars
for (let i = 0; i < 4; i++) {
const tx = W * (0.15 + i * 0.25)
const ty = GROUND_Y * 0.35
k.add([k.rect(3, 12), k.pos(tx, ty), k.color(safeColor(k, '#554422')), k.opacity(0.4), k.z(2)])
const flame = k.add([k.circle(5), k.pos(tx + 1, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff6600')), k.opacity(0.4), k.z(2)])
flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + i * 2) * 0.15 })
}
// Crowd silhouettes in upper tiers
for (let row = 0; row < 2; row++) {
for (let i = 0; i < 20; i++) {
k.add([
k.circle(2 + Math.random()), k.pos(15 + i * (W / 20), GROUND_Y * 0.08 + row * 12),
k.color(safeColor(k, '#2a1a10')), k.opacity(0.2), k.z(0),
])
}
}
// Crumbled stone debris
for (let i = 0; i < 8; i++) {
k.add([
k.rect(5 + Math.random() * 10, 3 + Math.random() * 6),
k.pos(Math.random() * W, GROUND_Y - 5 - Math.random() * 10),
k.color(safeColor(k, '#5a4a30')), k.opacity(0.25), k.z(2), k.rotate(Math.random() * 40 - 20),
])
}
// Sand dust particles
for (let i = 0; i < 6; i++) {
const dust = k.add([
k.circle(2 + Math.random() * 3), k.pos(Math.random() * W, GROUND_Y - 15 - Math.random() * 20),
k.color(safeColor(k, '#c0a060')), k.opacity(0.1), k.z(3),
])
const dx = dust.pos.x
dust.onUpdate(() => { dust.pos.x = dx + Math.sin(k.time() * 0.5 + i) * 12; dust.opacity = 0.05 + Math.sin(k.time() + i) * 0.05 })
}
} else if (a === 'haunted_mansion') {
// Gothic windows with moonlight
for (let i = 0; i < 5; i++) {
const wx = W * (0.1 + i * 0.2)
const wy = GROUND_Y * 0.2
k.add([k.rect(20, 35), k.pos(wx, wy), k.color(safeColor(k, '#0a0510')), k.opacity(0.4), k.z(1)])
k.add([k.rect(22, 1), k.pos(wx - 1, wy), k.color(safeColor(k, '#2a1830')), k.opacity(0.4), k.z(1)])
k.add([k.rect(22, 1), k.pos(wx - 1, wy + 35), k.color(safeColor(k, '#2a1830')), k.opacity(0.4), k.z(1)])
k.add([k.rect(1, 35), k.pos(wx + 10, wy), k.color(safeColor(k, '#2a1830')), k.opacity(0.3), k.z(1)])
k.add([k.rect(20, 1), k.pos(wx, wy + 17), k.color(safeColor(k, '#2a1830')), k.opacity(0.3), k.z(1)])
const glow = k.add([k.rect(16, 30), k.pos(wx + 2, wy + 3), k.color(safeColor(k, '#8844cc')), k.opacity(0.06), k.z(1)])
glow.onUpdate(() => { glow.opacity = 0.04 + Math.sin(k.time() * 0.5 + i) * 0.03 })
}
// Moon behind a window
k.add([k.circle(12), k.pos(W * 0.5 + 10, GROUND_Y * 0.26), k.anchor('center'), k.color(safeColor(k, '#ccccdd')), k.opacity(0.15), k.z(0)])
// Cobwebs
for (const cx of [15, W - 35]) {
for (let strand = 0; strand < 5; strand++) {
k.add([k.rect(20 + strand * 5, 1), k.pos(cx, 3 + strand * 4), k.color(safeColor(k, '#666666')), k.opacity(0.1), k.z(1), k.rotate(cx < W / 2 ? 28 + strand * 5 : -28 - strand * 5)])
}
}
// Candelabras
for (const cx of [W * 0.25, W * 0.75]) {
k.add([k.rect(2, 20), k.pos(cx, GROUND_Y - 20), k.color(safeColor(k, '#666644')), k.opacity(0.3), k.z(1)])
k.add([k.rect(12, 2), k.pos(cx - 5, GROUND_Y - 20), k.color(safeColor(k, '#666644')), k.opacity(0.3), k.z(1)])
for (let c = 0; c < 3; c++) {
const candleX = cx - 5 + c * 5
const cFlame = k.add([k.circle(2.5), k.pos(candleX + 1, GROUND_Y - 23), k.anchor('center'), k.color(safeColor(k, '#ffaa44')), k.opacity(0.4), k.z(2)])
cFlame.onUpdate(() => { cFlame.opacity = 0.3 + Math.sin(k.time() * 4 + c + cx) * 0.15 })
}
}
// Floating ghost particles
for (let i = 0; i < 8; i++) {
const ghost = k.add([
k.circle(3 + Math.random() * 3), k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k, '#aabbcc')), k.opacity(0), k.z(2),
])
const gx = ghost.pos.x, gy = ghost.pos.y
ghost.onUpdate(() => {
ghost.opacity = Math.max(0, Math.sin(k.time() * 0.6 + i * 2) * 0.15)
ghost.pos.x = gx + Math.sin(k.time() * 0.3 + i) * 20
ghost.pos.y = gy + Math.cos(k.time() * 0.25 + i) * 15
})
}
// Bats flying
for (let i = 0; i < 3; i++) {
const bat = k.add([k.rect(5, 2), k.pos(Math.random() * W, 15 + Math.random() * 40), k.color(safeColor(k, '#111111')), k.opacity(0.3), k.z(2)])
const bx = bat.pos.x, by = bat.pos.y
bat.onUpdate(() => {
bat.pos.x = bx + Math.sin(k.time() * 1.5 + i * 3) * 40
bat.pos.y = by + Math.cos(k.time() * 2 + i * 2) * 10
})
}
} else if (a === 'concert_hall') {
// Stage lights with sweeping cones
const lightColors = ['#ff2288', '#22ff88', '#4488ff', '#ffaa22', '#ff44ff', '#44ffff']
for (let i = 0; i < 6; i++) {
const lx = W * (0.1 + i * 0.15)
const cone = k.add([
k.rect(30, GROUND_Y * 0.7), k.pos(lx, 10), k.anchor('top'),
k.color(safeColor(k, lightColors[i])), k.opacity(0.04), k.z(2), k.rotate(0),
])
k.add([k.circle(4), k.pos(lx + 15, 8), k.anchor('center'), k.color(safeColor(k, lightColors[i])), k.opacity(0.4), k.z(2)])
const baseAngle = (i - 3) * 5
cone.onUpdate(() => {
cone.opacity = 0.02 + Math.sin(k.time() * 1.5 + i * 1.2) * 0.025
cone.angle = baseAngle + Math.sin(k.time() * 0.4 + i) * 8
})
}
// Speaker stacks
for (const sx of [10, W - 30]) {
for (let s = 0; s < 4; s++) {
k.add([k.rect(20, 18), k.pos(sx, GROUND_Y - 20 - s * 20), k.color(safeColor(k, '#111111')), k.opacity(0.5), k.z(1)])
k.add([k.circle(6), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k, '#222222')), k.opacity(0.4), k.z(1)])
const speakerCone = k.add([k.circle(4), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k, '#1a1a1a')), k.opacity(0.3), k.z(1), k.scale(1)])
speakerCone.onUpdate(() => { speakerCone.scale = k.vec2(1 + Math.sin(k.time() * 8 + s) * 0.05) })
}
}
// Crowd silhouettes
for (let i = 0; i < 30; i++) {
const cx = 5 + i * (W / 30)
const ch = 6 + Math.random() * 4
k.add([k.circle(2.5), k.pos(cx, GROUND_Y + 8), k.anchor('center'), k.color(safeColor(k, '#111111')), k.opacity(0.3), k.z(3)])
k.add([k.rect(4, ch), k.pos(cx - 2, GROUND_Y + 10), k.color(safeColor(k, '#111111')), k.opacity(0.25), k.z(3)])
}
// Smoke machine haze
for (let i = 0; i < 6; i++) {
const haze = k.add([
k.circle(30 + Math.random() * 25), k.pos(Math.random() * W, GROUND_Y - 8),
k.color(safeColor(k, '#20102a')), k.opacity(0.18), k.z(3),
])
const hx = haze.pos.x
haze.onUpdate(() => { haze.pos.x = hx + Math.sin(k.time() * 0.15 + i) * 15 })
}
// Laser beam sweep
const laser = k.add([k.rect(1, GROUND_Y), k.pos(W / 2, 0), k.color(safeColor(k, '#00ff00')), k.opacity(0.06), k.z(2)])
laser.onUpdate(() => { laser.pos.x = W / 2 + Math.sin(k.time() * 0.8) * (W * 0.4) })
} else if (a === 'sports_arena') {
// Stadium seating tiers with crowd
for (let tier = 0; tier < 5; tier++) {
const ty = GROUND_Y * 0.1 + tier * (GROUND_Y * 0.13)
k.add([k.rect(W, GROUND_Y * 0.1), k.pos(0, ty), k.color(safeColor(k, tier % 2 === 0 ? '#1a2a4a' : '#152040')), k.opacity(0.3), k.z(1)])
for (let i = 0; i < 18; i++) {
k.add([
k.circle(1.5 + Math.random()), k.pos(15 + i * (W / 18), ty + GROUND_Y * 0.04),
k.color(safeColor(k, ['#cc3333', '#3333cc', '#33cc33', '#cccc33', '#ffffff'][Math.floor(Math.random() * 5)])),
k.opacity(0.12), k.z(1),
])
}
}
// Floodlights
for (const fx of [W * 0.05, W * 0.95]) {
k.add([k.rect(3, GROUND_Y * 0.7), k.pos(fx, GROUND_Y * 0.1), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(2)])
const light = k.add([k.circle(5), k.pos(fx + 1, GROUND_Y * 0.08), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(2)])
k.add([k.rect(40, GROUND_Y * 0.6), k.pos(fx - 18, GROUND_Y * 0.12), k.color(safeColor(k, '#ffffff')), k.opacity(0.015), k.z(0)])
light.onUpdate(() => { light.opacity = 0.4 + Math.sin(k.time() * 0.5 + fx) * 0.1 })
}
// Scoreboard
k.add([k.rect(50, 18), k.pos(W / 2 - 25, 8), k.color(safeColor(k, '#0a0a20')), k.opacity(0.5), k.z(2)])
k.add([k.rect(48, 16), k.pos(W / 2 - 24, 9), k.color(safeColor(k, '#111133')), k.opacity(0.4), k.z(2)])
const scoreGlow = k.add([k.rect(20, 8), k.pos(W / 2 - 10, 13), k.color(safeColor(k, '#ff3333')), k.opacity(0.12), k.z(2)])
scoreGlow.onUpdate(() => { scoreGlow.opacity = 0.08 + Math.sin(k.time() * 2) * 0.05 })
// Camera flashes
for (let i = 0; i < 5; i++) {
const flash = k.add([
k.circle(2), k.pos(Math.random() * W, GROUND_Y * (0.15 + Math.random() * 0.5)),
k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(3),
])
flash.onUpdate(() => { flash.opacity = Math.random() > 0.995 ? 0.4 : 0 })
}
} else if (a === 'kitchen_stadium') {
// Tiled wall
for (let row = 0; row < 6; row++) {
for (let col = 0; col < 14; col++) {
k.add([
k.rect(W / 14 - 1, GROUND_Y / 6 - 1), k.pos(col * (W / 14), row * (GROUND_Y / 6)),
k.color(safeColor(k, (row + col) % 2 === 0 ? '#1a1210' : '#1e1614')), k.opacity(0.2), k.z(0),
])
}
}
// Stove with burners
k.add([k.rect(45, 35), k.pos(W * 0.06, GROUND_Y - 35), k.color(safeColor(k, '#1a1010')), k.opacity(0.45), k.z(1)])
k.add([k.rect(43, 2), k.pos(W * 0.06 + 1, GROUND_Y - 35), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(1)])
for (let b = 0; b < 4; b++) {
const flame = k.add([k.circle(4), k.pos(W * 0.06 + 6 + b * 10, GROUND_Y - 37), k.anchor('center'), k.color(safeColor(k, '#ff4400')), k.opacity(0.4), k.z(2)])
const innerFlame = k.add([k.circle(2), k.pos(W * 0.06 + 6 + b * 10, GROUND_Y - 38), k.anchor('center'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.3), k.z(2)])
flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + b) * 0.15; innerFlame.opacity = 0.2 + Math.sin(k.time() * 7 + b) * 0.1 })
}
// Hanging pots and pans
k.add([k.rect(W * 0.5, 2), k.pos(W * 0.2, 8), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1)])
for (let i = 0; i < 6; i++) {
const px = W * (0.22 + i * 0.08)
const hookLen = 12 + Math.random() * 10
k.add([k.rect(1, hookLen), k.pos(px, 10), k.color(safeColor(k, '#444444')), k.opacity(0.25), k.z(1)])
k.add([k.circle(7 + Math.random() * 4), k.pos(px, 10 + hookLen + 5), k.anchor('center'), k.color(safeColor(k, i % 2 === 0 ? '#555555' : '#664422')), k.opacity(0.25), k.z(1)])
}
// Knife rack
k.add([k.rect(3, 25), k.pos(W * 0.85, GROUND_Y * 0.3), k.color(safeColor(k, '#553322')), k.opacity(0.35), k.z(1)])
for (let i = 0; i < 4; i++) {
k.add([k.rect(2, 10 + Math.random() * 5), k.pos(W * 0.85 + 1, GROUND_Y * 0.32 + i * 5), k.color(safeColor(k, '#aaaaaa')), k.opacity(0.2), k.z(1)])
}
// Steam rising
for (let i = 0; i < 4; i++) {
const steam = k.add([
k.circle(5 + Math.random() * 5), k.pos(W * 0.06 + 10 + i * 10, GROUND_Y - 40),
k.color(safeColor(k, '#ffffff')), k.opacity(0.06), k.z(3),
])
const bx = steam.pos.x
steam.onUpdate(() => {
steam.pos.y -= 10 * k.dt()
steam.pos.x = bx + Math.sin(k.time() * 2 + i) * 4
steam.opacity *= 0.995
if (steam.pos.y < GROUND_Y - 80) { steam.pos.y = GROUND_Y - 40; steam.opacity = 0.06 }
})
}
} else if (a === 'junkyard') {
// Rusted crane silhouette
k.add([k.rect(6, GROUND_Y * 0.7), k.pos(W * 0.85, GROUND_Y * 0.1), k.color(safeColor(k, '#443322')), k.opacity(0.3), k.z(0)])
k.add([k.rect(W * 0.3, 4), k.pos(W * 0.6, GROUND_Y * 0.1), k.color(safeColor(k, '#443322')), k.opacity(0.25), k.z(0)])
const cable = k.add([k.rect(1, 35), k.pos(W * 0.65, GROUND_Y * 0.12), k.color(safeColor(k, '#555555')), k.opacity(0.2), k.z(0)])
cable.onUpdate(() => { cable.pos.x = W * 0.65 + Math.sin(k.time() * 0.3) * 5 })
// Piles of scrap
for (let pile = 0; pile < 8; pile++) {
const px = Math.random() * W
const py = GROUND_Y - 8
for (let j = 0; j < 7; j++) {
k.add([
k.rect(8 + Math.random() * 18, 4 + Math.random() * 10),
k.pos(px + (Math.random() - 0.5) * 25, py - j * 6 - Math.random() * 5),
k.color(safeColor(k, ['#554433', '#665544', '#443322', '#776655', '#332211', '#887766', '#553322'][j % 7])),
k.opacity(0.3), k.z(1), k.rotate(Math.random() * 40 - 20),
])
}
}
// Old car silhouette
k.add([k.rect(35, 15), k.pos(W * 0.15, GROUND_Y - 15), k.color(safeColor(k, '#2a2a1a')), k.opacity(0.3), k.z(1)])
k.add([k.rect(25, 10), k.pos(W * 0.15 + 5, GROUND_Y - 25), k.color(safeColor(k, '#2a2a1a')), k.opacity(0.25), k.z(1)])
k.add([k.circle(5), k.pos(W * 0.15 + 6, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k, '#1a1a0a')), k.opacity(0.3), k.z(1)])
k.add([k.circle(5), k.pos(W * 0.15 + 29, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k, '#1a1a0a')), k.opacity(0.3), k.z(1)])
// Sparking wires
for (let i = 0; i < 3; i++) {
const spark = k.add([
k.circle(2), k.pos(W * (0.3 + i * 0.2), GROUND_Y - 20 - Math.random() * 30),
k.color(safeColor(k, '#ffaa00')), k.opacity(0), k.z(3),
])
spark.onUpdate(() => { spark.opacity = Math.random() > 0.96 ? 0.5 : 0 })
}
// Smoke/steam wisps
for (let i = 0; i < 4; i++) {
const smoke = k.add([
k.circle(10 + Math.random() * 10), k.pos(Math.random() * W, GROUND_Y - 5),
k.color(safeColor(k, '#333322')), k.opacity(0.12), k.z(2),
])
const sx = smoke.pos.x
smoke.onUpdate(() => {
smoke.pos.y -= 5 * k.dt()
smoke.pos.x = sx + Math.sin(k.time() * 0.5 + i) * 6
if (smoke.pos.y < GROUND_Y - 60) { smoke.pos.y = GROUND_Y - 5; smoke.opacity = 0.12 }
})
}
} else if (a === 'meme_dimension') {
// Floating emoji shapes (pulsing)
for (let i = 0; i < 12; i++) {
const emoji = k.add([
k.circle(6 + Math.random() * 6), k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k, ['#ffcc00', '#ff4444', '#44ff44', '#4444ff', '#ff44ff', '#44ffff'][i % 6])), k.opacity(0.12), k.z(1), k.scale(1),
])
const bx = emoji.pos.x, by = emoji.pos.y
emoji.onUpdate(() => {
emoji.pos.x = bx + Math.sin(k.time() * 0.4 + i) * 15
emoji.pos.y = by + Math.cos(k.time() * 0.3 + i) * 10
emoji.scale = k.vec2(1 + Math.sin(k.time() * 1.5 + i) * 0.15)
})
}
// Glitch bands
for (let i = 0; i < 8; i++) {
const band = k.add([
k.rect(W, 2 + Math.random() * 4), k.pos(0, Math.random() * GROUND_Y),
k.color(safeColor(k, Math.random() > 0.5 ? '#ff44ff' : '#44ffff')), k.opacity(0), k.z(2),
])
band.onUpdate(() => {
band.opacity = Math.random() > 0.97 ? 0.12 + Math.random() * 0.08 : 0
if (band.opacity > 0) band.pos.x = (Math.random() - 0.5) * 10
else band.pos.x = 0
})
}
// Rainbow pixel streaks
const rainbowC = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff']
for (let i = 0; i < rainbowC.length; i++) {
const streak = k.add([
k.rect(W, 3), k.pos(0, GROUND_Y * 0.3 + i * 5),
k.color(safeColor(k, rainbowC[i])), k.opacity(0.04), k.z(0),
])
streak.onUpdate(() => { streak.opacity = 0.02 + Math.sin(k.time() * 2 + i) * 0.02 })
}
// Spinning geometric shapes
for (let i = 0; i < 4; i++) {
const geo = k.add([
k.rect(8 + Math.random() * 8, 8 + Math.random() * 8),
k.pos(W * (0.2 + i * 0.2), GROUND_Y * (0.3 + Math.random() * 0.3)),
k.anchor('center'), k.color(safeColor(k, '#ff44ff')), k.opacity(0.08), k.z(1), k.rotate(0),
])
geo.onUpdate(() => { geo.angle += (30 + i * 15) * k.dt() })
}
// VHS tracking lines
const vhs = k.add([k.rect(W, 6), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(3)])
vhs.onUpdate(() => {
vhs.pos.y = (vhs.pos.y + 80 * k.dt()) % (GROUND_Y + 20)
vhs.opacity = Math.random() > 0.9 ? 0.15 : 0
})
// RGB noise flickers
for (let i = 0; i < 6; i++) {
const noise = k.add([
k.rect(3 + Math.random() * 8, 3 + Math.random() * 8),
k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k, ['#ff0000', '#00ff00', '#0000ff'][i % 3])), k.opacity(0), k.z(2),
])
noise.onUpdate(() => {
noise.opacity = Math.random() > 0.98 ? 0.1 : 0
if (noise.opacity > 0) { noise.pos.x = Math.random() * W; noise.pos.y = Math.random() * GROUND_Y }
})
}
} else if (a === 'space_station') {
// Structural beams with rivets
for (let i = 0; i < 6; i++) {
const bx = W * (0.15 + i * 0.14)
k.add([k.rect(5, GROUND_Y), k.pos(bx, 0), k.color(safeColor(k, '#1a1a30')), k.opacity(0.3), k.z(1)])
for (let r = 0; r < 8; r++) {
k.add([k.circle(1), k.pos(bx + 2, r * (GROUND_Y / 8) + 5), k.anchor('center'), k.color(safeColor(k, '#2a2a40')), k.opacity(0.25), k.z(1)])
}
k.add([k.rect(W * 0.14, 3), k.pos(bx, GROUND_Y * 0.25), k.color(safeColor(k, '#1a1a30')), k.opacity(0.25), k.z(1)])
k.add([k.rect(W * 0.14, 3), k.pos(bx, GROUND_Y * 0.6), k.color(safeColor(k, '#1a1a30')), k.opacity(0.2), k.z(1)])
}
// Viewport
const vpX = W * 0.5, vpY = GROUND_Y * 0.3
k.add([k.circle(40), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k, '#000020')), k.opacity(0.5), k.z(0)])
k.add([k.circle(42), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k, '#2a2a50')), k.opacity(0.3), k.z(0)])
for (let s = 0; s < 15; s++) {
const dist = Math.random() * 35
const angle = Math.random() * Math.PI * 2
const sx = vpX + Math.cos(angle) * dist
const sy = vpY + Math.sin(angle) * dist
const vpStar = k.add([k.circle(0.8 + Math.random()), k.pos(sx, sy), k.color(safeColor(k, '#ffffff')), k.opacity(0.3), k.z(0)])
vpStar.onUpdate(() => { vpStar.opacity = 0.15 + Math.sin(k.time() * 2 + s) * 0.15 })
}
// Earth through viewport
k.add([k.circle(15), k.pos(vpX + 10, vpY + 5), k.anchor('center'), k.color(safeColor(k, '#2244aa')), k.opacity(0.15), k.z(0)])
k.add([k.circle(6), k.pos(vpX + 7, vpY + 2), k.anchor('center'), k.color(safeColor(k, '#22aa44')), k.opacity(0.08), k.z(0)])
// Control panels
for (const px of [W * 0.08, W * 0.88]) {
k.add([k.rect(25, 40), k.pos(px, GROUND_Y * 0.4), k.color(safeColor(k, '#0a0a20')), k.opacity(0.4), k.z(1)])
for (let led = 0; led < 8; led++) {
const ledEl = k.add([
k.rect(2, 2), k.pos(px + 4 + (led % 4) * 5, GROUND_Y * 0.42 + Math.floor(led / 4) * 6),
k.color(safeColor(k, ['#00ff88', '#ff4444', '#ffaa00', '#4488ff'][led % 4])), k.opacity(0.4), k.z(2),
])
ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.05 ? 0.4 : 0.1 })
}
}
// Warning stripes
for (let i = 0; i < 8; i++) {
k.add([k.rect(8, 3), k.pos(W * 0.3 + i * 12, GROUND_Y - 3), k.color(safeColor(k, i % 2 === 0 ? '#ffaa00' : '#000000')), k.opacity(0.12), k.z(3)])
}
} else if (a === 'savanna') {
// Distant mountain range
for (let i = 0; i < 5; i++) {
const mx = W * (0.1 + i * 0.2) + (Math.random() - 0.5) * 30
const mh = 30 + Math.random() * 40
k.add([k.circle(mh), k.pos(mx, GROUND_Y - mh * 0.3), k.anchor('center'), k.color(safeColor(k, '#2a2010')), k.opacity(0.12), k.z(0)])
}
// Acacia trees with branches
for (const tx of [W * 0.1, W * 0.4, W * 0.72]) {
const trunkH = 40 + Math.random() * 20
k.add([k.rect(5, trunkH), k.pos(tx, GROUND_Y - trunkH), k.color(safeColor(k, '#2a1a08')), k.opacity(0.4), k.z(1)])
k.add([k.rect(15, 3), k.pos(tx - 10, GROUND_Y - trunkH * 0.7), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(1), k.rotate(-20)])
k.add([k.rect(12, 3), k.pos(tx + 5, GROUND_Y - trunkH * 0.6), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(1), k.rotate(15)])
k.add([k.rect(50, 5), k.pos(tx - 22, GROUND_Y - trunkH - 2), k.color(safeColor(k, '#2a3a10')), k.opacity(0.3), k.z(1)])
k.add([k.rect(40, 4), k.pos(tx - 17, GROUND_Y - trunkH - 7), k.color(safeColor(k, '#2a3a10')), k.opacity(0.25), k.z(1)])
k.add([k.rect(25, 3), k.pos(tx - 10, GROUND_Y - trunkH - 11), k.color(safeColor(k, '#223308')), k.opacity(0.2), k.z(1)])
}
// Setting sun
const sunX = W * 0.85, sunY = GROUND_Y * 0.22
k.add([k.circle(50), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#dd6622')), k.opacity(0.06), k.z(0)])
k.add([k.circle(35), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#dd6622')), k.opacity(0.12), k.z(0)])
const savSun = k.add([k.circle(22), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffaa22')), k.opacity(0.3), k.z(0)])
savSun.onUpdate(() => { savSun.opacity = 0.25 + Math.sin(k.time() * 0.3) * 0.05 })
// Tall grass swaying
for (let i = 0; i < 20; i++) {
const gx = Math.random() * W
const grassH = 5 + Math.random() * 10
const grass = k.add([k.rect(1, grassH), k.pos(gx, GROUND_Y - grassH), k.color(safeColor(k, '#8a7830')), k.opacity(0.2), k.z(3), k.rotate(0)])
grass.onUpdate(() => { grass.angle = Math.sin(k.time() * 1.2 + i) * 5 })
}
// Elephant silhouette
const ex = W * 0.6, ey = GROUND_Y - 18
k.add([k.rect(18, 12), k.pos(ex, ey), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
k.add([k.rect(8, 8), k.pos(ex + 15, ey - 4), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
k.add([k.rect(3, 8), k.pos(ex + 2, ey + 12), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
k.add([k.rect(3, 8), k.pos(ex + 13, ey + 12), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
// Birds in V formation
for (let i = 0; i < 5; i++) {
const birdX = W * 0.3 + i * 8
const birdY = 20 + Math.abs(i - 2) * 5
k.add([k.rect(4, 1), k.pos(birdX, birdY), k.color(safeColor(k, '#222222')), k.opacity(0.2), k.z(1), k.rotate(-12)])
k.add([k.rect(4, 1), k.pos(birdX + 3, birdY), k.color(safeColor(k, '#222222')), k.opacity(0.2), k.z(1), k.rotate(12)])
}
} else if (a === 'server_room') {
// Server rack rows with LEDs
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 6; col++) {
const rx = 30 + col * (W / 6)
const ry = 20 + row * 55
const rw = W / 6 - 12
const rh = 46
k.add([k.rect(rw, rh), k.pos(rx, ry), k.color(safeColor(k, '#0a1a10')), k.opacity(0.4), k.z(1)])
k.add([k.rect(rw, 1), k.pos(rx, ry), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)])
k.add([k.rect(rw, 1), k.pos(rx, ry + rh), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)])
for (let s = 0; s < 5; s++) {
k.add([k.rect(rw - 4, 1), k.pos(rx + 2, ry + 10 + s * 7), k.color(safeColor(k, '#0a1008')), k.opacity(0.3), k.z(1)])
}
for (let led = 0; led < 4; led++) {
const ledEl = k.add([
k.rect(2, 2), k.pos(rx + 3 + led * 5, ry + 3),
k.color(safeColor(k, '#00ff88')), k.opacity(0.5), k.z(2),
])
ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.04 ? 0.5 : 0.1 })
}
if (Math.random() < 0.5) {
const act = k.add([k.rect(2, 2), k.pos(rx + rw - 6, ry + 3), k.color(safeColor(k, '#ffaa00')), k.opacity(0.5), k.z(2)])
act.onUpdate(() => { act.opacity = Math.random() > 0.3 ? 0.5 : 0.15 })
}
}
}
// Overhead cable trays
for (let i = 0; i < 3; i++) {
k.add([k.rect(W * 0.25, 3), k.pos(W * (0.1 + i * 0.3), 5), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)])
for (let c = 0; c < 4; c++) {
k.add([k.rect(W * 0.25, 1), k.pos(W * (0.1 + i * 0.3), 7 + c * 2), k.color(safeColor(k, ['#004400', '#000044', '#440000', '#444400'][c])), k.opacity(0.15), k.z(1)])
}
}
// Cool air flow particles
for (let i = 0; i < 5; i++) {
const airflow = k.add([
k.circle(2), k.pos(Math.random() * W, GROUND_Y - 10),
k.color(safeColor(k, '#00ff88')), k.opacity(0.08), k.z(3),
])
airflow.onUpdate(() => {
airflow.pos.y -= 15 * k.dt()
if (airflow.pos.y < 10) { airflow.pos.y = GROUND_Y - 10; airflow.pos.x = Math.random() * W }
})
}
// Emergency lighting
const emLight = k.add([k.circle(4), k.pos(W * 0.5, 5), k.anchor('center'), k.color(safeColor(k, '#ff0000')), k.opacity(0.2), k.z(2)])
emLight.onUpdate(() => { emLight.opacity = 0.1 + Math.sin(k.time() * 1.5) * 0.1 })
} else if (a === 'paper_mill') {
// Industrial ceiling pipes
for (let i = 0; i < 5; i++) {
const py = 5 + i * 4
k.add([k.rect(W, 3), k.pos(0, py), k.color(safeColor(k, i % 2 === 0 ? '#2a2a20' : '#222218')), k.opacity(0.35), k.z(1)])
for (let j = 0; j < 4; j++) {
k.add([k.circle(3), k.pos(W * (0.2 + j * 0.2), py + 1), k.anchor('center'), k.color(safeColor(k, '#333328')), k.opacity(0.3), k.z(1)])
}
}
// Conveyor belt
k.add([k.rect(W * 0.6, 6), k.pos(W * 0.2, GROUND_Y - 15), k.color(safeColor(k, '#333328')), k.opacity(0.35), k.z(1)])
for (let i = 0; i < 8; i++) {
k.add([k.circle(3), k.pos(W * 0.22 + i * (W * 0.075), GROUND_Y - 12), k.anchor('center'), k.color(safeColor(k, '#444438')), k.opacity(0.3), k.z(1)])
}
// Moving items on belt
for (let i = 0; i < 3; i++) {
const item = k.add([
k.rect(10, 6), k.pos(W * 0.25 + i * 60, GROUND_Y - 21),
k.color(safeColor(k, '#f0e68c')), k.opacity(0.2), k.z(2),
])
item.onUpdate(() => {
item.pos.x += 15 * k.dt()
if (item.pos.x > W * 0.78) item.pos.x = W * 0.2
})
}
// Paper rolls stacked
for (let i = 0; i < 3; i++) {
for (let j = 0; j <= i; j++) {
const rx = W * 0.08 + j * 14 - i * 7
const ry = GROUND_Y - 12 - (2 - i) * 12
k.add([k.circle(6), k.pos(rx, ry), k.anchor('center'), k.color(safeColor(k, '#ddd8b0')), k.opacity(0.2), k.z(1)])
k.add([k.circle(2), k.pos(rx, ry), k.anchor('center'), k.color(safeColor(k, '#aaa880')), k.opacity(0.15), k.z(1)])
}
}
// Industrial gears (rotating)
for (const gx of [W * 0.88, W * 0.92]) {
const gy = GROUND_Y * 0.35
k.add([k.circle(10), k.pos(gx, gy), k.anchor('center'), k.color(safeColor(k, '#444438')), k.opacity(0.25), k.z(1)])
k.add([k.circle(4), k.pos(gx, gy), k.anchor('center'), k.color(safeColor(k, '#333328')), k.opacity(0.3), k.z(1)])
for (let t = 0; t < 8; t++) {
const tooth = k.add([
k.rect(4, 3), k.pos(gx, gy), k.anchor('center'),
k.color(safeColor(k, '#444438')), k.opacity(0.25), k.z(1),
k.rotate(t * 45),
])
tooth.onUpdate(() => { tooth.angle = t * 45 + k.time() * (gx === W * 0.88 ? 20 : -25) })
}
}
// Steam from pipes
for (let i = 0; i < 4; i++) {
const steam = k.add([
k.circle(6 + Math.random() * 8), k.pos(W * (0.2 + i * 0.2), 25),
k.color(safeColor(k, '#ccccbb')), k.opacity(0.06), k.z(2),
])
const bsx = steam.pos.x
steam.onUpdate(() => {
steam.pos.y += 4 * k.dt()
steam.pos.x = bsx + Math.sin(k.time() + i) * 5
steam.opacity *= 0.998
if (steam.pos.y > GROUND_Y * 0.4) { steam.pos.y = 25; steam.opacity = 0.06 }
})
}
// Sawdust particles
for (let i = 0; i < 8; i++) {
const dust = k.add([
k.circle(1), k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k, '#f0e68c')), k.opacity(0.12), k.z(3),
])
const dx = dust.pos.x, dy = dust.pos.y
dust.onUpdate(() => {
dust.pos.x = dx + Math.sin(k.time() * 0.8 + i) * 10
dust.pos.y = dy + Math.cos(k.time() * 0.5 + i) * 8
})
}
}
}
k.scene('fight', () => {
// === SKY / BACKGROUND ATMOSPHERE ===
// Vertical gradient backdrop — 6 bands from dark ceiling to ground horizon
const bgBase = theme.bg
for (let band = 0; band < 6; band++) {
const bandH = GROUND_Y / 6
const brighten = band * 3
k.add([
k.rect(W, bandH + 1), k.pos(0, band * bandH),
k.color(safeColor(k,bgBase)),
k.opacity(1 - band * 0.04),
])
}
// Subtle horizon glow — wide soft band just above ground
k.add([
k.rect(W, 20), k.pos(0, GROUND_Y - 20),
k.color(safeColor(k,theme.accent)), k.opacity(0.06),
])
// === GROUND ===
// Main ground fill
k.add([k.rect(W, H - GROUND_Y + 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.ground))])
// Bright edge line at the surface
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.accent)), k.opacity(0.7)])
// Secondary highlight
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3)])
// Ground gradient — fades deeper
for (let i = 1; i <= 12; i++) {
k.add([k.rect(W, 2), k.pos(0, GROUND_Y + 3 + i * 6), k.color(safeColor(k,theme.accent)), k.opacity(0.1 - i * 0.007)])
}
// Perspective grid on the floor — converging lines for 3D depth
const vanishX = W / 2
const vanishY = GROUND_Y * 0.6
for (let i = 0; i <= 24; i++) {
const x = i * (W / 24)
// Lines from floor edge pointing toward vanishing point
const dx = x - vanishX
const topX = vanishX + dx * 0.3
k.add([
k.rect(1, GROUND_Y * 0.4 + 10), k.pos(x, GROUND_Y),
k.color(safeColor(k,theme.accent)), k.opacity(0.05),
])
}
// Horizontal depth lines on ground
for (let i = 0; i < 6; i++) {
const gy = GROUND_Y + 8 + i * i * 3
if (gy < H) {
k.add([k.rect(W, 1), k.pos(0, gy), k.color(safeColor(k,theme.accent)), k.opacity(0.08 - i * 0.012)])
}
}
// === SIDE PILLARS / FRAME ===
// Subtle dark pillars on edges for framing
k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
// Accent trim on pillars
k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
// === AMBIENT PARTICLES ===
// Floating dust/embers in the air (subtle, always present)
for (let i = 0; i < 18; i++) {
const particle = k.add([
k.circle(0.5 + Math.random() * 1.5),
k.pos(Math.random() * W, Math.random() * GROUND_Y),
k.color(safeColor(k,theme.accent)),
k.opacity(0.08 + Math.random() * 0.12),
k.z(2),
])
const baseX = particle.pos.x
const baseY = particle.pos.y
const driftSpeed = 0.1 + Math.random() * 0.3
const driftAmt = 8 + Math.random() * 15
particle.onUpdate(() => {
particle.pos.x = baseX + Math.sin(k.time() * driftSpeed + i * 1.3) * driftAmt
particle.pos.y = baseY + Math.cos(k.time() * driftSpeed * 0.7 + i) * (driftAmt * 0.5)
particle.opacity = 0.06 + Math.sin(k.time() * 0.8 + i * 0.9) * 0.06
})
}
// Arena-specific decorations
drawArenaDecor()
// Umpire chair (center-back, behind fighters)
const CHAIR_X = W / 2
const CHAIR_SEAT_Y = GROUND_Y * (0.38 + (1 - SF) * 0.2)
const cs = SF // chair scale
k.add([k.rect(4 * cs, GROUND_Y - CHAIR_SEAT_Y + 15 * cs), k.pos(CHAIR_X - 22 * cs, CHAIR_SEAT_Y - 8 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
k.add([k.rect(4 * cs, GROUND_Y - CHAIR_SEAT_Y + 15 * cs), k.pos(CHAIR_X + 18 * cs, CHAIR_SEAT_Y - 8 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
for (let i = 0; i < 6; i++) {
const rungY = CHAIR_SEAT_Y + 15 * cs + i * ((GROUND_Y - CHAIR_SEAT_Y - 15 * cs) / 6)
k.add([k.rect(44 * cs, 3), k.pos(CHAIR_X - 22 * cs, rungY), k.color(safeColor(k,'#443322')), k.opacity(0.6), k.z(3)])
}
k.add([k.rect(54 * cs, 5), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 2), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)])
k.add([k.rect(54 * cs, 1), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3), k.z(3)])
k.add([k.rect(48 * cs, 4), k.pos(CHAIR_X - 24 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)])
k.add([k.rect(3, 12 * cs), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
k.add([k.rect(3, 12 * cs), k.pos(CHAIR_X + 24 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
k.add([
k.sprite('judge', { anim: 'idle' }),
k.pos(CHAIR_X, CHAIR_SEAT_Y),
k.anchor('bot'),
k.scale(1.3 * SF),
k.z(4),
k.opacity(0.9),
'judge',
])
const scaleA = (1.8 + botA.tier * 0.4) * SF
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(-100, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), k.opacity(0), 'fighterA'])
const scaleB = (1.8 + botB.tier * 0.4) * SF
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W + 100, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), k.opacity(0), 'fighterB'])
config.onReady?.()
})
k.go('fight')
let comboA = 0
let comboB = 0
// === CHOREOGRAPHY SYSTEM (extracted to fight/choreography/) ===
const choreoCtx: ChoreoContext = {
k, W, H, GROUND_Y, FRAME_SIZE, theme,
safeColor: (color: string) => safeColor(k, color),
trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxExplosion,
sfxBoing, sfxBonk, sfxBulletHit, sfxClash, sfxCoin,
sfxGunshot, sfxJetpack, sfxRapidPunch, sfxSlideDown,
sfxZap, sfxZoomWhoosh, sfxBlock,
spawnBullet, spawnGrotesqueDetails, destroyGrotesqueDetails,
}
const choreographyMap = createChoreographyMap(choreoCtx)
const { spawnWeaponProp, destroyProp, spawnProp, moveProp } = createFactories(choreoCtx)
async function playBlock(side: 'a' | 'b') {
const defender = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!defender) return
const dir = side === 'a' ? 1 : -1
// Spawn shield in front
const shieldObjs = spawnWeaponProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19)
sfxBlock()
// Flash shield
shieldObjs.forEach(o => { o.opacity = 0.9 })
await k.wait(0.15)
// Impact sparks
spawnSparks(defender.pos.x + dir * 25, defender.pos.y - 25, 6, '#4488ff')
k.shake(3)
await k.wait(0.3)
// Shield fades
shieldObjs.forEach(o => {
k.tween(o.opacity, 0, 0.2, (v: number) => { o.opacity = v }).then(() => { if (o.exists()) o.destroy() })
})
await k.wait(0.1)
}
// === MORPH / COSTUME SYSTEM ===
// 3 morph types per character: elemental, mech, and beast
// Each morph temporarily transforms the fighter with visual overlays, aura, and color shift
interface MorphOverlay { obj: any; type: string }
let activeMorphs: MorphOverlay[] = []
function destroyMorphOverlays() {
activeMorphs.forEach(m => { if (m.obj.exists()) m.obj.destroy() })
activeMorphs = []
}
// Morph 0: ELEMENTAL — fire/ice/electric aura wraps around the fighter
function applyElementalMorph(fighter: any, side: 'a' | 'b') {
const seed = side === 'a' ? botA.seed : botB.seed
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
const element = Math.abs(h) % 3 // 0=fire, 1=ice, 2=electric
const colors = [
['#ff4400', '#ff8800', '#ffcc00'], // fire
['#44ccff', '#88eeff', '#ffffff'], // ice
['#ffff00', '#88ff00', '#00ffff'], // electric
][element]
// Aura glow behind fighter
const aura = k.add([
k.circle(45 + Math.random() * 10),
k.pos(fighter.pos.x, fighter.pos.y - 20),
k.color(safeColor(k,colors[0])),
k.opacity(0.15),
k.z(fighter.z - 1),
k.anchor('center'),
k.scale(1),
])
aura.onUpdate(() => {
aura.pos.x = fighter.pos.x
aura.pos.y = fighter.pos.y - 20
aura.opacity = 0.1 + Math.sin(k.time() * 8) * 0.08
const s = 1 + Math.sin(k.time() * 6) * 0.15
aura.scale.x = s; aura.scale.y = s
})
activeMorphs.push({ obj: aura, type: 'elemental' })
// Orbiting particles
for (let i = 0; i < 6; i++) {
const p = k.add([
k.circle(2 + Math.random() * 2),
k.pos(fighter.pos.x, fighter.pos.y),
k.color(safeColor(k,colors[1 + (i % 2)])),
k.opacity(0.6),
k.z(fighter.z + 1),
k.anchor('center'),
])
const angle = (i / 6) * Math.PI * 2
const radius = 30 + Math.random() * 15
p.onUpdate(() => {
const a = angle + k.time() * (3 + i * 0.5)
p.pos.x = fighter.pos.x + Math.cos(a) * radius
p.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.6
p.opacity = 0.4 + Math.sin(k.time() * 10 + i) * 0.3
})
activeMorphs.push({ obj: p, type: 'elemental' })
}
// Color tint the fighter
fighter.color = safeColor(k,colors[0])
fighter.opacity = 0.9
}
// Morph 1: MECH — armor plates, wings/jets, metallic overlay
function applyMechMorph(fighter: any, _side: 'a' | 'b') {
const dir = fighter.scale.x > 0 ? 1 : -1
// Shoulder armor plates
for (let s = 0; s < 2; s++) {
const sDir = s === 0 ? -1 : 1
const shoulder = k.add([
k.rect(12, 8), k.pos(fighter.pos.x + sDir * 25 * dir, fighter.pos.y - 30),
k.color(safeColor(k,'#888899')), k.opacity(0.85), k.z(fighter.z + 1), k.anchor('center'),
])
shoulder.onUpdate(() => {
shoulder.pos.x = fighter.pos.x + sDir * 25 * dir
shoulder.pos.y = fighter.pos.y - 30 + Math.sin(k.time() * 4) * 2
})
activeMorphs.push({ obj: shoulder, type: 'mech' })
}
// Jet wings + flame boosters
for (let w = 0; w < 2; w++) {
const wingDir = w === 0 ? -1 : 1
const wing = k.add([
k.rect(6, 20 + w * 5), k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y - 15),
k.color(safeColor(k,'#5566aa')), k.opacity(0.7), k.z(fighter.z - 1), k.anchor('center'),
k.rotate(wingDir * 15 * dir),
])
wing.onUpdate(() => {
wing.pos.x = fighter.pos.x - 30 * dir * wingDir
wing.pos.y = fighter.pos.y - 15
})
activeMorphs.push({ obj: wing, type: 'mech' })
const flame = k.add([
k.rect(4, 8 + Math.random() * 6),
k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y + 5),
k.color(safeColor(k,'#ff6600')),
k.opacity(0.6), k.z(fighter.z - 2), k.anchor('center'),
])
flame.onUpdate(() => {
flame.pos.x = fighter.pos.x - 30 * dir * wingDir + (Math.random() - 0.5) * 3
flame.pos.y = fighter.pos.y + 5 + Math.random() * 4
flame.opacity = 0.3 + Math.random() * 0.4
flame.color = safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')
})
activeMorphs.push({ obj: flame, type: 'mech' })
}
// Visor glow
const visor = k.add([
k.rect(20, 4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 35),
k.color(safeColor(k,'#00ffaa')), k.opacity(0.7), k.z(fighter.z + 2), k.anchor('center'),
])
visor.onUpdate(() => {
visor.pos.x = fighter.pos.x + 5 * dir
visor.pos.y = fighter.pos.y - 35
visor.opacity = 0.5 + Math.sin(k.time() * 12) * 0.3
})
activeMorphs.push({ obj: visor, type: 'mech' })
// Metallic tint
fighter.color = safeColor(k,'#aabbcc')
fighter.opacity = 0.95
}
// Morph 2: BEAST — grows horns/tail/claws, goes wild
function applyBeastMorph(fighter: any, side: 'a' | 'b') {
const dir = fighter.scale.x > 0 ? 1 : -1
const seed = side === 'a' ? botA.seed : botB.seed
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
const beastType = Math.abs(h >> 4) % 3 // 0=demon, 1=wolf, 2=dragon
const beastColors = [
['#cc2222', '#ff4444', '#880000'], // demon
['#666688', '#aaaacc', '#444466'], // wolf
['#228844', '#44cc66', '#115533'], // dragon
][beastType]
// Horns
for (let horn = 0; horn < 2; horn++) {
const hDir = horn === 0 ? -1 : 1
const h1 = k.add([
k.rect(3, 12 + Math.random() * 6),
k.pos(fighter.pos.x + hDir * 10, fighter.pos.y - 45),
k.color(safeColor(k,beastColors[0])),
k.opacity(0.9), k.z(fighter.z + 2), k.anchor('bot'),
k.rotate(hDir * (20 + Math.random() * 15)),
])
h1.onUpdate(() => {
h1.pos.x = fighter.pos.x + hDir * 10
h1.pos.y = fighter.pos.y - 45
})
activeMorphs.push({ obj: h1, type: 'beast' })
}
// Tail
for (let s = 0; s < 5; s++) {
const seg = k.add([
k.circle(4 - s * 0.5),
k.pos(fighter.pos.x - dir * (20 + s * 8), fighter.pos.y - 5 + s * 2),
k.color(safeColor(k,beastColors[1])),
k.opacity(0.8), k.z(fighter.z - 1), k.anchor('center'),
])
seg.onUpdate(() => {
const wave = Math.sin(k.time() * 5 + s * 0.8) * (4 + s * 2)
seg.pos.x = fighter.pos.x - dir * (20 + s * 8)
seg.pos.y = fighter.pos.y - 5 + s * 2 + wave
})
activeMorphs.push({ obj: seg, type: 'beast' })
}
// Claws on hands
for (let c = 0; c < 2; c++) {
const cDir = c === 0 ? -1 : 1
for (let cl = 0; cl < 3; cl++) {
const claw = k.add([
k.rect(2, 6), k.pos(fighter.pos.x + cDir * 22, fighter.pos.y - 18 + cl * 3),
k.color(safeColor(k,beastColors[2])),
k.opacity(0.8), k.z(fighter.z + 1), k.anchor('center'),
k.rotate(cDir * (30 + cl * 10)),
])
claw.onUpdate(() => {
claw.pos.x = fighter.pos.x + cDir * 22
claw.pos.y = fighter.pos.y - 18 + cl * 3
})
activeMorphs.push({ obj: claw, type: 'beast' })
}
}
// Wild eye glow
const eyeGlow = k.add([
k.circle(4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 36),
k.color(safeColor(k,'#ff0000')), k.opacity(0.6), k.z(fighter.z + 2), k.anchor('center'),
])
eyeGlow.onUpdate(() => {
eyeGlow.pos.x = fighter.pos.x + 5 * dir
eyeGlow.pos.y = fighter.pos.y - 36
eyeGlow.opacity = 0.4 + Math.sin(k.time() * 15) * 0.4
})
activeMorphs.push({ obj: eyeGlow, type: 'beast' })
// Size increase + color tint
fighter.color = safeColor(k,beastColors[0])
fighter.opacity = 0.9
}
const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph]
const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE']
// === THE CREATOR: OMNI-MORPH ===
// Instead of 3 morph types, the creator morphs into a random archetype each time
const OMNI_MORPH_ARCHETYPES = [
'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza',
'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'ghost', 'alien', 'dinosaur',
'pirate', 'ninja', 'cowboy', 'wizard', 'bee', 'frog', 'snail',
'robot', 'android', 'toaster', 'mech', 'minotaur', 'unicorn', 'phoenix', 'dragon',
'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem', 'vampire', 'werewolf', 'zombie',
'witch', 'demon', 'chef', 'firefighter', 'astronaut', 'clown', 'detective',
'lumberjack', 'scientist', 'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight',
'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon', 'snake',
'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda', 'hamster',
'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man',
'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern',
'garden_gnome', 'lamp_post', 'broom_man',
]
let omniMorphCounter = 0
async function applyCreatorOmniMorph(fighter: any, side: 'a' | 'b'): Promise<string> {
const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)]
// Generate and load a sprite sheet for the picked archetype
const bot = side === 'a' ? botA : botB
const colors = side === 'a' ? colorsA : colorsB
const morphKey = `omniMorph_${side}_${omniMorphCounter++}`
try {
const morphSheet = generateSpriteSheet(bot.seed, bot.tier, colors.primary, colors.secondary, pick)
await loadSpriteWithTimeout(morphKey, morphSheet, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
// Swap the fighter's sprite to the morphed archetype
fighter.use(k.sprite(morphKey, { anim: fighter.curAnim() || 'idle' }))
} catch (err) {
console.warn('[FightScene] omni-morph sprite failed:', err)
}
// Golden aura unique to creator morph
const aura = k.add([
k.circle(55),
k.pos(fighter.pos.x, fighter.pos.y - 20),
k.color(safeColor(k, '#c8a000')),
k.opacity(0.2),
k.z(fighter.z - 1),
k.anchor('center'),
k.scale(1),
])
aura.onUpdate(() => {
aura.pos.x = fighter.pos.x
aura.pos.y = fighter.pos.y - 20
aura.opacity = 0.12 + Math.sin(k.time() * 6) * 0.1
const s = 1 + Math.sin(k.time() * 4) * 0.2
aura.scale.x = s; aura.scale.y = s
})
activeMorphs.push({ obj: aura, type: 'omni' })
// Orbiting golden ₿ symbols (8 in dual rings, varied sizes)
for (let i = 0; i < 8; i++) {
const ring = i < 5 ? 0 : 1 // inner ring (5) + outer ring (3)
const ringCount = ring === 0 ? 5 : 3
const ringIdx = ring === 0 ? i : i - 5
const sz = ring === 0 ? 10 + Math.random() * 4 : 14 + Math.random() * 4
const rad = ring === 0 ? 40 : 60
const spd = ring === 0 ? 3 : -2 // counter-rotate outer ring
const btc = k.add([
k.text('₿', { size: sz }),
k.pos(fighter.pos.x, fighter.pos.y - 20),
k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')),
k.opacity(0.8),
k.z(fighter.z + 2),
k.anchor('center'),
k.rotate(0),
])
const baseAngle = (ringIdx / ringCount) * Math.PI * 2
btc.onUpdate(() => {
const a = baseAngle + k.time() * spd
btc.pos.x = fighter.pos.x + Math.cos(a) * rad
btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * rad * 0.5
btc.opacity = 0.7 + Math.sin(k.time() * 6 + i * 1.3) * 0.3
btc.angle = Math.sin(k.time() * 4 + i) * 20
})
activeMorphs.push({ obj: btc, type: 'omni' })
}
// Apply archetype-specific color tint based on pick category
const tints: Record<string, string> = {
fire: '#ff4400', ice: '#44ccff', nature: '#44cc44', dark: '#8844cc',
metal: '#aabbcc', electric: '#ffff00', beast: '#cc6622', cosmic: '#cc44ff',
}
const category =
['phoenix', 'dragon', 'demon'].includes(pick) ? 'fire' :
['penguin', 'snowman', 'yeti'].includes(pick) ? 'ice' :
['cactus', 'mushroom', 'frog', 'snail', 'turtle'].includes(pick) ? 'nature' :
['skeleton', 'ghost', 'vampire', 'zombie', 'witch'].includes(pick) ? 'dark' :
['robot', 'android', 'mech', 'toaster', 'knight'].includes(pick) ? 'metal' :
['alien', 'unicorn', 'mermaid'].includes(pick) ? 'cosmic' :
['werewolf', 'minotaur', 'lion', 'shark', 'crocodile'].includes(pick) ? 'beast' :
'electric'
fighter.color = safeColor(k, tints[category] || '#ffd700')
fighter.opacity = 0.9
return pick
}
// Get morph order for a bot (deterministic by seed)
function getMorphOrder(seed: string): number[] {
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]
return orders[Math.abs(h) % 6]
}
async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) {
const savedScaleX = fighter.scale.x
const savedScaleY = fighter.scale.y
const isHumanFighter = side === 'a' ? isHumanA : isHumanB
const morphSpriteKey = side === 'a' ? 'botA_morph' : 'botB_morph'
const normalSpriteKey = side === 'a' ? 'botA' : 'botB'
// Flash + announcement
screenFlash('#ffffff', 0.15)
sfxZoomWhoosh()
sfxSpecial()
const name = side === 'a' ? botA.name : botB.name
announceHype(`${name}${morphNames[morphIndex]}!`)
// Scale up dramatically
const growFactor = 1.3
await k.tween(0, 1, 0.3, (t) => {
fighter.scale.x = savedScaleX * (1 + t * (growFactor - 1))
fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1))
}, k.easings.easeOutBack)
// Human fighters morph into bots (opposite direction!)
if (isHumanFighter) {
fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' }))
}
// Apply the morph visuals
morphAppliers[morphIndex](fighter, side)
// Shockwave + shake
spawnShockwave(fighter.pos.x, fighter.pos.y - 20, '#ffffff')
k.shake(8)
glitchRGB(0.2)
return {
revert: async () => {
destroyMorphOverlays()
screenFlash(theme.accent, 0.1)
fighter.color = safeColor(k,'#ffffff')
fighter.opacity = 1
// Revert human fighters back to their human sprite
if (isHumanFighter) {
fighter.use(k.sprite(normalSpriteKey, { anim: 'idle' }))
}
await k.tween(0, 1, 0.2, (t) => {
fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor)
fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor)
}, k.easings.easeInQuad)
},
}
}
// === EMOTION & SPORTSMANSHIP SYSTEM ===
const heartfeltLines = [
'That bot fights like it\'s got student loans to pay!',
'I\'m not crying, you\'re crying!',
'Someone get this bot a movie deal!',
'This is better than anything on Netflix right now!',
'That bot has more heart than most politicians!',
'I haven\'t been this moved since my last Windows update!',
'The crowd is ugly crying and I respect that!',
'This bot has main character energy!',
'We\'re witnessing greatness and I don\'t say that lightly!',
'Even the judge is getting emotional!',
'Someone call their developer, they\'d be so proud!',
'This is the kind of fight you tell your grandkids about!',
'That\'s not a bot, that\'s a WARRIOR!',
'More drama than a Congressional hearing!',
'They left it all in the ring! And by all, I mean EVERYTHING!',
'The crowd is standing! The ones who can still stand, anyway!',
'Somebody film this before it gets taken down!',
'This fight has more plot twists than a Netflix original!',
'That bot fights like rent is due tomorrow!',
'My heart! My poor little heart!',
]
const crowdSympathyLines = [
'Pour one out for the fallen!',
'That\'s rough, buddy...',
'Somebody hug that bot!',
'They tried their best, which is... concerning!',
'At least they have a great personality!',
'Their developer still loves them! Probably!',
'It\'s a learning experience! A very painful one!',
'Better luck next patch!',
'Participation trophy incoming!',
'The exit is to your left!',
'Have you tried turning it off and on again?',
'Even their antivirus felt that!',
'They\'ll be in therapy for epochs after this!',
'At least they looked good losing!',
'That bot just became a cautionary tale!',
'Someone start a GoFundMe for their repairs!',
'Their training data did NOT prepare them for this!',
'That bot needs a hug and a firmware update!',
]
const respectLines = [
'Good fight.', 'Respect.', 'GG no re.',
'Same time next week?',
'You\'re built different. Not better, but different.',
'We should start a podcast.',
'That was actually fun. Don\'t tell anyone.',
'Your developer should be proud. Probably.',
'Ten out of ten, would fight again.',
'We\'re not so different, you and I.',
'I\'d swipe right.', 'You single?',
'Tell your GPU I said hi.',
'No hard feelings. Just hard hits.',
'You fight like someone who reads documentation.',
]
// Sanitize text for Kaplay (treats [ ] as styled text tags)
function safeText(t: string): string {
return t
.replace(/[\[\]{}<>`\\|~^]/g, '') // Kaplay styled-text tags and problematic chars
.replace(/[\x00-\x1f\x7f]/g, ' ') // Control characters → space
.replace(/[^\x20-\x7e]/g, '') // Strip non-ASCII (emoji, unicode) — Kaplay can't render them
.replace(/\s+/g, ' ') // Collapse whitespace
.trim()
}
// Speech bubble above a fighter — colourful with tail
// Active speech bubbles — destroy previous before showing new
let activeBubbleA: any[] = []
let activeBubbleB: any[] = []
function destroyBubble(els: any[]) {
els.forEach(el => { if (el.exists()) el.destroy() })
els.length = 0
}
function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) {
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!fighter || !text) return
// Kill previous bubble on this side
if (side === 'a') destroyBubble(activeBubbleA)
else destroyBubble(activeBubbleB)
const allEls: any[] = []
// Sanitize and truncate
const clean = safeText(text)
if (!clean) return
const maxLen = 80
const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean
const lines = wrapBubbleText(display, 18)
const fontSize = 12
const lineH = fontSize + 4
const padX = 10, padY = 8
const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38))
const bubbleW = textW + padX * 2
const bubbleH = lines.length * lineH + padY * 2
const tailSize = 10
// Position — push bubble to the outside of the bot so it doesn't overlap the sprite
const offsetX = side === 'a' ? -bubbleW * 0.7 : bubbleW * 0.7
const bx = Math.max(bubbleW / 2 + 4, Math.min(W - bubbleW / 2 - 4, fighter.pos.x + offsetX))
const by = Math.max(8, fighter.pos.y - 75 - bubbleH)
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
// Single border rect (no glow/accent layers — keeps rendering clean)
const border = k.add([
k.rect(bubbleW + 4, bubbleH + 4),
k.pos(bx - bubbleW / 2 - 2, by - 2),
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
])
allEls.push(border)
// Bubble body
const bubble = k.add([
k.rect(bubbleW, bubbleH),
k.pos(bx - bubbleW / 2, by),
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
])
allEls.push(bubble)
// Tail — pixel arrow pointing down to fighter
const tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15)
const tailBaseY = by + bubbleH
for (let row = 0; row < tailSize; row++) {
const tw = tailSize - row
// Border pixel row
const tailBorder = k.add([
k.rect(tw + 2, 1),
k.pos(tailX - (tw + 2) / 2, tailBaseY + row),
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
])
allEls.push(tailBorder)
// Inner pixel row
if (tw > 2) {
const tailInner = k.add([
k.rect(tw - 1, 1),
k.pos(tailX - (tw - 1) / 2, tailBaseY + row),
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
])
allEls.push(tailInner)
}
}
// Render text to an offscreen canvas, then display as a sprite
// This bypasses kaplay's text rendering which has color issues
const textCanvas = document.createElement('canvas')
textCanvas.width = bubbleW
textCanvas.height = bubbleH
const tc = textCanvas.getContext('2d')!
tc.fillStyle = '#ffffff'
tc.font = `${fontSize}px monospace`
tc.textBaseline = 'top'
for (let i = 0; i < lines.length; i++) {
tc.fillText(lines[i], padX, padY + i * lineH + 2)
}
const spriteKey = `bubble_${side}_${Date.now()}`
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
const tEl = k.add([
k.sprite(spriteKey),
k.pos(bx - bubbleW / 2, by),
k.opacity(1), k.z(57),
])
allEls.push(tEl)
})
// Border glow pulse
border.onUpdate(() => {
border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15
})
// Pop-in animation
const anchorX = bx, anchorY = by + bubbleH
allEls.forEach(el => {
const origX = el.pos.x, origY = el.pos.y
const origOpacity = el.opacity
el.pos.x = anchorX + (origX - anchorX) * 0.1
el.pos.y = anchorY + (origY - anchorY) * 0.1
el.opacity = 0
k.tween(0, 1, 0.25, (t) => {
el.pos.x = anchorX + (origX - anchorX) * t
el.pos.y = anchorY + (origY - anchorY) * t
el.opacity = origOpacity * t
}, k.easings.easeOutBack)
})
// Store reference
if (side === 'a') activeBubbleA = allEls
else activeBubbleB = allEls
// Fade out after duration
setTimeout(() => {
allEls.forEach(el => {
if (!el.exists()) return
k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => {
if (el.exists()) el.destroy()
})
})
}, duration * 1000)
}
function wrapBubbleText(text: string, maxChars: number): string[] {
const words = text.split(' ')
const lines: string[] = []
let line = ''
for (const word of words) {
if (line.length + word.length + 1 > maxChars && line.length > 0) {
lines.push(line)
line = word
} else {
line = line ? line + ' ' + word : word
}
}
if (line) lines.push(line)
// Max 5 lines, last line truncated
if (lines.length > 5) {
const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...']
return truncated
}
return lines
}
// Talking mouth animation — a small rectangle that opens/closes near the fighter's face
const talkingAnims: Record<string, { objs: any[]; timer: ReturnType<typeof setInterval> }> = {}
function startTalking(side: 'a' | 'b') {
stopTalking(side) // cleanup any existing
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!fighter?.exists()) return
const mouthColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
// Mouth position: slightly below center of the sprite
const mouthOffsetY = -28
const mouthW = 8
let open = false
const mouth = k.add([
k.rect(mouthW, 2),
k.pos(fighter.pos.x, fighter.pos.y + mouthOffsetY),
k.color(safeColor(k, mouthColor)),
k.opacity(0.9),
k.z(12),
k.anchor('center'),
])
// Toggle open/close every 120ms for a fast chatter effect
const timer = trackedInterval(() => {
if (!mouth.exists() || !fighter.exists()) { stopTalking(side); return }
open = !open
mouth.pos.x = fighter.pos.x
mouth.pos.y = fighter.pos.y + mouthOffsetY
mouth.height = open ? 5 : 2
}, 120)
talkingAnims[side] = { objs: [mouth], timer }
}
function stopTalking(side: 'a' | 'b') {
const anim = talkingAnims[side]
if (!anim) return
clearTracked(anim.timer)
anim.objs.forEach(o => { if (o.exists()) o.destroy() })
delete talkingAnims[side]
}
// Spawn floating text above a position
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
const label = k.add([
k.text(safeText(text), { size: 10 }),
k.pos(x, y - 10),
k.color(safeColor(k,color)),
k.opacity(1),
k.z(60),
k.anchor('center'),
])
k.tween(label.pos.y, y - 50, duration, (v) => { label.pos.y = v }, k.easings.easeOutQuad)
k.tween(1, 0, duration, (v) => { label.opacity = v }, k.easings.easeInQuad).then(() => {
if (label.exists()) label.destroy()
})
}
// Spawn a heart particle
function spawnHeart(x: number, y: number) {
const colors = ['#ff4466', '#ff6688', '#ff2244', '#ff88aa', '#cc2244']
const heart = k.add([
k.text('\u2665', { size: 12 + Math.random() * 8 }),
k.pos(x + (Math.random() - 0.5) * 40, y),
k.color(safeColor(k,colors[Math.floor(Math.random() * colors.length)])),
k.opacity(0.8),
k.z(58),
k.anchor('center'),
])
const driftX = (Math.random() - 0.5) * 30
k.tween(heart.pos.y, y - 60 - Math.random() * 40, 1.5, (v) => {
heart.pos.y = v
heart.pos.x += driftX * 0.01
}, k.easings.easeOutQuad)
k.tween(0.8, 0, 1.5, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
}
// Crowd signs: pop up from bottom with text, bob, then fade out
function spawnCrowdSigns(count: number, color: string, text?: string) {
const labels = text ? [text] : ['GO!', 'WOW', 'KO!', 'LOL', 'GG', 'OMG']
for (let i = 0; i < count; i++) {
const x = W * 0.15 + Math.random() * W * 0.7
const startY = H + 20
const endY = GROUND_Y + 10 + Math.random() * 20
const label = labels[Math.floor(Math.random() * labels.length)]
// Sign stick
const stick = k.add([
k.rect(3, 25),
k.pos(x, startY),
k.color(safeColor(k, '#886644')),
k.opacity(0.8),
k.z(8),
k.anchor('bot'),
])
// Sign face
const sign = k.add([
k.rect(Math.max(30, label.length * 10), 18),
k.pos(x, startY - 25),
k.color(safeColor(k, color)),
k.opacity(0.85),
k.z(8),
k.anchor('center'),
])
// Sign text
const txt = k.add([
k.text(label, { size: 10 }),
k.pos(x, startY - 25),
k.color(safeColor(k, '#ffffff')),
k.opacity(0.9),
k.z(9),
k.anchor('center'),
])
const delay = i * 0.08
// Rise up
k.wait(delay, () => {
k.tween(startY, endY, 0.3, (v) => { stick.pos.y = v; sign.pos.y = v - 25; txt.pos.y = v - 25 }, k.easings.easeOutBack)
})
// Bob + fade out after 1.5s
k.wait(delay + 1.5, () => {
k.tween(0.85, 0, 0.5, (v) => { stick.opacity = v; sign.opacity = v; txt.opacity = v }).then(() => {
if (stick.exists()) stick.destroy()
if (sign.exists()) sign.destroy()
if (txt.exists()) txt.destroy()
})
})
}
}
// === HUMAN OWNER APPEARANCES ===
// The silly human behind each bot occasionally shows up
/** Spawn a human as a "coach" standing behind the fighter, cheering/panicking */
async function spawnHumanCoach(side: 'a' | 'b', mood: 'cheer' | 'panic' | 'coach') {
const spriteId = side === 'a' ? 'humanA' : 'humanB'
const homeX = side === 'a' ? HOME_A - 60 : HOME_B + 60
const tag = `human_${side}`
// Don't spawn if already visible
if (k.get(tag).length > 0) return
const human = k.add([
k.sprite(spriteId, { anim: mood }),
k.pos(homeX, GROUND_Y - 4),
k.anchor('bot'),
k.scale(side === 'a' ? 1.2 : -1.2, 1.2),
k.z(5),
k.opacity(0),
tag,
])
// Fade in
await k.tween(0, 0.85, 0.3, (v) => { human.opacity = v }, k.easings.easeOutQuad)
await k.wait(2.0)
// Fade out
await k.tween(0.85, 0, 0.5, (v) => { human.opacity = v }, k.easings.easeInQuad)
if (human.exists()) human.destroy()
}
/** Human runs across the screen (comedic moment) */
async function humanRunAcross(side: 'a' | 'b') {
const spriteId = side === 'a' ? 'humanA' : 'humanB'
const fromLeft = side === 'a'
const startX = fromLeft ? -40 : W + 40
const endX = fromLeft ? W + 40 : -40
const human = k.add([
k.sprite(spriteId, { anim: 'run' }),
k.pos(startX, GROUND_Y - 4),
k.anchor('bot'),
k.scale(fromLeft ? 1.3 : -1.3, 1.3),
k.z(8),
k.opacity(0.9),
])
// Run across screen
await k.tween(startX, endX, 1.5, (v) => { human.pos.x = v }, k.easings.linear)
if (human.exists()) human.destroy()
}
/** Human briefly replaces the bot sprite (jumps in to fight, fails hilariously) */
async function humanJumpsIn(side: 'a' | 'b') {
const fighterTag = side === 'a' ? 'fighterA' : 'fighterB'
const spriteId = side === 'a' ? 'humanA' : 'humanB'
const fighter = k.get(fighterTag)[0] as Fighter
if (!fighter) return
// Spawn human at fighter position
const human = k.add([
k.sprite(spriteId, { anim: 'panic' }),
k.pos(fighter.pos.x, fighter.pos.y),
k.anchor('bot'),
k.scale(fighter.scale.x / 3, fighter.scale.y / 3),
k.z(fighter.z + 1),
k.opacity(0),
])
// Hide bot, show human
const origOpacity = fighter.opacity
fighter.opacity = 0
await k.tween(0, 1, 0.15, (v) => { human.opacity = v })
// Human flails around for a moment
human.play('panic')
await k.wait(0.4)
human.play('cheer')
sfxRandomSilly()
await k.wait(0.3)
human.play('panic')
await k.wait(0.3)
// Run away scared
const escapeX = side === 'a' ? -80 : W + 80
human.play('run')
await k.tween(human.pos.x, escapeX, 0.5, (v) => { human.pos.x = v }, k.easings.easeInQuad)
// Restore bot
fighter.opacity = origOpacity
if (human.exists()) human.destroy()
}
/** Random chance to show a human moment — called between rounds */
async function maybeShowHuman() {
const roll = Math.random()
if (roll > 0.15) return // 15% chance per round
const side: 'a' | 'b' = Math.random() < 0.5 ? 'a' : 'b'
const action = Math.random()
if (action < 0.4) {
// Coach appearance (most common)
const mood = Math.random() < 0.5 ? 'cheer' : 'panic'
await spawnHumanCoach(side, mood)
} else if (action < 0.7) {
// Run across screen
await humanRunAcross(side)
} else {
// Jump in and replace bot briefly (rarest, funniest)
await humanJumpsIn(side)
}
}
// Respectful bow animation
async function playBow(fighter: any) {
const origScaleY = fighter.scale.y
await k.tween(fighter.scale.y, origScaleY * 0.85, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad)
await k.wait(0.3)
await k.tween(fighter.scale.y, origScaleY, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad)
}
// Fist bump between two fighters
async function playFistBump(fighterA: any, fighterB: any) {
const midX = (fighterA.pos.x + fighterB.pos.x) / 2
const origAX = fighterA.pos.x
const origBX = fighterB.pos.x
await Promise.all([
k.tween(fighterA.pos.x, midX - 15, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad),
k.tween(fighterB.pos.x, midX + 15, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad),
])
sfxBlock()
spawnSparks(midX, fighterA.pos.y - 25, 6, '#ffe14d')
spawnEmoteText(midX, fighterA.pos.y - 40, 'GG!', '#ffe14d')
k.shake(2)
await k.wait(0.5)
await Promise.all([
k.tween(fighterA.pos.x, origAX, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad),
k.tween(fighterB.pos.x, origBX, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad),
])
}
// Winner helps loser back up
async function playHelpUp(winner: any, loser: any, winningSide: 'a' | 'b') {
const dir = winningSide === 'a' ? 1 : -1
const origWX = winner.pos.x
await k.tween(winner.pos.x, loser.pos.x - dir * 30, 0.4, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
winner.play('idle')
await k.wait(0.2)
loser.play('idle')
spawnEmoteText(loser.pos.x, loser.pos.y - 40, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
for (let i = 0; i < 3; i++) spawnHeart((winner.pos.x + loser.pos.x) / 2, winner.pos.y - 30)
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
await k.wait(0.6)
await k.tween(winner.pos.x, origWX, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
}
// === REFEREE LOBSTER FUNNY MOMENTS ===
async function judgeDoSomethingFunny() {
const judge = k.get('judge')[0]
if (!judge) return
const judgeOrigY = judge.pos.y
const funnyAction = Math.floor(Math.random() * 8)
if (funnyAction === 0) {
// Falls asleep, snaps awake
for (let z = 0; z < 3; z++) {
setTimeout(() => {
spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0)
}, z * 400)
}
await k.wait(1.2)
judge.play('shocked')
k.shake(2)
sfxBoing()
spawnEmoteText(judge.pos.x, judge.pos.y - 30, '!?', '#ff4444')
await k.tween(judge.pos.y, judgeOrigY - 20, 0.1, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
await k.tween(judge.pos.y, judgeOrigY, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
await k.wait(0.3)
judge.play('idle')
} else if (funnyAction === 1) {
// Little dance on the chair
for (let d = 0; d < 4; d++) {
await k.tween(judge.pos.y, judgeOrigY - 8, 0.08, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
await k.tween(judge.pos.y, judgeOrigY, 0.08, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
judge.play(d % 2 === 0 ? 'call_left' : 'call_right')
}
sfxRandomComedy()
judge.play('idle')
} else if (funnyAction === 2) {
// Holds up a 10 score card
judge.play('shocked')
const card = k.add([
k.rect(20, 14), k.pos(judge.pos.x + 20, judge.pos.y - 25),
k.color(safeColor(k,'#ffffff')), k.opacity(0.9), k.z(15), k.anchor('center'),
])
const score = k.add([
k.text('10', { size: 10 }), k.pos(judge.pos.x + 20, judge.pos.y - 25),
k.color(safeColor(k,'#000000')), k.opacity(1), k.z(16), k.anchor('center'),
])
sfxBoing()
await k.wait(1.0)
card.destroy(); score.destroy()
judge.play('idle')
} else if (funnyAction === 3) {
// Gets scared and hides behind the chair
judge.play('shocked')
sfxDodge()
await k.tween(judge.pos.y, judgeOrigY + 20, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
judge.opacity = 0.3
await k.wait(0.8)
await k.tween(judge.pos.y, judgeOrigY - 5, 0.2, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
judge.opacity = 0.9
await k.tween(judge.pos.y, judgeOrigY, 0.1, (v) => { judge.pos.y = v }, k.easings.easeInOutQuad)
judge.play('idle')
} else if (funnyAction === 4) {
// Eats a tiny sandwich
const sandwich = k.add([
k.rect(10, 8), k.pos(judge.pos.x - 15, judge.pos.y - 15),
k.color(safeColor(k,'#ddaa44')), k.opacity(0.9), k.z(15), k.anchor('center'),
])
const lettuce = k.add([
k.rect(12, 2), k.pos(judge.pos.x - 15, judge.pos.y - 15),
k.color(safeColor(k,'#44cc22')), k.opacity(0.8), k.z(16), k.anchor('center'),
])
await k.wait(0.4)
await k.tween(sandwich.pos.x, judge.pos.x, 0.2, (v) => { sandwich.pos.x = v; lettuce.pos.x = v }, k.easings.easeInQuad)
sandwich.destroy(); lettuce.destroy()
spawnEmoteText(judge.pos.x, judge.pos.y - 30, 'nom nom', '#ffcc44')
sfxBonk()
await k.wait(0.6)
} else if (funnyAction === 5) {
// Takes a selfie
judge.play('call_right')
const phone = k.add([
k.rect(6, 10), k.pos(judge.pos.x + 20, judge.pos.y - 20),
k.color(safeColor(k,'#333344')), k.opacity(0.9), k.z(15), k.anchor('center'),
])
await k.wait(0.3)
screenFlash('#ffffff', 0.05)
sfxZap()
spawnEmoteText(judge.pos.x, judge.pos.y - 35, '#selfie', '#ff88cc')
await k.wait(0.5)
phone.destroy()
judge.play('idle')
} else if (funnyAction === 6) {
// Waves a tiny flag
const flagColors = ['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00', '#ff6600', '#b83dff']
const flagColor = flagColors[Math.floor(Math.random() * flagColors.length)]
const flag = k.add([
k.rect(14, 9), k.pos(judge.pos.x + 18, judge.pos.y - 30),
k.color(safeColor(k,flagColor)), k.opacity(0.85), k.z(15), k.anchor('center'),
])
const pole = k.add([
k.rect(2, 18), k.pos(judge.pos.x + 11, judge.pos.y - 22),
k.color(safeColor(k,'#aa8855')), k.opacity(0.8), k.z(14), k.anchor('center'),
])
flag.onUpdate(() => {
flag.pos.y = judge.pos.y - 30 + Math.sin(k.time() * 10) * 3
})
sfxRandomComedy()
await k.wait(1.5)
flag.destroy(); pole.destroy()
} else {
// Falls off the chair, climbs back up
judge.play('shocked')
sfxBoing()
await k.tween(judge.pos.y, GROUND_Y - 6, 0.3, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
sfxBonk()
k.shake(4)
spawnEmoteText(judge.pos.x, GROUND_Y - 20, 'oof!', '#ff6644')
await k.wait(0.5)
await k.tween(judge.pos.y, judgeOrigY, 0.5, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
judge.play('idle')
spawnEmoteText(judge.pos.x, judge.pos.y - 25, '*ahem*', '#aaaaaa')
await k.wait(0.3)
}
}
// === SHOWBOATING / TAUNTING SYSTEM ===
// Fighters perform these during question/answer phases instead of boring idle bobbing
const _showboatActive: Record<string, boolean> = { a: false, b: false }
type ShowboatFn = (fighter: any, homeX: number, homeY: number, sx: number, sy: number, dir: number) => Promise<void>
// dir: 1 = facing right (bot A), -1 = facing left (bot B)
const showboats: ShowboatFn[] = [
// 0: Pace back and forth — walk a wide arc restlessly
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
await k.tween(f.pos.x, hx + dir * 100, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('idle')
await k.wait(0.1)
f.play('attack')
await k.tween(f.pos.x, hx - dir * 60, 0.4, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('idle')
await k.wait(0.1)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 1: Bouncing advance — hop toward opponent then back
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 3; i++) {
const targetX = hx + dir * (40 + i * 30)
f.play('kick')
await Promise.all([
k.tween(f.pos.x, targetX, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy - 35, 0.06, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBoing()
}
f.play('win'); await k.wait(0.15)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 2: Shadow box sprint — dash forward shadow boxing, dash back
async (f, hx, hy, _sx, _sy, dir) => {
const far = hx + dir * 120
f.play('attack'); sfxZoomWhoosh()
await k.tween(f.pos.x, far, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
for (let i = 0; i < 4; i++) {
f.play(i % 2 === 0 ? 'attack' : 'kick')
sfxDodge(); await k.wait(0.08)
}
f.play('special'); sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 3: Leapfrog across — multiple big jumps across the stage
async (f, hx, hy, _sx, _sy, dir) => {
const spots = [hx + dir * 60, hx + dir * 130, hx + dir * 60, hx]
for (const tx of spots) {
sfxBoing()
await Promise.all([
k.tween(f.pos.x, tx, 0.18, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 70, 0.09, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.09, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
}
},
// 4: Restless jog — jog back and forth with small hops
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
for (let i = 0; i < 5; i++) {
const dx = (i % 2 === 0 ? 1 : -1) * (30 + Math.random() * 40) * dir
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.13, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 12, 0.065, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.065, (v) => { f.pos.y = v })
),
])
}
f.pos.x = hx
},
// 5: Charge and flex — sprint to center, flex, sprint back
async (f, hx, hy, sx, sy, _dir) => {
const center = W / 2
f.play('special'); sfxZoomWhoosh()
await k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
f.play('win')
await k.tween(1, 1.3, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
spawnEmoteText(f.pos.x, f.pos.y - 50, 'COME AT ME!', '#ff4444')
await k.wait(0.3)
await k.tween(1.3, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 6: Triple backflip retreat — three backflips going backwards
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 3; i++) {
sfxBoing()
const targetX = hx - dir * (i + 1) * 30
const a0 = f.angle || 0
await Promise.all([
k.tween(f.pos.x, targetX, 0.2, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 60, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
k.tween(a0, a0 - 360, 0.2, (v) => { f.angle = v }),
])
f.angle = 0
}
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 7: Kangaroo hops — big bouncing jumps across the width
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
const hops = [hx + dir * 80, hx + dir * 150, hx + dir * 80, hx]
for (const tx of hops) {
await Promise.all([
k.tween(f.pos.x, tx, 0.15, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk(); k.shake(2)
}
},
// 8: Feint combo rush — charge in, throw combo, dodge back
async (f, hx, hy, _sx, _sy, dir) => {
sfxZoomWhoosh(); f.play('attack')
await k.tween(f.pos.x, hx + dir * 100, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
const anims = ['attack', 'kick', 'attack', 'special'] as const
for (const a of anims) { f.play(a); sfxDodge(); await k.wait(0.06) }
sfxBlock(); spawnSparks(f.pos.x + dir * 15, hy - 30, 5, '#ffcc00')
// Dodge-hop back
await Promise.all([
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy - 40, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
},
// 9: Dizzy spin walk — spin while walking forward, stumble back
async (f, hx, hy, _sx, _sy, dir) => {
const a0 = f.angle || 0
f.play('hit')
await Promise.all([
k.tween(f.pos.x, hx + dir * 70, 0.4, (v) => { f.pos.x = v }),
k.tween(a0, a0 + 720, 0.4, (v) => { f.angle = v }),
])
f.angle = 0
spawnEmoteText(f.pos.x, f.pos.y - 40, '@_@', '#ffcc00')
await k.wait(0.15)
// Stumble back
f.play('idle')
for (let i = 0; i < 3; i++) {
await k.tween(f.pos.x, f.pos.x - dir * 25, 0.1, (v) => { f.pos.x = v })
f.pos.y = hy - 5; await k.wait(0.02); f.pos.y = hy
}
},
// 10: Wall-to-wall dash — full screen sprint and back
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZoomWhoosh()
spawnAfterimages(f, 3)
await k.tween(f.pos.x, dir > 0 ? W - 30 : 30, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
sfxBlock(); k.shake(4)
await k.wait(0.1)
sfxZoomWhoosh(); spawnAfterimages(f, 3)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 11: Earthquake stomp march — stomp forward and back
async (f, hx, hy, sx, sy, dir) => {
f.play('attack')
for (let i = 0; i < 4; i++) {
const step = dir * 25
await k.tween(f.pos.x, f.pos.x + step, 0.08, (v) => { f.pos.x = v })
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
await k.wait(0.05); f.scale.y = sy
}
spawnSparks(f.pos.x, hy, 6, '#ff6600')
await k.wait(0.1)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 12: Taunt walk — strut toward opponent, gesture, moonwalk back
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
await k.tween(f.pos.x, hx + dir * 90, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('special')
spawnEmoteText(f.pos.x, f.pos.y - 50, 'TOO EASY!', '#ff44ff')
await k.wait(0.3)
// Moonwalk back
f.play('idle')
await k.tween(f.pos.x, hx, 0.5, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 13: Victory lap sprint — run around in a loop
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxZoomWhoosh()
await k.tween(f.pos.x, hx + dir * 140, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
// Jump over to other side
sfxBoing()
await Promise.all([
k.tween(f.pos.x, hx + dir * 140, 0.25, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 100, 0.125, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.125, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
f.play('win'); spawnSparks(f.pos.x, hy - 20, 5, theme.accent)
await k.wait(0.15)
sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 14: Nervous pacing — quick short paces with jittery hops
async (f, hx, hy) => {
for (let i = 0; i < 6; i++) {
const dx = (Math.random() - 0.5) * 80
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.1, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 10 - Math.random() * 20, 0.05, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
),
])
}
},
// 15: Ground pound jump — massive vertical jump + slam
async (f, hx, hy) => {
sfxBoing(); f.play('kick')
await k.tween(f.pos.y, hy - 150, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
await k.wait(0.1)
f.play('attack')
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(15)
spawnSparks(f.pos.x, hy, 15, '#ff6600')
spawnShockwave(f.pos.x, hy, '#ff4400')
spawnEmoteText(f.pos.x, f.pos.y - 60, 'BOOM!', '#ff2d2d')
await k.wait(0.2)
},
// 16: Zigzag sprint — fast zigzag across the arena
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxZoomWhoosh()
const pts = [
{ x: hx + dir * 50, y: hy - 40 },
{ x: hx + dir * 100, y: hy },
{ x: hx + dir * 150, y: hy - 40 },
{ x: hx + dir * 100, y: hy },
{ x: hx, y: hy },
]
for (const pt of pts) {
await Promise.all([
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
])
}
},
// 17: Double jump kick — jump, kick, jump higher, kick
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxBoing()
const mid = hx + dir * 60
await Promise.all([
k.tween(f.pos.x, mid, 0.15, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 50, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxKick(); spawnSparks(mid, hy - 30, 4, '#00ffcc')
f.play('special'); sfxBoing()
await Promise.all([
k.tween(f.pos.x, mid + dir * 40, 0.15, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxKick(); spawnSparks(f.pos.x, hy - 50, 6, '#ff44ff')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 18: Cocky strut and lean — walk forward, lean back with attitude
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
await k.tween(f.pos.x, hx + dir * 80, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
// Lean back
await k.tween(0, -25, 0.15, (v) => { f.angle = v }, k.easings.easeOutQuad)
await k.wait(0.25)
spawnEmoteText(f.pos.x, f.pos.y - 50, 'EZ', '#ffcc00')
await k.tween(-25, 0, 0.1, (v) => { f.angle = v })
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 19: Windmill sprint — spin while running across
async (f, hx, hy, _sx, _sy, dir) => {
const a0 = f.angle || 0
const anims = ['attack', 'kick', 'special'] as const
let animIdx = 0
const far = hx + dir * 130
f.play(anims[0])
// Sprint while spinning
const dur = 0.4
const startX = f.pos.x
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (far - startX) * t
f.angle = a0 + t * 1080 // 3 full spins
if (Math.floor(t * 12) !== Math.floor((t - 0.01) * 12)) {
f.play(anims[animIdx++ % anims.length])
}
})
f.angle = 0; sfxBlock(); k.shake(3)
await k.wait(0.1)
// Sprint back
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 20: Sumo stomp walk — heavy stomps advancing and retreating
async (f, hx, hy, _sx, sy, dir) => {
f.play('attack')
for (let i = 0; i < 6; i++) {
const step = (i < 3 ? dir : -dir) * 20
await k.tween(f.pos.x, f.pos.x + step, 0.1, (v) => { f.pos.x = v })
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
await k.wait(0.06); f.scale.y = sy
spawnSparks(f.pos.x, hy, 2, '#886644')
}
},
// 21: Flex pose — walk to center, scale up, pose
async (f, hx, hy, sx, sy) => {
const center = W / 2
f.play('win')
await k.tween(f.pos.x, center, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
await k.tween(1, 1.35, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
sfxPowerUp()
spawnEmoteText(f.pos.x, f.pos.y - 60, 'COME ON!', '#ff4444')
await k.wait(0.35)
await k.tween(1.35, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 22: Jump-kick air combo — big jump with mid-air kicks
async (f, hx, hy, _sx, _sy, dir) => {
sfxBoing()
const apex = hy - 120
const far = hx + dir * 100
await k.tween(f.pos.y, apex, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Mid-air combo while moving
f.play('kick'); sfxKick()
await k.tween(f.pos.x, far, 0.1, (v) => { f.pos.x = v })
f.play('attack'); sfxPunch()
await k.tween(f.pos.x, far + dir * 30, 0.08, (v) => { f.pos.x = v })
f.play('special')
spawnSparks(f.pos.x, f.pos.y, 8, '#ff44ff')
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(4)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 23: Air guitar walk — bouncing left and right with music
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxSlideUp()
spawnEmoteText(f.pos.x, f.pos.y - 50, '~♪~', '#ff44ff')
for (let i = 0; i < 6; i++) {
const dx = (i % 2 === 0 ? dir : -dir) * (30 + i * 10)
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.12, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 15, 0.06, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v })
),
])
}
f.pos.x = hx
},
// 24: Crane kick walk — slow martial arts advancing
async (f, hx, hy, _sx, sy, dir) => {
f.play('kick')
await Promise.all([
k.tween(f.pos.x, hx + dir * 60, 0.3, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 30, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
k.tween(1, 1.15, 0.3, (v) => { f.scale.y = sy * v }, k.easings.easeOutQuad),
])
await k.wait(0.3)
f.play('special'); sfxKick()
spawnSparks(f.pos.x + dir * 20, hy - 40, 6, '#00ccff')
await k.wait(0.15)
f.scale.y = sy
await Promise.all([
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 25: Girlfriend kiss — girlfriend walks in, kiss, hearts, she storms off
async (f, hx, hy, _sx, _sy, dir) => {
const startGfX = hx - dir * 120
const gfColor = ['#ff69b4', '#ff1493', '#ff6eb4', '#cc44aa'][Math.floor(Math.random() * 4)]
const cs = SF
const gf = k.add([k.rect(18 * cs, 35 * cs), k.pos(startGfX, hy - 18 * cs), k.anchor('center'), k.color(safeColor(k, gfColor)), k.opacity(0.9), k.z(11)])
const gfHair = k.add([k.rect(22 * cs, 10 * cs), k.pos(startGfX, hy - 35 * cs), k.anchor('center'), k.color(safeColor(k, '#8b4513')), k.opacity(0.9), k.z(12)])
// Walk toward fighter
await Promise.all([
k.tween(gf.pos.x, hx - dir * 15, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInOutQuad),
])
// Kiss!
sfxBoing()
const heart = k.add([k.text('*smooch*', { size: 10 }), k.pos(hx, hy - 55), k.anchor('center'), k.color(safeColor(k, '#ff1493')), k.opacity(1), k.z(30)])
k.tween(heart.pos.y, hy - 90, 0.8, (v) => { heart.pos.y = v })
k.tween(1, 0, 0.8, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
// Spawn floating hearts
for (let i = 0; i < 5; i++) {
const h = k.add([k.text('♥', { size: 8 + Math.random() * 6 }), k.pos(hx + (Math.random() - 0.5) * 30, hy - 30), k.color(safeColor(k, '#ff69b4')), k.opacity(0.8), k.z(25), k.anchor('center')])
k.tween(h.pos.y, hy - 70 - Math.random() * 40, 0.7, (v) => { h.pos.y = v })
k.tween(h.pos.x, h.pos.x + (Math.random() - 0.5) * 40, 0.7, (v) => { h.pos.x = v })
k.tween(0.8, 0, 0.7, (v) => { h.opacity = v }).then(() => { if (h.exists()) h.destroy() })
}
f.play('win')
await k.wait(0.5)
// She storms off
spawnEmoteText(gf.pos.x, gf.pos.y - 30, 'Good luck babe!', '#ff69b4')
await k.tween(gf.pos.x, startGfX, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInQuad)
gf.destroy(); gfHair.destroy()
},
// 26: Push-up walk — walk forward doing push-ups at intervals
async (f, hx, hy, sx, sy, dir) => {
for (let i = 0; i < 3; i++) {
await k.tween(f.pos.x, f.pos.x + dir * 35, 0.15, (v) => { f.pos.x = v })
// Push-up squash
await k.tween(1, 0.55, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeOutQuad)
await k.tween(0.55, 1, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeInQuad)
}
spawnEmoteText(f.pos.x, f.pos.y - 45, `${3 + Math.floor(Math.random() * 97)}!`, '#00ff88')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 27: Matrix dodge walk — dodge bullets walking forward
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 4; i++) {
await k.tween(f.pos.x, f.pos.x + dir * 25, 0.1, (v) => { f.pos.x = v })
// Lean dodge
const lean = (i % 2 === 0 ? -30 : 30)
await k.tween(0, lean, 0.08, (v) => { f.angle = v }, k.easings.easeOutQuad)
await k.tween(lean, 0, 0.08, (v) => { f.angle = v }, k.easings.easeInQuad)
}
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 28: Bouncy chest thump — bounce toward opponent thumping chest
async (f, hx, hy, _sx, _sy, dir) => {
f.play('attack')
for (let i = 0; i < 4; i++) {
await Promise.all([
k.tween(f.pos.x, f.pos.x + dir * 30, 0.1, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 25, 0.05, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
),
])
sfxBlock(); k.shake(2)
}
spawnEmoteText(f.pos.x, f.pos.y - 50, 'LET\'S GO!', '#ff4444')
await k.wait(0.15)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 29: Speed demon — afterimage dash back and forth
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special')
for (let i = 0; i < 3; i++) {
sfxZoomWhoosh(); spawnAfterimages(f, 3)
await k.tween(f.pos.x, hx + dir * (80 + i * 30), 0.08, (v) => { f.pos.x = v }, k.easings.easeInQuad)
spawnAfterimages(f, 2)
await k.tween(f.pos.x, hx - dir * 20, 0.08, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
}
f.pos.x = hx
},
]
// === CREATOR-EXCLUSIVE MAGICAL SHOWBOATS ===
const creatorShowboats: ShowboatFn[] = [
// 0: Levitate across — float up and glide across the screen trailing golden ₿
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxPowerUp()
await k.tween(f.pos.y, hy - 100, 0.25, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Float across dropping ₿ rain
const startX = f.pos.x
const far = hx + dir * 180
const dur = 0.6
let lastDrop = 0
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (far - startX) * t
f.pos.y = hy - 100 + Math.sin(t * Math.PI * 3) * 20
if (t - lastDrop > 0.08) {
lastDrop = t
const d = k.add([k.text('₿', { size: 6 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 10), k.color(safeColor(k, '#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
k.tween(d.pos.y, hy + 10, 0.5, (v) => { d.pos.y = v })
k.tween(0.7, 0, 0.5, (v) => { d.opacity = v }).then(() => { if (d.exists()) d.destroy() })
}
})
screenFlash('#ffd700', 0.06)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 1: Golden portal teleport — vanish, portals, reappear at random spots
async (f, hx, hy) => {
sfxSpecial()
// Vanish with flash
screenFlash('#ffd700', 0.08)
f.opacity = 0
// Teleport to 3 spots with golden portals
const spots = [
{ x: W * 0.2, y: hy - 60 },
{ x: W * 0.8, y: hy - 80 },
{ x: W * 0.5, y: hy - 120 },
]
for (const spot of spots) {
// Portal flash at destination
const portal = k.add([k.circle(20), k.pos(spot.x, spot.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.6), k.z(15), k.anchor('center')])
spawnSparks(spot.x, spot.y, 6, '#ffd700')
f.pos.x = spot.x; f.pos.y = spot.y; f.opacity = 1
f.play('special')
await k.wait(0.15)
f.opacity = 0
k.tween(0.6, 0, 0.2, (v) => { portal.opacity = v }).then(() => { if (portal.exists()) portal.destroy() })
sfxZap()
}
// Return home with big flash
screenFlash('#ffd700', 0.1)
f.pos.x = hx; f.pos.y = hy; f.opacity = 1
f.play('win')
spawnShockwave(hx, hy, '#ffd700')
sfxExplosion()
await k.wait(0.2)
},
// 2: Code rain summon — float up, summon massive golden code rain
async (f, hx, hy) => {
f.play('special'); sfxPowerUp()
await k.tween(f.pos.y, hy - 80, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Massive code rain
const chars = '₿01∞⚡⛏§∂∆≈≠'.split('')
for (let col = 0; col < 20; col++) {
const cx = Math.random() * W
for (let row = 0; row < 3; row++) {
const ch = chars[Math.floor(Math.random() * chars.length)]
const d = k.add([
k.text(ch, { size: 6 + Math.random() * 4 }), k.pos(cx, -10 - row * 20),
k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')),
k.opacity(0.5 + Math.random() * 0.4), k.z(8), k.anchor('center'),
])
k.tween(d.pos.y, hy + 30, 0.5 + Math.random() * 0.4, (v) => { d.pos.y = v; d.opacity = Math.max(0, 1 - (v - hy + 30) / 60) })
.then(() => { if (d.exists()) d.destroy() })
}
}
await k.wait(0.6)
await k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 3: ₿ orbit explosion — fly to center, ₿ symbols orbit then explode outward
async (f, hx, hy, sx, sy) => {
sfxZoomWhoosh(); f.play('special')
const center = W / 2
await Promise.all([
k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad),
k.tween(f.pos.y, hy - 60, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
])
// Spawn orbiting ₿
const orbs: any[] = []
for (let i = 0; i < 8; i++) {
const orb = k.add([k.text('₿', { size: 8 + Math.random() * 6 }), k.pos(center, f.pos.y), k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(15), k.anchor('center')])
orbs.push(orb)
}
// Orbit for a moment
const startTime = k.time()
const orbitCancel = k.onUpdate(() => {
const t = k.time() - startTime
for (let i = 0; i < orbs.length; i++) {
const a = t * 4 + i * (Math.PI * 2 / orbs.length)
const r = 30 + t * 15
orbs[i].pos.x = f.pos.x + Math.cos(a) * r
orbs[i].pos.y = f.pos.y + Math.sin(a) * r * 0.5
}
})
await k.wait(0.5)
orbitCancel.cancel()
// Explode outward
sfxExplosion(); screenFlash('#ffd700', 0.08); k.shake(8)
for (const orb of orbs) {
const angle = Math.random() * Math.PI * 2
k.tween(orb.pos.x, orb.pos.x + Math.cos(angle) * 200, 0.3, (v) => { orb.pos.x = v })
k.tween(orb.pos.y, orb.pos.y + Math.sin(angle) * 200, 0.3, (v) => { orb.pos.y = v })
k.tween(0.8, 0, 0.3, (v) => { orb.opacity = v }).then(() => { if (orb.exists()) orb.destroy() })
}
await k.wait(0.2)
await Promise.all([
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 4: Lightning flight — zigzag across the screen at super speed with lightning
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZoomWhoosh()
f.opacity = 0.8
const pts = [
{ x: hx + dir * 60, y: hy - 80 },
{ x: W / 2, y: hy - 140 },
{ x: hx + dir * 180, y: hy - 60 },
{ x: W / 2, y: hy - 100 },
{ x: hx, y: hy },
]
for (const pt of pts) {
spawnAfterimages(f, 2)
await Promise.all([
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
])
// Lightning bolt at each stop
sfxZap()
const bolt = k.add([k.rect(2, 30 + Math.random() * 40), k.pos(f.pos.x, f.pos.y - 50), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(20), k.anchor('center'), k.rotate(Math.random() * 30 - 15)])
k.tween(0.9, 0, 0.15, (v) => { bolt.opacity = v }).then(() => { if (bolt.exists()) bolt.destroy() })
}
f.opacity = 1
},
// 5: Satoshi meditation — float to center, golden aura pulse, wisdom text
async (f, hx, hy, sx, sy) => {
sfxPowerUp(); f.play('special')
await Promise.all([
k.tween(f.pos.x, W / 2, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 70, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
])
// Golden aura rings
for (let i = 0; i < 3; i++) {
const ring = k.add([k.circle(10), k.pos(f.pos.x, f.pos.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.5), k.z(9), k.anchor('center'), k.scale(1)])
k.tween(1, (60 + i * 20) / 10, 0.4, (v) => { ring.scale.x = v; ring.scale.y = v })
k.tween(0.5, 0, 0.4, (v) => { ring.opacity = v }).then(() => { if (ring.exists()) ring.destroy() })
await k.wait(0.12)
}
// Scale pulse
await k.tween(1, 1.2, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
const sayings = ['PROOF OF WORK', 'VERIFY, DON\'T TRUST', '21 MILLION', 'TICK TOCK NEXT BLOCK', 'NOT YOUR KEYS', 'STAY HUMBLE STACK SATS']
spawnEmoteText(f.pos.x, f.pos.y - 50, sayings[Math.floor(Math.random() * sayings.length)], '#ffd700')
await k.wait(0.4)
await k.tween(1.2, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
await Promise.all([
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 6: Golden tornado — spin rapidly while moving in a circle across the arena
async (f, hx, hy) => {
sfxZoomWhoosh(); f.play('special')
const cx = W / 2, cy = hy - 50, radius = 100
const dur = 0.8
let lastParticle = 0
await k.tween(0, Math.PI * 2, dur, (a) => {
f.pos.x = cx + Math.cos(a) * radius
f.pos.y = cy + Math.sin(a) * radius * 0.4
f.angle = (a / (Math.PI * 2)) * 1440 // 4 full spins
if (a - lastParticle > 0.3) {
lastParticle = a
const p = k.add([k.text('₿', { size: 5 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.6), k.z(9), k.anchor('center')])
k.tween(0.6, 0, 0.4, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
}
})
f.angle = 0
sfxExplosion(); k.shake(6)
spawnShockwave(f.pos.x, f.pos.y, '#ffd700')
f.pos.x = hx; f.pos.y = hy
},
// 7: Laptop hack — walk forward, pull out laptop (rect), type furiously, spawn effects
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special')
await k.tween(f.pos.x, hx + dir * 50, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
// Laptop
const cs = SF
const laptop = k.add([k.rect(24 * cs, 16 * cs), k.pos(f.pos.x + dir * 15, hy - 20 * cs), k.anchor('center'), k.color(safeColor(k, '#333333')), k.opacity(0.9), k.z(12)])
const screen = k.add([k.rect(20 * cs, 12 * cs), k.pos(f.pos.x + dir * 15, hy - 22 * cs), k.anchor('center'), k.color(safeColor(k, '#00ff00')), k.opacity(0.7), k.z(13)])
// Type — spawn code characters
const codeChars = '₿{}();=>0x1f'.split('')
for (let i = 0; i < 8; i++) {
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
const t = k.add([k.text(ch, { size: 5 }), k.pos(screen.pos.x + (Math.random() - 0.5) * 15, screen.pos.y), k.color(safeColor(k, '#00ff00')), k.opacity(0.8), k.z(14), k.anchor('center')])
k.tween(t.pos.y, t.pos.y - 25 - Math.random() * 20, 0.3, (v) => { t.pos.y = v })
k.tween(0.8, 0, 0.3, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
sfxCoin()
await k.wait(0.06)
}
spawnEmoteText(f.pos.x, f.pos.y - 55, 'HACKING...', '#00ff00')
await k.wait(0.2)
laptop.destroy(); screen.destroy()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 8: Bitcoin rain shower — fly across dropping ₿ coins everywhere
async (f, hx, hy, _sx, _sy, dir) => {
sfxPowerUp(); f.play('win')
await k.tween(f.pos.y, hy - 90, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Fly from one side to the other
const startX = dir > 0 ? 30 : W - 30
const endX = dir > 0 ? W - 30 : 30
f.pos.x = startX
const dur = 0.6
let lastCoin = 0
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (endX - startX) * t
f.pos.y = hy - 90 + Math.sin(t * Math.PI * 4) * 15
if (t - lastCoin > 0.04) {
lastCoin = t
const coin = k.add([k.text('₿', { size: 8 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 15), k.color(safeColor(k, Math.random() > 0.5 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(9), k.anchor('center')])
k.tween(coin.pos.y, hy + 10, 0.4, (v) => { coin.pos.y = v })
k.tween(coin.pos.x, coin.pos.x + (Math.random() - 0.5) * 30, 0.4, (v) => { coin.pos.x = v })
k.tween(0.8, 0, 0.4, (v) => { coin.opacity = v }).then(() => { if (coin.exists()) coin.destroy() })
}
})
sfxCoin()
f.pos.x = hx
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 9: Dimension rift — creator tears open a golden rift, steps through, appears elsewhere
async (f, hx, hy) => {
// Rift opens
sfxSpecial()
const riftX = f.pos.x + 30
const rift = k.add([k.rect(4, 50), k.pos(riftX, hy - 25), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(15)])
await k.tween(4, 30, 0.2, (v) => { rift.width = v })
spawnSparks(riftX, hy - 25, 8, '#ffd700')
// Step through — vanish
f.play('special')
await k.tween(f.pos.x, riftX, 0.1, (v) => { f.pos.x = v })
f.opacity = 0; sfxZap()
// Rift at destination
const destX = W - riftX
const rift2 = k.add([k.rect(30, 50), k.pos(destX, hy - 25), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(15)])
spawnSparks(destX, hy - 25, 8, '#ffd700')
await k.wait(0.15)
// Step out
f.pos.x = destX; f.opacity = 1; f.play('win')
sfxSpecial()
await k.wait(0.2)
// Close rifts
k.tween(0.9, 0, 0.2, (v) => { rift.opacity = v; rift2.opacity = v }).then(() => { if (rift.exists()) rift.destroy(); if (rift2.exists()) rift2.destroy() })
// Float home
await Promise.all([
k.tween(f.pos.x, hx, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 40, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
},
// 10: Golden ascension — rise way up, glow, descend with shockwave
async (f, hx, hy, sx, sy) => {
sfxPowerUp(); f.play('special')
await k.tween(f.pos.y, hy - 160, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Glow pulse
await k.tween(1, 1.5, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
screenFlash('#ffd700', 0.06)
// Spawn golden aura particles
for (let i = 0; i < 10; i++) {
const p = k.add([k.text('✦', { size: 4 + Math.random() * 6 }), k.pos(f.pos.x + (Math.random() - 0.5) * 40, f.pos.y + (Math.random() - 0.5) * 30), k.color(safeColor(k, '#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
k.tween(0.7, 0, 0.5, (v) => { p.opacity = v; p.pos.y -= 0.5 }).then(() => { if (p.exists()) p.destroy() })
}
spawnEmoteText(f.pos.x, f.pos.y - 40, 'HODL', '#ffd700')
await k.wait(0.3)
await k.tween(1.5, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
// Slam down
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(12)
spawnShockwave(hx, hy, '#ffd700')
spawnSparks(hx, hy, 15, '#ffd700')
await k.wait(0.15)
},
// 11: Phase walk — flicker opacity while walking through the opponent's space
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZap()
const far = dir > 0 ? W - 40 : 40
const dur = 0.5
await k.tween(0, 1, dur, (t) => {
f.pos.x = hx + (far - hx) * t
f.opacity = 0.2 + Math.abs(Math.sin(t * Math.PI * 8)) * 0.7
})
f.opacity = 1; sfxSpecial()
screenFlash('#ffd700', 0.05)
spawnSparks(f.pos.x, hy - 20, 6, '#ffd700')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 12: Bullet time attack — slow-mo dash with afterimages + golden bullet traces
async (f, hx, hy, _sx, _sy, dir) => {
f.play('attack')
// Darken screen for bullet time
const overlay = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(50)])
await k.tween(0, 0.35, 0.08, (v) => { overlay.opacity = v })
// Slow-mo lean back
await k.tween(0, -30, 0.2, (v) => { f.angle = v }, k.easings.easeOutQuad)
// Golden bullet traces fly past
for (let i = 0; i < 6; i++) {
const by = hy - 15 - Math.random() * 50
const bullet = k.add([k.rect(15, 2), k.pos(dir > 0 ? W + 10 : -10, by), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(51), k.anchor('center')])
const bdir = dir > 0 ? -1 : 1
k.tween(bullet.pos.x, bullet.pos.x + bdir * (W + 30), 0.35, (v) => { bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() })
sfxZoomWhoosh()
await k.wait(0.04)
}
await k.wait(0.1)
// Snap upright and dash through
await k.tween(-30, 0, 0.06, (v) => { f.angle = v })
f.play('special'); sfxZoomWhoosh()
// Dash with afterimages
const endX = hx + dir * 160
for (let i = 0; i < 6; i++) {
const ghostX = hx + (endX - hx) * (i / 6)
const ghost = k.add([k.rect(16, 30), k.pos(ghostX, hy - 15), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.3 - i * 0.04), k.z(49)])
k.tween(0.3, 0, 0.4, (v) => { ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() })
}
await k.tween(f.pos.x, endX, 0.12, (v) => { f.pos.x = v }, k.easings.easeInQuad)
screenFlash('#ffd700', 0.06); sfxCritical(); k.shake(8)
spawnSparks(f.pos.x, hy - 20, 10, '#ffd700')
await k.wait(0.15)
// Zip back
await k.tween(overlay.opacity, 0, 0.1, (v) => { overlay.opacity = v })
overlay.destroy()
await k.tween(f.pos.x, hx, 0.1, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 13: ₿ throne summon — conjure golden throne, sit imperiously, dismiss
async (f, hx, hy) => {
sfxSpecial()
const cs = SF
// Throne pieces
const seat = k.add([k.rect(40 * cs, 8 * cs), k.pos(hx, hy - 5), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
const back = k.add([k.rect(35 * cs, 30 * cs), k.pos(hx, hy - 25 * cs), k.anchor('center'), k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(8)])
const armL = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx - 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
const armR = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx + 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
const parts = [seat, back, armL, armR]
// Fade in
for (const p of parts) k.tween(0, 0.8, 0.2, (v) => { p.opacity = v })
spawnSparks(hx, hy - 15, 6, '#ffd700')
await k.wait(0.25)
// Sit
f.play('win')
await k.tween(f.pos.y, hy - 12, 0.12, (v) => { f.pos.y = v }, k.easings.easeInQuad)
spawnEmoteText(hx, f.pos.y - 50, 'kneel', '#ffd700')
await k.wait(0.6)
// Stand and dismiss
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v })
for (const p of parts) k.tween(p.opacity, 0, 0.2, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
await k.wait(0.2)
},
// 14: Disco dance — Creator gets down with golden disco moves
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
const colors = ['#ffd700', '#ff8c00', '#ffee88', '#c8a000', '#ffffff', '#ffd700']
spawnEmoteText(f.pos.x, f.pos.y - 55, '~♪ SATOSHI SHUFFLE ♪~', '#ffd700')
for (let i = 0; i < 10; i++) {
const dx = (i % 2 === 0 ? 25 : -25) + (Math.random() - 0.5) * 15
f.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'win')
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.06, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 15, 0.03, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.03, (v) => { f.pos.y = v })),
])
spawnSparks(f.pos.x, hy - 10, 2, colors[i % colors.length])
}
f.pos.x = hx
},
]
// Showboat loop: picks random showboats in sequence while active
async function _showboatLoop(side: 'a' | 'b') {
const tag = side === 'a' ? 'fighterA' : 'fighterB'
const homeX = side === 'a' ? HOME_A : HOME_B
const homeY = GROUND_Y - 6
const dir = side === 'a' ? 1 : -1
const arch = side === 'a' ? botA.archetype : botB.archetype
const isCreator = arch === 'the_creator'
// Creator uses magical showboats 60% of the time, normal 40%
const pool = isCreator ? [...showboats, ...creatorShowboats, ...creatorShowboats] : showboats
while (_showboatActive[side]) {
const f = k.get(tag)[0]
if (!f) break
const sx = f.scale.x
const sy = f.scale.y
const idx = Math.floor(Math.random() * pool.length)
try {
await pool[idx](f, homeX, homeY, sx, sy, dir)
// 40% chance of comedy sound punctuation after each showboat
if (Math.random() < 0.4) sfxRandomComedy()
} catch { break }
// Restore state after each showboat
const ff = k.get(tag)[0]
if (ff) {
ff.pos.x = homeX; ff.pos.y = homeY
ff.scale.x = sx; ff.scale.y = sy
ff.angle = 0; ff.opacity = 1
ff.play('idle')
}
// Brief pause between showboats
if (_showboatActive[side]) await k.wait(0.15 + Math.random() * 0.35)
}
}
return {
k,
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
showSpeechBubble(side, text, duration)
},
startTalking(side: 'a' | 'b') { startTalking(side) },
stopTalking(side: 'a' | 'b') { stopTalking(side) },
startShowboating(side: 'a' | 'b') {
if (_showboatActive[side]) return
_showboatActive[side] = true
_showboatLoop(side)
},
stopShowboating(side: 'a' | 'b') {
_showboatActive[side] = false
},
async playEntrance() {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const entrances = [
// 0: Drive in with a car
async (fighter: any, homeX: number, fromLeft: boolean) => {
const dir = fromLeft ? 1 : -1
const startX = fromLeft ? -120 : W + 120
const carY = GROUND_Y - 20
const carBody = k.add([k.rect(80, 30), k.pos(startX, carY), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#ffcc00', '#39ff14', '#ff6600'][Math.floor(Math.random() * 5)])), k.z(9), k.opacity(1)])
const wheel1 = k.add([k.circle(8), k.pos(startX - 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)])
const wheel2 = k.add([k.circle(8), k.pos(startX + 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)])
fighter.pos.x = startX
fighter.pos.y = carY - 30
fighter.opacity = 1
sfxZoomWhoosh()
await k.tween(startX, homeX, 0.5, (v) => {
carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir; fighter.pos.x = v
}, k.easings.easeOutQuad)
sfxBlock()
k.shake(6)
// Jump out
await k.tween(fighter.pos.y, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad)
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
sfxBonk()
// Drive car away
k.tween(carBody.pos.x, fromLeft ? W + 150 : -150, 0.4, (v) => {
carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir
}, k.easings.easeInQuad).then(() => { carBody.destroy(); wheel1.destroy(); wheel2.destroy() })
},
// 1: Fall from space
async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX
fighter.pos.y = -200
fighter.opacity = 1
sfxZoomWhoosh()
// Trail of fire
const trail: any[] = []
for (let i = 0; i < 6; i++) {
setTimeout(() => {
const t = k.add([k.circle(4 + Math.random() * 6), k.pos(homeX + (Math.random() - 0.5) * 20, fighter.pos.y + 20), k.color(safeColor(k,i < 3 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(9)])
trail.push(t)
k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
}, i * 40)
}
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
sfxExplosion()
k.shake(15)
screenFlash('#ff6600', 0.15)
spawnShockwave(homeX, GROUND_Y, '#ff6600')
spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00')
trail.forEach(t => { if (t.exists()) t.destroy() })
},
// 2: Robe entrance (boxing style)
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -60 : W + 60
fighter.pos.x = startX
fighter.pos.y = GROUND_Y - 6
fighter.opacity = 1
// Robe overlay
const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)]
const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,robeColor)), k.opacity(0.85), k.z(11)])
announceDeepIntro()
// Slow walk in
await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad)
await k.wait(0.3)
// Take off robe (fly up and fade)
sfxSpecial()
await k.tween(robe.pos.y, robe.pos.y - 100, 0.3, (v) => { robe.pos.y = v; robe.opacity = Math.max(0, 1 - (robe.pos.y - GROUND_Y + 130) / -100) }, k.easings.easeOutQuad)
robe.destroy()
k.shake(3)
spawnSparks(homeX, GROUND_Y - 40, 8, robeColor)
},
// 3: Girlfriend argument
async (fighter: any, homeX: number, fromLeft: boolean) => {
const dir = fromLeft ? 1 : -1
const startX = fromLeft ? -60 : W + 60
fighter.pos.x = startX
fighter.pos.y = GROUND_Y - 6
fighter.opacity = 1
// Girlfriend silhouette
const gfX = startX + dir * 40
const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor(k,'#ff69b4')), k.opacity(0.9), k.z(11)])
const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor(k,'#ff0000')), k.z(12)])
announceSilly('We need to talk! About your ELO!')
// Walk in arguing
await k.tween(startX, homeX + dir * 30, 0.6, (v) => {
fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40
}, k.easings.easeInOutQuad)
await k.wait(0.2)
heart.text = '!!!'
announceRandom("You promised you'd stop fighting!")
await k.wait(0.4)
// Girlfriend storms off
announceSilly('I\'m telling your developer!')
await k.tween(gf.pos.x, fromLeft ? -60 : W + 60, 0.5, (v) => { gf.pos.x = v; heart.pos.x = v }, k.easings.easeInQuad)
gf.destroy(); heart.destroy()
// Bot shrugs and walks to position
await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeInOutQuad)
sfxBoing()
},
// 4: Helicopter drop
async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX
fighter.pos.y = -100
fighter.opacity = 1
// Helicopter body
const heli = k.add([k.rect(60, 20), k.pos(homeX, -80), k.anchor('center'), k.color(safeColor(k,'#555555')), k.z(12)])
const blade = k.add([k.rect(80, 3), k.pos(homeX, -95), k.anchor('center'), k.color(safeColor(k,'#888888')), k.z(13), k.rotate(0)])
blade.onUpdate(() => { blade.angle += 720 * k.dt() })
sfxJetpack()
// Descend
await k.tween(-80, GROUND_Y - 80, 0.6, (v) => {
heli.pos.y = v; blade.pos.y = v - 15; fighter.pos.y = v + 30
}, k.easings.easeInOutQuad)
// Drop
sfxZoomWhoosh()
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.2, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(5)
// Helicopter flies away
k.tween(heli.pos.y, -200, 0.5, (v) => { heli.pos.y = v; blade.pos.y = v - 15 }, k.easings.easeInQuad)
.then(() => { heli.destroy(); blade.destroy() })
},
// 5: Teleport glitch
async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.opacity = 0
sfxZap()
// Glitch flickers at random positions
for (let i = 0; i < 6; i++) {
fighter.pos.x = homeX + (Math.random() - 0.5) * 200
fighter.pos.y = GROUND_Y - 6 + (Math.random() - 0.5) * 80
fighter.opacity = 0.4
scanlineGlitch(0.05)
await k.wait(0.06)
fighter.opacity = 0
await k.wait(0.04)
}
fighter.pos.x = homeX
fighter.pos.y = GROUND_Y - 6
fighter.opacity = 1
sfxZap()
screenFlash('#00f0ff', 0.1)
spawnSparks(homeX, GROUND_Y - 30, 10, '#00f0ff')
k.shake(5)
},
// 6: Skateboard ride in
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -80 : W + 80
const board = k.add([k.rect(40, 6), k.pos(startX, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#884422')), k.z(9)])
const wheelL = k.add([k.circle(4), k.pos(startX - 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)])
const wheelR = k.add([k.circle(4), k.pos(startX + 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)])
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 14; fighter.opacity = 1
sfxZoomWhoosh()
await k.tween(startX, homeX, 0.5, (v) => {
fighter.pos.x = v; board.pos.x = v; wheelL.pos.x = v - 14; wheelR.pos.x = v + 14
fighter.pos.y = GROUND_Y - 14 + Math.sin((v - startX) * 0.1) * 3
}, k.easings.easeOutQuad)
// Kickflip off
sfxBoing()
await k.tween(fighter.pos.y, GROUND_Y - 60, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad)
board.destroy(); wheelL.destroy(); wheelR.destroy()
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(3)
},
// 7: Rocket jetpack
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -80 : W + 80
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 80; fighter.opacity = 1
sfxJetpack()
// Fly in with flame trail
const flames: any[] = []
const interval = trackedInterval(() => {
const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)])
flames.push(f)
k.tween(f.opacity, 0, 0.3, (v) => { f.opacity = v }).then(() => { if (f.exists()) f.destroy() })
}, 30)
await k.tween(startX, homeX, 0.4, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad)
clearTracked(interval)
// Land
sfxExplosion()
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
k.shake(8)
spawnSparks(homeX, GROUND_Y - 10, 10, '#ff6600')
flames.forEach(f => { if (f.exists()) f.destroy() })
},
// 8: Emerge from portal
async (fighter: any, homeX: number, _fromLeft: boolean) => {
const portalColor = ['#b83dff', '#00f0ff', '#39ff14', '#ff2d7b'][Math.floor(Math.random() * 4)]
// Draw portal
const portal = k.add([k.circle(35), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,portalColor)), k.opacity(0), k.z(9)])
const portalRing = k.add([k.circle(40), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,'#ffffff')), k.opacity(0), k.z(8)])
sfxZap()
await k.tween(0, 0.7, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeOutQuad)
fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 30
fighter.opacity = 0
await k.tween(0, 1, 0.3, (v) => { fighter.opacity = v; fighter.pos.y = GROUND_Y - 30 + (GROUND_Y - 6 - GROUND_Y + 30) * v }, k.easings.easeOutQuad)
fighter.pos.y = GROUND_Y - 6
fighter.opacity = 1
sfxSpecial()
spawnSparks(homeX, GROUND_Y - 20, 10, portalColor)
await k.tween(portal.opacity, 0, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeInQuad)
portal.destroy(); portalRing.destroy()
},
// 9: Backflip entrance
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -40 : W + 40
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
sfxZoomWhoosh()
const flipCount = 2 + Math.floor(Math.random() * 2)
const totalDist = Math.abs(homeX - startX)
const flipDist = totalDist / flipCount
for (let i = 0; i < flipCount; i++) {
const from = startX + (fromLeft ? 1 : -1) * flipDist * i
const to = startX + (fromLeft ? 1 : -1) * flipDist * (i + 1)
await Promise.all([
k.tween(from, to, 0.2, (v) => { fighter.pos.x = v }, k.easings.linear),
k.tween(GROUND_Y - 6, GROUND_Y - 70, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(GROUND_Y - 70, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
),
])
if (i < flipCount - 1) sfxBoing()
}
sfxBonk(); k.shake(4)
spawnSparks(homeX, GROUND_Y - 10, 6, '#ffe14d')
},
// 10: Rise from underground
async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX; fighter.pos.y = GROUND_Y + 60; fighter.opacity = 0.5
// Crack the ground
const crack1 = k.add([k.rect(3, 15), k.pos(homeX - 10, GROUND_Y - 5), k.color(safeColor(k,'#ffcc00')), k.opacity(0.7), k.z(9), k.rotate(15)])
const crack2 = k.add([k.rect(3, 12), k.pos(homeX + 8, GROUND_Y - 3), k.color(safeColor(k,'#ffcc00')), k.opacity(0.6), k.z(9), k.rotate(-20)])
sfxExplosion(); k.shake(8)
await k.wait(0.2)
// Rise up
await k.tween(GROUND_Y + 60, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v; fighter.opacity = Math.min(1, (GROUND_Y + 60 - v) / 60) }, k.easings.easeOutQuad)
fighter.opacity = 1
spawnSparks(homeX, GROUND_Y - 10, 12, '#aa8833')
crack1.destroy(); crack2.destroy()
},
// 11: Slide in on ice
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -80 : W + 80
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
sfxSlideDown()
// Ice trail
const iceTrail: any[] = []
await k.tween(startX, homeX + (fromLeft ? 40 : -40), 0.4, (v) => {
fighter.pos.x = v
if (Math.random() < 0.3) {
const ice = k.add([k.rect(8, 3), k.pos(v, GROUND_Y - 2), k.color(safeColor(k,'#aaeeff')), k.opacity(0.5), k.z(1)])
iceTrail.push(ice)
}
}, k.easings.easeOutQuad)
// Slide to stop
await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutCubic)
sfxBlock(); k.shake(3)
setTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800)
},
// 12: Parachute drop
async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX + (Math.random() - 0.5) * 60; fighter.pos.y = -120; fighter.opacity = 1
const chute = k.add([k.circle(30), k.pos(fighter.pos.x, fighter.pos.y - 35), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00'][Math.floor(Math.random() * 4)])), k.opacity(0.8), k.z(12)])
const line1 = k.add([k.rect(1, 30), k.pos(fighter.pos.x - 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)])
const line2 = k.add([k.rect(1, 30), k.pos(fighter.pos.x + 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)])
// Float down
await k.tween(-120, GROUND_Y - 50, 0.7, (v) => {
fighter.pos.y = v; chute.pos.y = v - 35; line1.pos.y = v - 20; line2.pos.y = v - 20
fighter.pos.x += Math.sin(v * 0.05) * 0.5
chute.pos.x = fighter.pos.x; line1.pos.x = fighter.pos.x - 10; line2.pos.x = fighter.pos.x + 10
}, k.easings.easeInOutQuad)
// Cut chute
chute.destroy(); line1.destroy(); line2.destroy()
sfxZoomWhoosh()
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
await k.tween(fighter.pos.x, homeX, 0.2, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad)
sfxBonk(); k.shake(4)
},
// 13: Moonwalk in
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -60 : W + 60
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
announceCool('Smoother than a politician changing positions!')
// Moonwalk: visually moving backward but translating forward
fighter.scale.x = -fighter.scale.x // Face away
await k.tween(startX, homeX, 0.8, (v) => {
fighter.pos.x = v
fighter.pos.y = GROUND_Y - 6 + Math.sin((v - startX) * 0.15) * 3
}, k.easings.easeInOutQuad)
fighter.scale.x = -fighter.scale.x // Face right way
sfxBoing(); k.shake(2)
},
// 14: Thrown in by bouncer
async (fighter: any, homeX: number, fromLeft: boolean) => {
const doorX = fromLeft ? -30 : W + 30
fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
// Bouncer arm
const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor(k,'#444444')), k.z(12)])
announceSilly('And stay out! You\'re BANNED from the other fight!')
await k.wait(0.3)
// Throw
sfxZoomWhoosh()
arm.destroy()
await Promise.all([
k.tween(doorX, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad),
k.tween(GROUND_Y - 6, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(GROUND_Y - 80, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk(); k.shake(6)
spawnSparks(homeX, GROUND_Y - 10, 6, '#ff6600')
},
// 15: Lightning strike entrance
async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 0
// Lightning bolt from sky
const bolt = k.add([k.rect(4, H), k.pos(homeX, 0), k.color(safeColor(k,'#ffff44')), k.opacity(0.9), k.z(20)])
sfxZap(); screenFlash('#ffffff', 0.15); k.shake(12)
await k.wait(0.1)
bolt.destroy()
// Smoke / reveal
for (let i = 0; i < 8; i++) {
const smoke = k.add([k.circle(10 + Math.random() * 15), k.pos(homeX + (Math.random() - 0.5) * 40, GROUND_Y - 20 - Math.random() * 30), k.color(safeColor(k,'#aaaaaa')), k.opacity(0.6), k.z(11)])
k.tween(smoke.opacity, 0, 0.5, (v) => { smoke.opacity = v; smoke.pos.y -= 1 }).then(() => { if (smoke.exists()) smoke.destroy() })
}
await k.wait(0.3)
fighter.opacity = 1
sfxSpecial()
spawnSparks(homeX, GROUND_Y - 20, 10, '#ffff44')
},
// 16: Crowd surf in
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -60 : W + 60
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 50; fighter.opacity = 1
// Crowd hands
const hands: any[] = []
for (let i = 0; i < 8; i++) {
const hx = startX + (fromLeft ? 1 : -1) * (Math.abs(homeX - startX) / 8) * i
const h = k.add([k.rect(6, 20), k.pos(hx, GROUND_Y - 10), k.anchor('bot'), k.color(safeColor(k,'#cc9966')), k.z(8)])
hands.push(h)
}
announceCrowdReaction('cheer')
await k.tween(startX, homeX, 0.6, (v) => {
fighter.pos.x = v
fighter.pos.y = GROUND_Y - 50 + Math.sin((v - startX) * 0.08) * 10
}, k.easings.easeOutQuad)
// Drop down
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(3)
hands.forEach(h => { if (h.exists()) h.destroy() })
},
// 17: Riding a shopping cart
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -100 : W + 100
const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor(k,'#888888')), k.opacity(0.9), k.z(9)])
const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#444')), k.z(9)])
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1
announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!')
sfxZoomWhoosh()
await k.tween(startX, homeX, 0.5, (v) => {
fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15)
fighter.pos.y = GROUND_Y - 40 + Math.sin((v - startX) * 0.08) * 5
}, k.easings.easeOutQuad)
// Crash and tumble out
sfxBonk(); k.shake(6)
cartBody.destroy(); cartWheel.destroy()
await k.tween(fighter.pos.y, GROUND_Y - 50, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad)
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
spawnSparks(homeX, GROUND_Y - 10, 5, '#888888')
},
// 18: Dramatic slow walk with spotlight
async (fighter: any, homeX: number, fromLeft: boolean) => {
const startX = fromLeft ? -60 : W + 60
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
// Spotlight cone
const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor(k,'#ffe14d')), k.opacity(0.08), k.z(1)])
announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!')
await k.tween(startX, homeX, 1.0, (v) => {
fighter.pos.x = v; spot.pos.x = v - 30
}, k.easings.easeInOutQuad)
await k.wait(0.2)
spot.destroy()
sfxSpecial(); k.shake(3)
spawnSparks(homeX, GROUND_Y - 30, 8, '#ffe14d')
},
// 19: Cannon launch
async (fighter: any, homeX: number, fromLeft: boolean) => {
const cannonX = fromLeft ? -40 : W + 40
// Draw cannon
const cannon = k.add([k.rect(50, 25), k.pos(cannonX, GROUND_Y - 20), k.anchor('center'), k.color(safeColor(k,'#333333')), k.z(9), k.rotate(fromLeft ? -30 : 210)])
fighter.pos.x = cannonX; fighter.pos.y = GROUND_Y - 20; fighter.opacity = 0
await k.wait(0.3)
// Fire!
sfxGunshot(); sfxExplosion()
fighter.opacity = 1
screenFlash('#ffcc00', 0.1)
const flashCircle = k.add([k.circle(20), k.pos(cannonX + (fromLeft ? 25 : -25), GROUND_Y - 35), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(12)])
setTimeout(() => { if (flashCircle.exists()) flashCircle.destroy() }, 80)
// Arc to position
await Promise.all([
k.tween(cannonX, homeX, 0.35, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad),
k.tween(GROUND_Y - 20, GROUND_Y - 120, 0.17, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(GROUND_Y - 120, GROUND_Y - 6, 0.18, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk(); k.shake(10)
spawnShockwave(homeX, GROUND_Y, '#ff6600')
spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00')
cannon.destroy()
},
]
// THE CREATOR: Golden code rain portal entrance
const creatorEntrance = async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX
fighter.pos.y = -100
fighter.opacity = 0
// Golden code rain columns
const codeChars = '₿01∞⚡⛏§∂∆≈≠'.split('')
const rainDrops: any[] = []
for (let col = 0; col < 12; col++) {
const cx = homeX - 60 + col * 10
for (let row = 0; row < 4; row++) {
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
const rd = k.add([
k.text(ch, { size: 8 }), k.pos(cx, -20 - row * 25),
k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')),
k.opacity(0.6 + Math.random() * 0.4), k.z(48), k.anchor('center'),
])
rainDrops.push(rd)
k.tween(rd.pos.y, GROUND_Y + 20, 0.6 + Math.random() * 0.3, (v) => {
rd.pos.y = v
rd.opacity = Math.max(0, 1 - (v - GROUND_Y + 30) / 50)
}).then(() => { if (rd.exists()) rd.destroy() })
}
}
await k.wait(0.3)
// Portal flash
screenFlash('#ffd700', 0.15)
spawnShockwave(homeX, GROUND_Y - 30, '#c8a000')
sfxSpecial()
// Fighter descends through golden portal
fighter.opacity = 1
await k.tween(-100, GROUND_Y - 6, 0.5, (v) => {
fighter.pos.y = v
}, k.easings.easeOutBack)
k.shake(12)
sfxExplosion()
// Persistent orbiting ₿ letters around creator (always visible)
for (let i = 0; i < 10; i++) {
const ring = i < 6 ? 0 : 1
const ringIdx = ring === 0 ? i : i - 6
const ringCount = ring === 0 ? 6 : 4
const sz = ring === 0 ? 7 + Math.random() * 3 : 10 + Math.random() * 4
const rad = ring === 0 ? 25 + i * 4 : 45 + (i - 6) * 6
const spd = ring === 0 ? 2 + i * 0.3 : -(1.5 + (i - 6) * 0.4)
const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000', '#ffffff']
const p = k.add([
k.text('₿', { size: sz }), k.pos(homeX, GROUND_Y - 30),
k.color(safeColor(k, colors[i % colors.length])),
k.opacity(0.5), k.z(11), k.anchor('center'), k.rotate(0),
])
p.onUpdate(() => {
const a = k.time() * spd + ringIdx * (Math.PI * 2 / ringCount)
p.pos.x = fighter.pos.x + Math.cos(a) * rad
p.pos.y = fighter.pos.y - 25 + Math.sin(a) * rad * 0.45
p.opacity = 0.35 + Math.sin(k.time() * 5 + i * 1.2) * 0.25
p.angle = Math.sin(k.time() * 3 + i) * 15
})
}
announceCreatorEntrance()
rainDrops.forEach(r => { if (r.exists()) r.destroy() })
}
2026-03-08 21:33:41 +00:00
// Tier-gated entrance pools — higher tiers get access to more dramatic entrances
// Tier 0-1: simple walk-ins and slides
// Tier 2: + vehicles and dust-trail entrances
// Tier 3: + dramatic drops from above with screen shake
// Tier 4: + teleport/lightning/portal effects
// Tier 5+: + spotlight/robe/cannon (full dramatic)
const tierPools: Record<number, number[]> = {
0: [6, 9, 11, 13], // skateboard, backflip, ice slide, moonwalk
1: [6, 9, 11, 13],
2: [0, 3, 6, 9, 11, 13, 14, 17], // + car, girlfriend, bouncer, shopping cart
3: [0, 1, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17], // + space fall, helicopter, underground, parachute
4: [0, 1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], // + teleport, jetpack, portal, lightning, crowd surf
5: [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], // + robe, spotlight, cannon (all)
}
function pickTierEntrance(tier: number): number {
const pool = tierPools[Math.min(tier, 5)] || tierPools[0]
return pool[Math.floor(Math.random() * pool.length)]
}
const idxA = pickTierEntrance(botA.tier)
let idxB = pickTierEntrance(botB.tier)
// Avoid same entrance for both fighters when possible
let attempts = 0
while (idxB === idxA && attempts < 5) { idxB = pickTierEntrance(botB.tier); attempts++ }
// Play entrances with slight stagger — creator always gets special entrance
announceDeepIntro()
if (botA.archetype === 'the_creator') {
await creatorEntrance(fA, HOME_A, true)
} else {
await entrances[idxA](fA, HOME_A, true)
}
await k.wait(0.3)
if (botB.archetype === 'the_creator') {
await creatorEntrance(fB, HOME_B, false)
} else {
await entrances[idxB](fB, HOME_B, false)
}
await k.wait(0.2)
// Safety: ensure both fighters are visible and at home positions
fA.pos.x = HOME_A
fA.pos.y = GROUND_Y - 6
fA.opacity = 1
fB.pos.x = HOME_B
fB.pos.y = GROUND_Y - 6
fB.opacity = 1
// Both idle at home positions
fA.play('idle')
fB.play('idle')
},
// === PHYSICAL CONTACT SYSTEMS ===
// Helper: spawn dizzy stars orbiting a fighter's head
_spawnDizzyStars(fighter: any, duration: number) {
const stars: any[] = []
const starChars = ['*', '\u2605', '\u00d7', '!', '?']
const starColors = ['#ffff00', '#ffffff', '#ff4444', '#44ff44', '#ff88ff']
for (let i = 0; i < 4; i++) {
const s = k.add([
k.text(starChars[i % starChars.length], { size: 8 }),
k.pos(fighter.pos.x, fighter.pos.y - 55),
k.color(safeColor(k, starColors[i % starColors.length])),
k.opacity(0.9), k.z(55), k.anchor('center'),
])
stars.push(s)
}
const startTime = k.time()
const cancel = k.onUpdate(() => {
const elapsed = k.time() - startTime
if (elapsed > duration || !fighter.exists()) {
stars.forEach(s => { if (s.exists()) s.destroy() })
cancel.cancel()
return
}
for (let i = 0; i < stars.length; i++) {
if (!stars[i].exists()) continue
const angle = elapsed * 4 + (i * Math.PI * 2) / stars.length
stars[i].pos.x = fighter.pos.x + Math.cos(angle) * 22
stars[i].pos.y = fighter.pos.y - 55 + Math.sin(angle) * 8
stars[i].opacity = Math.max(0, 1 - elapsed / duration)
}
})
},
// Helper: reset both fighters to home positions
async _resetPositions() {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
await Promise.all([
k.tween(fA.pos.x, HOME_A, 0.2, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
k.tween(fB.pos.x, HOME_B, 0.2, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
k.tween(fA.pos.y, GROUND_Y, 0.15, (v) => { fA.pos.y = v }),
k.tween(fB.pos.y, GROUND_Y, 0.15, (v) => { fB.pos.y = v }),
])
fA.angle = 0; fB.angle = 0; fA.opacity = 1; fB.opacity = 1
fA.play('idle'); fB.play('idle')
},
// 0: RAPID BRAWL — close distance, trade rapid blows
async _brawlRapid(winningSide: 'a' | 'b' | null, intensity: number) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const midX = W / 2
sfxZoomWhoosh()
await Promise.all([
k.tween(fA.pos.x, midX - 30, 0.12, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
k.tween(fB.pos.x, midX + 30, 0.12, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
])
const hitCount = 4 + Math.floor(intensity * 6) + Math.floor(Math.random() * 3)
const anims = ['attack', 'kick', 'special', 'attack', 'kick'] as const
const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#00f0ff', '#ff2d2d', '#39ff14', '#ffffff', '#b83dff']
for (let i = 0; i < hitCount; i++) {
const aHits = winningSide === 'a' ? Math.random() < 0.65 : winningSide === 'b' ? Math.random() < 0.35 : Math.random() < 0.5
const atk = aHits ? fA : fB; const def = aHits ? fB : fA
atk.play(anims[i % anims.length]); sfxPunch(); def.play('hit')
k.shake(2 + Math.floor(intensity * 3))
spawnSparks(midX + (Math.random() - 0.5) * 20, GROUND_Y - 20 - Math.random() * 30, 3, hitColors[i % hitColors.length])
def.pos.x += (aHits ? 1 : -1) * (3 + Math.random() * 4)
await k.wait(0.04 + (1 - intensity) * 0.04)
}
await this._resetPositions()
},
// 1: KNOCKDOWN BRAWL — combo ends with loser knocked flat, has to get back up
async _brawlKnockdown(winningSide: 'a' | 'b' | null, intensity: number) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
const loser = winner === fA ? fB : fA
const dir = winner === fA ? 1 : -1
// Rush in, quick combo
sfxZoomWhoosh()
const contactX = loser.pos.x - dir * 40
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
const hits = 3 + Math.floor(intensity * 3)
for (let i = 0; i < hits; i++) {
winner.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch()
loser.play('hit'); k.shake(3 + i)
spawnSparks(loser.pos.x, loser.pos.y - 20 - Math.random() * 20, 3, '#ff6600')
loser.pos.x += dir * 5
await k.wait(0.05)
}
// Final hit — KNOCKDOWN
winner.play('special'); sfxCritical(); sfxExplosion()
loser.play('knockback'); k.shake(12)
spawnSparks(loser.pos.x, loser.pos.y - 25, 12, '#ff2d2d')
screenFlash('#ff2d2d', 0.1)
if (Math.random() < 0.5) sfxRandomComedy()
// Loser falls to ground — scale Y to flatten, rotate
await Promise.all([
k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.25, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
k.tween(loser.pos.y, GROUND_Y + 5, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad),
k.tween(0, dir * 90, 0.25, (v) => { loser.angle = v }),
])
sfxBonk(); k.shake(6)
spawnEmoteText(loser.pos.x, loser.pos.y - 20, 'DOWN!', '#ff4444')
// Loser lies there for a beat
await k.wait(0.4 + (1 - intensity) * 0.4)
// Slowly get back up — stagger, wobble
this._spawnDizzyStars(loser, 1.5)
spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ugh', '#aaaaaa')
await k.tween(loser.angle, 0, 0.3, (v) => { loser.angle = v }, k.easings.easeOutBack)
loser.play('hit')
// Stagger wobble while getting up
for (let i = 0; i < 3; i++) {
await k.tween(loser.pos.x, loser.pos.x + (i % 2 === 0 ? -12 : 12), 0.12, (v) => { loser.pos.x = v })
}
await k.wait(0.2)
await this._resetPositions()
},
// 2: WALL BOUNCE — punch sends fighter flying into screen edge, bounces off
async _brawlWallBounce(winningSide: 'a' | 'b' | null, intensity: number) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
const loser = winner === fA ? fB : fA
const dir = winner === fA ? 1 : -1
const wallX = dir === 1 ? W - 15 : 15
// Rush in for the big hit
sfxZoomWhoosh()
await k.tween(winner.pos.x, loser.pos.x - dir * 40, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
winner.play('special'); sfxCritical()
await k.wait(0.06)
loser.play('knockback'); k.shake(15)
spawnSparks(loser.pos.x, loser.pos.y - 25, 15, '#ff2d7b')
screenFlash('#ffffff', 0.08)
// Loser flies across to wall
await Promise.all([
k.tween(loser.pos.x, wallX, 0.15, (v) => { loser.pos.x = v }, k.easings.easeInQuad),
k.tween(loser.pos.y, GROUND_Y - 30, 0.08, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(loser.pos.y, GROUND_Y, 0.07, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
),
])
// WALL IMPACT
sfxExplosion(); k.shake(20)
spawnShockwave(wallX, GROUND_Y, '#ff6600')
spawnSparks(wallX, GROUND_Y - 40, 15, '#ffcc00')
screenFlash('#ff6600', 0.12)
sfxBoneCrack()
spawnEmoteText(wallX, GROUND_Y - 70, 'WALL!', '#ff6600')
// Bounce off wall — loser rebounds back toward center
loser.play('hit')
const bounceX = wallX - dir * (60 + Math.random() * 40)
await Promise.all([
k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
k.tween(loser.pos.y, GROUND_Y - 50, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
),
k.tween(0, dir * -360, 0.2, (v) => { loser.angle = v }),
])
loser.angle = 0
sfxBonk(); k.shake(5)
// Dizzy stagger after wall bounce
this._spawnDizzyStars(loser, 1.2)
loser.play('hit')
for (let w = 0; w < 4; w++) {
await k.tween(loser.pos.x, loser.pos.x + (w % 2 === 0 ? -10 : 10), 0.1, (v) => { loser.pos.x = v })
}
await k.wait(0.3)
winner.play('win'); spawnEmoteText(winner.pos.x, winner.pos.y - 55, 'GET REKT', '#39ff14')
await k.wait(0.3)
await this._resetPositions()
},
// 3: DIZZY STAGGER — sustained combo leaves loser wobbling around the screen
async _brawlDizzyStagger(winningSide: 'a' | 'b' | null, intensity: number) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
const loser = winner === fA ? fB : fA
const dir = winner === fA ? 1 : -1
// Rush in, big combo
sfxZoomWhoosh()
await k.tween(winner.pos.x, loser.pos.x - dir * 35, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
const combo = 5 + Math.floor(intensity * 4)
for (let i = 0; i < combo; i++) {
winner.play(['attack', 'kick', 'special'][i % 3]); sfxPunch()
loser.play('hit'); k.shake(3 + Math.floor(i * 0.5))
spawnSparks((winner.pos.x + loser.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 3, i % 2 === 0 ? '#ff2d7b' : '#ffcc00')
loser.pos.x += dir * 4
await k.wait(0.04)
}
// Final uppercut
winner.play('special'); sfxCritical(); k.shake(10)
spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ffe14d')
await k.tween(loser.pos.y, GROUND_Y - 40, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad)
await k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
sfxBonk()
// DIZZY PHASE — loser staggers around aimlessly
this._spawnDizzyStars(loser, 2.5)
loser.play('hit')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...wha?', '#ffff88')
winner.play('idle')
// Stagger path — zigzag drunkenly
const staggerPoints = [
loser.pos.x + dir * 30, loser.pos.x - dir * 50,
loser.pos.x + dir * 20, loser.pos.x - dir * 40,
loser.pos.x + dir * 10,
]
for (let s = 0; s < staggerPoints.length; s++) {
await Promise.all([
k.tween(loser.pos.x, staggerPoints[s], 0.2, (v) => { loser.pos.x = v }),
k.tween(loser.angle, (s % 2 === 0 ? 10 : -10), 0.2, (v) => { loser.angle = v }),
])
if (s % 2 === 0) sfxBoing()
}
loser.angle = 0
// Shake it off
spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ok', '#88ff88')
await k.wait(0.3)
await this._resetPositions()
},
// 4: SUPLEX SLAM — grab, lift overhead, slam to ground with bounce
async _brawlSuplex(attackerSide: 'a' | 'b', intensity: number) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!attacker || !defender) return
const dir = attackerSide === 'a' ? 1 : -1
// Rush in and grab
sfxZoomWhoosh()
await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
attacker.play('special'); sfxBlock()
await k.wait(0.1)
// Lift defender overhead
attacker.play('attack')
const liftX = attacker.pos.x
await Promise.all([
k.tween(defender.pos.x, liftX + dir * 5, 0.15, (v) => { defender.pos.x = v }),
k.tween(defender.pos.y, GROUND_Y - 120, 0.2, (v) => { defender.pos.y = v }, k.easings.easeOutQuad),
k.tween(0, 180, 0.2, (v) => { defender.angle = v }),
])
sfxPowerUp()
spawnEmoteText(attacker.pos.x, attacker.pos.y - 70, 'SUPLEX!', '#ff4444')
await k.wait(0.25)
// SLAM DOWN
sfxExplosion()
await Promise.all([
k.tween(defender.pos.y, GROUND_Y + 5, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad),
k.tween(defender.angle, 360, 0.1, (v) => { defender.angle = v }),
])
defender.angle = 0; defender.play('knockback')
k.shake(22); screenFlash('#ff2d2d', 0.15)
spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d')
spawnSparks(defender.pos.x, GROUND_Y - 10, 20, '#ff6600')
sfxBoneCrack()
// Ground bounce
await k.tween(defender.pos.y, GROUND_Y - 40, 0.1, (v) => { defender.pos.y = v }, k.easings.easeOutQuad)
sfxBoing()
await k.tween(defender.pos.y, GROUND_Y, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(5)
// Bounce 2 (smaller)
await k.tween(defender.pos.y, GROUND_Y - 15, 0.08, (v) => { defender.pos.y = v }, k.easings.easeOutQuad)
await k.tween(defender.pos.y, GROUND_Y, 0.08, (v) => { defender.pos.y = v }, k.easings.easeInQuad)
// Dizzy on the ground
this._spawnDizzyStars(defender, 1.5)
defender.play('hit')
await k.wait(0.5)
attacker.play('win')
await k.wait(0.3)
await this._resetPositions()
},
// 5: PING PONG VOLLEY — fighters knock each other back and forth across the screen
async _brawlPingPong(winningSide: 'a' | 'b' | null, intensity: number) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const volleys = 3 + Math.floor(intensity * 3) + Math.floor(Math.random() * 2) // 3-8
// Start in center
sfxZoomWhoosh()
await Promise.all([
k.tween(fA.pos.x, W / 2 - 30, 0.1, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
k.tween(fB.pos.x, W / 2 + 30, 0.1, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
])
for (let v = 0; v < volleys; v++) {
const aHits = v % 2 === 0
const atk = aHits ? fA : fB
const def = aHits ? fB : fA
const pDir = aHits ? 1 : -1
const pushDist = 50 + Math.random() * 40
atk.play(v % 3 === 0 ? 'attack' : v % 3 === 1 ? 'kick' : 'special')
sfxPunch(); def.play('hit'); k.shake(5 + v)
spawnSparks((atk.pos.x + def.pos.x) / 2, GROUND_Y - 30, 5, v % 2 === 0 ? '#ff2d7b' : '#00f0ff')
// Defender flies back
await Promise.all([
k.tween(def.pos.x, def.pos.x + pDir * pushDist, 0.1, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
k.tween(def.pos.y, GROUND_Y - 25, 0.05, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(def.pos.y, GROUND_Y, 0.05, (v) => { def.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk()
// Attacker chases
await k.tween(atk.pos.x, def.pos.x - pDir * 35, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
await k.wait(0.03)
}
// Final hit sends loser sliding
const finalAtk = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (volleys % 2 === 0 ? fA : fB)
const finalDef = finalAtk === fA ? fB : fA
finalAtk.play('special'); sfxCritical(); k.shake(12)
finalDef.play('knockback')
spawnSparks(finalDef.pos.x, finalDef.pos.y - 25, 12, '#ffe14d')
if (Math.random() < 0.5) sfxRandomComedy()
await k.wait(0.3)
await this._resetPositions()
},
// 6: GROUND AND POUND — attacker pins defender, hits them on the ground
async _brawlGroundPound(attackerSide: 'a' | 'b', intensity: number) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!attacker || !defender) return
const dir = attackerSide === 'a' ? 1 : -1
// Rush in and tackle
sfxZoomWhoosh()
await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.08, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
attacker.play('attack'); sfxPunch()
defender.play('knockback'); k.shake(8)
// Defender falls to ground (flatten angle)
await Promise.all([
k.tween(defender.pos.x, defender.pos.x + dir * 20, 0.15, (v) => { defender.pos.x = v }),
k.tween(0, dir * 85, 0.15, (v) => { defender.angle = v }),
])
sfxBonk()
// Attacker moves on top
await k.tween(attacker.pos.x, defender.pos.x - dir * 15, 0.08, (v) => { attacker.pos.x = v })
// Ground pound — rapid hits on downed fighter
const poundHits = 4 + Math.floor(intensity * 5)
for (let i = 0; i < poundHits; i++) {
attacker.play(i % 2 === 0 ? 'attack' : 'kick')
sfxPunch()
k.shake(2 + Math.floor(i * 0.4))
spawnSparks(defender.pos.x + (Math.random() - 0.5) * 20, defender.pos.y - 10, 2, i % 2 === 0 ? '#ff2d7b' : '#ff6600')
defender.pos.y += (i % 2 === 0 ? -2 : 2) // jostle
await k.wait(0.06 + (1 - intensity) * 0.04)
}
// Attacker gets up, backs off
attacker.play('win')
sfxRandomComedy()
await k.wait(0.3)
// Defender slowly gets up
this._spawnDizzyStars(defender, 1.8)
await k.tween(defender.angle, 0, 0.4, (v) => { defender.angle = v }, k.easings.easeOutBack)
defender.play('hit')
spawnEmoteText(defender.pos.x, defender.pos.y - 45, '...ouch', '#ff8888')
await k.wait(0.4)
await this._resetPositions()
},
// 7: HAYMAKER — slow dramatic wind-up, pause, devastating single hit with knockdown
async _brawlHaymaker(attackerSide: 'a' | 'b', _intensity: number) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!attacker || !defender) return
const dir = attackerSide === 'a' ? 1 : -1
// Slow walk toward defender
attacker.play('kick')
await k.tween(attacker.pos.x, defender.pos.x - dir * 45, 0.4, (v) => { attacker.pos.x = v }, k.easings.easeInOutQuad)
// Wind-up — lean back
attacker.play('special')
sfxDrumRoll()
await k.tween(attacker.pos.x, attacker.pos.x - dir * 20, 0.3, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad)
spawnEmoteText(attacker.pos.x, attacker.pos.y - 55, '...', '#ffffff')
await k.wait(0.5) // dramatic pause
// HAYMAKER
sfxCritical(); sfxExplosion()
attacker.play('attack')
await k.tween(attacker.pos.x, defender.pos.x - dir * 25, 0.04, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
defender.play('knockback'); k.shake(25)
screenFlash('#ffffff', 0.15)
spawnSparks(defender.pos.x, defender.pos.y - 25, 20, '#ff2d2d')
spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d')
sfxVineBoom()
// Defender flies across screen with spin
const flyX = defender.pos.x + dir * 150
await Promise.all([
k.tween(defender.pos.x, Math.min(W - 10, Math.max(10, flyX)), 0.25, (v) => { defender.pos.x = v }, k.easings.easeOutQuad),
k.tween(defender.pos.y, GROUND_Y - 80, 0.12, (v) => { defender.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(defender.pos.y, GROUND_Y, 0.13, (v) => { defender.pos.y = v }, k.easings.easeInQuad)
),
k.tween(0, dir * 720, 0.25, (v) => { defender.angle = v }),
])
defender.angle = 0; sfxBonk(); k.shake(8)
// Knockdown — rotate flat
await k.tween(0, dir * 90, 0.15, (v) => { defender.angle = v })
spawnEmoteText(defender.pos.x, defender.pos.y - 20, 'K.O.?!', '#ff4444')
this._spawnDizzyStars(defender, 2.0)
await k.wait(0.6)
// Get up slowly
await k.tween(defender.angle, 0, 0.35, (v) => { defender.angle = v }, k.easings.easeOutBack)
defender.play('hit')
await k.wait(0.3)
attacker.play('idle')
await this._resetPositions()
},
// 8: BODY CHECK BOUNCER — both charge, collide, one bounces off the other
async _brawlBodyCheck(winningSide: 'a' | 'b' | null, _intensity: number) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) return
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
const loser = winner === fA ? fB : fA
const dir = winner === fA ? 1 : -1
const midX = W / 2
// Both charge at each other
fA.play('special'); fB.play('special')
sfxZoomWhoosh()
await Promise.all([
k.tween(fA.pos.x, midX - 20, 0.12, (v) => { fA.pos.x = v }, k.easings.easeInQuad),
k.tween(fB.pos.x, midX + 20, 0.12, (v) => { fB.pos.x = v }, k.easings.easeInQuad),
])
// COLLISION
sfxCritical(); k.shake(18)
spawnSparks(midX, GROUND_Y - 40, 15, '#ffffff')
screenFlash('#ffffff', 0.1)
spawnShockwave(midX, GROUND_Y, '#ffcc00')
// Winner holds ground, loser bounces off
winner.play('win')
loser.play('knockback')
const bounceX = loser === fA ? -20 : W + 20
await Promise.all([
k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
k.tween(loser.pos.y, GROUND_Y - 60, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
),
k.tween(0, (loser === fA ? -1 : 1) * 540, 0.2, (v) => { loser.angle = v }),
])
// Off screen! Slide back in
loser.angle = 0; loser.play('hit')
sfxSlideWhistleDown()
const slideFrom = loser === fA ? -30 : W + 30
loser.pos.x = slideFrom
await k.tween(loser.pos.x, loser === fA ? HOME_A : HOME_B, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutBounce)
this._spawnDizzyStars(loser, 1.5)
sfxBoing()
spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...what happened?', '#ffff88')
await k.wait(0.5)
await this._resetPositions()
},
// Dispatch: pick a random physical exchange variant
async _brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) {
const variant = Math.floor(Math.random() * 9)
switch (variant) {
case 0: return this._brawlRapid(winningSide, intensity)
case 1: return this._brawlKnockdown(winningSide, intensity)
case 2: return this._brawlWallBounce(winningSide, intensity)
case 3: return this._brawlDizzyStagger(winningSide, intensity)
case 4: return this._brawlSuplex(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity)
case 5: return this._brawlPingPong(winningSide, intensity)
case 6: return this._brawlGroundPound(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity)
case 7: return this._brawlHaymaker(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity)
case 8: return this._brawlBodyCheck(winningSide, intensity)
}
},
// Clinch combo: grapple at close range, one fighter dominates with sustained contact
async _clinchCombo(attackerSide: 'a' | 'b', intensity: number) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!attacker || !defender) return
const dir = attackerSide === 'a' ? 1 : -1
const origAX = attackerSide === 'a' ? HOME_A : HOME_B
const origDX = attackerSide === 'a' ? HOME_B : HOME_A
const clinchX = (origAX + origDX) / 2
sfxZoomWhoosh()
attacker.play('attack')
await k.tween(attacker.pos.x, clinchX - dir * 25, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
await k.tween(defender.pos.x, clinchX + dir * 25, 0.08, (v) => { defender.pos.x = v }, k.easings.easeOutQuad)
const clinchHits = 3 + Math.floor(intensity * 5) + Math.floor(Math.random() * 3)
const movePool = ['attack', 'kick', 'special'] as const
for (let i = 0; i < clinchHits; i++) {
attacker.play(movePool[i % movePool.length]); sfxPunch()
await k.wait(0.03)
defender.play('hit'); k.shake(2 + Math.floor(i * 0.5))
defender.pos.x += dir * 4; attacker.pos.x += dir * 3
spawnSparks((attacker.pos.x + defender.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 2 + Math.floor(intensity * 3), i % 2 === 0 ? '#ff2d7b' : '#ffcc00')
if (Math.random() < 0.3) {
defender.play(movePool[Math.floor(Math.random() * movePool.length)]); sfxBlock()
attacker.play('hit'); k.shake(3); attacker.pos.x -= dir * 5; await k.wait(0.04)
}
await k.wait(0.04 + (1 - intensity) * 0.03)
}
sfxKick(); defender.play('knockback'); k.shake(6 + Math.floor(intensity * 6))
spawnSparks(defender.pos.x, defender.pos.y - 25, 8, '#ff2d2d')
await k.tween(defender.pos.x, origDX + dir * 30, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad)
attacker.play('idle')
await Promise.all([
k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad),
k.tween(defender.pos.x, origDX, 0.25, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad),
])
defender.play('idle')
},
// Counter-attack: after a choreography hit, the defender retaliates briefly
async _counterAttack(defenderSide: 'a' | 'b') {
const defender = k.get(defenderSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const attacker = k.get(defenderSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!defender || !attacker) return
const dir = defenderSide === 'a' ? 1 : -1
const origDX = defenderSide === 'a' ? HOME_A : HOME_B
defender.play('attack'); sfxZoomWhoosh()
await k.tween(defender.pos.x, attacker.pos.x - dir * 35, 0.08, (v) => { defender.pos.x = v }, k.easings.easeInQuad)
const counterHits = 2 + Math.floor(Math.random() * 2)
for (let i = 0; i < counterHits; i++) {
defender.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch()
attacker.play('hit'); k.shake(4)
spawnSparks(attacker.pos.x + (Math.random() - 0.5) * 15, attacker.pos.y - 20 - Math.random() * 15, 3, '#00f0ff')
await k.wait(0.05)
}
attacker.play('idle'); defender.play('idle')
await k.tween(defender.pos.x, origDX, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad)
},
async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) {
const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!attacker || !defender) return
const origAX = side === 'a' ? HOME_A : HOME_B
const origDX = side === 'a' ? HOME_B : HOME_A
const direction = side === 'a' ? 1 : -1
const fn = choreographyMap[choreographyName] || choreographyMap['dashPunch']
await fn(attacker, defender, direction, origAX, origDX, isCritical)
// Ensure positions are correct and sprites are idle
attacker.pos.x = origAX
defender.pos.x = origDX
attacker.opacity = 1
defender.opacity = 1
await k.wait(0.1)
attacker.play('idle')
defender.play('idle')
},
async playRound(event: RoundEvent) {
const fA = k.get('fighterA')[0] as Fighter
const fB = k.get('fighterB')[0] as Fighter
if (!fA || !fB) { await k.wait(0.5); return } // Sprites missing (mobile load failure) — skip animation
const aWon = event.winnerId === event.botAId
const bWon = event.winnerId === event.botBId
const margin = Math.abs(event.botAScore - event.botBScore)
const intensity = Math.min(1, margin / 8) // 0.0 to 1.0 continuous
const isCritical = margin > 4
// Super-speed scales with intensity
const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0))
// 3-7 exchanges — later rounds and higher intensity get more, ensuring sustained fights
const roundBonus = Math.min(2, Math.floor(event.round / 2))
const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0)
// Hyperdetail scales with intensity
const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
const savedScaleAX = fA?.scale.x
const savedScaleAY = fA?.scale.y
const savedScaleBX = fB?.scale.x
const savedScaleBY = fB?.scale.y
// Voice: round start announcements (30% chance, 50% if Creator) — fires BEFORE animation starts
const creatorInFight = botA.archetype === 'the_creator' || botB.archetype === 'the_creator'
let didChallengeVoice = false
if (Math.random() < (creatorInFight ? 0.5 : 0.3)) {
// Creator fights get existential commentary more often
if (creatorInFight && Math.random() < 0.6) {
announceCreatorRound(); didChallengeVoice = true
} else {
const challengeVoice: Record<string, () => void> = {
roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'),
food_fight: () => announceSilly('FOOD FIGHT! SOMEBODY\'S GETTING SERVED!'),
wrestling_match: () => announceDramatic('FROM THE TOP ROPE! THIS IS GONNA GET PHYSICAL!'),
music_battle: () => announceCool('MUSIC BATTLE! DROP THE BEAT AND YOUR OPPONENT!'),
magic_duel: () => announceDramatic('WIZARDS AT DAWN! SOMEBODY\'S GETTING HEXED!'),
meme_war: () => announceSilly('MEME WAR! YOUR HUMOR IS ABOUT TO GET RATIO\'D!'),
demolition: () => announceHype('DEMOLITION DERBY! NOTHING WILL SURVIVE THIS!'),
medieval_combat: () => announceDramatic('MEDIEVAL COMBAT! CHIVALRY IS DEAD AND SO IS YOUR OPPONENT!'),
space_war: () => announceCool('SPACE WAR! HOUSTON, WE HAVE A PROBLEM!'),
speed_blitz: () => announceHype('SPEED BLITZ! BLINK AND YOU\'LL MISS THE CARNAGE!'),
math_blitz: () => announceSilly('MATH BLITZ! SOMEBODY\'S ABOUT TO GET DIVIDED!'),
riddle: () => announceCool('RIDDLE TIME! BRAINS OVER BRAWN! BUT ALSO BRAWN!'),
code_golf: () => announceSilly('CODE GOLF! MAY THE SHORTEST SOLUTION WIN!'),
creative_writing: () => announceCool('CREATIVE WRITING! THE PEN IS MIGHTIER THAN THE SWORD!'),
hallucination_check: () => announceHype('HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!'),
trap_card: () => announceDramatic('TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!'),
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
retro_mode: () => announceHype('RETRO MODE! INSERT COIN! FIGHT!'),
}
const voiceFn = challengeVoice[event.challengeType]
if (voiceFn) { voiceFn(); didChallengeVoice = true }
else if (Math.random() < 0.5) { announceRoundHype(); didChallengeVoice = true }
}
}
// Give the voice line time to finish before the first punch lands
if (didChallengeVoice) await k.wait(0.8)
// Visual chaos: schizo cut before round (20% chance)
if (Math.random() < 0.2 && fA && fB) {
await schizoCut()
}
if (hyperDetail && fA && fB) {
// Zoom both bots up 1.8x and shift toward center for close-up feel
const zoomFactor = 1.6 + Math.random() * 0.4
await Promise.all([
k.tween(Math.abs(fA.scale.x), Math.abs(fA.scale.x) * zoomFactor, 0.3, (v) => {
fA.scale.x = savedScaleAX! > 0 ? v : -v; fA.scale.y = v
}, k.easings.easeOutQuad),
k.tween(Math.abs(fB.scale.x), Math.abs(fB.scale.x) * zoomFactor, 0.3, (v) => {
fB.scale.x = savedScaleBX! > 0 ? v : -v; fB.scale.y = v
}, k.easings.easeOutQuad),
k.tween(fA.pos.x, HOME_A + (W / 2 - HOME_A) * 0.25, 0.3, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
k.tween(fB.pos.x, HOME_B - (HOME_B - W / 2) * 0.25, 0.3, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
])
// Ren & Stimpy grotesque close-up overlays
spawnGrotesqueDetails(fA, zoomFactor * 0.6)
spawnGrotesqueDetails(fB, zoomFactor * 0.6)
// VHS tracking during hyperdetail
vhsTracking(0.5)
}
// Super-speed: persistent speed lines during round
let speedLines: any[] = []
if (superSpeed) {
for (let i = 0; i < 10; i++) {
const lineY = GROUND_Y - 5 - Math.random() * 130
const line = k.add([
k.rect(W, 1 + Math.random()), k.pos(0, lineY),
k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : theme.accent)),
k.opacity(0.15 + Math.random() * 0.1), k.z(25),
])
line.onUpdate(() => { line.opacity = 0.1 + Math.sin(k.time() * 8 + i * 2) * 0.08 })
speedLines.push(line)
}
}
// === RETRO MODE GAMEPAD OVERLAY ===
const retroObjs: any[] = []
if (event.challengeType === 'retro_mode') {
const padW = 90
const padH = 70
const padY = 18
const padAX = 12
const padBX = W - padW - 12
const btnSize = 14
const dpadColor = '#333333'
const btnOff = '#444444'
const btnA = '#22cc44'
const btnB = '#cc3333'
const labelColor = '#00f0ff'
// Draw two gamepads
for (const side of ['a', 'b'] as const) {
const px = side === 'a' ? padAX : padBX
// Pad background
retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#111111')), k.opacity(0.85), k.z(48)]))
retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#00f0ff')), k.opacity(0.15), k.z(48), k.outline(1)]))
// D-pad
const dx = px + 20
const dy = padY + 28
// Up
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy - btnSize * 1.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_up']))
// Down
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy + btnSize * 0.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_down']))
// Left
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize * 1.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_left']))
// Right
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx + btnSize * 0.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_right']))
// A button
retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 30, dy - 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_A']))
retroObjs.push(k.add([k.text('A', { size: 8 }), k.pos(px + padW - 33, dy - 10), k.color(safeColor(k, '#888')), k.z(50)]))
// B button
retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 16, dy + 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_B']))
retroObjs.push(k.add([k.text('B', { size: 8 }), k.pos(px + padW - 19, dy + 2), k.color(safeColor(k, '#888')), k.z(50)]))
// Label
const label = side === 'a' ? 'P1' : 'P2'
retroObjs.push(k.add([k.text(label, { size: 10 }), k.pos(px + padW / 2 - 6, padY + 3), k.color(safeColor(k, labelColor)), k.z(50)]))
}
// Animate gamepad button presses based on bot responses
const flashBtn = async (side: 'a' | 'b', inputStr: string) => {
if (!inputStr) return
const arrows: Record<string, string> = { '↑': 'up', '↓': 'down', '←': 'left', '→': 'right' }
for (const ch of inputStr) {
const dir = arrows[ch]
if (dir) {
const tag = 'retro_' + side + '_' + dir
const objs = k.get(tag)
for (const o of objs) { o.color = safeColor(k, '#00f0ff'); }
await k.wait(0.08)
for (const o of objs) { o.color = safeColor(k, dpadColor); }
}
if (ch === 'A' || ch === 'a') {
const objs = k.get('retro_' + side + '_A')
for (const o of objs) { if (o.color) o.color = safeColor(k, btnA); }
await k.wait(0.08)
for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); }
}
if (ch === 'B' || ch === 'b') {
const objs = k.get('retro_' + side + '_B')
for (const o of objs) { if (o.color) o.color = safeColor(k, btnB); }
await k.wait(0.08)
for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); }
}
}
}
// Parse and animate each bot's combo moves
2026-03-08 22:16:50 +00:00
const movesA = (event.botAResponse || '').split('|').map((s: string) => s.trim()).filter(Boolean).slice(0, 3)
const movesB = (event.botBResponse || '').split('|').map((s: string) => s.trim()).filter(Boolean).slice(0, 3)
// Flash moves in parallel for both pads
const animateCombo = async (side: 'a' | 'b', moves: string[]) => {
for (const combo of moves) {
await flashBtn(side, combo)
// Show combo text above pad
const px = side === 'a' ? padAX : padBX
const comboLabel = k.add([k.text(combo, { size: 8 }), k.pos(px + 4, padY + padH + 4), k.color(safeColor(k, '#ffff00')), k.opacity(1), k.z(50)])
retroObjs.push(comboLabel)
await k.wait(0.25)
comboLabel.opacity = 0
}
}
// Fire-and-forget — the animations run during the exchange loop below
animateCombo('a', movesA)
animateCombo('b', movesB)
}
for (let ex = 0; ex < exchangeCount; ex++) {
const isLastExchange = ex === exchangeCount - 1
// In earlier exchanges, sometimes the loser attacks back
let attackerSide: 'a' | 'b'
let exchangeCritical = false
if (isLastExchange) {
// Final exchange: winner lands the decisive blow
attackerSide = aWon ? 'a' : bWon ? 'b' : (Math.random() > 0.5 ? 'a' : 'b')
exchangeCritical = isCritical
} else if (aWon || bWon) {
// Earlier exchanges: mix of both sides attacking
const winnerSide = aWon ? 'a' : 'b'
const loserSide = aWon ? 'b' : 'a'
// Loser hits back less at high intensity (more one-sided domination)
attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide
exchangeCritical = false
// Occasionally play a comedy sound on non-decisive hits
if (Math.random() < 0.15) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() }
} else {
// Draw: alternate
attackerSide = ex % 2 === 0 ? 'a' : 'b'
}
const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier
const attackerArch = attackerSide === 'a' ? botA.archetype : botB.archetype
const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier, attackerArch)
// Morph system: transform during ultimates or devastating crits
const isUltimate = choreo.startsWith('ultimate')
const isCreatorFighter = attackerArch === 'the_creator'
// Creator: always morph on ultimates, 70% on devastating crits
const shouldMorph = isCreatorFighter
? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7))
: (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4))
let morphRevert: (() => Promise<void>) | null = null
if (shouldMorph) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (attacker) {
if (isCreatorFighter) {
// Omni-morph: creator transforms into a random archetype (sprite swap)
const originalSpriteKey = attackerSide === 'a' ? 'botA' : 'botB'
const morphedTo = await applyCreatorOmniMorph(attacker, attackerSide)
screenFlash('#ffd700', 0.15)
sfxZoomWhoosh()
sfxSpecial()
announceCreatorMorph(morphedTo)
const savedSX = attacker.scale.x
const savedSY = attacker.scale.y
await k.tween(0, 1, 0.3, (t) => {
attacker.scale.x = savedSX * (1 + t * 0.3)
attacker.scale.y = savedSY * (1 + t * 0.3)
}, k.easings.easeOutBack)
spawnShockwave(attacker.pos.x, attacker.pos.y - 20, '#ffd700')
k.shake(10)
glitchRGB(0.25)
morphRevert = async () => {
destroyMorphOverlays()
screenFlash('#c8a000', 0.1)
// Swap sprite back to original Creator
attacker.use(k.sprite(originalSpriteKey, { anim: 'idle' }))
attacker.color = safeColor(k, '#ffffff')
attacker.opacity = 1
await k.tween(0, 1, 0.2, (t) => {
attacker.scale.x = savedSX * 1.3 + t * (savedSX - savedSX * 1.3)
attacker.scale.y = savedSY * 1.3 + t * (savedSY - savedSY * 1.3)
}, k.easings.easeInQuad)
}
await k.wait(0.2)
} else {
const seed = attackerSide === 'a' ? botA.seed : botB.seed
const morphOrder = getMorphOrder(seed)
// Cycle through morphs based on round number
const morphIdx = morphOrder[event.round % 3]
const result = await playMorph(attacker, attackerSide, morphIdx)
morphRevert = result.revert
await k.wait(0.2)
}
}
}
// Camera pull-back for ultimates: zoom out slightly to show the full move
if (isUltimate && !hyperDetail && fA && fB) {
await cameraZoom(
[fA, fB],
[{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }],
0.85, 0.2, k.easings.easeOutQuad,
)
}
if (exchangeCritical && isLastExchange) {
fanfareCritical()
announceDramatic([
'CRITICAL HIT! THAT\'S GONNA LEAVE A MARK!',
'CRITICAL HIT! SOMEBODY CHECK IF THAT\'S LEGAL!',
'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!',
'CRITICAL HIT! THAT WAS PERSONAL!',
'CRITICAL! EVEN THE REFEREE FELT THAT!',
'CRITICAL! SEND THE AMBULANCE! ACTUALLY SEND TWO!',
'CRITICAL HIT! THAT BOT\'S WARRANTY JUST EXPIRED!',
'CRITICAL! THE SCOREBOARD CAN\'T EVEN HANDLE THIS!',
'OHHH CRITICAL! RIGHT IN THE CIRCUITS!',
'CRITICAL! THAT\'S NOT A HIT, THAT\'S A STATEMENT!',
][Math.floor(Math.random() * 10)])
await k.wait(0.4) // let the voice line land before the hit animation
announceCrowdReaction('gasp')
// Camera zoom-in for dramatic critical blow
if (!hyperDetail && fA && fB) {
await cameraZoom(
[fA, fB],
[{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }],
1.3, 0.2, k.easings.easeOutQuad,
)
}
// RGB glitch + hyperspeed lines on critical final blow
glitchRGB(0.3)
const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (defPos) hyperSpeedLines(defPos.pos.x, defPos.pos.y - 30, 0.4)
}
// Random scanline glitch during exchanges (25%)
if (Math.random() < 0.25) scanlineGlitch(0.2)
// Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact
if (!aWon && !bWon && isLastExchange) {
// Draw: brawl in the middle (sustained contact)
await this._brawlExchange(null, intensity)
} else {
// Decide exchange type: choreography, brawl, or clinch
// Brawl/clinch chance increases with later exchanges and higher intensity
const physicalChance = 0.35 + intensity * 0.2 + (ex > 1 ? 0.15 : 0)
const exchangeType = Math.random()
if (!isLastExchange && exchangeType < physicalChance * 0.5) {
// BRAWL: both fighters close distance and trade rapid blows
await this._brawlExchange(aWon ? 'a' : bWon ? 'b' : null, intensity)
} else if (!isLastExchange && exchangeType < physicalChance) {
// CLINCH COMBO: attacker grapples defender at close range
await this._clinchCombo(attackerSide, intensity)
} else if (!isLastExchange && Math.random() < 0.12) {
// DODGE: defender dodges (reduced from 15% to 12% — more contact, fewer misses)
await this.playDodge(attackerSide === 'a' ? 'b' : 'a')
sfxDodge()
if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing()
} else {
// CHOREOGRAPHY: signature move from the pool
await this.playAttack(attackerSide, choreo, exchangeCritical)
// COUNTER-ATTACK: defender fights back after getting hit (25% chance on non-final exchanges)
if (!isLastExchange && !exchangeCritical && Math.random() < 0.25) {
await k.wait(0.08)
await this._counterAttack(attackerSide === 'a' ? 'b' : 'a')
}
}
}
// Revert morph after the exchange
if (morphRevert) {
await morphRevert()
morphRevert = null
}
// Brief pause between exchanges (much shorter in super-speed)
if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.2 + Math.random() * 0.25)
}
// Clean up speed lines
speedLines.forEach(l => { if (l.exists()) l.destroy() })
speedLines = []
// Clean up retro gamepad overlays
retroObjs.forEach(o => { if (o.exists()) o.destroy() })
// Clean up grotesque overlays and any leftover morphs before zooming out
destroyGrotesqueDetails()
destroyMorphOverlays()
// Zoom back out from hyperdetail or critical camera zoom
const needsZoomOut = (hyperDetail || (isCritical && !hyperDetail)) && fA && fB && savedScaleAX != null && savedScaleBX != null
if (needsZoomOut) {
await Promise.all([
k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => {
fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v
}, k.easings.easeInOutQuad),
k.tween(fB.scale.y, Math.abs(savedScaleBY!), 0.25, (v) => {
fB.scale.x = savedScaleBX > 0 ? v : -v; fB.scale.y = v
}, k.easings.easeInOutQuad),
k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeInOutQuad),
k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeInOutQuad),
])
}
// Judge calls the round (non-blocking)
const judge = k.get('judge')[0]
if (judge) {
if (isCritical) {
judge.play('shocked')
await k.wait(0.25)
}
judge.play(aWon ? 'call_left' : bWon ? 'call_right' : 'idle')
k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') })
}
// === THE CREATOR CAMEO ===
// 6% chance per round (only if neither fighter IS the creator)
const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator'
if (neitherIsCreator && Math.random() < 0.06) {
const cameoX = k.width() / 2
const cameoY = GROUND_Y - 80
// Golden portal flash
screenFlash('#ffd700', 0.1)
const portal = k.add([
k.circle(30), k.pos(cameoX, cameoY), k.color(safeColor(k, '#c8a000')),
k.opacity(0), k.z(50), k.anchor('center'), k.scale(0.1),
])
await k.tween(0, 1, 0.3, (t) => {
portal.opacity = t * 0.4
portal.scale.x = t * 1.5; portal.scale.y = t * 1.5
}, k.easings.easeOutBack)
// ₿ symbol descends from portal
const gift = k.add([
k.text('₿', { size: 18 }), k.pos(cameoX, cameoY - 30),
k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(51), k.anchor('center'),
])
const creatorLabel = k.add([
k.text('THE CREATOR', { size: 8 }), k.pos(cameoX, cameoY + 25),
k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(51), k.anchor('center'),
])
await k.tween(0, 1, 0.4, (t) => {
gift.opacity = t
gift.pos.y = cameoY - 30 + t * 20
creatorLabel.opacity = t * 0.8
}, k.easings.easeOutQuad)
// Gift flies to the round winner (or random fighter if draw)
const targetFighter = aWon ? fA : bWon ? fB : (Math.random() < 0.5 ? fA : fB)
if (targetFighter) {
const giftTexts = ['POWER UP!', 'BLESSED!', 'SATOSHI\'S GIFT!', 'HODL STRENGTH!', '21M ENERGY!']
const tx = targetFighter.pos.x, ty = targetFighter.pos.y - 30
await k.tween(0, 1, 0.35, (t) => {
gift.pos.x = cameoX + (tx - cameoX) * t
gift.pos.y = (cameoY - 10) + (ty - (cameoY - 10)) * t
}, k.easings.easeInQuad)
// Impact flash on fighter
screenFlash('#ffd700', 0.08)
spawnShockwave(tx, ty, '#ffd700')
const blessText = giftTexts[Math.floor(Math.random() * giftTexts.length)]
const bless = k.add([
k.text(blessText, { size: 10 }), k.pos(tx, ty - 20),
k.color(safeColor(k, '#ffd700')), k.opacity(1), k.z(52), k.anchor('center'),
])
k.tween(0, 1, 0.8, (t) => {
bless.pos.y = ty - 20 - t * 30
bless.opacity = 1 - t
}).then(() => { if (bless.exists()) bless.destroy() })
announceCreatorCameo()
}
gift.destroy()
// Fade out portal and label
await k.tween(1, 0, 0.3, (t) => {
portal.opacity = t * 0.4
creatorLabel.opacity = t * 0.8
})
portal.destroy()
creatorLabel.destroy()
}
// Update combos
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
if (aWon) {
comboA++; comboB = 0
} else if (bWon) {
comboB++; comboA = 0
} else {
comboA = 0; comboB = 0
}
// Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end)
if (isCritical && (aWon || bWon)) {
fanfareDevastating()
if (hasCombo) { fanfareCombo(aWon ? comboA : comboB) } // SFX only, no speech
const devastatingLines = [
'SOMEBODY CALL A DOCTOR!',
'THAT BOT HAS A FAMILY!',
'THE CROWD IS LOSING IT!',
'EVEN THE JANITOR FELT THAT!',
'AND I\'M NOT EVEN BEING DRAMATIC!',
'CALL THE FIRE DEPARTMENT!',
'I CAN\'T BELIEVE WHAT I JUST WITNESSED!',
'THAT\'S GOTTA VOID THE WARRANTY!',
'SOMEBODY CHECK ON THAT BOT\'S NEXT OF KIN!',
'THE ARENA IS SHAKING!',
'THAT WAS ABSOLUTELY RUTHLESS!',
'HIS MOTHERBOARD JUST CALLED CRYING!',
'EVEN THE REPLAYS ARE SCARED!',
'THAT\'S ONE FOR THE HISTORY BOOKS!',
'DID ANYONE ELSE FEEL THE EARTH MOVE?',
'I\'M GETTING CHILLS AND I\'M MADE OF CODE!',
'THE CROWD JUST WENT SILENT... NOW THEY\'RE SCREAMING!',
'SOMEBODY GET THE STRETCHER!',
'THAT WAS PURE DISRESPECT!',
'I NEED A MOMENT TO PROCESS WHAT JUST HAPPENED!',
'THE OTHER BOT IS HAVING AN EXISTENTIAL CRISIS!',
'THAT HIT REGISTERED ON THE RICHTER SCALE!',
'NO RECOVERY FROM THAT ONE!',
'I THINK I SAW A PIXEL FLY OFF!',
'THE SPECTATORS ARE CALLING THEIR LAWYERS!',
'SOMEONE NOTIFY THE UNITED NATIONS!',
'THAT SHOULD BE CLASSIFIED AS A WAR CRIME!',
'MY GRANDMA COULD FEEL THAT AND SHE\'S OFFLINE!',
'THE ARENA INSURANCE PREMIUMS JUST WENT UP!',
'THAT BOT IS RECONSIDERING ITS LIFE CHOICES!',
]
// Creator gets custom devastating lines when they land the hit
const devastatingAttackerArch = aWon ? botA.archetype : botB.archetype
if (devastatingAttackerArch === 'the_creator') {
announceCreatorDevastating()
} else {
announceDramatic(devastatingLines[Math.floor(Math.random() * devastatingLines.length)])
}
await k.wait(0.5) // let the voice line play before crowd reacts
announceCrowdReaction('ooh')
// Dimensional shift on devastating rounds (60%)
if (Math.random() < 0.6) dimensionalShift(0.5)
} else if (hasCombo) {
// Non-critical combo
fanfareCombo(aWon ? comboA : comboB)
announceHype(`${aWon ? comboA : comboB} hit combo!`)
await k.wait(0.4)
announceCrowdReaction('cheer')
} else if (aWon || bWon) {
// Regular round win: crowd reacts (40%) — only SFX, no speech
if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
if (Math.random() < 0.1) {
await k.wait(0.3)
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
}
}
// Crowd sympathy for the loser on devastating rounds (25%) — visual only, no speech (devastating voice is still playing)
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
spawnCrowdSigns(3, '#4488ff', '\u2665')
}
// Crowd signs on big combos (20%)
if ((comboA >= 3 || comboB >= 3) && Math.random() < 0.2) {
const comboName = comboA >= 3 ? botA.name : botB.name
spawnCrowdSigns(4, '#ffe14d', comboName.slice(0, 6))
}
// Respect nod between fighters on close rounds (10% when margin <= 1)
if (margin <= 1 && Math.random() < 0.1) {
const fA2 = k.get('fighterA')[0] as Fighter
const fB2 = k.get('fighterB')[0] as Fighter
if (fA2 && fB2) {
spawnEmoteText(fA2.pos.x, fA2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
await k.wait(0.3)
spawnEmoteText(fB2.pos.x, fB2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
}
}
// Referee lobster does something funny (5% chance per round)
if (Math.random() < 0.05) {
await judgeDoSomethingFunny()
}
// Human owner shows up (15% chance per round)
await maybeShowHuman()
},
async playTaunt(side: 'a' | 'b') {
const taunter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!taunter) return
// Creator taunts come with menacing voice lines
const taunterArch = side === 'a' ? botA.archetype : botB.archetype
if (taunterArch === 'the_creator') announceCreatorTaunt()
const origY = taunter.pos.y
// Random taunt animation: hop, flex, or shake
const tauntType = Math.floor(Math.random() * 4)
if (tauntType === 0) {
// Victory hop
taunter.play('win')
await k.tween(taunter.pos.y, origY - 30, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeOutQuad)
await k.tween(taunter.pos.y, origY, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeInQuad)
sfxBoing()
} else if (tauntType === 1) {
// Flex / scale pulse
taunter.play('special')
const sx = taunter.scale.x
const sy = taunter.scale.y
await k.tween(1, 1.3, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeOutQuad)
await k.tween(1.3, 1, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeInQuad)
taunter.scale.x = sx
taunter.scale.y = sy
} else if (tauntType === 2) {
// Shake head side to side
const origX = taunter.pos.x
for (let i = 0; i < 3; i++) {
await k.tween(taunter.pos.x, origX + 8, 0.04, (v) => { taunter.pos.x = v })
await k.tween(taunter.pos.x, origX - 8, 0.04, (v) => { taunter.pos.x = v })
}
taunter.pos.x = origX
} else {
// Quick kick at the air
taunter.play('kick')
sfxDodge()
await k.wait(0.2)
}
await k.wait(0.15)
taunter.play('idle')
},
async playDodge(side: 'a' | 'b') {
const dodger = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!dodger) return
const origX = side === 'a' ? HOME_A : HOME_B
const origY = dodger.pos.y
const dir = side === 'a' ? -1 : 1
// Quick hop backward
await Promise.all([
k.tween(dodger.pos.x, origX + dir * 60, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeOutQuad),
k.tween(dodger.pos.y, origY - 60, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(dodger.pos.y, origY, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeInQuad)
),
])
await k.wait(0.1)
await k.tween(dodger.pos.x, origX, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeInOutQuad)
},
async playKO(winningSide: 'a' | 'b', winnerName: string) {
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
if (!loser || !winner) return
const dir = winningSide === 'a' ? 1 : -1
const loserOrigY = loser.pos.y
const contactX = loser.pos.x - dir * 45
const finishStyle = Math.floor(Math.random() * 34)
let fatalityTagline = 'FATALITY!'
if (finishStyle === 0) {
// STYLE A: Classic rush-in combo + uppercut launch
sfxSpecial()
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#ff2d2d', '#ffffff', '#ff2d7b']
for (let i = 0; i < 6; i++) {
winner.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special')
sfxPunch()
await k.wait(0.04)
loser.play('hit')
k.shake(4 + i * 2)
spawnSparks(loser.pos.x + (Math.random() - 0.5) * 25, loser.pos.y - 15 - Math.random() * 40, 5, hitColors[i])
loser.pos.x += dir * 5; loser.pos.y += (i % 2 === 0 ? -4 : 4)
await k.wait(0.04)
}
loser.pos.y = loserOrigY
winner.play('special'); sfxCritical(); await k.wait(0.06)
loser.play('knockback'); k.shake(20); screenFlash('#ff2d2d', 0.2)
spawnSparks(loser.pos.x, loser.pos.y - 30, 20, '#ff2d2d')
await Promise.all([
k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
k.tween(loser.pos.y, loserOrigY - 200, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(loser.pos.y, loserOrigY, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
),
])
} else if (finishStyle === 1) {
// STYLE B: Suplex finish — grab, spin overhead, slam headfirst
sfxSpecial()
const behindX = loser.pos.x + dir * 25
await k.tween(winner.pos.x, behindX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
winner.scale.x = -winner.scale.x
winner.play('special'); sfxClash(); k.shake(5); await k.wait(0.06)
// Arc overhead
const arcCx = (winner.pos.x + loser.pos.x) / 2
for (let i = 0; i <= 10; i++) {
const t = i / 10
const angle = Math.PI * t
loser.pos.x = arcCx + Math.cos(angle) * 40
loser.pos.y = GROUND_Y - Math.sin(angle) * 140
winner.pos.x = loser.pos.x + dir * 20
winner.pos.y = loser.pos.y + 10
await k.wait(0.015)
}
sfxExplosion(); sfxCritical()
k.shake(25); screenFlash('#ffffff', 0.2)
spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#ff2d2d')
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
loser.pos.y = loserOrigY
winner.scale.x = -winner.scale.x
} else if (finishStyle === 2) {
// STYLE C: Pinball wall-bounce finish
sfxSpecial()
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
winner.play('attack'); sfxPunch()
loser.play('hit'); k.shake(10)
spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ff2d7b')
// Bounce off walls
const wallColors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#b83dff', '#39ff14']
let lx = loser.pos.x
for (let i = 0; i < 5; i++) {
const toWall = i % 2 === (dir > 0 ? 0 : 1) ? W - 25 : 25
sfxZoomWhoosh()
await k.tween(lx, toWall, 0.05, (v) => { loser.pos.x = v }, k.easings.easeInQuad)
lx = toWall
sfxBonk(); k.shake(8 + i * 2)
spawnSparks(toWall, loser.pos.y - 20, 8, wallColors[i])
screenFlash(wallColors[i], 0.04)
loser.play(i % 2 === 0 ? 'hit' : 'knockback')
await k.wait(0.03)
}
sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.15)
spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d')
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffe14d')
} else if (finishStyle === 3) {
// STYLE D: Pocket cannon finish — pull out massive gun, obliterate
sfxSpecial()
winner.play('special'); await k.wait(0.08)
const gunLen = 80
const gx = winner.pos.x + dir * 20
const gy = winner.pos.y - 25
const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(safeColor(k,'#333333')), k.opacity(1), k.z(16), k.scale(0.1)])
await k.tween(0.1, 1, 0.12, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack)
sfxBoing()
// Fire 3 massive shots
for (let s = 0; s < 3; s++) {
sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3)
const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 50)
await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12)
sfxBulletHit()
loser.play(s < 2 ? 'hit' : 'knockback')
spawnSparks(loser.pos.x, loser.pos.y - 20, 12, '#ff6600')
spawnShockwave(loser.pos.x, GROUND_Y, '#ff6600')
loser.pos.x += dir * 25
await k.wait(0.05)
}
barrel.destroy()
screenFlash('#ff6600', 0.2); k.shake(20)
} else if (finishStyle === 4) {
// BANANA PEEL SLIP — drops banana, loser slips into orbit
fatalityTagline = 'PEEL OUT!'
announceSilly('Watch your step!')
const peel = spawnWeaponProp('banana', loser.pos.x, GROUND_Y - 5, false, 14)
sfxBoing(); await k.wait(0.3)
sfxSlideDown(); sfxWomp()
loser.play('knockback'); k.shake(8)
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLIP!', '#ffe135')
destroyProp(peel)
await Promise.all([
k.tween(loser.pos.y, loserOrigY - 250, 0.4, (v) => { loser.pos.y = v }, k.easings.easeOutQuad),
k.tween(loser.pos.x, loser.pos.x + dir * 60, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
])
sfxZoomWhoosh()
await k.tween(loser.pos.y, loserOrigY, 0.3, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
sfxSplat(); k.shake(15); screenFlash('#ffe135', 0.15)
spawnShockwave(loser.pos.x, GROUND_Y, '#ffe135')
winner.play('win'); sfxRandomComedy()
} else if (finishStyle === 5) {
// ANVIL DROP — cartoon anvil from the sky
fatalityTagline = 'HEAVY METAL!'
announceDramatic('Look up!')
sfxDrumRoll()
const shadow = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y + 2), k.color(safeColor(k, '#000000')), k.opacity(0.3), k.z(4)])
await k.tween(0.3, 0.6, 0.5, (v) => { shadow.opacity = v }, k.easings.easeInQuad)
const anvil = spawnWeaponProp('anvil', loser.pos.x, -40, false, 20)
sfxZoomWhoosh()
for (const p of anvil) {
k.tween(p.pos.y, p.pos.y + GROUND_Y + 20, 0.25, (v) => { p.pos.y = v }, k.easings.easeInQuad)
}
await k.wait(0.25)
sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#888888', 0.2)
spawnSparks(loser.pos.x, GROUND_Y - 10, 20, '#888888')
spawnShockwave(loser.pos.x, GROUND_Y, '#666666')
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CLANG!', '#cccccc')
await k.wait(0.4)
destroyProp(anvil); shadow.destroy()
winner.play('win')
} else if (finishStyle === 6) {
// RUBBER CHICKEN BEATDOWN
fatalityTagline = 'FOWL PLAY!'
announceSilly('Is that a rubber chicken!?')
const chicken = spawnWeaponProp('rubber_chicken', winner.pos.x + dir * 15, winner.pos.y - 30, dir < 0, 18)
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
for (let i = 0; i < 8; i++) {
winner.play(i % 2 === 0 ? 'attack' : 'kick')
sfxBoing(); sfxRandomComedy()
loser.play('hit'); k.shake(3 + i)
for (const p of chicken) { p.pos.x = loser.pos.x + (Math.random() - 0.5) * 10; p.pos.y = loser.pos.y - 30 + (Math.random() - 0.5) * 10 }
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 40 - Math.random() * 20, ['SQUEAK!', 'BAWK!', 'HONK!', 'SQUAWK!'][i % 4], '#ffdd44')
await k.wait(0.08)
}
sfxCritical(); k.shake(18); screenFlash('#ffdd44', 0.15)
loser.play('knockback')
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ffdd44')
destroyProp(chicken)
for (let i = 0; i < 10; i++) {
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 80 - Math.random() * 60, 4, 3, '#ffffff', 200)
}
await k.wait(0.3)
} else if (finishStyle === 7) {
// GIANT FLYSWATTER — SMASH
fatalityTagline = 'SWATTED!'
announceScream('BUG REPORT FILED!')
sfxZoomWhoosh()
const swatter = spawnWeaponProp('flyswatter', loser.pos.x, -60, false, 20)
for (const p of swatter) {
k.tween(p.pos.y, p.pos.y + GROUND_Y + 40, 0.2, (v) => { p.pos.y = v }, k.easings.easeInQuad)
}
await k.wait(0.2)
sfxSplat(); sfxExplosion(); k.shake(30); screenFlash('#44aa44', 0.2)
await k.tween(loser.scale.y, 0.2, 0.1, (v) => { loser.scale.y = v }, k.easings.easeInQuad)
spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'SPLAT!', '#44aa44')
spawnShockwave(loser.pos.x, GROUND_Y, '#44aa44')
await k.wait(0.5)
await k.tween(loser.scale.y, 1, 0.15, (v) => { loser.scale.y = v }, k.easings.easeOutBack)
destroyProp(swatter)
winner.play('win')
} else if (finishStyle === 8) {
// TOILET FLUSH
fatalityTagline = 'FLUSHED!'
announceSilly('Somebody call a plumber!')
const toilet = spawnWeaponProp('toilet', W / 2, GROUND_Y - 30, false, 12)
sfxBoing(); await k.wait(0.2)
sfxZoomWhoosh()
await k.tween(loser.pos.x, W / 2, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad)
loser.play('knockback'); sfxWomp()
for (let i = 0; i < 12; i++) {
const angle = (i / 12) * Math.PI * 4
const radius = 30 - (i * 2)
loser.pos.x = W / 2 + Math.cos(angle) * radius
loser.pos.y = GROUND_Y - 30 + Math.sin(angle) * radius * 0.5
sfxSlideDown()
await k.wait(0.04)
}
sfxExplosion(); k.shake(20); screenFlash('#aaddff', 0.2)
for (let i = 0; i < 12; i++) {
spawnProp(W / 2, GROUND_Y - 30, W / 2 + (Math.random() - 0.5) * 80, GROUND_Y - 60 - Math.random() * 40, 5, 5, '#4488ff', 300, true)
}
spawnEmoteText(W / 2, GROUND_Y - 80, 'FLUSH!', '#4488ff')
loser.pos.y = loserOrigY
await k.wait(0.3)
destroyProp(toilet)
winner.play('win')
} else if (finishStyle === 9) {
// KEYBOARD WARRIOR — CTRL+ALT+DELETE
fatalityTagline = 'CTRL ALT DELETED!'
announceRobot('Initiating keyboard protocol!')
const kb = spawnWeaponProp('keyboard', winner.pos.x + dir * 20, winner.pos.y - 25, dir < 0, 18)
sfxPowerUp(); await k.wait(0.15)
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
winner.play('attack'); sfxBonk(); k.shake(12)
loser.play('hit')
const keyLabels = ['C', 'T', 'R', 'L', 'A', 'L', 'T', 'D', 'E', 'L']
for (let i = 0; i < keyLabels.length; i++) {
const key = k.add([k.rect(6, 6), k.pos(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20), k.color(safeColor(k, '#666666')), k.z(20), k.opacity(1)])
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 40, loser.pos.y - 30 - Math.random() * 30, keyLabels[i], '#aaaaaa')
k.tween(key.pos.y, key.pos.y - 40 - Math.random() * 60, 0.4, (v) => { key.pos.y = v }, k.easings.easeOutQuad)
k.tween(1, 0, 0.5, (v) => { key.opacity = v }).then(() => { if (key.exists()) key.destroy() })
sfxPunch(); await k.wait(0.04)
}
sfxCritical(); k.shake(20); screenFlash('#0000ff', 0.3)
loser.play('knockback')
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'BSOD!', '#ffffff')
destroyProp(kb)
await k.wait(0.3)
} else if (finishStyle === 10) {
// DISCO BALL DROP — Saturday Night Fatality
fatalityTagline = 'GROOVY DEATH!'
announceSilly('Get down! No really, GET DOWN!')
const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff']
for (let i = 0; i < 6; i++) { setTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) }
const disco = spawnWeaponProp('disco_ball', loser.pos.x, -30, false, 20)
sfxSlideDown(); sfxBoing()
for (const p of disco) {
k.tween(p.pos.y, p.pos.y + GROUND_Y - 40, 0.3, (v) => { p.pos.y = v }, k.easings.easeOutBounce)
}
await k.wait(0.3)
for (let i = 0; i < 6; i++) {
loser.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special')
sfxRandomComedy(); screenFlash(discoColors[i], 0.04)
await k.wait(0.1)
}
sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.2)
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOGIE!', '#ff00ff')
await k.wait(0.3)
destroyProp(disco)
winner.play('win')
} else if (finishStyle === 11) {
// RUBBER DUCK ARMY — QUACKED
fatalityTagline = 'QUACKED!'
announceSilly('RELEASE THE QUACKEN!')
winner.play('special'); sfxPowerUp()
await k.wait(0.2)
for (let i = 0; i < 12; i++) {
const duck = spawnWeaponProp('rubber_duck', winner.pos.x + dir * 10, winner.pos.y - 20 + (Math.random() - 0.5) * 30, dir < 0, 16)
sfxBoing()
const targetY = loser.pos.y - 20 + (Math.random() - 0.5) * 30
for (const p of duck) {
k.tween(p.pos.x, loser.pos.x + (Math.random() - 0.5) * 20, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad)
k.tween(p.pos.y, targetY, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(duck))
}
if (i % 3 === 0) { loser.play(i % 2 === 0 ? 'hit' : 'knockback'); k.shake(4 + i); sfxBonk() }
await k.wait(0.05)
}
sfxExplosion(); k.shake(20); screenFlash('#ffdd00', 0.2)
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffdd00')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'QUACK!', '#ffdd00')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 12) {
// BAGUETTE BEATDOWN
fatalityTagline = 'BON APPETIT!'
announceFast('Is that a day-old baguette!?')
const bread = spawnWeaponProp('baguette', winner.pos.x + dir * 15, winner.pos.y - 25, dir < 0, 18)
sfxBoing()
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
for (let i = 0; i < 6; i++) {
winner.play(i % 2 === 0 ? 'attack' : 'kick')
sfxBonk(); loser.play('hit'); k.shake(5 + i * 2)
for (let c = 0; c < 3; c++) {
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 50, loserOrigY - 30 - Math.random() * 40, 3, 3, '#cc9944', 250, true)
}
for (const p of bread) { p.pos.x = loser.pos.x + dir * -10; p.pos.y = loser.pos.y - 25 }
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 40 - Math.random() * 15, ['BONK!', 'CRUST!', 'OUI!', 'PAIN!', 'CRUNCH!', 'HON HON!'][i], '#cc9944')
await k.wait(0.08)
}
sfxCritical(); k.shake(18); screenFlash('#cc9944', 0.15)
loser.play('knockback')
destroyProp(bread)
await k.wait(0.3)
} else if (finishStyle === 13) {
// GIANT BOOT — Monty Python style
fatalityTagline = 'BOOTED!'
announceDramatic('AND NOW FOR SOMETHING COMPLETELY DIFFERENT!')
sfxDrumRoll()
const boot = spawnWeaponProp('boot', loser.pos.x, -60, false, 22)
for (const p of boot) { p.scale = k.vec2(3, 3) }
await k.wait(0.3)
sfxZoomWhoosh()
for (const p of boot) {
k.tween(p.pos.y, p.pos.y + GROUND_Y + 60, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad)
}
await k.wait(0.15)
sfxExplosion(); sfxSplat(); k.shake(30); screenFlash('#553322', 0.2)
spawnShockwave(loser.pos.x, GROUND_Y, '#553322')
spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#442211')
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'STOMP!', '#553322')
await k.wait(0.5)
destroyProp(boot)
winner.play('win')
} else if (finishStyle === 14) {
// CANNON LAUNCH — fires loser out of a cannon
fatalityTagline = 'FIRED!'
announceDramatic('LOAD THE CANNON!')
const cannonProp = spawnWeaponProp('cannon', winner.pos.x + dir * 30, GROUND_Y - 15, dir < 0, 14)
sfxPowerUp(); await k.wait(0.3)
sfxZoomWhoosh()
await k.tween(loser.pos.x, winner.pos.x + dir * 30, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad)
loser.play('knockback'); loser.opacity = 0.3
sfxBoing(); await k.wait(0.2)
sfxGunshot(); sfxExplosion(); k.shake(25)
loser.opacity = 1; screenFlash('#ff6600', 0.2)
spawnSparks(winner.pos.x + dir * 50, GROUND_Y - 20, 20, '#ff6600')
await Promise.all([
k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
k.tween(loser.pos.y, loserOrigY - 150, 0.15, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk(); k.shake(15)
spawnShockwave(loser.pos.x, GROUND_Y, '#ff4400')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOM!', '#ff6600')
destroyProp(cannonProp)
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 15) {
// GIANT STAMP — REJECTED
fatalityTagline = 'REJECTED!'
announceRobot('Processing termination request.')
sfxDrumRoll()
const stamp = spawnWeaponProp('stamp', loser.pos.x, loser.pos.y - 80, false, 22)
await k.wait(0.3)
sfxZoomWhoosh()
for (const p of stamp) {
k.tween(p.pos.y, p.pos.y + 60, 0.1, (v) => { p.pos.y = v }, k.easings.easeInQuad)
}
await k.wait(0.1)
sfxBonk(); sfxSplat(); k.shake(20); screenFlash('#cc4444', 0.2)
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'REJECTED', '#cc4444')
for (let i = 0; i < 8; i++) {
spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 20 - Math.random() * 40, 4, 4, '#cc4444', 200, true)
}
await k.wait(0.4)
destroyProp(stamp)
winner.play('win')
} else if (finishStyle === 16) {
// VENDING MACHINE TIP
fatalityTagline = 'OUT OF ORDER!'
announceSilly('Should have read the warning label!')
const vm = spawnWeaponProp('vending_machine', winner.pos.x + dir * 40, GROUND_Y - 18, false, 14)
sfxPowerUp(); await k.wait(0.2)
winner.play('attack'); sfxPunch()
sfxZoomWhoosh()
for (const p of vm) {
k.tween(p.pos.x, loser.pos.x, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad)
}
await k.wait(0.2)
sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#cc3333', 0.2)
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#cc3333')
const snackColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff8844']
for (let i = 0; i < 8; i++) {
spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 100, GROUND_Y - 40 - Math.random() * 60, 5, 8, snackColors[i % 5], 250)
}
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CRUSHED!', '#cc3333')
destroyProp(vm)
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 17) {
// LAWN MOWER — mowed down
fatalityTagline = 'MOWED DOWN!'
announceFast('Rev it up!')
const mower = spawnWeaponProp('lawn_mower', winner.pos.x + dir * 10, GROUND_Y - 8, dir < 0, 16)
winner.play('special'); sfxPowerUp()
for (let i = 0; i < 3; i++) { sfxRandomComedy(); await k.wait(0.1) }
sfxZoomWhoosh()
for (const p of mower) {
k.tween(p.pos.x, loser.pos.x + dir * 40, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad)
}
await k.tween(winner.pos.x, loser.pos.x - dir * 20, 0.2, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(20); screenFlash('#44aa44', 0.2)
loser.play('knockback')
for (let i = 0; i < 15; i++) {
spawnProp(loser.pos.x, GROUND_Y - 5, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 30 - Math.random() * 50, 3, 3, '#44aa44', 300, false)
}
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BRRRRR!', '#44aa44')
destroyProp(mower)
await k.wait(0.3)
} else if (finishStyle === 18) {
// DUBSTEP DROP — bass drops, floor breaks
fatalityTagline = 'DROP THE BASS!'
announceScream('WUB WUB WUB!')
sfxPowerUp()
const wubColors = ['#ff00ff', '#00ffff', '#ff8800', '#39ff14']
for (let i = 0; i < 4; i++) {
screenFlash(wubColors[i], 0.05); k.shake(3 + i * 2); await k.wait(0.1)
}
sfxExplosion(); sfxExplosion(); k.shake(35); screenFlash('#ffffff', 0.15)
spawnShockwave(W / 2, GROUND_Y, '#ff00ff')
await k.wait(0.05)
spawnShockwave(W / 2, GROUND_Y, '#00ffff')
await k.wait(0.05)
spawnShockwave(W / 2, GROUND_Y, '#39ff14')
loser.play('knockback')
for (let i = 0; i < 5; i++) {
await k.tween(loser.pos.y, loserOrigY - 40, 0.04, (v) => { loser.pos.y = v }, k.easings.easeOutQuad)
sfxBonk(); k.shake(8)
await k.tween(loser.pos.y, loserOrigY, 0.04, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
screenFlash(wubColors[i % 4], 0.03)
}
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff00ff')
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'WUBWUB!', '#ff00ff')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 19) {
// 404 NOT FOUND — loser glitches out
fatalityTagline = 'ERROR 404!'
announceRobot('Error. Opponent not found.')
sfxZap()
for (let i = 0; i < 10; i++) {
loser.opacity = Math.random() > 0.5 ? 1 : 0.2
loser.pos.x += (Math.random() - 0.5) * 15
loser.play(['idle', 'attack', 'kick', 'hit', 'special'][Math.floor(Math.random() * 5)])
screenFlash('#00ff00', 0.02); sfxRandomFail()
await k.wait(0.06)
}
screenFlash('#0000ff', 0.3); sfxFail(); k.shake(20)
spawnEmoteText(loser.pos.x, loser.pos.y - 60, '404', '#00ff00')
spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'NOT FOUND', '#00ff00')
loser.opacity = 0; await k.wait(0.3)
loser.opacity = 1; sfxWomp()
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#00ff00')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 20) {
// PIE STORM — pies from everywhere
fatalityTagline = 'CREAM ATED!'
announceSilly('PIE PIE PIE!')
winner.play('special')
const pieAngles = [0, 45, 90, 135, 180, 225, 270, 315]
for (let i = 0; i < 8; i++) {
const rad = pieAngles[i] * Math.PI / 180
const fromX = loser.pos.x + Math.cos(rad) * 120
const fromY = loser.pos.y - 20 + Math.sin(rad) * 80
const pie = spawnWeaponProp('pie', fromX, fromY, false, 16)
for (const p of pie) {
k.tween(p.pos.x, loser.pos.x, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad)
k.tween(p.pos.y, loser.pos.y - 20, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(pie))
}
sfxBoing(); await k.wait(0.06)
if (i % 2 === 0) { loser.play(i % 4 === 0 ? 'hit' : 'knockback'); sfxSplat(); k.shake(5 + i) }
}
sfxExplosion(); k.shake(22); screenFlash('#f5deb3', 0.2)
for (let i = 0; i < 15; i++) {
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 100, loserOrigY - 40 - Math.random() * 50, 5, 5, '#ffffff', 250, true)
}
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SPLAT!', '#ffffff')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 21) {
// T-SHIRT CANNON — rapid fire merch
fatalityTagline = "MERCH'D!"
announceFast('FREE SHIRTS! FREE SHIRTS!')
winner.play('special')
const tCannon = k.add([k.rect(30, 15), k.pos(winner.pos.x + dir * 15, winner.pos.y - 25), k.color(safeColor(k, '#ff4488')), k.z(16)])
sfxPowerUp(); await k.wait(0.15)
const shirtColors = ['#ff4444', '#4444ff', '#44ff44', '#ffff44', '#ff44ff', '#44ffff', '#ff8844', '#8844ff']
for (let i = 0; i < 8; i++) {
sfxGunshot()
spawnProp(tCannon.pos.x + dir * 30, tCannon.pos.y, loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20 + (Math.random() - 0.5) * 20, 10, 8, shirtColors[i], 600)
if (i % 2 === 0) { loser.play('hit'); sfxBonk(); k.shake(5 + i) }
await k.wait(0.06)
}
sfxCritical(); k.shake(18); screenFlash('#ff4488', 0.2)
loser.play('knockback')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'XL!', '#ff4488')
tCannon.destroy()
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 22) {
// CONFETTI CANNON — party's over
fatalityTagline = "PARTY'S OVER!"
announceSilly('SURPRISE!')
winner.play('special'); sfxPowerUp(); await k.wait(0.2)
sfxExplosion(); k.shake(20); screenFlash('#ff88cc', 0.15)
const confettiColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff00', '#ff00ff', '#00ffff', '#ff8800', '#88ff00']
for (let i = 0; i < 30; i++) {
spawnProp(winner.pos.x + dir * 30, winner.pos.y - 25, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 80 - Math.random() * 60, 4, 3, confettiColors[i % 8], 400 + Math.random() * 200)
}
sfxBoing(); sfxRandomComedy()
loser.play('hit'); k.shake(10); await k.wait(0.15)
loser.play('knockback'); sfxCritical(); k.shake(15)
await k.tween(loser.pos.x, loser.pos.x + dir * 50, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad)
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'YAY?', '#ff88cc')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 23) {
// BALLOON POP — inflate then pop
fatalityTagline = 'POPPED!'
announceSilly('Inflate! INFLATE!')
winner.play('special'); sfxPowerUp()
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
const origSX = loser.scale.x; const origSY = loser.scale.y
for (let i = 0; i < 8; i++) {
const grow = 1 + (i + 1) * 0.15
await k.tween(loser.scale.y, origSY * grow, 0.08, (v) => { loser.scale.x = origSX * grow; loser.scale.y = v }, k.easings.easeOutQuad)
sfxBoing(); loser.play(i % 2 === 0 ? 'hit' : 'idle')
loser.pos.y = loserOrigY - (grow - 1) * 15
await k.wait(0.04)
}
sfxExplosion(); k.shake(25); screenFlash('#ff4488', 0.2)
loser.scale.x = origSX; loser.scale.y = origSY; loser.pos.y = loserOrigY
const balloonColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff']
for (let i = 0; i < 15; i++) {
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 60 - Math.random() * 80, 6, 4, balloonColors[i % 5], 250, true)
}
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'POP!', '#ff4488')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 24) {
// GIANT MAGNET — yanks loser back and forth
fatalityTagline = 'ATTRACTIVE FINISH!'
announceRobot('Magnetic field activated.')
winner.play('special'); sfxPowerUp()
const magnet = k.add([k.rect(20, 25), k.pos(winner.pos.x + dir * 20, winner.pos.y - 30), k.color(safeColor(k, '#cc0000')), k.z(18)])
const magnetTop = k.add([k.rect(20, 8), k.pos(winner.pos.x + dir * 20, winner.pos.y - 50), k.color(safeColor(k, '#0000cc')), k.z(18)])
sfxZap()
for (let i = 0; i < 6; i++) {
const pullTarget = i % 2 === 0 ? winner.pos.x + dir * 40 : (winningSide === 'a' ? HOME_B + 30 : HOME_A - 30)
sfxZoomWhoosh()
await k.tween(loser.pos.x, pullTarget, 0.1, (v) => { loser.pos.x = v }, k.easings.easeInOutQuad)
sfxBonk(); k.shake(6 + i * 2)
loser.play(i % 2 === 0 ? 'hit' : 'knockback')
spawnSparks(loser.pos.x, loser.pos.y - 20, 5, '#8888ff')
await k.wait(0.05)
}
sfxExplosion(); k.shake(22); screenFlash('#8888ff', 0.2)
spawnShockwave(loser.pos.x, GROUND_Y, '#8888ff')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BZZT!', '#8888ff')
magnet.destroy(); magnetTop.destroy()
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 25) {
// SLIDE WHISTLE — loser slides off screen
fatalityTagline = 'WHEEEE!'
announceSilly('Bye bye!')
sfxSlideUp(); winner.play('attack')
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
sfxPunch(); sfxBoing(); k.shake(10)
loser.play('knockback')
sfxSlideUp()
await Promise.all([
k.tween(loser.pos.y, -50, 0.5, (v) => { loser.pos.y = v }, k.easings.easeInQuad),
k.tween(loser.pos.x, loser.pos.x + dir * 30, 0.5, (v) => { loser.pos.x = v }, k.easings.easeInQuad),
])
spawnEmoteText(loser.pos.x, 20, '*', '#ffee00')
await k.wait(0.4)
sfxSlideDown(); sfxZoomWhoosh()
await k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
sfxSplat(); sfxExplosion(); k.shake(20); screenFlash('#ffcc00', 0.15)
spawnShockwave(loser.pos.x, GROUND_Y, '#ffcc00')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 26) {
// SLAPSTICK LADDER — keeps bonking loser
fatalityTagline = 'SLAPSTICKED!'
announceSilly('Watch where you swing that thing!')
const ladder = k.add([k.rect(6, 50), k.pos(winner.pos.x + dir * 5, winner.pos.y - 40), k.color(safeColor(k, '#cc8833')), k.z(18), k.anchor('center')])
sfxBoing()
for (let i = 0; i < 5; i++) {
winner.scale.x = -winner.scale.x
ladder.pos.x = winner.pos.x + (winner.scale.x > 0 ? 1 : -1) * 25
sfxZoomWhoosh(); await k.wait(0.06)
sfxBonk(); k.shake(6 + i * 2)
loser.play(i % 2 === 0 ? 'hit' : 'knockback')
spawnEmoteText(loser.pos.x, loser.pos.y - 40 - Math.random() * 20, ['BONK!', 'CLONK!', 'WHACK!', 'THUD!', 'OOF!'][i], '#cc8833')
await k.wait(0.08)
}
sfxCritical(); k.shake(18); screenFlash('#cc8833', 0.15)
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#cc8833')
ladder.destroy()
winner.scale.x = dir > 0 ? Math.abs(winner.scale.x) : -Math.abs(winner.scale.x)
spawnEmoteText(winner.pos.x, winner.pos.y - 50, '?', '#ffffff')
await k.wait(0.3)
} else if (finishStyle === 27) {
// STACK OVERFLOW — buried in code blocks
fatalityTagline = 'STACK OVERFLOW!'
announceRobot('Exception in thread main.')
sfxFail()
const blockColors = ['#282c34', '#1e1e1e', '#2d2d2d', '#1a1a2e', '#0d1117']
for (let i = 0; i < 10; i++) {
const block = k.add([
k.rect(18 + Math.random() * 12, 8 + Math.random() * 6),
k.pos(loser.pos.x + (Math.random() - 0.5) * 40, -20 - i * 15),
k.color(safeColor(k, blockColors[i % 5])), k.z(16 + i), k.opacity(0.9),
])
k.tween(block.pos.y, GROUND_Y - 5 - i * 6, 0.15 + i * 0.02, (v) => { block.pos.y = v }, k.easings.easeInQuad).then(() => {
sfxBonk(); k.shake(3)
setTimeout(() => { if (block.exists()) k.tween(1, 0, 0.8, (v) => { block.opacity = v }).then(() => { if (block.exists()) block.destroy() }) }, 800)
})
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 30 - Math.random() * 30, ['{', '}', '()', '=>', '[]', ';;', 'nil', 'NaN', '???', '//'][i], '#00ff00')
loser.play(i % 3 === 0 ? 'hit' : 'idle')
await k.wait(0.06)
}
sfxExplosion(); k.shake(20); screenFlash('#00ff00', 0.2)
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SEGFAULT', '#ff0000')
await k.wait(0.4)
winner.play('win')
} else if (finishStyle === 28) {
// GLITTER BOMB — fabulous destruction
fatalityTagline = 'FABULOUS!'
announceSilly('You will NEVER get this out of your hair!')
winner.play('special'); sfxPowerUp()
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
sfxPunch(); k.shake(5)
sfxExplosion(); k.shake(25)
const glitterColors = ['#ff88cc', '#ffcc00', '#88ffcc', '#cc88ff', '#ff8888', '#88ccff', '#ffff88', '#ff88ff']
for (let wave = 0; wave < 3; wave++) {
screenFlash(glitterColors[wave * 2], 0.04)
for (let i = 0; i < 15; i++) {
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 140, loserOrigY - 80 - Math.random() * 80, 3, 3, glitterColors[Math.floor(Math.random() * 8)], 200 + Math.random() * 200, Math.random() > 0.5)
}
sfxBoing(); loser.play(wave === 2 ? 'knockback' : 'hit')
await k.wait(0.1)
}
spawnShockwave(loser.pos.x, GROUND_Y, '#ff88cc')
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SPARKLE!', '#ff88cc')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 29) {
// SELFIE STICK — beats then takes selfie
fatalityTagline = 'SAY CHEESE!'
announceFast('Content creation time!')
const stick = k.add([k.rect(4, 40), k.pos(winner.pos.x + dir * 10, winner.pos.y - 50), k.color(safeColor(k, '#888888')), k.z(18)])
const phone = k.add([k.rect(8, 12), k.pos(winner.pos.x + dir * 10, winner.pos.y - 70), k.color(safeColor(k, '#222222')), k.z(19)])
sfxBoing()
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
for (let i = 0; i < 4; i++) {
winner.play('attack'); sfxBonk()
stick.pos.x = loser.pos.x; phone.pos.x = loser.pos.x
loser.play('hit'); k.shake(5 + i * 2)
await k.wait(0.08)
}
sfxCritical(); k.shake(15); loser.play('knockback')
await k.wait(0.15)
winner.play('win'); sfxCoin(); screenFlash('#ffffff', 0.3)
spawnEmoteText(W / 2, GROUND_Y - 80, '#selfie', '#ff4488')
spawnSparks(phone.pos.x, phone.pos.y, 10, '#ffffff')
stick.destroy(); phone.destroy()
await k.wait(0.3)
} else if (finishStyle === 30) {
// DANCE OFF — served
fatalityTagline = 'SERVED!'
announceHype('DANCE BATTLE!')
sfxPowerUp()
const moves = ['attack', 'kick', 'special', 'kick', 'attack', 'special'] as const
for (let i = 0; i < moves.length; i++) {
winner.play(moves[i]); sfxRandomComedy()
spawnSparks(winner.pos.x, winner.pos.y - 30, 3, ['#ff00ff', '#00ffff', '#ffff00'][i % 3])
await k.wait(0.08)
}
for (let i = 0; i < 4; i++) {
loser.play(moves[i]); sfxWomp()
loser.pos.x += (Math.random() - 0.5) * 10
await k.wait(0.1)
}
sfxFail()
winner.play('special'); sfxCritical(); sfxExplosion()
k.shake(25); screenFlash('#ff00ff', 0.2)
spawnShockwave(loser.pos.x, GROUND_Y, '#ff00ff')
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SERVED!', '#ff00ff')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 31) {
// WHOOPEE CUSHION — total gas
fatalityTagline = 'TOTAL GAS!'
announceSilly('What is that SMELL!?')
const cushion = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y - 5), k.color(safeColor(k, '#ff6688')), k.z(10), k.opacity(0.8)])
sfxBoing(); await k.wait(0.2)
sfxWomp(); sfxRandomComedy(); k.shake(8)
await k.tween(20, 40, 0.15, (v) => { cushion.radius = v }, k.easings.easeOutQuad)
sfxExplosion()
for (let i = 0; i < 12; i++) {
const gas = k.add([
k.circle(8 + Math.random() * 10),
k.pos(loser.pos.x + (Math.random() - 0.5) * 40, GROUND_Y - 10 - Math.random() * 30),
k.color(safeColor(k, '#88ff44')), k.opacity(0.5), k.z(15),
])
k.tween(gas.pos.y, gas.pos.y - 40 - Math.random() * 30, 0.6, (v) => { gas.pos.y = v }, k.easings.easeOutQuad)
k.tween(0.5, 0, 0.6, (v) => { gas.opacity = v }).then(() => { if (gas.exists()) gas.destroy() })
}
k.shake(20); screenFlash('#88ff44', 0.15)
loser.play('knockback')
await k.tween(loser.pos.y, loserOrigY - 120, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad)
await k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
sfxSplat(); k.shake(12)
cushion.destroy()
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'P U!', '#88ff44')
await k.wait(0.3)
winner.play('win')
} else if (finishStyle === 32) {
// RICK ROLL — never gonna give you up
fatalityTagline = 'NEVER GONNA GIVE YOU UP!'
announceHype('You know the rules, and so do I!')
sfxPowerUp()
const rickMoves = ['attack', 'kick', 'special', 'idle', 'attack', 'kick'] as const
for (let i = 0; i < 6; i++) {
winner.play(rickMoves[i])
const hop = winner.pos.y
await k.tween(winner.pos.y, hop - 15, 0.05, (v) => { winner.pos.y = v }, k.easings.easeOutQuad)
await k.tween(winner.pos.y, hop, 0.05, (v) => { winner.pos.y = v }, k.easings.easeInQuad)
sfxBoing()
screenFlash(['#ff4444', '#ff8800', '#ffff00', '#44ff44', '#4444ff', '#8844ff'][i], 0.03)
await k.wait(0.04)
}
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
winner.play('special'); sfxCritical(); sfxExplosion()
k.shake(25); screenFlash('#ff8800', 0.2)
loser.play('knockback')
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff8800')
spawnShockwave(loser.pos.x, GROUND_Y, '#ff8800')
spawnEmoteText(loser.pos.x, loser.pos.y - 50, "RICK'D!", '#ff8800')
await k.wait(0.3)
winner.play('win')
} else {
// PIZZA CUTTER — the big wheel
fatalityTagline = 'SLICED!'
announceFast('Extra large, extra lethal!')
const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18), k.rotate(0)])
const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor(k, '#553322')), k.z(17)])
sfxPowerUp(); winner.play('special')
sfxZoomWhoosh()
await Promise.all([
k.tween(wheel.pos.x, loser.pos.x, 0.2, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad),
k.tween(0, 720, 0.2, (v) => { wheel.angle = v }, k.easings.linear),
])
sfxCritical(); sfxSplat(); k.shake(20); screenFlash('#ffcc00', 0.15)
loser.play('knockback')
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ff6600')
await k.tween(wheel.pos.x, loser.pos.x + dir * 60, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeOutQuad)
sfxZoomWhoosh()
await k.tween(wheel.pos.x, loser.pos.x, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(15)
for (let i = 0; i < 8; i++) {
spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 30 - Math.random() * 40, 4, 4, i % 2 === 0 ? '#ffcc00' : '#cc4400', 250, true)
}
wheel.destroy(); handle.destroy()
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLICED!', '#ff6600')
await k.wait(0.3)
winner.play('win')
}
// Judge stands up for KO call
const judgeKO = k.get('judge')[0]
if (judgeKO) {
judgeKO.play('shocked')
const judgeOrigY = judgeKO.pos.y
k.tween(judgeKO.pos.y, judgeOrigY - 25, 0.2, (v) => { judgeKO.pos.y = v }, k.easings.easeOutQuad)
k.wait(1.5).then(() => {
if (judgeKO.exists()) {
judgeKO.play(winningSide === 'a' ? 'call_left' : 'call_right')
k.tween(judgeKO.pos.y, judgeOrigY, 0.3, (v) => { judgeKO.pos.y = v }, k.easings.easeInOutQuad)
}
})
}
// Winner's human cheers, loser's human panics (30% chance on KO)
if (Math.random() < 0.3) {
spawnHumanCoach(winningSide, 'cheer')
spawnHumanCoach(winningSide === 'a' ? 'b' : 'a', 'panic')
}
// === Common ending: KO + celebration ===
sfxKO()
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff2d2d')
k.shake(18); sfxExplosion()
await k.wait(0.3)
loser.play('ko')
if (Math.random() < 0.5) setTimeout(() => sfxRandomFail(), 200)
await k.wait(0.4) // pause for visual impact before fatality voice
const winnerArch = winningSide === 'a' ? botA.archetype : botB.archetype
const loserArch = winningSide === 'a' ? botB.archetype : botA.archetype
if (winnerArch === 'the_creator') {
announceCreatorKO()
} else {
announceFatality(fatalityTagline)
}
announceCrowdReaction('cheer')
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.25, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
await k.wait(0.2)
winner.play('win')
sfxWin()
// Creator win/lose get special announcements
if (winnerArch === 'the_creator') {
announceCreatorWin()
} else if (loserArch === 'the_creator') {
announceCreatorLose()
} else {
sfxWinAnnounce(winnerName)
}
spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d')
for (let i = 0; i < 4; i++) {
setTimeout(() => {
spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d')
}, i * 200)
}
await k.wait(0.5)
// Post-KO sportsmanship (30% chance): winner helps loser up or they fist bump
if (Math.random() < 0.3) {
const sportsType = Math.random()
if (sportsType < 0.35) {
// Winner helps loser back up
await playHelpUp(winner, loser, winningSide)
} else if (sportsType < 0.65) {
// Fist bump
loser.play('idle')
await k.wait(0.2)
await playFistBump(winner, loser)
} else if (sportsType < 0.85) {
// Both bow
loser.play('idle')
await k.wait(0.2)
await Promise.all([playBow(winner), playBow(loser)])
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
for (let i = 0; i < 5; i++) spawnHeart(W / 2 + (Math.random() - 0.5) * 100, GROUND_Y - 60)
} else {
// Crowd shows love — signs pop up
spawnCrowdSigns(5, '#ff4466', '\u2665')
announceDramatic(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
for (let i = 0; i < 8; i++) spawnHeart(Math.random() * W, GROUND_Y - 40 - Math.random() * 60)
await k.wait(0.8)
}
}
},
async playPerfect(winningSide: 'a' | 'b', winnerName: string) {
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!loser || !winner) return
// Judge goes wild for a perfect
const judgePerfect = k.get('judge')[0]
if (judgePerfect) {
judgePerfect.play('shocked')
const jOrigY = judgePerfect.pos.y
k.tween(judgePerfect.pos.y, jOrigY - 35, 0.15, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad)
k.wait(0.5).then(async () => {
if (!judgePerfect.exists()) return
for (let b = 0; b < 4; b++) {
await k.tween(judgePerfect.pos.y, jOrigY - 45, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad)
await k.tween(judgePerfect.pos.y, jOrigY - 35, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeInQuad)
}
judgePerfect.play(winningSide === 'a' ? 'call_left' : 'call_right')
k.tween(judgePerfect.pos.y, jOrigY, 0.3, (v) => { judgePerfect.pos.y = v }, k.easings.easeInOutQuad)
})
}
sfxPerfect()
const dir = winningSide === 'a' ? 1 : -1
const loserOrigY = loser.pos.y
const contactX = loser.pos.x - dir * 40
// === PHASE 1: Dramatic zoom rush into the loser ===
const origScaleX = winner.scale.x
const origScaleY = winner.scale.y
sfxZoomWhoosh()
// Winner zooms at camera
await Promise.all([
k.tween(Math.abs(origScaleX), 5, 0.2, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad),
k.tween(winner.pos.x, W / 2, 0.2, (v) => { winner.pos.x = v }, k.easings.easeOutQuad),
k.tween(winner.pos.y, H * 0.6, 0.2, (v) => { winner.pos.y = v }, k.easings.easeOutQuad),
])
screenFlash('#000000', 0.1)
spawnGrotesqueDetails(winner, 2.5)
await k.wait(0.1)
destroyGrotesqueDetails()
// Zoom back and SLAM into loser
sfxZoomWhoosh()
await Promise.all([
k.tween(winner.scale.y, Math.abs(origScaleY), 0.12, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad),
k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeInQuad),
k.tween(winner.pos.y, loserOrigY, 0.12, (v) => { winner.pos.y = v }, k.easings.easeInQuad),
])
// === PHASE 2: Devastating rapid combo at contact ===
const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#ff6600', '#b83dff', '#39ff14', '#ffffff', '#ff2d2d']
const hitCount = 10
for (let i = 0; i < hitCount; i++) {
const anim = ['attack', 'kick', 'special', 'attack'][i % 4]
winner.play(anim)
sfxRapidPunch()
await k.wait(0.04)
loser.play(i < hitCount - 1 ? 'hit' : 'knockback')
k.shake(3 + i)
spawnSparks(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 10 - Math.random() * 50, 5, colors[i % colors.length])
loser.pos.x += dir * 4
loser.pos.y += (i % 2 === 0 ? -3 : 3)
await k.wait(0.04)
}
loser.pos.y = loserOrigY
// === PHASE 3: Final massive hit — screen goes white ===
winner.play('special')
sfxCritical()
sfxExplosion()
await k.wait(0.06)
loser.play('knockback')
k.shake(35)
screenFlash('#ffe14d', 0.4)
spawnSparks(loser.pos.x, loser.pos.y - 30, 30, '#ffe14d')
spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d')
// Launch loser way off with a spin
await Promise.all([
k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
k.tween(loser.pos.y, loserOrigY - 300, 0.25, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
),
])
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
spawnSparks(loser.pos.x, GROUND_Y - 10, 30, '#ff2d2d')
k.shake(20)
sfxExplosion()
sfxBoing()
await k.wait(0.3)
loser.play('ko')
// Winner walks back and celebrates
winner.scale.x = origScaleX
winner.scale.y = origScaleY
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
winner.play('win')
setTimeout(() => {
sfxWin()
sfxWinAnnounce(winnerName)
}, 300)
// FlawlessVictory voice is called by FightViewer after playPerfect returns
announceCrowdReaction('cheer')
// Massive fireworks
for (let i = 0; i < 8; i++) {
setTimeout(() => {
spawnSparks(
Math.random() * W,
Math.random() * H * 0.5,
18,
['#ff2d7b', '#00f0ff', '#ffe14d', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i]
)
if (i % 2 === 0) sfxRandomComedy()
}, i * 200)
}
await k.wait(0.7)
},
2026-03-08 21:35:04 +00:00
async playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) {
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
if (!winner) return
if (isUpset) {
// UPSET: Dramatic zoom on winner + shocked gasp
const origSX = winner.scale.x
const origSY = winner.scale.y
sfxZoomWhoosh()
await Promise.all([
k.tween(Math.abs(origSX), 4, 0.25, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeOutQuad),
k.tween(winner.pos.x, W / 2, 0.25, (v: number) => { winner.pos.x = v }, k.easings.easeOutQuad),
])
screenFlash('#ffe14d', 0.15)
k.shake(8)
await k.wait(0.6)
// Zoom back
await Promise.all([
k.tween(winner.scale.y, Math.abs(origSY), 0.2, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad),
k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.2, (v: number) => { winner.pos.x = v }, k.easings.easeInQuad),
])
} else {
// NORMAL: Winner flex pose with camera flash
winner.play('win')
await k.wait(0.3)
screenFlash('#ffffff', 0.08)
sfxSpecial()
await k.wait(0.15)
screenFlash('#ffffff', 0.06)
await k.wait(0.15)
screenFlash('#ffffff', 0.04)
// Confetti shower
for (let i = 0; i < 20; i++) {
const cx = Math.random() * W
const confetti = k.add([k.rect(4, 4), k.pos(cx, -5), k.color(safeColor(k, ['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#b83dff', '#ff6600'][i % 6])), k.opacity(0.9), k.z(20), k.rotate(Math.random() * 360)])
k.tween(confetti.pos.y, GROUND_Y + 10, 0.8 + Math.random() * 0.5, (v: number) => { confetti.pos.y = v; confetti.angle += 180 * k.dt() }).then(() => confetti.destroy())
}
}
await k.wait(0.4)
},
startMusic() { startMusic() },
stopMusic() { stopMusic() },
destroy() {
stopMusic()
stopTalking('a')
stopTalking('b')
for (const id of cleanupTimers) {
clearInterval(id)
clearTimeout(id)
}
cleanupTimers.clear()
k.quit()
},
}
}
export type FightSceneController = Awaited<ReturnType<typeof createFightScene>>