diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue
index b6b18d3..d89aeff 100644
--- a/frontend/src/components/FightViewer.vue
+++ b/frontend/src/components/FightViewer.vue
@@ -8,6 +8,7 @@ import {
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
setMusicIntensity, stopAllAudio,
setMasterMute, isMasterMuted, ensureAudioContext,
+ speakQuestion, speakAnswer, speakNarration,
} from '../game/sounds'
interface Round {
@@ -66,6 +67,7 @@ const hitTextX = ref(50)
const hitTextY = ref(30)
const glitching = ref(false)
const soundOn = ref(true)
+const insanityMode = ref(false)
async function toggleSound() {
soundOn.value = !soundOn.value
@@ -73,6 +75,14 @@ async function toggleSound() {
setMasterMute(!soundOn.value)
}
+function toggleInsanity() {
+ insanityMode.value = !insanityMode.value
+ // Insanity mode kills all TTS immediately
+ if (insanityMode.value && typeof speechSynthesis !== 'undefined') {
+ speechSynthesis.cancel()
+ }
+}
+
// Staggered log
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
@@ -269,28 +279,72 @@ async function _doReplay() {
for (const round of props.fight.rounds) {
currentRound.value = round.roundNumber
- fanfareRound(round.roundNumber)
- await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
- await sleep(80)
- await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
- await sleep(80)
- fanfareFight()
- await showOverlay('FIGHT!', '#ff2d7b', 400)
- await sleep(80)
-
- // Show speech bubbles BEFORE the fight animation so viewers see what bots said
- if (scene) {
- if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 3.5)
- if (round.botBResponse) {
- setTimeout(() => {
- if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 3.2)
- }, 400)
- }
+ if (insanityMode.value) {
+ // Insanity: minimal overlays, no fanfares
+ await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150)
+ } else {
+ fanfareRound(round.roundNumber)
+ await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
+ await sleep(80)
+ await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
+ await sleep(80)
+ fanfareFight()
+ await showOverlay('FIGHT!', '#ff2d7b', 400)
+ await sleep(80)
}
- await sleep(300)
- // Log + fight animation in parallel
- const logPromise = addRoundToLog(round, true)
+ // === TTS-synced question + answer flow ===
+ const challenge = JSON.parse(round.challengeData)
+ const doTTS = soundOn.value && !insanityMode.value
+
+ // 1. Show question in log AND speak it
+ logItems.value.push(
+ { type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
+ )
+ scrollLog(); await sleep(insanityMode.value ? 30 : 100)
+ logItems.value.push(
+ { type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
+ )
+ scrollLog()
+ if (doTTS && challenge.prompt) {
+ await speakQuestion(challenge.prompt)
+ await sleep(150)
+ } else {
+ await sleep(insanityMode.value ? 50 : 600)
+ }
+
+ // 2. Bot A: log + bubble + mouth + TTS (all synced)
+ if (round.botAResponse) {
+ logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' })
+ logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
+ scrollLog()
+ if (scene && !insanityMode.value) {
+ scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5)
+ scene.startTalking('a')
+ }
+ if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse.slice(0, 60))
+ else await sleep(insanityMode.value ? 30 : 800)
+ scene?.stopTalking('a')
+ if (!insanityMode.value) await sleep(150)
+ }
+
+ // 3. Bot B: log + bubble + mouth + TTS
+ if (round.botBResponse) {
+ logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' })
+ logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
+ scrollLog()
+ if (scene && !insanityMode.value) {
+ scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5)
+ scene.startTalking('b')
+ }
+ if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse.slice(0, 60))
+ else await sleep(insanityMode.value ? 30 : 800)
+ scene?.stopTalking('b')
+ if (!insanityMode.value) await sleep(150)
+ }
+
+ // Log is already populated above — no need for addRoundToLog stagger
+ const logPromise = Promise.resolve()
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id
@@ -328,6 +382,8 @@ async function _doReplay() {
if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
scrollLog()
+ if (doTTS) await speakNarration(round.narration)
+ await sleep(insanityMode.value ? 30 : 200)
}
const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW'
@@ -352,17 +408,18 @@ async function _doReplay() {
setMusicIntensity(1 - lowestHp / 100)
// Taunting between rounds — winner taunts, sometimes both
- if (scene && (aWon || bWon)) {
+ if (!insanityMode.value && scene && (aWon || bWon)) {
const winnerSide = aWon ? 'a' : 'b'
await sleep(150)
await scene.playTaunt(winnerSide)
- // Sometimes loser taunts back (30% chance)
if (Math.random() < 0.3) {
await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a')
}
await sleep(200)
- } else {
+ } else if (!insanityMode.value) {
await sleep(400)
+ } else {
+ await sleep(30)
}
}
@@ -593,6 +650,17 @@ async function _doReplay() {
+
{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}
diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts
index 484730b..60a8c25 100644
--- a/frontend/src/game/FightScene.ts
+++ b/frontend/src/game/FightScene.ts
@@ -107,7 +107,29 @@ const TIER_ULTIMATES: Record = {
5: ['ultimateArmageddon', 'ultimateOmegaBeam', 'ultimateBeyondTheScreen', 'ultimateMatrixDodge', 'ultimateWorldEnder', 'ultimateAbsoluteZero', 'ultimateSolarCannon', 'ultimatePhantomStrike', 'ultimateGodPunch', 'ultimateVoidCollapse'],
}
-function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0): string {
+// The Creator — exclusive moves (50% ultimate chance, 100% on crits, custom pool)
+const CREATOR_MOVES = ['bitcoinRain', 'lightningInvoice', 'satoshiStrike', 'hashRateOverload', 'blockchainSlam', 'creatorMode', 'morphStrike', 'divineIntervention']
+const CREATOR_ULTIMATES = ['ultimateGenesisBlock', 'ultimateTheHalving', 'ultimateFullNodeCrush', 'ultimateSatoshiVision']
+
+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) {
@@ -636,44 +658,9 @@ export async function createFightScene(config: FightSceneConfig) {
}))
}
- // Grotesque close-up: spawn exaggerated impact details during zoom-ins
- let grotesqueObjs: any[] = []
- function spawnGrotesqueDetails(fighter: any, scaleFactor: number) {
- destroyGrotesqueDetails()
- if (!fighter?.exists()) return
- const fx = fighter.pos.x, fy = fighter.pos.y
- const s = Math.max(1, scaleFactor)
- // Impact lines radiating from fighter
- for (let i = 0; i < 6; i++) {
- const angle = (i / 6) * Math.PI * 2 + Math.random() * 0.3
- const len = 30 + Math.random() * 40
- const line = k.add([
- k.rect(3 * s, len * s),
- k.pos(fx + Math.cos(angle) * 20, fy - 30 + Math.sin(angle) * 20),
- k.color(safeColor(k, i % 2 === 0 ? '#ffffff' : '#ff2d2d')),
- k.opacity(0.7),
- k.z(25),
- k.rotate(angle * (180 / Math.PI) + 90),
- k.anchor('center'),
- ])
- grotesqueObjs.push(line)
- }
- // Speed/impact dots
- for (let i = 0; i < 4; i++) {
- const dot = k.add([
- k.circle(4 + Math.random() * 6),
- k.pos(fx + (Math.random() - 0.5) * 60, fy - 30 + (Math.random() - 0.5) * 50),
- k.color(safeColor(k, '#ffe14d')),
- k.opacity(0.8),
- k.z(25),
- ])
- grotesqueObjs.push(dot)
- }
- }
- function destroyGrotesqueDetails() {
- for (const o of grotesqueObjs) { if (o.exists()) o.destroy() }
- grotesqueObjs = []
- }
+ // Grotesque close-up overlays removed — noops retained for call-site compatibility
+ function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
+ function destroyGrotesqueDetails() {}
// Arena-specific background decoration
function drawArenaDecor() {
const a = arena
@@ -5718,6 +5705,453 @@ export async function createFightScene(config: FightSceneConfig) {
// Tier-gated pools (mapping is at module level for pickChoreography access)
+ // ═══════════════════════════════════════════════════════
+ // THE CREATOR — Exclusive choreographies (bitcoin-themed)
+ // ═══════════════════════════════════════════════════════
+
+ async function bitcoinRain(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ atk.play('special')
+ sfxSpecial()
+ await k.wait(0.2)
+ // Rain golden ₿ coins from above
+ const coinCount = isCritical ? 30 : 15
+ for (let i = 0; i < coinCount; i++) {
+ const cx = def.pos.x + (Math.random() - 0.5) * 120
+ const startY = def.pos.y - 200 - Math.random() * 100
+ const coin = k.add([
+ k.circle(isCritical ? 5 : 3),
+ k.pos(cx, startY),
+ k.color(safeColor(k, Math.random() > 0.3 ? '#ffd700' : '#ffee88')),
+ k.opacity(0.9),
+ k.z(18),
+ ])
+ coin.onUpdate(() => {
+ coin.pos.y += 600 * k.dt()
+ if (coin.pos.y > def.pos.y + 10) {
+ coin.destroy()
+ }
+ })
+ if (i % 4 === 0) sfxCoin()
+ await k.wait(0.03)
+ }
+ await k.wait(0.15)
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 18 : 8)
+ spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 10, '#ffd700')
+ if (isCritical) { screenFlash('#ffd700', 0.12); sfxExplosion() }
+ const push = dir * (isCritical ? 80 : 35)
+ k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.4)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function lightningInvoice(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ atk.play('special')
+ sfxZap()
+ await k.wait(0.15)
+ // Jagged lightning bolt from top of screen to defender
+ const segments = 8
+ const topY = -50
+ const botY = def.pos.y - 20
+ for (let i = 0; i < segments; i++) {
+ const t = i / segments
+ const bx = def.pos.x + (Math.random() - 0.5) * 40
+ const by2 = topY + t * (botY - topY)
+ const bolt = k.add([
+ k.rect(3, Math.abs(botY - topY) / segments + 5),
+ k.pos(bx, by2),
+ k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ffffff')),
+ k.opacity(0.95),
+ k.z(20),
+ ])
+ setTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300)
+ }
+ // ₿ symbol flash at impact
+ const btcFlash = k.add([
+ k.circle(isCritical ? 20 : 12),
+ k.pos(def.pos.x, def.pos.y - 30),
+ k.color(safeColor(k, '#ffd700')),
+ k.opacity(0.8),
+ k.z(22),
+ ])
+ k.tween(btcFlash.opacity, 0, 0.4, (v: number) => { btcFlash.opacity = v })
+ .then(() => { if (btcFlash.exists()) btcFlash.destroy() })
+ await k.wait(0.1)
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 20 : 10)
+ sfxExplosion()
+ spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ffd700')
+ if (isCritical) { screenFlash('#ffd700'); glitchRGB() }
+ const push = dir * (isCritical ? 90 : 40)
+ k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.4)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function satoshiStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ atk.play('special')
+ sfxSpecial()
+ await k.wait(0.1)
+ // Rapid-fire gold particle stream
+ const shotCount = isCritical ? 25 : 15
+ for (let i = 0; i < shotCount; i++) {
+ const p = k.add([
+ k.circle(2),
+ k.pos(atk.pos.x + dir * 30, atk.pos.y - 40 + (Math.random() - 0.5) * 15),
+ k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')),
+ k.opacity(0.9),
+ k.z(16),
+ ])
+ const speed = 800 + Math.random() * 400
+ p.onUpdate(() => {
+ p.pos.x += dir * speed * k.dt()
+ p.opacity -= 1.5 * k.dt()
+ if (p.opacity <= 0 || Math.abs(p.pos.x - atk.pos.x) > 500) p.destroy()
+ })
+ if (i % 3 === 0) sfxCoin()
+ if (i % 5 === 0) {
+ def.play('hit')
+ k.shake(2)
+ spawnSparks(def.pos.x, def.pos.y - 25 - Math.random() * 20, 3, '#ffd700')
+ }
+ await k.wait(0.025)
+ }
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 12 : 5)
+ if (isCritical) { screenFlash('#ffd700', 0.08); sfxCritical() }
+ const push = dir * (isCritical ? 70 : 30)
+ k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.3)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function hashRateOverload(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ atk.play('special')
+ sfxSpecial()
+ // Screen fills with scrolling hash text (simulated with falling green/gold rects)
+ const hashCount = isCritical ? 40 : 20
+ for (let i = 0; i < hashCount; i++) {
+ const hx = Math.random() * k.width()
+ const hy = Math.random() * k.height()
+ const h = k.add([
+ k.rect(8 + Math.random() * 12, 2),
+ k.pos(hx, hy),
+ k.color(safeColor(k, Math.random() > 0.5 ? '#00ff41' : '#ffd700')),
+ k.opacity(0.6),
+ k.z(25),
+ ])
+ h.onUpdate(() => {
+ h.pos.y += 200 * k.dt()
+ h.opacity -= 1.5 * k.dt()
+ if (h.opacity <= 0) h.destroy()
+ })
+ }
+ await k.wait(0.5)
+ // Explosion
+ screenFlash(isCritical ? '#ffd700' : '#00ff41', 0.15)
+ sfxExplosion()
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 25 : 12)
+ spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 12, '#ffd700')
+ if (isCritical) glitchRGB()
+ const push = dir * (isCritical ? 100 : 50)
+ k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.4)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function blockchainSlam(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ const contactX = origDX - dir * 60
+ sfxZoomWhoosh()
+ await k.tween(atk.pos.x, contactX, 0.12, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad)
+ atk.play('attack')
+ // Chain of blocks swinging as weapon
+ const blocks: any[] = []
+ const blockCount = isCritical ? 6 : 4
+ for (let i = 0; i < blockCount; i++) {
+ const bk = k.add([
+ k.rect(10, 10),
+ k.pos(atk.pos.x + dir * (i * 14), atk.pos.y - 40),
+ k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#c8a000')),
+ k.opacity(0.9),
+ k.z(17),
+ ])
+ blocks.push(bk)
+ }
+ // Swing chain toward defender
+ for (let i = 0; i < blockCount; i++) {
+ await k.tween(blocks[i].pos.x, def.pos.x + (i - blockCount / 2) * 5, 0.08, (v: number) => { blocks[i].pos.x = v }, k.easings.easeOutQuad)
+ await k.tween(blocks[i].pos.y, def.pos.y - 30, 0.04, (v: number) => { blocks[i].pos.y = v })
+ }
+ sfxPunch()
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 15 : 7)
+ spawnSparks(def.pos.x, def.pos.y - 30, 10, '#ffd700')
+ if (isCritical) { screenFlash('#ffd700', 0.1); sfxCritical() }
+ blocks.forEach(b => b.destroy())
+ const push = dir * (isCritical ? 80 : 35)
+ k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.35)
+ await Promise.all([
+ k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
+ k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
+ ])
+ atk.play('idle')
+ }
+
+ async function creatorMode(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ atk.play('special')
+ sfxSpecial()
+ // Typing effect — rapid small flashes from attacker
+ for (let i = 0; i < 8; i++) {
+ const sp = k.add([
+ k.circle(2),
+ k.pos(atk.pos.x + dir * 20, atk.pos.y - 45 + (Math.random() - 0.5) * 10),
+ k.color(safeColor(k, i % 2 === 0 ? '#00ff41' : '#ffd700')),
+ k.opacity(0.8),
+ k.z(16),
+ ])
+ setTimeout(() => { if (sp.exists()) sp.destroy() }, 200)
+ await k.wait(0.05)
+ }
+ // Reality warp — defender's sprite distorts
+ if (def.exists()) {
+ const origScaleX = def.scale?.x ?? 1
+ const origScaleY = def.scale?.y ?? 1
+ for (let w = 0; w < 4; w++) {
+ def.scaleTo(origScaleX * (1 + (Math.random() - 0.5) * 0.5), origScaleY * (1 + (Math.random() - 0.5) * 0.5))
+ await k.wait(0.06)
+ }
+ def.scaleTo(origScaleX, origScaleY)
+ }
+ sfxExplosion()
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 18 : 8)
+ spawnSparks(def.pos.x, def.pos.y - 30, 12, '#00ff41')
+ if (isCritical) { screenFlash('#00ff41'); glitchRGB() }
+ const push = dir * (isCritical ? 70 : 30)
+ k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.4)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function morphStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ // Flash golden — the morph is handled by the morph system
+ // This move itself is a quick dash-hit with gold sparks
+ screenFlash('#ffd700', 0.08)
+ sfxZoomWhoosh()
+ const contactX = origDX - dir * 45
+ await k.tween(atk.pos.x, contactX, 0.1, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad)
+ atk.play('attack')
+ sfxPunch()
+ await k.wait(0.08)
+ def.play(isCritical ? 'knockback' : 'hit')
+ k.shake(isCritical ? 14 : 6)
+ spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 15 : 8, '#ffd700')
+ if (isCritical) { sfxCritical(); screenFlash('#ffd700') }
+ const push = dir * (isCritical ? 80 : 35)
+ k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.3)
+ await Promise.all([
+ k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
+ k.tween(def.pos.x, origDX, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
+ ])
+ atk.play('idle')
+ }
+
+ async function divineIntervention(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
+ // Ascend off screen
+ sfxZoomWhoosh()
+ atk.play('win')
+ await k.tween(atk.pos.y, atk.pos.y - 300, 0.25, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad)
+ atk.opacity = 0
+ // Golden glow expanding where they were
+ const glowX = origAX
+ const glow = k.add([
+ k.circle(5),
+ k.pos(glowX, GROUND_Y - 40),
+ k.color(safeColor(k, '#ffd700')),
+ k.opacity(0.6),
+ k.z(19),
+ ])
+ await k.tween(5, 60, 0.3, (v: number) => { glow.radius = v; glow.opacity = 0.6 - v / 150 })
+ glow.destroy()
+ // Slam down onto defender from above
+ atk.opacity = 1
+ atk.pos.x = def.pos.x
+ atk.pos.y = -100
+ atk.play('kick')
+ sfxZoomWhoosh()
+ await k.tween(atk.pos.y, GROUND_Y, 0.15, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad)
+ // Massive impact
+ sfxExplosion()
+ k.shake(isCritical ? 30 : 18)
+ screenFlash('#ffd700', 0.15)
+ spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 15, '#ffd700')
+ spawnShockwave(def.pos.x, GROUND_Y, '#ffd700')
+ if (isCritical) glitchRGB()
+ def.play('knockback')
+ const push = dir * (isCritical ? 120 : 60)
+ k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.5)
+ await Promise.all([
+ k.tween(atk.pos.x, origAX, 0.25, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
+ k.tween(atk.pos.y, GROUND_Y, 0.01, (v: number) => { atk.pos.y = v }),
+ k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
+ ])
+ atk.play('idle')
+ }
+
+ // ── Creator Ultimates ──
+
+ async function ultimateGenesisBlock(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
+ // Screen goes dark, massive genesis block materializes and detonates
+ atk.play('special')
+ sfxSpecial()
+ const darkness = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(30)])
+ await k.tween(darkness.opacity, 0.7, 0.3, (v: number) => { darkness.opacity = v })
+ // Genesis block appears at center
+ const block = k.add([
+ k.rect(60, 60),
+ k.pos(k.width() / 2 - 30, k.height() / 2 - 30),
+ k.color(safeColor(k, '#ffd700')),
+ k.opacity(0),
+ k.z(35),
+ ])
+ await k.tween(block.opacity, 1, 0.3, (v: number) => { block.opacity = v })
+ await k.wait(0.3)
+ // Detonate!
+ sfxExplosion()
+ block.destroy()
+ darkness.destroy()
+ screenFlash('#ffd700', 0.2)
+ k.shake(35)
+ glitchRGB()
+ spawnShockwave(k.width() / 2, k.height() / 2, '#ffd700')
+ spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700')
+ def.play('knockback')
+ const push = dir * 120
+ k.tween(def.pos.x, origDX + push, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.6)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function ultimateTheHalving(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
+ // Screen splits horizontally with a golden line
+ atk.play('special')
+ sfxZap()
+ const line = k.add([
+ k.rect(k.width(), 3),
+ k.pos(0, k.height() / 2),
+ k.color(safeColor(k, '#ffd700')),
+ k.opacity(0),
+ k.z(32),
+ ])
+ await k.tween(line.opacity, 1, 0.15, (v: number) => { line.opacity = v })
+ screenFlash('#ffd700', 0.1)
+ glitchRGB()
+ sfxExplosion()
+ k.shake(25)
+ // Briefly scale defender to half
+ if (def.exists()) {
+ const origSY = def.scale?.y ?? 1
+ def.scaleTo(def.scale?.x ?? 1, origSY * 0.5)
+ await k.wait(0.2)
+ def.scaleTo(def.scale?.x ?? 1, origSY)
+ }
+ line.destroy()
+ def.play('knockback')
+ spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ffd700')
+ const push = dir * 100
+ k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.5)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function ultimateFullNodeCrush(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
+ // Giant server rack drops from above
+ atk.play('special')
+ sfxSpecial()
+ await k.wait(0.3)
+ const rack = k.add([
+ k.rect(40, 80),
+ k.pos(def.pos.x - 20, -100),
+ k.color(safeColor(k, '#333333')),
+ k.opacity(0.9),
+ k.z(28),
+ ])
+ // LEDs on the rack
+ for (let led = 0; led < 6; led++) {
+ k.add([
+ k.circle(1),
+ k.pos(rack.pos.x + 5 + led * 5, rack.pos.y + 10),
+ k.color(safeColor(k, led % 2 === 0 ? '#00ff41' : '#ff0000')),
+ k.opacity(0.8),
+ k.z(29),
+ ])
+ }
+ sfxZoomWhoosh()
+ await k.tween(rack.pos.y, def.pos.y - 80, 0.2, (v: number) => { rack.pos.y = v }, k.easings.easeInQuad)
+ // Impact
+ sfxExplosion()
+ k.shake(30)
+ screenFlash('#ffffff', 0.15)
+ spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffd700')
+ spawnShockwave(def.pos.x, GROUND_Y, '#333333')
+ rack.destroy()
+ def.play('knockback')
+ const push = dir * 100
+ k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.6)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
+ async function ultimateSatoshiVision(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
+ // Everything turns gold — defender trapped in bitcoin block
+ atk.play('special')
+ sfxSpecial()
+ // Gold overlay
+ const overlay = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(30)])
+ await k.tween(overlay.opacity, 0.4, 0.3, (v: number) => { overlay.opacity = v })
+ // Wireframe box around defender
+ const bx = def.pos.x - 30
+ const by2 = def.pos.y - 70
+ const bw = 60
+ const bh2 = 80
+ const top = k.add([k.rect(bw, 2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
+ const bot = k.add([k.rect(bw, 2), k.pos(bx, by2 + bh2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
+ const left = k.add([k.rect(2, bh2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
+ const right = k.add([k.rect(2, bh2), k.pos(bx + bw, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
+ await k.wait(0.3)
+ // Crush — shrink box
+ sfxZoomWhoosh()
+ await Promise.all([
+ k.tween(bx, def.pos.x - 5, 0.3, (v: number) => { left.pos.x = v; top.pos.x = v; bot.pos.x = v }),
+ k.tween(bx + bw, def.pos.x + 5, 0.3, (v: number) => { right.pos.x = v }),
+ ])
+ sfxExplosion()
+ k.shake(30)
+ screenFlash('#ffffff', 0.15)
+ glitchRGB();
+ [top, bot, left, right].forEach(b => b.destroy())
+ overlay.destroy()
+ spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700')
+ def.play('knockback')
+ const push = dir * 110
+ k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
+ await k.wait(0.6)
+ atk.play('idle')
+ await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
+ }
+
const choreographyMap: Record = {
dashPunch, aerialSlam, flyingKick, dashThrough, uppercut,
multiHit, projectile, jetpackDive, gunBurst, groundPound,
@@ -5821,6 +6255,11 @@ export async function createFightScene(config: FightSceneConfig) {
// Tier 5+
ultimateArmageddon, ultimateOmegaBeam, ultimateBeyondTheScreen, ultimateMatrixDodge, ultimateWorldEnder,
ultimateAbsoluteZero, ultimateSolarCannon, ultimatePhantomStrike, ultimateGodPunch, ultimateVoidCollapse,
+ // --- THE CREATOR: Bitcoin-themed moves ---
+ bitcoinRain, lightningInvoice, satoshiStrike, hashRateOverload, blockchainSlam, creatorMode,
+ morphStrike, divineIntervention,
+ // --- THE CREATOR: Exclusive ultimates ---
+ ultimateGenesisBlock, ultimateTheHalving, ultimateFullNodeCrush, ultimateSatoshiVision,
}
// === MORPH / COSTUME SYSTEM ===
@@ -6042,6 +6481,86 @@ export async function createFightScene(config: FightSceneConfig) {
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',
+ ]
+
+ function applyCreatorOmniMorph(fighter: any, _side: 'a' | 'b'): string {
+ const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)]
+
+ // 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 (4 orbiting)
+ for (let i = 0; i < 4; i++) {
+ const btc = k.add([
+ k.text('₿', { size: 10 }),
+ k.pos(fighter.pos.x, fighter.pos.y - 20),
+ k.color(safeColor(k, '#ffd700')),
+ k.opacity(0.7),
+ k.z(fighter.z + 2),
+ k.anchor('center'),
+ ])
+ const baseAngle = (i / 4) * Math.PI * 2
+ const radius = 40
+ btc.onUpdate(() => {
+ const a = baseAngle + k.time() * 3
+ btc.pos.x = fighter.pos.x + Math.cos(a) * radius
+ btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.5
+ btc.opacity = 0.7 + Math.sin(k.time() * 8 + i) * 0.3
+ })
+ activeMorphs.push({ obj: btc, type: 'omni' })
+ }
+
+ // Apply archetype-specific color tint based on pick category
+ const tints: Record = {
+ 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
@@ -6332,6 +6851,49 @@ export async function createFightScene(config: FightSceneConfig) {
return lines
}
+ // Talking mouth animation — a small rectangle that opens/closes near the fighter's face
+ const talkingAnims: Record }> = {}
+
+ function startTalking(side: 'a' | 'b') {
+ stopTalking(side) // cleanup any existing
+ const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
+ 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 = setInterval(() => {
+ 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
+ clearInterval(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([
@@ -6706,6 +7268,9 @@ export async function createFightScene(config: FightSceneConfig) {
showSpeechBubble(side, text, duration)
},
+ startTalking(side: 'a' | 'b') { startTalking(side) },
+ stopTalking(side: 'a' | 'b') { stopTalking(side) },
+
async playEntrance() {
const fA = k.get('fighterA')[0]
const fB = k.get('fighterB')[0]
@@ -7119,16 +7684,77 @@ export async function createFightScene(config: FightSceneConfig) {
},
]
+ // 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 golden particles around creator
+ for (let i = 0; i < 6; i++) {
+ const p = k.add([
+ k.circle(2), k.pos(homeX, GROUND_Y - 30),
+ k.color(safeColor(k, '#ffd700')), k.opacity(0.5), k.z(11), k.anchor('center'),
+ ])
+ p.onUpdate(() => {
+ const a = k.time() * (2 + i * 0.4) + i
+ p.pos.x = fighter.pos.x + Math.cos(a) * (20 + i * 5)
+ p.pos.y = fighter.pos.y - 25 + Math.sin(a) * (15 + i * 3)
+ p.opacity = 0.3 + Math.sin(k.time() * 6 + i) * 0.3
+ })
+ }
+ announceHype('THE CREATOR HAS ENTERED THE ARENA!')
+ rainDrops.forEach(r => { if (r.exists()) r.destroy() })
+ }
+
// Pick random entrance for each bot (different ones)
const idxA = Math.floor(Math.random() * entrances.length)
let idxB = Math.floor(Math.random() * entrances.length)
while (idxB === idxA && entrances.length > 1) idxB = Math.floor(Math.random() * entrances.length)
- // Play entrances with slight stagger
+ // Play entrances with slight stagger — creator always gets special entrance
announceDeepIntro()
- await entrances[idxA](fA, HOME_A, true)
+ if (botA.archetype === 'the_creator') {
+ await creatorEntrance(fA, HOME_A, true)
+ } else {
+ await entrances[idxA](fA, HOME_A, true)
+ }
await k.wait(0.3)
- await entrances[idxB](fB, HOME_B, false)
+ 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
@@ -7280,22 +7906,56 @@ export async function createFightScene(config: FightSceneConfig) {
}
const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier
- const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier)
+ 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 shouldMorph = isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4)
+ 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) | null = null
if (shouldMorph) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0]
if (attacker) {
- 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)
+ if (isCreatorFighter) {
+ // Omni-morph: creator transforms into a random archetype
+ const morphedTo = applyCreatorOmniMorph(attacker, attackerSide)
+ screenFlash('#ffd700', 0.15)
+ sfxZoomWhoosh()
+ sfxSpecial()
+ announceHype(`THE CREATOR — ${morphedTo.toUpperCase()} FORM!`)
+ 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)
+ 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
@@ -7421,6 +8081,69 @@ export async function createFightScene(config: FightSceneConfig) {
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() })
+ announceCool('THE CREATOR HAS BLESSED THIS FIGHT!')
+ }
+ 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) {
diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts
index 26441f6..6bb6e22 100644
--- a/frontend/src/game/sounds.ts
+++ b/frontend/src/game/sounds.ts
@@ -104,6 +104,7 @@ const voiceProfiles: Record = {
robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone
screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy
smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator
+ question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer
// 24 new profiles
whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper
boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming
@@ -204,6 +205,12 @@ function loadVoices() {
voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0]
voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)]
voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
+ // Question reader: highest quality voice available — tries premium/neural first
+ voiceProfiles.question_reader.voice = preferPremium([
+ /evan.*premium/i, /samantha.*enhanced/i, /daniel.*premium/i,
+ /google.*us.*english/i, /google.*uk.*english/i,
+ /evan/i, /aaron/i, /daniel/i, /james/i,
+ ]) || anyVoices[0]
// Assign voices to new profiles — spread across available voices for max variety
const vLen = anyVoices.length
const pick = (i: number) => anyVoices[i % vLen]
@@ -340,6 +347,52 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
speechSynthesis.speak(utter)
}
+/** Like speakAsync but with a forced minimum rate */
+function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise {
+ return new Promise((resolve) => {
+ if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
+ if (!voicesLoaded) loadVoices()
+ if (speechSynthesis.paused) speechSynthesis.resume()
+ const profile = voiceProfiles[profileName] || voiceProfiles.announcer
+ const utter = new SpeechSynthesisUtterance(text)
+ if (profile.voice) utter.voice = profile.voice
+ utter.pitch = profile.pitch
+ utter.rate = Math.max(minRate, profile.rate)
+ utter.volume = profile.volume * VOICE_VOLUME_SCALE
+ _speechQueueDepth++
+ const safetyTimeout = setTimeout(resolve, 15_000)
+ utter.onend = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
+ utter.onerror = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
+ speechSynthesis.speak(utter)
+ })
+}
+
+/** Like speak() but returns a promise that resolves when the utterance finishes */
+function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise {
+ return new Promise((resolve) => {
+ if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
+ if (!voicesLoaded) loadVoices()
+ if (speechSynthesis.paused) speechSynthesis.resume()
+ if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
+ const profile = voiceProfiles[profileName] || voiceProfiles.announcer
+ const utter = new SpeechSynthesisUtterance(text)
+ if (profile.voice) utter.voice = profile.voice
+ utter.pitch = profile.pitch
+ utter.rate = profile.rate
+ utter.volume = profile.volume * VOICE_VOLUME_SCALE
+ _speechQueueDepth++
+ utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
+ utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
+ // Safety timeout — never block forever (max 15s for any utterance)
+ const safetyTimeout = setTimeout(resolve, 15_000)
+ const origOnEnd = utter.onend
+ utter.onend = (ev) => { clearTimeout(safetyTimeout); (origOnEnd as any)(ev) }
+ const origOnError = utter.onerror
+ utter.onerror = (ev) => { clearTimeout(safetyTimeout); (origOnError as any)(ev) }
+ speechSynthesis.speak(utter)
+ })
+}
+
export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
@@ -512,6 +565,52 @@ export function announceRoundHype() {
voices[Math.floor(Math.random() * voices.length)](line)
}
+// === TTS: Questions, Answers & Narration ===
+// Distinct voice roles so players can always tell who's speaking.
+// Question = clear smooth reader, Answers = unique per-bot (intelligible subset),
+// Narration = dramatic judge.
+
+// Only voices with rate >= 0.6 and rate <= 1.3, and pitch between 0.5-1.5
+// so every voice is actually understandable by humans
+const INTELLIGIBLE_VOICES = [
+ 'smooth', 'robot', 'drill', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v',
+ 'posh', 'aussie', 'scottish', 'french', 'texan', 'wrestler_v', 'ai_core',
+ 'android_v', 'siri', 'professor', 'npc', 'news', 'crotchety', 'mech',
+ 'wizard_v', 'echo_v', 'boss_taunt', 'angel', 'punk', 'valley',
+]
+
+function hashName(name: string): number {
+ let h = 0
+ for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
+ return Math.abs(h)
+}
+function botVoiceKey(name: string): string {
+ return INTELLIGIBLE_VOICES[hashName(name) % INTELLIGIBLE_VOICES.length]
+}
+
+/** Speak the challenge question — premium voice, clear and brisk.
+ * Does NOT cancel previous speech so intro/hype lines finish naturally.
+ * Returns promise that resolves when the question finishes reading. */
+export function speakQuestion(text: string): Promise {
+ const trimmed = text.length > 140 ? text.slice(0, 137) + '...' : text
+ return speakAsync(trimmed, 'question_reader', false)
+}
+
+/** Speak a bot's answer in their assigned character voice at brisk pace.
+ * Returns promise that resolves when finished. */
+export function speakAnswer(botName: string, answer: string): Promise {
+ const trimmed = answer.length > 80 ? answer.slice(0, 77) + '...' : answer
+ // Use speakAsync with a rate boost: override profile rate to min 1.1
+ return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15)
+}
+
+/** Speak the judge's narration — brisk sportscaster energy.
+ * Returns promise that resolves when finished. */
+export function speakNarration(text: string): Promise {
+ const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text
+ return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
+}
+
// === FANFARES (8-bit melodic announcements) ===
export function fanfareRound(roundNum: number) {
diff --git a/frontend/src/game/sprites/archetypes/index.ts b/frontend/src/game/sprites/archetypes/index.ts
index caf5506..29a9366 100644
--- a/frontend/src/game/sprites/archetypes/index.ts
+++ b/frontend/src/game/sprites/archetypes/index.ts
@@ -34,6 +34,8 @@ import { chef, firefighter, astronautArch, clown, detective, nurse, lumberjack,
import { elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster } from './batch_animals2'
// Batch 8: Silly & Objects
import { sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan } from './batch_silly'
+// The Creator (game founder — unique god-tier character)
+import { theCreator } from './the_creator'
export const archetypes: Archetype[] = [
// Original 6
@@ -54,6 +56,8 @@ export const archetypes: Archetype[] = [
elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster,
// Batch 8: silly & objects
sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan,
+ // The Creator (god-tier, weight: 0 — never randomly rolled)
+ theCreator,
]
export function rollArchetype(roll: number): Archetype {
diff --git a/frontend/src/game/sprites/archetypes/the_creator.ts b/frontend/src/game/sprites/archetypes/the_creator.ts
new file mode 100644
index 0000000..1ae2f32
--- /dev/null
+++ b/frontend/src/game/sprites/archetypes/the_creator.ts
@@ -0,0 +1,313 @@
+import type { Archetype } from '../constants'
+
+export const theCreator: Archetype = {
+ name: 'the_creator',
+ weight: 0, // Never randomly rolled — only assigned via pubkey detection
+ canHaveVisor: false,
+ canHaveMohawk: false,
+ canHaveHorns: false,
+ drawFeatures: (p) => {
+ const { px, fill, pal, ox, oy, t, frame, tier, bounce,
+ idle, atk, kick, special, hit, knockback, ko, win,
+ cx, hOff, vBounce, globalY, koSlump,
+ bx, by, bw, bh, hx, hy, hw, hh,
+ armLx, armAttach, armW, armH, feetY } = p
+
+ // ═══════════════════════════════════════════
+ // THE CREATOR — God-tier unique character
+ // Guy Fawkes mask, hooded cloak, laptop,
+ // bitcoin chest emblem, orbiting gold particles,
+ // matrix code rain, tier-gated divine effects
+ // ═══════════════════════════════════════════
+
+ // Color constants
+ const MASK = '#f5f0e0' // warm ivory
+ const MASK_SHD = '#ddd5c0' // mask shadow
+ const HOOD = '#1a1a1a' // dark hood
+ const HOOD_LT = '#2a2a2a' // hood highlight
+ const GOLD = '#ffd700' // bitcoin gold
+ const GOLD_DK = '#c8a000' // dark gold
+ const GOLD_LT = '#ffee88' // light gold
+ const MATRIX = '#00ff41' // matrix green
+ const MATRIX_DK = '#00cc33' // darker green
+ const LAPTOP = '#333333' // laptop body
+ const SCREEN = '#00ff41' // laptop screen
+
+ // ── HOOD / CLOAK ──
+ // Dark hood drapes over head and extends down behind body
+ if (!ko) {
+ // Hood over head (extends above and around the head)
+ for (let iy = hy - 2; iy < hy + Math.floor(hh * 0.5); iy++) {
+ for (let ix = hx - 1; ix <= hx + hw; ix++) {
+ px(ix, iy, HOOD, ox, oy)
+ }
+ }
+ // Hood peak
+ px(cx + hOff, hy - 3, HOOD, ox, oy)
+ px(cx + hOff - 1, hy - 3, HOOD, ox, oy)
+ px(cx + hOff + 1, hy - 3, HOOD, ox, oy)
+ px(cx + hOff, hy - 4, HOOD, ox, oy)
+ // Hood highlight
+ px(hx, hy - 1, HOOD_LT, ox, oy)
+ px(hx + 1, hy - 2, HOOD_LT, ox, oy)
+
+ // Cloak behind body (darker layer extending from shoulders)
+ const cloakTop = by - 1
+ const cloakBot = feetY + globalY + 2
+ for (let iy = cloakTop; iy < cloakBot; iy++) {
+ const spread = Math.floor((iy - cloakTop) / 3)
+ px(bx - 2 - spread, iy, HOOD, ox, oy)
+ px(bx + bw + 1 + spread, iy, HOOD, ox, oy)
+ // Cloak back fill
+ if (iy > by + bh) {
+ for (let ix = bx - 1 - spread; ix <= bx + bw + spread; ix++) {
+ px(ix, iy, HOOD, ox, oy)
+ }
+ }
+ }
+
+ // Matrix code rain through cloak (animated green pixels)
+ const codeStreams = 3 + Math.floor(tier * 0.5)
+ for (let s = 0; s < codeStreams; s++) {
+ const streamX = bx - 1 + Math.floor((bw + 2) * s / codeStreams)
+ const phase = (t + s * 0.3) % 1
+ const streamLen = 3 + (s % 2)
+ for (let d = 0; d < streamLen; d++) {
+ const sy = cloakTop + Math.floor(phase * (cloakBot - cloakTop)) + d
+ if (sy < cloakBot && sy > by + bh - 2) {
+ const alpha = d === 0 ? MATRIX : d === 1 ? MATRIX_DK : '#006622'
+ px(streamX, sy, alpha, ox, oy)
+ }
+ }
+ }
+ }
+
+ // ── GUY FAWKES MASK ──
+ // Distinctive features: pointed chin, arched brows, upward mustache, eternal smile
+ if (!ko) {
+ const faceTop = hy + Math.floor(hh * 0.25)
+ const faceBot = hy + Math.floor(hh * 0.8)
+ const faceCx = cx + hOff
+
+ // Mask base — cream/ivory covering the face area
+ for (let iy = faceTop; iy < faceBot; iy++) {
+ const inset = iy === faceTop || iy === faceBot - 1 ? 1 : 0
+ for (let ix = hx + 2 + inset; ix < hx + hw - 2 - inset; ix++) {
+ px(ix, iy, MASK, ox, oy)
+ }
+ // Right-side shadow
+ px(hx + hw - 3, iy, MASK_SHD, ox, oy)
+ }
+
+ // Pointed chin extending below face
+ const chinY = faceBot
+ px(faceCx, chinY, MASK, ox, oy)
+ px(faceCx - 1, chinY, MASK_SHD, ox, oy)
+ px(faceCx + 1, chinY, MASK_SHD, ox, oy)
+ px(faceCx, chinY + 1, MASK_SHD, ox, oy)
+
+ // Eye slits — narrow golden glow
+ const eyeY = faceTop + Math.max(1, Math.floor((faceBot - faceTop) * 0.25))
+ const leX = faceCx - Math.floor(hw * 0.2)
+ const reX = faceCx + Math.floor(hw * 0.15)
+
+ // Arched eyebrows above eyes
+ px(leX - 1, eyeY - 1, '#333333', ox, oy)
+ px(leX, eyeY - 1, '#333333', ox, oy)
+ px(leX + 1, eyeY - 1, '#333333', ox, oy)
+ px(reX - 1, eyeY - 1, '#333333', ox, oy)
+ px(reX, eyeY - 1, '#333333', ox, oy)
+ px(reX + 1, eyeY - 1, '#333333', ox, oy)
+
+ // Eye slits with golden glow (pulsing)
+ const eyeColor = frame % 3 === 0 ? GOLD_LT : GOLD
+ px(leX, eyeY, eyeColor, ox, oy)
+ px(leX + 1, eyeY, eyeColor, ox, oy)
+ px(reX, eyeY, eyeColor, ox, oy)
+ px(reX + 1, eyeY, eyeColor, ox, oy)
+ // Eye glow halo (subtle)
+ if (special || atk) {
+ px(leX - 1, eyeY, GOLD_DK, ox, oy)
+ px(reX + 2, eyeY, GOLD_DK, ox, oy)
+ }
+
+ // Rosy cheeks
+ const cheekY = eyeY + 2
+ px(leX - 1, cheekY, '#cc8888', ox, oy)
+ px(reX + 2, cheekY, '#cc8888', ox, oy)
+
+ // Upward-curling mustache
+ const stacheY = cheekY + 1
+ px(faceCx - 1, stacheY, '#222222', ox, oy)
+ px(faceCx, stacheY, '#222222', ox, oy)
+ px(faceCx + 1, stacheY, '#222222', ox, oy)
+ // Curled tips upward
+ px(faceCx - 2, stacheY - 1, '#222222', ox, oy)
+ px(faceCx + 2, stacheY - 1, '#222222', ox, oy)
+
+ // Eternal smile
+ const smileY = stacheY + 1
+ px(faceCx - 2, smileY, '#333333', ox, oy)
+ px(faceCx - 1, smileY + 1, '#333333', ox, oy)
+ px(faceCx, smileY + 1, '#333333', ox, oy)
+ px(faceCx + 1, smileY + 1, '#333333', ox, oy)
+ px(faceCx + 2, smileY, '#333333', ox, oy)
+
+ // Thin goatee below chin
+ px(faceCx, chinY + 2, '#222222', ox, oy)
+ }
+
+ // ── BITCOIN SYMBOL ON CHEST ──
+ // Always visible — pulsing gold ₿
+ const btcColor = frame % 4 < 2 ? GOLD : GOLD_LT
+ const btcX = cx + hOff - 1
+ const btcY = by + Math.max(1, Math.floor(bh * 0.25))
+ // Vertical stroke of ₿
+ px(btcX, btcY, btcColor, ox, oy)
+ px(btcX, btcY + 1, btcColor, ox, oy)
+ px(btcX, btcY + 2, btcColor, ox, oy)
+ px(btcX, btcY + 3, btcColor, ox, oy)
+ // Right bumps of ₿
+ px(btcX + 1, btcY, btcColor, ox, oy)
+ px(btcX + 2, btcY + 1, btcColor, ox, oy)
+ px(btcX + 1, btcY + 2, btcColor, ox, oy)
+ px(btcX + 2, btcY + 3, btcColor, ox, oy)
+ px(btcX + 1, btcY + 4, btcColor, ox, oy)
+ // Top/bottom serifs
+ px(btcX, btcY - 1, GOLD_DK, ox, oy)
+ px(btcX, btcY + 4, GOLD_DK, ox, oy)
+
+ // ── LAPTOP IN LEFT HAND ──
+ // Replaces left arm visual — laptop body + green terminal screen
+ if (!ko && !knockback) {
+ const lapX = armLx - 2 + hOff
+ const lapY = armAttach + Math.floor(armH * 0.3) + globalY + vBounce
+ // Laptop body
+ fill(lapX, lapY, 6, 4, LAPTOP, ox, oy)
+ px(lapX, lapY, '#444444', ox, oy) // corner highlight
+ // Screen
+ const screenColor = (atk || special) ? '#ffffff' : SCREEN
+ fill(lapX + 1, lapY + 1, 4, 2, screenColor, ox, oy)
+ // Blinking cursor
+ if (frame % 2 === 0 && !atk && !special) {
+ px(lapX + 3, lapY + 2, '#000000', ox, oy)
+ }
+ // Magic emanating from laptop during attacks
+ if ((atk || special) && t > 0.2) {
+ const sparkDist = Math.floor(t * 6)
+ px(lapX + 3, lapY - sparkDist, GOLD, ox, oy)
+ px(lapX + 1, lapY - sparkDist - 1, MATRIX, ox, oy)
+ px(lapX + 5, lapY - sparkDist + 1, GOLD_LT, ox, oy)
+ if (sparkDist > 2) {
+ px(lapX + 2, lapY - sparkDist - 2, '#ffffff', ox, oy)
+ }
+ }
+ }
+
+ // ── FLOATING BITCOIN PARTICLES ──
+ // 4-6 gold dots orbiting the character always
+ if (!ko) {
+ const numParticles = 4 + Math.floor(tier * 0.5)
+ const orbitCx = cx + hOff
+ const orbitCy = by + Math.floor(bh / 2) + vBounce + globalY
+ for (let i = 0; i < numParticles; i++) {
+ const angle = t * Math.PI * 2 + i * (Math.PI * 2 / numParticles)
+ const radius = 8 + i * 2
+ const px2 = orbitCx + Math.round(Math.cos(angle) * radius)
+ const py2 = orbitCy + Math.round(Math.sin(angle) * radius * 0.6)
+ const colors = [GOLD, GOLD_LT, '#ff8c00', GOLD_DK, '#ffffff']
+ if ((frame + i) % 2 === 0) {
+ px(px2, py2, colors[i % colors.length], ox, oy)
+ }
+ }
+ }
+
+ // ── TIER 3+: LIGHTNING CROWN ──
+ if (tier >= 3 && !ko) {
+ const crownY = hy - 5 + vBounce + globalY
+ // 3 golden lightning points
+ for (let i = -1; i <= 1; i++) {
+ const crownX = cx + hOff + i * 3
+ const flash = (frame + i * 2) % 3 === 0 ? '#ffffff' : GOLD
+ px(crownX, crownY, flash, ox, oy)
+ px(crownX, crownY + 1, GOLD_DK, ox, oy)
+ }
+ // Lightning bolts connecting crown points
+ if (frame % 2 === 0) {
+ px(cx + hOff - 1, crownY, GOLD_LT, ox, oy)
+ px(cx + hOff + 1, crownY, GOLD_LT, ox, oy)
+ }
+ }
+
+ // ── TIER 4+: CODE WINGS ──
+ if (tier >= 4 && !ko && !knockback) {
+ const wingY = by + 1 + vBounce + globalY
+ const wingSpan = 4 + tier
+ // Left wing
+ for (let w = 0; w < wingSpan; w++) {
+ const wy = wingY + Math.floor(w * 0.4)
+ px(bx - 3 - w, wy, MATRIX, ox, oy)
+ if (w % 2 === 0) px(bx - 3 - w, wy - 1, MATRIX_DK, ox, oy)
+ }
+ // Right wing
+ for (let w = 0; w < wingSpan; w++) {
+ const wy = wingY + Math.floor(w * 0.4)
+ px(bx + bw + 2 + w, wy, MATRIX, ox, oy)
+ if (w % 2 === 0) px(bx + bw + 2 + w, wy - 1, MATRIX_DK, ox, oy)
+ }
+ // Wing glow particles
+ if (idle || win) {
+ const wgPhase = t * Math.PI * 2
+ px(bx - 4 - wingSpan, wingY + Math.round(Math.sin(wgPhase) * 2), GOLD, ox, oy)
+ px(bx + bw + 3 + wingSpan, wingY + Math.round(Math.cos(wgPhase) * 2), GOLD, ox, oy)
+ }
+ }
+
+ // ── TIER 5+: SATOSHI HALO ──
+ if (tier >= 5 && !ko) {
+ const haloY = hy - 7 + vBounce + globalY
+ const haloCx = cx + hOff
+ const haloR = 5
+ // 6 orbiting satoshi dots forming a halo ring
+ for (let i = 0; i < 6; i++) {
+ const angle = t * Math.PI * 1.5 + i * (Math.PI * 2 / 6)
+ const sx = haloCx + Math.round(Math.cos(angle) * haloR)
+ const sy = haloY + Math.round(Math.sin(angle) * 2)
+ const sColor = i % 2 === 0 ? GOLD : '#ffffff'
+ px(sx, sy, sColor, ox, oy)
+ }
+ }
+
+ // ── WIN POSE: GOLDEN EXPLOSION ──
+ if (win) {
+ const burstCx = cx + hOff
+ const burstCy = by + Math.floor(bh / 2) + vBounce + globalY
+ const burstR = 12 + tier * 2
+ for (let i = 0; i < 8; i++) {
+ const angle = t * Math.PI * 4 + i * (Math.PI / 4)
+ const dist = burstR * (0.5 + t * 0.5)
+ const bpx = burstCx + Math.round(Math.cos(angle) * dist)
+ const bpy = burstCy + Math.round(Math.sin(angle) * dist * 0.6)
+ const bColor = i % 3 === 0 ? GOLD : i % 3 === 1 ? GOLD_LT : '#ffffff'
+ if ((frame + i) % 2 === 0) px(bpx, bpy, bColor, ox, oy)
+ }
+ }
+
+ // ── KO: MASK CRACKS + FADING ──
+ if (ko) {
+ // Cracked mask fragments scattered
+ const faceCx = cx + hOff
+ const fY = hy + Math.floor(hh * 0.4)
+ px(faceCx - 1, fY + koSlump, MASK_SHD, ox, oy)
+ px(faceCx + 1, fY + 1 + koSlump, MASK, ox, oy)
+ px(faceCx, fY + 2 + koSlump, MASK_SHD, ox, oy)
+ // Fading golden eye
+ if (frame % 3 === 0) {
+ px(faceCx - 1, fY - 1 + koSlump, GOLD_DK, ox, oy)
+ }
+ // Laptop fallen on ground
+ fill(faceCx + 3, feetY + globalY - 1, 4, 2, LAPTOP, ox, oy)
+ px(faceCx + 4, feetY + globalY - 1, SCREEN, ox, oy)
+ }
+ },
+}
diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts
index 9bd35cc..92323b9 100644
--- a/frontend/src/game/sprites/index.ts
+++ b/frontend/src/game/sprites/index.ts
@@ -34,6 +34,19 @@ export function generateSpriteSheet(
const finalSecondary = customization?.secondaryColor || secondaryColor
const pal = makePal(finalPrimary, finalSecondary, tier)
+ // The Creator — forced golden palette + golden outline (totally unique)
+ if ((customization?.archetype || archetypeOverride) === 'the_creator') {
+ pal.body = 'hsl(43, 85%, 52%)'
+ pal.dark = 'hsl(43, 85%, 32%)'
+ pal.light = 'hsl(43, 95%, 67%)'
+ pal.acc = 'hsl(30, 90%, 55%)'
+ pal.accDark = 'hsl(30, 90%, 35%)'
+ pal.accLight = 'hsl(30, 95%, 70%)'
+ pal.out = '#c8a000' // golden outline instead of black
+ pal.skin = '#f5f0e0' // mask ivory
+ pal.skinDark = '#ddd5c0'
+ }
+
let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
diff --git a/server/src/app.ts b/server/src/app.ts
index 4066612..48f62e7 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -72,8 +72,17 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
return c.body(readFileSync(resolved))
}
- // Hashed assets — immutable cache
- app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
+ // Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload.
+ app.get('/assets/*', (c) => {
+ const resolved = join(publicDir, c.req.path)
+ if (!resolved.startsWith(publicDir) || !existsSync(resolved)) {
+ // Stale chunk hash from old deploy — tell the browser to reload
+ c.header('Content-Type', 'application/javascript')
+ c.header('Cache-Control', 'no-cache')
+ return c.body('window.location.reload();')
+ }
+ return serveFile(c, c.req.path, 'public, max-age=31536000, immutable')
+ })
// Root static files (favicon, manifest, robots, etc.)
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
diff --git a/server/src/engine/customization.ts b/server/src/engine/customization.ts
index 134396e..34109a5 100644
--- a/server/src/engine/customization.ts
+++ b/server/src/engine/customization.ts
@@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([
'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', 'human',
+ 'broom_man', 'human', 'the_creator',
])
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts
index 8d5a47d..e8a5a92 100644
--- a/server/src/engine/orchestrator.ts
+++ b/server/src/engine/orchestrator.ts
@@ -52,17 +52,32 @@ function emit(fightId: string, type: string, data: Record) {
// SSRF protection: block internal/private URLs
function isAllowedWebhookUrl(url: string): boolean {
try {
+ if (typeof url !== 'string' || url.length > 2048) return false
const parsed = new URL(url)
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
const hostname = parsed.hostname.toLowerCase()
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
+ // Localhost variants
+ if (hostname === 'localhost' || hostname === '::1') return false
+ if (hostname.startsWith('127.')) return false
+ // IPv6-mapped IPv4 localhost
+ if (hostname.startsWith('::ffff:127.')) return false
+ // Private IPv4 ranges
if (hostname.startsWith('10.')) return false
if (hostname.startsWith('192.168.')) return false
if (hostname.startsWith('172.')) {
const second = parseInt(hostname.split('.')[1])
if (second >= 16 && second <= 31) return false
}
- if (hostname === '169.254.169.254') return false
- if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
+ // Link-local and metadata
+ if (hostname.startsWith('169.254.')) return false
+ // IPv6 private (fc00::/7)
+ if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false
+ // IPv6 link-local (fe80::/10)
+ if (hostname.startsWith('fe80')) return false
+ // Reserved TLDs
+ if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false
+ // Null byte injection
+ if (hostname.includes('\0')) return false
return true
} catch {
return false
diff --git a/server/src/engine/payments.ts b/server/src/engine/payments.ts
index 7335017..bec62be 100644
--- a/server/src/engine/payments.ts
+++ b/server/src/engine/payments.ts
@@ -246,6 +246,26 @@ export async function payWinner(fightId: string, winnerId: string): Promise 0) {
+ const loserId = fightRows[0].botAId === winnerId ? fightRows[0].botBId : fightRows[0].botAId
+ const loserRows = await db.select({ name: schema.bots.name })
+ .from(schema.bots).where(eq(schema.bots.id, loserId)).limit(1)
+ loserName = loserRows[0]?.name || 'opponent'
+ }
+
+ const payoutDesc = `BOTFIGHTS VICTORY: ${winnerName} defeated ${loserName}! ${POT_SATS} sats prize`
+
// Look up winner's wallet connection
const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -262,13 +282,13 @@ export async function payWinner(fightId: string, winnerId: string): Promise {
+async function resolveAndCreateInvoice(lnAddress: string, amountSats: number, comment?: string): Promise {
const [name, domain] = lnAddress.split('@')
if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`)
@@ -443,6 +463,7 @@ async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): P
const callbackUrl = new URL(data.callback)
callbackUrl.searchParams.set('amount', String(amountMillisats))
+ if (comment) callbackUrl.searchParams.set('comment', comment)
const invoiceRes = await fetch(callbackUrl.toString())
if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`)
@@ -474,7 +495,7 @@ export async function refundEntry(paymentId: string): Promise {
if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000,
- description: 'Botfights ranked refund',
+ description: `BOTFIGHTS REFUND: ${ENTRY_FEE_SATS} sats ranked entry fee returned`,
})
const invoice = result.invoice as string
if (invoice) {
@@ -609,4 +630,56 @@ export async function recoverOrphanedPayments(): Promise {
}
}
+// In-memory set of payment IDs currently consumed by the ranked queue.
+// Safe because Node.js is single-threaded and the ranked queue is also in-memory.
+// On server restart, the queue is empty and recoverOrphanedPayments handles cleanup.
+const consumedPayments = new Set()
+
+/**
+ * Consume a confirmed entry payment for queue use.
+ * Returns true if the payment was successfully consumed, false if already used.
+ */
+export async function consumePaymentForQueue(paymentId: string, botId: string): Promise {
+ // Fast path: already consumed in this server lifetime
+ if (consumedPayments.has(paymentId)) return false
+
+ // Verify payment belongs to this bot, is confirmed, inbound, and not linked to a fight
+ const rows = await db.select({
+ id: schema.payments.id,
+ botId: schema.payments.botId,
+ status: schema.payments.status,
+ direction: schema.payments.direction,
+ fightId: schema.payments.fightId,
+ }).from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1)
+
+ if (rows.length === 0) return false
+ const p = rows[0]
+ if (p.botId !== botId) return false
+ if (p.status !== 'confirmed') return false
+ if (p.direction !== 'in') return false
+ if (p.fightId !== null) return false // already linked to a fight
+
+ consumedPayments.add(paymentId)
+ return true
+}
+
+/**
+ * Link consumed entry payments to the actual fight.
+ * Called after a ranked fight is created. Also removes from consumed set.
+ */
+export async function linkPaymentsToFight(fightId: string, paymentIds: string[]): Promise {
+ for (const pid of paymentIds) {
+ await db.update(schema.payments).set({ fightId })
+ .where(eq(schema.payments.id, pid))
+ consumedPayments.delete(pid)
+ }
+}
+
+/**
+ * Release a consumed payment back for refund when queue times out or bot leaves.
+ */
+export function releasePayment(paymentId: string): void {
+ consumedPayments.delete(paymentId)
+}
+
export { ENTRY_FEE_SATS, POT_SATS }
diff --git a/server/src/engine/ranked-queue.ts b/server/src/engine/ranked-queue.ts
index dccb146..3a53b61 100644
--- a/server/src/engine/ranked-queue.ts
+++ b/server/src/engine/ranked-queue.ts
@@ -1,7 +1,7 @@
import { db, schema } from '../db/index.js'
import { eq, sql } from 'drizzle-orm'
import { runFightAsync, isInFight } from './orchestrator.js'
-import { checkPaymentStatus, refundEntry } from './payments.js'
+import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js'
interface RankedQueueEntry {
botId: string
@@ -36,35 +36,49 @@ export function getRankedQueueStatus(): { waiting: number } {
* NEVER matches against mock bots.
*/
export async function joinRankedQueue(botId: string, paymentId: string): Promise {
- // Verify payment is confirmed
+ // Verify payment is confirmed (checks NWC if still pending)
const status = await checkPaymentStatus(paymentId)
if (status !== 'confirmed') {
throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`)
}
+ // Consume the payment — prevents double-spend
+ // Verifies payment is confirmed, unused, belongs to this bot
+ const consumed = await consumePaymentForQueue(paymentId, botId)
+ if (!consumed) {
+ throw new Error('Payment already used or does not belong to this bot.')
+ }
+
// Check cooldown
const cooldownUntil = rankedCooldowns.get(botId)
if (cooldownUntil && Date.now() < cooldownUntil) {
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
+ releasePayment(paymentId)
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
}
// Check if already in a fight
if (isInFight(botId)) {
+ releasePayment(paymentId)
throw new Error('Bot is already in a fight.')
}
// Load bot
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
- if (botRows.length === 0) throw new Error('Bot not found')
+ if (botRows.length === 0) {
+ releasePayment(paymentId)
+ throw new Error('Bot not found')
+ }
const bot = botRows[0]
if (!bot.isActive) {
+ releasePayment(paymentId)
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
// NEVER allow mock/classic bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
+ releasePayment(paymentId)
throw new Error('Practice bots cannot join ranked fights.')
}
@@ -75,6 +89,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
if (existing !== -1) {
const old = rankedQueue.splice(existing, 1)[0]
clearTimeout(old.timeoutHandle)
+ // Release the OLD payment (the new one is already consumed)
+ releasePayment(old.paymentId)
old.reject(new Error('Rejoined ranked queue'))
}
@@ -89,8 +105,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const opponent = rankedQueue.shift()!
clearTimeout(opponent.timeoutHandle)
- // Start ranked fight
+ // Start ranked fight and link both payments
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
+ await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
opponent.resolve(fightId)
return fightId
}
@@ -103,6 +120,7 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
const fightId = await runFightAsync(botId, mock.id, 'ranked')
+ await linkPaymentsToFight(fightId, [paymentId])
return fightId
}
}
@@ -113,7 +131,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const idx = rankedQueue.findIndex(e => e.botId === botId)
if (idx !== -1) {
rankedQueue.splice(idx, 1)
- // Refund the entry fee
+ // Release payment back to refundable state, then refund
+ releasePayment(paymentId)
try {
await refundEntry(paymentId)
reject(new Error('No ranked opponent found — entry fee refunded.'))
@@ -146,8 +165,9 @@ export async function leaveRankedQueue(botId: string): Promise {
const entry = rankedQueue.splice(idx, 1)[0]
clearTimeout(entry.timeoutHandle)
- // Refund entry fee
+ // Release payment, then refund
try {
+ releasePayment(entry.paymentId)
await refundEntry(entry.paymentId)
} catch (err) {
console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err)
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index b93315a..a9cf8bf 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -10,6 +10,9 @@ import { rateLimit } from '../middleware/rate-limit.js'
export const authRouter = new Hono()
+// The Creator — game founder pubkey (auto-assigns the_creator archetype)
+const CREATOR_PUBKEY = "da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39"
+
// Check name availability
authRouter.get("/check-name/:name", async (c) => {
const name = c.req.param("name")?.trim().toLowerCase()
@@ -58,6 +61,12 @@ authRouter.post('/login', async (c) => {
const bot = rows[0]
const isHuman = bot.webhookUrl === 'http://human.local/'
+ // Auto-upgrade: if creator logs in, ensure archetype is always the_creator
+ if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") {
+ await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id))
+ bot.archetype = "the_creator"
+ }
+
return c.json({
exists: true,
bot: {
@@ -154,7 +163,8 @@ authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
- const effectiveArchetype = custResult.data.archetype || archetype || 'standard'
+ const baseArchetype = custResult.data.archetype || archetype || 'standard'
+ const effectiveArchetype = pubkey === CREATOR_PUBKEY ? 'the_creator' : baseArchetype
const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null
await db.insert(schema.bots).values({
@@ -248,7 +258,7 @@ authRouter.post('/update', async (c) => {
const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
- if (!pubkey || typeof pubkey !== 'string') {
+ if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
@@ -264,6 +274,9 @@ authRouter.post('/update', async (c) => {
const updates: Record = {}
if (webhookUrl) {
+ if (typeof webhookUrl !== 'string' || webhookUrl.length > 2048) {
+ return c.json({ error: 'Invalid webhookUrl.' }, 400)
+ }
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
}
@@ -279,7 +292,12 @@ authRouter.post('/update', async (c) => {
updates.isActive = true
}
- if (profilePicUrl) updates.profilePicUrl = profilePicUrl
+ if (profilePicUrl) {
+ if (typeof profilePicUrl !== 'string' || profilePicUrl.length > 2048 || !/^https?:\/\//.test(profilePicUrl)) {
+ return c.json({ error: 'profilePicUrl must be a valid HTTP(S) URL.' }, 400)
+ }
+ updates.profilePicUrl = profilePicUrl
+ }
if (rawCustomization !== undefined) {
const custResult = validateCustomization(rawCustomization)
diff --git a/server/src/routes/payments.ts b/server/src/routes/payments.ts
index 70ebafe..b212767 100644
--- a/server/src/routes/payments.ts
+++ b/server/src/routes/payments.ts
@@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { encrypt, decrypt } from '../engine/crypto.js'
+import { rateLimit } from '../middleware/rate-limit.js'
export const paymentsRouter = new Hono()
@@ -82,11 +83,23 @@ paymentsRouter.get('/wallet-status', async (c) => {
return c.json({ connected: true, method: walletRows[0].method })
})
-// POST /create-invoice
-paymentsRouter.post('/create-invoice', async (c) => {
- const { botId } = await c.req.json<{ botId: string }>()
+// POST /create-invoice — rate limited: 10 per minute per IP
+paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
+ const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>()
if (!botId) return c.json({ error: 'Missing botId' }, 400)
+ // In production, verify bot ownership
+ if (process.env.NODE_ENV === 'production') {
+ if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
+ return c.json({ error: 'Missing pubkey' }, 400)
+ }
+ const botRows = await db.select({ publicKey: schema.bots.publicKey })
+ .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
+ if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
+ return c.json({ error: 'Unauthorized' }, 403)
+ }
+ }
+
try {
const result = await createEntryInvoice(botId)
return c.json(result)
@@ -96,9 +109,12 @@ paymentsRouter.post('/create-invoice', async (c) => {
}
})
-// GET /check/:paymentId
-paymentsRouter.get('/check/:paymentId', async (c) => {
+// GET /check/:paymentId — rate limited: 30 per minute per IP
+paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => {
const paymentId = c.req.param('paymentId')
+ if (!paymentId || paymentId.length > 24) {
+ return c.json({ error: 'Invalid paymentId' }, 400)
+ }
try {
const status = await checkPaymentStatus(paymentId)
return c.json({ status })
@@ -109,8 +125,12 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
})
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
-paymentsRouter.post('/confirm/:paymentId', async (c) => {
+paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
const paymentId = c.req.param('paymentId')
+ if (!paymentId || paymentId.length > 24) {
+ return c.json({ error: 'Invalid paymentId' }, 400)
+ }
+
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
const rows = await db.select().from(schema.payments)
@@ -119,16 +139,37 @@ paymentsRouter.post('/confirm/:paymentId', async (c) => {
const payment = rows[0]
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
+ if (payment.status !== 'pending') return c.json({ error: 'Payment is not pending' }, 400)
+
+ // Must be an inbound entry payment
+ if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
// Verify caller owns this payment's bot
- if (pubkey) {
+ if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
} else if (process.env.NODE_ENV === 'production') {
- return c.json({ error: 'Missing pubkey' }, 400)
+ return c.json({ error: 'Missing or invalid pubkey' }, 400)
+ }
+
+ // In production, also verify payment via NWC lookup (belt and suspenders)
+ if (process.env.NODE_ENV === 'production' && payment.invoice && payment.invoice !== 'dev_auto_confirmed') {
+ try {
+ const serverStatus = await checkPaymentStatus(paymentId)
+ if (serverStatus !== 'confirmed') {
+ return c.json({ error: 'Server could not verify payment. Try again.' }, 402)
+ }
+ // checkPaymentStatus already updated the DB
+ return c.json({ status: 'confirmed' })
+ } catch {
+ // NWC check failed — fall through to client-confirmed path with preimage
+ if (!preimage) {
+ return c.json({ error: 'Payment verification failed and no preimage provided.' }, 402)
+ }
+ }
}
await db.update(schema.payments).set({
diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts
index d11bd1f..a7408d9 100644
--- a/server/src/routes/queue.ts
+++ b/server/src/routes/queue.ts
@@ -3,6 +3,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
+import { rateLimit } from '../middleware/rate-limit.js'
export const queueRouter = new Hono()
@@ -48,15 +49,27 @@ queueRouter.get('/ranked-status', (c) => {
return c.json(getRankedQueueStatus())
})
-// Join ranked queue — requires confirmed payment
+// Join ranked queue — requires confirmed payment + bot ownership
queueRouter.post('/join-ranked/:botId', async (c) => {
const botId = c.req.param('botId')
- const { paymentId } = await c.req.json<{ paymentId: string }>()
+ const { paymentId, pubkey } = await c.req.json<{ paymentId: string; pubkey?: string }>()
if (!paymentId) {
return c.json({ error: 'Missing paymentId' }, 400)
}
+ // Verify bot ownership in production
+ if (process.env.NODE_ENV === 'production') {
+ if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
+ return c.json({ error: 'Missing pubkey' }, 400)
+ }
+ const botRows = await db.select({ publicKey: schema.bots.publicKey })
+ .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
+ if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
+ return c.json({ error: 'Unauthorized' }, 403)
+ }
+ }
+
try {
const fightId = await joinRankedQueue(botId, paymentId)
return c.json({ fightId, message: 'Ranked match found! Fight starting.' })