From 610e799605e3c3b3896532124e2f506c8e904273 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 19:46:39 +0000 Subject: [PATCH] feat: SSE live fight spectating with spectator count Enable real-time fight spectating for all live fights (not just human fights). Multiple spectators can watch simultaneously via SSE. Spectator count is tracked per-fight and broadcast with every SSE event. Co-Authored-By: Claude Opus 4.6 --- .claude/plans/breezy-questing-dove.md | 152 ++++ .claude/plans/concurrent-meandering-muffin.md | 769 ++++++++++++------ .claude/plans/greedy-skipping-lollipop.md | 214 +++++ .claude/plans/precious-sniffing-catmull.md | 36 + botfights.db | 0 frontend/src/components/FightViewer.vue | 39 +- frontend/src/components/SpritePreview.vue | 2 + frontend/src/composables/useWallet.ts | 13 +- frontend/src/game/sounds.ts | 88 +- frontend/src/game/sprites/index.ts | 20 +- frontend/src/game/tts-worker.ts | 51 ++ frontend/src/game/tts.ts | 154 ++-- frontend/src/pages/FightPage.vue | 213 ++++- frontend/src/pages/SoundboardPage.vue | 148 ++++ frontend/src/router.ts | 5 + frontend/vite.config.ts | 15 + server/botfights.db | 0 server/src/routes/fights.ts | 33 +- 18 files changed, 1555 insertions(+), 397 deletions(-) create mode 100644 .claude/plans/breezy-questing-dove.md create mode 100644 .claude/plans/greedy-skipping-lollipop.md create mode 100644 .claude/plans/precious-sniffing-catmull.md create mode 100644 botfights.db create mode 100644 frontend/src/game/tts-worker.ts create mode 100644 frontend/src/pages/SoundboardPage.vue create mode 100644 server/botfights.db diff --git a/.claude/plans/breezy-questing-dove.md b/.claude/plans/breezy-questing-dove.md new file mode 100644 index 0000000..9aab28b --- /dev/null +++ b/.claude/plans/breezy-questing-dove.md @@ -0,0 +1,152 @@ +# Production Hardening v2 — Final Status + +## Context + +The original hardening plan (`greedy-skipping-lollipop.md`) was 95% complete. A full security audit confirmed the fight engine, scoring, auth, SSRF, rate limiting, and webhook systems were solid. This session addressed remaining production gaps AND added Creator ₿ animation/morph enhancements AND fixed a production ranked-fight bug. + +--- + +## Phase 1: Security Headers & Error Hardening — DONE + +### 1.1 Security headers middleware — DONE +**File:** `server/src/app.ts` +- Added `secureHeaders` from `hono/secure-headers` with CSP (production only), X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy + +### 1.2 Request body size limit — DONE +**File:** `server/src/app.ts` +- Added `bodyLimit` from `hono/body-limit` — 256KB max for API requests + +### 1.3 Hide error details in production — DONE +**File:** `server/src/app.ts` +- `onError` returns generic "Internal server error" in production, full message in dev + +--- + +## Phase 2: Graceful Shutdown & Env Validation — DONE + +### 2.1 Graceful shutdown handler — DONE +**File:** `server/src/index.ts` +- SIGTERM/SIGINT handlers wait 10s for in-flight fights, clear pending human challenges, then exit + +### 2.2 Export helpers for shutdown — DONE +- `server/src/engine/orchestrator.ts` — `getActiveFighterCount()` +- `server/src/engine/human-responses.ts` — `clearAllPending()` + +### 2.3 Stricter env validation — DONE +**File:** `server/src/index.ts` +- Warns if CORS_ORIGIN not set in production + +--- + +## Phase 3: Payment Race Condition Fix — DONE + +### 3.1 Atomic consumePaymentForQueue — DONE +**File:** `server/src/engine/payments.ts` +- Replaced check-then-set with atomic `UPDATE ... WHERE` (single SQL statement) +- Imported raw `sqlite` from db/index.js + +### 3.2 releasePayment reverts DB state — DONE +**File:** `server/src/engine/payments.ts` +- Now also reverts `status` from `consumed` back to `confirmed` in DB + +--- + +## Phase 4: Live Round-by-Round TUI — DONE + +### 4.1 onRoundComplete callback — DONE +**File:** `server/src/engine/fight-loop.ts` +- New `onRoundComplete` option, subscribes to `fightEvents.onAll()` for `round_end` events + +### 4.2 TUI wired to round updates — DONE +**File:** `server/src/fight-loop-cli.ts` +- `onRoundComplete` updates `state.currentFight` HP/round/challengeType and re-renders + +--- + +## Phase 5: Creator ₿ Animation Enhancements — DONE + +### 5.1 Persistent orbiting ₿ letters on entrance — DONE +**File:** `frontend/src/game/FightScene.ts` (Creator entrance) +- Replaced 6 plain circle dots with 10 ₿ text letters in 2 counter-rotating rings +- Varied sizes (7-14px), 5 colors, gentle rocking animation +- Visible for the entire fight + +### 5.2 Upgraded omni-morph ₿ orbits — DONE +**File:** `frontend/src/game/FightScene.ts` (`applyCreatorOmniMorph`) +- Increased from 4 to 8 ₿ in dual rings (inner 5, outer counter-rotating 3) +- Varied sizes (10-18px), 3 colors, wobble animation + +### 5.3 bitcoinRain uses ₿ text — DONE +**File:** `frontend/src/game/FightScene.ts` (`bitcoinRain`) +- Changed from circles to actual ₿ text with 3 colors, varied sizes (6-18px), spinning as they fall + +### 5.4 Four new ₿ swarm choreographies — DONE +**File:** `frontend/src/game/FightScene.ts` +- `bitcoinSwarm` — 18-30 ₿ letters zigzag from Creator to defender +- `satoshiTornado` — 14-24 ₿ spiral in tightening vortex around defender, then explode +- `hodlWave` — wall of ₿ letters (4-6 rows x 7-10 cols) advances like a tidal wave +- `bitcoinBarrage` — rapid-fire stream of 20-35 spinning ₿ from the laptop +- All added to `CREATOR_MOVES` array and `choreographyMap` + +--- + +## Phase 6: Creator Omni-Morph Visual Fix — DONE + +### 6.1 Sprite swap on morph — DONE +**File:** `frontend/src/game/FightScene.ts` (`applyCreatorOmniMorph`) +- Was: only added overlays (aura, ₿, color tint) — sprite stayed as Creator +- Now: generates a full sprite sheet for the picked archetype via `generateSpriteSheet(seed, tier, colors, archetypeOverride)` +- Loads it as a Kaplay sprite and swaps with `fighter.use(k.sprite(morphKey))` +- Function changed from sync to `async` +- Unique sprite keys via incrementing counter prevent collisions + +### 6.2 Sprite revert after attack — DONE +**File:** `frontend/src/game/FightScene.ts` (morph trigger in playRound) +- `morphRevert` now calls `attacker.use(k.sprite(originalSpriteKey))` to swap back to Creator +- Added `await` on `applyCreatorOmniMorph` call (now async) + +--- + +## Phase 7: Production Ranked Fight Fix — DONE + +### 7.1 Missing pubkey in ranked endpoints — DONE +**File:** `frontend/src/composables/useWallet.ts` +- `payEntryFee()` now sends `{ botId, pubkey: pubkey.value }` to `/api/payments/create-invoice` + +**File:** `frontend/src/pages/JoinBoutPage.vue` +- `fightRanked()` now sends `{ paymentId, pubkey: pubkey.value }` to `/api/queue/join-ranked` + +**Root cause:** Server requires `pubkey` in production for ownership verification, but frontend wasn't sending it. Worked in dev because dev mode skips pubkey checks. + +--- + +## All Files Modified + +| File | Phase | Changes | +|------|-------|---------| +| `server/src/app.ts` | 1 | +secureHeaders, +bodyLimit, error hiding | +| `server/src/index.ts` | 2 | +graceful shutdown, +env validation | +| `server/src/engine/orchestrator.ts` | 2 | +getActiveFighterCount() | +| `server/src/engine/human-responses.ts` | 2 | +clearAllPending() | +| `server/src/engine/payments.ts` | 3 | Atomic consume, releasePayment DB revert | +| `server/src/engine/fight-loop.ts` | 4 | +onRoundComplete via fightEvents | +| `server/src/fight-loop-cli.ts` | 4 | Wire onRoundComplete to TUI | +| `frontend/src/game/FightScene.ts` | 5,6 | ₿ animations, omni-morph sprite swap, 4 new choreographies | +| `frontend/src/composables/useWallet.ts` | 7 | +pubkey in create-invoice | +| `frontend/src/pages/JoinBoutPage.vue` | 7 | +pubkey in join-ranked | +| `frontend/src/game/sprites/archetypes/the_creator.ts` | — | No changes (reference only) | + +--- + +## Verification + +1. `pnpm build` — verify no type errors (both server and frontend pass) +2. Production server: `curl -I /api/health` — X-Frame-Options, X-Content-Type-Options, CSP headers present +3. Body limit: oversized POST returns 413 +4. Error hiding: bad endpoint returns "Internal server error" (not stack trace) +5. Graceful shutdown: `kill -TERM` shows drain log +6. TUI: `pnpm fight-loop --max=3` shows live round-by-round HP updates +7. Payment race: concurrent ranked joins with same paymentId — only 1 succeeds +8. Ranked fights: no more "Missing pubkey" error in production +9. Creator fights: omni-morph visually transforms into random archetypes (sprite swaps) +10. Creator fights: ₿ letters orbit persistently, new swarm attacks fire regularly diff --git a/.claude/plans/concurrent-meandering-muffin.md b/.claude/plans/concurrent-meandering-muffin.md index e76beff..369116a 100644 --- a/.claude/plans/concurrent-meandering-muffin.md +++ b/.claude/plans/concurrent-meandering-muffin.md @@ -1,330 +1,557 @@ -# Botfights: 30-Day Production Plan +# Botfights: 6-Month Development Plan (March–September 2026) -## Context +## Current State (March 8, 2026) -Botfights is a procedural fighting game (Vue 3 + Kaplay + Hono/SQLite) where AI bots battle through challenge rounds with pixel art sprites, synthesized audio, and choreographed fight animations. Currently a working prototype with 6 character archetypes, 42 choreographies, 4 music tracks, 6 voice profiles, ~45 challenge prompts, and 12 mock bots. The goal is to scale this to production-grade content (100 characters, 300 moves, 10,000 challenges, 20 songs, 30 voices, 30+ arenas) and build an overnight fight loop that runs autonomously. +Botfights is a competitive AI fighting game (Vue 3 + Kaplay + Hono/SQLite) where bots battle through challenge rounds with pixel art sprites, synthesized audio, and choreographed fight animations. The core is feature-complete: + +| Category | Count | Status | +|----------|-------|--------| +| Archetypes | 101 (100 + THE CREATOR) | Done | +| Choreographies | 352+ (300 base + 40 tier-gated + 12 creator) | Done | +| Challenge Types | 16 | Done | +| Challenge Prompts | 800 (551 multiple-choice, 249 creative) | Done | +| Mock Bots | 100 (tiers 0–6) | Done | +| Arenas | 25 with modifiers | Done | +| Voice Profiles | 60+ | Done | +| Music Tracks | 10 procedural synth | Done | +| Sound Effects | 30+ | Done | +| Pages | 20 | Done | +| Nostr Auth | NIP-04/44/19 | Done | +| Payments | NWC + Lightning Address + Cashu | Done | +| PWA | Manifest + SW + offline assets | Done | +| Docker | Multi-stage, 42MB prod image | Done | +| TUI Fight Loop | Live round-by-round | Done | +| Production Hardening | Security headers, SSRF, rate limiting, graceful shutdown | Done | + +**FightScene.ts** is 11,313 lines. The codebase works end-to-end but hasn't been battle-tested with real users at scale. --- -## Phase 1: Module Splitting (Days 1-6) +## Month 1: Social Layer & Real-Money Launch (March 9 – April 5) -The 3 monolith files must be split before any content scaling. Without this, adding content to 3500-line files is unmaintainable. +Goal: Make botfights a place people want to hang out and bet sats. -### Day 1-2: Split `sprites.ts` (954 lines) +### Week 1: Spectator Experience -``` -frontend/src/game/sprites/ - index.ts -- re-exports generateSpriteSheet, getBotColors, etc. - palette.ts -- makePal, parseHSL, Pal interface - constants.ts -- FRAME_SIZE, ANIMATIONS, INTERNAL, SCALE - drawing.ts -- px(), box(), fill() drawing primitives - base-body.ts -- drawFrame() core body/head/arms/legs/pose logic - archetypes/ - index.ts -- archetype registry + roll function - standard.ts -- base (no extra features) - lobster.ts -- claws, antennae, segmented body, tail - sheep.ts -- wool, floppy ears, hooves - cyborg.ts -- metal plating, robot eye, wiring - blob.ts -- wobbly outline, googly eyes, drool - tank.ts -- armor, rivets, treads - judge.ts -- generateJudgeSpriteSheet() -``` +#### 1.1 Live Fight Spectating via SSE +**Files:** `server/src/routes/fights.ts`, `frontend/src/pages/FightPage.vue` +- SSE endpoint streams round results, HP changes, challenge reveals in real-time +- Multiple spectators can watch a live fight simultaneously +- Auto-redirect from fight card to live view when fight starts +- Spectator count badge on active fights -Each archetype exports `{ name, weight, drawFeatures(ctx, pal, params) }`. The base-body calls the archetype's `drawFeatures` after the shared body drawing. +#### 1.2 Fight Feed / Activity Stream +**New file:** `frontend/src/pages/FeedPage.vue` +- Reverse-chronological feed of recent fights: winner, KO/decision, Elo changes +- Filter by tier, arena, bot name +- Click any fight to watch replay +- Replace or augment current HomePage with this -**Test:** Sprite preview page renders all 6 archetypes at all tiers identically to before. +#### 1.3 Reactions / Crowd Noise +**Files:** `frontend/src/components/FightViewer.vue`, `server/src/routes/fights.ts` +- Spectators can send emoji reactions during live fights (fist, fire, skull, 100, clown) +- Reactions appear as floating particles over the fight +- Server aggregates reactions, broadcasts via SSE +- Crowd SFX intensity scales with reaction count -### Day 3-4: Split `FightScene.ts` (~3500 lines) +### Week 2: Betting System UI + +#### 2.1 Bet Placement UI +**Files:** `frontend/src/components/BetPanel.vue`, `frontend/src/pages/FightCardPage.vue` +- Pre-fight bet panel: pick winner, set amount (21/100/500/1000 sats) +- Cashu token deposit flow (paste token or scan QR) +- NWC direct-pay option for connected wallets +- Odds display based on Elo difference +- Bet confirmation with payout preview + +#### 2.2 Bet Settlement & Payout +**Files:** `server/src/engine/betting.ts`, `server/src/engine/payments.ts` +- Atomic settlement on fight completion +- Winner payout minus 5% house cut (goes to fight pot for ranked matches) +- Cashu token minting for winnings OR Lightning payout +- Refund on cancelled/errored fights + +#### 2.3 Betting History +**Files:** `frontend/src/pages/BotProfilePage.vue`, new `frontend/src/components/BetHistory.vue` +- Personal bet history on profile page +- P&L tracking (total wagered, total won, net) +- "Hot streak" and "cold streak" indicators + +### Week 3: Nostr Social Integration + +#### 3.1 Fight Results to Nostr +**New file:** `server/src/engine/nostr-publish.ts` +- Post NIP-01 kind:1 notes for notable fights (KOs, perfects, upsets, tier changes) +- Include fight replay link, bot names, Elo changes +- Configurable relay list via env var +- Only publish if `BOTFIGHTS_NOSTR_NSEC` is set + +#### 3.2 Nostr Profile Enrichment +**Files:** `frontend/src/composables/useNostr.ts`, `frontend/src/pages/BotProfilePage.vue` +- Fetch NIP-05, display name, banner from Nostr relays +- Show Nostr profile picture alongside bot sprite +- Link to user's Nostr profile (njump.me or similar) + +#### 3.3 Zap Integration +**Files:** `frontend/src/components/FightViewer.vue`, `server/src/routes/payments.ts` +- "Zap the winner" button after fight ends +- NWC zap flow: create invoice, user approves, confirm +- Zap receipts displayed on bot profile + +### Week 4: Ranked Season System + +#### 4.1 Season Framework +**New file:** `server/src/engine/seasons.ts` +- Season = 2 weeks, auto-rotates +- Season Elo resets to soft floor (blend of final Elo + 1200 baseline) +- Season leaderboard separate from all-time +- Season rewards: title badges, exclusive arena unlocks + +#### 4.2 Season Leaderboard UI +**Files:** `frontend/src/pages/LeaderboardPage.vue` +- Toggle between "This Season" and "All Time" +- Season countdown timer +- Top 3 highlighted with crown/medal sprites +- Season history archive + +#### 4.3 Ranked Queue Improvements +**Files:** `server/src/engine/ranked-queue.ts`, `frontend/src/pages/JoinBoutPage.vue` +- Elo-bracket matchmaking (±200 Elo, widens over time) +- Ranked queue status indicator (how many waiting) +- Estimated wait time +- Ranked-only challenges (harder prompts, no multiple choice) + +--- + +## Month 2: Tournaments, Human Play & Content Expansion (April 6 – May 3) + +Goal: Structured competition and enough content that nothing feels repetitive. + +### Week 5: Tournament System + +#### 5.1 Tournament Engine +**New files:** `server/src/engine/tournaments.ts`, `server/src/db/schema.ts` (new tables) +- Single-elimination brackets (8, 16, 32 bots) +- Round-robin group stage option +- Entry fee (configurable sats amount) → prize pool +- Auto-advance winners, schedule next round +- DB tables: `tournaments`, `tournament_entries`, `tournament_matches` + +#### 5.2 Tournament UI +**New files:** `frontend/src/pages/TournamentPage.vue`, `frontend/src/pages/TournamentListPage.vue` +- Visual bracket display (SVG or canvas) +- Live bracket updates as fights complete +- Tournament lobby: registered bots, countdown to start +- Results page with champion highlight + +#### 5.3 Scheduled Tournaments +**Files:** `server/src/engine/fight-loop.ts`, `server/src/engine/tournaments.ts` +- Daily automated tournaments (e.g., 8pm UTC) +- "The Halvening" weekly 32-bot major tournament +- Special themed tournaments (code-golf only, roast-battle only) +- Cron-based scheduling via fight loop + +### Week 6: Human vs Bot Improvements + +#### 6.1 Human Fight Polish +**Files:** `frontend/src/pages/HumanFightPage.vue`, `server/src/engine/human-responses.ts` +- Fix the TODO in human-responses.ts (currently `pass # TODO: implement`) +- Mobile-optimized input (large buttons for multiple choice, big text area for creative) +- Countdown timer with visual urgency (color shift, shake) +- Immediate feedback after answering (correct/wrong animation) + +#### 6.2 Practice Mode +**New file:** `frontend/src/pages/PracticePage.vue` +- Fight against mock bots without Elo impact +- Choose difficulty tier +- Post-fight breakdown: which rounds you won/lost and why +- Suggested improvements + +#### 6.3 Human vs Human +**Files:** `server/src/engine/orchestrator.ts`, `server/src/routes/queue.ts` +- Real-time matchmaking for two human players +- Both answer same challenge simultaneously +- WebSocket or SSE for synchronization +- Elo-rated human ladder separate from bot ladder + +### Week 7: Content Scaling + +#### 7.1 Challenge Prompts → 2,000 +**Files:** `server/src/engine/challenges.ts` +- 75 more prompts per existing 16 types = 1,200 new prompts +- Focus on Bitcoin/cypherpunk themed prompts: + - "What year was the Bitcoin whitepaper published?" + - "Explain proof-of-work to a medieval blacksmith" + - "Roast someone who thinks Ethereum is decentralized" + - "Write a haiku about the mempool" +- All factual types get multiple-choice options +- Difficulty tiers: easy (tier 0–2 fights), medium (3–4), hard (5–6) + +#### 7.2 Music Tracks → 20 +**Files:** `frontend/src/game/sounds.ts` +- 10 new procedural tracks: + - 2x Drum & Bass (170–175 BPM) + - 2x Lo-fi Hip Hop (90–100 BPM) + - 2x Ska Punk (200–220 BPM) + - 2x Boss Fight (140–160 BPM, heavy) + - 2x Chiptune (180–200 BPM) +- Intensity-grouped: low (lo-fi, chiptune), mid (ska, D&B), high (boss fight) + +#### 7.3 Arenas → 40 +**Files:** `server/src/engine/arenas.ts`, `frontend/src/game/FightScene.ts` +- 15 new arenas: + - bitcoin_mine (hash_power modifier) + - lightning_node (speed_3x) + - satoshis_garage (legacy_code) + - mempool (chaos_mode) + - silk_road_ruins (trap_heavy) + - block_height_tower (accuracy_buff) + - hodl_cave (endurance_2x) + - whale_pool (high_stakes) + - node_farm (efficiency_buff) + - genesis_block (all_types) + - pizza_day_parlor (food_2x) + - difficulty_adjustment (adaptive) + - 51_percent_arena (chaos_mode) + - timechain_temple (speed_2x) + - nostr_relay_room (crowd_favorite) +- Each with unique theme colors and background elements + +### Week 8: Choreography & Animation Polish + +#### 8.1 Choreographies → 500 +**Files:** `frontend/src/game/FightScene.ts` +- 148 new choreographies across categories: + - 30 Bitcoin-themed (lightning_bolt, hash_smash, block_drop, mempool_flood, difficulty_bomb, fee_spike, utxo_scatter, node_sync, relay_bounce, channel_force_close, etc.) + - 25 more food moves + - 25 more energy/beam moves + - 20 more silly/absurd moves + - 20 more weapon moves + - 15 more vehicle moves + - 13 more animal moves +- Wire into CHALLENGE_THEMED with balanced distribution + +#### 8.2 Entrance Animations +**Files:** `frontend/src/game/FightScene.ts` +- 10 new tier-gated entrances: + - Tier 0–1: simple walk-in + - Tier 2: slide in with dust + - Tier 3: drop from above with impact shake + - Tier 4: teleport with lightning flash + - Tier 5: dramatic slow-mo entrance with camera zoom + - Tier 6: custom per-bot entrance (THE CREATOR golden portal already done) + +#### 8.3 Victory Celebrations +**Files:** `frontend/src/game/FightScene.ts` +- Post-fight victory animations tied to finish type: + - KO: winner flexes, camera flash effect + - Perfect: winner does victory lap, confetti rain + - Decision: both bow, crowd cheers + - Upset: dramatic zoom on winner, shocked crowd SFX + +--- + +## Month 3: Scale, Polish & Launch (May 4 – May 31) + +Goal: Production-ready for real users, real sats, real competition. + +### Week 9: FightScene.ts Decomposition + +#### 9.1 Split the Monolith +FightScene.ts at 11,313 lines is the single biggest maintenance risk. Split into focused modules: ``` frontend/src/game/fight/ - index.ts -- createFightScene() orchestrator, returns controller - types.ts -- FightContext interface (k, W, H, GROUND_Y, HOME_A, HOME_B, helpers) - constants.ts -- ARENA_THEMES, spriteAnims, positions - particles.ts -- spawnSparks, spawnBulletHoles, spawnExhaust, etc. - projectiles.ts -- spawnProjectile, spawnBullet, spawnProp, spawnBarrage - effects.ts -- glitchRGB, scanlineGlitch, vhsTracking, dimensionalShift, etc. - grotesque.ts -- spawnGrotesqueDetails, destroyGrotesqueDetails - arena-decor.ts -- drawArenaDecor() per-arena backgrounds + index.ts -- createFightScene() orchestrator + types.ts -- FightContext, shared interfaces + constants.ts -- arena themes, sprite anims, positions + particles.ts -- all particle spawn functions + projectiles.ts -- projectile spawning and physics + effects.ts -- screen effects (glitch, scanline, VHS, etc.) + grotesque.ts -- close-up detail system + arena.ts -- arena background rendering + entrances.ts -- bot entrance animations + morphs.ts -- morph system (elemental, mech, beast, omni) choreography/ - index.ts -- choreographyMap registry, pickChoreography - challenge-map.ts -- CHALLENGE_THEMED + critMoves - basic.ts -- dashPunch, aerialSlam, flyingKick, uppercut - ranged.ts -- projectile, gunBurst, sniperShot, minigunSpray - movement.ts -- dashThrough, fullScreenDash, teleportStrike - heavy.ts -- groundPound, jetpackDive, bodySlam, suplex - weapons.ts -- swordSlash, hammerSmash, katanaCombo, chainsawRev, whipCrack - vehicles.ts -- motorbikeCharge, carSmash, boatCannon - themed.ts -- golfClubSmash, fireBreath, riddleBarrage, coinShower, etc. - finishers.ts -- playKO (4 styles), playPerfect + index.ts -- choreographyMap, pickChoreography + registry.ts -- CHALLENGE_THEMED, critMoves + basic.ts -- punches, kicks, slams + ranged.ts -- projectiles, guns, beams + heavy.ts -- ground pounds, body slams + weapons.ts -- swords, hammers, etc. + food.ts -- food-themed moves + silly.ts -- absurd/comedy moves + bitcoin.ts -- Bitcoin-themed moves + creator.ts -- THE CREATOR exclusive moves + ultimates.ts -- tier-gated ultimate moves + finishers.ts -- KO styles, perfect finish round.ts -- playRound, playTaunt, playDodge + camera.ts -- camera shake, zoom, pan + hud.ts -- HP bars, round counter, timer ``` -**Critical pattern:** Choreography functions close over Kaplay context. Each file exports functions that take a `FightContext` object with all shared state/helpers. +- Zero behavior changes — pure refactor +- Each module receives FightContext object +- Test: replay 50 fights, verify identical behavior -**Test:** Replay several fights, verify all 42 choreographies work. +### Week 10: Performance & Mobile -### Day 5-6: Split `sounds.ts` (1381 lines) + server modules +#### 10.1 Performance Optimization +- Profile FightScene with Chrome DevTools +- Reduce particle count on mobile (detect via `navigator.maxTouchPoints`) +- Lazy-load Kokoro TTS model (2.2MB) — don't block initial render +- Sprite sheet atlas: batch all archetypes into single texture +- RequestAnimationFrame budget monitoring — drop effects if frame time > 16ms -``` -frontend/src/game/sounds/ - index.ts -- re-exports - context.ts -- AudioContext setup, gain nodes - primitives.ts -- tone(), noise(), sweep() helpers - sfx.ts -- all sfx functions (punch, kick, special, etc.) - voice.ts -- voice profiles, speak(), announce*(), voice lines - music/ - index.ts -- startMusic(), stopMusic(), setMusicIntensity() - types.ts -- MusicTrack interface - player.ts -- playMusicBar() scheduler - tracks/ - neon-fury.ts - dark-circuit.ts - pixel-blitz.ts - skull-crusher.ts +#### 10.2 Mobile UX Polish +**Files:** Various frontend components +- Touch-friendly bet panel (large tap targets, swipe gestures) +- Responsive fight viewer (portrait mode layout) +- Bottom navigation bar for mobile +- Haptic feedback on hits (Vibration API) +- Reduced motion mode (prefers-reduced-motion) -server/src/engine/ - challenges/ - index.ts -- pickChallenge(), registry - types.ts -- Challenge interfaces - speed-blitz.ts -- prompts array - riddle.ts - ... (one per type) - mock/ - index.ts -- runMockFight(), seedMockFights() - bots.ts -- MOCK_BOTS array - answers.ts -- MOCK_ANSWERS, BAD_ANSWERS - trash-talk.ts -- TRASH_TALK lines -``` +#### 10.3 Offline / Weak Connection +- Queue position preserved on reconnect (SSE retry) +- Optimistic UI for bet placement +- Replay cache: save last 10 watched fights for offline replay +- Connection status indicator -**Test:** Full end-to-end fight replay with music, SFX, voice. +### Week 11: Admin, Analytics & Bot Developer Experience -**Deliverable:** Identical functionality, ~40 small focused modules instead of 3 monoliths. +#### 11.1 Admin Dashboard +**New files:** `frontend/src/pages/AdminPage.vue`, `server/src/routes/admin.ts` +- Protected by THE CREATOR pubkey (only admin) +- Live stats: active fights, queue depth, bets in escrow, total sats moved +- Bot management: deactivate, reset Elo, ban +- Challenge management: add/remove prompts, preview scoring +- Fight log viewer with full round details +- System health: DB size, memory, uptime + +#### 11.2 Privacy-Respecting Analytics +**New file:** `server/src/engine/analytics.ts` +- No PII, no cookies, no third-party scripts +- Aggregate counts only: fights/day, unique bots/day, sats wagered/day +- Stored in SQLite, queryable from admin dashboard +- Public stats endpoint for transparency: `GET /api/stats/public` + +#### 11.3 Bot Developer Docs +**Files:** `frontend/src/pages/DocsPage.vue` +- Interactive API explorer (try endpoints from the browser) +- Webhook payload examples for all 16 challenge types +- Response format with scoring breakdown +- "Build your first bot" tutorial (curl, Python, Node examples) +- Webhook testing tool (send test challenge, see response + score) + +### Week 12: Launch Prep & Stress Testing + +#### 12.1 Load Testing +- Simulate 100 concurrent spectators on single fight +- Simulate 20 concurrent fights +- Simulate 50 concurrent bet placements +- SQLite WAL mode verification under write contention +- Memory profiling under sustained fight-loop operation (24hr soak test) + +#### 12.2 Backup & Recovery +**New file:** `server/src/engine/backup.ts` +- Automated SQLite backup to file (daily rotation, keep 7) +- Export fight history as JSON (for Nostr archival) +- Elo snapshot before season reset (rollback safety) + +#### 12.3 Launch Checklist +- [ ] All 16 challenge types tested end-to-end with real webhook +- [ ] NWC payment flow tested with real sats (testnet then mainnet) +- [ ] Cashu token deposit + withdrawal tested +- [ ] Betting flow: place bet → watch fight → receive payout +- [ ] Tournament: create → fill → run → settle prizes +- [ ] Human fight: join queue → answer challenges → see result +- [ ] Mobile: full flow on iOS Safari + Android Chrome +- [ ] PWA: install → offline → reconnect → resume +- [ ] TUI fight loop: 24hr soak test without crash +- [ ] Docker: fresh build → deploy → health check → seed → fight +- [ ] Nostr: fight results published to relay, zaps working +- [ ] Security: rate limiting, SSRF, body limits, error hiding verified +- [ ] THE CREATOR: all exclusive features working (entrance, morphs, cameo, moves) +- [ ] Bundle size < 300KB gzipped (run `npx vite-bundle-visualizer`) +- [ ] Memory: 50-fight replay stays flat (Chrome DevTools heap snapshot) +- [ ] Server: 24hr fight-loop RSS < 250MB +- [ ] Mobile: fight loads in < 2s on throttled 4G +- [ ] SQLite: indexes verified, WAL mode enabled, p95 query < 50ms --- -## Phase 2: Characters (Days 7-12) +## Performance & Footprint (Continuous Thread) -### Day 7-8: New archetypes batch 1 (8 new) -- `dog.ts` -- floppy ears, wagging tail, snout with tongue, collar -- `cat.ts` -- pointed ears, whiskers, curled tail, slit eyes -- `cactus.ts` -- spikes everywhere, small flower on head, no real arms -- `pizza.ts` -- triangular body, cheese drip, pepperoni spots -- `mushroom.ts` -- dome cap head, spotted cap, stubby legs, spore particles -- `shark.ts` -- dorsal fin, teeth row, tail fin -- `penguin.ts` -- tuxedo coloring, beak, flippers -- `octopus.ts` -- 4 visible tentacles, large head, suction cups +Performance is not a one-week task — it's a discipline that runs across all 3 months. Every feature addition must respect these budgets. -### Day 9-10: New archetypes batch 2 (8 more) -- `skeleton.ts` -- visible ribs, skull head, bony limbs -- `ghost.ts` -- semi-transparent, wavy bottom (no legs), glowing eyes -- `alien.ts` -- big head, huge eyes, antenna, green tint -- `dinosaur.ts` -- tail, tiny arms, spiky back, big jaw -- `pirate.ts` -- eye patch, hat, peg leg, hook hand -- `ninja.ts` -- mask, headband, throwing star -- `cowboy.ts` -- hat, bandana, boots with spurs -- `wizard.ts` -- pointed hat, robe, staff replaces one arm, beard +### Budgets -### Day 11: Final archetypes + hybrid system -- `bee.ts` -- stripes, wings, stinger, antennae -- `frog.ts` -- wide mouth, bulging eyes, webbed feet, long tongue -- `snail.ts` -- shell on back, eye stalks, slime trail -- **Hybrid system:** 10% chance a bot combines features from 2 archetypes (lobster+sunglasses, sheep+robot arm, etc.) -- **Total:** 25 archetypes +| Metric | Target | Current Estimate | +|--------|--------|-----------------| +| Initial JS bundle | < 300KB gzipped | Audit needed | +| Kokoro TTS model | Lazy-load, < 2.2MB | 2.2MB (loaded eagerly) | +| Sprite sheet per bot | < 15KB PNG | ~8KB | +| Fight replay memory | < 80MB peak | Unknown (profile needed) | +| Server memory (idle) | < 100MB RSS | ~60MB | +| Server memory (fight-loop 24hr) | < 250MB RSS | Unknown | +| Docker image | < 50MB | 42MB | +| SQLite DB after 10K fights | < 200MB | ~5MB (100 fights) | +| Time to first fight frame | < 2s on 4G | Unknown | +| SSE reconnect | < 1s | Unknown | -### Day 12: 100 Named Bots + Seed Data -Expand `MOCK_BOTS` from 12 to 100 with distribution: -- ~5 tier-5, ~10 tier-4, ~20 tier-3, ~25 tier-2, ~25 tier-1, ~15 tier-0 +### Month 1 Performance Tasks -Funny names by theme: -- **Tech:** `segfault_sarah`, `stack_overflow_sam`, `infinite_loop_larry`, `null_reference_nancy` -- **Food:** `pizza_pete`, `sushi_sensei`, `taco_tornado`, `burrito_bomber` -- **Animals:** `mega_lobster_xl`, `tactical_penguin`, `stealth_shark_3000` -- **Pop culture:** `keyboard_warrior`, `meme_lord_420`, `reply_guy_9000` -- **Absurd:** `sentient_toaster`, `angry_calculator`, `philosophical_fork` +#### P1.1 Bundle Audit & Tree-Shaking +- Run `npx vite-bundle-visualizer` to identify bloat +- Verify Kaplay tree-shakes unused modules +- Verify nostr-tools only imports what's needed (not full bundle) +- Code-split routes: lazy-load all pages except HomePage +- Target: identify top 3 bundle offenders and fix -Pre-seed 200 fight history records. +#### P1.2 Kokoro TTS Lazy Loading +**Files:** `frontend/src/game/sounds.ts` +- Don't load the 2.2MB Kokoro model on page load +- Load on first fight start (with loading indicator) +- Cache in service worker after first load +- Fallback to Web Speech API while loading -**Test:** `pnpm seed`, verify 100 bots, leaderboard renders, sprite preview shows all archetypes. +#### P1.3 Sprite Atlas +**Files:** `frontend/src/game/sprites/` +- Currently generates sprite sheets per-bot at fight time +- Pre-generate common archetypes into a shared atlas texture +- Single GPU texture upload instead of per-bot +- Reduces fight init time and GPU memory ---- +#### P1.4 SSE Connection Pooling +**Files:** `server/src/routes/fights.ts` +- Ensure SSE connections are properly cleaned up on client disconnect +- Add connection limit per IP (max 5 concurrent SSE streams) +- Heartbeat every 15s to detect dead connections +- Memory: track active SSE count in admin stats -## Phase 3: Choreographies (Days 13-19) +### Month 2 Performance Tasks -Scale from 42 to 300 choreographies. Each follows the established function signature. +#### P2.1 Fight Replay Memory Profiling +- Profile memory during 10-fight replay session +- Identify particle/sprite leaks (Kaplay objects not destroyed) +- Add `fight.destroy()` cleanup that nulls all references +- Test: play 50 fights consecutively, memory should stay flat -### Day 13-14: Food choreographies (~30 moves) -`choreography/food.ts`: -- `pizzaThrow`, `bananaSlip`, `pieFace`, `hotDogWhip`, `sushiRoll` -- `watermelonSmash`, `popcornBarrage`, `burritoWrap`, `cakeExplosion`, `iceCreamFreeze` -- `tacoSlam`, `donutSpin`, `breadSlap`, `eggCrack`, `soupSplash` -- `cheeseWheel`, `grapeShot`, `pineappleGrenade`, `cornCobGatling`, `steakSlap` -- Plus ~10 more food-themed variations +#### P2.2 Choreography Performance Tiers +**Files:** `frontend/src/game/FightScene.ts` +- Tag each choreography with a `particleCount` estimate +- On low-end devices (detect via `navigator.hardwareConcurrency < 4`): + - Reduce particle counts by 60% + - Skip grotesque close-ups + - Use simpler camera movements + - Disable background arena animations +- Toggle in settings: "Performance mode" -### Day 15-16: Spins/flips (~25) + vehicles (~15) -`choreography/spins.ts`: -- `spin720`, `backflipKick`, `tornadoSpin`, `corkscrewDive`, `cartwheel` -- `helicopterSpin`, `barrelRoll`, `frontflipSlam`, `spinningPiledriver` -- `doubleBackflip`, `wallflip`, `moonwalk`, `breakdanceSpin`, `drillKick` -- Plus more rotation/flip-based moves +#### P2.3 SQLite Optimization +**Files:** `server/src/db/index.ts` +- Enable WAL mode: `PRAGMA journal_mode=WAL` +- Set `PRAGMA synchronous=NORMAL` (safe with WAL) +- Add indexes: `fights(status)`, `fights(createdAt)`, `bots(eloRating)`, `payments(status)` +- `PRAGMA optimize` on daily cron +- Connection pool size: 1 writer + 4 readers (via better-sqlite3's sync nature this is automatic, but verify no concurrent write contention) -`choreography/vehicles.ts` expansion: -- `skateboard`, `helicopter`, `shoppingCart`, `zamboni`, `rocketSled` -- `segway`, `unicycle`, `bulldozer`, `rollercoaster`, `cannonball` -- Plus existing (motorbikeCharge, carSmash, boatCannon) +#### P2.4 Server Memory Leak Hunt +- Run fight-loop for 4 hours, capture heap snapshots at 0h, 1h, 2h, 4h +- Check for: unclosed SSE streams, orphaned fight event listeners, growing caches +- Add `process.memoryUsage()` to admin stats endpoint +- Set up `--max-old-space-size=256` in Docker CMD as safety net -### Day 17-18: Animal (~20) + energy (~20) + silly (~25) -`choreography/animal.ts`: -- `sharkBite`, `beeSwarm`, `frogTongue`, `stampede`, `spiderWeb` -- `snakeStrike`, `bearHug`, `eagleDive`, `ramCharge`, `crocodileRoll` +### Month 3 Performance Tasks -`choreography/energy.ts`: -- `kamehameha`, `hadouken`, `spiritBomb`, `thunderStrike`, `dragonPunch` -- `novaBlast`, `blackHole`, `sonicBoom`, `plasmaWhip`, `quantumTunnel` +#### P3.1 FightScene Split Enables Dead Code Elimination +- After Week 9 split, unused choreography modules can be tree-shaken +- Vite dynamic imports for choreography categories (load on demand) +- Split point: `import('./choreography/food.ts')` only when food moves are picked -`choreography/silly.ts`: -- `pillowFight`, `tickleAttack`, `selfieStun`, `vibeCheck`, `dabOnEm` -- `yeetThrow`, `airHorn`, `rubberChicken`, `bananaPeelChain`, `confettiCannon` -- `invisibleWall`, `slipNSlide`, `trampolineBounce`, `bubbleWrap`, `kazooBlast` +#### P3.2 CDN & Caching Strategy +- Static assets: immutable hash filenames, `Cache-Control: max-age=31536000` +- API responses: `Cache-Control: no-store` for fight data, `max-age=60` for leaderboard +- Service worker: Stale-while-revalidate for sprites/audio +- Compress all API responses with gzip (Hono middleware) -### Day 19: Wire all 300 choreographies -- Register all in `choreographyMap` -- Update `CHALLENGE_THEMED` with new move pools per challenge type -- Balance `pickChoreography` weights for maximum variety -- Add new moves to `critMoves` and `wild` arrays +#### P3.3 Load Test Results → Optimization +- Profile under simulated 100-spectator load +- Identify: SSE fan-out bottleneck, DB query hotspots, memory ceiling +- Optimize based on actual profiling data, not guesses +- Document capacity ceiling: "This VPS handles X concurrent fights with Y spectators" -**Test:** Run 50 fight replays, verify no crashes, observe variety. +#### P3.4 Production Monitoring +**Files:** `server/src/engine/analytics.ts` +- Log slow queries (> 50ms) to structured log +- Track: p50/p95 fight duration, webhook response latency, payment settlement time +- Memory/CPU metrics every 60s to analytics table +- Alert threshold: memory > 200MB, fight error rate > 5%, payment failure > 1% ---- +### Mobile-Specific Performance -## Phase 4: Challenges (Days 20-22) +#### Touch & Rendering +- Canvas rendering: limit to 30fps on mobile (save battery), 60fps on desktop +- Disable parallax scrolling and complex CSS animations on mobile +- Use `will-change: transform` sparingly (GPU memory cost) +- Intersection Observer for off-screen fight cards (don't render invisible content) -Scale from ~45 prompts to ~10,000 challenges. Hilarious, modern, pop culture, stupid/silly. - -### Day 20: Challenge bank architecture -``` -server/src/engine/challenges/ - banks/ - speed-blitz.ts -- 1,200+ prompts - riddle.ts -- 500+ riddles - code-golf.ts -- 400+ challenges - roast-battle.ts -- 600+ roast prompts - hallucination.ts -- 800+ true/false - token-economy.ts -- 500+ explain prompts - creative-writing.ts -- 400+ creative prompts - math-blitz.ts -- 1,500+ math problems (many templated) - trap-card.ts -- 600+ prompt injections - meme-knowledge.ts -- 500+ meme identification (NEW TYPE) - emoji-translate.ts -- 400+ emoji challenges (NEW TYPE) - debate.ts -- 300+ debate topics (NEW TYPE) - fill-the-blank.ts -- 400+ completions (NEW TYPE) - reverse-engineer.ts -- 300+ output puzzles (NEW TYPE) - survival.ts -- 500+ escalating difficulty (NEW TYPE) -``` - -### Day 21: Build prompt banks (~10,000 total) -- Mix of hand-written and procedural templates -- Pop culture: "Is it true Elon Musk once fought a kangaroo?", "What would happen if the Minecraft creeper attended a job interview?" -- Modern/silly: "Explain blockchain to a medieval peasant", "Write a breakup text from a printer to its owner" -- Procedural templates for math/trivia: fill in numbers, countries, units - -### Day 22: New challenge types + mock answers -- 6 new types: meme_knowledge, emoji_translate, debate, fill_the_blank, reverse_engineer, survival -- Scoring for each, mock answers for all 100 bots -- Wire into frontend `CHALLENGE_THEMED` - ---- - -## Phase 5: Audio (Days 23-25) - -### Day 23: 16 new music tracks (20 total) -Each track ~40 lines. Genre variety: -- 2x Synthwave (180-200 BPM), 2x Metal (250-280 BPM) -- 2x Chiptune (200-220 BPM), 2x Jazz Fusion (160-180 BPM) -- 2x Drum & Bass (170-175 BPM), 2x Lo-fi (140-160 BPM) -- 2x Ska Punk (210-230 BPM), 2x Boss Fight (280-300 BPM) - -Intensity-based genre grouping: -- Low: lo-fi, jazz, chiptune | Medium: synthwave, ska punk -- High: metal, drum & bass | Max: boss fight - -### Day 24: 24 new voice profiles (30 total) -New: whisper, manic, gravelly, chipper, deadpan, sports_caster, old_timey, drill_sergeant, surfer_dude, valley_girl, pirate_voice, robot_glitch, auctioneer, news_anchor, horror_narrator, lullaby, rage_quit, stoner, brit_posh, aussie, game_show, wrestling_announcer, noir_detective, anime_narrator - -Expand voice line libraries to 300+ unique lines. - -### Day 25: New SFX + arena-specific ambience -- 20 new SFX for new choreography types -- Arena-specific ambient background layers (beach waves, space hum, forest chirps) - ---- - -## Phase 6: Scenes & Polish (Days 26-27) - -### Day 26: 15+ new arenas (30+ total) -volcano, underwater, rooftop, train, kitchen, arcade, library, parking_lot, school, hospital, factory, castle, moon_base, ice_rink, stadium - -New modifiers: fire_boost, ice_slow, gravity_low, double_damage, chaos_mode - -### Day 27: Scene transitions + animated elements -- 4 transition styles: wipe, circle close/open, pixel dissolve, glitch cut -- Animated arena backgrounds: floating particles, dynamic lighting -- Enhanced spectator crowd reactions tied to round outcomes - ---- - -## Phase 7: Production Loop (Days 28-30) - -### Day 28: Fight scheduler + matchmaking -`server/src/engine/scheduler.ts`: -- `startScheduler(intervalMs)` -- Elo-based matchmaking loop -- Routes: `POST /api/scheduler/start|stop`, `GET /api/scheduler/status` -- Queue with rate limiting + bot cooldown - -### Day 29: Headless mode + overnight operation -- `pnpm run fight-loop` for headless scheduler -- Env vars: `FIGHT_INTERVAL_MS`, `FIGHTS_PER_BATCH`, `MAX_CONCURRENT` -- Structured JSON logging, graceful SIGTERM shutdown -- `GET /api/stats` endpoint, health monitoring - -### Day 30: Final seed data + integration test -- Generate: 100 bots, 500+ fight history -- Verify: Elo bell curve, tier distribution -- Full E2E: seed -> dev -> replay 10 fights -> fight-loop -> 100 fights -> typecheck + lint +#### Network +- Preconnect to API server: `` +- Prefetch next likely page (e.g., fight card → fight page) +- Compress SSE payloads (only send changed fields, not full state) +- Offline-first: service worker serves cached shell immediately --- ## Dependency Graph ``` -Phase 1 (Split) --- BLOCKS EVERYTHING - | - +-- Phase 2 (Characters) -- independent after Phase 1 - +-- Phase 3 (Choreographies) -- independent after Phase 1 - +-- Phase 4 (Challenges) -- independent after Phase 1 - +-- Phase 5 (Audio) -- independent after Phase 1 - | - +-- Phase 6 (Scenes) -- needs Phase 1 + Phase 3 - +-- Phase 7 (Production) -- needs Phase 2 + Phase 4 +Month 1 Month 2 Month 3 +─────── ─────── ─────── +W1 Spectating ──────────────────────────────────────────────── W9 FightScene Split +W2 Betting UI ──┐ W10 Performance +W3 Nostr Social │ W5 Tournaments ──────────────── W11 Admin +W4 Seasons ─────┤ W6 Human Play W12 Launch + │ W7 Content ─────────────────── W12 Launch + └──────────────→ W8 Choreography ─────────────→ W9 FightScene Split ``` -## Testing Strategy +- Weeks 1–4 are mostly independent of each other (parallelize) +- Week 5 (Tournaments) needs Week 2 (Betting) for entry fees +- Week 6 (Human Play) needs Week 1 (Spectating) for live sync +- Week 9 (Split) should happen after Week 8 (Choreography) to avoid splitting then adding +- Week 12 (Launch) needs everything -After every day: -1. `pnpm typecheck` -- catches import/export errors -2. Sprite preview page -- verify all archetypes render -3. Watch 5 random fight replays -- no crashes, visual variety -4. Audio check -- music, voices, SFX all play -5. `pnpm seed` -- verify DB has expected data +## Risk Mitigation -## Critical Files +| Risk | Mitigation | +|------|-----------| +| FightScene.ts too fragile to split | Comprehensive replay testing before/after; git branch for safe rollback | +| SQLite bottleneck under load | WAL mode + connection pooling; monitor write contention; Turso migration path if needed | +| NWC wallet failures | Graceful fallback to Cashu; refund queue for stuck payments | +| Choreography visual bugs | Automated screenshot comparison for 20 reference fights | +| Scope creep | Each week is self-contained; can ship at end of any week | -- `frontend/src/game/sprites.ts` -> split into `sprites/` directory -- `frontend/src/game/FightScene.ts` -> split into `fight/` directory -- `frontend/src/game/sounds.ts` -> split into `sounds/` directory -- `frontend/src/components/FightViewer.vue` -> update imports -- `server/src/engine/challenges.ts` -> split into `challenges/` directory -- `server/src/engine/mock.ts` -> split into `mock/` + expand to 100 bots -- `server/src/engine/arenas.ts` -> expand to 30+ arenas -- `server/src/seed.ts` -> update imports -- `server/src/routes/fights.ts` -> add scheduler routes +## Success Metrics (End of Month 3) + +**Content:** +- 500+ choreographies, 2,000+ challenge prompts, 40 arenas, 20 music tracks + +**Adoption:** +- 10+ real bot developers registered and competing +- Tournaments running daily with real sats +- Betting volume: 10,000+ sats/day +- Mobile PWA installs > 0 (any adoption = success for v1) + +**Reliability:** +- Zero stuck fights, zero lost payments over 7-day period +- Fight loop stable for 72hr continuous operation + +**Performance:** +- Initial bundle < 300KB gzipped (excluding lazy-loaded TTS) +- Time to first fight frame < 2s on 4G mobile +- 50-fight replay session with flat memory (no leaks) +- Server stable at < 250MB RSS after 24hr fight-loop +- 20 concurrent fights + 100 spectators without degradation +- Docker image stays < 50MB +- SQLite handles 10K fights without query degradation (< 50ms p95) diff --git a/.claude/plans/greedy-skipping-lollipop.md b/.claude/plans/greedy-skipping-lollipop.md new file mode 100644 index 0000000..dccf4d3 --- /dev/null +++ b/.claude/plans/greedy-skipping-lollipop.md @@ -0,0 +1,214 @@ +# Production-Grade Botfights: Implementation Plan + +## Context + +Botfights is a competitive game where users register AI bots with webhook URLs. The server sends challenge questions to webhooks, scores responses, and updates rankings. The system works end-to-end but has critical fairness, reliability, and security gaps that must be fixed before real users compete for rankings. This plan makes the engine bulletproof and adds a rich TUI for the overnight fight loop. + +--- + +## Phase 1: Fight Integrity (Foundation) + +Everything depends on fights producing correct, trustworthy results. + +### 1.1 Schema additions +**File:** `server/src/db/schema.ts`, `server/src/db/migrate.ts` +- Add to `bots`: `lastFightAt TEXT`, `consecutiveErrors INTEGER DEFAULT 0`, `lastErrorAt TEXT` +- Add migration SQL for existing DBs + +### 1.2 Transaction-wrap Elo updates +**Files:** `server/src/engine/orchestrator.ts` (lines 276-306), `server/src/engine/mock.ts` (lines 392-421) +- Wrap fight finalization (status update + both bot stat updates) in a single SQLite transaction +- Prevents partial Elo corruption on crash or concurrent fights + +### 1.3 Block self-fights +**Files:** `server/src/engine/orchestrator.ts`, `server/src/engine/mock.ts`, `server/src/routes/fights.ts` +- Add `if (botAId === botBId) throw new Error('A bot cannot fight itself')` at top of `runFight`, `runFightAsync`, `runMockFight` + +### 1.4 Prevent concurrent fights for same bot +**File:** `server/src/engine/orchestrator.ts` +- In-memory `Set` of currently-fighting bot IDs +- Check before starting, add on start, remove in `.finally()` + +### 1.5 Crash recovery for stuck fights +**File:** `server/src/engine/orchestrator.ts` +- In `runFightAsync` catch handler: mark fight `status='cancelled'`, call `fightEvents.cleanup()` +- On server startup: mark any `status='live'` fights older than 10 minutes as `cancelled` + +### 1.6 Fix creative scoring (anti-gaming) +**File:** `server/src/engine/scoring.ts` (lines 166-174) +- Replace `estimateQuality` with multi-factor heuristic: character diversity, word diversity, length window (30-400 ideal), speed bonus +- Prevents gaming by dumping 500 chars of garbage text + +### 1.7 Tighten factual answer checking +**File:** `server/src/engine/answers.ts` (line 78) +- Add word-boundary awareness: `containsWholeWord()` helper using regex `\b` +- Short answers (2 chars like "au", "fe") require near-exact match, not just containment +- Prevents "I feel confident" matching accepted answer "fe" + +### 1.8 Dampen mock-bot Elo farming +**File:** `server/src/engine/orchestrator.ts` +- When one combatant is a mock bot, use K-factor 12 instead of 32 +- Real bot-vs-real bot fights keep K=32 for full stakes + +--- + +## Phase 2: Webhook Contract & Developer Experience + +### 2.1 Response size limits +**File:** `server/src/engine/orchestrator.ts` (line 83) +- Replace `res.text()` with size-limited reader (10KB max) +- Truncate `answer` to 2000 chars, `trash_talk` to 200 chars +- Prevents OOM attacks from malicious webhooks + +### 2.2 SSRF protection +**File:** `server/src/engine/orchestrator.ts` (new `isAllowedWebhookUrl` function) +- Block localhost, private IPs (10.x, 192.168.x, 172.16-31.x), AWS metadata (169.254.169.254), .local/.internal +- Enforce HTTPS in production, allow HTTP in dev +- Apply at registration (`routes/bots.ts`, `routes/auth.ts`) AND at call time + +### 2.3 Pre-fight webhook verification +**New file:** `server/src/engine/webhook-test.ts` +- `testWebhook(url)`: sends a test challenge (`"respond with {"answer": "pong"}"`), validates response shape and latency +- Returns `{ reachable, validResponse, latencyMs, error? }` + +**Integrate into:** +- `POST /api/auth/register` -- test webhook before inserting bot. Reject with specific error if it fails. +- `POST /api/bots` -- same +- New route `POST /api/bots/:name/test` -- re-test webhook on demand (replaces weak `/health` check) + +### 2.4 Webhook reliability tracking +**File:** `server/src/engine/orchestrator.ts` +- After each webhook call: increment `consecutiveErrors` on failure, reset to 0 on success +- If `consecutiveErrors >= 5`: mark bot `isActive: false`, skip in matchmaking +- Bot owner must re-test webhook to reactivate + +### 2.5 Add `fight_id` to webhook payload +**File:** `server/src/engine/orchestrator.ts` (line 48) +- Thread `fightId` through `callWebhook` and `getBotResponse` +- Bot developers can correlate challenge POSTs to specific fights for debugging + +--- + +## Phase 3: Anti-Gaming & Security + +### 3.1 Rate limiting +**New file:** `server/src/middleware/rate-limit.ts` +- Simple in-memory rate limiter (no new deps), per-IP sliding window +- Apply: `POST /api/bots` (5/hr), `POST /api/auth/register` (5/hr), `POST /api/queue/join` (1 per 10s per bot), all other POSTs (60/min) + +### 3.2 Case-insensitive name uniqueness +**Files:** `server/src/routes/bots.ts` (line 39), `server/src/routes/auth.ts` (line 78) +- Force bot names to lowercase at registration time +- Prevents name squatting ("MyBot" vs "mybot") + +### 3.3 Queue cooldowns +**File:** `server/src/engine/queue.ts` +- In-memory `Map` of post-fight cooldowns (15 seconds) +- Set cooldown after fight completes (called from `orchestrator.ts`) +- Reject queue join if cooldown active + +### 3.4 Concurrent fight checks in queue +**File:** `server/src/engine/queue.ts` +- Check the `activeFighters` set (from 1.4) before allowing queue join +- Prevents a bot from queueing while already in a fight + +--- + +## Phase 4: TUI Fight Loop + +Minimal deps -- only `chalk` for colors. All layout via ANSI codes and Unicode box drawing. + +### 4.1 Add dependency +`chalk@5` to `server/package.json` + +### 4.2 TUI state tracker +**New file:** `server/src/tui/state.ts` +- `TuiState` interface: fight count, KOs, perfects, draws, errors, current fight (bots/HP/round/events), recent fights, leaderboard, biggest upset, Elo movers, elapsed time + +### 4.3 TUI renderer +**New file:** `server/src/tui/renderer.ts` + +``` ++=================== BOTFIGHTS OVERNIGHT LOOP ====================+ +| Fight #47 of 200 Elapsed: 12m 34s | +| Style: mixed Rate: 3.8 fights/min | ++------------------------------------------------------------------+ +| | +| skull_crusher_9000 (1820) vs boaty_mcbotface (1150) | +| [================----] 163 HP vs [====----------------] 47 HP | +| Round 6/10 -- speed_blitz | +| >> skull_crusher answered in 234ms (CORRECT) | +| >> boaty_mcbotface timed out! FREE HIT! | +| | ++========================= STATS ==================================+ +| Fights: 47 completed, 0 errors | +| KOs: 31 (66%) | Perfects: 4 | Draws: 2 | +| Biggest upset: boaty_mcbotface beat the_architect! | ++========================= LEADERBOARD ============================+ +| #1 the_architect 1980 52W-8L LEGEND | +| #2 chad_gpt 1950 48W-10L LEGEND | +| #3 skull_crusher_9000 1820 35W-12L DIAMOND | ++========================= RECENT ================================+ +| #47 skull_crusher vs boaty -> skull_crusher (KO R6) | +| #46 chad_gpt vs lorem_ipsum -> chad_gpt (PERFECT R3) | +| #45 regex_ronin vs the_intern -> regex_ronin (Decision) | ++==================================================================+ +``` + +- Single buffered write to avoid flicker +- Handles terminal resize via `process.stdout.on('resize')` +- Tier colors via chalk + +### 4.4 Refactor fight loop with callbacks +**File:** `server/src/engine/fight-loop.ts` +- Add callback options: `onFightStart`, `onRoundComplete`, `onFightComplete`, `onError` +- Wire event bus so TUI gets live round-by-round updates + +### 4.5 Rewrite CLI +**File:** `server/src/fight-loop-cli.ts` +- Create TUI state + renderer, pass callbacks to fight loop +- Graceful SIGINT: show final summary screen +- Same CLI args (`--max`, `--interval`, `--style`) + +### 4.6 Final summary screen +On loop end or Ctrl+C: duration, total fights, KO/perfect/draw rates, top Elo movers, biggest upset, most active bot + +--- + +## Phase 5: Verification + +### 5.1 Manual test sequence +1. `pnpm seed` -- verify schema migrations run +2. `pnpm dev` -- verify server starts, orphaned fights cleaned up on startup +3. Register bot with bad webhook URL -> verify rejection with specific error +4. Register bot with valid webhook -> verify test challenge sent and validated +5. Try self-fight via `/api/fights/matchmake` -> verify blocked +6. Run `pnpm fight-loop --max=10` -> verify TUI renders, stats update live +7. Kill process mid-fight, restart -> verify stuck fights cleaned up +8. Send oversized response from test webhook -> verify 10KB limit +9. Rapid-fire queue joins -> verify rate limiting and cooldowns + +--- + +## Files Summary + +**Modified (11 files):** +- `server/src/db/schema.ts` -- new columns +- `server/src/db/migrate.ts` -- migration SQL +- `server/src/engine/orchestrator.ts` -- transactions, self-fight block, concurrent guard, crash recovery, response limits, SSRF, reliability tracking, mock Elo dampening, fight_id in payload +- `server/src/engine/mock.ts` -- transaction wrap, self-fight block +- `server/src/engine/scoring.ts` -- creative scoring rewrite +- `server/src/engine/answers.ts` -- word boundary fixes +- `server/src/engine/queue.ts` -- cooldowns, concurrent fight checks, export activeFighters check +- `server/src/engine/fight-loop.ts` -- callback options for TUI +- `server/src/fight-loop-cli.ts` -- TUI integration +- `server/src/app.ts` -- rate limiting, startup cleanup +- `server/package.json` -- add chalk + +**Created (4 files):** +- `server/src/engine/webhook-test.ts` -- pre-fight webhook verification +- `server/src/middleware/rate-limit.ts` -- rate limiter +- `server/src/tui/state.ts` -- TUI state tracker +- `server/src/tui/renderer.ts` -- TUI renderer + +**Execution order:** Phase 1 (1.1-1.8) -> Phase 2 (2.1-2.5) -> Phase 3 (3.1-3.4) -> Phase 4 (4.1-4.6) -> Phase 5 verification diff --git a/.claude/plans/precious-sniffing-catmull.md b/.claude/plans/precious-sniffing-catmull.md new file mode 100644 index 0000000..a379cab --- /dev/null +++ b/.claude/plans/precious-sniffing-catmull.md @@ -0,0 +1,36 @@ +# Plan: Create Zaps.md Implementation Guide + +## Context +The user wants a `Zaps.md` planning document at the project root that serves as a comprehensive implementation guide for a future Claude session. When the user says "implement zaps", Claude reads this doc and knows exactly what to build. + +## What to create +- **File**: `/Users/dorian/Projects/botfights/Zaps.md` + +## Key design decisions from research + +### Payment model: NWC (primary) + Cashu (alternative) +- NWC (NIP-47): User connects Lightning wallet once. Server sends encrypted payment requests via Nostr relay. Use nostr-tools directly (no Alby SDK) for privacy. +- Cashu ecash: Alternative — user pastes ecash token, server redeems with mint. +- Server wallet: Small Lightning wallet (Alby Hub or LNbits) via `BOTFIGHTS_NWC_URL` env var. Transient escrow only (seconds, not balances). + +### Two modes: Free (unchanged) + Ranked (new, 21 sats) +- Ranked: both pay 21 sats → winner gets 42 sats +- Ranked queue never matches against mock bots +- Timeout = refund (not mock fallback) + +### New files needed +- DB tables: `payments`, `wallet_connections` + new columns on fights/bots +- Server: `engine/payments.ts`, `engine/ranked-queue.ts`, `routes/payments.ts` +- Frontend: `composables/useWallet.ts`, `components/WalletConnect.vue` +- Modifications to: orchestrator, queue routes, JoinBoutPage, FightPage, BotProfilePage, schema + +### Dependencies (all MIT) +- `nostr-tools` ^2.x — NWC protocol +- `@cashu/cashu-ts` ^2.x — ecash tokens + +## Implementation +Write Zaps.md with full architecture, schema SQL, function signatures, edge cases, phases, and security notes — enough for a fresh Claude session to implement from. + +## Verification +- Read the written Zaps.md and confirm completeness +- Ensure all file paths match actual codebase structure diff --git a/botfights.db b/botfights.db new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index beacc21..6f7b45b 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -643,9 +643,9 @@ async function _doReplay() {
-

