diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 8e20f9d..b750dce 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,6 +1,19 @@
diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue
index 1edec5d..7596dff 100644
--- a/frontend/src/components/FightViewer.vue
+++ b/frontend/src/components/FightViewer.vue
@@ -142,13 +142,26 @@ async function initScene() {
}
canvasRef.value = newCanvas
- scene = await createFightScene({
- canvas: newCanvas,
- botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses },
- botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses },
- arena: props.fight.arena,
- })
- sceneReady.value = true
+ // Wrap scene creation in try/catch + timeout so a mobile sprite loading failure
+ // or hang never blocks the overlay/voice/round flow
+ try {
+ const scenePromise = createFightScene({
+ canvas: newCanvas,
+ botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses },
+ botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses },
+ arena: props.fight.arena,
+ })
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 10_000))
+ const result = await Promise.race([scenePromise, timeout])
+ if (result) {
+ scene = result
+ sceneReady.value = true
+ } else {
+ console.warn('[FightViewer] Scene creation timed out (10s) — continuing without canvas')
+ }
+ } catch (err) {
+ console.error('[FightViewer] Scene creation failed — continuing without canvas:', err)
+ }
}
const challengeLabel = (type: string) => {
diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts
index 18ab764..ee5bbe2 100644
--- a/frontend/src/game/FightScene.ts
+++ b/frontend/src/game/FightScene.ts
@@ -284,20 +284,32 @@ export async function createFightScene(config: FightSceneConfig) {
const isHumanA = botA.archetype === 'human'
const isHumanB = botB.archetype === 'human'
- // Await sprite loading to prevent race condition where scene starts before sprites decode
+ // Helper: load sprite with a 5s timeout so mobile never hangs forever
+ async function loadSpriteWithTimeout(name: string, src: string, opts: any): Promise {
+ try {
+ await Promise.race([
+ k.loadSprite(name, src, opts),
+ new Promise((_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([
- k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
- k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
+ 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)
- k.loadSprite('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
+ 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)
- k.loadSprite('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
+ loadSpriteWithTimeout('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
// Judge sprite (red lobster referee)
@@ -308,7 +320,7 @@ export async function createFightScene(config: FightSceneConfig) {
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 k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
+ 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)
@@ -318,8 +330,8 @@ export async function createFightScene(config: FightSceneConfig) {
humanAnims[name] = { from: cfg.row * HUMAN_MAX_FRAMES, to: cfg.row * HUMAN_MAX_FRAMES + cfg.frames - 1, loop: true, speed: 6 }
}
await Promise.all([
- k.loadSprite('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
- k.loadSprite('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
+ 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()
@@ -8033,6 +8045,10 @@ export async function createFightScene(config: FightSceneConfig) {
},
async playRound(event: RoundEvent) {
+ const fA = k.get('fighterA')[0]
+ const fB = k.get('fighterB')[0]
+ 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)
@@ -8045,8 +8061,6 @@ export async function createFightScene(config: FightSceneConfig) {
const exchangeCount = 2 + Math.floor(Math.random() * 3)
// Hyperdetail scales with intensity
const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
- const fA = k.get('fighterA')[0]
- const fB = k.get('fighterB')[0]
const savedScaleAX = fA?.scale.x
const savedScaleAY = fA?.scale.y
const savedScaleBX = fB?.scale.x
diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts
index 7f9e5bd..a7b5d7f 100644
--- a/frontend/src/game/sounds.ts
+++ b/frontend/src/game/sounds.ts
@@ -347,55 +347,24 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
speechSynthesis.speak(utter)
}
-/** Like speakAsync but with a forced minimum rate + Chrome keepalive */
-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++
- let done = false
- const cleanup = () => {
- if (done) return
- done = true
- clearTimeout(safetyTimeout)
- clearInterval(keepalive)
- _speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
- resolve()
- }
- const safetyTimeout = setTimeout(cleanup, 15_000)
- // Chrome bug workaround: speechSynthesis silently kills utterances after ~15s.
- // Periodic pause/resume resets Chrome's internal timer.
- const keepalive = setInterval(() => {
- if (speechSynthesis.speaking && !speechSynthesis.paused) {
- speechSynthesis.pause()
- speechSynthesis.resume()
- }
- }, 10_000)
- utter.onend = cleanup
- utter.onerror = cleanup
- speechSynthesis.speak(utter)
- })
-}
+// iOS Safari breaks with pause()/resume() — only do keepalive on desktop Chrome
+const _isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent)
+const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent)
-/** Like speak() but returns a promise that resolves when the utterance finishes */
-function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise {
+/** Core async speak — resolves when speech finishes or bails fast if speech won't work */
+function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise {
return new Promise((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (!voicesLoaded) loadVoices()
+ // No voices available = speech won't work, bail immediately
+ if (!voicesLoaded) { resolve(); return }
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.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
let done = false
@@ -403,24 +372,44 @@ function speakAsync(text: string, profileName: string, cancelPrevious: boolean =
if (done) return
done = true
clearTimeout(safetyTimeout)
- clearInterval(keepalive)
+ clearTimeout(startupCheck)
+ if (keepalive) clearInterval(keepalive)
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
resolve()
}
- const safetyTimeout = setTimeout(cleanup, 15_000)
- // Chrome keepalive: periodic pause/resume prevents silent cutoff
- const keepalive = setInterval(() => {
- if (speechSynthesis.speaking && !speechSynthesis.paused) {
- speechSynthesis.pause()
- speechSynthesis.resume()
- }
- }, 10_000)
+ // Hard safety cap — never block longer than 8s
+ const safetyTimeout = setTimeout(cleanup, 8_000)
+ // Fast bail: if speech hasn't started within 500ms, it's not going to work (mobile/no gesture)
+ const startupCheck = setTimeout(() => {
+ if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup()
+ }, 500)
+ // Desktop Chrome keepalive: periodic pause/resume prevents Chrome's 15s silent cutoff
+ // Do NOT do this on iOS — it permanently kills speech on Safari
+ let keepalive: ReturnType | null = null
+ if (_isDesktopChrome) {
+ keepalive = setInterval(() => {
+ if (speechSynthesis.speaking && !speechSynthesis.paused) {
+ speechSynthesis.pause()
+ speechSynthesis.resume()
+ }
+ }, 10_000)
+ }
utter.onend = cleanup
utter.onerror = cleanup
speechSynthesis.speak(utter)
})
}
+/** Like speakAsync but with a forced minimum rate */
+function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise {
+ return _speakAsyncCore(text, profileName, minRate)
+}
+
+/** Like speak() but returns a promise that resolves when the utterance finishes */
+function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise {
+ return _speakAsyncCore(text, profileName, undefined, cancelPrevious)
+}
+
export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue
index ec4e2d5..42f5336 100644
--- a/frontend/src/pages/JoinBoutPage.vue
+++ b/frontend/src/pages/JoinBoutPage.vue
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import { useWallet } from '../composables/useWallet'
import SpritePreview from '../components/SpritePreview.vue'
+import { ensureAudioContext } from '../game/sounds'
import HumanPreview from '../components/HumanPreview.vue'
import WalletConnect from '../components/WalletConnect.vue'
@@ -384,6 +385,8 @@ async function practice() {
if (!bot.value || isJoiningPractice.value) return
isJoiningPractice.value = true
error.value = ''
+ // Unlock audio/speech NOW while we're in user gesture context (lost after async navigation)
+ ensureAudioContext()
try {
const res = await fetch(`/api/fights/practice/${bot.value.id}`, { method: 'POST' })
if (res.ok) {