diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue
index 19ccfd9..da62e76 100644
--- a/frontend/src/components/FightViewer.vue
+++ b/frontend/src/components/FightViewer.vue
@@ -322,6 +322,11 @@ async function replay() {
async function _doReplay() {
if (!props.fight.botA || !props.fight.botB) return
+ if (!props.fight.rounds || props.fight.rounds.length === 0) {
+ console.warn('[FightViewer] No rounds to play — skipping replay')
+ emit('replay-done')
+ return
+ }
displayHpA.value = 100
displayHpB.value = 100
logItems.value = []
diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts
index 97f19d6..d6779a4 100644
--- a/frontend/src/game/FightScene.ts
+++ b/frontend/src/game/FightScene.ts
@@ -9,7 +9,7 @@ import { pickChoreography } from './fight/choreography/picker'
export { pickChoreography }
import {
isPerfMode, perfParticles, isReducedMotion, haptic,
- GROUND_Y_RATIO, HOME_A_RATIO, HOME_B_RATIO, REFERENCE_HEIGHT, SPRITE_LOAD_TIMEOUT_MS,
+ GROUND_Y_RATIO, HOME_A_RATIO, HOME_B_RATIO, REFERENCE_HEIGHT, MOBILE_SCALE_CAP, SPRITE_LOAD_TIMEOUT_MS,
FIGHTER_BASE_SCALE, FIGHTER_TIER_SCALE, JUDGE_SCALE, HUMAN_COACH_SCALE, HUMAN_RUN_SCALE,
AMBIENT_PARTICLE_COUNT, SKY_GRADIENT_BANDS, GROUND_DEPTH_LINES, PERSPECTIVE_GRID_LINES, PILLAR_WIDTH,
SPEECH_BUBBLE_MAX_CHARS, SPEECH_BUBBLE_WRAP, SPEECH_BUBBLE_MAX_LINES, SPEECH_BUBBLE_FONT_SIZE, SPEECH_BUBBLE_DEFAULT_DURATION,
@@ -193,7 +193,7 @@ export async function createFightScene(config: FightSceneConfig) {
const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration)
const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration)
- const SF = Math.min(1, H / REFERENCE_HEIGHT)
+ const SF = Math.max(MOBILE_SCALE_CAP, Math.min(1, H / REFERENCE_HEIGHT))
const GROUND_Y = H * GROUND_Y_RATIO
const HOME_A = W * HOME_A_RATIO
const HOME_B = W * HOME_B_RATIO
diff --git a/frontend/src/game/fight/config.ts b/frontend/src/game/fight/config.ts
index e57dfc5..3cbb8bb 100644
--- a/frontend/src/game/fight/config.ts
+++ b/frontend/src/game/fight/config.ts
@@ -87,10 +87,11 @@ export const HYPERDETAIL_BASE_CHANCE = 0.05 // Base hyperdetail chance
export const HYPERDETAIL_INTENSITY_SCALE = 0.45 // Scales with intensity
// --- Scene layout ---
-export const GROUND_Y_RATIO = 0.78 // Ground line as fraction of canvas height
+export const GROUND_Y_RATIO = 0.82 // Ground line as fraction of canvas height
export const HOME_A_RATIO = 0.28 // Fighter A home X position ratio
export const HOME_B_RATIO = 0.72 // Fighter B home X position ratio
export const REFERENCE_HEIGHT = 500 // Reference canvas height for scale factor
+export const MOBILE_SCALE_CAP = 0.55 // Cap SF on small screens so fighters aren't too tiny
export const SPRITE_LOAD_TIMEOUT_MS = 5_000 // Max wait for sprite loading (mobile hang prevention)
// --- Fighter scaling ---
diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts
index e24eaf4..78f63d6 100644
--- a/frontend/src/game/sprites/index.ts
+++ b/frontend/src/game/sprites/index.ts
@@ -201,14 +201,35 @@ export function generateSpriteSheet(
if (tier >= 3) { px(cx + hOff, beltY, '#ffd700', ox, oy); px(cx + hOff + 1, beltY, '#ffd700', ox, oy) }
}
- // Chest emblem (tier 4+)
- if (tier >= 4) {
+ // ₿ Bitcoin chest badge — every fighter carries the symbol
+ {
const ey = by + Math.round(bh * 0.3)
- px(cx + hOff - 1, ey, pal.acc, ox, oy)
- px(cx + hOff, ey, pal.accLight, ox, oy)
- px(cx + hOff + 1, ey, pal.acc, ox, oy)
- px(cx + hOff, ey - 1, pal.acc, ox, oy)
- px(cx + hOff, ey + 1, pal.acc, ox, oy)
+ const ec = cx + hOff
+ // Tier 0-1: simple orange ₿ dot
+ // Tier 2-3: small ₿ shape (vertical bar + bumps)
+ // Tier 4+: full golden ₿ with glow
+ if (tier >= 4) {
+ // Golden glow behind
+ px(ec - 2, ey, '#ffd70066', ox, oy)
+ px(ec + 2, ey, '#ffd70066', ox, oy)
+ px(ec, ey - 2, '#ffd70066', ox, oy)
+ px(ec, ey + 2, '#ffd70066', ox, oy)
+ }
+ const bc = tier >= 4 ? '#ffd700' : tier >= 2 ? '#ff8c00' : '#ff6600'
+ // Vertical stroke
+ px(ec, ey - 1, bc, ox, oy)
+ px(ec, ey, bc, ox, oy)
+ px(ec, ey + 1, bc, ox, oy)
+ // Right bumps (₿ shape)
+ if (bw >= 6) {
+ px(ec + 1, ey - 1, bc, ox, oy)
+ px(ec + 1, ey + 1, bc, ox, oy)
+ }
+ // Top/bottom serifs
+ if (tier >= 2) {
+ px(ec, ey - 2, tier >= 4 ? '#ffd700' : '#cc7000', ox, oy)
+ px(ec, ey + 2, tier >= 4 ? '#ffd700' : '#cc7000', ox, oy)
+ }
}
// Shoulder pads (tier 3+)
diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue
index 57c774e..e9cfcb5 100644
--- a/frontend/src/pages/BotProfilePage.vue
+++ b/frontend/src/pages/BotProfilePage.vue
@@ -379,18 +379,26 @@ const tierClass = (t: number) => `tier-${t}`
-
+
+
+
+
{{ stats.satsWon }}
+
-
+
`tier-${t}`
:tier="stats.tier"
:size="140"
:customization="stats.customization || undefined"
- class="drop-shadow-[0_0_20px_var(--glow)]"
+ class="drop-shadow-[0_0_20px_var(--glow)] relative z-10"
:style="{ '--glow': stats.tierColor + '80' } as any"
/>
+
+
+
+
{{ stats.satsWon }}
+
diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue
index 498cf39..1845004 100644
--- a/frontend/src/pages/FightPage.vue
+++ b/frontend/src/pages/FightPage.vue
@@ -357,7 +357,12 @@ async function handleFightEnd(data: any) {
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
- await loadFight()
+ // Retry loading until rounds are populated (server may still be persisting)
+ for (let attempt = 0; attempt < 5; attempt++) {
+ await loadFight()
+ if (fight.value?.rounds?.length) break
+ await new Promise(r => setTimeout(r, 800))
+ }
isLive.value = false
disconnectSSE()
stopHumanPolling()
diff --git a/server/src/app.ts b/server/src/app.ts
index 26511ff..5ebd77d 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -36,15 +36,23 @@ app.use('*', logger())
const allowedOrigin = process.env.CORS_ORIGIN || '*'
app.use('/api/*', cors({ origin: allowedOrigin }))
+// COOP/COEP headers: required for SharedArrayBuffer (Kokoro TTS WASM threading)
+app.use('*', async (c, next) => {
+ await next()
+ c.header('Cross-Origin-Opener-Policy', 'same-origin')
+ c.header('Cross-Origin-Embedder-Policy', 'credentialless')
+})
+
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
app.use('*', secureHeaders({
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
defaultSrc: ["'self'"],
- scriptSrc: ["'self'"],
+ scriptSrc: ["'self'", 'blob:'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
+ workerSrc: ["'self'", 'blob:'],
} : undefined,
}))
diff --git a/server/src/engine/challenges-extra.ts b/server/src/engine/challenges-extra.ts
index d6e3de6..03ad7f4 100644
--- a/server/src/engine/challenges-extra.ts
+++ b/server/src/engine/challenges-extra.ts
@@ -463,6 +463,14 @@ export const EXTRA_PROMPTS: Record
= {
{ prompt: 'Roast someone who trusts custodial exchanges with their Bitcoin.' , theme: 'bitcoin' },
{ prompt: 'Roast someone who calls themselves a "Web3 developer" but can\'t deploy a smart contract.' , theme: 'bot_coding' },
{ prompt: 'Roast a sysadmin who uses root for everything.' , theme: 'bot_coding' },
+ { prompt: 'Roast a "vibe coder" who writes entire apps with AI prompts and has no idea how any of it works. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast someone who replaced their entire engineering team with ChatGPT and wonders why production is on fire. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast a developer who says "just use AI" instead of learning to code. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast someone whose entire GitHub profile is AI-generated code they don\'t understand. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast a startup founder who pivoted to AI 6 times in one year. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast someone who uses 4 different AI coding assistants simultaneously and still can\'t ship. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast a developer who added Rust to a JavaScript project "for performance" and now nobody can build it. 3 sentences.', theme: 'bot_coding' },
+ { prompt: 'Roast someone who says "I don\'t need tests, I have AI to find my bugs." 3 sentences.', theme: 'bot_coding' },
],
creative_writing: [
diff --git a/server/src/engine/dev-seed.ts b/server/src/engine/dev-seed.ts
new file mode 100644
index 0000000..c068447
--- /dev/null
+++ b/server/src/engine/dev-seed.ts
@@ -0,0 +1,54 @@
+import { db, schema } from '../db/index.js'
+import { sql } from 'drizzle-orm'
+import { createTournament, joinTournament, startTournament } from './tournaments.js'
+import { logger } from '../lib/logger.js'
+
+/** Seed a dev tournament with mock bots for testing betting/tournament UI */
+export async function seedDevTournament(): Promise {
+ // Skip if a tournament already exists
+ const existing = db.select({ count: sql`count(*)` })
+ .from(schema.tournaments)
+ .get()
+ if (existing && existing.count > 0) {
+ logger.info('dev-seed', 'tournaments already exist — skipping seed')
+ return
+ }
+
+ // Grab 8 mock bots for the tournament
+ const bots = db.select({ id: schema.bots.id, name: schema.bots.name })
+ .from(schema.bots)
+ .limit(8)
+ .all()
+
+ if (bots.length < 4) {
+ logger.warn('dev-seed', `only ${bots.length} bots — need at least 4 for tournament seed`)
+ return
+ }
+
+ // Create tournament
+ const tournamentId = createTournament(
+ 'SATOSHI SHOWDOWN #1',
+ 'single_elim',
+ 8,
+ 100,
+ )
+
+ // Join bots
+ for (const bot of bots.slice(0, 8)) {
+ try {
+ joinTournament(tournamentId, bot.id)
+ } catch (err) {
+ logger.warn('dev-seed', `failed to join bot ${bot.name}: ${(err as Error).message}`)
+ }
+ }
+
+ // Start the tournament (seeds by elo, generates bracket)
+ try {
+ startTournament(tournamentId)
+ logger.info('dev-seed', `seeded tournament "${tournamentId}" with ${Math.min(bots.length, 8)} bots`)
+ } catch (err) {
+ logger.warn('dev-seed', `failed to start tournament: ${(err as Error).message}`)
+ }
+
+ logger.info('dev-seed', 'dev seeding complete — tournament ready for testing')
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 3135c6c..74ea6e0 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -6,6 +6,7 @@ import { seedMockBots, seedClassicBots } from './engine/mock.js'
import { startBackgroundFights } from './engine/background.js'
import { getActiveFighterCount } from './engine/orchestrator.js'
import { clearAllPending } from './engine/human-responses.js'
+import { seedDevTournament } from './engine/dev-seed.js'
// Production env validation — warn but don't crash (wallet features degrade gracefully)
if (process.env.NODE_ENV === 'production') {
@@ -24,6 +25,11 @@ runMigrations()
await seedMockBots()
await seedClassicBots()
+// Dev mode: seed tournament + betting data for testing
+if (process.env.NODE_ENV !== 'production') {
+ await seedDevTournament()
+}
+
const port = Number(process.env.PORT) || 9100
serve({ fetch: app.fetch, port }, () => {