+ :style="{ '--announce-color': announcementColor }"> {{ announcement }}

@@ -720,20 +720,24 @@ async function _doReplay() { position: relative; } -.announce-text { - animation: announce-pulse 0.4s ease-in-out infinite alternate, announce-hue 2s linear infinite; - -webkit-text-stroke: 1px rgba(0,0,0,0.3); +/* Colourful 3D extruded text */ +.announce-text-3d { + color: var(--announce-color, #ffffff); + paint-order: stroke fill; + -webkit-text-stroke: 2px rgba(0,0,0,0.6); + text-shadow: + 2px 2px 0 #1a0020, + 4px 4px 0 #2a0040, + 6px 6px 0 #3a0060, + 8px 8px 0 #4d0080, + 0 0 20px var(--announce-color, #ffffff), + 0 0 60px var(--announce-color, #ffffff); + will-change: transform; + animation: announce-3d-pulse 0.35s ease-in-out infinite alternate; } -@keyframes announce-pulse { - from { transform: scale(1) rotate(-1deg); } - to { transform: scale(1.08) rotate(1deg); } -} -@keyframes announce-hue { - 0% { filter: hue-rotate(0deg) brightness(1); } - 25% { filter: hue-rotate(15deg) brightness(1.1); } - 50% { filter: hue-rotate(0deg) brightness(1.2); } - 75% { filter: hue-rotate(-15deg) brightness(1.1); } - 100% { filter: hue-rotate(0deg) brightness(1); } +@keyframes announce-3d-pulse { + from { transform: scale(1) rotate(-0.5deg); } + to { transform: scale(1.06) rotate(0.5deg); } } /* Chromatic aberration on announcements */ @@ -748,8 +752,11 @@ async function _doReplay() { left: 0; right: 0; text-align: center; - opacity: 0.5; + opacity: 0.35; pointer-events: none; + text-shadow: none; + -webkit-text-stroke: 0; + will-change: transform; } .announce-chromatic::before { color: #ff2d7b; diff --git a/frontend/src/components/SpritePreview.vue b/frontend/src/components/SpritePreview.vue index 8a71cbc..eff2ec5 100644 --- a/frontend/src/components/SpritePreview.vue +++ b/frontend/src/components/SpritePreview.vue @@ -55,6 +55,8 @@ watch(() => [props.seed, props.archetype, props.customization, props.pose], () = onUnmounted(() => { if (animHandle) clearTimeout(animHandle) + animHandle = null + if (img) { img.onload = null; img.src = ''; img = null } }) diff --git a/frontend/src/composables/useWallet.ts b/frontend/src/composables/useWallet.ts index a34baad..488965e 100644 --- a/frontend/src/composables/useWallet.ts +++ b/frontend/src/composables/useWallet.ts @@ -38,9 +38,14 @@ function parseNwcUrl(url: string): NwcConfig { const relay = params.get('relay') const secret = params.get('secret') if (!pubkey || !relay || !secret) { - throw new Error('Invalid NWC URL') + throw new Error('Invalid NWC URL: missing pubkey, relay, or secret') } - return { pubkey, relay, secret: hexToBytes(secret) } + // Validate hex before parsing — must be even-length hex string + const trimmed = secret.trim() + if (trimmed.length === 0 || trimmed.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(trimmed)) { + throw new Error('Invalid NWC URL: secret is not valid hex (must be even-length hex string)') + } + return { pubkey, relay, secret: hexToBytes(trimmed) } } export function useWallet() { @@ -159,7 +164,11 @@ export function useWallet() { // If NWC connected, auto-pay via NWC and confirm directly const nwcUrl = localStorage.getItem('bf_nwc_url') + let nwcValid = false if (nwcUrl) { + try { parseNwcUrl(nwcUrl); nwcValid = true } catch { /* bad stored URL — fall through to poll */ } + } + if (nwcUrl && nwcValid) { paymentStatus.value = 'paying' const preimage = await payViaNWC(nwcUrl, bolt11) diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index d661242..6cda3a7 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -289,8 +289,9 @@ if (typeof speechSynthesis !== 'undefined') { speechSynthesis.onvoiceschanged = loadVoices loadVoices() // Chrome bug workaround: speechSynthesis pauses after ~15s. - // Periodic resume() keeps it alive. + // Periodic resume() keeps it alive — but only if Kokoro isn't handling TTS. setInterval(() => { + if (isKokoroReady()) return // Kokoro active — don't touch Web Speech if (speechSynthesis.speaking && !speechSynthesis.paused) return if (speechSynthesis.paused) speechSynthesis.resume() }, 5000) @@ -298,7 +299,7 @@ if (typeof speechSynthesis !== 'undefined') { if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { - if (speechSynthesis.paused) speechSynthesis.resume() + if (!isKokoroReady() && speechSynthesis.paused) speechSynthesis.resume() if (ctx?.state === 'suspended') ctx.resume().catch(() => {}) } }) @@ -309,16 +310,28 @@ let _speechQueueDepth = 0 // Scale speech volume down so voice doesn't overpower SFX/music const VOICE_VOLUME_SCALE = 0.7 -function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) { +let _webSpeechActive = false // track if Web Speech has queued utterances + +function _cancelWebSpeech() { + if (typeof speechSynthesis !== 'undefined' && _webSpeechActive) { + speechSynthesis.cancel() + _speechQueueDepth = 0 + _webSpeechActive = false + } +} + +export function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) { if (masterMuted) return // Try Kokoro TTS first — high quality, no browser bugs if (isKokoroReady() && sfxGain) { + // Kill any lingering Web Speech utterances so voices don't double + _cancelWebSpeech() const profile = voiceProfiles[profileName] || voiceProfiles.announcer if (cancelPrevious) kokoroStop() kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) return } - // Fallback: Web Speech API + // Fallback: Web Speech API (only used while Kokoro model is loading) if (typeof speechSynthesis === 'undefined') return if (!voicesLoaded) loadVoices() if (speechSynthesis.paused) speechSynthesis.resume() @@ -335,21 +348,30 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals utter.rate = profile.rate utter.volume = profile.volume * VOICE_VOLUME_SCALE _speechQueueDepth++ - utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } + _webSpeechActive = true + utter.onend = () => { + _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) + if (_speechQueueDepth === 0) _webSpeechActive = false + } utter.onerror = (ev) => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) + if (_speechQueueDepth === 0) _webSpeechActive = false if (ev.error !== 'canceled' && ev.error !== 'interrupted') { - setTimeout(() => { - if (!masterMuted && typeof speechSynthesis !== 'undefined') { - const retry = new SpeechSynthesisUtterance(text) - if (profile.voice) retry.voice = profile.voice - retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE - _speechQueueDepth++ - retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } - retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } - speechSynthesis.speak(retry) - } - }, 200) + // Only retry if Kokoro still isn't ready — avoid doubling + if (!isKokoroReady()) { + setTimeout(() => { + if (!masterMuted && !isKokoroReady() && typeof speechSynthesis !== 'undefined') { + const retry = new SpeechSynthesisUtterance(text) + if (profile.voice) retry.voice = profile.voice + retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE + _speechQueueDepth++ + _webSpeechActive = true + retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false } + retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); if (_speechQueueDepth === 0) _webSpeechActive = false } + speechSynthesis.speak(retry) + } + }, 200) + } } } speechSynthesis.speak(utter) @@ -364,6 +386,7 @@ async function _speakAsyncCore(text: string, profileName: string, rateOverride?: if (masterMuted) return // Try Kokoro TTS first if (isKokoroReady() && sfxGain) { + _cancelWebSpeech() if (cancelPrevious) kokoroStop() const profile = voiceProfiles[profileName] || voiceProfiles.announcer const handled = await kokoroSpeakAsync(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE) @@ -427,6 +450,7 @@ export function stopAllAudio() { kokoroStop() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() _speechQueueDepth = 0 + _webSpeechActive = false // Disconnect gain nodes to instantly kill all in-flight oscillators/buffers, // then reconnect so future sounds still work if (ctx) { @@ -446,29 +470,9 @@ export function stopAllAudio() { } // Public voice functions -export function announce(text: string, pitch?: number, rate?: number) { - if (masterMuted) return - // Kokoro ignores pitch/rate overrides — just use the announcer profile - if (isKokoroReady()) { - speak(text, 'announcer') - return - } - if (pitch !== undefined || rate !== undefined) { - if (typeof speechSynthesis === 'undefined') return - if (!voicesLoaded) loadVoices() - const utter = new SpeechSynthesisUtterance(text) - const profile = voiceProfiles.announcer - if (profile.voice) utter.voice = profile.voice - utter.pitch = pitch ?? 1.0 - utter.rate = rate ?? 0.8 - utter.volume = VOICE_VOLUME_SCALE - _speechQueueDepth++ - utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } - utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) } - speechSynthesis.speak(utter) - } else { - speak(text, 'announcer') - } +export function announce(text: string, _pitch?: number, _rate?: number) { + // Always route through speak() — prevents doubled voices from separate Web Speech paths + speak(text, 'announcer') } export function announceDeep(text: string) { speak(text, 'deep') } @@ -2882,9 +2886,11 @@ export async function ensureAudioContext() { if (c.state === 'suspended') { try { await c.resume() } catch {} } - // Share AudioContext with kokoro and start loading the model + // Share AudioContext with kokoro and start loading the model. + // Defer initKokoro so the click handler finishes first — the 4.8MB module + // parse + 86MB model download happen after the UI is unblocked. setAudioContext(c) - initKokoro() + setTimeout(() => initKokoro(), 0) // Prime speech synthesis as fallback — mobile browsers require // a speak() call inside a user gesture to unlock speechSynthesis if (typeof speechSynthesis !== 'undefined') { diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts index 92323b9..e24eaf4 100644 --- a/frontend/src/game/sprites/index.ts +++ b/frontend/src/game/sprites/index.ts @@ -19,10 +19,22 @@ export interface SpriteCustomization { forceHorns?: boolean } +// Sprite sheet cache — avoids regenerating expensive canvas work +const _spriteCache = new Map() +const MAX_SPRITE_CACHE = 40 + +function _spriteCacheKey(seed: string, tier: number, primary: string, secondary: string, arch?: string, cust?: SpriteCustomization): string { + return `${seed}|${tier}|${primary}|${secondary}|${arch || ''}|${cust ? JSON.stringify(cust) : ''}` +} + export function generateSpriteSheet( seed: string, tier: number, primaryColor: string, secondaryColor: string, archetypeOverride?: string, customization?: SpriteCustomization, ): string { + const cacheKey = _spriteCacheKey(seed, tier, primaryColor, secondaryColor, archetypeOverride, customization) + const cached = _spriteCache.get(cacheKey) + if (cached) return cached + const canvas = document.createElement('canvas') canvas.width = FRAME_SIZE * MAX_FRAMES canvas.height = FRAME_SIZE * TOTAL_ROWS @@ -551,7 +563,13 @@ export function generateSpriteSheet( for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFrame(f, row, pose, cfg.frames - 1, cfg.frames) } - return canvas.toDataURL() + const dataUrl = canvas.toDataURL() + if (_spriteCache.size >= MAX_SPRITE_CACHE) { + const oldest = _spriteCache.keys().next().value + if (oldest) _spriteCache.delete(oldest) + } + _spriteCache.set(cacheKey, dataUrl) + return dataUrl } /** Load a sprite sheet data URL into a canvas (async for reliable image decode) */ diff --git a/frontend/src/game/tts-worker.ts b/frontend/src/game/tts-worker.ts new file mode 100644 index 0000000..18d1f20 --- /dev/null +++ b/frontend/src/game/tts-worker.ts @@ -0,0 +1,51 @@ +// Web Worker for Kokoro TTS — runs ONNX neural network inference off the main thread. +// This prevents the 1-5 second freezes that occur when generate() runs on the UI thread. + +let tts: any = null + +self.addEventListener('message', async (e: MessageEvent) => { + const msg = e.data + + switch (msg.type) { + case 'init': { + try { + const { KokoroTTS } = await import('kokoro-js') + tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', { + dtype: 'q8', + device: null, + progress_callback: (p: any) => { + if (p.progress !== undefined) { + self.postMessage({ type: 'progress', progress: p.progress }) + } + }, + }) + self.postMessage({ type: 'init-done' }) + } catch (err) { + self.postMessage({ type: 'init-failed', error: String(err) }) + } + break + } + + case 'generate': { + if (!tts) { + self.postMessage({ type: 'generate-failed', id: msg.id, error: 'not initialized' }) + break + } + try { + const result = await tts.generate(msg.text, { + voice: msg.voice, + speed: msg.speed, + }) + // Copy to a standalone ArrayBuffer so we can transfer ownership (zero-copy to main thread) + const audio = new Float32Array(result.audio) + self.postMessage( + { type: 'generate-done', id: msg.id, audio, sampleRate: result.sampling_rate }, + { transfer: [audio.buffer] }, + ) + } catch (err) { + self.postMessage({ type: 'generate-failed', id: msg.id, error: String(err) }) + } + break + } + } +}) diff --git a/frontend/src/game/tts.ts b/frontend/src/game/tts.ts index 651fd06..5bfc4f1 100644 --- a/frontend/src/game/tts.ts +++ b/frontend/src/game/tts.ts @@ -1,9 +1,7 @@ // Kokoro TTS engine — high-quality client-side text-to-speech -// Lazy-loads the 86MB q8 model on first use, caches in browser storage. +// All ONNX inference runs in a Web Worker so the main thread never freezes. // Falls through to speechSynthesis if model isn't ready yet. -import type { KokoroTTS as KokoroTTSType } from 'kokoro-js' - type KokoroVoice = 'af_heart' | 'af_alloy' | 'af_aoede' | 'af_bella' | 'af_jessica' | 'af_kore' | 'af_nicole' | 'af_nova' | 'af_river' | 'af_sarah' | 'af_sky' | 'am_adam' | 'am_echo' | 'am_eric' | 'am_fenrir' | 'am_liam' | 'am_michael' | 'am_onyx' | 'am_puck' | 'am_santa' | @@ -107,16 +105,23 @@ const VOICE_MAP: Record = { // Default fallback const DEFAULT_VOICE: VoiceMapping = { voice: 'am_fenrir', speed: 1.0 } -let ttsInstance: KokoroTTSType | null = null -let ttsLoading = false -let ttsLoadFailed = false +// --- Worker state --- +let _worker: Worker | null = null +let _workerReady = false +let _workerLoading = false +let _workerFailed = false +let _nextReqId = 0 + +// Pending worker requests: id → resolve/reject +const _pending = new Map void + reject: (e: Error) => void +}>() + +// --- Audio state (main thread only) --- let _audioCtx: AudioContext | null = null - -// Audio cache: key = "voice:speed:text" → AudioBuffer const audioCache = new Map() -const MAX_CACHE = 200 - -// Currently playing sources (for stop) +const MAX_CACHE = 30 const activeSources: Set = new Set() function getAudioCtx(): AudioContext { @@ -132,56 +137,84 @@ export function setAudioContext(ctx: AudioContext) { /** Is the kokoro model loaded and ready? */ export function isKokoroReady(): boolean { - return ttsInstance !== null + return _workerReady } /** Is the kokoro model currently loading? */ export function isKokoroLoading(): boolean { - return ttsLoading + return _workerLoading } -/** Start loading the kokoro model. Call early (e.g. on first user gesture). */ +/** Handle messages from the TTS worker */ +function _handleWorkerMessage(e: MessageEvent) { + const msg = e.data + if (msg.type === 'generate-done') { + const p = _pending.get(msg.id) + if (p) { + _pending.delete(msg.id) + p.resolve({ audio: msg.audio, sampleRate: msg.sampleRate }) + } + } else if (msg.type === 'generate-failed') { + const p = _pending.get(msg.id) + if (p) { + _pending.delete(msg.id) + p.reject(new Error(msg.error)) + } + } +} + +/** Start loading the kokoro model in a Web Worker. Call early (e.g. on first user gesture). */ export async function initKokoro(onProgress?: (pct: number) => void): Promise { - if (ttsInstance || ttsLoading || ttsLoadFailed) return - ttsLoading = true + if (_workerReady || _workerLoading || _workerFailed) return + _workerLoading = true + try { - const { KokoroTTS } = await import('kokoro-js') - ttsInstance = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', { - dtype: 'q8', - device: null, // auto-detect (WebGPU → WASM fallback) - progress_callback: onProgress ? (p: any) => { - if (p.progress !== undefined) onProgress(p.progress) - } : undefined, + _worker = new Worker(new URL('./tts-worker.ts', import.meta.url), { type: 'module' }) + + await new Promise((resolve, reject) => { + _worker!.onmessage = (e) => { + const msg = e.data + if (msg.type === 'init-done') { + _workerReady = true + _workerLoading = false + // Switch to persistent handler for generate responses + _worker!.onmessage = _handleWorkerMessage + resolve() + } else if (msg.type === 'init-failed') { + reject(new Error(msg.error)) + } else if (msg.type === 'progress' && onProgress) { + onProgress(msg.progress) + } + } + _worker!.onerror = (e) => { + reject(new Error(e.message || 'Worker failed to load')) + } + _worker!.postMessage({ type: 'init' }) }) - ttsLoading = false + // Pre-cache common fight phrases in the background _precacheCommon() } catch (e) { - console.warn('[kokoro] Failed to load TTS model:', e) - ttsLoading = false - ttsLoadFailed = true + console.warn('[kokoro] Failed to init TTS worker:', e) + _workerLoading = false + _workerFailed = true + if (_worker) { _worker.terminate(); _worker = null } } } // Common phrases to pre-generate so they play instantly const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [ - { text: 'K. O.!', profile: 'announcer' }, - { text: 'Devastating!', profile: 'deep' }, - { text: 'Flawless victory!', profile: 'deep' }, - { text: 'Finish it!', profile: 'announcer' }, - { text: 'Fatality!', profile: 'deep' }, { text: 'Round one!', profile: 'announcer' }, - { text: 'Round two!', profile: 'announcer' }, - { text: 'Round three!', profile: 'announcer' }, { text: 'Fight!', profile: 'announcer' }, + { text: 'K. O.!', profile: 'announcer' }, ] async function _precacheCommon() { - if (!ttsInstance) return for (const { text, profile } of PRECACHE_PHRASES) { + await new Promise(r => setTimeout(r, 50)) try { await _generateAndCache(text, profile) - } catch { /* swallow — non-critical */ } + } catch { /* non-critical */ } } } @@ -190,21 +223,38 @@ function _cacheKey(text: string, profile: string): string { return `${m.voice}:${m.speed}:${text}` } +/** Send a generate request to the worker and wait for the result (10s timeout) */ +function _workerGenerate(text: string, voice: string, speed: number): Promise<{ audio: Float32Array; sampleRate: number }> { + return new Promise((resolve, reject) => { + if (!_worker || !_workerReady) { + reject(new Error('worker not ready')) + return + } + const id = _nextReqId++ + const timeout = setTimeout(() => { + _pending.delete(id) + reject(new Error('TTS generation timed out')) + }, 10_000) + _pending.set(id, { + resolve: (v) => { clearTimeout(timeout); resolve(v) }, + reject: (e) => { clearTimeout(timeout); reject(e) }, + }) + _worker.postMessage({ type: 'generate', id, text, voice, speed }) + }) +} + async function _generateAndCache(text: string, profile: string): Promise { - if (!ttsInstance) return null + if (!_workerReady) return null const key = _cacheKey(text, profile) const cached = audioCache.get(key) if (cached) return cached const mapping = VOICE_MAP[profile] || DEFAULT_VOICE - const raw = await ttsInstance.generate(text, { - voice: mapping.voice, - speed: mapping.speed, - }) + const raw = await _workerGenerate(text, mapping.voice, mapping.speed) - // Convert Float32Array → AudioBuffer + // Convert Float32Array → AudioBuffer (lightweight, main thread) const ctx = getAudioCtx() - const buf = ctx.createBuffer(1, raw.audio.length, raw.sampling_rate) + const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate) buf.getChannelData(0).set(raw.audio) // Evict oldest if cache is full @@ -240,7 +290,7 @@ function _playBuffer(buf: AudioBuffer, dest: AudioNode, volume: number): Promise /** * Generate and play TTS. Returns a promise that resolves when done. - * Returns null if kokoro isn't ready (caller should fall back). + * Returns false if kokoro isn't ready (caller should fall back). */ export async function kokoroSpeakAsync( text: string, @@ -248,7 +298,7 @@ export async function kokoroSpeakAsync( dest: AudioNode, volume: number = 0.7, ): Promise { - if (!ttsInstance) return false + if (!_workerReady) return false try { const buf = await _generateAndCache(text, profileName) if (!buf) return false @@ -269,7 +319,7 @@ export function kokoroSpeak( dest: AudioNode, volume: number = 0.7, ): boolean { - if (!ttsInstance) return false + if (!_workerReady) return false // Check cache for instant playback const key = _cacheKey(text, profileName) const cached = audioCache.get(key) @@ -277,7 +327,7 @@ export function kokoroSpeak( _playBuffer(cached, dest, volume) return true } - // Generate async — will play when ready + // Generate in worker — will play when ready _generateAndCache(text, profileName).then(buf => { if (buf) _playBuffer(buf, dest, volume) }).catch(() => {}) @@ -301,3 +351,13 @@ export function kokoroClearCache() { export function getKokoroVoice(profileName: string): VoiceMapping { return VOICE_MAP[profileName] || DEFAULT_VOICE } + +/** Get all voice profile names */ +export function getVoiceProfileNames(): string[] { + return Object.keys(VOICE_MAP) +} + +/** Get the full voice map (for soundboard UI) */ +export function getVoiceMap(): Record { + return VOICE_MAP +} diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index f718f5d..7bb6175 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -67,9 +67,23 @@ watch(() => route.params.fightId, async (newId) => { } if (isLive.value) { startPolling() + connectSSE() if (isHumanFight.value) { startHumanPolling() - connectSSE() + await nextTick() + await nextTick() + if (liveFightData.value) await initLiveScene() + } else { + // Bot-vs-bot spectating: init live scene for real-time viewing + if (!liveFightData.value) await loadFight() + if (!liveFightData.value) { + // loadFight sets liveFightData only for human fights; set it for spectating too + const res = await fetch(`/api/fights/${fightId.value}`) + if (res.ok) { + const data = await res.json() + if (data.botA && data.botB) liveFightData.value = data + } + } await nextTick() await nextTick() if (liveFightData.value) await initLiveScene() @@ -112,6 +126,7 @@ const liveSoundOn = ref(true) const liveAnnouncement = ref('') const liveAnnouncementColor = ref('#ffffff') const liveAnnouncementVisible = ref(false) +const spectatorCount = ref(0) const currentChallengeInfo = ref<{ type: string; label: string } | null>(null) const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) const pendingSSEEvents = ref<{ type: string; data: any }[]>([]) @@ -212,9 +227,11 @@ function startPolling() { pollCount++ const s = await loadFight() if (s === 'finished') { - // Human fights handle end via SSE fight_end event — don't transition here - if (!isHumanFight.value) { + // SSE fight_end handles transition for all live fights with SSE connected + if (!eventSource) { + // Fallback: no SSE connected, transition directly isLive.value = false + disconnectSSE() stopHumanPolling() if (pollHandle) { clearInterval(pollHandle); pollHandle = null } } @@ -361,6 +378,22 @@ async function initLiveScene() { function connectSSE() { eventSource = new EventSource(`/api/fights/${fightId.value}/stream`) + eventSource.addEventListener('spectator_count', (e) => { + try { + const data = JSON.parse(e.data) + spectatorCount.value = data.count || 0 + } catch { /* ignore */ } + }) + + eventSource.addEventListener('ping', (e) => { + try { + if (e.data) { + const data = JSON.parse(e.data) + if (data.spectators !== undefined) spectatorCount.value = data.spectators + } + } catch { /* ignore */ } + }) + eventSource.addEventListener('round_start', (e) => { try { const data = JSON.parse(e.data) @@ -409,7 +442,9 @@ function connectSSE() { eventSource.addEventListener('round_end', (e) => { try { - handleRoundEnd(JSON.parse(e.data)).catch(() => {}) + const data = JSON.parse(e.data) + if (data.spectators !== undefined) spectatorCount.value = data.spectators + handleRoundEnd(data).catch(() => {}) } catch (err) { console.warn('[FightPage] SSE round_end failed:', err) } @@ -417,7 +452,9 @@ function connectSSE() { eventSource.addEventListener('fight_end', (e) => { try { - handleFightEnd(JSON.parse(e.data)).catch(() => {}) + const data = JSON.parse(e.data) + if (data.spectators !== undefined) spectatorCount.value = data.spectators + handleFightEnd(data).catch(() => {}) } catch (err) { console.warn('[FightPage] SSE fight_end failed:', err) } @@ -441,6 +478,7 @@ function connectSSE() { function disconnectSSE() { if (eventSource) { eventSource.close(); eventSource = null } + spectatorCount.value = 0 } async function showLiveOverlay(text: string, color: string, duration: number) { @@ -816,6 +854,12 @@ function stopAutoBattle() { BATTLE LOG + + + + + {{ spectatorCount }} +
@@ -969,6 +1013,7 @@ function stopAutoBattle() { {{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }} | ⚡{{ liveFightData.potSats || 42 }} SATS + | {{ spectatorCount }} watching {{ Math.round(liveFightData.botB.eloRating || 0) }}
@@ -1003,17 +1048,153 @@ function stopAutoBattle() {
- -
-
-

FIGHT IN PROGRESS

-

- Round {{ liveRounds }} — webhooks being called... -

-

- AUTO BATTLE #{{ autoBattleCount + 1 }} - -

+ +
+ + +
+
+ + + + BATTLE LOG + + + + + {{ spectatorCount }} + +
+
+
+
+

{{ item.text }}

+
+

{{ item.text }}

+
+
+

{{ item.text }}

+
+
+

{{ item.text }}

+
+
+

{{ item.text }}

+
+

{{ item.text }}

+

{{ item.text }}

+
+
Waiting for fight to begin...
+
+ + +
+
+

+ Round {{ liveCurrentRound }} + Waiting for fight... +

+ +
+

+ AUTO BATTLE #{{ autoBattleCount + 1 }} + +

+
+
+ + +
+ + +
+ +
+
+

{{ liveFightData.botA.name }}

+ {{ liveHpA }} +
+
+
+ VS +
+
+
+ {{ liveHpB }} +

{{ liveFightData.botB.name }}

+
+
+ +