From 7090e6f01db6d28e716de58a22bedaaea19cb316 Mon Sep 17 00:00:00 2001
From: Allen
Date: Thu, 30 Apr 2026 02:14:25 +0000
Subject: [PATCH] Initial commit
---
.gitignore | 6 +
CLAUDE.md | 191 +
backend/package-lock.json | 2550 ++++++
backend/package.json | 29 +
backend/scripts/create-classic-games.js | 414 +
backend/scripts/final-4-games.js | 724 ++
backend/scripts/fix-games.js | 462 +
backend/scripts/fix-snake.js | 197 +
backend/scripts/fix-touch-handling.js | 333 +
backend/scripts/init-db.sql | 127 +
.../scripts/migrations/002_full_platform.sql | 314 +
backend/scripts/migrations/003_onboarding.sql | 21 +
backend/scripts/migrations/004_audit_logs.sql | 24 +
backend/scripts/publish-sample-games.js | 209 +
backend/scripts/regenerate-all-games.js | 278 +
backend/scripts/rollback-touch-fix.js | 61 +
backend/scripts/sample-games.js | 142 +
backend/src/app.js | 116 +
backend/src/middleware/auth.js | 169 +
backend/src/middleware/errorHandler.js | 31 +
backend/src/middleware/validate.js | 22 +
backend/src/models/achievements.js | 145 +
backend/src/models/auditLog.js | 52 +
backend/src/models/classrooms.js | 299 +
backend/src/models/collections.js | 135 +
backend/src/models/db.js | 24 +
backend/src/models/favorites.js | 96 +
backend/src/models/follows.js | 80 +
backend/src/models/gameVersions.js | 67 +
backend/src/models/games.js | 166 +
backend/src/models/notifications.js | 121 +
backend/src/models/plays.js | 192 +
backend/src/models/themes.js | 95 +
backend/src/models/users.js | 227 +
backend/src/models/xp.js | 143 +
backend/src/routes/admin.js | 726 ++
backend/src/routes/arcade.js | 174 +
backend/src/routes/auth.js | 391 +
backend/src/routes/bugs.js | 216 +
backend/src/routes/classrooms.js | 660 ++
backend/src/routes/collections.js | 272 +
backend/src/routes/contact.js | 73 +
backend/src/routes/creation.js | 1200 +++
backend/src/routes/developer.js | 293 +
backend/src/routes/games.js | 287 +
backend/src/routes/leaderboard.js | 348 +
backend/src/routes/localai.js | 409 +
backend/src/routes/play.js | 410 +
backend/src/routes/push.js | 53 +
backend/src/routes/share.js | 211 +
backend/src/routes/themes.js | 128 +
backend/src/routes/users.js | 794 ++
backend/src/utils/claude.js | 451 +
backend/src/utils/contentFilter.js | 440 +
backend/src/utils/email.js | 405 +
backend/src/utils/gameStyles.js | 427 +
backend/src/utils/haiku.js | 196 +
backend/src/utils/localAI.js | 238 +
backend/src/utils/promptBuilder.js | 421 +
backend/src/utils/pushNotify.js | 101 +
backend/src/utils/usernames.js | 118 +
deploy.sh | 112 +
frontend/.gitignore | 24 +
frontend/README.md | 16 +
frontend/eslint.config.js | 29 +
frontend/index.html | 26 +
frontend/package-lock.json | 7548 +++++++++++++++++
frontend/package.json | 32 +
frontend/public/asset-browser.html | 1842 ++++
frontend/public/assets/README.md | 283 +
frontend/public/assets/assetCatalog.js | 354 +
frontend/public/assets/assetManager.js | 1272 +++
frontend/public/assets/avatar/accessories.js | 762 ++
.../public/assets/avatar/accessoryRenderer.js | 1571 ++++
frontend/public/assets/avatar/animations.js | 1422 ++++
frontend/public/assets/avatar/avatarData.js | 473 ++
.../public/assets/avatar/avatarRenderer.js | 1798 ++++
frontend/public/assets/avatar/avatarSystem.js | 997 +++
.../public/assets/avatar/contextCatalog.js | 262 +
.../public/assets/avatar/contextRenderer.js | 959 +++
.../public/assets/avatar/contexts/costumes.js | 1875 ++++
.../public/assets/avatar/contexts/pure.js | 1104 +++
.../public/assets/avatar/contexts/vehicles.js | 1799 ++++
.../assets/backgrounds/backgroundCatalog.js | 197 +
.../assets/backgrounds/backgroundEngine.js | 745 ++
frontend/public/assets/backgrounds/effects.js | 47 +
.../assets/backgrounds/themes/abstract.js | 396 +
.../assets/backgrounds/themes/colorful.js | 625 ++
.../assets/backgrounds/themes/fantasy.js | 505 ++
.../assets/backgrounds/themes/indoor.js | 479 ++
.../assets/backgrounds/themes/mechanical.js | 545 ++
.../assets/backgrounds/themes/nature.js | 360 +
.../public/assets/backgrounds/themes/retro.js | 551 ++
.../assets/backgrounds/themes/seasonal.js | 385 +
.../public/assets/backgrounds/themes/sky.js | 232 +
.../public/assets/backgrounds/themes/space.js | 275 +
.../assets/backgrounds/themes/spooky.js | 694 ++
.../assets/backgrounds/themes/sports.js | 595 ++
.../assets/backgrounds/themes/underwater.js | 267 +
.../public/assets/backgrounds/themes/urban.js | 466 +
.../assets/backgrounds/themes/weather.js | 476 ++
frontend/public/assets/compatibility.js | 1106 +++
.../public/assets/music/moodCategories.js | 168 +
frontend/public/assets/music/musicEngine.js | 848 ++
frontend/public/assets/music/musicLibrary.js | 3467 ++++++++
frontend/public/assets/presets.js | 2526 ++++++
frontend/public/avatar-creator/index.html | 966 +++
frontend/public/clear-cache.html | 210 +
frontend/public/docs/ADDING_NEW_ASSETS.md | 266 +
frontend/public/docs/API_REFERENCE.md | 2754 ++++++
.../public/docs/ASSET_LIBRARY_OVERVIEW.md | 598 ++
frontend/public/docs/AVATAR_SYSTEM.md | 734 ++
frontend/public/docs/BACKGROUND_CATALOG.md | 470 +
frontend/public/docs/CONTEXT_CATALOG.md | 721 ++
frontend/public/docs/GAME_GENERATION_GUIDE.md | 170 +
frontend/public/docs/MUSIC_CATALOG.md | 406 +
frontend/public/docs/PRESET_GUIDE.md | 546 ++
frontend/public/examples/comparison-demo.html | 675 ++
frontend/public/examples/flappy-example.html | 533 ++
.../public/examples/platformer-example.html | 533 ++
frontend/public/examples/racer-example.html | 702 ++
frontend/public/icon-192.png | 1 +
frontend/public/icon.svg | 10 +
frontend/public/push-handler.js | 56 +
frontend/public/templates/TEMPLATE_GUIDE.md | 451 +
.../public/templates/gameTemplateAssets.html | 905 ++
.../public/templates/gameTemplateSimple.html | 392 +
frontend/public/tests/asset-test-suite.html | 1982 +++++
frontend/public/vite.svg | 1 +
frontend/src/App.css | 275 +
frontend/src/App.jsx | 108 +
frontend/src/api.js | 785 ++
frontend/src/assets/react.svg | 1 +
frontend/src/components/AvatarBuilder.css | 214 +
frontend/src/components/AvatarBuilder.jsx | 412 +
frontend/src/components/BugReportModal.css | 141 +
frontend/src/components/BugReportModal.jsx | 96 +
frontend/src/components/DevToolbar.css | 659 ++
frontend/src/components/DevToolbar.jsx | 553 ++
frontend/src/components/FeatureTooltip.css | 173 +
frontend/src/components/FeatureTooltip.jsx | 165 +
frontend/src/components/Footer.css | 118 +
frontend/src/components/Footer.jsx | 97 +
frontend/src/components/GameCard.css | 413 +
frontend/src/components/GameCard.jsx | 295 +
frontend/src/components/GuestBanner.css | 104 +
frontend/src/components/GuestBanner.jsx | 31 +
frontend/src/components/Header.css | 607 ++
frontend/src/components/Header.jsx | 255 +
frontend/src/components/InstallPrompt.css | 159 +
frontend/src/components/InstallPrompt.jsx | 103 +
.../src/components/LevelUpCelebration.css | 118 +
.../src/components/LevelUpCelebration.jsx | 65 +
frontend/src/components/NotificationBell.css | 352 +
frontend/src/components/NotificationBell.jsx | 164 +
frontend/src/components/OfflineIndicator.css | 52 +
frontend/src/components/OfflineIndicator.jsx | 41 +
.../src/components/OnboardingTutorial.css | 280 +
.../src/components/OnboardingTutorial.jsx | 160 +
frontend/src/components/PromptRefiner.css | 279 +
frontend/src/components/PromptRefiner.jsx | 263 +
frontend/src/components/ShareModal.css | 361 +
frontend/src/components/ShareModal.jsx | 198 +
frontend/src/components/ThemeBanner.css | 146 +
frontend/src/components/ThemeBanner.jsx | 58 +
frontend/src/components/TokenHelpModal.css | 251 +
frontend/src/components/TokenHelpModal.jsx | 107 +
frontend/src/components/TweakPanel.css | 371 +
frontend/src/components/TweakPanel.jsx | 298 +
frontend/src/components/UpgradePrompt.css | 372 +
frontend/src/components/UpgradePrompt.jsx | 70 +
frontend/src/components/XPDisplay.css | 98 +
frontend/src/components/XPDisplay.jsx | 64 +
frontend/src/context/AuthContext.jsx | 158 +
frontend/src/hooks/useFingerprint.js | 77 +
frontend/src/hooks/useFocusTrap.js | 125 +
frontend/src/hooks/useOfflineGames.js | 116 +
frontend/src/hooks/useThumbnailRecorder.js | 121 +
frontend/src/index.css | 302 +
frontend/src/main.jsx | 10 +
frontend/src/music/musicEngine.js | 834 ++
frontend/src/music/musicLibrary.js | 1541 ++++
frontend/src/music/styleCategories.js | 345 +
frontend/src/pages/Achievements.css | 284 +
frontend/src/pages/Achievements.jsx | 143 +
frontend/src/pages/Admin.css | 1199 +++
frontend/src/pages/Admin.jsx | 1103 +++
frontend/src/pages/Arcade.css | 263 +
frontend/src/pages/Arcade.jsx | 186 +
frontend/src/pages/Auth.css | 330 +
frontend/src/pages/Classrooms.css | 505 ++
frontend/src/pages/Classrooms.jsx | 546 ++
frontend/src/pages/Collections.css | 338 +
frontend/src/pages/Collections.jsx | 354 +
frontend/src/pages/Create.css | 530 ++
frontend/src/pages/Create.jsx | 321 +
frontend/src/pages/CreateWizard.css | 1366 +++
frontend/src/pages/CreateWizard.jsx | 1320 +++
frontend/src/pages/CreatorProfile.css | 270 +
frontend/src/pages/CreatorProfile.jsx | 225 +
frontend/src/pages/Dashboard.css | 688 ++
frontend/src/pages/Dashboard.jsx | 207 +
frontend/src/pages/Edit.jsx | 31 +
frontend/src/pages/ForgotPassword.jsx | 84 +
frontend/src/pages/GameAnalytics.css | 511 ++
frontend/src/pages/GameAnalytics.jsx | 246 +
frontend/src/pages/GameStats.css | 488 ++
frontend/src/pages/GameStats.jsx | 256 +
frontend/src/pages/Home.css | 494 ++
frontend/src/pages/Home.jsx | 205 +
frontend/src/pages/Leaderboard.css | 806 ++
frontend/src/pages/Leaderboard.jsx | 347 +
frontend/src/pages/Legal.css | 196 +
frontend/src/pages/Login.jsx | 78 +
frontend/src/pages/Play.css | 2308 +++++
frontend/src/pages/Play.jsx | 1643 ++++
frontend/src/pages/Privacy.jsx | 250 +
frontend/src/pages/Profile.css | 704 ++
frontend/src/pages/Profile.jsx | 461 +
frontend/src/pages/Register.jsx | 207 +
frontend/src/pages/ReleaseNotes.css | 226 +
frontend/src/pages/ReleaseNotes.jsx | 155 +
frontend/src/pages/ResetPassword.jsx | 134 +
frontend/src/pages/TeacherConsole.css | 835 ++
frontend/src/pages/TeacherConsole.jsx | 691 ++
frontend/src/pages/Terms.jsx | 195 +
frontend/vite.config.js | 146 +
games/samples/asteroids.html | 542 ++
games/samples/block-drop.html | 536 ++
games/samples/brick-breaker.html | 426 +
games/samples/bridge-builder.html | 487 ++
games/samples/connect-four.html | 557 ++
games/samples/dino-runner.html | 361 +
games/samples/fruit-slicer.html | 496 ++
games/samples/geometry-runner.html | 450 +
games/samples/lights-out.html | 356 +
games/samples/lunar-lander.html | 539 ++
games/samples/maze-chomper.html | 535 ++
games/samples/memory-match.html | 343 +
games/samples/platformer.html | 578 ++
games/samples/pong.html | 347 +
games/samples/twenty-forty-eight.html | 445 +
games/samples/whack-a-mole.html | 387 +
243 files changed, 115122 insertions(+)
create mode 100644 .gitignore
create mode 100644 CLAUDE.md
create mode 100644 backend/package-lock.json
create mode 100644 backend/package.json
create mode 100644 backend/scripts/create-classic-games.js
create mode 100644 backend/scripts/final-4-games.js
create mode 100644 backend/scripts/fix-games.js
create mode 100644 backend/scripts/fix-snake.js
create mode 100755 backend/scripts/fix-touch-handling.js
create mode 100644 backend/scripts/init-db.sql
create mode 100644 backend/scripts/migrations/002_full_platform.sql
create mode 100644 backend/scripts/migrations/003_onboarding.sql
create mode 100644 backend/scripts/migrations/004_audit_logs.sql
create mode 100644 backend/scripts/publish-sample-games.js
create mode 100644 backend/scripts/regenerate-all-games.js
create mode 100755 backend/scripts/rollback-touch-fix.js
create mode 100644 backend/scripts/sample-games.js
create mode 100644 backend/src/app.js
create mode 100644 backend/src/middleware/auth.js
create mode 100644 backend/src/middleware/errorHandler.js
create mode 100644 backend/src/middleware/validate.js
create mode 100644 backend/src/models/achievements.js
create mode 100644 backend/src/models/auditLog.js
create mode 100644 backend/src/models/classrooms.js
create mode 100644 backend/src/models/collections.js
create mode 100644 backend/src/models/db.js
create mode 100644 backend/src/models/favorites.js
create mode 100644 backend/src/models/follows.js
create mode 100644 backend/src/models/gameVersions.js
create mode 100644 backend/src/models/games.js
create mode 100644 backend/src/models/notifications.js
create mode 100644 backend/src/models/plays.js
create mode 100644 backend/src/models/themes.js
create mode 100644 backend/src/models/users.js
create mode 100644 backend/src/models/xp.js
create mode 100644 backend/src/routes/admin.js
create mode 100644 backend/src/routes/arcade.js
create mode 100644 backend/src/routes/auth.js
create mode 100644 backend/src/routes/bugs.js
create mode 100644 backend/src/routes/classrooms.js
create mode 100644 backend/src/routes/collections.js
create mode 100644 backend/src/routes/contact.js
create mode 100644 backend/src/routes/creation.js
create mode 100644 backend/src/routes/developer.js
create mode 100644 backend/src/routes/games.js
create mode 100644 backend/src/routes/leaderboard.js
create mode 100644 backend/src/routes/localai.js
create mode 100644 backend/src/routes/play.js
create mode 100644 backend/src/routes/push.js
create mode 100644 backend/src/routes/share.js
create mode 100644 backend/src/routes/themes.js
create mode 100644 backend/src/routes/users.js
create mode 100644 backend/src/utils/claude.js
create mode 100644 backend/src/utils/contentFilter.js
create mode 100644 backend/src/utils/email.js
create mode 100644 backend/src/utils/gameStyles.js
create mode 100644 backend/src/utils/haiku.js
create mode 100644 backend/src/utils/localAI.js
create mode 100644 backend/src/utils/promptBuilder.js
create mode 100644 backend/src/utils/pushNotify.js
create mode 100644 backend/src/utils/usernames.js
create mode 100755 deploy.sh
create mode 100644 frontend/.gitignore
create mode 100644 frontend/README.md
create mode 100644 frontend/eslint.config.js
create mode 100644 frontend/index.html
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/public/asset-browser.html
create mode 100644 frontend/public/assets/README.md
create mode 100644 frontend/public/assets/assetCatalog.js
create mode 100644 frontend/public/assets/assetManager.js
create mode 100644 frontend/public/assets/avatar/accessories.js
create mode 100644 frontend/public/assets/avatar/accessoryRenderer.js
create mode 100644 frontend/public/assets/avatar/animations.js
create mode 100644 frontend/public/assets/avatar/avatarData.js
create mode 100644 frontend/public/assets/avatar/avatarRenderer.js
create mode 100644 frontend/public/assets/avatar/avatarSystem.js
create mode 100644 frontend/public/assets/avatar/contextCatalog.js
create mode 100644 frontend/public/assets/avatar/contextRenderer.js
create mode 100644 frontend/public/assets/avatar/contexts/costumes.js
create mode 100644 frontend/public/assets/avatar/contexts/pure.js
create mode 100644 frontend/public/assets/avatar/contexts/vehicles.js
create mode 100644 frontend/public/assets/backgrounds/backgroundCatalog.js
create mode 100644 frontend/public/assets/backgrounds/backgroundEngine.js
create mode 100644 frontend/public/assets/backgrounds/effects.js
create mode 100644 frontend/public/assets/backgrounds/themes/abstract.js
create mode 100644 frontend/public/assets/backgrounds/themes/colorful.js
create mode 100644 frontend/public/assets/backgrounds/themes/fantasy.js
create mode 100644 frontend/public/assets/backgrounds/themes/indoor.js
create mode 100644 frontend/public/assets/backgrounds/themes/mechanical.js
create mode 100644 frontend/public/assets/backgrounds/themes/nature.js
create mode 100644 frontend/public/assets/backgrounds/themes/retro.js
create mode 100644 frontend/public/assets/backgrounds/themes/seasonal.js
create mode 100644 frontend/public/assets/backgrounds/themes/sky.js
create mode 100644 frontend/public/assets/backgrounds/themes/space.js
create mode 100644 frontend/public/assets/backgrounds/themes/spooky.js
create mode 100644 frontend/public/assets/backgrounds/themes/sports.js
create mode 100644 frontend/public/assets/backgrounds/themes/underwater.js
create mode 100644 frontend/public/assets/backgrounds/themes/urban.js
create mode 100644 frontend/public/assets/backgrounds/themes/weather.js
create mode 100644 frontend/public/assets/compatibility.js
create mode 100644 frontend/public/assets/music/moodCategories.js
create mode 100644 frontend/public/assets/music/musicEngine.js
create mode 100644 frontend/public/assets/music/musicLibrary.js
create mode 100644 frontend/public/assets/presets.js
create mode 100644 frontend/public/avatar-creator/index.html
create mode 100644 frontend/public/clear-cache.html
create mode 100644 frontend/public/docs/ADDING_NEW_ASSETS.md
create mode 100644 frontend/public/docs/API_REFERENCE.md
create mode 100644 frontend/public/docs/ASSET_LIBRARY_OVERVIEW.md
create mode 100644 frontend/public/docs/AVATAR_SYSTEM.md
create mode 100644 frontend/public/docs/BACKGROUND_CATALOG.md
create mode 100644 frontend/public/docs/CONTEXT_CATALOG.md
create mode 100644 frontend/public/docs/GAME_GENERATION_GUIDE.md
create mode 100644 frontend/public/docs/MUSIC_CATALOG.md
create mode 100644 frontend/public/docs/PRESET_GUIDE.md
create mode 100644 frontend/public/examples/comparison-demo.html
create mode 100644 frontend/public/examples/flappy-example.html
create mode 100644 frontend/public/examples/platformer-example.html
create mode 100644 frontend/public/examples/racer-example.html
create mode 100644 frontend/public/icon-192.png
create mode 100644 frontend/public/icon.svg
create mode 100644 frontend/public/push-handler.js
create mode 100644 frontend/public/templates/TEMPLATE_GUIDE.md
create mode 100644 frontend/public/templates/gameTemplateAssets.html
create mode 100644 frontend/public/templates/gameTemplateSimple.html
create mode 100644 frontend/public/tests/asset-test-suite.html
create mode 100644 frontend/public/vite.svg
create mode 100644 frontend/src/App.css
create mode 100644 frontend/src/App.jsx
create mode 100644 frontend/src/api.js
create mode 100644 frontend/src/assets/react.svg
create mode 100644 frontend/src/components/AvatarBuilder.css
create mode 100644 frontend/src/components/AvatarBuilder.jsx
create mode 100644 frontend/src/components/BugReportModal.css
create mode 100644 frontend/src/components/BugReportModal.jsx
create mode 100644 frontend/src/components/DevToolbar.css
create mode 100644 frontend/src/components/DevToolbar.jsx
create mode 100644 frontend/src/components/FeatureTooltip.css
create mode 100644 frontend/src/components/FeatureTooltip.jsx
create mode 100644 frontend/src/components/Footer.css
create mode 100644 frontend/src/components/Footer.jsx
create mode 100644 frontend/src/components/GameCard.css
create mode 100644 frontend/src/components/GameCard.jsx
create mode 100644 frontend/src/components/GuestBanner.css
create mode 100644 frontend/src/components/GuestBanner.jsx
create mode 100644 frontend/src/components/Header.css
create mode 100644 frontend/src/components/Header.jsx
create mode 100644 frontend/src/components/InstallPrompt.css
create mode 100644 frontend/src/components/InstallPrompt.jsx
create mode 100644 frontend/src/components/LevelUpCelebration.css
create mode 100644 frontend/src/components/LevelUpCelebration.jsx
create mode 100644 frontend/src/components/NotificationBell.css
create mode 100644 frontend/src/components/NotificationBell.jsx
create mode 100644 frontend/src/components/OfflineIndicator.css
create mode 100644 frontend/src/components/OfflineIndicator.jsx
create mode 100644 frontend/src/components/OnboardingTutorial.css
create mode 100644 frontend/src/components/OnboardingTutorial.jsx
create mode 100644 frontend/src/components/PromptRefiner.css
create mode 100644 frontend/src/components/PromptRefiner.jsx
create mode 100644 frontend/src/components/ShareModal.css
create mode 100644 frontend/src/components/ShareModal.jsx
create mode 100644 frontend/src/components/ThemeBanner.css
create mode 100644 frontend/src/components/ThemeBanner.jsx
create mode 100644 frontend/src/components/TokenHelpModal.css
create mode 100644 frontend/src/components/TokenHelpModal.jsx
create mode 100644 frontend/src/components/TweakPanel.css
create mode 100644 frontend/src/components/TweakPanel.jsx
create mode 100644 frontend/src/components/UpgradePrompt.css
create mode 100644 frontend/src/components/UpgradePrompt.jsx
create mode 100644 frontend/src/components/XPDisplay.css
create mode 100644 frontend/src/components/XPDisplay.jsx
create mode 100644 frontend/src/context/AuthContext.jsx
create mode 100644 frontend/src/hooks/useFingerprint.js
create mode 100644 frontend/src/hooks/useFocusTrap.js
create mode 100644 frontend/src/hooks/useOfflineGames.js
create mode 100644 frontend/src/hooks/useThumbnailRecorder.js
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/main.jsx
create mode 100644 frontend/src/music/musicEngine.js
create mode 100644 frontend/src/music/musicLibrary.js
create mode 100644 frontend/src/music/styleCategories.js
create mode 100644 frontend/src/pages/Achievements.css
create mode 100644 frontend/src/pages/Achievements.jsx
create mode 100644 frontend/src/pages/Admin.css
create mode 100644 frontend/src/pages/Admin.jsx
create mode 100644 frontend/src/pages/Arcade.css
create mode 100644 frontend/src/pages/Arcade.jsx
create mode 100644 frontend/src/pages/Auth.css
create mode 100644 frontend/src/pages/Classrooms.css
create mode 100644 frontend/src/pages/Classrooms.jsx
create mode 100644 frontend/src/pages/Collections.css
create mode 100644 frontend/src/pages/Collections.jsx
create mode 100644 frontend/src/pages/Create.css
create mode 100644 frontend/src/pages/Create.jsx
create mode 100644 frontend/src/pages/CreateWizard.css
create mode 100644 frontend/src/pages/CreateWizard.jsx
create mode 100644 frontend/src/pages/CreatorProfile.css
create mode 100644 frontend/src/pages/CreatorProfile.jsx
create mode 100644 frontend/src/pages/Dashboard.css
create mode 100644 frontend/src/pages/Dashboard.jsx
create mode 100644 frontend/src/pages/Edit.jsx
create mode 100644 frontend/src/pages/ForgotPassword.jsx
create mode 100644 frontend/src/pages/GameAnalytics.css
create mode 100644 frontend/src/pages/GameAnalytics.jsx
create mode 100644 frontend/src/pages/GameStats.css
create mode 100644 frontend/src/pages/GameStats.jsx
create mode 100644 frontend/src/pages/Home.css
create mode 100644 frontend/src/pages/Home.jsx
create mode 100644 frontend/src/pages/Leaderboard.css
create mode 100644 frontend/src/pages/Leaderboard.jsx
create mode 100644 frontend/src/pages/Legal.css
create mode 100644 frontend/src/pages/Login.jsx
create mode 100644 frontend/src/pages/Play.css
create mode 100644 frontend/src/pages/Play.jsx
create mode 100644 frontend/src/pages/Privacy.jsx
create mode 100644 frontend/src/pages/Profile.css
create mode 100644 frontend/src/pages/Profile.jsx
create mode 100644 frontend/src/pages/Register.jsx
create mode 100644 frontend/src/pages/ReleaseNotes.css
create mode 100644 frontend/src/pages/ReleaseNotes.jsx
create mode 100644 frontend/src/pages/ResetPassword.jsx
create mode 100644 frontend/src/pages/TeacherConsole.css
create mode 100644 frontend/src/pages/TeacherConsole.jsx
create mode 100644 frontend/src/pages/Terms.jsx
create mode 100644 frontend/vite.config.js
create mode 100644 games/samples/asteroids.html
create mode 100644 games/samples/block-drop.html
create mode 100644 games/samples/brick-breaker.html
create mode 100644 games/samples/bridge-builder.html
create mode 100644 games/samples/connect-four.html
create mode 100644 games/samples/dino-runner.html
create mode 100644 games/samples/fruit-slicer.html
create mode 100644 games/samples/geometry-runner.html
create mode 100644 games/samples/lights-out.html
create mode 100644 games/samples/lunar-lander.html
create mode 100644 games/samples/maze-chomper.html
create mode 100644 games/samples/memory-match.html
create mode 100644 games/samples/platformer.html
create mode 100644 games/samples/pong.html
create mode 100644 games/samples/twenty-forty-eight.html
create mode 100644 games/samples/whack-a-mole.html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1da3dc3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+backend/.env
+backend/node_modules/
+frontend/node_modules/
+node_modules/
+*.log
+.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..49c2d93
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,191 @@
+# GamerComp.com - AI Game Arcade
+
+Mobile-first game arcade where kids (8-16) play AI-generated HTML5 canvas games, create games via wizard, and earn XP.
+
+## Quick Reference
+
+```bash
+./deploy.sh # Build + restart + reload nginx
+./deploy.sh --clear-cloudflare # Also purge CDN cache
+pm2 logs gamercomp-api --lines 50
+sudo -u postgres psql -d game_arcade
+```
+
+## Tech Stack
+
+| Layer | Tech |
+|-------|------|
+| Frontend | React 18, Vite, React Router, PWA |
+| Backend | Node.js, Express, PostgreSQL, Redis |
+| Auth | JWT + FingerprintJS (guests) |
+| AI (generation) | Claude Sonnet (`claude-sonnet-4-20250514`) — streaming |
+| AI (lightweight) | Claude Haiku (`claude-haiku-4-5-20251001`) — tweaks, refinement, summaries |
+| Push | Web Push (VAPID) via `web-push` |
+| Infra | DigitalOcean, Nginx, PM2, Cloudflare |
+
+## Project Structure
+
+```
+frontend/src/
+├── pages/
+│ ├── Play.jsx # Game player + DevToolbar (P key)
+│ ├── CreateWizard.jsx # 3-step wizard + SSE progress
+│ ├── Dashboard.jsx # "My Games" - collections + created games only
+│ ├── Profile.jsx # Stats, streaks, XP activity, achievements, history
+│ ├── Arcade.jsx # Game browser with live previews
+│ └── ...
+├── components/
+│ ├── Header.jsx # Sticky nav (static on /play/*), mobile menu
+│ ├── DevToolbar.jsx # Tweak, refine, code editor (P key)
+│ ├── TweakPanel.jsx # 3-step: preview → confirm → success
+│ ├── GameCard.jsx # Live preview with audio mock
+│ ├── OnboardingTutorial.jsx # First-time wizard (DB-persisted)
+│ └── ...
+├── context/AuthContext.jsx
+└── api.js # 180s timeout, SSE streaming, push
+
+backend/src/
+├── routes/
+│ ├── creation.js # Background generation + SSE streaming
+│ ├── localai.js # Haiku tweaks: preview, classify, apply, review
+│ ├── push.js # VAPID key, subscribe/unsubscribe
+│ └── ...
+└── utils/
+ ├── claude.js # Sonnet: generateGameStream, regenerateGameStream
+ ├── haiku.js # preprocessTweak, explainError, summarize*
+ ├── localAI.js # refinePrompt, classifyTweak, applyTweak
+ └── pushNotify.js # Web push send/subscribe
+```
+
+## AI Architecture
+
+**Haiku-first**: All lightweight AI uses Claude Haiku directly.
+
+| Task | Model | Location |
+|------|-------|----------|
+| Game generation/refinement | Sonnet (streaming) | `claude.js` |
+| Tweak preview/apply | Haiku | `localAI.js` |
+| Prompt refinement | Haiku | `localAI.js` |
+| Code review | Haiku | `localai.js` route |
+| Error explanation | Haiku | `haiku.js` |
+| Summaries | Haiku | `haiku.js` |
+
+## Background Generation + SSE
+
+1. `POST /api/creation/:id/generate` returns immediately
+2. Backend runs async, streams progress to `activeGenerations` Map (5min TTL)
+3. Client connects `GET /api/creation/:id/stream?token=JWT`
+4. SSE replays buffered events, streams new ones
+5. On complete: DB save, in-app notification, email, push
+
+**SSE auth**: JWT via `?token=` query param.
+**Nginx**: Requires `proxy_buffering off` for `/api`.
+
+## Key Pages
+
+### Dashboard → "My Games"
+- Collections grid (4 max shown)
+- Created games list with status badges
+- Create button — no stats/XP (moved to Profile)
+
+### Profile (own)
+- Avatar, XP bar, level icon
+- Quick stats: Games Created, Played, Time, Total XP
+- Streaks: Login (🔥), Creation (⚡) with milestones
+- XP activity: Recent transactions
+- Achievements grid (earned/locked toggle)
+- Tabs: Created Games, Favorites, Play History
+
+### Play + DevToolbar
+- Press **P** to open (creators only, draft/testing)
+- **Tweak tab**: 3-step flow with Haiku preview → confirm → success
+- **Edit tab**: Code editor with Haiku review before save
+- **Refine tab**: Full Sonnet regeneration with SSE progress
+- **Prompt tab**: View generation prompt
+- Test 30s minimum before publishing
+
+### Header
+- Sticky on all pages except `/play/*` (static there)
+- Mobile hamburger menu
+- Token display, notification bell, avatar+level badge
+
+### Onboarding
+- 4-step welcome wizard for new users
+- Persisted in DB (`onboarding_complete` column)
+- Survives cache clear/reload
+
+## API Endpoints
+
+```
+# Public
+GET /api/arcade/games|:id
+GET /api/leaderboard/players|creators|games
+GET /api/push/vapid-key
+
+# Auth
+POST /api/auth/register|login|guest-login
+POST /api/auth/forgot-password|reset-password
+
+# Authenticated
+POST /api/play/start|finish|rate|favorite
+POST /api/creation/start|:id/generate|refine|publish
+GET /api/creation/:id/stream # SSE (?token=JWT)
+POST /api/localai/refine-prompt|classify|tweak/:gameId
+POST /api/localai/tweak/:gameId/preview # Haiku preview
+POST /api/localai/review-code/:gameId # Haiku code review
+POST /api/localai/save-code/:gameId # Save manual edits
+POST /api/push/subscribe|unsubscribe
+GET /api/users/me
+```
+
+## Database Tables
+
+**Core**: users, games, plays, ratings, high_scores
+**Social**: favorites, follows, notifications, collections
+**Progress**: achievements, user_achievements, xp_transactions
+**Education**: classrooms, classroom_students, assignments
+**Push**: push_subscriptions
+**Reports**: game_reports, bug_reports
+
+## Common Tasks
+
+```bash
+# Add page
+1. frontend/src/pages/NewPage.jsx
+2. Add route in App.jsx
+3. Add to Header.jsx if needed
+4. ./deploy.sh
+
+# Add API endpoint
+1. backend/src/routes/file.js
+2. frontend/src/api.js method
+3. Mount in app.js if new file
+4. pm2 restart gamercomp-api
+
+# Database change
+sudo -u postgres psql -d game_arcade
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gamearc;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO gamearc;
+```
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| UI not updating | `/clear-cache.html` or Ctrl+Shift+R |
+| PM2 errors | `pm2 flush && pm2 restart gamercomp-api` |
+| SSE not working | Check nginx `proxy_buffering off` |
+| Generation timeout | Check 180s timeouts (nginx, api.js) |
+| Iframe blank | Needs `sandbox="allow-scripts"` |
+| Push not delivered | Check VAPID keys, subscriptions pruned on 410 |
+| Mobile keyboard hiding | Use `font-size: 16px` on inputs |
+
+## Architecture Notes
+
+**Iframe Communication**: Games export `pauseGame()`, `resumeGame()`, `pauseMusic()`, `resumeMusic()`, `gameScore`, `setMusicEnabled()`, `setSfxEnabled()` on window.
+
+**Content Safety**: Multi-layer filtering (profanity, PII, violence). Gaming allowlist. Kid-friendly errors.
+
+**Audio Mocking**: GameCard.jsx mocks `AudioContext` and `Audio` completely to prevent sounds in previews.
+
+**Mobile Touch**: Play.jsx uses `onTouchEnd` with `e.preventDefault()` for buttons. Textareas need `font-size: 16px` to prevent iOS zoom and keyboard issues.
diff --git a/backend/package-lock.json b/backend/package-lock.json
new file mode 100644
index 0000000..d4c61e4
--- /dev/null
+++ b/backend/package-lock.json
@@ -0,0 +1,2550 @@
+{
+ "name": "gamercomp-api",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gamercomp-api",
+ "version": "1.0.0",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "bcrypt": "^5.1.1",
+ "cors": "^2.8.5",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-rate-limit": "^7.1.5",
+ "express-validator": "^7.0.1",
+ "helmet": "^7.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "nodemailer": "^7.0.13",
+ "pg": "^8.11.3",
+ "redis": "^4.6.10",
+ "uuid": "^9.0.1",
+ "web-push": "^3.6.7"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.2"
+ }
+ },
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.39.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
+ "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7"
+ }
+ },
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@redis/bloom": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
+ "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
+ "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "cluster-key-slot": "1.1.2",
+ "generic-pool": "3.9.0",
+ "yallist": "4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@redis/graph": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
+ "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
+ "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
+ "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
+ "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "18.19.130",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.13",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+ "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^4.0.4"
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC"
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agent-base/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/agent-base/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
+ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
+ "license": "ISC"
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/bcrypt": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
+ "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.11",
+ "node-addon-api": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bn.js": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
+ "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "license": "MIT"
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "license": "MIT"
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-validator": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
+ "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21",
+ "validator": "~13.15.23"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data-encoder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+ "license": "MIT"
+ },
+ "node_modules/formdata-node": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "1.0.0",
+ "web-streams-polyfill": "4.0.0-beta.3"
+ },
+ "engines": {
+ "node": ">= 12.20"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/generic-pool": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+ "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
+ "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/http_ece": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
+ "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
+ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
+ "license": "MIT"
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "7.0.13",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
+ "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.11",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
+ "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.1.2",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
+ "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "pg-connection-string": "^2.11.0",
+ "pg-pool": "^3.11.0",
+ "pg-protocol": "^1.11.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
+ "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
+ "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
+ "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/qs": {
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/redis": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
+ "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "./packages/*"
+ ],
+ "dependencies": {
+ "@redis/bloom": "1.2.0",
+ "@redis/client": "1.6.1",
+ "@redis/graph": "1.1.1",
+ "@redis/json": "1.0.7",
+ "@redis/search": "1.2.0",
+ "@redis/time-series": "1.1.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/validator": {
+ "version": "13.15.26",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
+ "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/web-push": {
+ "version": "3.6.7",
+ "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
+ "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "asn1.js": "^5.3.0",
+ "http_ece": "1.2.0",
+ "https-proxy-agent": "^7.0.0",
+ "jws": "^4.0.0",
+ "minimist": "^1.2.5"
+ },
+ "bin": {
+ "web-push": "src/cli.js"
+ },
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/web-push/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/web-push/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/web-push/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/web-push/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "4.0.0-beta.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..cbc8875
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "gamercomp-api",
+ "version": "1.0.0",
+ "description": "GamerComp.com Game Arcade API",
+ "main": "src/app.js",
+ "scripts": {
+ "start": "node src/app.js",
+ "dev": "nodemon src/app.js"
+ },
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.39.0",
+ "bcrypt": "^5.1.1",
+ "cors": "^2.8.5",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-rate-limit": "^7.1.5",
+ "express-validator": "^7.0.1",
+ "helmet": "^7.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "nodemailer": "^7.0.13",
+ "pg": "^8.11.3",
+ "redis": "^4.6.10",
+ "uuid": "^9.0.1",
+ "web-push": "^3.6.7"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.2"
+ }
+}
diff --git a/backend/scripts/create-classic-games.js b/backend/scripts/create-classic-games.js
new file mode 100644
index 0000000..8bf2dab
--- /dev/null
+++ b/backend/scripts/create-classic-games.js
@@ -0,0 +1,414 @@
+require('dotenv').config();
+const { pool } = require('../src/models/db');
+const { generateGame } = require('../src/utils/claude');
+
+const ADMIN_USER_ID = 2; // arcade_master
+
+const classicGames = [
+ {
+ title: "Snake",
+ prompt: `Create a classic Snake game where the player controls a snake that grows longer each time it eats food.
+
+GAMEPLAY MECHANICS:
+- The snake moves continuously in the current direction
+- Arrow keys (desktop) or swipe gestures (mobile) change direction
+- A food item (apple or dot) appears randomly on the screen
+- When the snake eats food, it grows by one segment and score increases by 10
+- The game ends if the snake hits the wall or its own body
+- Snake should start with 3 segments moving to the right
+- Speed should gradually increase every 50 points
+
+VISUAL DESIGN:
+- Use a grid-based playing field with a subtle grid pattern
+- Snake head should be slightly different color than body (dark green head, lighter green body)
+- Food should be red and visually distinct (like an apple)
+- Display score prominently in the top-left corner
+- Show a "Game Over" screen with final score and "Play Again" button
+
+CONTROLS:
+- Desktop: Arrow keys for direction
+- Mobile: Swipe in any direction to change snake direction
+- Prevent 180-degree turns (can't go directly backward)`
+ },
+ {
+ title: "Tetris",
+ prompt: `Create a Tetris-style falling block puzzle game.
+
+GAMEPLAY MECHANICS:
+- Seven different tetromino shapes (I, O, T, S, Z, J, L) fall from the top
+- Player can move pieces left/right and rotate them
+- Pieces fall automatically at a steady pace
+- When a horizontal line is completely filled, it clears and awards points
+- Clearing multiple lines at once awards bonus points (1 line=100, 2=300, 3=500, 4=800)
+- Game ends when pieces stack to the top
+- Show the next piece that will appear
+
+VISUAL DESIGN:
+- 10 columns wide, 20 rows tall playing field
+- Each tetromino type has a distinct bright color
+- Ghost piece shows where current piece will land
+- Clear lines with a satisfying flash animation
+- Display score, level, and lines cleared
+- Level increases every 10 lines, increasing fall speed
+
+CONTROLS:
+- Left/Right arrows or A/D: Move piece horizontally
+- Up arrow or W: Rotate piece clockwise
+- Down arrow or S: Soft drop (faster fall)
+- Space: Hard drop (instant drop)
+- Mobile: On-screen buttons for all actions`
+ },
+ {
+ title: "Pong",
+ prompt: `Create a classic Pong game - the original video game tennis.
+
+GAMEPLAY MECHANICS:
+- Two paddles on opposite sides of the screen (left and right)
+- A ball bounces between them
+- Players score when the ball passes the opponent's paddle
+- Ball speed increases slightly after each paddle hit
+- Ball angle changes based on where it hits the paddle (edges = sharper angles)
+- First player to 11 points wins
+- Ball resets to center after each point, launching toward the player who was scored on
+
+VISUAL DESIGN:
+- Black background with white elements (classic arcade look)
+- Dotted line down the center of the screen
+- Paddles are simple white rectangles
+- Ball is a small white square
+- Large score display at top for both players
+- "Player 1 Wins!" or "Player 2 Wins!" message at game end
+
+CONTROLS:
+- Player 1 (left): W/S keys to move up/down
+- Player 2 (right): Up/Down arrow keys
+- Mobile: Touch left side to control left paddle, right side for right paddle
+- Alternatively, make Player 2 a simple AI opponent`
+ },
+ {
+ title: "Breakout",
+ prompt: `Create a Breakout/Brick Breaker arcade game.
+
+GAMEPLAY MECHANICS:
+- Rows of colored bricks at the top of the screen
+- Player controls a paddle at the bottom
+- Ball bounces around, breaking bricks on contact
+- Different colored bricks may take multiple hits (green=1, yellow=2, orange=3, red=4)
+- Some bricks drop power-ups: wider paddle, multi-ball, slow ball
+- Player has 3 lives, loses one if ball falls below paddle
+- Level complete when all bricks are destroyed
+- Ball angle changes based on where it hits the paddle
+
+VISUAL DESIGN:
+- Colorful brick arrangement (rainbow pattern from top to bottom)
+- Bricks should visibly crack or change color when damaged
+- Particle effects when bricks break
+- Paddle has a metallic/shiny appearance
+- Display score at top, lives as small ball icons
+- Power-ups are falling capsules with distinct colors
+
+CONTROLS:
+- Mouse movement or Left/Right arrows move the paddle
+- Click or Space to launch the ball from paddle at game start
+- Mobile: Touch and drag to move paddle`
+ },
+ {
+ title: "Space Invaders",
+ prompt: `Create the classic Space Invaders alien shooting game.
+
+GAMEPLAY MECHANICS:
+- Grid of alien invaders (5 rows of 11) march left and right, dropping down each time they hit an edge
+- Player ship at bottom can move left/right and shoot upward
+- Aliens occasionally drop bombs downward
+- Player has 3 lives, loses one if hit by bomb
+- Killing aliens awards points (bottom row=10, increasing by 10 per row up)
+- Mystery UFO occasionally flies across the top (bonus 50-300 points)
+- Aliens speed up as fewer remain
+- Game over if aliens reach the bottom
+
+VISUAL DESIGN:
+- Classic pixel-art style aliens (3 different designs for variety)
+- Aliens animate between 2 frames as they move
+- Green player ship with white bullets
+- Four destructible shields/bunkers that erode when hit
+- Black space background with stars
+- Score and high score at top, lives shown as ship icons
+
+CONTROLS:
+- Left/Right arrows or A/D to move ship
+- Space bar to shoot (limit: one bullet on screen at a time for authenticity, or allow 3)
+- Mobile: Touch left/right sides to move, tap center to shoot`
+ },
+ {
+ title: "Pac-Man",
+ prompt: `Create a Pac-Man style maze chase game.
+
+GAMEPLAY MECHANICS:
+- Player controls Pac-Man through a maze eating dots
+- Four ghosts chase Pac-Man with different behaviors
+- Small dots = 10 points, large power pellets = 50 points
+- Power pellets make ghosts vulnerable (blue) for 8 seconds - eating them = 200, 400, 800, 1600 points
+- Player has 3 lives, loses one if touched by a non-vulnerable ghost
+- Level complete when all dots are eaten
+- Tunnels on sides wrap around to opposite side
+- Ghosts get faster with each level
+
+VISUAL DESIGN:
+- Classic maze layout with blue walls
+- Pac-Man is yellow circle with animated chomping mouth
+- Four colorful ghosts: red (Blinky), pink (Pinky), cyan (Inky), orange (Clyde)
+- Ghosts have cute eyes that look in their direction of travel
+- Dots are small, power pellets are large and flashing
+- Display score, high score, and lives
+
+CONTROLS:
+- Arrow keys for direction (Pac-Man turns at next intersection)
+- Mobile: Swipe in direction to turn
+- Pac-Man continues moving in current direction until wall`
+ },
+ {
+ title: "Flappy Bird",
+ prompt: `Create a Flappy Bird-style endless flying game.
+
+GAMEPLAY MECHANICS:
+- Bird automatically falls due to gravity
+- Tapping/clicking makes the bird flap and rise
+- Pipes (or obstacles) scroll from right to left with gaps to fly through
+- Each pipe passed = 1 point
+- Game over if bird hits pipe or ground/ceiling
+- Pipe gap size stays constant, but spacing between pipes can vary
+- Bird rotates based on velocity (nose up when rising, nose down when falling)
+- High score is saved and displayed
+
+VISUAL DESIGN:
+- Cute, simple bird with flapping wing animation
+- Colorful green pipes coming from top and bottom
+- Scrolling background with clouds and simple scenery
+- Ground at bottom with scrolling texture
+- Large score display in center-top during play
+- Game over screen shows score vs best score with "Tap to Retry"
+
+CONTROLS:
+- Spacebar, click, or tap anywhere to flap
+- That's it - one-button gameplay!
+- Bird should feel floaty with satisfying physics`
+ },
+ {
+ title: "2048",
+ prompt: `Create the 2048 sliding number puzzle game.
+
+GAMEPLAY MECHANICS:
+- 4x4 grid of tiles
+- Tiles contain powers of 2 (2, 4, 8, 16... up to 2048+)
+- Swiping/arrow key slides ALL tiles in that direction
+- When two tiles with same number collide, they merge into their sum
+- After each move, a new tile (2 or 4) appears in a random empty spot
+- Goal: Create a 2048 tile (though game continues after)
+- Game over when no more moves are possible
+- Score increases by the value of each merged tile
+
+VISUAL DESIGN:
+- Clean, modern design with rounded tile corners
+- Each number has a distinct background color (2=light, higher=darker/warmer)
+- Smooth sliding and merging animations
+- Tiles should pop/scale when they appear and merge
+- Display current score and best score
+- Large, clear numbers on tiles
+- "You Win!" celebration when reaching 2048, with option to continue
+
+CONTROLS:
+- Arrow keys to slide all tiles in a direction
+- Mobile: Swipe gestures in any direction
+- Consider adding "Undo" button for last move`
+ },
+ {
+ title: "Minesweeper",
+ prompt: `Create the classic Minesweeper puzzle game.
+
+GAMEPLAY MECHANICS:
+- Grid of covered cells (start with 10x10 with 15 mines for beginner)
+- Left-click reveals a cell
+- Numbers show how many adjacent cells (including diagonals) contain mines
+- Empty cells auto-reveal adjacent empty cells (flood fill)
+- Right-click places a flag to mark suspected mines
+- First click is always safe (never a mine)
+- Win by revealing all non-mine cells
+- Lose by clicking a mine (reveal all mines, show X on wrong flags)
+- Timer starts on first click
+
+VISUAL DESIGN:
+- Clean grid with beveled 3D-style unrevealed cells
+- Revealed cells are flat with clear numbers
+- Numbers colored by value (1=blue, 2=green, 3=red, etc.)
+- Mines shown as black circles/bombs when revealed
+- Flags are red
+- Smiley face button to restart (changes expression: smile, surprised on click, dead on loss, cool on win)
+- Display mine count remaining and timer
+
+CONTROLS:
+- Left-click to reveal cell
+- Right-click to toggle flag
+- Mobile: Tap to reveal, long-press to flag
+- Include toggle button for flag mode on mobile`
+ },
+ {
+ title: "Asteroids",
+ prompt: `Create the classic Asteroids space shooter game.
+
+GAMEPLAY MECHANICS:
+- Player ship in center of screen can rotate and thrust
+- Ship wraps around screen edges (appears on opposite side)
+- Shoot bullets to destroy asteroids
+- Large asteroids split into 2 medium, medium split into 2 small
+- Points: Large=20, Medium=50, Small=100
+- Player has 3 lives, loses one if hit by asteroid
+- Ship has brief invincibility after respawning
+- UFOs occasionally appear and shoot at player (Small=1000pts, Large=200pts)
+- Hyperspace: random teleport as escape (risky - might teleport into asteroid)
+
+VISUAL DESIGN:
+- Vector graphics style (white lines on black background)
+- Ship is a simple triangle that rotates smoothly
+- Asteroids are irregular polygons that rotate as they drift
+- Bullets are small dots/lines
+- Thrust shows small particles behind ship
+- Ship explosion is satisfying particle burst
+- Display score and lives (ship icons)
+
+CONTROLS:
+- Left/Right arrows or A/D to rotate ship
+- Up arrow or W to thrust forward
+- Space to shoot
+- Shift or X for hyperspace
+- Mobile: On-screen joystick for rotation/thrust, fire button`
+ },
+ {
+ title: "Frogger",
+ prompt: `Create a Frogger-style road and river crossing game.
+
+GAMEPLAY MECHANICS:
+- Frog starts at bottom, must reach one of 5 home bases at top
+- Bottom half: Cross road with cars/trucks moving at different speeds
+- Top half: Cross river by jumping on logs and turtles
+- Falling in water or getting hit by vehicle = lose a life
+- Turtles occasionally submerge (warning animation first)
+- Bonus items appear on logs (flies for points, female frog for bonus)
+- Time limit per frog (30 seconds) - bonus points for time remaining
+- Fill all 5 home bases to complete level
+- Each level, traffic and logs move faster
+
+VISUAL DESIGN:
+- Colorful top-down view
+- Cute pixel-art frog that faces direction of last move
+- Road section: gray with lane markings, colorful vehicles
+- River section: blue water, brown logs, green turtles
+- Safe zones: green grass/lily pads
+- Home bases at top with flowers when empty, frog when filled
+- Display score, time bar, and lives
+
+CONTROLS:
+- Arrow keys for hopping in four directions
+- Each press = one hop (grid-based movement)
+- Mobile: Swipe in direction to hop, or on-screen d-pad`
+ },
+ {
+ title: "Simon Says",
+ prompt: `Create a Simon Says memory pattern game.
+
+GAMEPLAY MECHANICS:
+- Four colored buttons arranged in quadrants (red, blue, green, yellow)
+- Computer plays a sequence by lighting up buttons with sounds
+- Player must repeat the sequence by clicking buttons in same order
+- Sequence grows by one each successful round
+- Wrong button = game over, show final score (rounds completed)
+- Speed increases gradually as sequence gets longer
+- Each color has a distinct tone/sound
+- High score is saved and displayed
+
+VISUAL DESIGN:
+- Large circular or rounded square device
+- Four bright colored quadrants that light up when active (glow effect)
+- Center shows current round number
+- Buttons dim when inactive, bright when pressed/shown
+- "Simon Says" title at top
+- Satisfying button press animations (press down effect)
+- Game over screen with score and "Play Again"
+- Consider a "strict mode" toggle (one mistake = restart from beginning vs. one mistake = retry same sequence)
+
+CONTROLS:
+- Click/tap on colored buttons to repeat sequence
+- Each button should have hover effect on desktop
+- Consider keyboard shortcuts (1,2,3,4 or Q,W,A,S for the four colors)
+- "Start" button to begin new game`
+ }
+];
+
+async function createGame(gameData, index) {
+ console.log(`\n[${index + 1}/12] Generating: ${gameData.title}...`);
+
+ try {
+ const result = await generateGame(gameData.prompt);
+
+ if (!result.success) {
+ console.error(` Failed to generate ${gameData.title}: ${result.error}`);
+ return null;
+ }
+
+ // Insert into database with prompt
+ const dbResult = await pool.query(
+ `INSERT INTO games (creator_id, title, description, game_code, prompt, status, published_at)
+ VALUES ($1, $2, $3, $4, $5, 'published', NOW())
+ RETURNING id`,
+ [
+ ADMIN_USER_ID,
+ gameData.title,
+ gameData.prompt.split('\n\n')[0], // Use first paragraph as description
+ result.code,
+ gameData.prompt
+ ]
+ );
+
+ console.log(` Created: ${gameData.title} (ID: ${dbResult.rows[0].id})`);
+ return dbResult.rows[0].id;
+ } catch (error) {
+ console.error(` Error creating ${gameData.title}:`, error.message);
+ return null;
+ }
+}
+
+async function main() {
+ console.log('Creating 12 classic games with detailed prompts...\n');
+ console.log('Using Claude Sonnet 4 for high-quality game generation.');
+ console.log('This will take several minutes...\n');
+
+ const createdGames = [];
+
+ for (let i = 0; i < classicGames.length; i++) {
+ const gameId = await createGame(classicGames[i], i);
+ if (gameId) {
+ createdGames.push({ title: classicGames[i].title, id: gameId });
+ }
+
+ // Small delay between API calls
+ if (i < classicGames.length - 1) {
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+ }
+
+ console.log('\n========================================');
+ console.log('SUMMARY');
+ console.log('========================================');
+ console.log(`Successfully created: ${createdGames.length}/12 games\n`);
+
+ createdGames.forEach(g => {
+ console.log(` - ${g.title} (ID: ${g.id})`);
+ });
+
+ await pool.end();
+ process.exit(0);
+}
+
+main().catch(err => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/backend/scripts/final-4-games.js b/backend/scripts/final-4-games.js
new file mode 100644
index 0000000..8837c39
--- /dev/null
+++ b/backend/scripts/final-4-games.js
@@ -0,0 +1,724 @@
+require('dotenv').config();
+const { pool } = require('../src/models/db');
+const { generateGame } = require('../src/utils/claude');
+
+const games = [
+ {
+ id: 14,
+ title: "Snake",
+ prompt: `Classic Snake game - VERY EASY for 8-year-olds. Must be playable for 1+ minute before getting hard.
+
+IMPORTANT - NO AUDIO TOGGLE BUTTONS:
+- Do NOT add any sound toggle buttons to the game
+- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
+- Just expose these functions, don't create UI for them
+
+CRITICAL - CANVAS SETUP:
+\`\`\`javascript
+const canvas = document.createElement('canvas');
+document.body.appendChild(canvas);
+document.body.style.margin = '0';
+document.body.style.overflow = 'hidden';
+document.body.style.background = '#111';
+
+function resize() {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+}
+resize();
+window.addEventListener('resize', resize);
+const ctx = canvas.getContext('2d');
+\`\`\`
+
+GAME SPEED - VERY IMPORTANT:
+- Initial update interval: 280ms (VERY SLOW - easy for kids)
+- Only speed up after score >= 100 (about 10 apples)
+- Minimum interval: 150ms (never faster)
+- Speed formula: Math.max(150, 280 - Math.floor(score / 20) * 10)
+
+GAME MECHANICS:
+- Grid: 15x15 cells (large cells, easy to see)
+- Snake starts with 3 segments in center, moving RIGHT
+- WRAP AROUND edges (no wall death - forgiving)
+- Game over ONLY on self-collision
+- Apple = +10 points
+
+CONTROLS:
+- Arrow keys for desktop
+- Swipe detection (touchstart/touchend position difference)
+- Semi-transparent D-pad at bottom (70px buttons)
+
+PAUSE SUPPORT:
+\`\`\`javascript
+let paused = false;
+window.pauseGame = () => { paused = true; };
+window.resumeGame = () => { paused = false; };
+// In game loop: if (paused) return;
+\`\`\`
+
+NES-STYLE MUSIC - Multi-channel chiptune:
+\`\`\`javascript
+let audioCtx, musicEnabled = true, sfxEnabled = true;
+let musicPlaying = false;
+
+function initAudio() {
+ if (audioCtx) return;
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+}
+
+// Two-channel NES music: melody + bass
+const melody = [
+ {note: 392, dur: 0.15}, {note: 440, dur: 0.15}, {note: 494, dur: 0.15}, {note: 523, dur: 0.3},
+ {note: 494, dur: 0.15}, {note: 440, dur: 0.15}, {note: 392, dur: 0.3}, {note: 330, dur: 0.3}
+];
+const bass = [196, 196, 220, 220, 196, 196, 165, 165];
+let melodyIdx = 0, bassIdx = 0;
+
+function playMelody() {
+ if (!musicEnabled || !audioCtx || paused) return;
+ const n = melody[melodyIdx];
+ const osc = audioCtx.createOscillator();
+ const gain = audioCtx.createGain();
+ osc.type = 'square';
+ osc.frequency.value = n.note;
+ gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + n.dur);
+ osc.connect(gain).connect(audioCtx.destination);
+ osc.start();
+ osc.stop(audioCtx.currentTime + n.dur);
+ melodyIdx = (melodyIdx + 1) % melody.length;
+}
+
+function playBass() {
+ if (!musicEnabled || !audioCtx || paused) return;
+ const osc = audioCtx.createOscillator();
+ const gain = audioCtx.createGain();
+ osc.type = 'triangle';
+ osc.frequency.value = bass[bassIdx];
+ gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);
+ osc.connect(gain).connect(audioCtx.destination);
+ osc.start();
+ osc.stop(audioCtx.currentTime + 0.2);
+ bassIdx = (bassIdx + 1) % bass.length;
+}
+
+let melodyInt, bassInt;
+function startMusic() {
+ if (musicPlaying) return;
+ musicPlaying = true;
+ melodyInt = setInterval(playMelody, 200);
+ bassInt = setInterval(playBass, 400);
+}
+function stopMusic() {
+ musicPlaying = false;
+ clearInterval(melodyInt);
+ clearInterval(bassInt);
+}
+
+window.setMusicEnabled = (v) => { musicEnabled = v; v ? startMusic() : stopMusic(); };
+window.setSfxEnabled = (v) => { sfxEnabled = v; };
+\`\`\`
+
+SOUND EFFECTS (when sfxEnabled):
+- Eat: 880Hz square, 50ms
+- Game over: 220Hz descending to 110Hz, 400ms
+
+VISUALS:
+- Dark background (#111)
+- Bright green snake (#22c55e) with darker head (#16a34a)
+- Red apple (#ef4444)
+- White score at top center
+- D-pad: semi-transparent white arrows on dark circles`
+ },
+ {
+ id: 18,
+ title: "Space Invaders",
+ prompt: `Space Invaders - VERY EASY for 8-year-olds. Must be playable for 1+ minute.
+
+IMPORTANT - NO AUDIO TOGGLE BUTTONS:
+- Do NOT add any sound toggle buttons to the game
+- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
+- Just expose these functions, don't create UI for them
+
+CRITICAL - CANVAS SETUP:
+\`\`\`javascript
+const canvas = document.createElement('canvas');
+document.body.appendChild(canvas);
+document.body.style.margin = '0';
+document.body.style.overflow = 'hidden';
+document.body.style.background = '#000';
+
+function resize() {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+}
+resize();
+window.addEventListener('resize', resize);
+const ctx = canvas.getContext('2d');
+\`\`\`
+
+ALIEN INITIALIZATION - CRITICAL (do this AFTER canvas is sized):
+\`\`\`javascript
+let aliens = [];
+let player = { x: 0, y: 0, width: 50, height: 30 };
+let gameStarted = false;
+
+function initGame() {
+ // Player at bottom
+ player.x = canvas.width / 2;
+ player.y = canvas.height - 70;
+
+ // Create aliens - 3 rows x 6 columns
+ aliens = [];
+ const startX = canvas.width * 0.2;
+ const startY = 80;
+ for (let row = 0; row < 3; row++) {
+ for (let col = 0; col < 6; col++) {
+ aliens.push({
+ x: startX + col * 60,
+ y: startY + row * 50,
+ alive: true,
+ width: 40,
+ height: 30
+ });
+ }
+ }
+ alienDir = 1;
+ alienSpeed = 0.3; // VERY slow
+ gameStarted = true;
+}
+
+// Call initGame when first interaction happens
+canvas.addEventListener('click', () => { if (!gameStarted) { initAudio(); initGame(); } });
+canvas.addEventListener('touchstart', () => { if (!gameStarted) { initAudio(); initGame(); } });
+\`\`\`
+
+ALIEN MOVEMENT - MUST move sideways, NOT fall:
+\`\`\`javascript
+let alienDir = 1;
+let alienSpeed = 0.3;
+
+function updateAliens() {
+ if (!gameStarted || paused) return;
+
+ let hitEdge = false;
+ let minX = canvas.width, maxX = 0;
+
+ aliens.forEach(a => {
+ if (!a.alive) return;
+ a.x += alienDir * alienSpeed;
+ minX = Math.min(minX, a.x);
+ maxX = Math.max(maxX, a.x + a.width);
+ });
+
+ if (minX < 20 || maxX > canvas.width - 20) {
+ hitEdge = true;
+ }
+
+ if (hitEdge) {
+ alienDir *= -1;
+ aliens.forEach(a => { if (a.alive) a.y += 20; });
+ }
+}
+\`\`\`
+
+MOBILE CONTROLS - Slide to move, tap to fire:
+\`\`\`javascript
+let touchStartX = 0;
+let touchMoved = false;
+let lastTouchX = 0;
+
+canvas.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ if (!gameStarted) { initAudio(); initGame(); return; }
+ touchStartX = e.touches[0].clientX;
+ lastTouchX = touchStartX;
+ touchMoved = false;
+});
+
+canvas.addEventListener('touchmove', (e) => {
+ e.preventDefault();
+ const touchX = e.touches[0].clientX;
+ const dx = touchX - lastTouchX;
+ if (Math.abs(touchX - touchStartX) > 10) touchMoved = true;
+ player.x = Math.max(25, Math.min(canvas.width - 25, player.x + dx));
+ lastTouchX = touchX;
+});
+
+canvas.addEventListener('touchend', (e) => {
+ e.preventDefault();
+ if (!touchMoved && gameStarted) {
+ fireBullet();
+ }
+});
+\`\`\`
+
+DESKTOP: Arrow keys to move, Space to fire
+
+PAUSE SUPPORT:
+\`\`\`javascript
+let paused = false;
+window.pauseGame = () => { paused = true; };
+window.resumeGame = () => { paused = false; };
+\`\`\`
+
+DIFFICULTY:
+- Aliens shoot VERY rarely: Math.random() < 0.001 per frame per alien
+- Player has 5 lives
+- Bullets move slowly (4px/frame)
+- 10 points per alien
+
+NES-STYLE MUSIC - Space march:
+\`\`\`javascript
+let audioCtx, musicEnabled = true, sfxEnabled = true, musicPlaying = false;
+
+function initAudio() {
+ if (audioCtx) return;
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+}
+
+const marchNotes = [82, 98, 82, 98]; // E2, G2 pattern
+let marchIdx = 0, marchInt;
+
+function playMarch() {
+ if (!musicEnabled || !audioCtx || paused) return;
+ const osc = audioCtx.createOscillator();
+ const gain = audioCtx.createGain();
+ osc.type = 'square';
+ osc.frequency.value = marchNotes[marchIdx];
+ gain.gain.setValueAtTime(0.12, audioCtx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.08);
+ osc.connect(gain).connect(audioCtx.destination);
+ osc.start();
+ osc.stop(audioCtx.currentTime + 0.08);
+ marchIdx = (marchIdx + 1) % marchNotes.length;
+
+ // Also play higher accent every 4 notes
+ if (marchIdx === 0) {
+ const osc2 = audioCtx.createOscillator();
+ const gain2 = audioCtx.createGain();
+ osc2.type = 'square';
+ osc2.frequency.value = 165;
+ gain2.gain.setValueAtTime(0.06, audioCtx.currentTime);
+ gain2.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.05);
+ osc2.connect(gain2).connect(audioCtx.destination);
+ osc2.start();
+ osc2.stop(audioCtx.currentTime + 0.05);
+ }
+}
+
+function startMusic() {
+ if (musicPlaying) return;
+ musicPlaying = true;
+ marchInt = setInterval(playMarch, 500);
+}
+function stopMusic() {
+ musicPlaying = false;
+ clearInterval(marchInt);
+}
+
+window.setMusicEnabled = (v) => { musicEnabled = v; v ? startMusic() : stopMusic(); };
+window.setSfxEnabled = (v) => { sfxEnabled = v; };
+\`\`\`
+
+SOUND EFFECTS:
+- Fire: 1200Hz, 30ms square
+- Alien hit: noise burst 50ms
+- Player hit: 80Hz, 200ms
+
+VISUALS:
+- Black background
+- Green triangular ship at bottom
+- Colorful aliens (red/yellow/cyan by row)
+- Show "TAP TO START" before game begins
+- Lives as small ships bottom-left
+- Score top-left`
+ },
+ {
+ id: 20,
+ title: "Flappy Bird",
+ prompt: `Flappy Bird - VERY EASY for 8-year-olds. Playable for 1+ minute before hard.
+
+IMPORTANT - NO AUDIO TOGGLE BUTTONS:
+- Do NOT add any sound toggle buttons to the game
+- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
+- Just expose these functions, don't create UI for them
+
+CRITICAL - CANVAS SETUP:
+\`\`\`javascript
+const canvas = document.createElement('canvas');
+document.body.appendChild(canvas);
+document.body.style.margin = '0';
+document.body.style.overflow = 'hidden';
+
+function resize() {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+}
+resize();
+window.addEventListener('resize', resize);
+const ctx = canvas.getContext('2d');
+\`\`\`
+
+PROGRESSIVE DIFFICULTY - VERY GRADUAL:
+\`\`\`javascript
+function getGapSize() {
+ // Start HUGE (50% of screen), shrink SLOWLY
+ const baseGap = canvas.height * 0.5;
+ const minGap = canvas.height * 0.25;
+ // Only shrink after 60 seconds worth of points (about 30 pipes)
+ const reduction = Math.min(score, 30) * 5;
+ return Math.max(minGap, baseGap - reduction);
+}
+
+function getPipeSpeed() {
+ // Start slow (2), max out at 4
+ return 2 + Math.min(score * 0.05, 2);
+}
+
+function getGravity() {
+ return 0.35; // Gentle gravity throughout
+}
+\`\`\`
+
+GAME MECHANICS:
+- Bird at x = canvas.width * 0.3
+- Gravity: 0.35 per frame (gentle)
+- Flap: velocity = -7 (strong boost)
+- Pipes spawn every 180 frames
+- Gap position: random but never too close to top/bottom
+- Forgiving hitbox: 5px smaller than visual on each side
+
+CONTROLS:
+\`\`\`javascript
+let gameOver = false, gameStarted = false;
+let bird = { x: 0, y: 0, vy: 0, radius: 18 };
+
+function flap() {
+ initAudio();
+ if (gameOver) { resetGame(); return; }
+ if (!gameStarted) { gameStarted = true; startMusic(); }
+ bird.vy = -7;
+ if (sfxEnabled) playFlap();
+}
+
+canvas.addEventListener('click', flap);
+canvas.addEventListener('touchstart', (e) => { e.preventDefault(); flap(); });
+document.addEventListener('keydown', (e) => {
+ if (e.code === 'Space' || e.key === ' ') { e.preventDefault(); flap(); }
+});
+\`\`\`
+
+PAUSE SUPPORT:
+\`\`\`javascript
+let paused = false;
+window.pauseGame = () => { paused = true; stopMusic(); };
+window.resumeGame = () => { paused = false; if (gameStarted && !gameOver) startMusic(); };
+\`\`\`
+
+NES-STYLE MUSIC - Happy chiptune:
+\`\`\`javascript
+let audioCtx, musicEnabled = true, sfxEnabled = true, musicPlaying = false;
+
+function initAudio() {
+ if (audioCtx) return;
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+}
+
+// Happy melody in C major
+const melody = [
+ {n: 523, d: 0.12}, {n: 587, d: 0.12}, {n: 659, d: 0.12}, {n: 784, d: 0.24},
+ {n: 659, d: 0.12}, {n: 587, d: 0.12}, {n: 523, d: 0.24}, {n: 440, d: 0.12},
+ {n: 523, d: 0.12}, {n: 587, d: 0.12}, {n: 523, d: 0.24}
+];
+const bass = [262, 262, 330, 330, 262, 262, 220, 220, 262, 262, 262];
+let mIdx = 0, melodyInt, bassInt;
+
+function playMelodyNote() {
+ if (!musicEnabled || !audioCtx || paused) return;
+ const m = melody[mIdx];
+ const osc = audioCtx.createOscillator();
+ const g = audioCtx.createGain();
+ osc.type = 'square';
+ osc.frequency.value = m.n;
+ g.gain.setValueAtTime(0.08, audioCtx.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + m.d * 0.9);
+ osc.connect(g).connect(audioCtx.destination);
+ osc.start();
+ osc.stop(audioCtx.currentTime + m.d);
+ mIdx = (mIdx + 1) % melody.length;
+}
+
+function playBassNote() {
+ if (!musicEnabled || !audioCtx || paused) return;
+ const osc = audioCtx.createOscillator();
+ const g = audioCtx.createGain();
+ osc.type = 'triangle';
+ osc.frequency.value = bass[mIdx % bass.length];
+ g.gain.setValueAtTime(0.06, audioCtx.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);
+ osc.connect(g).connect(audioCtx.destination);
+ osc.start();
+ osc.stop(audioCtx.currentTime + 0.15);
+}
+
+function startMusic() {
+ if (musicPlaying) return;
+ musicPlaying = true;
+ melodyInt = setInterval(playMelodyNote, 150);
+ bassInt = setInterval(playBassNote, 300);
+}
+function stopMusic() {
+ musicPlaying = false;
+ clearInterval(melodyInt);
+ clearInterval(bassInt);
+}
+
+window.setMusicEnabled = (v) => { musicEnabled = v; if(gameStarted && !gameOver) v ? startMusic() : stopMusic(); };
+window.setSfxEnabled = (v) => { sfxEnabled = v; };
+
+function playFlap() {
+ if (!sfxEnabled || !audioCtx) return;
+ // Quick noise whoosh
+ const bufferSize = audioCtx.sampleRate * 0.03;
+ const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i/bufferSize);
+ const src = audioCtx.createBufferSource();
+ src.buffer = buffer;
+ const g = audioCtx.createGain();
+ g.gain.value = 0.15;
+ src.connect(g).connect(audioCtx.destination);
+ src.start();
+}
+\`\`\`
+
+VISUALS:
+- Sky blue gradient (#87CEEB to #E0F4FF)
+- Yellow bird (circle) with orange beak, white eye
+- Green pipes (#22c55e) with darker caps (#16a34a)
+- Brown ground (#8B4513) at bottom, 40px tall
+- Large white score at top center
+- "Tap to Start" text before game
+- "Game Over - Tap to Retry" when dead`
+ },
+ {
+ id: 22,
+ title: "Minesweeper",
+ prompt: `Minesweeper - EASY for 8-year-olds. DOM-based (not canvas).
+
+IMPORTANT - NO AUDIO TOGGLE BUTTONS:
+- Do NOT add any sound toggle buttons to the game
+- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
+- Just expose these functions, don't create UI for them
+
+LAYOUT SETUP:
+\`\`\`javascript
+document.body.style.cssText = 'margin:0;background:#1e293b;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:15px;box-sizing:border-box;font-family:system-ui,sans-serif;';
+
+const GRID_SIZE = 8;
+const MINES = 8;
+const cellSize = Math.min(48, Math.floor((Math.min(window.innerWidth, window.innerHeight) - 60) / GRID_SIZE));
+
+// Header
+const header = document.createElement('div');
+header.style.cssText = 'display:flex;gap:15px;margin-bottom:12px;align-items:center;color:white;';
+document.body.appendChild(header);
+
+// Mine counter
+const mineCount = document.createElement('span');
+mineCount.style.cssText = 'font-size:1.2rem;font-weight:bold;min-width:40px;';
+mineCount.textContent = '💣 ' + MINES;
+header.appendChild(mineCount);
+
+// Reset button
+const resetBtn = document.createElement('button');
+resetBtn.style.cssText = 'font-size:1.5rem;padding:5px 15px;border:none;border-radius:8px;cursor:pointer;background:#fbbf24;';
+resetBtn.textContent = '😊';
+resetBtn.onclick = resetGame;
+header.appendChild(resetBtn);
+
+// Timer
+const timerEl = document.createElement('span');
+timerEl.style.cssText = 'font-size:1.2rem;font-weight:bold;min-width:50px;';
+timerEl.textContent = '⏱ 0';
+header.appendChild(timerEl);
+
+// Grid
+const grid = document.createElement('div');
+grid.style.cssText = \`display:grid;grid-template-columns:repeat(\${GRID_SIZE},\${cellSize}px);gap:3px;background:#334155;padding:10px;border-radius:10px;\`;
+document.body.appendChild(grid);
+\`\`\`
+
+CELL CREATION:
+\`\`\`javascript
+const cells = [];
+const cellData = []; // {mine, revealed, flagged, adjacentMines}
+
+function createCells() {
+ grid.innerHTML = '';
+ cells.length = 0;
+ cellData.length = 0;
+
+ for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
+ const cell = document.createElement('div');
+ cell.style.cssText = \`
+ width:\${cellSize}px;height:\${cellSize}px;
+ background:#64748b;border-radius:6px;
+ display:flex;align-items:center;justify-content:center;
+ font-size:\${cellSize * 0.55}px;font-weight:bold;
+ cursor:pointer;user-select:none;
+ transition:background 0.1s;
+ \`;
+
+ cellData.push({ mine: false, revealed: false, flagged: false, adjacentMines: 0 });
+
+ // MOBILE LONG PRESS FOR FLAG
+ let pressTimer = null;
+ let longPressed = false;
+
+ cell.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ longPressed = false;
+ cell.style.background = '#475569';
+ pressTimer = setTimeout(() => {
+ longPressed = true;
+ toggleFlag(i);
+ cell.style.background = cellData[i].revealed ? '#e2e8f0' : '#64748b';
+ }, 400);
+ });
+
+ cell.addEventListener('touchend', (e) => {
+ e.preventDefault();
+ clearTimeout(pressTimer);
+ cell.style.background = cellData[i].revealed ? '#e2e8f0' : '#64748b';
+ if (!longPressed && !gameOver) {
+ revealCell(i);
+ }
+ });
+
+ cell.addEventListener('touchmove', () => {
+ clearTimeout(pressTimer);
+ cell.style.background = cellData[i].revealed ? '#e2e8f0' : '#64748b';
+ });
+
+ // Desktop
+ cell.addEventListener('click', () => revealCell(i));
+ cell.addEventListener('contextmenu', (e) => { e.preventDefault(); toggleFlag(i); });
+
+ cells.push(cell);
+ grid.appendChild(cell);
+ }
+}
+\`\`\`
+
+GAME LOGIC:
+- First click + 8 neighbors always safe (place mines after)
+- Flood fill for empty cells
+- Numbers colored: 1=blue, 2=green, 3=red, 4=purple, 5=maroon, 6=teal
+
+PAUSE SUPPORT:
+\`\`\`javascript
+let paused = false;
+window.pauseGame = () => { paused = true; stopMusic(); };
+window.resumeGame = () => { paused = false; startMusic(); };
+\`\`\`
+
+NES-STYLE MUSIC - Calm puzzle:
+\`\`\`javascript
+let audioCtx, musicEnabled = true, sfxEnabled = true, musicPlaying = false;
+
+function initAudio() {
+ if (audioCtx) return;
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+}
+
+// Calm arpeggio pattern
+const notes = [262, 330, 392, 330, 262, 294, 349, 294];
+let noteIdx = 0, musicInt;
+
+function playNote() {
+ if (!musicEnabled || !audioCtx || paused || gameOver) return;
+ const osc = audioCtx.createOscillator();
+ const g = audioCtx.createGain();
+ osc.type = 'sine';
+ osc.frequency.value = notes[noteIdx];
+ g.gain.setValueAtTime(0.07, audioCtx.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.25);
+ osc.connect(g).connect(audioCtx.destination);
+ osc.start();
+ osc.stop(audioCtx.currentTime + 0.25);
+ noteIdx = (noteIdx + 1) % notes.length;
+}
+
+function startMusic() {
+ if (musicPlaying) return;
+ musicPlaying = true;
+ musicInt = setInterval(playNote, 350);
+}
+function stopMusic() {
+ musicPlaying = false;
+ clearInterval(musicInt);
+}
+
+window.setMusicEnabled = (v) => { musicEnabled = v; v ? startMusic() : stopMusic(); };
+window.setSfxEnabled = (v) => { sfxEnabled = v; };
+\`\`\`
+
+SOUND EFFECTS:
+- Reveal: 600Hz click, 20ms
+- Flag: 300Hz, 40ms
+- Mine: noise explosion 200ms
+- Win: ascending C-E-G-C arpeggio
+
+VISUALS:
+- Dark slate background
+- Gray unrevealed (#64748b)
+- Light revealed (#e2e8f0)
+- 🚩 for flags, 💣 for mines
+- 😊 normal, 😎 win, 😵 lose for reset button`
+ }
+];
+
+async function regenerateGame(g) {
+ console.log(`\nRegenerating: ${g.title}...`);
+ try {
+ const result = await generateGame(g.prompt);
+ if (!result.success) {
+ console.log(` FAILED: ${result.error}`);
+ return false;
+ }
+ await pool.query('UPDATE games SET game_code = $1, prompt = $2 WHERE id = $3', [result.code, g.prompt, g.id]);
+ console.log(` SUCCESS`);
+ return true;
+ } catch (e) {
+ console.log(` ERROR: ${e.message}`);
+ return false;
+ }
+}
+
+async function main() {
+ console.log('='.repeat(50));
+ console.log('REGENERATING 4 GAMES - v3');
+ console.log('Slow snake, fixed space invaders, better music');
+ console.log('='.repeat(50));
+
+ let ok = 0;
+ for (const g of games) {
+ if (await regenerateGame(g)) ok++;
+ await new Promise(r => setTimeout(r, 3000));
+ }
+
+ console.log('\n' + '='.repeat(50));
+ console.log(`COMPLETE: ${ok}/4 successful`);
+ console.log('='.repeat(50));
+
+ await pool.end();
+ process.exit(ok === 4 ? 0 : 1);
+}
+
+main().catch(err => {
+ console.error('Fatal:', err);
+ process.exit(1);
+});
diff --git a/backend/scripts/fix-games.js b/backend/scripts/fix-games.js
new file mode 100644
index 0000000..f042726
--- /dev/null
+++ b/backend/scripts/fix-games.js
@@ -0,0 +1,462 @@
+require('dotenv').config();
+const { pool } = require('../src/models/db');
+const { generateGame } = require('../src/utils/claude');
+
+// Games that need fixing (Pong ID:16 and Minesweeper ID:22 work well)
+const gamesToFix = [
+ {
+ id: 14,
+ title: "Snake",
+ prompt: `Create a classic Snake game. The snake starts in the center moving right.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Snake moves continuously in current direction
+- Game starts immediately when page loads
+- Use requestAnimationFrame for smooth animation
+
+CONTROLS (implement ALL):
+- Desktop: Arrow keys change direction (prevent 180° turns)
+- Mobile: Touch/swipe detection - swipe in any direction to turn the snake
+- Add visible on-screen arrow buttons for mobile (semi-transparent, positioned at bottom)
+
+GAMEPLAY:
+- Snake is green segments on a dark grid background
+- Red apple appears randomly, eating it grows snake by 1 and adds 10 points
+- Game over when hitting walls or self
+- Show score in top-left corner
+- Game over screen shows "Game Over! Score: X" with "Tap to Restart" button
+- Speed increases slightly every 50 points
+
+MOBILE ON-SCREEN CONTROLS:
+Add a d-pad style control at the bottom of the screen:
+- Four arrow buttons (up, down, left, right) arranged in a cross pattern
+- Buttons should be 50px, semi-transparent white with dark arrows
+- Position fixed at bottom center of game area
+
+Keep game area square and centered. Use canvas for rendering.`
+ },
+ {
+ id: 15,
+ title: "Tetris",
+ prompt: `Create a Tetris falling block puzzle game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Pieces fall automatically at steady pace
+- Game starts immediately when page loads
+- Use requestAnimationFrame for game loop
+
+CONTROLS (implement ALL):
+- Desktop: Left/Right arrows move piece, Up arrow rotates, Down arrow soft drop, Space hard drop
+- Mobile: Add visible touch buttons at bottom of screen
+
+MOBILE CONTROLS (REQUIRED):
+Create a control panel at bottom with these buttons:
+- Left arrow button (move left)
+- Right arrow button (move right)
+- Rotate button (clockwise rotation)
+- Down button (soft drop)
+- Drop button (hard drop)
+Buttons: 45px size, semi-transparent, arranged in a row
+
+GAMEPLAY:
+- 10 columns, 20 rows playing field
+- 7 tetromino shapes (I, O, T, S, Z, J, L) with different colors
+- Completed lines clear with flash effect
+- Score: 1 line=100, 2=300, 3=500, 4=800 points
+- Show next piece preview on the side
+- Game over when pieces reach top
+- Speed increases every level (10 lines = 1 level)
+
+Use canvas rendering. Game area should be centered with controls below.`
+ },
+ {
+ id: 17,
+ title: "Breakout",
+ prompt: `Create a Breakout/Brick Breaker arcade game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Ball bounces continuously once launched
+- Paddle follows mouse/touch position smoothly
+- Game starts with ball on paddle, click/tap to launch
+
+CONTROLS:
+- Desktop: Mouse movement controls paddle position, click to launch ball
+- Mobile: Touch and drag anywhere to move paddle, tap to launch ball
+- Paddle should follow finger/mouse X position instantly
+
+GAMEPLAY:
+- 8 rows of colored bricks at top (rainbow colors)
+- White paddle at bottom, ball is white circle
+- Ball bounces off walls, paddle, and bricks
+- Each brick hit = 10 points, brick disappears
+- Ball angle changes based on where it hits paddle (edges = sharper angle)
+- Lose a life if ball falls below paddle (3 lives total)
+- Win when all bricks destroyed
+- Show score and lives at top
+
+VISUAL:
+- Black background
+- Colorful bricks in rows (red, orange, yellow, green, blue, purple)
+- Paddle is white rectangle, rounded corners
+- Ball leaves a short trail effect
+- Bricks flash when hit before disappearing
+
+Use canvas. No on-screen buttons needed - paddle follows touch/mouse directly.`
+ },
+ {
+ id: 18,
+ title: "Space Invaders",
+ prompt: `Create a Space Invaders shooting game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Aliens move and player can shoot immediately
+- Smooth 60fps animation with requestAnimationFrame
+
+CONTROLS (implement ALL):
+- Desktop: Left/Right arrows move ship, Space to shoot
+- Mobile: Touch left half of screen to move left, right half to move right
+- Mobile: Tap center area or double-tap anywhere to shoot
+- Add visible Left/Right buttons at bottom corners and Fire button in center
+
+MOBILE CONTROLS (REQUIRED):
+- Left button: bottom-left corner, 60px, arrow pointing left
+- Right button: bottom-right corner, 60px, arrow pointing right
+- Fire button: bottom-center, 70px, red circle with "FIRE" text
+All buttons semi-transparent
+
+GAMEPLAY:
+- 5 rows of 8 aliens that move side-to-side and drop down
+- Player ship at bottom can move and shoot upward
+- One bullet on screen at a time
+- Aliens occasionally drop bombs
+- 3 lives, hit by bomb = lose life
+- Bottom row aliens = 10pts, increasing 10pts per row up
+- Aliens speed up as fewer remain
+- Wave complete when all aliens destroyed
+
+VISUAL:
+- Black space background with twinkling stars
+- Green player ship (triangle)
+- Aliens are simple pixel-art style (different shapes per row)
+- White bullets, red alien bombs
+
+Use canvas rendering.`
+ },
+ {
+ id: 19,
+ title: "Pac-Man",
+ prompt: `Create a simplified Pac-Man maze game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Pac-Man moves continuously in set direction
+- Simple maze (not full Pac-Man complexity)
+- Smooth animation at 60fps
+
+CONTROLS (implement ALL):
+- Desktop: Arrow keys set movement direction
+- Mobile: Swipe in direction to change Pac-Man's direction
+- Add on-screen d-pad for mobile (arrow buttons in cross pattern)
+
+MOBILE CONTROLS (REQUIRED):
+D-pad at bottom center:
+- Up, Down, Left, Right buttons arranged in cross
+- Each button 50px, semi-transparent
+- Pac-Man turns at next opportunity when button pressed
+
+GAMEPLAY:
+- Simple maze with blue walls (use a basic grid maze, 15x15)
+- Yellow dots throughout maze (10 points each)
+- 4 large power pellets in corners (50 points, makes ghosts vulnerable)
+- 4 colored ghosts that chase Pac-Man
+- When powered up (8 seconds), ghosts turn blue and can be eaten (200 pts)
+- Lose life if touched by non-blue ghost (3 lives)
+- Level complete when all dots eaten
+
+VISUAL:
+- Black background, blue maze walls
+- Yellow Pac-Man with chomping animation
+- Ghosts: Red, Pink, Cyan, Orange with googly eyes
+- Dots are small white circles, power pellets are large and flashing
+
+Use canvas. Keep maze simple for playability.`
+ },
+ {
+ id: 20,
+ title: "Flappy Bird",
+ prompt: `Create a Flappy Bird style game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Bird flaps on ANY input (tap, click, spacebar, any key)
+- Gravity pulls bird down constantly
+- Game starts with "Tap to Start" message
+
+CONTROLS (SIMPLE):
+- ANY tap, click, touch, or key press = bird flaps upward
+- That's the only control needed
+- Make sure touchstart event is captured on the entire game canvas
+
+GAMEPLAY:
+- Bird on left side of screen, pipes scroll from right
+- Pipes have gap in middle to fly through
+- Each pipe passed = 1 point
+- Game over if bird hits pipe, ground, or ceiling
+- Bird rotates based on velocity (nose up when rising, down when falling)
+- After game over, show score and "Tap to Restart"
+
+PHYSICS:
+- Gravity: bird falls at accelerating rate
+- Flap: gives instant upward velocity
+- Bird should feel "floaty" but responsive
+- Pipe gap should be generous (bird height * 4)
+
+VISUAL:
+- Light blue sky background with simple clouds
+- Green pipes from top and bottom
+- Yellow/orange bird with wing flap animation
+- Ground at bottom with scrolling grass texture
+- Large score number at top center
+
+Use canvas. Entire canvas should respond to touch events.`
+ },
+ {
+ id: 21,
+ title: "2048",
+ prompt: `Create the 2048 sliding puzzle game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Tiles slide and merge correctly
+- Touch swipe detection must work reliably
+- Game should feel smooth and responsive
+
+CONTROLS:
+- Desktop: Arrow keys slide all tiles in that direction
+- Mobile: Swipe gestures (detect swipe direction on touchend)
+- Swipe detection: track touchstart and touchend positions, determine direction
+
+SWIPE IMPLEMENTATION:
+- On touchstart: record start X,Y
+- On touchend: calculate delta X,Y
+- If |deltaX| > |deltaY| and |deltaX| > 30px: horizontal swipe
+- If |deltaY| > |deltaX| and |deltaY| > 30px: vertical swipe
+- Determine direction based on positive/negative delta
+
+GAMEPLAY:
+- 4x4 grid of tiles
+- Start with two "2" tiles in random positions
+- Sliding merges matching adjacent tiles (2+2=4, 4+4=8, etc)
+- After each move, new tile (90% chance of 2, 10% chance of 4) appears
+- Score increases by merged tile value
+- Game over when no moves possible
+- Win message at 2048 but allow continuing
+
+VISUAL:
+- Clean, modern design with rounded tiles
+- Each number has distinct background color:
+ - 2: #eee4da, 4: #ede0c8, 8: #f2b179, 16: #f59563
+ - 32: #f67c5f, 64: #f65e3b, 128+: #edcf72 to #edc22e
+- Tiles animate when sliding and merging (CSS transitions)
+- Large, bold numbers on tiles
+- Score display at top
+
+Use div-based grid with CSS transitions for smooth animations.`
+ },
+ {
+ id: 23,
+ title: "Asteroids",
+ prompt: `Create a classic Asteroids space shooter.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Ship rotates and thrusts with momentum-based physics
+- Screen wrapping (go off one edge, appear on opposite)
+- Smooth 60fps gameplay
+
+CONTROLS (implement ALL):
+- Desktop: Left/Right rotate ship, Up thrusts forward, Space shoots
+- Mobile: On-screen virtual joystick or buttons
+
+MOBILE CONTROLS (REQUIRED):
+Left side: Rotation buttons (two buttons: rotate left, rotate right)
+Right side: Action buttons (Thrust button, Fire button)
+All buttons 50-60px, semi-transparent, positioned at bottom
+
+GAMEPLAY:
+- Ship starts in center, 3 lives
+- Large asteroids split into 2 medium, medium into 2 small
+- Points: Large=20, Medium=50, Small=100
+- Ship has inertia - keeps drifting after thrust stops
+- Bullets travel straight and wrap around screen
+- Collision with asteroid = lose life, brief invincibility after respawn
+- Clear all asteroids = next wave with more asteroids
+
+PHYSICS:
+- Ship rotation is instant
+- Thrust adds velocity in facing direction
+- Small friction to eventually slow down
+- Asteroids drift at random angles and speeds
+
+VISUAL:
+- Black background with small white stars
+- Ship is white triangle outline (vector style)
+- Asteroids are irregular polygon outlines
+- Bullets are small white dots
+- Explosion particles when things are destroyed
+
+Use canvas with vector-style graphics (lines, not filled shapes).`
+ },
+ {
+ id: 24,
+ title: "Frogger",
+ prompt: `Create a Frogger road-crossing game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Grid-based hop movement (one square per input)
+- Cars and logs move continuously
+- Simple but playable level design
+
+CONTROLS:
+- Desktop: Arrow keys to hop in that direction
+- Mobile: On-screen d-pad buttons OR swipe to hop
+
+MOBILE CONTROLS (REQUIRED):
+D-pad at bottom:
+- Up/Down/Left/Right buttons in cross pattern
+- Each button 55px, semi-transparent
+- Each tap = one hop in that direction
+
+GAMEPLAY:
+- Frog starts at bottom, goal is to reach top
+- Bottom half: Road with cars/trucks moving left and right
+- Top half: River with logs and turtles moving left and right
+- Hit by car = lose life
+- Fall in water (miss a log) = lose life
+- Ride logs/turtles to cross river
+- 5 home spots at top to fill
+- Fill all 5 = level complete
+- 3 lives, timer countdown (30 seconds per frog)
+
+LEVEL DESIGN:
+- Row 1 (bottom): Safe starting grass
+- Rows 2-4: Roads with vehicles (alternate directions)
+- Row 5: Safe middle grass
+- Rows 6-8: River with logs/turtles
+- Row 9 (top): Home bases with lily pads
+
+VISUAL:
+- Green frog (top-down view)
+- Gray roads with lane markings
+- Blue water
+- Brown logs, green turtles
+- Colorful cars/trucks
+- Green grass safe zones
+
+Use canvas, grid-based movement.`
+ },
+ {
+ id: 25,
+ title: "Simon Says",
+ prompt: `Create a Simon Says memory pattern game.
+
+CRITICAL REQUIREMENTS:
+- Game MUST work on both desktop and mobile
+- Buttons must be large and easy to tap on mobile
+- Clear visual and audio-like feedback when buttons activate
+- Pattern plays automatically, then waits for player input
+
+CONTROLS:
+- Desktop: Click the colored buttons OR use keys 1,2,3,4
+- Mobile: Tap the colored buttons (make them big!)
+
+GAMEPLAY:
+- Four large colored buttons (Red, Blue, Green, Yellow)
+- Arranged in 2x2 grid or quadrants of a circle
+- Computer shows pattern by lighting up buttons in sequence
+- Player must repeat the pattern by pressing buttons
+- Each round adds one more to the sequence
+- Wrong button = game over, show final score (rounds completed)
+- Display current round number
+
+BUTTON BEHAVIOR:
+- When showing pattern: button lights up brightly for 0.5s, 0.3s gap between
+- When player presses: button lights up while pressed
+- Each button should have distinct appearance when lit vs unlit
+- Consider adding different tones/sounds for each button (optional)
+
+VISUAL:
+- Large, touchable buttons (at least 120px each on mobile)
+- Distinct colors: Red (#ff4444), Blue (#4444ff), Green (#44ff44), Yellow (#ffff44)
+- Buttons dim when inactive, bright when active
+- Center area shows round number and "Watch..." or "Your turn!" message
+- Dark background to make colors pop
+- "Start Game" button initially, "Play Again" after game over
+
+LAYOUT:
+- Buttons should fill most of the screen
+- 2x2 grid with small gaps
+- Score/round display above or below buttons
+- Make sure buttons are finger-friendly size
+
+Use div-based buttons with CSS for the glow effects. No canvas needed.`
+ }
+];
+
+async function fixGame(gameData) {
+ console.log(`\nRegenerating: ${gameData.title} (ID: ${gameData.id})...`);
+
+ try {
+ const result = await generateGame(gameData.prompt);
+
+ if (!result.success) {
+ console.error(` Failed: ${result.error}`);
+ return false;
+ }
+
+ // Update the game in database
+ await pool.query(
+ `UPDATE games SET game_code = $1, prompt = $2 WHERE id = $3`,
+ [result.code, gameData.prompt, gameData.id]
+ );
+
+ console.log(` Success: ${gameData.title} updated`);
+ return true;
+ } catch (error) {
+ console.error(` Error: ${error.message}`);
+ return false;
+ }
+}
+
+async function main() {
+ console.log('Fixing games with improved controls...');
+ console.log(`Games to fix: ${gamesToFix.length}`);
+ console.log('Using Claude Sonnet 4 for generation.\n');
+
+ let successCount = 0;
+
+ for (const game of gamesToFix) {
+ const success = await fixGame(game);
+ if (success) successCount++;
+
+ // Delay between API calls
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+
+ console.log('\n========================================');
+ console.log(`COMPLETE: ${successCount}/${gamesToFix.length} games fixed`);
+ console.log('========================================');
+
+ await pool.end();
+ process.exit(0);
+}
+
+main().catch(err => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/backend/scripts/fix-snake.js b/backend/scripts/fix-snake.js
new file mode 100644
index 0000000..21b0469
--- /dev/null
+++ b/backend/scripts/fix-snake.js
@@ -0,0 +1,197 @@
+require('dotenv').config();
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ host: process.env.DB_HOST,
+ port: process.env.DB_PORT,
+ database: process.env.DB_NAME,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+});
+
+const snakeCode = `
+Snake
+
+Score: 0
TAP TO START Arrow keys or swipe to move
Game Over! Score: 0
Play Again
+`;
+
+async function fix() {
+ try {
+ await pool.query(
+ "UPDATE games SET game_code = $1 WHERE title = 'Snake Classic'",
+ [snakeCode]
+ );
+ console.log('Snake game fixed!');
+ } finally {
+ await pool.end();
+ }
+}
+
+fix().catch(console.error);
diff --git a/backend/scripts/fix-touch-handling.js b/backend/scripts/fix-touch-handling.js
new file mode 100755
index 0000000..a482a9f
--- /dev/null
+++ b/backend/scripts/fix-touch-handling.js
@@ -0,0 +1,333 @@
+#!/usr/bin/env node
+/**
+ * Fix touch handling in all existing games
+ *
+ * Problem: Games restart on every touch during gameplay because the canvas
+ * has a touchstart/click listener that calls startGame() unconditionally.
+ *
+ * Solution: Modify touch/click handlers to only restart when gameState === 'gameOver'
+ *
+ * Usage:
+ * node fix-touch-handling.js # Fix all games
+ * node fix-touch-handling.js --dry-run # Preview changes without saving
+ * node fix-touch-handling.js --game 14 # Fix specific game by ID
+ */
+
+require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
+
+const { Pool } = require('pg');
+const Anthropic = require('@anthropic-ai/sdk');
+
+const pool = new Pool({
+ host: process.env.DB_HOST || 'localhost',
+ port: parseInt(process.env.DB_PORT || '5432'),
+ database: process.env.DB_NAME || 'game_arcade',
+ user: process.env.DB_USER || 'gamearc',
+ password: process.env.DB_PASSWORD
+});
+
+const anthropic = new Anthropic({
+ apiKey: process.env.CLAUDE_API_KEY,
+ timeout: 120 * 1000 // 120 seconds for larger games
+});
+
+const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
+
+const FIX_PROMPT = `You are fixing touch handling in an HTML5 canvas game for mobile. The issues are:
+
+**Problems**:
+1. Game may restart on every touch during gameplay (unplayable on mobile)
+2. After game over, tapping immediately starts AND plays (no pause to see score)
+3. Both touchstart and click events may fire (double-tap issue)
+
+**Required Changes**:
+
+1. **Use a 3-state system**: 'ready' → 'playing' → 'gameOver' → 'ready'
+ - 'ready': Shows "Tap to Start", waits for tap
+ - 'playing': Game is active, taps control the game
+ - 'gameOver': Shows score + "Tap to Play Again", tap goes to 'ready' (NOT directly to playing)
+
+2. **Fix touch handler to prevent double-fire**:
+\`\`\`javascript
+let lastTapTime = 0;
+function handleTap(e) {
+ if (e.type === 'touchstart') e.preventDefault();
+ const now = Date.now();
+ if (now - lastTapTime < 100) return; // Debounce
+ lastTapTime = now;
+ // ... rest of tap logic
+}
+canvas.addEventListener('touchstart', handleTap, { passive: false });
+canvas.addEventListener('click', handleTap);
+\`\`\`
+
+3. **State transitions on tap**:
+ - If gameState === 'ready': set gameState = 'playing', start game
+ - If gameState === 'playing': perform game action (flap, jump, etc.)
+ - If gameState === 'gameOver': set gameState = 'ready', reset game (but DON'T start yet)
+
+4. **Update draw function** to show appropriate messages:
+ - ready: "Tap to Start!"
+ - gameOver: "Game Over! Score: X" + "Tap to Continue"
+
+**IMPORTANT**:
+- Keep all existing game mechanics intact
+- Keep all existing visual styles
+- Only change the state management and tap handling
+- Return the complete fixed HTML code
+
+Return ONLY the complete HTML code, no explanations.`;
+
+async function fixGameCode(gameCode, gameTitle) {
+ try {
+ const response = await anthropic.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 16000,
+ system: FIX_PROMPT,
+ messages: [{
+ role: 'user',
+ content: `Fix this game "${gameTitle}":\n\n${gameCode}`
+ }]
+ });
+
+ let fixedCode = response.content[0].text.trim();
+
+ // Remove markdown code blocks if present
+ if (fixedCode.startsWith('```html')) {
+ fixedCode = fixedCode.slice(7);
+ } else if (fixedCode.startsWith('```')) {
+ fixedCode = fixedCode.slice(3);
+ }
+ if (fixedCode.endsWith('```')) {
+ fixedCode = fixedCode.slice(0, -3);
+ }
+
+ return fixedCode.trim();
+ } catch (error) {
+ console.error(` Error fixing game: ${error.message}`);
+ return null;
+ }
+}
+
+async function saveGameVersion(gameId, oldCode, newCode, changeDescription) {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ // Get current version number
+ const versionResult = await client.query(
+ 'SELECT COALESCE(MAX(version_number), 0) as max_version FROM game_versions WHERE game_id = $1',
+ [gameId]
+ );
+ const newVersion = versionResult.rows[0].max_version + 1;
+
+ // Save version history
+ await client.query(
+ `INSERT INTO game_versions (game_id, version_number, game_code, change_type, change_description)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [gameId, newVersion, oldCode, 'fix', changeDescription]
+ );
+
+ // Update game code
+ await client.query(
+ 'UPDATE games SET game_code = $1, code_updated_at = NOW() WHERE id = $2',
+ [newCode, gameId]
+ );
+
+ await client.query('COMMIT');
+ return newVersion;
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ } finally {
+ client.release();
+ }
+}
+
+async function analyzeGame(gameCode) {
+ // Thorough heuristic check for touch issues
+ const issues = [];
+
+ // Look for canvas touch/click handlers
+ const canvasClickMatch = gameCode.match(/canvas\.(addEventListener\s*\(\s*['"](?:click|touchstart|pointerdown)['"]|onclick\s*=|ontouchstart\s*=)/gi);
+ const hasCanvasTouch = canvasClickMatch && canvasClickMatch.length > 0;
+
+ // Check for proper 3-state system (ready/playing/gameOver)
+ const hasThreeStates = /gameState\s*===?\s*['"]ready['"]/i.test(gameCode);
+
+ // Check for gameState or gameOver variable
+ const hasGameState = /(?:let|var|const)\s+gameState\s*=/i.test(gameCode);
+ const hasGameOver = /(?:let|var|const)\s+(?:gameOver|isGameOver|game_over)\s*=/i.test(gameCode);
+
+ // Check for tap debouncing
+ const hasTapDebounce = /lastTapTime|tapDebounce|Date\.now\(\)\s*-/i.test(gameCode);
+
+ // Check if restart/reset functions check game state before executing
+ const hasConditionalRestart = /if\s*\(\s*(?:gameState\s*===?\s*['"](?:gameOver|over|ended)['"]|gameOver\s*(?:===?\s*true)?|isGameOver)/i.test(gameCode);
+
+ // For games where canvas touch is the game mechanic (like Flappy Bird),
+ // check if game over transitions to 'ready' (not directly back to playing)
+ const hasFlap = /function\s+flap\s*\(/i.test(gameCode);
+ const flapChecksGameOver = /function\s+flap[\s\S]{0,200}if\s*\(\s*gameOver\s*\)/i.test(gameCode);
+
+ // Check if resetGame goes to 'ready' state instead of immediately allowing play
+ const resetGoesToReady = /resetGame[\s\S]{0,300}gameState\s*=\s*['"]ready['"]/i.test(gameCode);
+
+ // Games using d-pad buttons should NOT have canvas click for restart during play
+ const hasDpad = /class\s*=\s*['"].*dpad/i.test(gameCode);
+ const hasButtonControls = /getElementById\s*\(\s*['"](?:leftBtn|rightBtn|upBtn|downBtn|actionBtn|shootBtn)/i.test(gameCode);
+
+ // Check for problematic patterns
+ if (hasCanvasTouch) {
+ // Missing 3-state system means game may feel "restarty"
+ if (!hasThreeStates && hasFlap) {
+ issues.push('Flappy-style game missing 3-state system (ready/playing/gameOver)');
+ }
+
+ // No tap debouncing
+ if (!hasTapDebounce) {
+ issues.push('No tap debouncing (may double-fire on mobile)');
+ }
+
+ // If using d-pad/buttons AND canvas touch handler exists, check if it's properly guarded
+ if ((hasDpad || hasButtonControls) && !hasConditionalRestart) {
+ issues.push('Uses button controls but canvas touch handler may not check game state');
+ }
+
+ // If game uses flap mechanic, check it's guarded
+ if (hasFlap && !flapChecksGameOver) {
+ issues.push('Flap function may not check gameOver state');
+ }
+ }
+
+ // If no game state tracking at all, that's a problem
+ if (hasCanvasTouch && !hasGameState && !hasGameOver) {
+ issues.push('Has canvas touch but no gameState/gameOver tracking');
+ }
+
+ return {
+ needsFix: issues.length > 0,
+ issues,
+ details: { hasCanvasTouch, hasGameState, hasGameOver, hasThreeStates, hasTapDebounce, hasConditionalRestart, hasDpad, hasButtonControls }
+ };
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const dryRun = args.includes('--dry-run');
+ const gameIdArg = args.find(a => a.startsWith('--game'));
+ const specificGameId = gameIdArg ? parseInt(args[args.indexOf(gameIdArg) + 1] || args[args.indexOf('--game') + 1]) : null;
+
+ console.log('='.repeat(60));
+ console.log(' Touch Handling Fix Script');
+ console.log('='.repeat(60));
+ console.log(`Mode: ${dryRun ? 'DRY RUN (no changes will be saved)' : 'LIVE (changes will be saved)'}`);
+ console.log();
+
+ try {
+ // Get games to fix
+ let query = `
+ SELECT id, title, game_code, status
+ FROM games
+ WHERE status IN ('published', 'testing', 'pending_review')
+ AND game_code IS NOT NULL
+ AND LENGTH(game_code) > 0
+ `;
+ const params = [];
+
+ if (specificGameId) {
+ query += ' AND id = $1';
+ params.push(specificGameId);
+ }
+
+ query += ' ORDER BY id';
+
+ const result = await pool.query(query, params);
+ const games = result.rows;
+
+ console.log(`Found ${games.length} games to process\n`);
+
+ let fixed = 0;
+ let skipped = 0;
+ let errors = 0;
+
+ for (const game of games) {
+ console.log(`[${game.id}] ${game.title} (${game.status})`);
+
+ // Analyze if fix is needed
+ const analysis = await analyzeGame(game.game_code);
+
+ // Debug output
+ if (args.includes('--verbose')) {
+ console.log(' Details:', JSON.stringify(analysis.details, null, 2));
+ }
+
+ if (!analysis.needsFix) {
+ console.log(' ✓ Already has proper touch handling');
+ skipped++;
+ continue;
+ }
+
+ console.log(' Issues found:');
+ analysis.issues.forEach(issue => console.log(` - ${issue}`));
+
+ // Fix the game
+ console.log(' Fixing with Haiku...');
+ const fixedCode = await fixGameCode(game.game_code, game.title);
+
+ if (!fixedCode) {
+ console.log(' ✗ Failed to fix');
+ errors++;
+ continue;
+ }
+
+ // Check if code actually changed
+ if (fixedCode === game.game_code) {
+ console.log(' ⊘ No changes needed');
+ skipped++;
+ continue;
+ }
+
+ const sizeDiff = fixedCode.length - game.game_code.length;
+ console.log(` Code size: ${game.game_code.length} → ${fixedCode.length} (${sizeDiff >= 0 ? '+' : ''}${sizeDiff} bytes)`);
+
+ if (dryRun) {
+ console.log(' [DRY RUN] Would save changes');
+ fixed++;
+ } else {
+ // Save the fix
+ const version = await saveGameVersion(
+ game.id,
+ game.game_code,
+ fixedCode,
+ 'Fixed touch handling: game only restarts on tap after game over'
+ );
+ console.log(` ✓ Fixed and saved as version ${version}`);
+ fixed++;
+ }
+
+ // Small delay to avoid rate limiting
+ await new Promise(r => setTimeout(r, 500));
+ }
+
+ console.log('\n' + '='.repeat(60));
+ console.log(' Summary');
+ console.log('='.repeat(60));
+ console.log(` Fixed: ${fixed}`);
+ console.log(` Skipped: ${skipped}`);
+ console.log(` Errors: ${errors}`);
+ console.log(` Total: ${games.length}`);
+
+ if (dryRun && fixed > 0) {
+ console.log('\nRun without --dry-run to apply fixes.');
+ }
+
+ } catch (error) {
+ console.error('Script failed:', error);
+ process.exit(1);
+ } finally {
+ await pool.end();
+ }
+}
+
+main();
diff --git a/backend/scripts/init-db.sql b/backend/scripts/init-db.sql
new file mode 100644
index 0000000..b46e197
--- /dev/null
+++ b/backend/scripts/init-db.sql
@@ -0,0 +1,127 @@
+-- Users table
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(30) UNIQUE NOT NULL,
+ email VARCHAR(255) UNIQUE,
+ password_hash VARCHAR(255),
+ display_name VARCHAR(50),
+ is_guest BOOLEAN DEFAULT false,
+ device_fingerprint VARCHAR(255),
+ tokens_balance INTEGER DEFAULT 5,
+ total_tokens_earned INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW(),
+ last_login TIMESTAMP,
+ is_banned BOOLEAN DEFAULT false,
+ role VARCHAR(20) DEFAULT 'player'
+);
+
+-- Games table
+CREATE TABLE IF NOT EXISTS games (
+ id SERIAL PRIMARY KEY,
+ creator_id INTEGER REFERENCES users(id),
+ title VARCHAR(100) NOT NULL,
+ description TEXT,
+ game_code TEXT NOT NULL,
+ thumbnail_url VARCHAR(500),
+ status VARCHAR(20) DEFAULT 'draft',
+ play_count INTEGER DEFAULT 0,
+ total_ratings INTEGER DEFAULT 0,
+ rating_sum INTEGER DEFAULT 0,
+ tokens_earned INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW(),
+ published_at TIMESTAMP,
+ last_played TIMESTAMP,
+ is_featured BOOLEAN DEFAULT false,
+ rejection_reason TEXT
+);
+
+-- Play sessions
+CREATE TABLE IF NOT EXISTS plays (
+ id SERIAL PRIMARY KEY,
+ player_id INTEGER REFERENCES users(id),
+ game_id INTEGER REFERENCES games(id),
+ started_at TIMESTAMP DEFAULT NOW(),
+ ended_at TIMESTAMP,
+ score INTEGER,
+ duration_seconds INTEGER,
+ token_spent BOOLEAN DEFAULT false,
+ device_fingerprint VARCHAR(255),
+ ip_address INET
+);
+
+-- High scores
+CREATE TABLE IF NOT EXISTS high_scores (
+ id SERIAL PRIMARY KEY,
+ game_id INTEGER REFERENCES games(id),
+ player_id INTEGER REFERENCES users(id),
+ score INTEGER NOT NULL,
+ achieved_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(game_id, player_id)
+);
+
+-- Developer earnings
+CREATE TABLE IF NOT EXISTS developer_earnings (
+ id SERIAL PRIMARY KEY,
+ developer_id INTEGER REFERENCES users(id),
+ game_id INTEGER REFERENCES games(id),
+ tokens_earned INTEGER NOT NULL,
+ play_id INTEGER REFERENCES plays(id),
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- Guest sessions (for fingerprint tracking)
+CREATE TABLE IF NOT EXISTS guest_sessions (
+ id SERIAL PRIMARY KEY,
+ device_fingerprint VARCHAR(255) NOT NULL,
+ user_agent TEXT,
+ ip_address INET,
+ tokens_granted_today INTEGER DEFAULT 5,
+ last_token_grant DATE DEFAULT CURRENT_DATE,
+ total_plays INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW(),
+ last_seen TIMESTAMP DEFAULT NOW()
+);
+
+-- Game reports (moderation)
+CREATE TABLE IF NOT EXISTS game_reports (
+ id SERIAL PRIMARY KEY,
+ game_id INTEGER REFERENCES games(id),
+ reporter_id INTEGER REFERENCES users(id),
+ reason VARCHAR(50),
+ description TEXT,
+ status VARCHAR(20) DEFAULT 'pending',
+ reviewed_by INTEGER REFERENCES users(id),
+ reviewed_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- Suspicious activity log
+CREATE TABLE IF NOT EXISTS suspicious_activity_log (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id),
+ activity_type VARCHAR(50),
+ details JSONB,
+ ip_address INET,
+ device_fingerprint VARCHAR(255),
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- Ratings table (to track one rating per user per game)
+CREATE TABLE IF NOT EXISTS ratings (
+ id SERIAL PRIMARY KEY,
+ game_id INTEGER REFERENCES games(id),
+ user_id INTEGER REFERENCES users(id),
+ rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
+ created_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(game_id, user_id)
+);
+
+-- Create indexes for performance
+CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
+CREATE INDEX IF NOT EXISTS idx_games_creator ON games(creator_id);
+CREATE INDEX IF NOT EXISTS idx_plays_player ON plays(player_id);
+CREATE INDEX IF NOT EXISTS idx_plays_game ON plays(game_id);
+CREATE INDEX IF NOT EXISTS idx_high_scores_game ON high_scores(game_id);
+CREATE INDEX IF NOT EXISTS idx_guest_fingerprint ON guest_sessions(device_fingerprint);
+CREATE INDEX IF NOT EXISTS idx_games_published ON games(published_at) WHERE status = 'published';
+CREATE INDEX IF NOT EXISTS idx_games_play_count ON games(play_count DESC) WHERE status = 'published';
diff --git a/backend/scripts/migrations/002_full_platform.sql b/backend/scripts/migrations/002_full_platform.sql
new file mode 100644
index 0000000..51c5318
--- /dev/null
+++ b/backend/scripts/migrations/002_full_platform.sql
@@ -0,0 +1,314 @@
+-- GamerComp Full Platform Migration
+-- Run with: psql -U gamearc -d game_arcade -f 002_full_platform.sql
+
+BEGIN;
+
+-- ============================================
+-- UPDATE EXISTING TABLES
+-- ============================================
+
+-- Add missing columns to users table
+ALTER TABLE users ADD COLUMN IF NOT EXISTS xp_total INTEGER DEFAULT 0;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS creator_level INTEGER DEFAULT 1;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS creator_badge VARCHAR(50) DEFAULT 'Newbie';
+ALTER TABLE users ADD COLUMN IF NOT EXISTS daily_login_streak INTEGER DEFAULT 0;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS weekly_creation_streak INTEGER DEFAULT 0;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_date DATE;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS last_creation_date DATE;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_verified BOOLEAN DEFAULT false;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_data JSONB;
+
+-- Add missing columns to games table
+ALTER TABLE games ADD COLUMN IF NOT EXISTS creation_prompt TEXT;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS prompt_answers JSONB;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS game_style VARCHAR(50);
+ALTER TABLE games ADD COLUMN IF NOT EXISTS difficulty_tags VARCHAR(50)[];
+ALTER TABLE games ADD COLUMN IF NOT EXISTS theme_tags VARCHAR(50)[];
+ALTER TABLE games ADD COLUMN IF NOT EXISTS accessibility_features VARCHAR(50)[];
+ALTER TABLE games ADD COLUMN IF NOT EXISTS is_mobile_friendly BOOLEAN DEFAULT true;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS favorite_count INTEGER DEFAULT 0;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS remix_count INTEGER DEFAULT 0;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS remixed_from_id INTEGER REFERENCES games(id);
+ALTER TABLE games ADD COLUMN IF NOT EXISTS allow_remix BOOLEAN DEFAULT true;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS creator_played BOOLEAN DEFAULT false;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS creator_play_duration INTEGER DEFAULT 0;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS ai_review_passed BOOLEAN DEFAULT false;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS ai_review_notes TEXT;
+ALTER TABLE games ADD COLUMN IF NOT EXISTS license VARCHAR(20) DEFAULT 'GPL-3.0';
+ALTER TABLE games ADD COLUMN IF NOT EXISTS thumbnail_data TEXT;
+
+-- Add missing columns to plays table
+ALTER TABLE plays ADD COLUMN IF NOT EXISTS levels_completed INTEGER DEFAULT 0;
+ALTER TABLE plays ADD COLUMN IF NOT EXISTS difficulty_played VARCHAR(20);
+ALTER TABLE plays ADD COLUMN IF NOT EXISTS is_creator_play BOOLEAN DEFAULT false;
+
+-- Update high_scores table
+ALTER TABLE high_scores ADD COLUMN IF NOT EXISTS difficulty VARCHAR(20);
+
+-- ============================================
+-- CREATE NEW TABLES
+-- ============================================
+
+-- FAVORITES
+CREATE TABLE IF NOT EXISTS favorites (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(user_id, game_id)
+);
+
+-- FOLLOWS (follow creators)
+CREATE TABLE IF NOT EXISTS follows (
+ id SERIAL PRIMARY KEY,
+ follower_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ following_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(follower_id, following_id),
+ CHECK (follower_id != following_id)
+);
+
+-- NOTIFICATIONS
+CREATE TABLE IF NOT EXISTS notifications (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ type VARCHAR(50) NOT NULL,
+ title VARCHAR(100),
+ message TEXT,
+ link VARCHAR(255),
+ is_read BOOLEAN DEFAULT false,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- ACHIEVEMENTS
+CREATE TABLE IF NOT EXISTS achievements (
+ id SERIAL PRIMARY KEY,
+ code VARCHAR(50) UNIQUE NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ icon VARCHAR(50),
+ xp_reward INTEGER DEFAULT 0,
+ category VARCHAR(50)
+);
+
+-- USER ACHIEVEMENTS
+CREATE TABLE IF NOT EXISTS user_achievements (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
+ earned_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(user_id, achievement_id)
+);
+
+-- COLLECTIONS (user playlists)
+CREATE TABLE IF NOT EXISTS collections (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ is_public BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- COLLECTION GAMES
+CREATE TABLE IF NOT EXISTS collection_games (
+ id SERIAL PRIMARY KEY,
+ collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
+ game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
+ added_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(collection_id, game_id)
+);
+
+-- XP TRANSACTIONS (track all XP gains)
+CREATE TABLE IF NOT EXISTS xp_transactions (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ amount INTEGER NOT NULL,
+ reason VARCHAR(100),
+ reference_id INTEGER,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- WEEKLY THEMES
+CREATE TABLE IF NOT EXISTS weekly_themes (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ bonus_xp_multiplier DECIMAL(3,2) DEFAULT 1.5,
+ is_active BOOLEAN DEFAULT false
+);
+
+-- CLASSROOMS (teacher mode)
+CREATE TABLE IF NOT EXISTS classrooms (
+ id SERIAL PRIMARY KEY,
+ teacher_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ name VARCHAR(100) NOT NULL,
+ join_code VARCHAR(10) UNIQUE NOT NULL,
+ is_public_publishing_allowed BOOLEAN DEFAULT false,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- CLASSROOM STUDENTS
+CREATE TABLE IF NOT EXISTS classroom_students (
+ id SERIAL PRIMARY KEY,
+ classroom_id INTEGER REFERENCES classrooms(id) ON DELETE CASCADE,
+ student_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ joined_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(classroom_id, student_id)
+);
+
+-- CLASSROOM ASSIGNMENTS
+CREATE TABLE IF NOT EXISTS classroom_assignments (
+ id SERIAL PRIMARY KEY,
+ classroom_id INTEGER REFERENCES classrooms(id) ON DELETE CASCADE,
+ title VARCHAR(100) NOT NULL,
+ description TEXT,
+ required_game_style VARCHAR(50),
+ due_date TIMESTAMP,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- ============================================
+-- CREATE INDEXES
+-- ============================================
+
+CREATE INDEX IF NOT EXISTS idx_games_style ON games(game_style);
+CREATE INDEX IF NOT EXISTS idx_games_difficulty ON games USING GIN(difficulty_tags);
+CREATE INDEX IF NOT EXISTS idx_games_themes ON games USING GIN(theme_tags);
+CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id);
+CREATE INDEX IF NOT EXISTS idx_favorites_game ON favorites(game_id);
+CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id);
+CREATE INDEX IF NOT EXISTS idx_follows_following ON follows(following_id);
+CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
+CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id) WHERE is_read = false;
+CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
+CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
+CREATE INDEX IF NOT EXISTS idx_xp_transactions_user ON xp_transactions(user_id);
+CREATE INDEX IF NOT EXISTS idx_classrooms_teacher ON classrooms(teacher_id);
+CREATE INDEX IF NOT EXISTS idx_classroom_students_classroom ON classroom_students(classroom_id);
+CREATE INDEX IF NOT EXISTS idx_classroom_students_student ON classroom_students(student_id);
+
+-- ============================================
+-- SEED ACHIEVEMENTS
+-- ============================================
+
+INSERT INTO achievements (code, name, description, icon, xp_reward, category) VALUES
+-- Creator achievements
+('first_game', 'First Game', 'Published your first game', '🎮', 100, 'creator'),
+('ten_games', 'Prolific Creator', 'Published 10 games', '🏭', 500, 'creator'),
+('hundred_plays', 'Getting Popular', 'One of your games reached 100 plays', '📈', 200, 'creator'),
+('thousand_plays', 'Viral Hit', 'One of your games reached 1000 plays', '🔥', 1000, 'creator'),
+('five_stars', 'Five Star Developer', 'Got a 5-star average rating on a game', '⭐', 300, 'creator'),
+('remixed', 'Inspirational', 'Someone remixed your game', '🔄', 150, 'creator'),
+('ten_remixes', 'Remix Master', '10 games were remixed from yours', '👑', 750, 'creator'),
+
+-- Player achievements
+('first_play', 'Player One', 'Played your first game', '🕹️', 25, 'player'),
+('fifty_plays', 'Dedicated Gamer', 'Played 50 games', '🎯', 200, 'player'),
+('high_scorer', 'High Scorer', 'Got #1 on any leaderboard', '🏆', 300, 'player'),
+('all_styles', 'Genre Explorer', 'Played every game style', '🗺️', 400, 'player'),
+
+-- Social achievements
+('first_follow', 'Fan Club', 'Followed your first creator', '👥', 25, 'social'),
+('ten_favorites', 'Collector', 'Favorited 10 games', '❤️', 100, 'social'),
+('first_collection', 'Curator', 'Created your first collection', '📚', 50, 'social'),
+
+-- Prompt engineering achievements
+('no_edits', 'Prompt Pro', 'Created a game that needed zero edits', '✨', 250, 'creator'),
+('detailed_prompt', 'Detail Detective', 'Used 5+ specific details in your prompt', '🔍', 100, 'creator'),
+('accessibility', 'Inclusive Designer', 'Added accessibility features to a game', '♿', 200, 'creator'),
+
+-- Streak achievements
+('week_streak', 'Week Warrior', '7-day login streak', '📅', 150, 'special'),
+('month_streak', 'Monthly Master', '30-day login streak', '🗓️', 500, 'special'),
+('creation_streak', 'Creative Machine', 'Created games 4 weeks in a row', '🏃', 400, 'special'),
+
+-- Special
+('beta_tester', 'Beta Tester', 'Joined during beta', '🧪', 500, 'special'),
+('bug_reporter', 'Bug Hunter', 'Reported 5 bugs that were fixed', '🐛', 200, 'special')
+ON CONFLICT (code) DO NOTHING;
+
+-- ============================================
+-- CREATE HELPER FUNCTIONS
+-- ============================================
+
+-- Function to calculate creator level from XP
+CREATE OR REPLACE FUNCTION get_creator_level(xp INTEGER)
+RETURNS INTEGER AS $$
+BEGIN
+ RETURN CASE
+ WHEN xp >= 10000 THEN 10
+ WHEN xp >= 6000 THEN 9
+ WHEN xp >= 4000 THEN 8
+ WHEN xp >= 2500 THEN 7
+ WHEN xp >= 1500 THEN 6
+ WHEN xp >= 1000 THEN 5
+ WHEN xp >= 600 THEN 4
+ WHEN xp >= 300 THEN 3
+ WHEN xp >= 100 THEN 2
+ ELSE 1
+ END;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+-- Function to get creator badge from level
+CREATE OR REPLACE FUNCTION get_creator_badge(level INTEGER)
+RETURNS VARCHAR AS $$
+BEGIN
+ RETURN CASE level
+ WHEN 10 THEN 'Grandmaster'
+ WHEN 9 THEN 'Legend'
+ WHEN 8 THEN 'Master'
+ WHEN 7 THEN 'Expert'
+ WHEN 6 THEN 'Pro'
+ WHEN 5 THEN 'Game Dev'
+ WHEN 4 THEN 'Creator'
+ WHEN 3 THEN 'Apprentice'
+ WHEN 2 THEN 'Beginner'
+ ELSE 'Newbie'
+ END;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+-- Function to award XP and update level
+CREATE OR REPLACE FUNCTION award_xp(p_user_id INTEGER, p_amount INTEGER, p_reason VARCHAR, p_reference_id INTEGER DEFAULT NULL)
+RETURNS TABLE(new_xp INTEGER, new_level INTEGER, new_badge VARCHAR, leveled_up BOOLEAN) AS $$
+DECLARE
+ old_level INTEGER;
+ current_xp INTEGER;
+ calculated_level INTEGER;
+ calculated_badge VARCHAR;
+BEGIN
+ -- Get current XP and level
+ SELECT xp_total, creator_level INTO current_xp, old_level FROM users WHERE id = p_user_id;
+
+ -- Calculate new XP
+ current_xp := COALESCE(current_xp, 0) + p_amount;
+
+ -- Calculate new level and badge
+ calculated_level := get_creator_level(current_xp);
+ calculated_badge := get_creator_badge(calculated_level);
+
+ -- Update user
+ UPDATE users
+ SET xp_total = current_xp,
+ creator_level = calculated_level,
+ creator_badge = calculated_badge
+ WHERE id = p_user_id;
+
+ -- Log XP transaction
+ INSERT INTO xp_transactions (user_id, amount, reason, reference_id)
+ VALUES (p_user_id, p_amount, p_reason, p_reference_id);
+
+ -- Return results
+ RETURN QUERY SELECT current_xp, calculated_level, calculated_badge, (calculated_level > COALESCE(old_level, 1));
+END;
+$$ LANGUAGE plpgsql;
+
+COMMIT;
+
+-- Show summary
+SELECT 'Migration complete!' as status;
+SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;
diff --git a/backend/scripts/migrations/003_onboarding.sql b/backend/scripts/migrations/003_onboarding.sql
new file mode 100644
index 0000000..4d19c40
--- /dev/null
+++ b/backend/scripts/migrations/003_onboarding.sql
@@ -0,0 +1,21 @@
+-- Migration 003: Onboarding Flow
+-- Adds onboarding tracking to users and new onboarding achievements
+
+-- Add onboarding columns to users table
+ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarding_complete BOOLEAN DEFAULT false;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarding_step INTEGER DEFAULT 0;
+
+-- Create index for finding users who haven't completed onboarding
+CREATE INDEX IF NOT EXISTS idx_users_onboarding ON users(onboarding_complete) WHERE onboarding_complete = false;
+
+-- Insert onboarding achievements
+INSERT INTO achievements (code, name, description, icon, xp_reward, category) VALUES
+ ('welcome_tour', 'Welcome Tour', 'Completed the welcome tutorial', '🎉', 50, 'onboarding'),
+ ('first_arcade_visit', 'Explorer', 'Visited the arcade for the first time', '🗺️', 25, 'onboarding'),
+ ('profile_customized', 'Personal Touch', 'Customized your profile', '✨', 50, 'onboarding'),
+ ('onboarding_complete', 'Ready to Play!', 'Completed all getting started steps', '🚀', 100, 'onboarding')
+ON CONFLICT (code) DO NOTHING;
+
+-- Grant permissions
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gamearc;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO gamearc;
diff --git a/backend/scripts/migrations/004_audit_logs.sql b/backend/scripts/migrations/004_audit_logs.sql
new file mode 100644
index 0000000..e97814e
--- /dev/null
+++ b/backend/scripts/migrations/004_audit_logs.sql
@@ -0,0 +1,24 @@
+-- Migration 004: Admin Audit Logs
+-- Tracks all admin actions for accountability and auditing
+
+-- Create audit_logs table
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id SERIAL PRIMARY KEY,
+ admin_id INTEGER NOT NULL REFERENCES users(id),
+ action VARCHAR(100) NOT NULL,
+ target_type VARCHAR(50) NOT NULL, -- 'game', 'user', 'report'
+ target_id INTEGER NOT NULL,
+ details JSONB,
+ ip_address VARCHAR(45), -- Supports IPv6
+ created_at TIMESTAMP DEFAULT NOW()
+);
+
+-- Create indexes for common queries
+CREATE INDEX IF NOT EXISTS idx_audit_logs_admin_id ON audit_logs(admin_id);
+CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_type, target_id);
+CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
+
+-- Grant permissions
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gamearc;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO gamearc;
diff --git a/backend/scripts/publish-sample-games.js b/backend/scripts/publish-sample-games.js
new file mode 100644
index 0000000..9df53a4
--- /dev/null
+++ b/backend/scripts/publish-sample-games.js
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+/**
+ * Publish sample games under the "Arcade Master" account
+ * Run with: node scripts/publish-sample-games.js
+ */
+
+require('dotenv').config();
+const { Pool } = require('pg');
+const bcrypt = require('bcrypt');
+const fs = require('fs');
+const path = require('path');
+
+const pool = new Pool({
+ host: process.env.DB_HOST,
+ port: process.env.DB_PORT,
+ database: process.env.DB_NAME,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+});
+
+const ARCADE_MASTER = {
+ username: 'ArcadeMaster',
+ email: 'arcademaster@gamercomp.com',
+ password: 'ArcadeMaster2024!',
+ displayName: 'Arcade Master'
+};
+
+const GAMES_DIR = path.join(__dirname, '../../games/samples');
+
+// Game metadata (title, description, prompt for each game)
+const GAME_METADATA = {
+ 'brick-breaker.html': {
+ title: 'Brick Breaker',
+ description: 'Classic paddle and ball game! Move your paddle to bounce the ball and break all the bricks. Easy to learn, fun to play!',
+ prompt: 'Create a classic brick breaker game with a wide paddle, slow ball, and 5 lives. Make it easy for kids to play.'
+ },
+ 'pong.html': {
+ title: 'Pong',
+ description: 'The classic 2-player game! Play against the computer and try to score 7 points first. The computer makes mistakes so you can win!',
+ prompt: 'Create a single-player Pong game vs AI. The AI should make mistakes sometimes so kids can win.'
+ },
+ 'dino-runner.html': {
+ title: 'Dino Runner',
+ description: 'Run as far as you can! Tap or press space to jump over cacti. The game starts slow so you can learn the timing.',
+ prompt: 'Create a Chrome Dino style endless runner. Start very slow with obstacles far apart.'
+ },
+ 'asteroids.html': {
+ title: 'Asteroids',
+ description: 'Classic space shooter! Rotate your ship and blast the asteroids. You have shields that protect you when you get hit.',
+ prompt: 'Create an Asteroids game with a large ship, slow asteroids, and invincibility after being hit.'
+ },
+ 'whack-a-mole.html': {
+ title: 'Whack-a-Mole',
+ description: 'Tap the moles when they pop up! They stay visible for a long time so you can catch them. 60 seconds to get the high score!',
+ prompt: 'Create a whack-a-mole game with a 3x3 grid. Moles should stay up for 2+ seconds.'
+ },
+ 'block-drop.html': {
+ title: 'Block Drop',
+ description: 'Stack falling blocks to complete lines! Pieces fall slowly so you have time to think. A ghost piece shows where blocks will land.',
+ prompt: 'Create a Tetris-style game with very slow falling pieces and helpful ghost piece preview.'
+ },
+ 'twenty-forty-eight.html': {
+ title: '2048',
+ description: 'Swipe to combine numbers! 2+2=4, 4+4=8, and so on. No time limit - take as long as you need to plan your moves.',
+ prompt: 'Create the 2048 puzzle game with no time pressure and clear directional arrows.'
+ },
+ 'memory-match.html': {
+ title: 'Memory Match',
+ description: 'Find all the matching pairs! Flip cards to reveal pictures and remember where they are. Only 6 pairs to start.',
+ prompt: 'Create a memory matching game with a small 3x4 grid (6 pairs) and cards that stay flipped for 1.5 seconds.'
+ },
+ 'connect-four.html': {
+ title: 'Connect Four',
+ description: 'Drop your pieces to connect 4 in a row! Play against a friendly computer that lets you learn the game.',
+ prompt: 'Create Connect Four vs AI. The AI should play randomly most of the time so kids can win.'
+ },
+ 'lights-out.html': {
+ title: 'Lights Out',
+ description: 'Turn off all the lights to win! Clicking a light toggles it and its neighbors. Includes a helpful tutorial!',
+ prompt: 'Create Lights Out puzzle with an easy 3x3 grid, interactive tutorial, and hover highlights.'
+ },
+ 'geometry-runner.html': {
+ title: 'Geometry Runner',
+ description: 'Jump over obstacles in this rhythm-style runner! Starts very slow with obstacles far apart so you can learn the timing.',
+ prompt: 'Create a Geometry Dash style runner that starts very slow with generous jump timing.'
+ },
+ 'lunar-lander.html': {
+ title: 'Lunar Lander',
+ description: 'Land your spaceship safely on the moon! You have lots of fuel and the landing pad is big. Low gravity makes it easier.',
+ prompt: 'Create a Lunar Lander with generous fuel, low gravity, and a large landing pad.'
+ },
+ 'fruit-slicer.html': {
+ title: 'Fruit Slicer',
+ description: 'Swipe through the fruit to slice it! Fruit moves slowly and stays on screen longer. Watch out for bombs!',
+ prompt: 'Create a Fruit Ninja style game with big slow fruit and rare bombs at the start.'
+ },
+ 'maze-chomper.html': {
+ title: 'Maze Chomper',
+ description: 'Eat all the dots while avoiding ghosts! You move much faster than the ghosts, and power pellets last a long time.',
+ prompt: 'Create a Pac-Man style game where the player is 3x faster than ghosts and power-ups last 10 seconds.'
+ },
+ 'platformer.html': {
+ title: 'Platformer',
+ description: 'Jump and run to reach the flag! 5 lives, slow enemies, and big platforms make this adventure fun for everyone.',
+ prompt: 'Create a simple platformer with 5 lives, slow enemies, and generous jump mechanics.'
+ },
+ 'bridge-builder.html': {
+ title: 'Bridge Builder',
+ description: 'Build a bridge to get the car across! Strong beams and a light car make building easy. Includes a helpful tutorial.',
+ prompt: 'Create a bridge building physics game with strong materials, light car, and visual tutorial.'
+ }
+};
+
+async function getOrCreateArcadeMaster() {
+ // Check if user exists
+ let result = await pool.query(
+ 'SELECT * FROM users WHERE username = $1',
+ [ARCADE_MASTER.username]
+ );
+
+ if (result.rows.length > 0) {
+ console.log(`Found existing Arcade Master account (ID: ${result.rows[0].id})`);
+ return result.rows[0];
+ }
+
+ // Create new user
+ const passwordHash = await bcrypt.hash(ARCADE_MASTER.password, 10);
+ result = await pool.query(
+ `INSERT INTO users (username, email, password_hash, display_name, tokens_balance, role)
+ VALUES ($1, $2, $3, $4, 9999, 'developer')
+ RETURNING *`,
+ [ARCADE_MASTER.username, ARCADE_MASTER.email, passwordHash, ARCADE_MASTER.displayName]
+ );
+
+ console.log(`Created Arcade Master account (ID: ${result.rows[0].id})`);
+ return result.rows[0];
+}
+
+async function publishGame(creatorId, filename, gameCode) {
+ const metadata = GAME_METADATA[filename];
+ if (!metadata) {
+ console.log(` Skipping ${filename} - no metadata defined`);
+ return null;
+ }
+
+ // Check if game already exists
+ const existing = await pool.query(
+ 'SELECT id FROM games WHERE title = $1 AND creator_id = $2',
+ [metadata.title, creatorId]
+ );
+
+ if (existing.rows.length > 0) {
+ // Update existing game
+ await pool.query(
+ `UPDATE games SET game_code = $1, description = $2, prompt = $3 WHERE id = $4`,
+ [gameCode, metadata.description, metadata.prompt, existing.rows[0].id]
+ );
+ console.log(` Updated: ${metadata.title} (ID: ${existing.rows[0].id})`);
+ return existing.rows[0].id;
+ }
+
+ // Insert new game as published
+ const result = await pool.query(
+ `INSERT INTO games (creator_id, title, description, game_code, prompt, status, published_at)
+ VALUES ($1, $2, $3, $4, $5, 'published', NOW())
+ RETURNING id`,
+ [creatorId, metadata.title, metadata.description, gameCode, metadata.prompt]
+ );
+
+ console.log(` Published: ${metadata.title} (ID: ${result.rows[0].id})`);
+ return result.rows[0].id;
+}
+
+async function main() {
+ try {
+ console.log('=== Publishing Sample Games ===\n');
+
+ // Get or create Arcade Master account
+ const arcadeMaster = await getOrCreateArcadeMaster();
+ console.log('');
+
+ // Read all game files
+ const files = fs.readdirSync(GAMES_DIR).filter(f => f.endsWith('.html'));
+ console.log(`Found ${files.length} game files in ${GAMES_DIR}\n`);
+
+ let published = 0;
+ for (const filename of files) {
+ const filepath = path.join(GAMES_DIR, filename);
+ const gameCode = fs.readFileSync(filepath, 'utf8');
+
+ const gameId = await publishGame(arcadeMaster.id, filename, gameCode);
+ if (gameId) published++;
+ }
+
+ console.log(`\n=== Complete! ===`);
+ console.log(`Published ${published} games under ${ARCADE_MASTER.username}`);
+ console.log(`\nArcade Master credentials:`);
+ console.log(` Username: ${ARCADE_MASTER.username}`);
+ console.log(` Password: ${ARCADE_MASTER.password}`);
+
+ } catch (error) {
+ console.error('Error:', error);
+ process.exit(1);
+ } finally {
+ await pool.end();
+ }
+}
+
+main();
diff --git a/backend/scripts/regenerate-all-games.js b/backend/scripts/regenerate-all-games.js
new file mode 100644
index 0000000..a7d67dc
--- /dev/null
+++ b/backend/scripts/regenerate-all-games.js
@@ -0,0 +1,278 @@
+require('dotenv').config();
+const { pool } = require('../src/models/db');
+const { generateGame } = require('../src/utils/claude');
+
+const allGames = [
+ {
+ id: 14,
+ title: "Snake",
+ prompt: `Classic Snake game - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Swipe anywhere to change direction
+- Also add semi-transparent d-pad buttons at bottom (up/down/left/right)
+
+GAMEPLAY:
+- Snake starts moving right automatically
+- Eating apple grows snake, adds 10 points
+- Game over on wall/self collision
+- Speed increases every 5 apples
+
+SOUNDS: Eat sound (high beep), game over (low tone), background beat`
+ },
+ {
+ id: 15,
+ title: "Tetris",
+ prompt: `Tetris falling blocks - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Tap left half to move left, right half to move right
+- Swipe down for drop
+- Tap piece or swipe up to rotate
+- Add visible buttons at bottom: [←] [→] [↻] [↓]
+
+GAMEPLAY:
+- 7 tetromino shapes, auto-falling
+- Clear lines for points (100/300/500/800)
+- Show next piece
+- Speed increases each level
+
+SOUNDS: Move (click), rotate (whoosh), line clear (ding), game over`
+ },
+ {
+ id: 16,
+ title: "Pong",
+ prompt: `Pong paddle game - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Touch and drag on YOUR side to move your paddle
+- Single player vs AI opponent
+
+GAMEPLAY:
+- Ball bounces between paddles
+- First to 5 points wins
+- Ball speeds up each hit
+- AI difficulty increases with score
+
+SOUNDS: Paddle hit (pop), wall bounce (tick), score (ding), win/lose`
+ },
+ {
+ id: 17,
+ title: "Breakout",
+ prompt: `Breakout brick breaker - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Touch anywhere and drag to move paddle
+- Paddle follows finger X position
+- Tap to launch ball from paddle
+
+GAMEPLAY:
+- Colorful brick rows at top
+- Ball bounces off paddle to break bricks
+- Each brick = 10 points
+- 3 lives, lose one when ball falls
+
+SOUNDS: Brick break (pop), paddle hit (thud), ball launch (whoosh), life lost`
+ },
+ {
+ id: 18,
+ title: "Space Invaders",
+ prompt: `Space Invaders shooter - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Large [←] and [→] buttons at bottom corners
+- Large [FIRE] button at bottom center
+- Touch zones: left third moves left, right third moves right, center fires
+
+GAMEPLAY:
+- Alien grid moves and descends
+- Shoot aliens for points (10-50 per row)
+- Aliens shoot back randomly
+- 3 lives, clear all aliens to win wave
+
+SOUNDS: Shoot (pew), alien hit (explosion), player hit (boom), march beat`
+ },
+ {
+ id: 19,
+ title: "Pac-Man",
+ prompt: `Pac-Man maze game - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Swipe in direction to change Pac-Man direction
+- Also add d-pad overlay at bottom of screen
+
+GAMEPLAY:
+- Simple 15x15 maze
+- Eat dots (10pts) and power pellets (50pts)
+- 4 ghosts chase, turn blue when powered
+- Eat blue ghosts for bonus (200pts)
+- 3 lives
+
+SOUNDS: Wakka-wakka eating, power pellet sound, ghost eaten, death`
+ },
+ {
+ id: 20,
+ title: "Flappy Bird",
+ prompt: `Flappy Bird - mobile-first with sound.
+
+MOBILE CONTROLS:
+- TAP ANYWHERE on screen to flap
+- That's the only control - dead simple
+- Entire game canvas is the tap zone
+
+GAMEPLAY:
+- Bird falls with gravity
+- Tap to flap up
+- Pass through pipe gaps
+- Each pipe = 1 point
+- Game over on any collision
+
+SOUNDS: Flap (whoosh), score (ding), hit (thud), background wing beat`
+ },
+ {
+ id: 21,
+ title: "2048",
+ prompt: `2048 number puzzle - mobile-first with sound.
+
+MOBILE CONTROLS:
+- SWIPE in any direction to slide tiles
+- Must detect swipe vs tap
+- Minimum swipe distance: 30px
+
+GAMEPLAY:
+- 4x4 grid
+- Swipe merges matching numbers
+- New tile after each move
+- Win at 2048, game over when stuck
+
+SOUNDS: Slide (swoosh), merge (pop - higher pitch for bigger numbers), game over`
+ },
+ {
+ id: 22,
+ title: "Minesweeper",
+ prompt: `Minesweeper - mobile-first with sound.
+
+MOBILE CONTROLS:
+- TAP to reveal cell
+- LONG PRESS (0.5s) to flag/unflag
+- Cells must be large enough to tap (40px minimum)
+
+GAMEPLAY:
+- 9x9 grid, 10 mines
+- Numbers show adjacent mines
+- First tap always safe
+- Flag all mines to win
+
+SOUNDS: Reveal (click), flag (flag sound), mine (explosion), win (fanfare)`
+ },
+ {
+ id: 23,
+ title: "Asteroids",
+ prompt: `Asteroids space shooter - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Left side: two buttons for rotate left/right
+- Right side: THRUST button and FIRE button
+- Virtual joystick alternative: drag to rotate, tap to thrust
+
+GAMEPLAY:
+- Ship in center with momentum physics
+- Shoot asteroids to break them
+- Large→Medium→Small
+- Screen wrapping
+- 3 lives
+
+SOUNDS: Thrust (rumble), shoot (pew), asteroid break (boom), ship explode`
+ },
+ {
+ id: 24,
+ title: "Frogger",
+ prompt: `Frogger road crossing - mobile-first with sound.
+
+MOBILE CONTROLS:
+- D-pad at bottom: up/down/left/right buttons
+- Or SWIPE in direction to hop
+- Each input = one grid hop
+
+GAMEPLAY:
+- Cross road (avoid cars) then river (ride logs)
+- Grid-based movement
+- 5 home spots to fill
+- 30 second timer per frog
+- 3 lives
+
+SOUNDS: Hop (boing), car hit (splat), water splash, home safe (ding)`
+ },
+ {
+ id: 25,
+ title: "Simon Says",
+ prompt: `Simon Says memory game - mobile-first with sound.
+
+MOBILE CONTROLS:
+- Four LARGE colored buttons (2x2 grid)
+- Each button at least 100px
+- Tap to select color
+
+GAMEPLAY:
+- Watch pattern light up
+- Repeat by tapping buttons
+- Pattern grows each round
+- Wrong tap = game over
+
+SOUNDS: Each color has unique tone (do, re, mi, fa), error (buzz), round complete (fanfare)`
+ }
+];
+
+async function regenerateGame(gameData) {
+ console.log(`\nRegenerating: ${gameData.title} (ID: ${gameData.id})...`);
+
+ try {
+ const result = await generateGame(gameData.prompt);
+
+ if (!result.success) {
+ console.error(` FAILED: ${result.error}`);
+ return false;
+ }
+
+ await pool.query(
+ `UPDATE games SET game_code = $1, prompt = $2 WHERE id = $3`,
+ [result.code, gameData.prompt, gameData.id]
+ );
+
+ console.log(` SUCCESS: ${gameData.title}`);
+ return true;
+ } catch (error) {
+ console.error(` ERROR: ${error.message}`);
+ return false;
+ }
+}
+
+async function main() {
+ console.log('='.repeat(50));
+ console.log('REGENERATING ALL GAMES');
+ console.log('Mobile-first + Sound Effects + Music');
+ console.log('='.repeat(50));
+
+ let success = 0;
+ let failed = 0;
+
+ for (const game of allGames) {
+ const ok = await regenerateGame(game);
+ if (ok) success++; else failed++;
+
+ // Delay between API calls
+ await new Promise(r => setTimeout(r, 2000));
+ }
+
+ console.log('\n' + '='.repeat(50));
+ console.log(`COMPLETE: ${success} success, ${failed} failed`);
+ console.log('='.repeat(50));
+
+ await pool.end();
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+main().catch(err => {
+ console.error('Fatal:', err);
+ process.exit(1);
+});
diff --git a/backend/scripts/rollback-touch-fix.js b/backend/scripts/rollback-touch-fix.js
new file mode 100755
index 0000000..3cd0a6c
--- /dev/null
+++ b/backend/scripts/rollback-touch-fix.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+/**
+ * Rollback the touch handling fixes - restore original game code
+ */
+
+require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
+
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ host: process.env.DB_HOST || 'localhost',
+ port: parseInt(process.env.DB_PORT || '5432'),
+ database: process.env.DB_NAME || 'game_arcade',
+ user: process.env.DB_USER || 'gamearc',
+ password: process.env.DB_PASSWORD
+});
+
+async function main() {
+ console.log('Rolling back touch handling fixes...\n');
+
+ // Find all games that were "fixed" and have a version to restore from
+ const result = await pool.query(`
+ SELECT gv.game_id, gv.game_code, g.title
+ FROM game_versions gv
+ JOIN games g ON g.id = gv.game_id
+ WHERE gv.change_type = 'fix'
+ AND gv.change_description LIKE '%touch handling%'
+ ORDER BY gv.game_id
+ `);
+
+ console.log(`Found ${result.rows.length} games to rollback\n`);
+
+ for (const row of result.rows) {
+ console.log(`[${row.game_id}] ${row.title}`);
+
+ // Restore the original code
+ await pool.query(
+ 'UPDATE games SET game_code = $1, code_updated_at = NOW() WHERE id = $2',
+ [row.game_code, row.game_id]
+ );
+
+ // Delete the fix version from history
+ await pool.query(
+ `DELETE FROM game_versions
+ WHERE game_id = $1
+ AND change_type = 'fix'
+ AND change_description LIKE '%touch handling%'`,
+ [row.game_id]
+ );
+
+ console.log(' ✓ Restored original code');
+ }
+
+ console.log('\nRollback complete!');
+ await pool.end();
+}
+
+main().catch(err => {
+ console.error('Error:', err);
+ process.exit(1);
+});
diff --git a/backend/scripts/sample-games.js b/backend/scripts/sample-games.js
new file mode 100644
index 0000000..4f9c472
--- /dev/null
+++ b/backend/scripts/sample-games.js
@@ -0,0 +1,142 @@
+require('dotenv').config();
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ host: process.env.DB_HOST,
+ port: process.env.DB_PORT,
+ database: process.env.DB_NAME,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+});
+
+const games = [
+ {
+ title: "Star Catcher",
+ description: "Catch falling stars while avoiding meteors! Move left and right to collect as many stars as you can.",
+ code: `
+Star Catcher
+
+Score: 0
🧺
Game Over! Final Score: 0
Play Again ← → Arrow keys or touch to move
+`
+ },
+ {
+ title: "Balloon Pop Frenzy",
+ description: "Pop as many balloons as you can before they float away! Click or tap the balloons to pop them and score points.",
+ code: `
+Balloon Pop
+
+Score: 0
Time: 30
Time's Up! Score: 0
Play Again
+`
+ },
+ {
+ title: "Snake Classic",
+ description: "The classic snake game! Eat the apples to grow longer but don't hit the walls or yourself. Use arrow keys or swipe to control.",
+ code: `
+Snake
+
+Score: 0
Arrow keys or swipe to move
Game Over! Score: 0
Play Again
+`
+ },
+ {
+ title: "Memory Match",
+ description: "Test your memory! Flip cards to find matching pairs. Complete the game with as few moves as possible.",
+ code: `
+Memory Match
+
+Memory Match Moves: 0 | Pairs: 0/8
You Win! Completed in 0 moves
Play Again
+`
+ },
+ {
+ title: "Whack-a-Mole",
+ description: "Quick! Whack the moles before they hide! Test your reflexes in this classic arcade game.",
+ code: `
+Whack-a-Mole
+
+Whack-a-Mole! Score: 0 Time: 30
Time's Up! Final Score: 0
Play Again
+`
+ },
+ {
+ title: "Color Match",
+ description: "Match the color to the word! But be careful - the colors are tricky. How fast can you react?",
+ code: `
+Color Match
+
+Score: 0 Time: 30
RED
Does the COLOR match the WORD?
YES NO
Game Over! Final Score: 0
Play Again
+`
+ },
+ {
+ title: "Fruit Ninja",
+ description: "Slice the fruits as they fly across the screen! Avoid the bombs or it's game over.",
+ code: `
+Fruit Ninja
+
+Score: 0
❤️❤️❤️
Game Over! Final Score: 0
Play Again
+`
+ },
+ {
+ title: "Math Blaster",
+ description: "Solve math problems as fast as you can! Each correct answer earns points, but wrong answers cost time.",
+ code: `
+Math Blaster
+
+Score: 0 Time: 60
5 + 3 = ?
Time's Up! Final Score: 0
Problems Solved: 0
Play Again
+`
+ },
+ {
+ title: "Brick Breaker",
+ description: "Classic brick breaking action! Bounce the ball to destroy all bricks. Don't let the ball fall!",
+ code: `
+Brick Breaker
+
+Score: 0 | Lives: 3
← → or touch to move
Game Over Score: 0
Play Again
+`
+ },
+ {
+ title: "Space Invaders",
+ description: "Defend Earth from alien invaders! Shoot them down before they reach the bottom. Classic arcade action!",
+ code: `
+Space Invaders
+
+SCORE: 0 | LIVES: 3
← → MOVE | SPACE FIRE | TAP TO SHOOT
GAME OVER SCORE: 0
PLAY AGAIN
+`
+ }
+];
+
+async function insertGames() {
+ const client = await pool.connect();
+ try {
+ // Get admin user ID
+ const adminResult = await client.query(
+ "SELECT id FROM users WHERE role = 'admin' LIMIT 1"
+ );
+
+ if (adminResult.rows.length === 0) {
+ console.error('No admin user found!');
+ return;
+ }
+
+ const adminId = adminResult.rows[0].id;
+ console.log('Using admin ID:', adminId);
+
+ for (const game of games) {
+ const result = await client.query(
+ `INSERT INTO games (creator_id, title, description, game_code, status, published_at, play_count)
+ VALUES ($1, $2, $3, $4, 'published', NOW(), $5)
+ ON CONFLICT DO NOTHING
+ RETURNING id, title`,
+ [adminId, game.title, game.description, game.code, Math.floor(Math.random() * 100) + 10]
+ );
+
+ if (result.rows[0]) {
+ console.log('Created game:', result.rows[0].title);
+ }
+ }
+
+ console.log('All games created successfully!');
+ } finally {
+ client.release();
+ await pool.end();
+ }
+}
+
+insertGames().catch(console.error);
diff --git a/backend/src/app.js b/backend/src/app.js
new file mode 100644
index 0000000..1be9eba
--- /dev/null
+++ b/backend/src/app.js
@@ -0,0 +1,116 @@
+require('dotenv').config();
+const express = require('express');
+const cors = require('cors');
+const helmet = require('helmet');
+const rateLimit = require('express-rate-limit');
+
+const authRoutes = require('./routes/auth');
+const gamesRoutes = require('./routes/games');
+const playRoutes = require('./routes/play');
+const arcadeRoutes = require('./routes/arcade');
+const developerRoutes = require('./routes/developer');
+const adminRoutes = require('./routes/admin');
+const usersRoutes = require('./routes/users');
+const collectionsRoutes = require('./routes/collections');
+const creationRoutes = require('./routes/creation');
+const shareRoutes = require('./routes/share');
+const themesRoutes = require('./routes/themes');
+const classroomsRoutes = require('./routes/classrooms');
+const leaderboardRoutes = require('./routes/leaderboard');
+const contactRoutes = require('./routes/contact');
+const bugsRoutes = require('./routes/bugs');
+const localaiRoutes = require('./routes/localai');
+const pushRoutes = require('./routes/push');
+
+const errorHandler = require('./middleware/errorHandler');
+const { pool } = require('./models/db');
+
+const app = express();
+
+// Trust proxy (behind nginx)
+app.set('trust proxy', 1);
+
+// Security middleware
+app.use(helmet());
+app.use(cors({
+ origin: process.env.NODE_ENV === 'production'
+ ? process.env.CORS_ORIGIN
+ : ['http://localhost:3000', 'http://localhost:5173'],
+ credentials: true
+}));
+
+// Rate limiting
+const limiter = rateLimit({
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
+ max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
+ message: { error: 'Too many requests, please try again later.' }
+});
+app.use(limiter);
+
+// Stricter rate limit for auth endpoints
+const authLimiter = rateLimit({
+ windowMs: 60000,
+ max: 5,
+ message: { error: 'Too many authentication attempts, please try again later.' }
+});
+
+// Body parsing
+app.use(express.json({ limit: '1mb' }));
+app.use(express.urlencoded({ extended: true }));
+
+// Request logging
+app.use((req, res, next) => {
+ const start = Date.now();
+ res.on('finish', () => {
+ const duration = Date.now() - start;
+ console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
+ });
+ next();
+});
+
+// Health check
+app.get('/api/health', (req, res) => {
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
+});
+
+// Routes
+app.use('/api/auth', authLimiter, authRoutes);
+app.use('/api/games', gamesRoutes);
+app.use('/api/play', playRoutes);
+app.use('/api/arcade', arcadeRoutes);
+app.use('/api/developer', developerRoutes);
+app.use('/api/admin', adminRoutes);
+app.use('/api/users', usersRoutes);
+app.use('/api/collections', collectionsRoutes);
+app.use('/api/creation', creationRoutes);
+app.use('/api/share', shareRoutes);
+app.use('/api/themes', themesRoutes);
+app.use('/api/classrooms', classroomsRoutes);
+app.use('/api/leaderboard', leaderboardRoutes);
+app.use('/api/contact', contactRoutes);
+app.use('/api/bugs', bugsRoutes);
+app.use('/api/localai', localaiRoutes);
+app.use('/api/push', pushRoutes);
+
+// Error handling
+app.use(errorHandler);
+
+// 404 handler
+app.use((req, res) => {
+ res.status(404).json({ error: 'Not found' });
+});
+
+const PORT = process.env.PORT || 3000;
+
+// Graceful shutdown
+process.on('SIGTERM', async () => {
+ console.log('SIGTERM received, shutting down gracefully');
+ await pool.end();
+ process.exit(0);
+});
+
+app.listen(PORT, () => {
+ console.log(`GamerComp API running on port ${PORT}`);
+});
+
+module.exports = app;
diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js
new file mode 100644
index 0000000..bb5a850
--- /dev/null
+++ b/backend/src/middleware/auth.js
@@ -0,0 +1,169 @@
+const jwt = require('jsonwebtoken');
+const Users = require('../models/users');
+const { GuestSessions } = require('../models/plays');
+const { generateUsername } = require('../utils/usernames');
+
+const authenticate = async (req, res, next) => {
+ try {
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'No token provided' });
+ }
+
+ const token = authHeader.substring(7);
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+
+ const user = await Users.findById(decoded.userId);
+ if (!user) {
+ return res.status(401).json({ error: 'User not found' });
+ }
+
+ if (user.is_banned) {
+ return res.status(403).json({ error: 'Account is banned' });
+ }
+
+ req.user = user;
+ next();
+ } catch (error) {
+ if (error.name === 'TokenExpiredError') {
+ return res.status(401).json({ error: 'Token expired' });
+ }
+ if (error.name === 'JsonWebTokenError') {
+ return res.status(401).json({ error: 'Invalid token' });
+ }
+ next(error);
+ }
+};
+
+const optionalAuth = async (req, res, next) => {
+ try {
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return next();
+ }
+
+ const token = authHeader.substring(7);
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ const user = await Users.findById(decoded.userId);
+
+ if (user && !user.is_banned) {
+ req.user = user;
+ }
+ next();
+ } catch {
+ next();
+ }
+};
+
+const requireRole = (...roles) => {
+ return (req, res, next) => {
+ if (!req.user) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ if (!roles.includes(req.user.role)) {
+ return res.status(403).json({ error: 'Insufficient permissions' });
+ }
+ next();
+ };
+};
+
+// Middleware that authenticates user OR auto-creates a guest from fingerprint
+const authenticateOrGuest = async (req, res, next) => {
+ try {
+ const authHeader = req.headers.authorization;
+
+ // If token provided, try to authenticate normally
+ if (authHeader && authHeader.startsWith('Bearer ')) {
+ const token = authHeader.substring(7);
+ try {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ const user = await Users.findById(decoded.userId);
+ if (user && !user.is_banned) {
+ req.user = user;
+ return next();
+ }
+ } catch {
+ // Token invalid, continue to guest flow
+ }
+ }
+
+ // No valid token - check for fingerprint to create/find guest
+ const fingerprint = req.body.fingerprint || req.headers['x-device-fingerprint'];
+
+ if (!fingerprint || fingerprint.length < 10) {
+ return res.status(401).json({
+ error: 'Authentication required',
+ requiresLogin: true,
+ message: 'Please sign in or allow guest access'
+ });
+ }
+
+ // Find or create guest user
+ const ipAddress = req.ip || req.connection?.remoteAddress || 'unknown';
+ const userAgent = req.headers['user-agent'] || 'unknown';
+
+ // Check for existing guest with this fingerprint
+ let user = await Users.findByFingerprint(fingerprint);
+
+ if (!user) {
+ // Create new guest user
+ const username = await generateUsername();
+ user = await Users.create({
+ username,
+ email: null,
+ password: null,
+ isGuest: true,
+ deviceFingerprint: fingerprint
+ });
+ // Set initial tokens for guest
+ await Users.updateTokens(user.id, 5);
+ user = await Users.findById(user.id);
+ }
+
+ // Track/update guest session
+ await GuestSessions.findOrCreate(fingerprint, user.id, userAgent, ipAddress);
+
+ // Refresh daily tokens for guests (5 per day)
+ const session = await GuestSessions.checkAndResetDailyTokens(fingerprint, 5);
+ if (session.tokensGranted > 0) {
+ await Users.updateTokens(user.id, session.tokensGranted);
+ user = await Users.findById(user.id); // Refresh user data
+ }
+
+ req.user = user;
+ req.isAutoGuest = true; // Flag that this was auto-created
+ next();
+ } catch (error) {
+ next(error);
+ }
+};
+
+// Middleware to require non-guest user
+const requireNonGuest = (req, res, next) => {
+ if (!req.user) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+ if (req.user.is_guest) {
+ return res.status(403).json({
+ error: 'Sign up required',
+ requiresSignup: true,
+ message: 'Sign up free to access this feature!'
+ });
+ }
+ next();
+};
+
+const generateToken = (userId) => {
+ return jwt.sign({ userId }, process.env.JWT_SECRET, {
+ expiresIn: process.env.JWT_EXPIRES_IN || '7d'
+ });
+};
+
+module.exports = {
+ authenticate,
+ optionalAuth,
+ requireRole,
+ generateToken,
+ authenticateOrGuest,
+ requireNonGuest
+};
diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js
new file mode 100644
index 0000000..2028a17
--- /dev/null
+++ b/backend/src/middleware/errorHandler.js
@@ -0,0 +1,31 @@
+const errorHandler = (err, req, res, next) => {
+ console.error('Error:', err);
+
+ // Validation errors from express-validator
+ if (err.array && typeof err.array === 'function') {
+ return res.status(400).json({
+ error: 'Validation failed',
+ details: err.array()
+ });
+ }
+
+ // PostgreSQL errors
+ if (err.code === '23505') {
+ return res.status(409).json({ error: 'Resource already exists' });
+ }
+ if (err.code === '23503') {
+ return res.status(400).json({ error: 'Referenced resource not found' });
+ }
+
+ // JWT errors are handled in auth middleware
+
+ // Default error
+ const statusCode = err.statusCode || 500;
+ const message = process.env.NODE_ENV === 'production'
+ ? 'Internal server error'
+ : err.message;
+
+ res.status(statusCode).json({ error: message });
+};
+
+module.exports = errorHandler;
diff --git a/backend/src/middleware/validate.js b/backend/src/middleware/validate.js
new file mode 100644
index 0000000..4d2bb1c
--- /dev/null
+++ b/backend/src/middleware/validate.js
@@ -0,0 +1,22 @@
+const { validationResult } = require('express-validator');
+
+const validate = (validations) => {
+ return async (req, res, next) => {
+ for (const validation of validations) {
+ const result = await validation.run(req);
+ if (!result.isEmpty()) break;
+ }
+
+ const errors = validationResult(req);
+ if (errors.isEmpty()) {
+ return next();
+ }
+
+ res.status(400).json({
+ error: 'Validation failed',
+ details: errors.array().map(e => ({ field: e.path, message: e.msg }))
+ });
+ };
+};
+
+module.exports = validate;
diff --git a/backend/src/models/achievements.js b/backend/src/models/achievements.js
new file mode 100644
index 0000000..f41e22e
--- /dev/null
+++ b/backend/src/models/achievements.js
@@ -0,0 +1,145 @@
+const { pool } = require('./db');
+
+const Achievements = {
+ async getAll() {
+ const result = await pool.query(
+ 'SELECT * FROM achievements ORDER BY category, xp_reward'
+ );
+ return result.rows;
+ },
+
+ async getByCode(code) {
+ const result = await pool.query(
+ 'SELECT * FROM achievements WHERE code = $1',
+ [code]
+ );
+ return result.rows[0];
+ },
+
+ async getUserAchievements(userId) {
+ const result = await pool.query(
+ `SELECT a.*, ua.earned_at
+ FROM achievements a
+ LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = $1
+ ORDER BY a.category, a.xp_reward`,
+ [userId]
+ );
+ return result.rows;
+ },
+
+ // Alias for getting all achievements with user's earned status
+ async getAllWithUserStatus(userId) {
+ return this.getUserAchievements(userId);
+ },
+
+ async getUserEarnedAchievements(userId) {
+ const result = await pool.query(
+ `SELECT a.*, ua.earned_at
+ FROM user_achievements ua
+ JOIN achievements a ON ua.achievement_id = a.id
+ WHERE ua.user_id = $1
+ ORDER BY ua.earned_at DESC`,
+ [userId]
+ );
+ return result.rows;
+ },
+
+ async award(userId, achievementCode) {
+ const achievement = await this.getByCode(achievementCode);
+ if (!achievement) return null;
+
+ // Check if already earned
+ const existing = await pool.query(
+ 'SELECT id FROM user_achievements WHERE user_id = $1 AND achievement_id = $2',
+ [userId, achievement.id]
+ );
+ if (existing.rows.length > 0) return null;
+
+ // Award achievement
+ await pool.query(
+ 'INSERT INTO user_achievements (user_id, achievement_id) VALUES ($1, $2)',
+ [userId, achievement.id]
+ );
+
+ // Award XP
+ if (achievement.xp_reward > 0) {
+ await pool.query(
+ 'SELECT * FROM award_xp($1, $2, $3, $4)',
+ [userId, achievement.xp_reward, `achievement:${achievementCode}`, achievement.id]
+ );
+ }
+
+ return achievement;
+ },
+
+ async checkAndAward(userId, checkType, value) {
+ const awarded = [];
+
+ switch (checkType) {
+ case 'games_published':
+ if (value === 1) {
+ const a = await this.award(userId, 'first_game');
+ if (a) awarded.push(a);
+ }
+ if (value === 10) {
+ const a = await this.award(userId, 'ten_games');
+ if (a) awarded.push(a);
+ }
+ break;
+
+ case 'game_plays':
+ if (value >= 100) {
+ const a = await this.award(userId, 'hundred_plays');
+ if (a) awarded.push(a);
+ }
+ if (value >= 1000) {
+ const a = await this.award(userId, 'thousand_plays');
+ if (a) awarded.push(a);
+ }
+ break;
+
+ case 'games_played':
+ if (value === 1) {
+ const a = await this.award(userId, 'first_play');
+ if (a) awarded.push(a);
+ }
+ if (value === 50) {
+ const a = await this.award(userId, 'fifty_plays');
+ if (a) awarded.push(a);
+ }
+ break;
+
+ case 'favorites_count':
+ if (value === 10) {
+ const a = await this.award(userId, 'ten_favorites');
+ if (a) awarded.push(a);
+ }
+ break;
+
+ case 'first_follow':
+ const a = await this.award(userId, 'first_follow');
+ if (a) awarded.push(a);
+ break;
+
+ case 'first_collection':
+ const b = await this.award(userId, 'first_collection');
+ if (b) awarded.push(b);
+ break;
+
+ case 'login_streak':
+ if (value === 7) {
+ const a = await this.award(userId, 'week_streak');
+ if (a) awarded.push(a);
+ }
+ if (value === 30) {
+ const a = await this.award(userId, 'month_streak');
+ if (a) awarded.push(a);
+ }
+ break;
+ }
+
+ return awarded;
+ }
+};
+
+module.exports = Achievements;
diff --git a/backend/src/models/auditLog.js b/backend/src/models/auditLog.js
new file mode 100644
index 0000000..8370db6
--- /dev/null
+++ b/backend/src/models/auditLog.js
@@ -0,0 +1,52 @@
+const { pool } = require('./db');
+
+const AuditLog = {
+ async create(adminId, action, targetType, targetId, details, ipAddress) {
+ const result = await pool.query(
+ `INSERT INTO audit_logs (admin_id, action, target_type, target_id, details, ip_address)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING *`,
+ [adminId, action, targetType, targetId, JSON.stringify(details), ipAddress]
+ );
+ return result.rows[0];
+ },
+
+ async getRecent(limit = 50) {
+ const result = await pool.query(
+ `SELECT al.*, u.username as admin_username
+ FROM audit_logs al
+ JOIN users u ON al.admin_id = u.id
+ ORDER BY al.created_at DESC
+ LIMIT $1`,
+ [limit]
+ );
+ return result.rows;
+ },
+
+ async getByAdmin(adminId, limit = 50) {
+ const result = await pool.query(
+ `SELECT al.*, u.username as admin_username
+ FROM audit_logs al
+ JOIN users u ON al.admin_id = u.id
+ WHERE al.admin_id = $1
+ ORDER BY al.created_at DESC
+ LIMIT $2`,
+ [adminId, limit]
+ );
+ return result.rows;
+ },
+
+ async getByTarget(targetType, targetId) {
+ const result = await pool.query(
+ `SELECT al.*, u.username as admin_username
+ FROM audit_logs al
+ JOIN users u ON al.admin_id = u.id
+ WHERE al.target_type = $1 AND al.target_id = $2
+ ORDER BY al.created_at DESC`,
+ [targetType, targetId]
+ );
+ return result.rows;
+ }
+};
+
+module.exports = AuditLog;
diff --git a/backend/src/models/classrooms.js b/backend/src/models/classrooms.js
new file mode 100644
index 0000000..bd94215
--- /dev/null
+++ b/backend/src/models/classrooms.js
@@ -0,0 +1,299 @@
+const { pool } = require('./db');
+const crypto = require('crypto');
+
+function generateJoinCode() {
+ return crypto.randomBytes(3).toString('hex').toUpperCase();
+}
+
+const Classrooms = {
+ // Create a new classroom
+ async create(teacherId, name, isPublicPublishingAllowed = false) {
+ let joinCode = generateJoinCode();
+
+ // Ensure unique join code
+ let attempts = 0;
+ while (attempts < 10) {
+ const existing = await pool.query(
+ 'SELECT id FROM classrooms WHERE join_code = $1',
+ [joinCode]
+ );
+ if (existing.rows.length === 0) break;
+ joinCode = generateJoinCode();
+ attempts++;
+ }
+
+ const result = await pool.query(
+ `INSERT INTO classrooms (teacher_id, name, join_code, is_public_publishing_allowed)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [teacherId, name, joinCode, isPublicPublishingAllowed]
+ );
+ return result.rows[0];
+ },
+
+ // Get classroom by ID
+ async findById(id) {
+ const result = await pool.query(
+ `SELECT c.*, u.username as teacher_username, u.display_name as teacher_display_name,
+ (SELECT COUNT(*) FROM classroom_students WHERE classroom_id = c.id) as student_count
+ FROM classrooms c
+ JOIN users u ON c.teacher_id = u.id
+ WHERE c.id = $1`,
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ // Get classroom by join code
+ async findByJoinCode(joinCode) {
+ const result = await pool.query(
+ `SELECT c.*, u.username as teacher_username, u.display_name as teacher_display_name
+ FROM classrooms c
+ JOIN users u ON c.teacher_id = u.id
+ WHERE c.join_code = $1`,
+ [joinCode.toUpperCase()]
+ );
+ return result.rows[0];
+ },
+
+ // Get all classrooms for a teacher
+ async getByTeacher(teacherId) {
+ const result = await pool.query(
+ `SELECT c.*,
+ (SELECT COUNT(*) FROM classroom_students WHERE classroom_id = c.id) as student_count,
+ (SELECT COUNT(*) FROM classroom_assignments WHERE classroom_id = c.id) as assignment_count
+ FROM classrooms c
+ WHERE c.teacher_id = $1
+ ORDER BY c.created_at DESC`,
+ [teacherId]
+ );
+ return result.rows;
+ },
+
+ // Get all classrooms a student is enrolled in
+ async getByStudent(studentId) {
+ const result = await pool.query(
+ `SELECT c.*, u.username as teacher_username, u.display_name as teacher_display_name,
+ cs.joined_at,
+ (SELECT COUNT(*) FROM classroom_students WHERE classroom_id = c.id) as student_count
+ FROM classroom_students cs
+ JOIN classrooms c ON cs.classroom_id = c.id
+ JOIN users u ON c.teacher_id = u.id
+ WHERE cs.student_id = $1
+ ORDER BY cs.joined_at DESC`,
+ [studentId]
+ );
+ return result.rows;
+ },
+
+ // Update classroom
+ async update(id, teacherId, { name, isPublicPublishingAllowed }) {
+ const result = await pool.query(
+ `UPDATE classrooms
+ SET name = COALESCE($3, name),
+ is_public_publishing_allowed = COALESCE($4, is_public_publishing_allowed)
+ WHERE id = $1 AND teacher_id = $2
+ RETURNING *`,
+ [id, teacherId, name, isPublicPublishingAllowed]
+ );
+ return result.rows[0];
+ },
+
+ // Delete classroom
+ async delete(id, teacherId) {
+ const result = await pool.query(
+ 'DELETE FROM classrooms WHERE id = $1 AND teacher_id = $2 RETURNING id',
+ [id, teacherId]
+ );
+ return result.rows[0];
+ },
+
+ // Regenerate join code
+ async regenerateJoinCode(id, teacherId) {
+ const joinCode = generateJoinCode();
+ const result = await pool.query(
+ 'UPDATE classrooms SET join_code = $3 WHERE id = $1 AND teacher_id = $2 RETURNING *',
+ [id, teacherId, joinCode]
+ );
+ return result.rows[0];
+ },
+
+ // Add student to classroom
+ async addStudent(classroomId, studentId) {
+ try {
+ const result = await pool.query(
+ `INSERT INTO classroom_students (classroom_id, student_id)
+ VALUES ($1, $2)
+ ON CONFLICT (classroom_id, student_id) DO NOTHING
+ RETURNING *`,
+ [classroomId, studentId]
+ );
+ return result.rows[0];
+ } catch (error) {
+ if (error.code === '23505') { // Unique violation
+ return null; // Already enrolled
+ }
+ throw error;
+ }
+ },
+
+ // Remove student from classroom
+ async removeStudent(classroomId, studentId, teacherId) {
+ // Verify teacher owns classroom
+ const classroom = await this.findById(classroomId);
+ if (!classroom || classroom.teacher_id !== teacherId) {
+ return null;
+ }
+
+ const result = await pool.query(
+ 'DELETE FROM classroom_students WHERE classroom_id = $1 AND student_id = $2 RETURNING *',
+ [classroomId, studentId]
+ );
+ return result.rows[0];
+ },
+
+ // Get students in a classroom
+ async getStudents(classroomId) {
+ const result = await pool.query(
+ `SELECT u.id, u.username, u.display_name, u.xp_total, u.creator_level, u.creator_badge,
+ cs.joined_at,
+ (SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
+ (SELECT COUNT(*) FROM plays WHERE player_id = u.id) as games_played
+ FROM classroom_students cs
+ JOIN users u ON cs.student_id = u.id
+ WHERE cs.classroom_id = $1
+ ORDER BY u.username`,
+ [classroomId]
+ );
+ return result.rows;
+ },
+
+ // Check if user is student in classroom
+ async isStudent(classroomId, studentId) {
+ const result = await pool.query(
+ 'SELECT id FROM classroom_students WHERE classroom_id = $1 AND student_id = $2',
+ [classroomId, studentId]
+ );
+ return result.rows.length > 0;
+ },
+
+ // Check if user is teacher of classroom
+ async isTeacher(classroomId, teacherId) {
+ const result = await pool.query(
+ 'SELECT id FROM classrooms WHERE id = $1 AND teacher_id = $2',
+ [classroomId, teacherId]
+ );
+ return result.rows.length > 0;
+ },
+
+ // Create assignment
+ async createAssignment(classroomId, { title, description, requiredGameStyle, dueDate }) {
+ const result = await pool.query(
+ `INSERT INTO classroom_assignments (classroom_id, title, description, required_game_style, due_date)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [classroomId, title, description, requiredGameStyle, dueDate]
+ );
+ return result.rows[0];
+ },
+
+ // Get assignments for a classroom
+ async getAssignments(classroomId) {
+ const result = await pool.query(
+ `SELECT * FROM classroom_assignments
+ WHERE classroom_id = $1
+ ORDER BY due_date ASC NULLS LAST, created_at DESC`,
+ [classroomId]
+ );
+ return result.rows;
+ },
+
+ // Get assignment by ID
+ async getAssignment(assignmentId) {
+ const result = await pool.query(
+ `SELECT a.*, c.teacher_id, c.name as classroom_name
+ FROM classroom_assignments a
+ JOIN classrooms c ON a.classroom_id = c.id
+ WHERE a.id = $1`,
+ [assignmentId]
+ );
+ return result.rows[0];
+ },
+
+ // Delete assignment
+ async deleteAssignment(assignmentId, teacherId) {
+ const result = await pool.query(
+ `DELETE FROM classroom_assignments a
+ USING classrooms c
+ WHERE a.id = $1 AND a.classroom_id = c.id AND c.teacher_id = $2
+ RETURNING a.id`,
+ [assignmentId, teacherId]
+ );
+ return result.rows[0];
+ },
+
+ // Get student progress in classroom
+ async getStudentProgress(classroomId, studentId) {
+ const result = await pool.query(
+ `SELECT
+ u.xp_total, u.creator_level, u.creator_badge,
+ (SELECT COUNT(*) FROM games WHERE creator_id = $2 AND status = 'published') as games_created,
+ (SELECT COUNT(*) FROM plays WHERE player_id = $2) as total_plays,
+ (SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = $2) as games_played_by_others
+ FROM users u
+ WHERE u.id = $2`,
+ [classroomId, studentId]
+ );
+ return result.rows[0];
+ },
+
+ // Get classroom leaderboard
+ async getLeaderboard(classroomId) {
+ const result = await pool.query(
+ `SELECT u.id, u.username, u.display_name, u.xp_total, u.creator_level, u.creator_badge,
+ (SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
+ RANK() OVER (ORDER BY u.xp_total DESC) as rank
+ FROM classroom_students cs
+ JOIN users u ON cs.student_id = u.id
+ WHERE cs.classroom_id = $1
+ ORDER BY u.xp_total DESC
+ LIMIT 50`,
+ [classroomId]
+ );
+ return result.rows;
+ },
+
+ // Get detailed student progress for teacher console
+ async getDetailedStudentProgress(classroomId) {
+ const result = await pool.query(
+ `SELECT
+ u.id,
+ u.username,
+ u.display_name,
+ u.xp_total,
+ u.creator_level as level,
+ u.creator_badge as badge,
+ cs.joined_at,
+ (SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
+ (SELECT COUNT(*) FROM plays WHERE player_id = u.id) as games_played,
+ (SELECT COUNT(*) FROM user_achievements WHERE user_id = u.id) as achievement_count,
+ (SELECT COALESCE(SUM(xp_transactions.amount), 0)
+ FROM xp_transactions
+ WHERE user_id = u.id
+ AND created_at >= NOW() - INTERVAL '7 days') as xp_this_week,
+ (SELECT COUNT(*) FROM games
+ WHERE creator_id = u.id
+ AND status = 'published'
+ AND created_at >= NOW() - INTERVAL '7 days') as games_this_week,
+ (SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays_on_games
+ FROM classroom_students cs
+ JOIN users u ON cs.student_id = u.id
+ WHERE cs.classroom_id = $1
+ ORDER BY u.xp_total DESC`,
+ [classroomId]
+ );
+ return result.rows;
+ }
+};
+
+module.exports = Classrooms;
diff --git a/backend/src/models/collections.js b/backend/src/models/collections.js
new file mode 100644
index 0000000..d8149a0
--- /dev/null
+++ b/backend/src/models/collections.js
@@ -0,0 +1,135 @@
+const { pool } = require('./db');
+
+const Collections = {
+ async create({ userId, name, description, isPublic = true }) {
+ const result = await pool.query(
+ `INSERT INTO collections (user_id, name, description, is_public)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [userId, name, description, isPublic]
+ );
+ return result.rows[0];
+ },
+
+ async findById(collectionId) {
+ const result = await pool.query(
+ `SELECT c.*, u.username as owner_username, u.display_name as owner_display_name,
+ (SELECT COUNT(*) FROM collection_games WHERE collection_id = c.id) as game_count
+ FROM collections c
+ JOIN users u ON c.user_id = u.id
+ WHERE c.id = $1`,
+ [collectionId]
+ );
+ return result.rows[0];
+ },
+
+ async update(collectionId, userId, { name, description, isPublic }) {
+ const result = await pool.query(
+ `UPDATE collections
+ SET name = COALESCE($3, name),
+ description = COALESCE($4, description),
+ is_public = COALESCE($5, is_public)
+ WHERE id = $1 AND user_id = $2
+ RETURNING *`,
+ [collectionId, userId, name, description, isPublic]
+ );
+ return result.rows[0];
+ },
+
+ async delete(collectionId, userId) {
+ await pool.query(
+ 'DELETE FROM collections WHERE id = $1 AND user_id = $2',
+ [collectionId, userId]
+ );
+ },
+
+ async getUserCollections(userId) {
+ const result = await pool.query(
+ `SELECT c.*,
+ (SELECT COUNT(*) FROM collection_games WHERE collection_id = c.id) as game_count
+ FROM collections c
+ WHERE c.user_id = $1
+ ORDER BY c.created_at DESC`,
+ [userId]
+ );
+ return result.rows;
+ },
+
+ async getPublicCollections(limit = 20, offset = 0) {
+ const result = await pool.query(
+ `SELECT c.*, u.username as owner_username, u.display_name as owner_display_name,
+ (SELECT COUNT(*) FROM collection_games WHERE collection_id = c.id) as game_count
+ FROM collections c
+ JOIN users u ON c.user_id = u.id
+ WHERE c.is_public = true
+ ORDER BY c.created_at DESC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+ return result.rows;
+ },
+
+ async addGame(collectionId, gameId, userId) {
+ // Verify ownership
+ const collection = await pool.query(
+ 'SELECT id FROM collections WHERE id = $1 AND user_id = $2',
+ [collectionId, userId]
+ );
+ if (collection.rows.length === 0) {
+ throw new Error('Collection not found or not owned by user');
+ }
+
+ const result = await pool.query(
+ `INSERT INTO collection_games (collection_id, game_id)
+ VALUES ($1, $2)
+ ON CONFLICT (collection_id, game_id) DO NOTHING
+ RETURNING *`,
+ [collectionId, gameId]
+ );
+ return result.rows[0];
+ },
+
+ async removeGame(collectionId, gameId, userId) {
+ // Verify ownership
+ const collection = await pool.query(
+ 'SELECT id FROM collections WHERE id = $1 AND user_id = $2',
+ [collectionId, userId]
+ );
+ if (collection.rows.length === 0) {
+ throw new Error('Collection not found or not owned by user');
+ }
+
+ await pool.query(
+ 'DELETE FROM collection_games WHERE collection_id = $1 AND game_id = $2',
+ [collectionId, gameId]
+ );
+ },
+
+ async getGames(collectionId, includePrivate = false) {
+ const collection = await this.findById(collectionId);
+ if (!collection) return [];
+ if (!collection.is_public && !includePrivate) return [];
+
+ const result = await pool.query(
+ `SELECT g.*, u.username as creator_username, u.display_name as creator_display_name,
+ cg.added_at
+ FROM collection_games cg
+ JOIN games g ON cg.game_id = g.id
+ JOIN users u ON g.creator_id = u.id
+ WHERE cg.collection_id = $1 AND g.status = 'published'
+ ORDER BY cg.added_at DESC`,
+ [collectionId]
+ );
+ return result.rows;
+ },
+
+ async getCount(userId) {
+ const result = await pool.query(
+ 'SELECT COUNT(*) as count FROM collections WHERE user_id = $1',
+ [userId]
+ );
+ return parseInt(result.rows[0].count);
+ }
+};
+
+module.exports = Collections;
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
new file mode 100644
index 0000000..d4ab414
--- /dev/null
+++ b/backend/src/models/db.js
@@ -0,0 +1,24 @@
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ host: process.env.DB_HOST,
+ port: process.env.DB_PORT,
+ database: process.env.DB_NAME,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ max: 20,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 2000,
+});
+
+pool.on('error', (err) => {
+ console.error('Unexpected error on idle client', err);
+ process.exit(-1);
+});
+
+// Test connection on startup
+pool.query('SELECT NOW()')
+ .then(() => console.log('Database connected'))
+ .catch(err => console.error('Database connection error:', err));
+
+module.exports = { pool };
diff --git a/backend/src/models/favorites.js b/backend/src/models/favorites.js
new file mode 100644
index 0000000..cdc7818
--- /dev/null
+++ b/backend/src/models/favorites.js
@@ -0,0 +1,96 @@
+const { pool } = require('./db');
+
+const Favorites = {
+ async add(userId, gameId) {
+ const result = await pool.query(
+ `INSERT INTO favorites (user_id, game_id)
+ VALUES ($1, $2)
+ ON CONFLICT (user_id, game_id) DO NOTHING
+ RETURNING *`,
+ [userId, gameId]
+ );
+
+ // Update game favorite count
+ if (result.rows.length > 0) {
+ await pool.query(
+ 'UPDATE games SET favorite_count = favorite_count + 1 WHERE id = $1',
+ [gameId]
+ );
+ }
+
+ return result.rows[0];
+ },
+
+ async remove(userId, gameId) {
+ const result = await pool.query(
+ 'DELETE FROM favorites WHERE user_id = $1 AND game_id = $2 RETURNING *',
+ [userId, gameId]
+ );
+
+ // Update game favorite count
+ if (result.rows.length > 0) {
+ await pool.query(
+ 'UPDATE games SET favorite_count = GREATEST(0, favorite_count - 1) WHERE id = $1',
+ [gameId]
+ );
+ }
+
+ return result.rows[0];
+ },
+
+ async toggle(userId, gameId) {
+ const existing = await pool.query(
+ 'SELECT id FROM favorites WHERE user_id = $1 AND game_id = $2',
+ [userId, gameId]
+ );
+
+ if (existing.rows.length > 0) {
+ await this.remove(userId, gameId);
+ return { favorited: false };
+ } else {
+ await this.add(userId, gameId);
+ return { favorited: true };
+ }
+ },
+
+ async isFavorited(userId, gameId) {
+ const result = await pool.query(
+ 'SELECT id FROM favorites WHERE user_id = $1 AND game_id = $2',
+ [userId, gameId]
+ );
+ return result.rows.length > 0;
+ },
+
+ async getUserFavorites(userId, limit = 50, offset = 0) {
+ const result = await pool.query(
+ `SELECT g.*, u.username as creator_username, u.display_name as creator_display_name,
+ f.created_at as favorited_at
+ FROM favorites f
+ JOIN games g ON f.game_id = g.id
+ JOIN users u ON g.creator_id = u.id
+ WHERE f.user_id = $1 AND g.status = 'published'
+ ORDER BY f.created_at DESC
+ LIMIT $2 OFFSET $3`,
+ [userId, limit, offset]
+ );
+ return result.rows;
+ },
+
+ async getCount(userId) {
+ const result = await pool.query(
+ 'SELECT COUNT(*) as count FROM favorites WHERE user_id = $1',
+ [userId]
+ );
+ return parseInt(result.rows[0].count);
+ },
+
+ async getUserFavoriteIds(userId) {
+ const result = await pool.query(
+ 'SELECT game_id FROM favorites WHERE user_id = $1',
+ [userId]
+ );
+ return result.rows.map(r => r.game_id);
+ }
+};
+
+module.exports = Favorites;
diff --git a/backend/src/models/follows.js b/backend/src/models/follows.js
new file mode 100644
index 0000000..2b962c0
--- /dev/null
+++ b/backend/src/models/follows.js
@@ -0,0 +1,80 @@
+const { pool } = require('./db');
+
+const Follows = {
+ async follow(followerId, followingId) {
+ if (followerId === followingId) {
+ throw new Error('Cannot follow yourself');
+ }
+
+ const result = await pool.query(
+ `INSERT INTO follows (follower_id, following_id)
+ VALUES ($1, $2)
+ ON CONFLICT (follower_id, following_id) DO NOTHING
+ RETURNING *`,
+ [followerId, followingId]
+ );
+ return result.rows[0];
+ },
+
+ async unfollow(followerId, followingId) {
+ const result = await pool.query(
+ 'DELETE FROM follows WHERE follower_id = $1 AND following_id = $2 RETURNING *',
+ [followerId, followingId]
+ );
+ return result.rows[0];
+ },
+
+ async isFollowing(followerId, followingId) {
+ const result = await pool.query(
+ 'SELECT id FROM follows WHERE follower_id = $1 AND following_id = $2',
+ [followerId, followingId]
+ );
+ return result.rows.length > 0;
+ },
+
+ async getFollowers(userId, limit = 50, offset = 0) {
+ const result = await pool.query(
+ `SELECT u.id, u.username, u.display_name, u.avatar_data,
+ u.creator_level, u.creator_badge, f.created_at as followed_at
+ FROM follows f
+ JOIN users u ON f.follower_id = u.id
+ WHERE f.following_id = $1
+ ORDER BY f.created_at DESC
+ LIMIT $2 OFFSET $3`,
+ [userId, limit, offset]
+ );
+ return result.rows;
+ },
+
+ async getFollowing(userId, limit = 50, offset = 0) {
+ const result = await pool.query(
+ `SELECT u.id, u.username, u.display_name, u.avatar_data,
+ u.creator_level, u.creator_badge, f.created_at as followed_at
+ FROM follows f
+ JOIN users u ON f.following_id = u.id
+ WHERE f.follower_id = $1
+ ORDER BY f.created_at DESC
+ LIMIT $2 OFFSET $3`,
+ [userId, limit, offset]
+ );
+ return result.rows;
+ },
+
+ async getFollowerCount(userId) {
+ const result = await pool.query(
+ 'SELECT COUNT(*) as count FROM follows WHERE following_id = $1',
+ [userId]
+ );
+ return parseInt(result.rows[0].count);
+ },
+
+ async getFollowingCount(userId) {
+ const result = await pool.query(
+ 'SELECT COUNT(*) as count FROM follows WHERE follower_id = $1',
+ [userId]
+ );
+ return parseInt(result.rows[0].count);
+ }
+};
+
+module.exports = Follows;
diff --git a/backend/src/models/gameVersions.js b/backend/src/models/gameVersions.js
new file mode 100644
index 0000000..4d7e2b5
--- /dev/null
+++ b/backend/src/models/gameVersions.js
@@ -0,0 +1,67 @@
+const { pool } = require('./db');
+
+const GameVersions = {
+ async saveVersion(gameId, gameCode, changeType, description = null, costData = {}) {
+ // Get next version number
+ const versionResult = await pool.query(
+ 'SELECT COALESCE(MAX(version_number), 0) + 1 as next_version FROM game_versions WHERE game_id = $1',
+ [gameId]
+ );
+ const versionNumber = versionResult.rows[0].next_version;
+
+ const result = await pool.query(
+ `INSERT INTO game_versions (game_id, version_number, game_code, change_type, change_description,
+ claude_input_tokens, claude_output_tokens, api_cost_cents)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id, version_number, change_type, change_description, created_at`,
+ [
+ gameId,
+ versionNumber,
+ gameCode,
+ changeType,
+ description,
+ costData.inputTokens || 0,
+ costData.outputTokens || 0,
+ costData.apiCostCents || 0
+ ]
+ );
+
+ return result.rows[0];
+ },
+
+ async getVersions(gameId) {
+ const result = await pool.query(
+ `SELECT id, version_number, change_type, change_description,
+ LENGTH(game_code) as code_length,
+ claude_input_tokens, claude_output_tokens, api_cost_cents,
+ created_at
+ FROM game_versions
+ WHERE game_id = $1
+ ORDER BY version_number DESC`,
+ [gameId]
+ );
+ return result.rows;
+ },
+
+ async getVersion(gameId, versionNumber) {
+ const result = await pool.query(
+ `SELECT * FROM game_versions
+ WHERE game_id = $1 AND version_number = $2`,
+ [gameId, versionNumber]
+ );
+ return result.rows[0] || null;
+ },
+
+ async getLatestVersion(gameId) {
+ const result = await pool.query(
+ `SELECT * FROM game_versions
+ WHERE game_id = $1
+ ORDER BY version_number DESC
+ LIMIT 1`,
+ [gameId]
+ );
+ return result.rows[0] || null;
+ }
+};
+
+module.exports = GameVersions;
diff --git a/backend/src/models/games.js b/backend/src/models/games.js
new file mode 100644
index 0000000..303d518
--- /dev/null
+++ b/backend/src/models/games.js
@@ -0,0 +1,166 @@
+const { pool } = require('./db');
+
+const Games = {
+ async create({ creatorId, title, description, gameCode, prompt }) {
+ const result = await pool.query(
+ `INSERT INTO games (creator_id, title, description, game_code, prompt)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [creatorId, title, description, gameCode, prompt]
+ );
+ return result.rows[0];
+ },
+
+ async findById(id) {
+ const result = await pool.query(
+ `SELECT g.*, g.prompt, u.username as creator_username, u.display_name as creator_display_name
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ WHERE g.id = $1`,
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ async findByCreator(creatorId) {
+ const result = await pool.query(
+ `SELECT * FROM games WHERE creator_id = $1 ORDER BY created_at DESC`,
+ [creatorId]
+ );
+ return result.rows;
+ },
+
+ async update(id, { title, description, gameCode }) {
+ const result = await pool.query(
+ `UPDATE games SET
+ title = COALESCE($2, title),
+ description = COALESCE($3, description),
+ game_code = COALESCE($4, game_code)
+ WHERE id = $1
+ RETURNING *`,
+ [id, title, description, gameCode]
+ );
+ return result.rows[0];
+ },
+
+ async submit(id) {
+ const result = await pool.query(
+ `UPDATE games SET status = 'pending_review' WHERE id = $1 AND status = 'draft' RETURNING *`,
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ async approve(id) {
+ const result = await pool.query(
+ `UPDATE games SET status = 'published', published_at = NOW() WHERE id = $1 RETURNING *`,
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ async reject(id, reason) {
+ const result = await pool.query(
+ `UPDATE games SET status = 'rejected', rejection_reason = $2 WHERE id = $1 RETURNING *`,
+ [id, reason]
+ );
+ return result.rows[0];
+ },
+
+ async delete(id) {
+ await pool.query(`DELETE FROM games WHERE id = $1`, [id]);
+ },
+
+ async incrementPlayCount(id) {
+ await pool.query(
+ `UPDATE games SET play_count = play_count + 1, last_played = NOW() WHERE id = $1`,
+ [id]
+ );
+ },
+
+ async addRating(gameId, rating) {
+ await pool.query(
+ `UPDATE games SET total_ratings = total_ratings + 1, rating_sum = rating_sum + $2 WHERE id = $1`,
+ [gameId, rating]
+ );
+ },
+
+ async addTokensEarned(id, tokens) {
+ await pool.query(
+ `UPDATE games SET tokens_earned = tokens_earned + $2 WHERE id = $1`,
+ [id, tokens]
+ );
+ },
+
+ async getPublished({ sort = 'trending', page = 1, limit = 20, search = '', difficulty = '' }) {
+ const offset = (page - 1) * limit;
+ let orderBy = 'play_count DESC';
+
+ switch (sort) {
+ case 'new':
+ orderBy = 'published_at DESC';
+ break;
+ case 'top-rated':
+ orderBy = 'CASE WHEN total_ratings > 0 THEN rating_sum::float / total_ratings ELSE 0 END DESC';
+ break;
+ case 'trending':
+ default:
+ orderBy = 'play_count DESC, published_at DESC';
+ }
+
+ const params = [limit, offset];
+ const clauses = [];
+
+ if (search) {
+ params.push(`%${search}%`);
+ clauses.push(`(title ILIKE $${params.length} OR description ILIKE $${params.length})`);
+ }
+
+ if (difficulty) {
+ params.push(difficulty);
+ clauses.push(`$${params.length} = ANY(difficulty_tags)`);
+ }
+
+ const whereClause = clauses.length > 0 ? 'AND ' + clauses.join(' AND ') : '';
+
+ const result = await pool.query(
+ `SELECT g.id, g.title, g.description, g.thumbnail_url, g.thumbnail_data, g.play_count,
+ g.total_ratings, g.rating_sum, g.published_at, g.is_featured,
+ g.game_code, g.prompt, g.creator_id, g.favorite_count,
+ g.difficulty_tags, g.game_style,
+ u.username as creator_username, u.display_name as creator_display_name,
+ CASE WHEN g.total_ratings > 0 THEN g.rating_sum::float / g.total_ratings ELSE 0 END as average_rating
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ WHERE g.status = 'published' ${whereClause}
+ ORDER BY ${orderBy}
+ LIMIT $1 OFFSET $2`,
+ params
+ );
+ return result.rows;
+ },
+
+ async getPendingReview() {
+ const result = await pool.query(
+ `SELECT g.*, u.username as creator_username
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ WHERE g.status = 'pending_review'
+ ORDER BY g.created_at ASC`
+ );
+ return result.rows;
+ },
+
+ async getByUser(userId) {
+ const result = await pool.query(
+ `SELECT id, title, description, thumbnail_url, play_count, total_ratings, rating_sum, status, published_at
+ FROM games
+ WHERE creator_id = $1 AND status = 'published'
+ ORDER BY published_at DESC`,
+ [userId]
+ );
+ return result.rows;
+ }
+};
+
+module.exports = Games;
diff --git a/backend/src/models/notifications.js b/backend/src/models/notifications.js
new file mode 100644
index 0000000..8de8dc2
--- /dev/null
+++ b/backend/src/models/notifications.js
@@ -0,0 +1,121 @@
+const { pool } = require('./db');
+
+const Notifications = {
+ async create({ userId, type, title, message, link }) {
+ const result = await pool.query(
+ `INSERT INTO notifications (user_id, type, title, message, link)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [userId, type, title, message, link]
+ );
+ return result.rows[0];
+ },
+
+ async getForUser(userId, limit = 50, unreadOnly = false) {
+ const whereClause = unreadOnly ? 'AND is_read = false' : '';
+ const result = await pool.query(
+ `SELECT * FROM notifications
+ WHERE user_id = $1 ${whereClause}
+ ORDER BY created_at DESC
+ LIMIT $2`,
+ [userId, limit]
+ );
+ return result.rows;
+ },
+
+ async markAsRead(notificationId, userId) {
+ const result = await pool.query(
+ `UPDATE notifications
+ SET is_read = true
+ WHERE id = $1 AND user_id = $2
+ RETURNING *`,
+ [notificationId, userId]
+ );
+ return result.rows[0];
+ },
+
+ async markAllAsRead(userId) {
+ await pool.query(
+ 'UPDATE notifications SET is_read = true WHERE user_id = $1',
+ [userId]
+ );
+ },
+
+ async getUnreadCount(userId) {
+ const result = await pool.query(
+ 'SELECT COUNT(*) as count FROM notifications WHERE user_id = $1 AND is_read = false',
+ [userId]
+ );
+ return parseInt(result.rows[0].count);
+ },
+
+ async delete(notificationId, userId) {
+ await pool.query(
+ 'DELETE FROM notifications WHERE id = $1 AND user_id = $2',
+ [notificationId, userId]
+ );
+ },
+
+ // Helper methods to create specific notification types
+ async notifyNewFollower(userId, followerUsername) {
+ return this.create({
+ userId,
+ type: 'new_follower',
+ title: 'New Follower!',
+ message: `${followerUsername} started following you`,
+ link: `/creator/${followerUsername}`
+ });
+ },
+
+ async notifyGameMilestone(userId, gameTitle, milestone) {
+ return this.create({
+ userId,
+ type: 'milestone',
+ title: `${milestone} plays!`,
+ message: `Your game "${gameTitle}" reached ${milestone} plays!`,
+ link: null
+ });
+ },
+
+ async notifyAchievement(userId, achievementName, achievementIcon) {
+ return this.create({
+ userId,
+ type: 'achievement',
+ title: 'Achievement Unlocked!',
+ message: `${achievementIcon} ${achievementName}`,
+ link: '/achievements'
+ });
+ },
+
+ async notifyGameRemixed(userId, gameTitle, remixerUsername) {
+ return this.create({
+ userId,
+ type: 'remix',
+ title: 'Your game was remixed!',
+ message: `${remixerUsername} remixed "${gameTitle}"`,
+ link: `/creator/${remixerUsername}`
+ });
+ },
+
+ async notifyLevelUp(userId, newLevel, newBadge) {
+ return this.create({
+ userId,
+ type: 'level_up',
+ title: 'Level Up!',
+ message: `You're now Level ${newLevel}: ${newBadge}`,
+ link: '/profile'
+ });
+ },
+
+ async notifyGameReady(userId, gameTitle, gameId) {
+ return this.create({
+ userId,
+ type: 'game_ready',
+ title: 'Your Game is Ready!',
+ message: `"${gameTitle}" has been generated and is ready to play!`,
+ link: `/play/${gameId}?newGame=true`
+ });
+ }
+};
+
+module.exports = Notifications;
diff --git a/backend/src/models/plays.js b/backend/src/models/plays.js
new file mode 100644
index 0000000..abc4651
--- /dev/null
+++ b/backend/src/models/plays.js
@@ -0,0 +1,192 @@
+const { pool } = require('./db');
+
+const Plays = {
+ async create({ playerId, gameId, deviceFingerprint, ipAddress, isCreatorPlay = false }) {
+ const result = await pool.query(
+ `INSERT INTO plays (player_id, game_id, device_fingerprint, ip_address, is_creator_play)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [playerId, gameId, deviceFingerprint, ipAddress, isCreatorPlay]
+ );
+ return result.rows[0];
+ },
+
+ async finish(id, { score, durationSeconds, levelsCompleted = 0, tokenSpent = false }) {
+ const result = await pool.query(
+ `UPDATE plays SET ended_at = NOW(), score = $2, duration_seconds = $3,
+ levels_completed = $4, token_spent = $5
+ WHERE id = $1
+ RETURNING *`,
+ [id, score, durationSeconds, levelsCompleted, tokenSpent]
+ );
+ return result.rows[0];
+ },
+
+ async findById(id) {
+ const result = await pool.query('SELECT * FROM plays WHERE id = $1', [id]);
+ return result.rows[0];
+ },
+
+ async getPlayerPlaysToday(playerId) {
+ const result = await pool.query(
+ `SELECT COUNT(*) FROM plays
+ WHERE player_id = $1 AND started_at::date = CURRENT_DATE`,
+ [playerId]
+ );
+ return parseInt(result.rows[0].count);
+ }
+};
+
+const HighScores = {
+ async upsert({ gameId, playerId, score, difficulty = null }) {
+ // Check existing score
+ const existing = await pool.query(
+ 'SELECT score FROM high_scores WHERE game_id = $1 AND player_id = $2',
+ [gameId, playerId]
+ );
+ const previousScore = existing.rows[0]?.score || 0;
+ const isNew = score > previousScore;
+
+ const result = await pool.query(
+ `INSERT INTO high_scores (game_id, player_id, score, difficulty)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (game_id, player_id)
+ DO UPDATE SET score = GREATEST(high_scores.score, EXCLUDED.score),
+ difficulty = COALESCE(EXCLUDED.difficulty, high_scores.difficulty),
+ achieved_at = CASE WHEN EXCLUDED.score > high_scores.score THEN NOW() ELSE high_scores.achieved_at END
+ RETURNING *`,
+ [gameId, playerId, score, difficulty]
+ );
+ return { ...result.rows[0], isNew };
+ },
+
+ async getTopScores(gameId, limit = 50) {
+ const result = await pool.query(
+ `SELECT hs.score, hs.achieved_at, u.username, u.display_name
+ FROM high_scores hs
+ JOIN users u ON hs.player_id = u.id
+ WHERE hs.game_id = $1
+ ORDER BY hs.score DESC
+ LIMIT $2`,
+ [gameId, limit]
+ );
+ return result.rows;
+ }
+};
+
+const Ratings = {
+ async upsert({ gameId, userId, rating }) {
+ // Check if user already rated
+ const existing = await pool.query(
+ 'SELECT id, rating FROM ratings WHERE game_id = $1 AND user_id = $2',
+ [gameId, userId]
+ );
+
+ if (existing.rows[0]) {
+ // Update existing rating - adjust game stats
+ const oldRating = existing.rows[0].rating;
+ await pool.query(
+ 'UPDATE ratings SET rating = $3 WHERE game_id = $1 AND user_id = $2',
+ [gameId, userId, rating]
+ );
+ await pool.query(
+ 'UPDATE games SET rating_sum = rating_sum - $2 + $3 WHERE id = $1',
+ [gameId, oldRating, rating]
+ );
+ } else {
+ // New rating
+ await pool.query(
+ 'INSERT INTO ratings (game_id, user_id, rating) VALUES ($1, $2, $3)',
+ [gameId, userId, rating]
+ );
+ await pool.query(
+ 'UPDATE games SET total_ratings = total_ratings + 1, rating_sum = rating_sum + $2 WHERE id = $1',
+ [gameId, rating]
+ );
+ }
+ },
+
+ async getUserRating(gameId, userId) {
+ const result = await pool.query(
+ 'SELECT rating FROM ratings WHERE game_id = $1 AND user_id = $2',
+ [gameId, userId]
+ );
+ return result.rows[0]?.rating;
+ }
+};
+
+const DeveloperEarnings = {
+ async create({ developerId, gameId, tokensEarned, playId = null, source = 'play' }) {
+ await pool.query(
+ `INSERT INTO developer_earnings (developer_id, game_id, tokens_earned, play_id, source)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [developerId, gameId, tokensEarned, playId, source]
+ );
+ },
+
+ async getByDeveloper(developerId) {
+ const result = await pool.query(
+ `SELECT SUM(tokens_earned) as total_earned,
+ COUNT(*) as total_plays_monetized
+ FROM developer_earnings
+ WHERE developer_id = $1`,
+ [developerId]
+ );
+ return result.rows[0];
+ }
+};
+
+const GuestSessions = {
+ async findOrCreate(fingerprint, userId, userAgent, ipAddress) {
+ // Sanitize IP address - must be valid inet or null
+ const sanitizedIp = ipAddress && ipAddress !== 'unknown' && /^[\d.:a-fA-F]+$/.test(ipAddress)
+ ? ipAddress
+ : null;
+
+ // Check if exists
+ let result = await pool.query(
+ 'SELECT * FROM guest_sessions WHERE device_fingerprint = $1',
+ [fingerprint]
+ );
+
+ if (result.rows[0]) {
+ // Update last seen and user_id (in case guest was just created)
+ await pool.query(
+ 'UPDATE guest_sessions SET last_seen = NOW(), user_agent = $1, ip_address = COALESCE($2, ip_address), user_id = COALESCE($3, user_id) WHERE id = $4',
+ [userAgent, sanitizedIp, userId, result.rows[0].id]
+ );
+ return result.rows[0];
+ }
+
+ // Create new
+ result = await pool.query(
+ `INSERT INTO guest_sessions (device_fingerprint, user_id, user_agent, ip_address)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [fingerprint, userId, userAgent, sanitizedIp]
+ );
+ return result.rows[0];
+ },
+
+ async checkAndResetDailyTokens(fingerprint, dailyTokens = 5) {
+ // Check if tokens need to be reset for today
+ const result = await pool.query(
+ `UPDATE guest_sessions
+ SET tokens_granted_today = CASE
+ WHEN last_token_grant IS NULL OR last_token_grant < CURRENT_DATE THEN $2
+ ELSE tokens_granted_today
+ END,
+ last_token_grant = CURRENT_DATE
+ WHERE device_fingerprint = $1
+ RETURNING tokens_granted_today,
+ CASE WHEN last_token_grant IS NULL OR last_token_grant < CURRENT_DATE THEN $2 ELSE 0 END as tokens_granted`,
+ [fingerprint, dailyTokens]
+ );
+ return {
+ tokensGrantedToday: result.rows[0]?.tokens_granted_today ?? 0,
+ tokensGranted: result.rows[0]?.tokens_granted ?? 0
+ };
+ }
+};
+
+module.exports = { Plays, HighScores, Ratings, DeveloperEarnings, GuestSessions };
diff --git a/backend/src/models/themes.js b/backend/src/models/themes.js
new file mode 100644
index 0000000..e034ddc
--- /dev/null
+++ b/backend/src/models/themes.js
@@ -0,0 +1,95 @@
+const { pool } = require('./db');
+
+const Themes = {
+ async getCurrentTheme() {
+ const result = await pool.query(
+ `SELECT * FROM weekly_themes
+ WHERE start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
+ AND is_active = true
+ ORDER BY start_date DESC
+ LIMIT 1`
+ );
+ return result.rows[0];
+ },
+
+ async getUpcomingThemes(limit = 4) {
+ const result = await pool.query(
+ `SELECT * FROM weekly_themes
+ WHERE start_date > CURRENT_DATE
+ ORDER BY start_date ASC
+ LIMIT $1`,
+ [limit]
+ );
+ return result.rows;
+ },
+
+ async getPastThemes(limit = 8) {
+ const result = await pool.query(
+ `SELECT * FROM weekly_themes
+ WHERE end_date < CURRENT_DATE
+ ORDER BY end_date DESC
+ LIMIT $1`,
+ [limit]
+ );
+ return result.rows;
+ },
+
+ async getById(id) {
+ const result = await pool.query(
+ 'SELECT * FROM weekly_themes WHERE id = $1',
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ async create({ name, description, startDate, endDate, bonusXpMultiplier = 1.5 }) {
+ const result = await pool.query(
+ `INSERT INTO weekly_themes (name, description, start_date, end_date, bonus_xp_multiplier, is_active)
+ VALUES ($1, $2, $3, $4, $5, false)
+ RETURNING *`,
+ [name, description, startDate, endDate, bonusXpMultiplier]
+ );
+ return result.rows[0];
+ },
+
+ async activate(id) {
+ const result = await pool.query(
+ 'UPDATE weekly_themes SET is_active = true WHERE id = $1 RETURNING *',
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ async deactivate(id) {
+ const result = await pool.query(
+ 'UPDATE weekly_themes SET is_active = false WHERE id = $1 RETURNING *',
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ // Check if a game matches the current theme based on title/description keywords
+ async checkThemeMatch(gameTitle, gameDescription, answers) {
+ const currentTheme = await this.getCurrentTheme();
+ if (!currentTheme) return { matches: false };
+
+ // Simple keyword matching - check if theme name words appear in game content
+ const themeWords = currentTheme.name.toLowerCase().split(/\s+/);
+ const gameContent = `${gameTitle} ${gameDescription} ${JSON.stringify(answers || {})}`.toLowerCase();
+
+ const matchCount = themeWords.filter(word =>
+ word.length > 3 && gameContent.includes(word)
+ ).length;
+
+ // Match if at least one significant word matches
+ const matches = matchCount > 0;
+
+ return {
+ matches,
+ theme: currentTheme,
+ bonusMultiplier: matches ? parseFloat(currentTheme.bonus_xp_multiplier) : 1
+ };
+ }
+};
+
+module.exports = Themes;
diff --git a/backend/src/models/users.js b/backend/src/models/users.js
new file mode 100644
index 0000000..0a349f7
--- /dev/null
+++ b/backend/src/models/users.js
@@ -0,0 +1,227 @@
+const { pool } = require('./db');
+const bcrypt = require('bcrypt');
+
+const SALT_ROUNDS = 10;
+
+const Users = {
+ async create({ username, email, password, isGuest = false, deviceFingerprint = null }) {
+ const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null;
+
+ const result = await pool.query(
+ `INSERT INTO users (username, email, password_hash, display_name, is_guest, device_fingerprint)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, username, email, display_name, is_guest, tokens_balance, role, created_at`,
+ [username, email, passwordHash, username, isGuest, deviceFingerprint]
+ );
+ return result.rows[0];
+ },
+
+ async findByEmail(email) {
+ const result = await pool.query(
+ 'SELECT * FROM users WHERE email = $1 AND is_banned = false',
+ [email]
+ );
+ return result.rows[0];
+ },
+
+ async findByUsername(username) {
+ const result = await pool.query(
+ 'SELECT * FROM users WHERE username = $1',
+ [username]
+ );
+ return result.rows[0];
+ },
+
+ async findById(id) {
+ const result = await pool.query(
+ `SELECT id, username, email, display_name, is_guest, tokens_balance, total_tokens_earned,
+ role, is_teacher, created_at, is_banned, xp_total, creator_level, creator_badge,
+ daily_login_streak, weekly_creation_streak, avatar_data, is_verified,
+ onboarding_complete, onboarding_step, total_api_cost_cents, total_generations
+ FROM users WHERE id = $1`,
+ [id]
+ );
+ return result.rows[0];
+ },
+
+ async findByFingerprint(fingerprint) {
+ const result = await pool.query(
+ 'SELECT * FROM users WHERE device_fingerprint = $1 AND is_guest = true',
+ [fingerprint]
+ );
+ return result.rows[0];
+ },
+
+ async verifyPassword(user, password) {
+ if (!user.password_hash) return false;
+ return bcrypt.compare(password, user.password_hash);
+ },
+
+ async updatePassword(id, newPassword) {
+ const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
+ await pool.query(
+ 'UPDATE users SET password_hash = $2 WHERE id = $1',
+ [id, passwordHash]
+ );
+ },
+
+ async updateLastLogin(id) {
+ await pool.query(
+ 'UPDATE users SET last_login = NOW() WHERE id = $1',
+ [id]
+ );
+ },
+
+ async updateTokens(id, amount) {
+ const result = await pool.query(
+ `UPDATE users SET tokens_balance = tokens_balance + $2,
+ total_tokens_earned = CASE WHEN $2 > 0 THEN total_tokens_earned + $2 ELSE total_tokens_earned END
+ WHERE id = $1
+ RETURNING tokens_balance`,
+ [id, amount]
+ );
+ return result.rows[0]?.tokens_balance;
+ },
+
+ async getTokenBalance(id) {
+ const result = await pool.query(
+ 'SELECT tokens_balance FROM users WHERE id = $1',
+ [id]
+ );
+ return result.rows[0]?.tokens_balance ?? 0;
+ },
+
+ async update(id, { displayName, email }) {
+ const result = await pool.query(
+ `UPDATE users SET display_name = COALESCE($2, display_name), email = COALESCE($3, email)
+ WHERE id = $1
+ RETURNING id, username, email, display_name, tokens_balance, role`,
+ [id, displayName, email]
+ );
+ return result.rows[0];
+ },
+
+ async ban(id) {
+ await pool.query('UPDATE users SET is_banned = true WHERE id = $1', [id]);
+ },
+
+ async grantDailyTokens() {
+ // Grant 50 tokens to all users daily, cap at 100
+ await pool.query(
+ `UPDATE users SET tokens_balance = LEAST(tokens_balance + 50, 100)`
+ );
+ },
+
+ async getPublicProfile(userId) {
+ const result = await pool.query(
+ `SELECT u.id, u.username, u.display_name, u.avatar_data,
+ u.xp_total, u.creator_level, u.creator_badge, u.created_at,
+ (SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_count,
+ (SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays,
+ (SELECT COUNT(*) FROM follows WHERE following_id = u.id) as followers_count,
+ (SELECT COUNT(*) FROM follows WHERE follower_id = u.id) as following_count
+ FROM users u
+ WHERE u.id = $1 AND u.is_banned = false`,
+ [userId]
+ );
+ return result.rows[0];
+ },
+
+ async getPublicProfileByUsername(username) {
+ const result = await pool.query(
+ `SELECT u.id, u.username, u.display_name, u.avatar_data,
+ u.xp_total, u.creator_level, u.creator_badge, u.created_at,
+ (SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_count,
+ (SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays,
+ (SELECT COUNT(*) FROM follows WHERE following_id = u.id) as followers_count,
+ (SELECT COUNT(*) FROM follows WHERE follower_id = u.id) as following_count
+ FROM users u
+ WHERE u.username = $1 AND u.is_banned = false`,
+ [username]
+ );
+ return result.rows[0];
+ },
+
+ async updateLoginStreak(userId) {
+ const result = await pool.query(
+ `UPDATE users SET
+ daily_login_streak = CASE
+ WHEN last_login_date = CURRENT_DATE - INTERVAL '1 day' THEN daily_login_streak + 1
+ WHEN last_login_date = CURRENT_DATE THEN daily_login_streak
+ ELSE 1
+ END,
+ last_login_date = CURRENT_DATE,
+ last_login = NOW()
+ WHERE id = $1
+ RETURNING daily_login_streak`,
+ [userId]
+ );
+ return result.rows[0]?.daily_login_streak || 1;
+ },
+
+ async updateCreationStreak(userId) {
+ const result = await pool.query(
+ `UPDATE users SET
+ weekly_creation_streak = CASE
+ WHEN last_creation_date >= CURRENT_DATE - INTERVAL '7 days' THEN weekly_creation_streak + 1
+ ELSE 1
+ END,
+ last_creation_date = CURRENT_DATE
+ WHERE id = $1
+ RETURNING weekly_creation_streak`,
+ [userId]
+ );
+ return result.rows[0]?.weekly_creation_streak || 1;
+ },
+
+ async updateAvatar(userId, avatarData) {
+ const result = await pool.query(
+ 'UPDATE users SET avatar_data = $2 WHERE id = $1 RETURNING avatar_data',
+ [userId, JSON.stringify(avatarData)]
+ );
+ return result.rows[0]?.avatar_data;
+ },
+
+ async getCreatorStats(userId) {
+ const result = await pool.query(
+ `SELECT
+ (SELECT COUNT(*) FROM games WHERE creator_id = $1 AND status = 'published') as games_published,
+ (SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = $1) as total_plays,
+ (SELECT COALESCE(SUM(favorite_count), 0) FROM games WHERE creator_id = $1) as total_favorites,
+ (SELECT COALESCE(SUM(tokens_earned), 0) FROM games WHERE creator_id = $1) as tokens_earned,
+ (SELECT COUNT(*) FROM follows WHERE following_id = $1) as followers,
+ (SELECT COALESCE(AVG(CASE WHEN total_ratings > 0 THEN rating_sum::float / total_ratings END), 0)
+ FROM games WHERE creator_id = $1 AND total_ratings > 0) as avg_rating`,
+ [userId]
+ );
+ return result.rows[0];
+ },
+
+ async getOnboardingStatus(userId) {
+ const result = await pool.query(
+ `SELECT onboarding_complete, onboarding_step FROM users WHERE id = $1`,
+ [userId]
+ );
+ return result.rows[0];
+ },
+
+ async updateOnboardingStep(userId, step) {
+ const result = await pool.query(
+ `UPDATE users SET onboarding_step = $2 WHERE id = $1
+ RETURNING onboarding_step, onboarding_complete`,
+ [userId, step]
+ );
+ return result.rows[0];
+ },
+
+ async completeOnboarding(userId) {
+ const result = await pool.query(
+ `UPDATE users SET onboarding_complete = true, onboarding_step = 4 WHERE id = $1
+ RETURNING onboarding_step, onboarding_complete`,
+ [userId]
+ );
+ return result.rows[0];
+ }
+};
+
+module.exports = Users;
diff --git a/backend/src/models/xp.js b/backend/src/models/xp.js
new file mode 100644
index 0000000..6d6ae46
--- /dev/null
+++ b/backend/src/models/xp.js
@@ -0,0 +1,143 @@
+const { pool } = require('./db');
+
+// XP Values
+const XP_VALUES = {
+ GAME_CREATED: 50,
+ GAME_PLAYED_BY_OTHERS: 2,
+ GAME_FAVORITED: 5,
+ GAME_FIVE_STAR: 10,
+ GAME_REMIXED: 25,
+ PLAY_GAME: 5,
+ GET_HIGH_SCORE: 20
+};
+
+// Level thresholds
+const LEVELS = [
+ { level: 1, xp: 0, badge: 'Newbie', icon: '🌱' },
+ { level: 2, xp: 100, badge: 'Beginner', icon: '🎮' },
+ { level: 3, xp: 300, badge: 'Apprentice', icon: '🎯' },
+ { level: 4, xp: 600, badge: 'Composer', icon: '⭐' },
+ { level: 5, xp: 1000, badge: 'Pro Composer', icon: '🔥' },
+ { level: 6, xp: 1500, badge: 'Expert', icon: '💎' },
+ { level: 7, xp: 2500, badge: 'Maestro', icon: '🏆' },
+ { level: 8, xp: 4000, badge: 'Master', icon: '👑' },
+ { level: 9, xp: 6000, badge: 'Legend', icon: '🌟' },
+ { level: 10, xp: 10000, badge: 'Grandmaster', icon: '🎖️' }
+];
+
+const XP = {
+ XP_VALUES,
+ LEVELS,
+
+ getLevelInfo(xp) {
+ let levelInfo = LEVELS[0];
+ for (const level of LEVELS) {
+ if (xp >= level.xp) {
+ levelInfo = level;
+ } else {
+ break;
+ }
+ }
+ return levelInfo;
+ },
+
+ getNextLevelInfo(xp) {
+ for (const level of LEVELS) {
+ if (xp < level.xp) {
+ return level;
+ }
+ }
+ return null; // Max level
+ },
+
+ getProgressToNextLevel(xp) {
+ const currentLevel = this.getLevelInfo(xp);
+ const nextLevel = this.getNextLevelInfo(xp);
+
+ if (!nextLevel) return { progress: 100, xpNeeded: 0 };
+
+ const xpIntoCurrentLevel = xp - currentLevel.xp;
+ const xpForLevel = nextLevel.xp - currentLevel.xp;
+ const progress = Math.floor((xpIntoCurrentLevel / xpForLevel) * 100);
+
+ return {
+ progress,
+ xpNeeded: nextLevel.xp - xp,
+ currentLevelXp: xpIntoCurrentLevel,
+ levelXpRequired: xpForLevel
+ };
+ },
+
+ async award(userId, amount, reason, referenceId = null) {
+ // Check for active weekly theme bonus
+ const themeResult = await pool.query(
+ `SELECT bonus_xp_multiplier FROM weekly_themes
+ WHERE is_active = true AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
+ LIMIT 1`
+ );
+
+ let multiplier = 1;
+ if (themeResult.rows.length > 0 && reason === 'game_created') {
+ multiplier = parseFloat(themeResult.rows[0].bonus_xp_multiplier);
+ }
+
+ const finalAmount = Math.floor(amount * multiplier);
+
+ // Use the database function to award XP
+ const result = await pool.query(
+ 'SELECT * FROM award_xp($1, $2, $3, $4)',
+ [userId, finalAmount, reason, referenceId]
+ );
+
+ const xpResult = result.rows[0];
+
+ // If leveled up, create notification
+ if (xpResult.leveled_up) {
+ const Notifications = require('./notifications');
+ await Notifications.notifyLevelUp(userId, xpResult.new_level, xpResult.new_badge);
+ }
+
+ return {
+ xpAwarded: finalAmount,
+ newXp: xpResult.new_xp,
+ newLevel: xpResult.new_level,
+ newBadge: xpResult.new_badge,
+ leveledUp: xpResult.leveled_up,
+ multiplier: multiplier > 1 ? multiplier : null
+ };
+ },
+
+ async getTransactions(userId, limit = 50) {
+ const result = await pool.query(
+ `SELECT * FROM xp_transactions
+ WHERE user_id = $1
+ ORDER BY created_at DESC
+ LIMIT $2`,
+ [userId, limit]
+ );
+ return result.rows;
+ },
+
+ async getUserStats(userId) {
+ const user = await pool.query(
+ 'SELECT xp_total, creator_level, creator_badge FROM users WHERE id = $1',
+ [userId]
+ );
+
+ if (user.rows.length === 0) return null;
+
+ const { xp_total, creator_level, creator_badge } = user.rows[0];
+ const levelInfo = this.getLevelInfo(xp_total || 0);
+ const progress = this.getProgressToNextLevel(xp_total || 0);
+
+ return {
+ xp: xp_total || 0,
+ level: creator_level || 1,
+ badge: creator_badge || 'Newbie',
+ icon: levelInfo.icon,
+ ...progress
+ };
+ }
+};
+
+module.exports = XP;
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
new file mode 100644
index 0000000..26de53e
--- /dev/null
+++ b/backend/src/routes/admin.js
@@ -0,0 +1,726 @@
+const express = require('express');
+const { body, query } = require('express-validator');
+const Games = require('../models/games');
+const Users = require('../models/users');
+const AuditLog = require('../models/auditLog');
+const { pool } = require('../models/db');
+const { authenticate, requireRole } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+
+const router = express.Router();
+
+// Helper to get client IP address
+function getClientIp(req) {
+ return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
+ req.headers['x-real-ip'] ||
+ req.socket?.remoteAddress ||
+ req.ip;
+}
+
+// Get moderation queue
+router.get('/moderation-queue',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const games = await Games.getPendingReview();
+
+ res.json({
+ games: games.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ gameCode: g.game_code,
+ creatorUsername: g.creator_username,
+ createdAt: g.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Review game (approve or reject)
+router.post('/review/:gameId',
+ authenticate,
+ requireRole('admin'),
+ validate([
+ body('action').isIn(['approve', 'reject']),
+ body('reason').optional().trim().isLength({ max: 500 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { action, reason } = req.body;
+
+ const game = await Games.findById(gameId);
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.status !== 'pending_review') {
+ return res.status(400).json({ error: 'Game is not pending review' });
+ }
+
+ if (action === 'approve') {
+ await Games.approve(gameId);
+
+ // Log game approval
+ await AuditLog.create(
+ req.user.id,
+ 'game_approved',
+ 'game',
+ parseInt(gameId),
+ { gameTitle: game.title, creatorId: game.creator_id },
+ getClientIp(req)
+ );
+
+ res.json({ message: 'Game approved and published' });
+ } else {
+ if (!reason) {
+ return res.status(400).json({ error: 'Rejection reason required' });
+ }
+ await Games.reject(gameId, reason);
+
+ // Log game rejection
+ await AuditLog.create(
+ req.user.id,
+ 'game_rejected',
+ 'game',
+ parseInt(gameId),
+ { gameTitle: game.title, creatorId: game.creator_id, reason },
+ getClientIp(req)
+ );
+
+ res.json({ message: 'Game rejected', reason });
+ }
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get game reports
+router.get('/reports',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const result = await pool.query(
+ `SELECT r.*, g.title as game_title, g.creator_id, g.status as game_status,
+ u.username as reporter_username,
+ creator.username as creator_username
+ FROM game_reports r
+ JOIN games g ON r.game_id = g.id
+ JOIN users u ON r.reporter_id = u.id
+ JOIN users creator ON g.creator_id = creator.id
+ WHERE r.status = 'pending'
+ ORDER BY r.created_at DESC`
+ );
+
+ res.json({
+ reports: result.rows.map(r => ({
+ id: r.id,
+ gameId: r.game_id,
+ gameTitle: r.game_title,
+ gameStatus: r.game_status,
+ creatorId: r.creator_id,
+ creatorUsername: r.creator_username,
+ reporterUsername: r.reporter_username,
+ reason: r.reason,
+ description: r.description,
+ createdAt: r.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Resolve a report
+router.post('/reports/:reportId/resolve',
+ authenticate,
+ requireRole('admin'),
+ validate([
+ body('action').isIn(['dismiss', 'warn', 'unpublish', 'ban']),
+ body('notes').optional().trim().isLength({ max: 500 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { reportId } = req.params;
+ const { action, notes } = req.body;
+
+ // Get the report
+ const reportResult = await pool.query(
+ `SELECT r.*, g.creator_id, g.title as game_title
+ FROM game_reports r
+ JOIN games g ON r.game_id = g.id
+ WHERE r.id = $1`,
+ [reportId]
+ );
+
+ if (reportResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Report not found' });
+ }
+
+ const report = reportResult.rows[0];
+
+ // Mark report as resolved
+ await pool.query(
+ `UPDATE game_reports SET status = 'resolved', reviewed_by = $1, reviewed_at = NOW()
+ WHERE id = $2`,
+ [req.user.id, reportId]
+ );
+
+ // Take action based on decision
+ if (action === 'unpublish') {
+ await pool.query(
+ `UPDATE games SET status = 'rejected', rejection_reason = $1 WHERE id = $2`,
+ [notes || 'Content policy violation', report.game_id]
+ );
+ } else if (action === 'ban') {
+ await Users.ban(report.creator_id);
+ // Also unpublish the game
+ await pool.query(
+ `UPDATE games SET status = 'rejected', rejection_reason = 'Creator banned' WHERE id = $1`,
+ [report.game_id]
+ );
+ }
+
+ // Log report resolution
+ await AuditLog.create(
+ req.user.id,
+ 'report_resolved',
+ 'report',
+ parseInt(reportId),
+ {
+ action,
+ notes,
+ gameId: report.game_id,
+ gameTitle: report.game_title,
+ creatorId: report.creator_id,
+ reason: report.reason
+ },
+ getClientIp(req)
+ );
+
+ res.json({
+ message: `Report resolved with action: ${action}`,
+ action,
+ reportId: parseInt(reportId)
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Unpublish a game (without a report)
+router.post('/unpublish/:gameId',
+ authenticate,
+ requireRole('admin'),
+ validate([
+ body('reason').trim().isLength({ min: 1, max: 500 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { reason } = req.body;
+
+ const game = await Games.findById(gameId);
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ await pool.query(
+ `UPDATE games SET status = 'rejected', rejection_reason = $1 WHERE id = $2`,
+ [reason, gameId]
+ );
+
+ // Log game unpublishing
+ await AuditLog.create(
+ req.user.id,
+ 'game_unpublished',
+ 'game',
+ parseInt(gameId),
+ { gameTitle: game.title, creatorId: game.creator_id, reason },
+ getClientIp(req)
+ );
+
+ res.json({ message: 'Game unpublished', gameId: parseInt(gameId) });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Admin dashboard stats
+router.get('/stats',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const [pending, reports, users, games, todayPlays, spending] = await Promise.all([
+ pool.query(`SELECT COUNT(*) FROM games WHERE status = 'pending_review'`),
+ pool.query(`SELECT COUNT(*) FROM game_reports WHERE status = 'pending'`),
+ pool.query(`SELECT COUNT(*) FROM users`),
+ pool.query(`SELECT COUNT(*) FROM games WHERE status = 'published'`),
+ pool.query(`SELECT COUNT(*) FROM plays WHERE started_at >= CURRENT_DATE`),
+ pool.query(`SELECT
+ COALESCE(SUM(total_api_cost_cents), 0) as total_cost,
+ COALESCE(SUM(total_generations), 0) as total_generations,
+ COALESCE(SUM(CASE WHEN created_at >= CURRENT_DATE - INTERVAL '30 days' THEN total_api_cost_cents ELSE 0 END), 0) as cost_30d
+ FROM users`)
+ ]);
+
+ res.json({
+ stats: {
+ pendingReview: parseInt(pending.rows[0].count),
+ pendingReports: parseInt(reports.rows[0].count),
+ totalUsers: parseInt(users.rows[0].count),
+ publishedGames: parseInt(games.rows[0].count),
+ todayPlays: parseInt(todayPlays.rows[0].count),
+ totalApiCostCents: parseInt(spending.rows[0].total_cost),
+ totalGenerations: parseInt(spending.rows[0].total_generations),
+ cost30dCents: parseInt(spending.rows[0].cost_30d)
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Ban user
+router.post('/ban-user/:userId',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const { userId } = req.params;
+
+ const user = await Users.findById(userId);
+ if (!user) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ if (user.role === 'admin') {
+ return res.status(400).json({ error: 'Cannot ban admin users' });
+ }
+
+ await Users.ban(userId);
+
+ // Log user ban
+ await AuditLog.create(
+ req.user.id,
+ 'user_banned',
+ 'user',
+ parseInt(userId),
+ { username: user.username, email: user.email },
+ getClientIp(req)
+ );
+
+ res.json({ message: 'User banned', userId: parseInt(userId) });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get audit logs
+router.get('/audit-log',
+ authenticate,
+ requireRole('admin'),
+ validate([
+ query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
+ query('adminId').optional().isInt().toInt(),
+ query('targetType').optional().isIn(['game', 'user', 'report']),
+ query('targetId').optional().isInt().toInt()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { limit = 50, adminId, targetType, targetId } = req.query;
+
+ let logs;
+ if (adminId) {
+ logs = await AuditLog.getByAdmin(adminId, limit);
+ } else if (targetType && targetId) {
+ logs = await AuditLog.getByTarget(targetType, targetId);
+ } else {
+ logs = await AuditLog.getRecent(limit);
+ }
+
+ res.json({
+ logs: logs.map(log => ({
+ id: log.id,
+ adminId: log.admin_id,
+ adminUsername: log.admin_username,
+ action: log.action,
+ targetType: log.target_type,
+ targetId: log.target_id,
+ details: log.details,
+ ipAddress: log.ip_address,
+ createdAt: log.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Game diagnostics search
+router.get('/game-diagnostics',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const { search, gameId } = req.query;
+
+ let queryStr;
+ let params = [];
+
+ if (gameId) {
+ // Exact game ID search
+ queryStr = `
+ SELECT g.id, g.title, g.status, g.game_code, g.creation_prompt,
+ g.generation_error, g.rejection_reason,
+ g.claude_input_tokens, g.claude_output_tokens,
+ g.api_cost_cents, g.total_cost_cents, g.generations_count,
+ g.created_at, g.published_at, g.game_style,
+ u.username as creator_username, u.email as creator_email
+ FROM games g
+ LEFT JOIN users u ON g.creator_id = u.id
+ WHERE g.id = $1
+ `;
+ params = [gameId];
+ } else if (search) {
+ // Search by title or creator username
+ queryStr = `
+ SELECT g.id, g.title, g.status, g.game_code, g.creation_prompt,
+ g.generation_error, g.rejection_reason,
+ g.claude_input_tokens, g.claude_output_tokens,
+ g.api_cost_cents, g.total_cost_cents, g.generations_count,
+ g.created_at, g.published_at, g.game_style,
+ u.username as creator_username, u.email as creator_email
+ FROM games g
+ LEFT JOIN users u ON g.creator_id = u.id
+ WHERE g.title ILIKE $1 OR u.username ILIKE $1
+ ORDER BY g.created_at DESC
+ LIMIT 50
+ `;
+ params = [`%${search}%`];
+ } else {
+ // Default: show problematic games
+ queryStr = `
+ SELECT g.id, g.title, g.status, g.game_code, g.creation_prompt,
+ g.generation_error, g.rejection_reason,
+ g.claude_input_tokens, g.claude_output_tokens,
+ g.api_cost_cents, g.total_cost_cents, g.generations_count,
+ g.created_at, g.published_at, g.game_style,
+ u.username as creator_username, u.email as creator_email
+ FROM games g
+ LEFT JOIN users u ON g.creator_id = u.id
+ WHERE g.generation_error IS NOT NULL OR g.status IN ('draft', 'generating')
+ ORDER BY g.created_at DESC
+ LIMIT 50
+ `;
+ }
+
+ const result = await pool.query(queryStr, params);
+
+ res.json({
+ games: result.rows.map(g => ({
+ id: g.id,
+ title: g.title,
+ status: g.status,
+ creatorUsername: g.creator_username,
+ creatorEmail: g.creator_email,
+ error: g.generation_error,
+ rejectionReason: g.rejection_reason,
+ hasCode: !!g.game_code,
+ codeLength: g.game_code ? g.game_code.length : 0,
+ codePreview: g.game_code ? g.game_code.substring(0, 500) : null,
+ prompt: g.creation_prompt ? g.creation_prompt.substring(0, 1000) : null,
+ inputTokens: g.claude_input_tokens || 0,
+ outputTokens: g.claude_output_tokens || 0,
+ apiCostCents: g.api_cost_cents || 0,
+ totalCostCents: g.total_cost_cents || 0,
+ generationsCount: g.generations_count || 0,
+ gameStyle: g.game_style,
+ createdAt: g.created_at,
+ publishedAt: g.published_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Clear Cloudflare cache
+router.post('/clear-cache',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const zoneId = process.env.CLOUDFLARE_ZONE_ID;
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
+
+ if (!zoneId || !apiToken) {
+ return res.status(400).json({
+ error: 'Cloudflare credentials not configured',
+ details: 'Set CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN in environment'
+ });
+ }
+
+ // Purge Cloudflare cache
+ const response = await fetch(
+ `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
+ {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${apiToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ purge_everything: true })
+ }
+ );
+
+ const result = await response.json();
+
+ if (!result.success) {
+ console.error('Cloudflare purge failed:', result.errors);
+ return res.status(500).json({
+ error: 'Cloudflare purge failed',
+ details: result.errors
+ });
+ }
+
+ // Log cache clear action
+ await AuditLog.create(
+ req.user.id,
+ 'cache_cleared',
+ 'system',
+ null,
+ { type: 'cloudflare', purgedAt: new Date().toISOString() },
+ getClientIp(req)
+ );
+
+ res.json({
+ success: true,
+ message: 'Cloudflare cache purged successfully',
+ purgedAt: new Date().toISOString()
+ });
+ } catch (error) {
+ console.error('Cache clear error:', error);
+ next(error);
+ }
+ }
+);
+
+// System health check
+router.get('/system-health',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const checks = {};
+ const startTime = Date.now();
+
+ // 1. Database connectivity and stats
+ try {
+ const dbStats = await pool.query(`
+ SELECT
+ (SELECT COUNT(*) FROM users) as total_users,
+ (SELECT COUNT(*) FROM games WHERE status = 'published') as published_games,
+ (SELECT COUNT(*) FROM games WHERE status = 'pending_review') as pending_review,
+ (SELECT COUNT(*) FROM games WHERE status = 'draft') as draft_games,
+ (SELECT COUNT(*) FROM games WHERE status = 'generating') as generating,
+ (SELECT COUNT(*) FROM games WHERE generation_error IS NOT NULL) as games_with_errors,
+ (SELECT COUNT(*) FROM plays) as total_plays,
+ (SELECT COUNT(*) FROM plays WHERE started_at >= CURRENT_DATE) as today_plays,
+ (SELECT COUNT(*) FROM game_reports WHERE status = 'pending') as pending_reports,
+ (SELECT COUNT(*) FROM bug_reports WHERE status = 'open') as open_bugs,
+ (SELECT COALESCE(SUM(total_api_cost_cents), 0) FROM users) as total_api_cost_cents
+ `);
+ checks.database = {
+ status: 'healthy',
+ stats: dbStats.rows[0]
+ };
+ } catch (dbError) {
+ checks.database = { status: 'error', error: dbError.message };
+ }
+
+ // 2. Memory usage
+ try {
+ const memUsage = process.memoryUsage();
+ checks.memory = {
+ status: 'healthy',
+ heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
+ heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
+ rss: Math.round(memUsage.rss / 1024 / 1024),
+ external: Math.round(memUsage.external / 1024 / 1024)
+ };
+ // Warn if heap usage is over 80%
+ if (memUsage.heapUsed / memUsage.heapTotal > 0.8) {
+ checks.memory.status = 'warning';
+ checks.memory.message = 'High memory usage';
+ }
+ } catch (memError) {
+ checks.memory = { status: 'error', error: memError.message };
+ }
+
+ // 3. Environment checks
+ checks.environment = {
+ status: 'healthy',
+ nodeVersion: process.version,
+ uptime: Math.round(process.uptime()),
+ uptimeFormatted: formatUptime(process.uptime()),
+ claudeApiKey: !!process.env.CLAUDE_API_KEY,
+ cloudflareConfigured: !!(process.env.CLOUDFLARE_ZONE_ID && process.env.CLOUDFLARE_API_TOKEN),
+ vapidConfigured: !!process.env.VAPID_PUBLIC_KEY,
+ smtpConfigured: !!process.env.SMTP_HOST
+ };
+
+ // 4. Disk space (approximate via temp file)
+ try {
+ const fs = require('fs');
+ const os = require('os');
+ const { execSync } = require('child_process');
+ const dfOutput = execSync('df -h / | tail -1').toString().trim();
+ const parts = dfOutput.split(/\s+/);
+ checks.disk = {
+ status: 'healthy',
+ total: parts[1],
+ used: parts[2],
+ available: parts[3],
+ usagePercent: parseInt(parts[4])
+ };
+ if (checks.disk.usagePercent > 90) {
+ checks.disk.status = 'critical';
+ checks.disk.message = 'Disk space critically low';
+ } else if (checks.disk.usagePercent > 80) {
+ checks.disk.status = 'warning';
+ checks.disk.message = 'Disk space running low';
+ }
+ } catch (diskError) {
+ checks.disk = { status: 'unknown', error: 'Could not check disk space' };
+ }
+
+ // 5. API endpoints health
+ const endpoints = [
+ { name: 'Arcade Games', path: '/api/arcade/games?limit=1' },
+ { name: 'Game Styles', path: '/api/creation/styles' },
+ { name: 'Leaderboard', path: '/api/leaderboard/players?limit=1' },
+ { name: 'Push VAPID', path: '/api/push/vapid-key' }
+ ];
+
+ checks.endpoints = { status: 'healthy', results: [] };
+ for (const ep of endpoints) {
+ try {
+ const response = await fetch(`http://localhost:3000${ep.path}`);
+ checks.endpoints.results.push({
+ name: ep.name,
+ status: response.ok ? 'ok' : 'error',
+ code: response.status
+ });
+ if (!response.ok) checks.endpoints.status = 'warning';
+ } catch (epError) {
+ checks.endpoints.results.push({
+ name: ep.name,
+ status: 'error',
+ error: epError.message
+ });
+ checks.endpoints.status = 'error';
+ }
+ }
+
+ // 6. Recent activity
+ try {
+ const recentActivity = await pool.query(`
+ SELECT
+ (SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '24 hours') as new_users_24h,
+ (SELECT COUNT(*) FROM games WHERE created_at >= NOW() - INTERVAL '24 hours') as new_games_24h,
+ (SELECT COUNT(*) FROM plays WHERE started_at >= NOW() - INTERVAL '1 hour') as plays_last_hour
+ `);
+ checks.activity = {
+ status: 'healthy',
+ ...recentActivity.rows[0]
+ };
+ } catch (actError) {
+ checks.activity = { status: 'error', error: actError.message };
+ }
+
+ // Calculate overall status
+ const statuses = Object.values(checks).map(c => c.status);
+ let overallStatus = 'healthy';
+ if (statuses.includes('error') || statuses.includes('critical')) {
+ overallStatus = 'critical';
+ } else if (statuses.includes('warning')) {
+ overallStatus = 'warning';
+ }
+
+ const responseTime = Date.now() - startTime;
+
+ res.json({
+ status: overallStatus,
+ timestamp: new Date().toISOString(),
+ responseTimeMs: responseTime,
+ checks
+ });
+ } catch (error) {
+ console.error('System health check error:', error);
+ next(error);
+ }
+ }
+);
+
+function formatUptime(seconds) {
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+}
+
+// Create admin user (one-time setup endpoint)
+router.post('/setup-admin',
+ validate([
+ body('username').trim().isLength({ min: 3, max: 30 }),
+ body('email').isEmail().normalizeEmail(),
+ body('password').isLength({ min: 8 }),
+ body('setupKey').equals(process.env.JWT_SECRET).withMessage('Invalid setup key')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { username, email, password } = req.body;
+
+ // Check if any admin exists
+ const existingAdmin = await pool.query(
+ "SELECT id FROM users WHERE role = 'admin' LIMIT 1"
+ );
+
+ if (existingAdmin.rows.length > 0) {
+ return res.status(400).json({ error: 'Admin already exists' });
+ }
+
+ const user = await Users.create({ username, email, password });
+
+ // Promote to admin
+ await pool.query("UPDATE users SET role = 'admin' WHERE id = $1", [user.id]);
+
+ res.status(201).json({
+ message: 'Admin account created',
+ username: user.username
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/arcade.js b/backend/src/routes/arcade.js
new file mode 100644
index 0000000..7b5fa19
--- /dev/null
+++ b/backend/src/routes/arcade.js
@@ -0,0 +1,174 @@
+const express = require('express');
+const { query } = require('express-validator');
+const Games = require('../models/games');
+const { HighScores, Ratings } = require('../models/plays');
+const { optionalAuth } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+
+const router = express.Router();
+
+// List published games
+router.get('/games',
+ optionalAuth,
+ validate([
+ query('sort').optional().isIn(['trending', 'new', 'top-rated']),
+ query('page').optional().isInt({ min: 1 }),
+ query('limit').optional().isInt({ min: 1, max: 50 }),
+ query('search').optional().trim().isLength({ max: 100 }),
+ query('difficulty').optional().isIn(['easy', 'medium', 'hard', ''])
+ ]),
+ async (req, res, next) => {
+ try {
+ const {
+ sort = 'trending',
+ page = 1,
+ limit = 20,
+ search = '',
+ difficulty = ''
+ } = req.query;
+
+ const games = await Games.getPublished({
+ sort,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ search,
+ difficulty
+ });
+
+ // Get user's favorites if logged in
+ let userFavorites = new Set();
+ if (req.user) {
+ const Favorites = require('../models/favorites');
+ const favoriteIds = await Favorites.getUserFavoriteIds(req.user.id);
+ userFavorites = new Set(favoriteIds);
+ }
+
+ res.json({
+ games: games.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ thumbnailUrl: g.thumbnail_url,
+ thumbnailData: g.thumbnail_data,
+ playCount: g.play_count,
+ averageRating: parseFloat(g.average_rating) || 0,
+ totalRatings: g.total_ratings,
+ creatorId: g.creator_id,
+ creatorUsername: g.creator_username,
+ creatorDisplayName: g.creator_display_name,
+ publishedAt: g.published_at,
+ isFeatured: g.is_featured,
+ gameCode: g.game_code,
+ favoriteCount: g.favorite_count || 0,
+ isFavorited: userFavorites.has(g.id),
+ difficultyTags: g.difficulty_tags || [],
+ gameStyle: g.game_style
+ })),
+ page: parseInt(page),
+ hasMore: games.length === parseInt(limit)
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get public game details
+router.get('/games/:gameId',
+ optionalAuth,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game || game.status !== 'published') {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ // Get user's rating, play count, and favorite status if logged in
+ let userRating = null;
+ let userPlayCount = 0;
+ let isFavorited = false;
+ if (req.user) {
+ userRating = await Ratings.getUserRating(gameId, req.user.id);
+ const playCountResult = await require('../models/db').pool.query(
+ 'SELECT COUNT(*) as count FROM plays WHERE game_id = $1 AND player_id = $2',
+ [gameId, req.user.id]
+ );
+ userPlayCount = parseInt(playCountResult.rows[0].count) || 0;
+
+ // Check if favorited
+ const Favorites = require('../models/favorites');
+ const userFavorites = await Favorites.getUserFavoriteIds(req.user.id);
+ isFavorited = userFavorites.includes(parseInt(gameId));
+ }
+
+ res.json({
+ game: {
+ id: game.id,
+ title: game.title,
+ description: game.description,
+ thumbnailUrl: game.thumbnail_url,
+ thumbnailData: game.thumbnail_data,
+ playCount: game.play_count,
+ averageRating: game.total_ratings > 0
+ ? game.rating_sum / game.total_ratings
+ : 0,
+ totalRatings: game.total_ratings,
+ creatorId: game.creator_id,
+ creatorUsername: game.creator_username,
+ creatorDisplayName: game.creator_display_name,
+ publishedAt: game.published_at,
+ isFeatured: game.is_featured,
+ prompt: game.prompt,
+ creationPrompt: game.creation_prompt,
+ originalPrompt: game.original_prompt,
+ promptWasEdited: game.prompt_was_edited || false,
+ promptEditCount: game.prompt_edit_count || 0,
+ promptAnswers: game.prompt_answers,
+ gameStyle: game.game_style,
+ difficultyTags: game.difficulty_tags || [],
+ allowRemix: game.allow_remix,
+ remixedFromId: game.remixed_from_id
+ },
+ userRating,
+ userPlayCount,
+ isFavorited
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get high scores for a game
+router.get('/games/:gameId/high-scores',
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game || game.status !== 'published') {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const scores = await HighScores.getTopScores(gameId, 50);
+
+ res.json({
+ gameId: parseInt(gameId),
+ gameTitle: game.title,
+ highScores: scores.map((s, i) => ({
+ rank: i + 1,
+ username: s.username,
+ displayName: s.display_name,
+ score: s.score,
+ achievedAt: s.achieved_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
new file mode 100644
index 0000000..6357e6e
--- /dev/null
+++ b/backend/src/routes/auth.js
@@ -0,0 +1,391 @@
+const express = require('express');
+const { body } = require('express-validator');
+const Users = require('../models/users');
+const XP = require('../models/xp');
+const Achievements = require('../models/achievements');
+const { GuestSessions } = require('../models/plays');
+const { generateToken } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const { validateUsername, generateUsernameSuggestions } = require('../utils/usernames');
+const { filterUsername, logFilterAction } = require('../utils/contentFilter');
+const { sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email');
+const crypto = require('crypto');
+const { pool } = require('../models/db');
+
+const router = express.Router();
+
+// Get username suggestions
+router.get('/username-suggestions', (req, res) => {
+ const suggestions = generateUsernameSuggestions(5);
+ res.json({ suggestions });
+});
+
+// Validate username
+router.post('/validate-username',
+ validate([
+ body('username').trim().isLength({ min: 1 })
+ ]),
+ async (req, res) => {
+ const { username } = req.body;
+ const validation = validateUsername(username);
+
+ // Also check if username is taken
+ if (validation.valid) {
+ const existing = await Users.findByUsername(username);
+ if (existing) {
+ validation.valid = false;
+ validation.errors.push('This username is already taken');
+ validation.suggestions = generateUsernameSuggestions(5);
+ }
+ }
+
+ res.json(validation);
+ }
+);
+
+// Register
+router.post('/register',
+ validate([
+ body('username')
+ .trim()
+ .isLength({ min: 3, max: 30 })
+ .withMessage('Username must be 3-30 characters')
+ .matches(/^[a-zA-Z0-9_]+$/)
+ .withMessage('Username can only contain letters, numbers, and underscores'),
+ body('email')
+ .isEmail()
+ .normalizeEmail()
+ .withMessage('Valid email required'),
+ body('password')
+ .isLength({ min: 6 })
+ .withMessage('Password must be at least 6 characters'),
+ body('fingerprint')
+ .optional()
+ .isLength({ min: 10, max: 255 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { username, email, password, fingerprint } = req.body;
+
+ // Validate username (no real names)
+ const usernameValidation = validateUsername(username);
+ if (!usernameValidation.valid) {
+ return res.status(400).json({
+ error: usernameValidation.errors[0],
+ message: 'Real names are not allowed for privacy protection. Please choose a fun gaming name!',
+ suggestions: usernameValidation.suggestions
+ });
+ }
+
+ // Content filter for inappropriate usernames
+ const usernameFilter = filterUsername(username);
+ if (usernameFilter.blocked) {
+ logFilterAction(null, 'username', username, usernameFilter);
+ return res.status(400).json({
+ error: usernameFilter.message,
+ blocked: true,
+ suggestions: generateUsernameSuggestions(5)
+ });
+ }
+
+ // Check if username exists
+ const existingUsername = await Users.findByUsername(username);
+ if (existingUsername) {
+ return res.status(409).json({
+ error: 'Username already taken',
+ suggestions: generateUsernameSuggestions(5)
+ });
+ }
+
+ const existingEmail = await Users.findByEmail(email);
+ if (existingEmail) {
+ return res.status(409).json({ error: 'Email already registered' });
+ }
+
+ const user = await Users.create({ username, email, password });
+ const token = generateToken(user.id);
+
+ // Migrate guest play history if fingerprint provided
+ if (fingerprint) {
+ const guestUser = await Users.findByFingerprint(fingerprint);
+ if (guestUser && guestUser.is_guest) {
+ // Transfer plays from guest to new user
+ await pool.query(
+ 'UPDATE plays SET player_id = $1 WHERE player_id = $2',
+ [user.id, guestUser.id]
+ );
+ // Transfer high scores
+ await pool.query(
+ 'UPDATE high_scores SET player_id = $1 WHERE player_id = $2',
+ [user.id, guestUser.id]
+ );
+ // Transfer ratings
+ await pool.query(
+ 'UPDATE ratings SET user_id = $1 WHERE user_id = $2',
+ [user.id, guestUser.id]
+ );
+ // Add guest's tokens to new user (but cap at reasonable amount)
+ const guestTokens = Math.min(guestUser.tokens_balance || 0, 50);
+ if (guestTokens > 0) {
+ await Users.updateTokens(user.id, guestTokens);
+ }
+ // Delete guest user (cleanup)
+ await pool.query('DELETE FROM users WHERE id = $1', [guestUser.id]);
+ // Clean up guest session
+ await pool.query('DELETE FROM guest_sessions WHERE device_fingerprint = $1', [fingerprint]);
+ console.log(`Migrated guest ${guestUser.id} to new user ${user.id}`);
+ }
+ }
+
+ // Get XP stats (will be defaults for new user)
+ const xpStats = await XP.getUserStats(user.id);
+
+ // Refresh user data after potential token migration
+ const refreshedUser = await Users.findById(user.id);
+
+ // Send welcome email (don't wait, don't fail registration)
+ sendWelcomeEmail({ email, username, displayName: user.display_name }).catch(err => {
+ console.error('Welcome email failed:', err.message);
+ });
+
+ res.status(201).json({
+ message: 'Account created successfully',
+ user: {
+ id: user.id,
+ username: user.username,
+ displayName: user.display_name,
+ tokensBalance: refreshedUser.tokens_balance,
+ role: user.role,
+ isTeacher: user.is_teacher || user.role === 'teacher',
+ xp: xpStats,
+ onboardingComplete: false,
+ onboardingStep: 0
+ },
+ token
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Login
+router.post('/login',
+ validate([
+ body('email').isEmail().normalizeEmail(),
+ body('password').notEmpty()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { email, password } = req.body;
+
+ const user = await Users.findByEmail(email);
+ if (!user) {
+ return res.status(401).json({ error: 'Invalid email or password' });
+ }
+
+ const validPassword = await Users.verifyPassword(user, password);
+ if (!validPassword) {
+ return res.status(401).json({ error: 'Invalid email or password' });
+ }
+
+ // Update login streak and get new streak value
+ const loginStreak = await Users.updateLoginStreak(user.id);
+ const token = generateToken(user.id);
+
+ // Check for streak achievements
+ const streakAchievements = await Achievements.checkAndAward(user.id, 'login_streak', loginStreak);
+
+ // Get XP stats for proper level display
+ const xpStats = await XP.getUserStats(user.id);
+
+ // Get updated user data for streaks
+ const updatedUser = await Users.findById(user.id);
+
+ res.json({
+ user: {
+ id: user.id,
+ username: user.username,
+ displayName: user.display_name,
+ tokensBalance: user.tokens_balance,
+ role: user.role,
+ isTeacher: user.is_teacher || user.role === 'teacher',
+ xp: xpStats,
+ loginStreak: updatedUser.daily_login_streak,
+ creationStreak: updatedUser.weekly_creation_streak,
+ onboardingComplete: updatedUser.onboarding_complete || false,
+ onboardingStep: updatedUser.onboarding_step || 0
+ },
+ token,
+ newAchievements: streakAchievements.length > 0 ? streakAchievements : undefined
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Guest login
+router.post('/guest-login',
+ validate([
+ body('fingerprint')
+ .isLength({ min: 10, max: 255 })
+ .withMessage('Valid device fingerprint required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { fingerprint } = req.body;
+ const userAgent = req.headers['user-agent'] || '';
+ const ipAddress = req.ip || req.connection.remoteAddress;
+
+ // Check for existing guest user with this fingerprint
+ let user = await Users.findByFingerprint(fingerprint);
+
+ if (!user) {
+ // Create guest session tracking (pass null for userId since user doesn't exist yet)
+ await GuestSessions.findOrCreate(fingerprint, null, userAgent, ipAddress);
+
+ // Create guest user with fun random name
+ const { generateUsername } = require('../utils/usernames');
+ const guestUsername = generateUsername();
+ user = await Users.create({
+ username: guestUsername,
+ isGuest: true,
+ deviceFingerprint: fingerprint
+ });
+ }
+
+ // Update login streak for guest users too
+ const loginStreak = await Users.updateLoginStreak(user.id);
+ const token = generateToken(user.id);
+
+ res.json({
+ user: {
+ id: user.id,
+ username: user.username,
+ displayName: user.display_name || 'Guest Player',
+ tokensBalance: user.tokens_balance,
+ isGuest: true,
+ role: user.role,
+ isTeacher: user.is_teacher || user.role === 'teacher',
+ loginStreak,
+ onboardingComplete: user.onboarding_complete || false,
+ onboardingStep: user.onboarding_step || 0
+ },
+ token
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Request password reset
+router.post('/forgot-password',
+ validate([
+ body('email').isEmail().normalizeEmail().withMessage('Valid email required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { email } = req.body;
+
+ // Always return success to prevent email enumeration
+ const successResponse = {
+ message: 'If an account with that email exists, a password reset link has been sent.'
+ };
+
+ const user = await Users.findByEmail(email);
+ if (!user) {
+ return res.json(successResponse);
+ }
+
+ // Can't reset password for guest accounts
+ if (user.is_guest) {
+ return res.json(successResponse);
+ }
+
+ // Generate secure token
+ const token = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
+
+ // Store reset token
+ await pool.query(
+ `INSERT INTO password_reset_tokens (user_id, token, expires_at)
+ VALUES ($1, $2, $3)`,
+ [user.id, token, expiresAt]
+ );
+
+ // Send reset email (don't wait, don't fail)
+ const resetUrl = `${process.env.FRONTEND_URL || 'https://gamercomp.com'}/reset-password?token=${token}`;
+ sendPasswordResetEmail({
+ email: user.email,
+ username: user.username,
+ resetUrl
+ }).catch(err => {
+ console.error('Password reset email failed:', err.message);
+ });
+
+ res.json(successResponse);
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Reset password with token
+router.post('/reset-password',
+ validate([
+ body('token').isLength({ min: 64, max: 64 }).withMessage('Invalid reset token'),
+ body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { token, password } = req.body;
+
+ // Find valid, unused token
+ const result = await pool.query(
+ `SELECT prt.*, u.email
+ FROM password_reset_tokens prt
+ JOIN users u ON u.id = prt.user_id
+ WHERE prt.token = $1
+ AND prt.used = false
+ AND prt.expires_at > NOW()`,
+ [token]
+ );
+
+ if (result.rows.length === 0) {
+ return res.status(400).json({
+ error: 'Invalid or expired reset link. Please request a new one.'
+ });
+ }
+
+ const resetToken = result.rows[0];
+
+ // Update password
+ await Users.updatePassword(resetToken.user_id, password);
+
+ // Mark token as used
+ await pool.query(
+ 'UPDATE password_reset_tokens SET used = true WHERE id = $1',
+ [resetToken.id]
+ );
+
+ // Invalidate all other reset tokens for this user
+ await pool.query(
+ 'UPDATE password_reset_tokens SET used = true WHERE user_id = $1 AND id != $2',
+ [resetToken.user_id, resetToken.id]
+ );
+
+ res.json({ message: 'Password reset successfully. You can now log in.' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Logout (client-side token deletion, but we could add token blacklisting)
+router.post('/logout', (req, res) => {
+ res.json({ message: 'Logged out successfully' });
+});
+
+module.exports = router;
diff --git a/backend/src/routes/bugs.js b/backend/src/routes/bugs.js
new file mode 100644
index 0000000..37a8b24
--- /dev/null
+++ b/backend/src/routes/bugs.js
@@ -0,0 +1,216 @@
+const express = require('express');
+const { body, param, query } = require('express-validator');
+const { pool } = require('../models/db');
+const { authenticate, requireRole } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const Notifications = require('../models/notifications');
+const { sendNotificationEmail } = require('../utils/email');
+
+const router = express.Router();
+
+// Submit a bug report (authenticated users)
+router.post('/',
+ authenticate,
+ validate([
+ body('title').trim().isLength({ min: 5, max: 200 }).withMessage('Title must be 5-200 characters'),
+ body('description').trim().isLength({ min: 10, max: 5000 }).withMessage('Description must be 10-5000 characters'),
+ body('pageUrl').optional().trim().isLength({ max: 500 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { title, description, pageUrl } = req.body;
+
+ const result = await pool.query(
+ `INSERT INTO bug_reports (user_id, title, description, page_url)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [req.user.id, title, description, pageUrl || null]
+ );
+
+ res.status(201).json({
+ message: 'Bug report submitted. Thank you for helping improve GamerComp!',
+ report: result.rows[0]
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get my bug reports (authenticated users)
+router.get('/my-reports',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const result = await pool.query(
+ `SELECT id, title, status, priority, created_at, resolved_at
+ FROM bug_reports
+ WHERE user_id = $1
+ ORDER BY created_at DESC`,
+ [req.user.id]
+ );
+
+ res.json({ reports: result.rows });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Admin: Get all bug reports
+router.get('/admin',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const { status, priority } = req.query;
+
+ let query = `
+ SELECT br.*,
+ u.username as reporter_username,
+ u.email as reporter_email,
+ resolver.username as resolver_username
+ FROM bug_reports br
+ LEFT JOIN users u ON br.user_id = u.id
+ LEFT JOIN users resolver ON br.resolved_by = resolver.id
+ WHERE 1=1
+ `;
+ const params = [];
+
+ if (status && status !== 'all') {
+ params.push(status);
+ query += ` AND br.status = $${params.length}`;
+ }
+
+ if (priority && priority !== 'all') {
+ params.push(priority);
+ query += ` AND br.priority = $${params.length}`;
+ }
+
+ query += ` ORDER BY
+ CASE br.priority
+ WHEN 'critical' THEN 1
+ WHEN 'high' THEN 2
+ WHEN 'normal' THEN 3
+ WHEN 'low' THEN 4
+ END,
+ br.created_at DESC`;
+
+ const result = await pool.query(query, params);
+
+ // Get counts by status
+ const countsResult = await pool.query(`
+ SELECT status, COUNT(*) as count
+ FROM bug_reports
+ GROUP BY status
+ `);
+ const counts = {};
+ countsResult.rows.forEach(row => {
+ counts[row.status] = parseInt(row.count);
+ });
+
+ res.json({
+ reports: result.rows,
+ counts
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Admin: Update bug report status
+router.put('/admin/:id',
+ authenticate,
+ requireRole('admin'),
+ validate([
+ param('id').isInt(),
+ body('status').isIn(['open', 'in_progress', 'resolved', 'closed', 'wont_fix']),
+ body('priority').optional().isIn(['low', 'normal', 'high', 'critical']),
+ body('adminNotes').optional().trim().isLength({ max: 2000 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { id } = req.params;
+ const { status, priority, adminNotes } = req.body;
+
+ // Get the current bug report
+ const current = await pool.query(
+ 'SELECT * FROM bug_reports WHERE id = $1',
+ [id]
+ );
+
+ if (current.rows.length === 0) {
+ return res.status(404).json({ error: 'Bug report not found' });
+ }
+
+ const bug = current.rows[0];
+ const wasResolved = ['resolved', 'closed'].includes(bug.status);
+ const isNowResolved = ['resolved', 'closed'].includes(status);
+
+ // Update the bug report
+ const isResolvingNow = ['resolved', 'closed'].includes(status);
+ const result = await pool.query(
+ `UPDATE bug_reports
+ SET status = $2,
+ priority = COALESCE($3, priority),
+ admin_notes = COALESCE($4, admin_notes),
+ resolved_at = CASE WHEN $5 AND resolved_at IS NULL THEN NOW() ELSE resolved_at END,
+ resolved_by = CASE WHEN $5 AND resolved_by IS NULL THEN $6 ELSE resolved_by END,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *`,
+ [id, status, priority || null, adminNotes || null, isResolvingNow, req.user.id]
+ );
+
+ // If status changed to resolved/closed and user exists, notify them
+ if (!wasResolved && isNowResolved && bug.user_id) {
+ const statusText = status === 'resolved' ? 'has been fixed' : 'has been closed';
+ const notifMessage = `Your bug report "${bug.title}" ${statusText}. Thank you for helping improve GamerComp!`;
+ await Notifications.create({
+ userId: bug.user_id,
+ type: 'bug_resolved',
+ title: 'Bug Report Updated',
+ message: notifMessage,
+ link: null
+ });
+
+ // Send email notification
+ const userResult = await pool.query('SELECT email FROM users WHERE id = $1', [bug.user_id]);
+ if (userResult.rows.length > 0 && userResult.rows[0].email) {
+ sendNotificationEmail(
+ { email: userResult.rows[0].email },
+ { title: 'Bug Report Updated', message: notifMessage }
+ ).catch(() => {});
+ }
+ }
+
+ res.json({
+ message: 'Bug report updated',
+ report: result.rows[0]
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Admin: Delete bug report
+router.delete('/admin/:id',
+ authenticate,
+ requireRole('admin'),
+ validate([param('id').isInt()]),
+ async (req, res, next) => {
+ try {
+ const { id } = req.params;
+
+ await pool.query('DELETE FROM bug_reports WHERE id = $1', [id]);
+
+ res.json({ message: 'Bug report deleted' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/classrooms.js b/backend/src/routes/classrooms.js
new file mode 100644
index 0000000..57fe4a0
--- /dev/null
+++ b/backend/src/routes/classrooms.js
@@ -0,0 +1,660 @@
+const express = require('express');
+const { body, param } = require('express-validator');
+const { pool } = require('../models/db');
+const Classrooms = require('../models/classrooms');
+const { authenticate, requireRole } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+
+const router = express.Router();
+
+// Get my classrooms (as teacher or student)
+router.get('/my',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const teacherClassrooms = await Classrooms.getByTeacher(req.user.id);
+ const studentClassrooms = await Classrooms.getByStudent(req.user.id);
+
+ res.json({
+ asTeacher: teacherClassrooms.map(c => ({
+ id: c.id,
+ name: c.name,
+ joinCode: c.join_code,
+ studentCount: parseInt(c.student_count) || 0,
+ assignmentCount: parseInt(c.assignment_count) || 0,
+ isPublicPublishingAllowed: c.is_public_publishing_allowed,
+ createdAt: c.created_at
+ })),
+ asStudent: studentClassrooms.map(c => ({
+ id: c.id,
+ name: c.name,
+ teacherUsername: c.teacher_username,
+ teacherDisplayName: c.teacher_display_name,
+ studentCount: parseInt(c.student_count) || 0,
+ joinedAt: c.joined_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Create a new classroom (any authenticated user)
+router.post('/',
+ authenticate,
+ validate([
+ body('name').trim().isLength({ min: 1, max: 100 }).withMessage('Classroom name required (max 100 chars)'),
+ body('isPublicPublishingAllowed').optional().isBoolean()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { name, isPublicPublishingAllowed = false } = req.body;
+
+ const classroom = await Classrooms.create(req.user.id, name, isPublicPublishingAllowed);
+
+ res.status(201).json({
+ message: 'Classroom created successfully',
+ classroom: {
+ id: classroom.id,
+ name: classroom.name,
+ joinCode: classroom.join_code,
+ isPublicPublishingAllowed: classroom.is_public_publishing_allowed,
+ createdAt: classroom.created_at
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get classroom details
+router.get('/:classroomId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+ const classroom = await Classrooms.findById(classroomId);
+
+ if (!classroom) {
+ return res.status(404).json({ error: 'Classroom not found' });
+ }
+
+ // Check if user has access (teacher or student)
+ const isTeacher = classroom.teacher_id === req.user.id;
+ const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
+
+ if (!isTeacher && !isStudent) {
+ return res.status(403).json({ error: 'You do not have access to this classroom' });
+ }
+
+ const response = {
+ id: classroom.id,
+ name: classroom.name,
+ teacherUsername: classroom.teacher_username,
+ teacherDisplayName: classroom.teacher_display_name,
+ studentCount: parseInt(classroom.student_count) || 0,
+ isPublicPublishingAllowed: classroom.is_public_publishing_allowed,
+ createdAt: classroom.created_at,
+ isTeacher,
+ isStudent
+ };
+
+ // Only teachers see the join code
+ if (isTeacher) {
+ response.joinCode = classroom.join_code;
+ }
+
+ res.json({ classroom: response });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Update classroom (teacher only)
+router.put('/:classroomId',
+ authenticate,
+ validate([
+ body('name').optional().trim().isLength({ min: 1, max: 100 }),
+ body('isPublicPublishingAllowed').optional().isBoolean()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+ const { name, isPublicPublishingAllowed } = req.body;
+
+ const classroom = await Classrooms.update(classroomId, req.user.id, {
+ name,
+ isPublicPublishingAllowed
+ });
+
+ if (!classroom) {
+ return res.status(404).json({ error: 'Classroom not found or you are not the teacher' });
+ }
+
+ res.json({
+ message: 'Classroom updated',
+ classroom: {
+ id: classroom.id,
+ name: classroom.name,
+ joinCode: classroom.join_code,
+ isPublicPublishingAllowed: classroom.is_public_publishing_allowed
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Delete classroom (teacher only)
+router.delete('/:classroomId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+ const deleted = await Classrooms.delete(classroomId, req.user.id);
+
+ if (!deleted) {
+ return res.status(404).json({ error: 'Classroom not found or you are not the teacher' });
+ }
+
+ res.json({ message: 'Classroom deleted' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Regenerate join code (teacher only)
+router.post('/:classroomId/regenerate-code',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+ const classroom = await Classrooms.regenerateJoinCode(classroomId, req.user.id);
+
+ if (!classroom) {
+ return res.status(404).json({ error: 'Classroom not found or you are not the teacher' });
+ }
+
+ res.json({
+ message: 'Join code regenerated',
+ joinCode: classroom.join_code
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Join a classroom by code (students)
+router.post('/join',
+ authenticate,
+ validate([
+ body('joinCode').trim().isLength({ min: 6, max: 10 }).withMessage('Valid join code required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { joinCode } = req.body;
+
+ const classroom = await Classrooms.findByJoinCode(joinCode);
+ if (!classroom) {
+ return res.status(404).json({ error: 'Invalid join code. Please check and try again.' });
+ }
+
+ // Can't join own classroom
+ if (classroom.teacher_id === req.user.id) {
+ return res.status(400).json({ error: 'You cannot join your own classroom as a student' });
+ }
+
+ const enrollment = await Classrooms.addStudent(classroom.id, req.user.id);
+
+ if (!enrollment) {
+ return res.status(400).json({ error: 'You are already enrolled in this classroom' });
+ }
+
+ res.json({
+ message: `Successfully joined ${classroom.name}!`,
+ classroom: {
+ id: classroom.id,
+ name: classroom.name,
+ teacherUsername: classroom.teacher_username,
+ teacherDisplayName: classroom.teacher_display_name
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Leave a classroom (students)
+router.post('/:classroomId/leave',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+
+ const result = await pool.query(
+ 'DELETE FROM classroom_students WHERE classroom_id = $1 AND student_id = $2 RETURNING id',
+ [classroomId, req.user.id]
+ );
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'You are not enrolled in this classroom' });
+ }
+
+ res.json({ message: 'You have left the classroom' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get students in classroom (teacher or student in class)
+router.get('/:classroomId/students',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+
+ // Check access
+ const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
+ const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
+
+ if (!isTeacher && !isStudent) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const students = await Classrooms.getStudents(classroomId);
+
+ res.json({
+ students: students.map(s => ({
+ id: s.id,
+ username: s.username,
+ displayName: s.display_name,
+ xpTotal: s.xp_total,
+ level: s.creator_level,
+ badge: s.creator_badge,
+ gamesCreated: parseInt(s.games_created) || 0,
+ gamesPlayed: parseInt(s.games_played) || 0,
+ joinedAt: s.joined_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Remove student from classroom (teacher only)
+router.delete('/:classroomId/students/:studentId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId, studentId } = req.params;
+
+ const removed = await Classrooms.removeStudent(classroomId, parseInt(studentId), req.user.id);
+
+ if (!removed) {
+ return res.status(404).json({ error: 'Student not found or you are not the teacher' });
+ }
+
+ res.json({ message: 'Student removed from classroom' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get classroom leaderboard
+router.get('/:classroomId/leaderboard',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+
+ // Check access
+ const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
+ const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
+
+ if (!isTeacher && !isStudent) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const leaderboard = await Classrooms.getLeaderboard(classroomId);
+
+ res.json({
+ leaderboard: leaderboard.map(s => ({
+ rank: parseInt(s.rank),
+ id: s.id,
+ username: s.username,
+ displayName: s.display_name,
+ xpTotal: s.xp_total,
+ level: s.creator_level,
+ badge: s.creator_badge,
+ gamesCreated: parseInt(s.games_created) || 0
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get detailed student progress (teacher only)
+router.get('/:classroomId/student-progress',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+
+ // Only teachers can access detailed progress
+ const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
+ if (!isTeacher) {
+ return res.status(403).json({ error: 'Only teachers can access detailed student progress' });
+ }
+
+ const students = await Classrooms.getDetailedStudentProgress(classroomId);
+
+ res.json({
+ students: students.map(s => ({
+ id: s.id,
+ username: s.username,
+ displayName: s.display_name,
+ xpTotal: parseInt(s.xp_total) || 0,
+ level: parseInt(s.level) || 1,
+ badge: s.badge || 'Newbie',
+ joinedAt: s.joined_at,
+ gamesCreated: parseInt(s.games_created) || 0,
+ gamesPlayed: parseInt(s.games_played) || 0,
+ achievementCount: parseInt(s.achievement_count) || 0,
+ xpThisWeek: parseInt(s.xp_this_week) || 0,
+ gamesThisWeek: parseInt(s.games_this_week) || 0,
+ totalPlaysOnGames: parseInt(s.total_plays_on_games) || 0
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// === ASSIGNMENTS ===
+
+// Create assignment (teacher only)
+router.post('/:classroomId/assignments',
+ authenticate,
+ validate([
+ body('title').trim().isLength({ min: 1, max: 100 }).withMessage('Assignment title required'),
+ body('description').optional().trim().isLength({ max: 1000 }),
+ body('requiredGameStyle').optional().trim().isLength({ max: 50 }),
+ body('dueDate').optional().isISO8601()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+ const { title, description, requiredGameStyle, dueDate } = req.body;
+
+ // Verify teacher
+ const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
+ if (!isTeacher) {
+ return res.status(403).json({ error: 'Only the teacher can create assignments' });
+ }
+
+ const assignment = await Classrooms.createAssignment(classroomId, {
+ title,
+ description,
+ requiredGameStyle,
+ dueDate
+ });
+
+ res.status(201).json({
+ message: 'Assignment created',
+ assignment: {
+ id: assignment.id,
+ title: assignment.title,
+ description: assignment.description,
+ requiredGameStyle: assignment.required_game_style,
+ dueDate: assignment.due_date,
+ createdAt: assignment.created_at
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get assignments for classroom
+router.get('/:classroomId/assignments',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+
+ // Check access
+ const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
+ const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
+
+ if (!isTeacher && !isStudent) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const assignments = await Classrooms.getAssignments(classroomId);
+
+ res.json({
+ assignments: assignments.map(a => ({
+ id: a.id,
+ title: a.title,
+ description: a.description,
+ requiredGameStyle: a.required_game_style,
+ dueDate: a.due_date,
+ createdAt: a.created_at,
+ isOverdue: a.due_date && new Date(a.due_date) < new Date()
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Delete assignment (teacher only)
+router.delete('/:classroomId/assignments/:assignmentId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { assignmentId } = req.params;
+
+ const deleted = await Classrooms.deleteAssignment(assignmentId, req.user.id);
+
+ if (!deleted) {
+ return res.status(404).json({ error: 'Assignment not found or you are not the teacher' });
+ }
+
+ res.json({ message: 'Assignment deleted' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Mark assignment as completed (student)
+router.post('/:classroomId/assignments/:assignmentId/complete',
+ authenticate,
+ validate([
+ body('gameId').optional().isInt(),
+ body('notes').optional().trim().isLength({ max: 500 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { classroomId, assignmentId } = req.params;
+ const { gameId, notes } = req.body;
+
+ // Check student is enrolled
+ const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
+ if (!isStudent) {
+ return res.status(403).json({ error: 'You are not enrolled in this classroom' });
+ }
+
+ // Mark as completed
+ const result = await pool.query(
+ `INSERT INTO assignment_completions (assignment_id, student_id, game_id, notes)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (assignment_id, student_id)
+ DO UPDATE SET completed_at = NOW(), game_id = COALESCE($3, assignment_completions.game_id), notes = COALESCE($4, assignment_completions.notes)
+ RETURNING *`,
+ [assignmentId, req.user.id, gameId || null, notes || null]
+ );
+
+ res.json({
+ message: 'Assignment marked as completed',
+ completion: result.rows[0]
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get my assignment status (student)
+router.get('/:classroomId/my-assignments',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+
+ // Check student is enrolled
+ const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
+ if (!isStudent) {
+ return res.status(403).json({ error: 'You are not enrolled in this classroom' });
+ }
+
+ const result = await pool.query(
+ `SELECT a.*,
+ ac.completed_at, ac.game_id, ac.notes as completion_notes,
+ g.title as submitted_game_title
+ FROM classroom_assignments a
+ LEFT JOIN assignment_completions ac ON a.id = ac.assignment_id AND ac.student_id = $2
+ LEFT JOIN games g ON ac.game_id = g.id
+ WHERE a.classroom_id = $1
+ ORDER BY a.due_date ASC NULLS LAST, a.created_at DESC`,
+ [classroomId, req.user.id]
+ );
+
+ res.json({
+ assignments: result.rows.map(a => ({
+ id: a.id,
+ title: a.title,
+ description: a.description,
+ requiredGameStyle: a.required_game_style,
+ dueDate: a.due_date,
+ createdAt: a.created_at,
+ isCompleted: !!a.completed_at,
+ completedAt: a.completed_at,
+ submittedGameId: a.game_id,
+ submittedGameTitle: a.submitted_game_title,
+ completionNotes: a.completion_notes,
+ isOverdue: a.due_date && new Date(a.due_date) < new Date() && !a.completed_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Send message to all students in classroom (teacher only)
+router.post('/:classroomId/broadcast',
+ authenticate,
+ validate([
+ body('message').trim().isLength({ min: 1, max: 500 }).withMessage('Message required (max 500 chars)'),
+ body('title').optional().trim().isLength({ max: 100 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { classroomId } = req.params;
+ const { message, title } = req.body;
+
+ // Check teacher owns classroom
+ const classroom = await Classrooms.findById(classroomId);
+ if (!classroom || classroom.teacher_id !== req.user.id) {
+ return res.status(403).json({ error: 'You are not the teacher of this classroom' });
+ }
+
+ // Get all students
+ const students = await Classrooms.getStudents(classroomId);
+
+ // Create notification for each student
+ const Notifications = require('../models/notifications');
+ const notificationTitle = title || `Message from ${req.user.displayName || req.user.username}`;
+
+ for (const student of students) {
+ await Notifications.create(student.id, 'classroom_message', notificationTitle, message, `/classrooms`);
+ }
+
+ res.json({
+ message: 'Broadcast sent successfully',
+ recipientCount: students.length
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get assignment completion stats for teacher
+router.get('/:classroomId/assignments/:assignmentId/completions',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { classroomId, assignmentId } = req.params;
+
+ // Check teacher owns classroom
+ const classroom = await Classrooms.findById(classroomId);
+ if (!classroom || classroom.teacher_id !== req.user.id) {
+ return res.status(403).json({ error: 'You are not the teacher of this classroom' });
+ }
+
+ const result = await pool.query(
+ `SELECT cs.student_id, u.username, u.display_name,
+ ac.completed_at, ac.game_id, ac.notes,
+ g.title as game_title
+ FROM classroom_students cs
+ JOIN users u ON cs.student_id = u.id
+ LEFT JOIN assignment_completions ac ON ac.assignment_id = $2 AND ac.student_id = cs.student_id
+ LEFT JOIN games g ON ac.game_id = g.id
+ WHERE cs.classroom_id = $1
+ ORDER BY ac.completed_at ASC NULLS LAST, u.username ASC`,
+ [classroomId, assignmentId]
+ );
+
+ const completions = result.rows;
+ const completedCount = completions.filter(c => c.completed_at).length;
+
+ res.json({
+ totalStudents: completions.length,
+ completedCount,
+ completionRate: completions.length > 0 ? Math.round((completedCount / completions.length) * 100) : 0,
+ students: completions.map(c => ({
+ studentId: c.student_id,
+ username: c.username,
+ displayName: c.display_name,
+ isCompleted: !!c.completed_at,
+ completedAt: c.completed_at,
+ submittedGameId: c.game_id,
+ submittedGameTitle: c.game_title,
+ notes: c.notes
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/collections.js b/backend/src/routes/collections.js
new file mode 100644
index 0000000..95143f9
--- /dev/null
+++ b/backend/src/routes/collections.js
@@ -0,0 +1,272 @@
+const express = require('express');
+const { body, param } = require('express-validator');
+const Collections = require('../models/collections');
+const Achievements = require('../models/achievements');
+const { authenticate, optionalAuth } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const { filterTitle, filterDescription, logFilterAction } = require('../utils/contentFilter');
+
+const router = express.Router();
+
+// Create a new collection
+router.post('/',
+ authenticate,
+ validate([
+ body('name').trim().isLength({ min: 1, max: 100 }).withMessage('Name is required (max 100 chars)'),
+ body('description').optional().trim().isLength({ max: 500 }),
+ body('isPublic').optional().isBoolean()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { name, description, isPublic } = req.body;
+
+ // Content filter for collection name
+ const nameFilter = filterTitle(name);
+ if (nameFilter.blocked) {
+ logFilterAction(req.user.id, 'collection_name', name, nameFilter);
+ return res.status(400).json({
+ error: nameFilter.message,
+ blocked: true,
+ canRetry: true
+ });
+ }
+
+ // Content filter for description
+ if (description) {
+ const descFilter = filterDescription(description);
+ if (descFilter.blocked) {
+ logFilterAction(req.user.id, 'collection_description', description, descFilter);
+ return res.status(400).json({
+ error: descFilter.message,
+ blocked: true,
+ canRetry: true
+ });
+ }
+ }
+
+ const collection = await Collections.create({
+ userId: req.user.id,
+ name,
+ description,
+ isPublic: isPublic !== false
+ });
+
+ // Check for first collection achievement
+ const count = await Collections.getCount(req.user.id);
+ await Achievements.checkAndAward(req.user.id, 'first_collection', count);
+
+ res.status(201).json({
+ message: 'Collection created',
+ collection: {
+ id: collection.id,
+ name: collection.name,
+ description: collection.description,
+ isPublic: collection.is_public,
+ createdAt: collection.created_at
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get collection by ID
+router.get('/:id',
+ optionalAuth,
+ async (req, res, next) => {
+ try {
+ const collection = await Collections.findById(req.params.id);
+
+ if (!collection) {
+ return res.status(404).json({ error: 'Collection not found' });
+ }
+
+ // Check visibility
+ const isOwner = req.user && req.user.id === collection.user_id;
+ if (!collection.is_public && !isOwner) {
+ return res.status(404).json({ error: 'Collection not found' });
+ }
+
+ let games = await Collections.getGames(req.params.id, isOwner);
+
+ // Filter games by search query if provided
+ const { search } = req.query;
+ if (search && search.trim()) {
+ const searchTerm = search.trim().toLowerCase();
+ games = games.filter(g =>
+ g.title.toLowerCase().includes(searchTerm)
+ );
+ }
+
+ res.json({
+ collection: {
+ id: collection.id,
+ name: collection.name,
+ description: collection.description,
+ isPublic: collection.is_public,
+ ownerUsername: collection.owner_username,
+ ownerDisplayName: collection.owner_display_name,
+ gameCount: parseInt(collection.game_count),
+ createdAt: collection.created_at
+ },
+ games: games.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ thumbnailUrl: g.thumbnail_url,
+ playCount: g.play_count,
+ creatorUsername: g.creator_username,
+ addedAt: g.added_at
+ })),
+ isOwner
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Update collection
+router.put('/:id',
+ authenticate,
+ validate([
+ body('name').optional().trim().isLength({ min: 1, max: 100 }),
+ body('description').optional().trim().isLength({ max: 500 }),
+ body('isPublic').optional().isBoolean()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { name, description, isPublic } = req.body;
+
+ // Content filter for name
+ if (name) {
+ const nameFilter = filterTitle(name);
+ if (nameFilter.blocked) {
+ logFilterAction(req.user.id, 'collection_name', name, nameFilter);
+ return res.status(400).json({
+ error: nameFilter.message,
+ blocked: true,
+ canRetry: true
+ });
+ }
+ }
+
+ // Content filter for description
+ if (description) {
+ const descFilter = filterDescription(description);
+ if (descFilter.blocked) {
+ logFilterAction(req.user.id, 'collection_description', description, descFilter);
+ return res.status(400).json({
+ error: descFilter.message,
+ blocked: true,
+ canRetry: true
+ });
+ }
+ }
+
+ const updated = await Collections.update(req.params.id, req.user.id, {
+ name,
+ description,
+ isPublic
+ });
+
+ if (!updated) {
+ return res.status(404).json({ error: 'Collection not found or not owned by you' });
+ }
+
+ res.json({
+ message: 'Collection updated',
+ collection: {
+ id: updated.id,
+ name: updated.name,
+ description: updated.description,
+ isPublic: updated.is_public
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Delete collection
+router.delete('/:id',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ await Collections.delete(req.params.id, req.user.id);
+ res.json({ message: 'Collection deleted' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Add game to collection
+router.post('/:id/games',
+ authenticate,
+ validate([
+ body('gameId').isInt().withMessage('Valid game ID required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.body;
+
+ await Collections.addGame(req.params.id, gameId, req.user.id);
+
+ res.json({ message: 'Game added to collection' });
+ } catch (error) {
+ if (error.message.includes('not found')) {
+ return res.status(404).json({ error: error.message });
+ }
+ next(error);
+ }
+ }
+);
+
+// Remove game from collection
+router.delete('/:id/games/:gameId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ await Collections.removeGame(req.params.id, req.params.gameId, req.user.id);
+ res.json({ message: 'Game removed from collection' });
+ } catch (error) {
+ if (error.message.includes('not found')) {
+ return res.status(404).json({ error: error.message });
+ }
+ next(error);
+ }
+ }
+);
+
+// Get public collections
+router.get('/',
+ async (req, res, next) => {
+ try {
+ const { page = 1, limit = 20 } = req.query;
+ const offset = (parseInt(page) - 1) * parseInt(limit);
+
+ const collections = await Collections.getPublicCollections(parseInt(limit), offset);
+
+ res.json({
+ collections: collections.map(c => ({
+ id: c.id,
+ name: c.name,
+ description: c.description,
+ ownerUsername: c.owner_username,
+ ownerDisplayName: c.owner_display_name,
+ gameCount: parseInt(c.game_count),
+ createdAt: c.created_at
+ })),
+ page: parseInt(page),
+ hasMore: collections.length === parseInt(limit)
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/contact.js b/backend/src/routes/contact.js
new file mode 100644
index 0000000..37ea4e0
--- /dev/null
+++ b/backend/src/routes/contact.js
@@ -0,0 +1,73 @@
+const express = require('express');
+const { body } = require('express-validator');
+const validate = require('../middleware/validate');
+const { forwardToAdmin, sendEmail } = require('../utils/email');
+const { filterText, logFilterAction } = require('../utils/contentFilter');
+
+const router = express.Router();
+
+// Submit contact form
+router.post('/',
+ validate([
+ body('name').trim().isLength({ min: 1, max: 100 }).withMessage('Name required (max 100 chars)'),
+ body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
+ body('subject').trim().isLength({ min: 1, max: 200 }).withMessage('Subject required (max 200 chars)'),
+ body('message').trim().isLength({ min: 10, max: 5000 }).withMessage('Message must be 10-5000 characters')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { name, email, subject, message } = req.body;
+
+ // Content filter
+ const messageFilter = filterText(message, { contentType: 'message' });
+ if (messageFilter.blocked) {
+ logFilterAction(null, 'contact_message', message, messageFilter);
+ return res.status(400).json({
+ error: messageFilter.message,
+ blocked: true
+ });
+ }
+
+ // Forward to admin
+ const result = await forwardToAdmin(
+ `${name} <${email}>`,
+ subject,
+ message
+ );
+
+ if (result.success) {
+ // Send confirmation to user
+ await sendEmail({
+ to: email,
+ subject: 'We received your message - GamerComp',
+ text: `Hi ${name},\n\nThanks for reaching out! We received your message and will get back to you soon.\n\nYour message:\n"${message.substring(0, 200)}${message.length > 200 ? '...' : ''}"\n\n- The GamerComp Team`,
+ html: `
+
+
Thanks for reaching out!
+
Hi ${name},
+
We received your message and will get back to you soon.
+
+ Your message:
+ "${message.substring(0, 200)}${message.length > 200 ? '...' : ''}"
+
+
- The GamerComp Team
+
+ `
+ });
+
+ res.json({
+ success: true,
+ message: 'Message sent successfully! Check your email for confirmation.'
+ });
+ } else {
+ res.status(500).json({
+ error: 'Failed to send message. Please try again later.'
+ });
+ }
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/creation.js b/backend/src/routes/creation.js
new file mode 100644
index 0000000..eeb36a1
--- /dev/null
+++ b/backend/src/routes/creation.js
@@ -0,0 +1,1200 @@
+const express = require('express');
+const { body } = require('express-validator');
+const Games = require('../models/games');
+const Users = require('../models/users');
+const Achievements = require('../models/achievements');
+const XP = require('../models/xp');
+const Notifications = require('../models/notifications');
+const { pool } = require('../models/db');
+const { authenticate } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const gameStyles = require('../utils/gameStyles');
+const { buildPrompt, generateSummary, countDetails } = require('../utils/promptBuilder');
+const { generateGame, generateGameStream, regenerateGameStream, estimateComplexity } = require('../utils/claude');
+const { filterGamePrompt, filterTitle, filterDescription, logFilterAction } = require('../utils/contentFilter');
+const { sendNotificationEmail, sendGameReadyEmail } = require('../utils/email');
+const GameVersions = require('../models/gameVersions');
+const { preprocessPrompt, explainError, summarizeNewGame, summarizeCodeChanges } = require('../utils/haiku');
+
+const router = express.Router();
+
+// In-memory generation tracking for SSE
+const activeGenerations = new Map(); // gameId → { status, events[], listeners[], userId }
+
+// Helper for hierarchical remix versioning
+function parseGameVersion(title) {
+ const match = title.match(/^(.+?)\s+v(\d+(?:\.\d+)*)$/);
+ if (match) return { base: match[1].trim(), version: match[2] };
+ return { base: title, version: null };
+}
+
+// ==========================================
+// GAME STYLES & QUESTIONS
+// ==========================================
+
+// Get all available game styles
+router.get('/styles',
+ async (req, res) => {
+ res.json({
+ styles: gameStyles.getAllStyles()
+ });
+ }
+);
+
+// Get questions for a specific style
+router.get('/styles/:styleId/questions',
+ async (req, res) => {
+ const { styleId } = req.params;
+ const questions = gameStyles.getQuestionsForStyle(styleId);
+
+ if (!questions) {
+ return res.status(404).json({ error: 'Game style not found' });
+ }
+
+ res.json(questions);
+ }
+);
+
+// Estimate complexity for a game before generation
+router.post('/estimate-complexity',
+ authenticate,
+ validate([
+ body('styleId').isIn(Object.keys(gameStyles.GAME_STYLES)).withMessage('Invalid game style'),
+ body('answers').isObject().withMessage('Answers must be an object')
+ ]),
+ async (req, res) => {
+ const { styleId, answers } = req.body;
+ const estimate = estimateComplexity(styleId, answers);
+
+ res.json({
+ complexity: estimate.complexity,
+ tokenBudget: estimate.tokenBudget,
+ warnings: estimate.warnings,
+ isHighComplexity: estimate.isHighComplexity,
+ suggestions: estimate.isHighComplexity ? [
+ 'Consider fewer endings or levels',
+ 'Remove 2-player mode if not essential',
+ 'Start simple - you can add features later with AI Refine'
+ ] : []
+ });
+ }
+);
+
+// ==========================================
+// GAME CREATION FLOW
+// ==========================================
+
+// Step 1: Start creation - save initial answers and get a draft ID
+// Returns the assembled prompt for user to review and edit
+router.post('/start',
+ authenticate,
+ validate([
+ body('styleId').isIn(Object.keys(gameStyles.GAME_STYLES)).withMessage('Invalid game style'),
+ body('title').optional().trim().isLength({ min: 1, max: 100 }),
+ body('answers').isObject().withMessage('Answers must be an object')
+ ]),
+ async (req, res, next) => {
+ try {
+ // Check if guest
+ if (req.user.is_guest) {
+ return res.status(403).json({
+ error: 'Guests cannot create games. Please create an account first.'
+ });
+ }
+
+ const { styleId, title, answers } = req.body;
+ const style = gameStyles.getStyleById(styleId);
+
+ // Content filtering for game creation answers
+ const promptFilter = filterGamePrompt(answers);
+ if (promptFilter.blocked) {
+ logFilterAction(req.user.id, 'game_prompt', JSON.stringify(answers), promptFilter);
+ return res.status(400).json({
+ error: promptFilter.message,
+ blocked: true,
+ category: promptFilter.issues[0]?.categories[0] || 'content',
+ canRetry: true
+ });
+ }
+
+ // Filter title if provided
+ if (title) {
+ const titleFilter = filterTitle(title);
+ if (titleFilter.blocked) {
+ logFilterAction(req.user.id, 'game_title', title, titleFilter);
+ return res.status(400).json({
+ error: titleFilter.message,
+ blocked: true,
+ category: titleFilter.categories[0] || 'content',
+ canRetry: true
+ });
+ }
+ }
+
+ // Generate a default title if not provided
+ const gameTitle = title || `${style.name} Game by ${req.user.username}`;
+
+ // Generate the prompt from answers
+ const creationPrompt = buildPrompt(styleId, answers);
+ const summary = generateSummary(styleId, answers);
+
+ // Create draft game record - save original_prompt for comparison
+ const result = await pool.query(
+ `INSERT INTO games (
+ creator_id, title, description, game_code, creation_prompt,
+ original_prompt, prompt_answers, game_style, status
+ ) VALUES ($1, $2, $3, '', $4, $4, $5, $6, 'draft')
+ RETURNING id, title, status, created_at`,
+ [
+ req.user.id,
+ gameTitle,
+ summary,
+ creationPrompt,
+ JSON.stringify(answers),
+ styleId
+ ]
+ );
+
+ const game = result.rows[0];
+
+ // Estimate complexity for the prompt review screen
+ const complexityEstimate = estimateComplexity(styleId, answers);
+
+ res.status(201).json({
+ message: 'Game creation started',
+ game: {
+ id: game.id,
+ title: game.title,
+ status: game.status,
+ createdAt: game.created_at
+ },
+ summary,
+ prompt: creationPrompt,
+ // Return answers so frontend can highlight user inputs in prompt
+ promptAnswers: answers,
+ // Complexity info for warnings
+ complexity: complexityEstimate.complexity,
+ tokenBudget: complexityEstimate.tokenBudget,
+ complexityWarnings: complexityEstimate.warnings,
+ isHighComplexity: complexityEstimate.isHighComplexity
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Helper: send SSE event to all listeners for a game
+function emitSSE(gameId, event) {
+ const gen = activeGenerations.get(String(gameId));
+ if (!gen) return;
+ gen.events.push(event);
+ for (const listener of gen.listeners) {
+ try {
+ listener.write(`data: ${JSON.stringify(event)}\n\n`);
+ } catch { /* ignore broken connections */ }
+ }
+}
+
+// Helper: run background generation and emit SSE events
+async function runBackgroundGeneration(gameId, userId, finalPrompt, styleId, answers, gameTitle) {
+ const genKey = String(gameId);
+ activeGenerations.set(genKey, { status: 'generating', events: [], listeners: [], userId });
+
+ try {
+ emitSSE(gameId, { type: 'started', message: 'AI is starting to build your game...' });
+
+ const result = await generateGameStream(finalPrompt, styleId, answers, (progress) => {
+ emitSSE(gameId, progress);
+ });
+
+ if (!result.success) {
+ const errorReason = result.error || 'Generation failed';
+ await pool.query(
+ `UPDATE games SET status = 'draft', generation_error = $2 WHERE id = $1`,
+ [gameId, errorReason]
+ );
+
+ await pool.query(
+ `INSERT INTO bug_reports (user_id, title, description, page_url, priority)
+ VALUES ($1, $2, $3, $4, 'high')`,
+ [userId, `[Auto] Game generation failed: ${gameTitle || 'Untitled'}`,
+ `Generation failed for game ID ${gameId}.\nError: ${errorReason}\nStyle: ${styleId}`,
+ `/play/${gameId}`]
+ ).catch(() => {});
+
+ const { friendlyError } = await explainError(errorReason, 'generation');
+
+ // Notify user
+ await Notifications.create({
+ userId,
+ type: 'game_ready',
+ title: 'Game Generation Failed',
+ message: friendlyError,
+ link: `/create`
+ }).catch(() => {});
+
+ const userResult = await pool.query('SELECT email FROM users WHERE id = $1', [userId]);
+ if (userResult.rows[0]?.email) {
+ sendGameReadyEmail({ email: userResult.rows[0].email }, gameTitle || 'Untitled', gameId, true).catch(() => {});
+ }
+
+ emitSSE(gameId, { type: 'error', error: friendlyError, rawError: errorReason });
+ return;
+ }
+
+ // Calculate costs
+ const serverCostCents = 10;
+ const totalCostCents = (result.apiCostCents || 0) + serverCostCents;
+
+ await pool.query(
+ `UPDATE games SET
+ game_code = $1, status = 'testing',
+ code_version = COALESCE(code_version, 0) + 1,
+ code_updated_at = NOW(),
+ claude_input_tokens = COALESCE(claude_input_tokens, 0) + $3,
+ claude_output_tokens = COALESCE(claude_output_tokens, 0) + $4,
+ api_cost_cents = COALESCE(api_cost_cents, 0) + $5,
+ server_cost_cents = COALESCE(server_cost_cents, 0) + $6,
+ total_cost_cents = COALESCE(total_cost_cents, 0) + $7,
+ generations_count = COALESCE(generations_count, 0) + 1
+ WHERE id = $2`,
+ [result.code, gameId, result.inputTokens || 0, result.outputTokens || 0,
+ result.apiCostCents || 0, serverCostCents, totalCostCents]
+ );
+
+ await GameVersions.saveVersion(gameId, result.code, 'generation', 'Initial generation', {
+ inputTokens: result.inputTokens || 0, outputTokens: result.outputTokens || 0,
+ apiCostCents: result.apiCostCents || 0
+ }).catch(() => {});
+
+ await pool.query(
+ `UPDATE users SET total_api_cost_cents = COALESCE(total_api_cost_cents, 0) + $1,
+ total_generations = COALESCE(total_generations, 0) + 1 WHERE id = $2`,
+ [totalCostCents, userId]
+ );
+
+ // Generate Haiku summary
+ const gameSummary = await summarizeNewGame(result.code, finalPrompt);
+
+ // Create notification
+ await Notifications.create({
+ userId,
+ type: 'game_ready',
+ title: 'Your Game is Ready!',
+ message: `"${gameTitle}" has been generated! ${gameSummary}`,
+ link: `/play/${gameId}?newGame=true`
+ }).catch(() => {});
+
+ // Send email
+ const userResult = await pool.query('SELECT email FROM users WHERE id = $1', [userId]);
+ if (userResult.rows[0]?.email) {
+ sendGameReadyEmail({ email: userResult.rows[0].email }, gameTitle || 'Untitled', gameId, false).catch(() => {});
+ }
+
+ // Send push notification
+ try {
+ const pushNotify = require('../utils/pushNotify');
+ await pushNotify.send(userId, {
+ title: 'Your Game is Ready!',
+ body: `"${gameTitle}" has been generated! Tap to play.`,
+ url: `/play/${gameId}?newGame=true`
+ });
+ } catch { /* push may not be set up yet */ }
+
+ emitSSE(gameId, {
+ type: 'complete',
+ gameCode: result.code,
+ summary: gameSummary,
+ tokensUsed: result.tokensUsed,
+ costCents: totalCostCents,
+ warnings: result.warnings || [],
+ wasTruncated: result.wasTruncated || false
+ });
+ } catch (error) {
+ console.error(`Background generation error for game ${gameId}:`, error);
+ try {
+ await pool.query(
+ `UPDATE games SET status = 'draft', generation_error = $2 WHERE id = $1 AND status = 'generating'`,
+ [gameId, error.message]
+ );
+ } catch { /* ignore */ }
+
+ const { friendlyError } = await explainError(error.message, 'generation').catch(() => ({ friendlyError: 'Something went wrong, try again!' }));
+ emitSSE(gameId, { type: 'error', error: friendlyError, rawError: error.message });
+ } finally {
+ // Clean up after 5 minutes (keep events for reconnection)
+ setTimeout(() => {
+ activeGenerations.delete(genKey);
+ }, 5 * 60 * 1000);
+ }
+}
+
+// Helper: run background refinement and emit SSE events
+async function runBackgroundRefinement(gameId, userId, existingCode, cleanedFeedback, gameTitle, originalFeedback) {
+ const genKey = String(gameId);
+ activeGenerations.set(genKey, { status: 'refining', events: [], listeners: [], userId });
+
+ try {
+ emitSSE(gameId, { type: 'started', message: 'AI is updating your game...' });
+
+ const result = await regenerateGameStream(existingCode, cleanedFeedback, (progress) => {
+ emitSSE(gameId, progress);
+ });
+
+ if (!result.success) {
+ await pool.query(`UPDATE games SET status = 'testing' WHERE id = $1`, [gameId]);
+ const { friendlyError } = await explainError(result.error || 'Refinement failed', 'refine');
+ emitSSE(gameId, { type: 'error', error: friendlyError, rawError: result.error });
+ return;
+ }
+
+ const serverCostCents = 10;
+ const totalCostCents = (result.apiCostCents || 0) + serverCostCents;
+
+ await pool.query(
+ `UPDATE games SET
+ game_code = $1, status = 'testing', pending_feedback = NULL,
+ creator_played = false, creator_play_duration = 0,
+ code_version = COALESCE(code_version, 0) + 1,
+ code_updated_at = NOW(),
+ claude_input_tokens = COALESCE(claude_input_tokens, 0) + $3,
+ claude_output_tokens = COALESCE(claude_output_tokens, 0) + $4,
+ api_cost_cents = COALESCE(api_cost_cents, 0) + $5,
+ server_cost_cents = COALESCE(server_cost_cents, 0) + $6,
+ total_cost_cents = COALESCE(total_cost_cents, 0) + $7,
+ generations_count = COALESCE(generations_count, 0) + 1
+ WHERE id = $2`,
+ [result.code, gameId, result.inputTokens || 0, result.outputTokens || 0,
+ result.apiCostCents || 0, serverCostCents, totalCostCents]
+ );
+
+ await GameVersions.saveVersion(gameId, result.code, 'refinement', originalFeedback, {
+ inputTokens: result.inputTokens || 0, outputTokens: result.outputTokens || 0,
+ apiCostCents: result.apiCostCents || 0
+ }).catch(() => {});
+
+ await pool.query(
+ `UPDATE users SET total_api_cost_cents = COALESCE(total_api_cost_cents, 0) + $1,
+ total_generations = COALESCE(total_generations, 0) + 1 WHERE id = $2`,
+ [totalCostCents, userId]
+ );
+
+ const changeSummary = await summarizeCodeChanges(existingCode, result.code, cleanedFeedback);
+
+ await Notifications.create({
+ userId,
+ type: 'game_ready',
+ title: 'Game Updated!',
+ message: `"${gameTitle}" has been refined! ${changeSummary}`,
+ link: `/play/${gameId}?newGame=true`
+ }).catch(() => {});
+
+ try {
+ const pushNotify = require('../utils/pushNotify');
+ await pushNotify.send(userId, {
+ title: 'Game Updated!',
+ body: `"${gameTitle}" has been refined! Tap to test.`,
+ url: `/play/${gameId}?newGame=true`
+ });
+ } catch { /* push may not be set up yet */ }
+
+ emitSSE(gameId, {
+ type: 'complete',
+ gameCode: result.code,
+ summary: changeSummary,
+ tokensUsed: result.tokensUsed,
+ costCents: totalCostCents
+ });
+ } catch (error) {
+ console.error(`Background refinement error for game ${gameId}:`, error);
+ try {
+ await pool.query(`UPDATE games SET status = 'testing' WHERE id = $1 AND status = 'refining'`, [gameId]);
+ } catch { /* ignore */ }
+ const { friendlyError } = await explainError(error.message, 'refine').catch(() => ({ friendlyError: 'Something went wrong, try again!' }));
+ emitSSE(gameId, { type: 'error', error: friendlyError, rawError: error.message });
+ } finally {
+ setTimeout(() => { activeGenerations.delete(String(gameId)); }, 5 * 60 * 1000);
+ }
+}
+
+// SSE endpoint for streaming generation progress
+router.get('/:gameId/stream',
+ async (req, res) => {
+ // Auth via query param (SSE doesn't support headers)
+ const jwt = require('jsonwebtoken');
+ const token = req.query.token;
+ if (!token) return res.status(401).json({ error: 'Auth required' });
+
+ let userId;
+ try {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ userId = decoded.id || decoded.userId;
+ } catch {
+ return res.status(401).json({ error: 'Invalid token' });
+ }
+
+ const { gameId } = req.params;
+
+ // Verify game ownership
+ const gameResult = await pool.query(
+ 'SELECT id FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, userId]
+ );
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ // Set SSE headers
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no'
+ });
+
+ const genKey = String(gameId);
+ const gen = activeGenerations.get(genKey);
+
+ if (gen) {
+ // Send buffered events (supports reconnection)
+ for (const event of gen.events) {
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
+ }
+
+ // Register as listener for new events
+ gen.listeners.push(res);
+ } else {
+ // No active generation - check DB status
+ const statusResult = await pool.query(
+ 'SELECT status, game_code FROM games WHERE id = $1', [gameId]
+ );
+ const game = statusResult.rows[0];
+ if (game && game.status === 'testing' && game.game_code) {
+ res.write(`data: ${JSON.stringify({ type: 'complete', gameCode: game.game_code, summary: 'Your game is ready!' })}\n\n`);
+ } else {
+ res.write(`data: ${JSON.stringify({ type: 'waiting', message: 'Waiting for generation to start...' })}\n\n`);
+ }
+ }
+
+ // Keepalive ping every 15s
+ const keepalive = setInterval(() => {
+ try { res.write(`:ping\n\n`); } catch { clearInterval(keepalive); }
+ }, 15000);
+
+ // Clean up on disconnect
+ req.on('close', () => {
+ clearInterval(keepalive);
+ if (gen) {
+ gen.listeners = gen.listeners.filter(l => l !== res);
+ }
+ });
+ }
+);
+
+// Step 2: Generate the game code with Claude (now returns immediately, runs in background)
+router.post('/:gameId/generate',
+ authenticate,
+ validate([
+ body('editedPrompt').optional().isString().isLength({ max: 50000 }),
+ body('promptWasEdited').optional().isBoolean()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { editedPrompt, promptWasEdited } = req.body;
+
+ // Get the draft game
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ // Allow regeneration from draft or if previous generation failed
+ if (!['draft', 'generating'].includes(game.status)) {
+ return res.status(400).json({ error: 'Game has already been generated' });
+ }
+
+ // Use edited prompt if provided, otherwise use the original
+ const promptToUse = editedPrompt || game.creation_prompt;
+
+ if (!promptToUse) {
+ return res.status(400).json({ error: 'No prompt found. Please restart creation.' });
+ }
+
+ // Content filter the edited prompt if it was modified
+ if (editedPrompt && promptWasEdited) {
+ const promptFilter = filterDescription(editedPrompt);
+ if (promptFilter.blocked) {
+ logFilterAction(req.user.id, 'edited_prompt', editedPrompt, promptFilter);
+ return res.status(400).json({
+ error: promptFilter.message,
+ blocked: true,
+ canRetry: true
+ });
+ }
+ }
+
+ // Haiku preprocessing - clean up the prompt
+ const preprocessed = await preprocessPrompt(promptToUse, 'creation');
+ const finalPrompt = preprocessed.cleanedText;
+
+ // Mark as generating
+ await pool.query(
+ `UPDATE games SET
+ status = 'generating',
+ generation_started_at = NOW(),
+ creation_prompt = $2,
+ prompt_was_edited = COALESCE($3, false),
+ prompt_edit_count = CASE WHEN $3 = true THEN COALESCE(prompt_edit_count, 0) + 1 ELSE prompt_edit_count END
+ WHERE id = $1`,
+ [gameId, finalPrompt, promptWasEdited || false]
+ );
+
+ // Start generation in background (don't await)
+ const styleId = game.game_style || 'freeform';
+ const answers = game.prompt_answers || {};
+ runBackgroundGeneration(gameId, req.user.id, finalPrompt, styleId, answers, game.title);
+
+ // Return immediately
+ res.json({
+ status: 'generating',
+ gameId: parseInt(gameId),
+ message: 'Generation started! Connect to the stream endpoint for real-time progress.'
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Step 3: Update game after playing (track creator testing)
+router.post('/:gameId/test',
+ authenticate,
+ validate([
+ body('durationSeconds').isInt({ min: 0 }).withMessage('Duration required'),
+ body('levelsCompleted').optional().isInt({ min: 0 }),
+ body('thumbnailData').optional().isString().isLength({ max: 500000 }) // ~375KB base64
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { durationSeconds, levelsCompleted = 0, thumbnailData } = req.body;
+
+ // Get the game
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ if (game.status !== 'testing') {
+ return res.status(400).json({ error: 'Game is not in testing phase' });
+ }
+
+ // Update testing progress
+ const newDuration = (game.creator_play_duration || 0) + durationSeconds;
+
+ // Save thumbnail if provided and we don't already have one
+ if (thumbnailData && !game.thumbnail_data) {
+ await pool.query(
+ `UPDATE games SET
+ creator_play_duration = $1,
+ creator_played = $2,
+ thumbnail_data = $3
+ WHERE id = $4`,
+ [newDuration, newDuration >= 60 || levelsCompleted >= 2, thumbnailData, gameId]
+ );
+ } else {
+ await pool.query(
+ `UPDATE games SET
+ creator_play_duration = $1,
+ creator_played = $2
+ WHERE id = $3`,
+ [newDuration, newDuration >= 60 || levelsCompleted >= 2, gameId]
+ );
+ }
+
+ // Check if ready to publish
+ const canPublish = newDuration >= 60 || levelsCompleted >= 2;
+
+ res.json({
+ message: canPublish
+ ? 'Testing complete! You can now publish your game.'
+ : 'Keep testing! Play for at least 1 minute or complete 2 levels.',
+ totalDuration: newDuration,
+ canPublish,
+ remainingTime: canPublish ? 0 : Math.max(0, 60 - newDuration),
+ thumbnailSaved: !!thumbnailData && !game.thumbnail_data
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Save thumbnail separately
+router.post('/:gameId/thumbnail',
+ authenticate,
+ validate([
+ body('thumbnailData').isString().isLength({ min: 100, max: 500000 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { thumbnailData } = req.body;
+
+ // Verify ownership
+ const gameResult = await pool.query(
+ 'SELECT id FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ await pool.query(
+ 'UPDATE games SET thumbnail_data = $1 WHERE id = $2',
+ [thumbnailData, gameId]
+ );
+
+ res.json({ message: 'Thumbnail saved', success: true });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Step 4: Refine game with feedback (now returns immediately, runs in background)
+router.post('/:gameId/refine',
+ authenticate,
+ validate([
+ body('feedback').trim().isLength({ min: 5, max: 1000 }).withMessage('Feedback required (5-1000 chars)')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { feedback } = req.body;
+
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ if (!['testing', 'draft'].includes(game.status)) {
+ const statusMessages = {
+ generating: 'Please wait for generation to complete before refining.',
+ refining: 'This game is already being refined. Please wait.',
+ published: 'Unpublish the game first to make changes.',
+ pending_review: 'Game is pending review and cannot be modified.',
+ rejected: 'This game was rejected and cannot be refined.'
+ };
+ return res.status(400).json({
+ error: statusMessages[game.status] || 'Cannot refine this game in its current state.'
+ });
+ }
+
+ const feedbackFilter = filterDescription(feedback);
+ if (feedbackFilter.blocked) {
+ logFilterAction(req.user.id, 'game_feedback', feedback, feedbackFilter);
+ return res.status(400).json({
+ error: feedbackFilter.message,
+ blocked: true,
+ category: feedbackFilter.categories[0] || 'content',
+ canRetry: true
+ });
+ }
+
+ if (game.game_code) {
+ await GameVersions.saveVersion(game.id, game.game_code, 'pre_refinement', feedback).catch(() => {});
+ }
+
+ const preprocessed = await preprocessPrompt(feedback, 'refine');
+ const cleanedFeedback = preprocessed.cleanedText;
+
+ await pool.query(
+ `UPDATE games SET pending_feedback = $1, status = 'refining' WHERE id = $2`,
+ [cleanedFeedback, gameId]
+ );
+
+ // Start refinement in background (don't await)
+ runBackgroundRefinement(gameId, req.user.id, game.game_code, cleanedFeedback, game.title, feedback);
+
+ res.json({
+ status: 'refining',
+ gameId: parseInt(gameId),
+ message: 'Refinement started! Connect to the stream endpoint for real-time progress.'
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Step 5: Publish the game
+router.post('/:gameId/publish',
+ authenticate,
+ validate([
+ body('title').optional().trim().isLength({ min: 1, max: 100 }),
+ body('description').optional().trim().isLength({ max: 500 }),
+ body('difficultyTags').optional().isArray(),
+ body('themeTags').optional().isArray(),
+ body('accessibilityFeatures').optional().isArray()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { title, description, difficultyTags, themeTags, accessibilityFeatures } = req.body;
+
+ // Content filtering for title
+ if (title) {
+ const titleFilter = filterTitle(title);
+ if (titleFilter.blocked) {
+ logFilterAction(req.user.id, 'game_title', title, titleFilter);
+ return res.status(400).json({
+ error: titleFilter.message,
+ blocked: true,
+ category: titleFilter.categories[0] || 'content',
+ canRetry: true
+ });
+ }
+ }
+
+ // Content filtering for description
+ if (description) {
+ const descFilter = filterDescription(description);
+ if (descFilter.blocked) {
+ logFilterAction(req.user.id, 'game_description', description, descFilter);
+ return res.status(400).json({
+ error: descFilter.message,
+ blocked: true,
+ category: descFilter.categories[0] || 'content',
+ canRetry: true
+ });
+ }
+ }
+
+ // Get the game
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ if (game.status === 'published') {
+ return res.status(400).json({ error: 'Game is already published' });
+ }
+
+ // Check if creator has tested enough
+ if (!game.creator_played) {
+ return res.status(400).json({
+ error: 'Please test your game for at least 1 minute before publishing.',
+ remainingTime: Math.max(0, 60 - (game.creator_play_duration || 0))
+ });
+ }
+
+ // Publish the game
+ await pool.query(
+ `UPDATE games SET
+ title = COALESCE($2, title),
+ description = COALESCE($3, description),
+ difficulty_tags = COALESCE($4, difficulty_tags),
+ theme_tags = COALESCE($5, theme_tags),
+ accessibility_features = COALESCE($6, accessibility_features),
+ status = 'published',
+ published_at = NOW()
+ WHERE id = $1`,
+ [gameId, title, description, difficultyTags, themeTags, accessibilityFeatures]
+ );
+
+ // Send publish notification email
+ const userResult = await pool.query('SELECT email FROM users WHERE id = $1', [req.user.id]);
+ if (userResult.rows.length > 0 && userResult.rows[0].email) {
+ const gameTitle = title || game.title;
+ sendNotificationEmail(
+ { email: userResult.rows[0].email },
+ {
+ title: 'Your Game is Live!',
+ message: `Your game "${gameTitle}" has been published to the arcade! Players can now find and play it.`,
+ link: `${process.env.FRONTEND_URL || 'https://gamercomp.com'}/play/${gameId}`
+ }
+ ).catch(() => {});
+ }
+
+ // Award XP for game creation
+ const xpResult = await XP.award(req.user.id, XP.XP_VALUES.GAME_CREATED, 'game_created', gameId);
+
+ // Update creation streak
+ const creationStreak = await Users.updateCreationStreak(req.user.id);
+
+ // Check for achievements
+ const gamesPublishedResult = await pool.query(
+ "SELECT COUNT(*) as count FROM games WHERE creator_id = $1 AND status = 'published'",
+ [req.user.id]
+ );
+ const gamesPublished = parseInt(gamesPublishedResult.rows[0].count);
+ const achievements = await Achievements.checkAndAward(req.user.id, 'games_published', gamesPublished);
+
+ // Check for creation streak achievement (4 weeks in a row)
+ if (creationStreak >= 4) {
+ const streakAchievement = await Achievements.award(req.user.id, 'creation_streak');
+ if (streakAchievement) achievements.push(streakAchievement);
+ }
+
+ // Check for "Prompt Pro" (no edits needed) - if published on first try
+ const promptAnswers = game.prompt_answers || {};
+ const detailCount = countDetails(promptAnswers);
+ if (detailCount >= 5) {
+ const a = await Achievements.award(req.user.id, 'detailed_prompt');
+ if (a) achievements.push(a);
+ }
+
+ // Check for accessibility achievement
+ if (accessibilityFeatures && accessibilityFeatures.length > 0) {
+ const a = await Achievements.award(req.user.id, 'accessibility');
+ if (a) achievements.push(a);
+ }
+
+ // Notify achievements
+ for (const achievement of achievements) {
+ await Notifications.notifyAchievement(req.user.id, achievement.name, achievement.icon);
+ }
+
+ res.json({
+ message: 'Your game is now live in the arcade!',
+ game: {
+ id: gameId,
+ title: title || game.title,
+ status: 'published',
+ url: `/play/${gameId}`
+ },
+ xp: xpResult,
+ achievements: achievements.map(a => ({
+ code: a.code,
+ name: a.name,
+ icon: a.icon,
+ xpReward: a.xp_reward
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// REMIX FLOW
+// ==========================================
+
+// Clone a game for remixing
+router.post('/:gameId/remix',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+
+ // Check if guest
+ if (req.user.is_guest) {
+ return res.status(403).json({
+ error: 'Guests cannot create games. Please create an account first.'
+ });
+ }
+
+ // Get the original game
+ const originalResult = await pool.query(
+ `SELECT g.*, u.username as creator_username
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ WHERE g.id = $1 AND g.status = 'published'`,
+ [gameId]
+ );
+
+ if (originalResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const original = originalResult.rows[0];
+
+ if (!original.allow_remix) {
+ return res.status(403).json({ error: 'This game does not allow remixes' });
+ }
+
+ // Hierarchical versioning: parse source title for version info
+ const { base, version } = parseGameVersion(original.title);
+
+ let newTitle;
+ if (!version) {
+ // Original game (no version) - count remixes to get next top-level version
+ const remixCountResult = await pool.query(
+ 'SELECT COUNT(*) as count FROM games WHERE remixed_from_id = $1',
+ [gameId]
+ );
+ const versionNum = parseInt(remixCountResult.rows[0].count) + 2; // original is v1, first remix is v2
+ newTitle = `${base} v${versionNum}`;
+ } else {
+ // Already versioned - create sub-version (v2 → v2.1, v2.1 → v2.1.1)
+ const remixCountResult = await pool.query(
+ 'SELECT COUNT(*) as count FROM games WHERE remixed_from_id = $1',
+ [gameId]
+ );
+ const subVersion = parseInt(remixCountResult.rows[0].count) + 1;
+ newTitle = `${base} v${version}.${subVersion}`;
+ }
+
+ // Create remix draft
+ const remixResult = await pool.query(
+ `INSERT INTO games (
+ creator_id, title, description, game_code, creation_prompt,
+ prompt_answers, game_style, status, remixed_from_id
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'testing', $8)
+ RETURNING id, title, status, created_at`,
+ [
+ req.user.id,
+ newTitle,
+ `Remixed from ${original.creator_username}'s "${original.title}"`,
+ original.game_code,
+ original.creation_prompt,
+ original.prompt_answers,
+ original.game_style,
+ gameId
+ ]
+ );
+
+ const remix = remixResult.rows[0];
+
+ // Increment remix count on original
+ await pool.query(
+ 'UPDATE games SET remix_count = remix_count + 1 WHERE id = $1',
+ [gameId]
+ );
+
+ // Award XP to original creator
+ if (original.creator_id !== req.user.id) {
+ await XP.award(original.creator_id, XP.XP_VALUES.GAME_REMIXED, 'game_remixed', gameId);
+
+ // Notify original creator
+ await Notifications.notifyGameRemixed(original.creator_id, original.title, req.user.username);
+
+ // Check remix achievements
+ const remixCountResult = await pool.query(
+ 'SELECT SUM(remix_count) as total FROM games WHERE creator_id = $1',
+ [original.creator_id]
+ );
+ const totalRemixes = parseInt(remixCountResult.rows[0].total) || 0;
+ if (totalRemixes === 1) {
+ await Achievements.award(original.creator_id, 'remixed');
+ } else if (totalRemixes === 10) {
+ await Achievements.award(original.creator_id, 'ten_remixes');
+ }
+ }
+
+ res.status(201).json({
+ message: 'Remix created! You can now modify and test it.',
+ game: {
+ id: remix.id,
+ title: remix.title,
+ status: remix.status,
+ createdAt: remix.created_at
+ },
+ original: {
+ id: original.id,
+ title: original.title,
+ creatorUsername: original.creator_username
+ },
+ promptAnswers: original.prompt_answers,
+ creationPrompt: original.creation_prompt
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get creation progress for a draft game
+router.get('/:gameId/status',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+
+ const result = await pool.query(
+ `SELECT id, title, description, status, game_style, prompt_answers,
+ creation_prompt, original_prompt, prompt_was_edited, prompt_edit_count,
+ creator_played, creator_play_duration,
+ game_code, pending_feedback, generation_error,
+ generation_started_at, generation_completed_at, created_at
+ FROM games
+ WHERE id = $1 AND creator_id = $2`,
+ [gameId, req.user.id]
+ );
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = result.rows[0];
+
+ // Calculate generation elapsed time if still generating
+ let generationElapsed = null;
+ if (game.status === 'generating' && game.generation_started_at) {
+ generationElapsed = Math.floor((Date.now() - new Date(game.generation_started_at).getTime()) / 1000);
+ }
+
+ res.json({
+ game: {
+ id: game.id,
+ title: game.title,
+ description: game.description,
+ status: game.status,
+ gameStyle: game.game_style,
+ promptAnswers: game.prompt_answers,
+ creationPrompt: game.creation_prompt,
+ originalPrompt: game.original_prompt,
+ promptWasEdited: game.prompt_was_edited,
+ promptEditCount: game.prompt_edit_count || 0,
+ hasCode: !!game.game_code,
+ gameCode: game.status !== 'published' ? game.game_code : null,
+ pendingFeedback: game.pending_feedback,
+ generationError: game.generation_error,
+ generationElapsed,
+ creatorPlayed: game.creator_played,
+ creatorPlayDuration: game.creator_play_duration || 0,
+ canPublish: game.creator_played,
+ remainingTestTime: Math.max(0, 60 - (game.creator_play_duration || 0)),
+ createdAt: game.created_at
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// VERSION HISTORY
+// ==========================================
+
+// Get version history for a game
+router.get('/:gameId/versions',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+
+ // Verify ownership
+ const gameResult = await pool.query(
+ 'SELECT id FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const versions = await GameVersions.getVersions(gameId);
+
+ res.json({
+ versions: versions.map(v => ({
+ id: v.id,
+ versionNumber: v.version_number,
+ changeType: v.change_type,
+ changeDescription: v.change_description,
+ codeLength: v.code_length,
+ inputTokens: v.claude_input_tokens,
+ outputTokens: v.claude_output_tokens,
+ apiCostCents: v.api_cost_cents,
+ createdAt: v.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Rollback to a previous version
+router.post('/:gameId/rollback',
+ authenticate,
+ validate([
+ body('versionNumber').isInt({ min: 1 }).withMessage('Version number required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { versionNumber } = req.body;
+
+ // Verify ownership
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ // Only allow rollback on testing/draft games
+ if (!['testing', 'draft'].includes(game.status)) {
+ return res.status(400).json({ error: 'Can only rollback games in testing or draft status' });
+ }
+
+ // Get the target version
+ const version = await GameVersions.getVersion(gameId, versionNumber);
+ if (!version) {
+ return res.status(404).json({ error: 'Version not found' });
+ }
+
+ // Save current code as pre-rollback snapshot
+ if (game.game_code) {
+ await GameVersions.saveVersion(gameId, game.game_code, 'pre_rollback', `Rollback to v${versionNumber}`).catch(() => {});
+ }
+
+ // Apply the old code and reset testing
+ await pool.query(
+ `UPDATE games SET
+ game_code = $1,
+ code_version = COALESCE(code_version, 0) + 1,
+ code_updated_at = NOW(),
+ creator_played = false,
+ creator_play_duration = 0,
+ status = 'testing'
+ WHERE id = $2`,
+ [version.game_code, gameId]
+ );
+
+ res.json({
+ message: `Rolled back to version ${versionNumber}`,
+ versionNumber
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/developer.js b/backend/src/routes/developer.js
new file mode 100644
index 0000000..6b751fa
--- /dev/null
+++ b/backend/src/routes/developer.js
@@ -0,0 +1,293 @@
+const express = require('express');
+const Games = require('../models/games');
+const { DeveloperEarnings } = require('../models/plays');
+const { pool } = require('../models/db');
+const { authenticate } = require('../middleware/auth');
+
+const router = express.Router();
+
+// Get developer dashboard
+router.get('/dashboard',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const games = await Games.findByCreator(req.user.id);
+ const earnings = await DeveloperEarnings.getByDeveloper(req.user.id);
+
+ const totalPlays = games.reduce((sum, g) => sum + g.play_count, 0);
+ const publishedGames = games.filter(g => g.status === 'published');
+ const pendingGames = games.filter(g => g.status === 'pending_review');
+ const draftGames = games.filter(g => g.status === 'draft');
+
+ res.json({
+ stats: {
+ totalGames: games.length,
+ publishedGames: publishedGames.length,
+ pendingReview: pendingGames.length,
+ drafts: draftGames.length,
+ totalPlays: totalPlays,
+ tokensEarned: parseInt(earnings.total_earned) || 0,
+ currentBalance: req.user.tokens_balance
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get developer's games with detailed stats
+router.get('/games',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const games = await Games.findByCreator(req.user.id);
+
+ res.json({
+ games: games.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ status: g.status,
+ playCount: g.play_count,
+ tokensEarned: g.tokens_earned,
+ averageRating: g.total_ratings > 0
+ ? (g.rating_sum / g.total_ratings).toFixed(1)
+ : null,
+ totalRatings: g.total_ratings,
+ createdAt: g.created_at,
+ publishedAt: g.published_at,
+ rejectionReason: g.rejection_reason
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Request payout (placeholder - would integrate with payment system)
+router.post('/request-payout',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const minPayout = 100;
+
+ if (req.user.tokens_balance < minPayout) {
+ return res.status(400).json({
+ error: `Minimum payout is ${minPayout} tokens`,
+ currentBalance: req.user.tokens_balance
+ });
+ }
+
+ // TODO: Integrate with payment system
+ // For now, just acknowledge the request
+
+ res.json({
+ message: 'Payout request received',
+ amount: req.user.tokens_balance,
+ note: 'Payout processing coming soon!'
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get game analytics (creator only)
+router.get('/games/:gameId/analytics',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+
+ // Get the game and verify ownership
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.creator_id !== req.user.id) {
+ return res.status(403).json({ error: 'You can only view analytics for your own games' });
+ }
+
+ // Run all analytics queries in parallel for performance
+ const [
+ dailyPlaysResult,
+ ratingDistributionResult,
+ sessionStatsResult,
+ uniquePlayersResult,
+ highScoreDistributionResult,
+ peakHoursResult,
+ highestScoreResult
+ ] = await Promise.all([
+ // Daily play counts for last 30 days
+ pool.query(
+ `SELECT DATE(started_at) as date, COUNT(*) as count
+ FROM plays
+ WHERE game_id = $1
+ AND started_at >= CURRENT_DATE - INTERVAL '30 days'
+ GROUP BY DATE(started_at)
+ ORDER BY date ASC`,
+ [gameId]
+ ),
+
+ // Rating distribution (1-5 stars breakdown)
+ pool.query(
+ `SELECT rating, COUNT(*) as count
+ FROM ratings
+ WHERE game_id = $1
+ GROUP BY rating
+ ORDER BY rating ASC`,
+ [gameId]
+ ),
+
+ // Average session duration
+ pool.query(
+ `SELECT
+ AVG(duration_seconds) as avg_duration,
+ MIN(duration_seconds) as min_duration,
+ MAX(duration_seconds) as max_duration,
+ COUNT(*) as total_sessions
+ FROM plays
+ WHERE game_id = $1 AND duration_seconds IS NOT NULL`,
+ [gameId]
+ ),
+
+ // Total unique players
+ pool.query(
+ `SELECT COUNT(DISTINCT player_id) as unique_players
+ FROM plays
+ WHERE game_id = $1`,
+ [gameId]
+ ),
+
+ // High score distribution (buckets)
+ pool.query(
+ `SELECT
+ CASE
+ WHEN score < 100 THEN '0-99'
+ WHEN score < 500 THEN '100-499'
+ WHEN score < 1000 THEN '500-999'
+ WHEN score < 5000 THEN '1000-4999'
+ WHEN score < 10000 THEN '5000-9999'
+ ELSE '10000+'
+ END as score_range,
+ COUNT(*) as count
+ FROM high_scores
+ WHERE game_id = $1
+ GROUP BY
+ CASE
+ WHEN score < 100 THEN '0-99'
+ WHEN score < 500 THEN '100-499'
+ WHEN score < 1000 THEN '500-999'
+ WHEN score < 5000 THEN '1000-4999'
+ WHEN score < 10000 THEN '5000-9999'
+ ELSE '10000+'
+ END
+ ORDER BY MIN(score) ASC`,
+ [gameId]
+ ),
+
+ // Peak play hours (hour of day distribution)
+ pool.query(
+ `SELECT EXTRACT(HOUR FROM started_at) as hour, COUNT(*) as count
+ FROM plays
+ WHERE game_id = $1
+ GROUP BY EXTRACT(HOUR FROM started_at)
+ ORDER BY hour ASC`,
+ [gameId]
+ ),
+
+ // Highest score
+ pool.query(
+ `SELECT MAX(score) as highest_score FROM high_scores WHERE game_id = $1`,
+ [gameId]
+ )
+ ]);
+
+ // Build daily plays with date labels (last 30 days filled in)
+ const today = new Date();
+ const thirtyDaysAgo = new Date(today);
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+
+ const dailyPlaysMap = new Map(
+ dailyPlaysResult.rows.map(row => [
+ row.date.toISOString().split('T')[0],
+ parseInt(row.count)
+ ])
+ );
+
+ const dailyPlays = [];
+ for (let d = new Date(thirtyDaysAgo); d <= today; d.setDate(d.getDate() + 1)) {
+ const dateStr = d.toISOString().split('T')[0];
+ const dayNum = d.getDate();
+ dailyPlays.push({
+ date: dateStr,
+ plays: dailyPlaysMap.get(dateStr) || 0,
+ label: dayNum.toString()
+ });
+ }
+
+ // Build rating distribution as object {1: count, 2: count, ...}
+ const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
+ ratingDistributionResult.rows.forEach(row => {
+ ratingDistribution[row.rating] = parseInt(row.count);
+ });
+
+ // Build peak hours as object {0: count, 1: count, ...23: count}
+ const peakHours = {};
+ for (let hour = 0; hour < 24; hour++) {
+ peakHours[hour] = 0;
+ }
+ peakHoursResult.rows.forEach(row => {
+ peakHours[parseInt(row.hour)] = parseInt(row.count);
+ });
+
+ // Build score distribution as object with range labels
+ const scoreRanges = ['0-99', '100-499', '500-999', '1000-4999', '5000-9999', '10000+'];
+ const scoreDistribution = {};
+ scoreRanges.forEach(range => { scoreDistribution[range] = 0; });
+ highScoreDistributionResult.rows.forEach(row => {
+ scoreDistribution[row.score_range] = parseInt(row.count);
+ });
+
+ const sessionStats = sessionStatsResult.rows[0];
+ const avgSessionDuration = sessionStats.avg_duration
+ ? Math.round(parseFloat(sessionStats.avg_duration))
+ : 0;
+
+ const uniquePlayers = parseInt(uniquePlayersResult.rows[0].unique_players);
+ const highestScore = highestScoreResult.rows[0].highest_score
+ ? parseInt(highestScoreResult.rows[0].highest_score)
+ : null;
+
+ const avgRating = game.total_ratings > 0
+ ? parseFloat((game.rating_sum / game.total_ratings).toFixed(2))
+ : null;
+
+ res.json({
+ dailyPlays,
+ ratingDistribution,
+ peakHours,
+ scoreDistribution,
+ summary: {
+ totalPlays: game.play_count || 0,
+ uniquePlayers,
+ avgSessionDuration,
+ favoriteCount: game.favorite_count || 0,
+ avgRating,
+ totalRatings: game.total_ratings || 0,
+ highestScore,
+ remixCount: game.remix_count || 0,
+ tokensEarned: game.tokens_earned || 0
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js
new file mode 100644
index 0000000..a4b2342
--- /dev/null
+++ b/backend/src/routes/games.js
@@ -0,0 +1,287 @@
+const express = require('express');
+const { body } = require('express-validator');
+const Games = require('../models/games');
+const { pool } = require('../models/db');
+const { authenticate } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const { generateGame, regenerateGame } = require('../utils/claude');
+
+const router = express.Router();
+
+// Create game with AI
+router.post('/create',
+ authenticate,
+ validate([
+ body('description')
+ .trim()
+ .isLength({ min: 10, max: 1000 })
+ .withMessage('Game description must be 10-1000 characters'),
+ body('title')
+ .optional()
+ .trim()
+ .isLength({ min: 1, max: 100 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { description, title } = req.body;
+
+ // Check if user is a guest
+ if (req.user.is_guest) {
+ return res.status(403).json({
+ error: 'Guests cannot create games. Please create an account first.'
+ });
+ }
+
+ // Generate game with Claude
+ const result = await generateGame(description);
+
+ if (!result.success) {
+ return res.status(500).json({ error: 'Failed to generate game. Please try again.' });
+ }
+
+ // Create game in database
+ const gameTitle = title || `Game by ${req.user.username}`;
+ const game = await Games.create({
+ creatorId: req.user.id,
+ title: gameTitle,
+ description: description,
+ gameCode: result.code,
+ prompt: description
+ });
+
+ res.status(201).json({
+ message: 'Game created successfully',
+ game: {
+ id: game.id,
+ title: game.title,
+ description: game.description,
+ status: game.status,
+ createdAt: game.created_at
+ },
+ previewCode: result.code,
+ tokensUsed: result.tokensUsed || 0
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get game by ID
+router.get('/:gameId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ // Only show code if owner or game is published
+ const isOwner = game.creator_id === req.user.id;
+ const isPublished = game.status === 'published';
+
+ if (!isOwner && !isPublished) {
+ return res.status(403).json({ error: 'Game not available' });
+ }
+
+ // Prevent caching of game code responses
+ res.set({
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+
+ res.json({
+ game: {
+ id: game.id,
+ title: game.title,
+ description: game.description,
+ gameCode: game.game_code,
+ codeVersion: game.code_version || 1,
+ prompt: game.prompt,
+ creationPrompt: game.creation_prompt,
+ originalPrompt: game.original_prompt,
+ promptWasEdited: game.prompt_was_edited || false,
+ promptEditCount: game.prompt_edit_count || 0,
+ promptAnswers: game.prompt_answers,
+ status: game.status,
+ playCount: game.play_count,
+ totalRatings: game.total_ratings,
+ ratingSum: game.rating_sum,
+ creatorId: game.creator_id,
+ creatorUsername: game.creator_username,
+ creatorDisplayName: game.creator_display_name,
+ createdAt: game.created_at,
+ publishedAt: game.published_at,
+ creatorPlayed: game.creator_played,
+ creatorPlayDuration: game.creator_play_duration || 0,
+ isOwner
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Update game
+router.put('/:gameId',
+ authenticate,
+ validate([
+ body('title').optional().trim().isLength({ min: 1, max: 100 }),
+ body('description').optional().trim().isLength({ min: 10, max: 1000 }),
+ body('feedback').optional().trim().isLength({ min: 5, max: 1000 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { title, description, feedback } = req.body;
+
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.creator_id !== req.user.id) {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+
+ if (game.status === 'published') {
+ return res.status(400).json({ error: 'Cannot edit published games' });
+ }
+
+ let updatedCode = game.game_code;
+ let tokensUsed = 0;
+
+ // If feedback provided, regenerate with Claude
+ if (feedback) {
+ const result = await regenerateGame(game.game_code, feedback);
+ if (result.success) {
+ updatedCode = result.code;
+ tokensUsed = result.tokensUsed || 0;
+ } else {
+ return res.status(500).json({ error: 'Failed to regenerate game. Please try again.' });
+ }
+ }
+
+ const updated = await Games.update(gameId, {
+ title,
+ description,
+ gameCode: feedback ? updatedCode : undefined
+ });
+
+ res.json({
+ message: 'Game updated',
+ game: {
+ id: updated.id,
+ title: updated.title,
+ description: updated.description,
+ status: updated.status,
+ gameCode: updated.game_code
+ },
+ tokensUsed
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Submit for review
+router.post('/:gameId/submit',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.creator_id !== req.user.id) {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+
+ if (game.status !== 'draft') {
+ return res.status(400).json({ error: 'Game has already been submitted' });
+ }
+
+ const updated = await Games.submit(gameId);
+
+ res.json({
+ message: 'Game submitted for review',
+ game: { id: updated.id, status: updated.status }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Unpublish game (creator only)
+router.post('/:gameId/unpublish',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.creator_id !== req.user.id) {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+
+ if (game.status !== 'published') {
+ return res.status(400).json({ error: 'Game is not published' });
+ }
+
+ // Unpublish - set status back to testing so they can re-publish
+ await pool.query(
+ `UPDATE games SET status = 'testing', published_at = NULL WHERE id = $1`,
+ [gameId]
+ );
+
+ res.json({
+ message: 'Game unpublished. You can edit and re-publish it.',
+ game: { id: parseInt(gameId), status: 'testing' }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Delete game
+router.delete('/:gameId',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.creator_id !== req.user.id && req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+
+ await Games.delete(gameId);
+
+ res.json({ message: 'Game deleted' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/leaderboard.js b/backend/src/routes/leaderboard.js
new file mode 100644
index 0000000..11c1b94
--- /dev/null
+++ b/backend/src/routes/leaderboard.js
@@ -0,0 +1,348 @@
+const express = require('express');
+const { query } = require('express-validator');
+const { pool } = require('../models/db');
+const validate = require('../middleware/validate');
+
+const router = express.Router();
+
+// ==========================================
+// LEADERBOARD ENDPOINTS
+// Fun leaderboards for our gaming community!
+// ==========================================
+
+/**
+ * GET /api/leaderboard/players
+ * Top players by XP - Who's leveling up the fastest?
+ */
+router.get('/players',
+ validate([
+ query('page').optional().isInt({ min: 1 }),
+ query('limit').optional().isInt({ min: 1, max: 50 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const page = parseInt(req.query.page) || 1;
+ const limit = parseInt(req.query.limit) || 20;
+ const offset = (page - 1) * limit;
+
+ // Get top players by XP
+ const result = await pool.query(
+ `SELECT
+ u.id,
+ u.username,
+ u.display_name,
+ u.avatar_data,
+ u.xp_total,
+ u.creator_level,
+ u.creator_badge,
+ (SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
+ (SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays_received
+ FROM users u
+ WHERE u.is_banned = false AND u.xp_total > 0
+ ORDER BY u.xp_total DESC, u.created_at ASC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+
+ // Get total count for pagination info
+ const countResult = await pool.query(
+ 'SELECT COUNT(*) FROM users WHERE is_banned = false AND xp_total > 0'
+ );
+ const totalCount = parseInt(countResult.rows[0].count);
+
+ res.json({
+ leaderboard: result.rows.map((player, index) => ({
+ rank: offset + index + 1,
+ id: player.id,
+ username: player.username,
+ displayName: player.display_name,
+ avatarData: player.avatar_data,
+ xp: player.xp_total,
+ level: player.creator_level,
+ badge: player.creator_badge,
+ gamesCreated: parseInt(player.games_created) || 0,
+ totalPlaysReceived: parseInt(player.total_plays_received) || 0
+ })),
+ pagination: {
+ page,
+ limit,
+ totalCount,
+ totalPages: Math.ceil(totalCount / limit),
+ hasMore: offset + result.rows.length < totalCount
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+/**
+ * GET /api/leaderboard/creators
+ * Top creators by total plays on their games - Who makes the most popular games?
+ */
+router.get('/creators',
+ validate([
+ query('page').optional().isInt({ min: 1 }),
+ query('limit').optional().isInt({ min: 1, max: 50 }),
+ query('period').optional().isIn(['week', 'month', 'all'])
+ ]),
+ async (req, res, next) => {
+ try {
+ const page = parseInt(req.query.page) || 1;
+ const limit = parseInt(req.query.limit) || 20;
+ const period = req.query.period || 'all';
+ const offset = (page - 1) * limit;
+
+ // Build date filter based on period
+ let dateFilter = '';
+ if (period === 'week') {
+ dateFilter = "AND p.started_at >= NOW() - INTERVAL '7 days'";
+ } else if (period === 'month') {
+ dateFilter = "AND p.started_at >= NOW() - INTERVAL '30 days'";
+ }
+
+ // Get top creators by total plays
+ const result = await pool.query(
+ `SELECT
+ u.id,
+ u.username,
+ u.display_name,
+ u.avatar_data,
+ u.xp_total,
+ u.creator_level,
+ u.creator_badge,
+ COUNT(DISTINCT g.id) as games_count,
+ COUNT(p.id) as total_plays,
+ COALESCE(SUM(g.favorite_count), 0) as total_favorites,
+ COALESCE(
+ AVG(CASE WHEN g.total_ratings > 0 THEN g.rating_sum::float / g.total_ratings END),
+ 0
+ ) as avg_rating
+ FROM users u
+ JOIN games g ON g.creator_id = u.id AND g.status = 'published'
+ LEFT JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
+ WHERE u.is_banned = false
+ GROUP BY u.id
+ HAVING COUNT(p.id) > 0
+ ORDER BY total_plays DESC, games_count DESC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+
+ // Get total count
+ const countResult = await pool.query(
+ `SELECT COUNT(DISTINCT u.id)
+ FROM users u
+ JOIN games g ON g.creator_id = u.id AND g.status = 'published'
+ JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
+ WHERE u.is_banned = false`
+ );
+ const totalCount = parseInt(countResult.rows[0].count);
+
+ res.json({
+ leaderboard: result.rows.map((creator, index) => ({
+ rank: offset + index + 1,
+ id: creator.id,
+ username: creator.username,
+ displayName: creator.display_name,
+ avatarData: creator.avatar_data,
+ xp: creator.xp_total,
+ level: creator.creator_level,
+ badge: creator.creator_badge,
+ gamesCount: parseInt(creator.games_count) || 0,
+ totalPlays: parseInt(creator.total_plays) || 0,
+ totalFavorites: parseInt(creator.total_favorites) || 0,
+ avgRating: parseFloat(creator.avg_rating).toFixed(1) || '0.0'
+ })),
+ period,
+ pagination: {
+ page,
+ limit,
+ totalCount,
+ totalPages: Math.ceil(totalCount / limit),
+ hasMore: offset + result.rows.length < totalCount
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+/**
+ * GET /api/leaderboard/games
+ * Most played games - What's everyone playing?
+ */
+router.get('/games',
+ validate([
+ query('page').optional().isInt({ min: 1 }),
+ query('limit').optional().isInt({ min: 1, max: 50 }),
+ query('period').optional().isIn(['week', 'month', 'all'])
+ ]),
+ async (req, res, next) => {
+ try {
+ const page = parseInt(req.query.page) || 1;
+ const limit = parseInt(req.query.limit) || 20;
+ const period = req.query.period || 'all';
+ const offset = (page - 1) * limit;
+
+ // For week/month, we need to count recent plays
+ // For all-time, we can use the play_count column directly
+ let orderBy = 'g.play_count DESC';
+ let selectPlays = 'g.play_count as period_plays';
+ let dateFilter = '';
+
+ if (period === 'week' || period === 'month') {
+ const interval = period === 'week' ? '7 days' : '30 days';
+ dateFilter = `AND p.started_at >= NOW() - INTERVAL '${interval}'`;
+
+ // Count plays in the time period
+ const result = await pool.query(
+ `SELECT
+ g.id,
+ g.title,
+ g.description,
+ g.thumbnail_url,
+ g.thumbnail_data,
+ g.play_count as all_time_plays,
+ g.favorite_count,
+ g.total_ratings,
+ g.rating_sum,
+ g.published_at,
+ g.game_style,
+ u.id as creator_id,
+ u.username as creator_username,
+ u.display_name as creator_display_name,
+ u.avatar_data as creator_avatar_data,
+ COUNT(p.id) as period_plays
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ LEFT JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
+ WHERE g.status = 'published' AND u.is_banned = false
+ GROUP BY g.id, u.id
+ HAVING COUNT(p.id) > 0
+ ORDER BY period_plays DESC, g.play_count DESC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+
+ // Get total count
+ const countResult = await pool.query(
+ `SELECT COUNT(DISTINCT g.id)
+ FROM games g
+ JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
+ WHERE g.status = 'published'`
+ );
+ const totalCount = parseInt(countResult.rows[0].count);
+
+ return res.json({
+ leaderboard: result.rows.map((game, index) => ({
+ rank: offset + index + 1,
+ id: game.id,
+ title: game.title,
+ description: game.description,
+ thumbnailUrl: game.thumbnail_url,
+ thumbnailData: game.thumbnail_data,
+ gameStyle: game.game_style,
+ periodPlays: parseInt(game.period_plays) || 0,
+ allTimePlays: parseInt(game.all_time_plays) || 0,
+ favoriteCount: parseInt(game.favorite_count) || 0,
+ avgRating: game.total_ratings > 0
+ ? (game.rating_sum / game.total_ratings).toFixed(1)
+ : '0.0',
+ totalRatings: game.total_ratings,
+ publishedAt: game.published_at,
+ creator: {
+ id: game.creator_id,
+ username: game.creator_username,
+ displayName: game.creator_display_name,
+ avatarData: game.creator_avatar_data
+ }
+ })),
+ period,
+ pagination: {
+ page,
+ limit,
+ totalCount,
+ totalPages: Math.ceil(totalCount / limit),
+ hasMore: offset + result.rows.length < totalCount
+ }
+ });
+ }
+
+ // All-time leaderboard using play_count column
+ const result = await pool.query(
+ `SELECT
+ g.id,
+ g.title,
+ g.description,
+ g.thumbnail_url,
+ g.thumbnail_data,
+ g.play_count,
+ g.favorite_count,
+ g.total_ratings,
+ g.rating_sum,
+ g.published_at,
+ g.game_style,
+ u.id as creator_id,
+ u.username as creator_username,
+ u.display_name as creator_display_name,
+ u.avatar_data as creator_avatar_data
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ WHERE g.status = 'published' AND u.is_banned = false AND g.play_count > 0
+ ORDER BY g.play_count DESC, g.published_at DESC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ );
+
+ // Get total count
+ const countResult = await pool.query(
+ `SELECT COUNT(*)
+ FROM games g
+ JOIN users u ON g.creator_id = u.id
+ WHERE g.status = 'published' AND u.is_banned = false AND g.play_count > 0`
+ );
+ const totalCount = parseInt(countResult.rows[0].count);
+
+ res.json({
+ leaderboard: result.rows.map((game, index) => ({
+ rank: offset + index + 1,
+ id: game.id,
+ title: game.title,
+ description: game.description,
+ thumbnailUrl: game.thumbnail_url,
+ thumbnailData: game.thumbnail_data,
+ gameStyle: game.game_style,
+ periodPlays: parseInt(game.play_count) || 0,
+ allTimePlays: parseInt(game.play_count) || 0,
+ favoriteCount: parseInt(game.favorite_count) || 0,
+ avgRating: game.total_ratings > 0
+ ? (game.rating_sum / game.total_ratings).toFixed(1)
+ : '0.0',
+ totalRatings: game.total_ratings,
+ publishedAt: game.published_at,
+ creator: {
+ id: game.creator_id,
+ username: game.creator_username,
+ displayName: game.creator_display_name,
+ avatarData: game.creator_avatar_data
+ }
+ })),
+ period,
+ pagination: {
+ page,
+ limit,
+ totalCount,
+ totalPages: Math.ceil(totalCount / limit),
+ hasMore: offset + result.rows.length < totalCount
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/localai.js b/backend/src/routes/localai.js
new file mode 100644
index 0000000..1e52dd0
--- /dev/null
+++ b/backend/src/routes/localai.js
@@ -0,0 +1,409 @@
+const express = require('express');
+const { body } = require('express-validator');
+const { pool } = require('../models/db');
+const { authenticate } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const { filterDescription, logFilterAction } = require('../utils/contentFilter');
+const GameVersions = require('../models/gameVersions');
+const { refinePrompt, classifyTweak, applyTweak, isAvailable } = require('../utils/localAI');
+const { preprocessTweak, explainError, summarizeCodeChanges } = require('../utils/haiku');
+
+const router = express.Router();
+
+// ==========================================
+// HEALTH CHECK (no auth)
+// ==========================================
+
+router.get('/health', async (req, res) => {
+ const health = await isAvailable();
+ res.json(health);
+});
+
+// ==========================================
+// STAGE 1: PROMPT REFINEMENT CHAT
+// ==========================================
+
+router.post('/refine-prompt',
+ authenticate,
+ validate([
+ body('gameIdea').trim().isLength({ min: 3, max: 1000 }).withMessage('Game idea required (3-1000 chars)'),
+ body('conversationHistory').isArray().withMessage('Conversation history must be an array')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameIdea, conversationHistory } = req.body;
+
+ // Content filter the game idea
+ const ideaFilter = filterDescription(gameIdea);
+ if (ideaFilter.blocked) {
+ logFilterAction(req.user.id, 'prompt_refine', gameIdea, ideaFilter);
+ return res.status(400).json({
+ error: ideaFilter.message,
+ blocked: true
+ });
+ }
+
+ // Filter latest user message in conversation
+ if (conversationHistory.length > 0) {
+ const lastUserMsg = [...conversationHistory].reverse().find(m => m.role === 'user');
+ if (lastUserMsg) {
+ const msgFilter = filterDescription(lastUserMsg.text);
+ if (msgFilter.blocked) {
+ logFilterAction(req.user.id, 'prompt_refine_msg', lastUserMsg.text, msgFilter);
+ return res.status(400).json({
+ error: msgFilter.message,
+ blocked: true
+ });
+ }
+ }
+ }
+
+ const result = await refinePrompt(gameIdea, conversationHistory);
+
+ if (!result.success) {
+ return res.status(503).json({
+ error: 'AI refinement is temporarily unavailable. Please use the guided questions instead.',
+ unavailable: true
+ });
+ }
+
+ res.json({
+ question: result.question,
+ isComplete: result.isComplete,
+ refinedSpec: result.refinedSpec,
+ durationMs: result.durationMs
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// STAGE 3: CLASSIFY FEEDBACK
+// ==========================================
+
+router.post('/classify',
+ authenticate,
+ validate([
+ body('feedback').trim().isLength({ min: 5, max: 500 }).withMessage('Feedback required (5-500 chars)'),
+ body('gameId').isInt().withMessage('Valid game ID required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { feedback } = req.body;
+
+ const result = await classifyTweak(feedback);
+
+ // Estimate cost for Claude if major
+ const estimatedCostCents = result.classification === 'major_change' ? 5 : 0;
+
+ res.json({
+ classification: result.classification,
+ confidence: result.confidence,
+ reason: result.reason,
+ estimatedCostCents
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// STAGE 3: PREVIEW TWEAK (before applying)
+// ==========================================
+
+router.post('/tweak/:gameId/preview',
+ authenticate,
+ validate([
+ body('feedback').trim().isLength({ min: 5, max: 500 }).withMessage('Feedback required (5-500 chars)')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { feedback } = req.body;
+
+ // Get the game and verify ownership
+ const gameResult = await pool.query(
+ 'SELECT id, title, status, creator_id FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ // Content filter
+ const feedbackFilter = filterDescription(feedback);
+ if (feedbackFilter.blocked) {
+ logFilterAction(req.user.id, 'local_tweak_preview', feedback, feedbackFilter);
+ return res.status(400).json({
+ error: feedbackFilter.message,
+ blocked: true
+ });
+ }
+
+ // Haiku preprocessing - check feasibility and optimize
+ const preprocess = await preprocessTweak(feedback, game.title);
+
+ if (preprocess.needsClarification) {
+ return res.json({
+ canProceed: false,
+ needsClarification: true,
+ question: preprocess.question
+ });
+ }
+
+ if (preprocess.canTweak === false) {
+ return res.json({
+ canProceed: false,
+ tooComplex: true,
+ reason: preprocess.reason
+ });
+ }
+
+ // Return the optimized request for confirmation
+ res.json({
+ canProceed: true,
+ originalRequest: feedback,
+ optimizedRequest: preprocess.cleanedRequest || feedback,
+ wasOptimized: (preprocess.cleanedRequest && preprocess.cleanedRequest !== feedback)
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// STAGE 3: REVIEW MANUAL CODE CHANGES
+// ==========================================
+
+router.post('/review-code/:gameId',
+ authenticate,
+ validate([
+ body('newCode').isLength({ min: 100 }).withMessage('Code is too short')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { newCode } = req.body;
+
+ // Get the game and verify ownership
+ const gameResult = await pool.query(
+ 'SELECT id, title, game_code, status, creator_id FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ if (!['testing', 'draft'].includes(game.status)) {
+ return res.status(400).json({ error: 'Cannot edit code for this game in its current state.' });
+ }
+
+ // Use Haiku to summarize what changed
+ const summary = await summarizeCodeChanges(game.game_code, newCode, 'manual code edits');
+
+ res.json({
+ success: true,
+ summary,
+ oldCodeLength: (game.game_code || '').length,
+ newCodeLength: newCode.length
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// STAGE 3: SAVE MANUAL CODE CHANGES
+// ==========================================
+
+router.post('/save-code/:gameId',
+ authenticate,
+ validate([
+ body('newCode').isLength({ min: 100 }).withMessage('Code is too short')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { newCode } = req.body;
+
+ // Get the game and verify ownership
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ if (!['testing', 'draft'].includes(game.status)) {
+ return res.status(400).json({ error: 'Cannot edit code for this game in its current state.' });
+ }
+
+ // Save pre-edit version
+ await GameVersions.saveVersion(game.id, game.game_code, 'pre_manual_edit', 'Manual code edit').catch(() => {});
+
+ // Update game code
+ await pool.query(
+ `UPDATE games SET
+ game_code = $1,
+ code_version = COALESCE(code_version, 0) + 1,
+ code_updated_at = NOW()
+ WHERE id = $2`,
+ [newCode, gameId]
+ );
+
+ // Save post-edit version
+ await GameVersions.saveVersion(game.id, newCode, 'manual_edit', 'Manual code edit').catch(() => {});
+
+ // Get summary for display
+ const summary = await summarizeCodeChanges(game.game_code, newCode, 'manual code edits');
+
+ res.json({
+ success: true,
+ summary,
+ codeLength: newCode.length
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// STAGE 3: APPLY FREE TWEAK
+// ==========================================
+
+router.post('/tweak/:gameId',
+ authenticate,
+ validate([
+ body('feedback').trim().isLength({ min: 5, max: 500 }).withMessage('Feedback required (5-500 chars)')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const { feedback } = req.body;
+
+ // Get the game and verify ownership
+ const gameResult = await pool.query(
+ 'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (gameResult.rows.length === 0) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const game = gameResult.rows[0];
+
+ if (!['testing', 'draft'].includes(game.status)) {
+ return res.status(400).json({ error: 'Cannot tweak this game in its current state.' });
+ }
+
+ if (!game.game_code) {
+ return res.status(400).json({ error: 'No game code to tweak. Generate the game first.' });
+ }
+
+ // Content filter
+ const feedbackFilter = filterDescription(feedback);
+ if (feedbackFilter.blocked) {
+ logFilterAction(req.user.id, 'local_tweak', feedback, feedbackFilter);
+ return res.status(400).json({
+ error: feedbackFilter.message,
+ blocked: true
+ });
+ }
+
+ // Haiku preprocessing - check feasibility and clean up
+ const preprocess = await preprocessTweak(feedback, game.title);
+ if (preprocess.needsClarification) {
+ return res.json({
+ success: false,
+ needsClarification: true,
+ question: preprocess.question
+ });
+ }
+ if (preprocess.canTweak === false) {
+ return res.json({
+ success: false,
+ cannotTweak: true,
+ reason: preprocess.reason,
+ suggestOverhaul: true
+ });
+ }
+ const cleanedFeedback = preprocess.cleanedRequest || feedback;
+
+ // Save pre-tweak version
+ await GameVersions.saveVersion(game.id, game.game_code, 'pre_tweak', cleanedFeedback).catch(() => {});
+
+ // Apply tweak via Haiku
+ const result = await applyTweak(game.game_code, cleanedFeedback);
+
+ if (!result.success) {
+ const { friendlyError } = await explainError(result.error || 'Tweak failed', 'tweak');
+ return res.json({
+ success: false,
+ error: friendlyError,
+ rawError: result.error,
+ estimatedCostCents: 5
+ });
+ }
+
+ // Get Haiku summary of changes
+ const changesDescription = await summarizeCodeChanges(game.game_code, result.code, cleanedFeedback);
+
+ // Update game code
+ const tweakCount = (game.local_ai_tweaks_count || 0) + 1;
+ const savedCents = (game.local_ai_savings_cents || 0) + 5; // Each tweak saves ~$0.05
+
+ await pool.query(
+ `UPDATE games SET
+ game_code = $1,
+ code_version = COALESCE(code_version, 0) + 1,
+ code_updated_at = NOW(),
+ local_ai_tweaks_count = $2,
+ local_ai_savings_cents = $3
+ WHERE id = $4`,
+ [result.code, tweakCount, savedCents, gameId]
+ );
+
+ // Save post-tweak version
+ await GameVersions.saveVersion(game.id, result.code, 'local_tweak', feedback).catch(() => {});
+
+ // Update user stats
+ await pool.query(
+ `UPDATE users SET
+ total_local_ai_tweaks = COALESCE(total_local_ai_tweaks, 0) + 1,
+ total_local_ai_savings_cents = COALESCE(total_local_ai_savings_cents, 0) + 5
+ WHERE id = $1`,
+ [req.user.id]
+ ).catch(() => {});
+
+ res.json({
+ success: true,
+ gameCode: result.code,
+ changesDescription,
+ tweakCount,
+ savedCents,
+ durationMs: result.durationMs
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/play.js b/backend/src/routes/play.js
new file mode 100644
index 0000000..51d29ff
--- /dev/null
+++ b/backend/src/routes/play.js
@@ -0,0 +1,410 @@
+const express = require('express');
+const { body } = require('express-validator');
+const Games = require('../models/games');
+const Users = require('../models/users');
+const { Plays, HighScores, Ratings, DeveloperEarnings } = require('../models/plays');
+const Favorites = require('../models/favorites');
+const Achievements = require('../models/achievements');
+const XP = require('../models/xp');
+const { pool } = require('../models/db');
+const { authenticate, authenticateOrGuest, requireNonGuest } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+
+const router = express.Router();
+
+// Start play session - NO TOKEN DEDUCTION HERE
+// Tokens are only charged after 1 minute of play
+// Accepts both authenticated users AND guests (via fingerprint)
+router.post('/start',
+ authenticateOrGuest,
+ validate([
+ body('gameId').isInt().withMessage('Valid game ID required'),
+ body('fingerprint').optional().isString().isLength({ min: 10, max: 255 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.body;
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.status !== 'published') {
+ return res.status(400).json({ error: 'Game is not available to play' });
+ }
+
+ // Check if this is the creator playing their own game
+ const isCreatorPlay = game.creator_id === req.user.id;
+
+ // Only check tokens if not playing own game
+ if (!isCreatorPlay) {
+ const tokenBalance = await Users.getTokenBalance(req.user.id);
+ if (tokenBalance < 1) {
+ return res.status(402).json({
+ error: 'Not enough tokens. Come back tomorrow for 50 free tokens!',
+ tokensBalance: tokenBalance
+ });
+ }
+ }
+
+ // Create play session
+ const ipAddress = req.ip || req.connection.remoteAddress;
+ const play = await Plays.create({
+ playerId: req.user.id,
+ gameId: gameId,
+ deviceFingerprint: req.user.device_fingerprint,
+ ipAddress: ipAddress,
+ isCreatorPlay
+ });
+
+ // Increment game play count (only for non-creator plays)
+ if (!isCreatorPlay) {
+ await Games.incrementPlayCount(gameId);
+ }
+
+ const tokenBalance = await Users.getTokenBalance(req.user.id);
+
+ // Prevent caching of game code responses
+ res.set({
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+
+ res.json({
+ message: isCreatorPlay
+ ? 'Playing your own game (no tokens charged)'
+ : 'Play session started. Token will be charged after 1 minute of play.',
+ sessionId: play.id,
+ tokensBalance: tokenBalance,
+ gameCode: game.game_code,
+ codeVersion: game.code_version || 1,
+ isCreatorPlay,
+ isGuest: req.user.is_guest,
+ // If auto-created guest, include user info for frontend
+ ...(req.isAutoGuest && {
+ guestUser: {
+ id: req.user.id,
+ username: req.user.username,
+ tokensBalance: tokenBalance,
+ isGuest: true
+ }
+ })
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Finish play session
+router.post('/finish',
+ authenticateOrGuest,
+ validate([
+ body('sessionId').isInt().withMessage('Valid session ID required'),
+ body('score').optional().isInt({ min: 0 }),
+ body('durationSeconds').optional().isInt({ min: 0 }),
+ body('levelsCompleted').optional().isInt({ min: 0 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { sessionId, score, durationSeconds, levelsCompleted } = req.body;
+
+ const play = await Plays.findById(sessionId);
+ if (!play) {
+ return res.status(404).json({ error: 'Play session not found' });
+ }
+
+ if (play.player_id !== req.user.id) {
+ return res.status(403).json({ error: 'Not your play session' });
+ }
+
+ if (play.ended_at) {
+ return res.status(400).json({ error: 'Session already finished' });
+ }
+
+ const actualDuration = durationSeconds || Math.floor((Date.now() - new Date(play.started_at).getTime()) / 1000);
+
+ // Token handling
+ let tokensCharged = 0;
+ let newBalance = await Users.getTokenBalance(req.user.id);
+ let xpResult = null;
+ const earnedAchievements = [];
+
+ // Only charge tokens for non-creator plays
+ if (!play.is_creator_play && actualDuration >= 60 && !play.token_spent) {
+ const minutesPlayed = Math.floor(actualDuration / 60);
+ tokensCharged = Math.min(minutesPlayed, newBalance);
+
+ if (tokensCharged > 0) {
+ newBalance = await Users.updateTokens(req.user.id, -tokensCharged);
+
+ // Credit the game creator and award XP
+ const game = await Games.findById(play.game_id);
+ if (game && game.creator_id !== req.user.id) {
+ await Users.updateTokens(game.creator_id, tokensCharged);
+ await Games.addTokensEarned(game.id, tokensCharged);
+ await DeveloperEarnings.create({
+ developerId: game.creator_id,
+ gameId: game.id,
+ tokensEarned: tokensCharged,
+ playId: sessionId,
+ source: 'play'
+ });
+
+ // Award XP to creator for plays
+ await XP.award(game.creator_id, XP.XP_VALUES.GAME_PLAYED_BY_OTHERS, 'game_played', game.id);
+ }
+ }
+ }
+
+ // Award XP to player (not for guests)
+ if (!play.is_creator_play && !req.user.is_guest) {
+ xpResult = await XP.award(req.user.id, XP.XP_VALUES.PLAY_GAME, 'played_game', play.game_id);
+ }
+
+ // Finish the play session
+ await Plays.finish(sessionId, {
+ score,
+ durationSeconds: actualDuration,
+ levelsCompleted,
+ tokenSpent: tokensCharged > 0
+ });
+
+ // Update high score if provided (guests can get high scores but no XP/achievements)
+ let isNewHighScore = false;
+ if (score !== undefined && !play.is_creator_play) {
+ const highScoreResult = await HighScores.upsert({
+ gameId: play.game_id,
+ playerId: req.user.id,
+ score
+ });
+ isNewHighScore = highScoreResult.isNew;
+
+ // Check if #1 on leaderboard (achievements only for non-guests)
+ if (isNewHighScore && !req.user.is_guest) {
+ const topScore = await pool.query(
+ 'SELECT player_id FROM high_scores WHERE game_id = $1 ORDER BY score DESC LIMIT 1',
+ [play.game_id]
+ );
+ if (topScore.rows.length > 0 && topScore.rows[0].player_id === req.user.id) {
+ const a = await Achievements.award(req.user.id, 'high_scorer');
+ if (a) earnedAchievements.push(a);
+
+ // Bonus XP for high score
+ await XP.award(req.user.id, XP.XP_VALUES.GET_HIGH_SCORE, 'high_score', play.game_id);
+ }
+ }
+ }
+
+ // Check games played achievements (not for guests)
+ if (!play.is_creator_play && !req.user.is_guest) {
+ const playCountResult = await pool.query(
+ 'SELECT COUNT(DISTINCT game_id) as count FROM plays WHERE player_id = $1',
+ [req.user.id]
+ );
+ const gamesPlayed = parseInt(playCountResult.rows[0].count);
+ const gamesAchievements = await Achievements.checkAndAward(req.user.id, 'games_played', gamesPlayed);
+ earnedAchievements.push(...gamesAchievements);
+ }
+
+ res.json({
+ message: tokensCharged > 0
+ ? `Play session completed. ${tokensCharged} token(s) charged.`
+ : 'Play session completed.',
+ score,
+ durationSeconds: actualDuration,
+ tokensCharged,
+ tokensBalance: newBalance,
+ isNewHighScore,
+ xp: xpResult,
+ achievements: earnedAchievements.map(a => ({
+ code: a.code,
+ name: a.name,
+ icon: a.icon,
+ xpReward: a.xp_reward
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Rate game (guests can rate too)
+router.post('/rate',
+ authenticateOrGuest,
+ validate([
+ body('gameId').isInt().withMessage('Valid game ID required'),
+ body('rating').isInt({ min: 1, max: 5 }).withMessage('Rating must be 1-5'),
+ body('fingerprint').optional().isString().isLength({ min: 10, max: 255 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId, rating } = req.body;
+
+ const game = await Games.findById(gameId);
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.status !== 'published') {
+ return res.status(400).json({ error: 'Cannot rate unpublished games' });
+ }
+
+ // Can't rate own game
+ if (game.creator_id === req.user.id) {
+ return res.status(400).json({ error: 'Cannot rate your own game' });
+ }
+
+ // Upsert rating
+ await Ratings.upsert({ gameId, userId: req.user.id, rating });
+
+ // Award XP to creator for 5-star ratings (only from non-guest users)
+ if (rating === 5 && !req.user.is_guest) {
+ await XP.award(game.creator_id, XP.XP_VALUES.GAME_FIVE_STAR, 'five_star_rating', gameId);
+ }
+
+ res.json({ message: 'Rating saved', rating, isGuest: req.user.is_guest });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Toggle favorite (requires signed up user, not guest)
+router.post('/favorite',
+ authenticate,
+ requireNonGuest,
+ validate([
+ body('gameId').isInt().withMessage('Valid game ID required')
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId } = req.body;
+
+ const game = await Games.findById(gameId);
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ if (game.status !== 'published') {
+ return res.status(400).json({ error: 'Cannot favorite unpublished games' });
+ }
+
+ const result = await Favorites.toggle(req.user.id, gameId);
+
+ // Award XP to creator when favorited
+ if (result.favorited && game.creator_id !== req.user.id) {
+ await XP.award(game.creator_id, XP.XP_VALUES.GAME_FAVORITED, 'game_favorited', gameId);
+
+ // Track for developer earnings
+ await DeveloperEarnings.create({
+ developerId: game.creator_id,
+ gameId,
+ tokensEarned: 0,
+ source: 'favorite'
+ });
+ }
+
+ // Check favorites achievement
+ const favoritesCount = await Favorites.getCount(req.user.id);
+ const achievements = await Achievements.checkAndAward(req.user.id, 'favorites_count', favoritesCount);
+
+ res.json({
+ message: result.favorited ? 'Added to favorites' : 'Removed from favorites',
+ favorited: result.favorited,
+ achievements: achievements.map(a => ({
+ code: a.code,
+ name: a.name,
+ icon: a.icon
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Report game with optional comment and AI feedback
+router.post('/report',
+ authenticate,
+ validate([
+ body('gameId').isInt().withMessage('Valid game ID required'),
+ body('reason').isIn([
+ 'wont_load',
+ 'crashes',
+ 'broken',
+ 'inappropriate',
+ 'stolen',
+ 'other'
+ ]).withMessage('Invalid reason'),
+ body('comment').optional().trim().isLength({ max: 1000 })
+ ]),
+ async (req, res, next) => {
+ try {
+ const { gameId, reason, comment } = req.body;
+
+ const game = await Games.findById(gameId);
+ if (!game) {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ // Check for existing report from this user
+ const existingReport = await pool.query(
+ 'SELECT id FROM game_reports WHERE game_id = $1 AND reporter_id = $2',
+ [gameId, req.user.id]
+ );
+
+ if (existingReport.rows.length > 0) {
+ return res.status(400).json({ error: 'You have already reported this game' });
+ }
+
+ // Build description for admin
+ const reasonLabels = {
+ wont_load: "Won't Load",
+ crashes: "Game Crashes",
+ broken: "Broken/Unplayable",
+ inappropriate: "Inappropriate Content",
+ stolen: "Stolen/Copied",
+ other: "Other Issue"
+ };
+ const description = `${reasonLabels[reason] || reason}${comment ? ': ' + comment : ''}`;
+
+ await pool.query(
+ `INSERT INTO game_reports (game_id, reporter_id, reason, comment, description)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [gameId, req.user.id, reason, comment || null, description]
+ );
+
+ // Generate AI feedback for the reporter
+ let aiFeedback = null;
+ try {
+ const Anthropic = require('@anthropic-ai/sdk');
+ const client = new Anthropic({ apiKey: process.env.CLAUDE_API_KEY, timeout: 15000 });
+ const aiResult = await client.messages.create({
+ model: 'claude-haiku-4-5-20251001',
+ max_tokens: 150,
+ messages: [{
+ role: 'user',
+ content: `A kid reported an issue with a game called "${game.title}". Reason: ${reasonLabels[reason]}. ${comment ? 'Comment: ' + comment : ''}. Write a short, friendly 1-2 sentence acknowledgment for the reporter (age 8-16). Be encouraging and let them know we'll look into it. Don't use the word "sorry".`
+ }]
+ });
+ aiFeedback = aiResult.content[0]?.text || null;
+ } catch (aiErr) {
+ // AI feedback is optional - don't fail the report
+ console.error('AI feedback generation failed:', aiErr.message);
+ }
+
+ res.json({
+ message: 'Report submitted. Thank you for helping keep GamerComp safe!',
+ aiFeedback
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js
new file mode 100644
index 0000000..72393a1
--- /dev/null
+++ b/backend/src/routes/push.js
@@ -0,0 +1,53 @@
+const express = require('express');
+const { body } = require('express-validator');
+const { authenticate } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const pushNotify = require('../utils/pushNotify');
+
+const router = express.Router();
+
+// Get VAPID public key (no auth needed)
+router.get('/vapid-key', (req, res) => {
+ const key = process.env.VAPID_PUBLIC_KEY;
+ if (!key) {
+ return res.status(503).json({ error: 'Push notifications not configured' });
+ }
+ res.json({ publicKey: key });
+});
+
+// Subscribe to push notifications
+router.post('/subscribe',
+ authenticate,
+ validate([
+ body('subscription').isObject().withMessage('Subscription object required'),
+ body('subscription.endpoint').isURL().withMessage('Valid endpoint required'),
+ body('subscription.keys.auth').isString().withMessage('Auth key required'),
+ body('subscription.keys.p256dh').isString().withMessage('P256dh key required')
+ ]),
+ async (req, res, next) => {
+ try {
+ await pushNotify.subscribe(req.user.id, req.body.subscription);
+ res.json({ success: true, message: 'Push subscription saved' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Unsubscribe from push notifications
+router.post('/unsubscribe',
+ authenticate,
+ validate([
+ body('endpoint').isURL().withMessage('Endpoint required')
+ ]),
+ async (req, res, next) => {
+ try {
+ await pushNotify.unsubscribe(req.user.id, req.body.endpoint);
+ res.json({ success: true, message: 'Push subscription removed' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/share.js b/backend/src/routes/share.js
new file mode 100644
index 0000000..576c500
--- /dev/null
+++ b/backend/src/routes/share.js
@@ -0,0 +1,211 @@
+const express = require('express');
+const Games = require('../models/games');
+const { pool } = require('../models/db');
+
+const router = express.Router();
+
+// Generate share page with Open Graph meta tags
+router.get('/game/:gameId', async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game || game.status !== 'published') {
+ return res.status(404).send('Game not found');
+ }
+
+ const gameUrl = `https://gamercomp.com/play/${gameId}`;
+ const shareImage = `https://gamercomp.com/api/share/game/${gameId}/image`;
+ const description = game.description || `Play ${game.title} on GamerComp - created by ${game.creator_username}`;
+
+ // Serve HTML with meta tags for social media crawlers
+ res.send(`
+
+
+
+
+ ${escapeHtml(game.title)} - GamerComp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Redirecting to ${escapeHtml(game.title)} ...
+
+
+`);
+ } catch (error) {
+ next(error);
+ }
+});
+
+// Generate a simple share image (placeholder - returns a styled SVG)
+router.get('/game/:gameId/image', async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game) {
+ return res.status(404).send('Game not found');
+ }
+
+ // Generate a simple SVG image for sharing
+ const svg = `
+
+
+
+
+
+
+
+ ${escapeHtml(game.title)}
+ by ${escapeHtml(game.creator_username)}
+ ${game.play_count} plays
+ 🎮 GamerComp.com
+ `;
+
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.send(svg);
+ } catch (error) {
+ next(error);
+ }
+});
+
+// Embed page - serves game in a clean, embeddable format
+router.get('/embed/:gameId', async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game || game.status !== 'published') {
+ return res.status(404).send(`
+Game Not Found
+
+Game not found or not published.
+`);
+ }
+
+ // Increment play count for embeds
+ await Games.incrementPlayCount(gameId);
+
+ // Serve the game code directly in an embeddable page
+ res.send(`
+
+
+
+
+ ${escapeHtml(game.title)} - GamerComp
+
+
+
+
+
+
+
+
+`);
+ } catch (error) {
+ next(error);
+ }
+});
+
+// Get share data for a game (for frontend use)
+router.get('/data/:gameId', async (req, res, next) => {
+ try {
+ const { gameId } = req.params;
+ const game = await Games.findById(gameId);
+
+ if (!game || game.status !== 'published') {
+ return res.status(404).json({ error: 'Game not found' });
+ }
+
+ const baseUrl = 'https://gamercomp.com';
+
+ res.json({
+ title: game.title,
+ description: game.description || `Play ${game.title} on GamerComp`,
+ creator: game.creator_username,
+ playCount: game.play_count,
+ urls: {
+ play: `${baseUrl}/play/${gameId}`,
+ share: `${baseUrl}/api/share/game/${gameId}`,
+ embed: `${baseUrl}/api/share/embed/${gameId}`,
+ image: `${baseUrl}/api/share/game/${gameId}/image`
+ },
+ embedCode: ``,
+ social: {
+ twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out "${game.title}" on GamerComp! 🎮`)}&url=${encodeURIComponent(`${baseUrl}/api/share/game/${gameId}`)}`,
+ facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(`${baseUrl}/api/share/game/${gameId}`)}`,
+ reddit: `https://reddit.com/submit?url=${encodeURIComponent(`${baseUrl}/api/share/game/${gameId}`)}&title=${encodeURIComponent(game.title + ' - GamerComp')}`,
+ whatsapp: `https://wa.me/?text=${encodeURIComponent(`Check out "${game.title}" on GamerComp! 🎮 ${baseUrl}/api/share/game/${gameId}`)}`
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+function escapeHtml(text) {
+ if (!text) return '';
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+module.exports = router;
diff --git a/backend/src/routes/themes.js b/backend/src/routes/themes.js
new file mode 100644
index 0000000..dcc26f6
--- /dev/null
+++ b/backend/src/routes/themes.js
@@ -0,0 +1,128 @@
+const express = require('express');
+const Themes = require('../models/themes');
+const { authenticate, requireRole } = require('../middleware/auth');
+
+const router = express.Router();
+
+// Get current active theme (public)
+router.get('/current', async (req, res, next) => {
+ try {
+ const theme = await Themes.getCurrentTheme();
+
+ if (!theme) {
+ return res.json({ theme: null });
+ }
+
+ res.json({
+ theme: {
+ id: theme.id,
+ name: theme.name,
+ description: theme.description,
+ startDate: theme.start_date,
+ endDate: theme.end_date,
+ bonusXpMultiplier: parseFloat(theme.bonus_xp_multiplier),
+ daysRemaining: Math.ceil((new Date(theme.end_date) - new Date()) / (1000 * 60 * 60 * 24))
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+// Get upcoming themes (public)
+router.get('/upcoming', async (req, res, next) => {
+ try {
+ const themes = await Themes.getUpcomingThemes();
+
+ res.json({
+ themes: themes.map(t => ({
+ id: t.id,
+ name: t.name,
+ description: t.description,
+ startDate: t.start_date,
+ endDate: t.end_date,
+ bonusXpMultiplier: parseFloat(t.bonus_xp_multiplier)
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+// Get past themes (public)
+router.get('/past', async (req, res, next) => {
+ try {
+ const themes = await Themes.getPastThemes();
+
+ res.json({
+ themes: themes.map(t => ({
+ id: t.id,
+ name: t.name,
+ description: t.description,
+ startDate: t.start_date,
+ endDate: t.end_date
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+// Admin: Create new theme
+router.post('/',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const { name, description, startDate, endDate, bonusXpMultiplier } = req.body;
+
+ const theme = await Themes.create({
+ name,
+ description,
+ startDate,
+ endDate,
+ bonusXpMultiplier
+ });
+
+ res.status(201).json({ theme });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Admin: Activate theme
+router.post('/:themeId/activate',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const theme = await Themes.activate(req.params.themeId);
+ if (!theme) {
+ return res.status(404).json({ error: 'Theme not found' });
+ }
+ res.json({ theme, message: 'Theme activated' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Admin: Deactivate theme
+router.post('/:themeId/deactivate',
+ authenticate,
+ requireRole('admin'),
+ async (req, res, next) => {
+ try {
+ const theme = await Themes.deactivate(req.params.themeId);
+ if (!theme) {
+ return res.status(404).json({ error: 'Theme not found' });
+ }
+ res.json({ theme, message: 'Theme deactivated' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js
new file mode 100644
index 0000000..f175e7f
--- /dev/null
+++ b/backend/src/routes/users.js
@@ -0,0 +1,794 @@
+const express = require('express');
+const { body, param } = require('express-validator');
+const Users = require('../models/users');
+const Games = require('../models/games');
+const Favorites = require('../models/favorites');
+const Follows = require('../models/follows');
+const Collections = require('../models/collections');
+const Notifications = require('../models/notifications');
+const Achievements = require('../models/achievements');
+const XP = require('../models/xp');
+const { pool } = require('../models/db');
+const { authenticate, optionalAuth } = require('../middleware/auth');
+const validate = require('../middleware/validate');
+const { filterUsername, logFilterAction } = require('../utils/contentFilter');
+
+const router = express.Router();
+
+// ==========================================
+// CURRENT USER ENDPOINTS
+// ==========================================
+
+// Get current user profile (includes email - private)
+router.get('/me',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const xpStats = await XP.getUserStats(req.user.id);
+
+ res.json({
+ user: {
+ id: req.user.id,
+ username: req.user.username,
+ email: req.user.email,
+ displayName: req.user.display_name,
+ isGuest: req.user.is_guest,
+ tokensBalance: req.user.tokens_balance,
+ totalTokensEarned: req.user.total_tokens_earned,
+ role: req.user.role,
+ isTeacher: req.user.is_teacher || req.user.role === 'teacher',
+ createdAt: req.user.created_at,
+ avatarData: req.user.avatar_data,
+ xp: xpStats,
+ onboardingComplete: req.user.onboarding_complete || false,
+ onboardingStep: req.user.onboarding_step || 0,
+ loginStreak: req.user.daily_login_streak || 0,
+ creationStreak: req.user.weekly_creation_streak || 0,
+ totalApiCostCents: req.user.total_api_cost_cents || 0,
+ totalGenerations: req.user.total_generations || 0
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Update current user profile
+router.put('/me',
+ authenticate,
+ validate([
+ body('displayName').optional().trim().isLength({ min: 1, max: 50 }),
+ body('email').optional().isEmail().normalizeEmail()
+ ]),
+ async (req, res, next) => {
+ try {
+ const { displayName, email } = req.body;
+
+ if (email && email !== req.user.email) {
+ const existing = await Users.findByEmail(email);
+ if (existing) {
+ return res.status(409).json({ error: 'Email already in use' });
+ }
+ }
+
+ // Content filter for display name
+ if (displayName) {
+ const displayNameFilter = filterUsername(displayName);
+ if (displayNameFilter.blocked) {
+ logFilterAction(req.user.id, 'display_name', displayName, displayNameFilter);
+ return res.status(400).json({
+ error: displayNameFilter.message,
+ blocked: true,
+ canRetry: true
+ });
+ }
+ }
+
+ const updated = await Users.update(req.user.id, { displayName, email });
+
+ // Award profile customization achievement if display name changed
+ if (displayName && displayName !== req.user.display_name) {
+ const awarded = await Achievements.award(req.user.id, 'profile_customized');
+ if (awarded) {
+ await Notifications.notifyAchievement(req.user.id, 'Personal Touch', '✨');
+ }
+ }
+
+ res.json({
+ message: 'Profile updated',
+ user: {
+ id: updated.id,
+ username: updated.username,
+ email: updated.email,
+ displayName: updated.display_name,
+ tokensBalance: updated.tokens_balance,
+ role: updated.role
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Update current user's avatar
+router.put('/me/avatar',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { avatar } = req.body;
+
+ if (!avatar || typeof avatar !== 'object') {
+ return res.status(400).json({ error: 'Avatar data is required' });
+ }
+
+ // Validate avatar has expected properties
+ const validKeys = ['skinTone', 'hairStyle', 'hairColor', 'eyes', 'eyeColor', 'mouth', 'accessory', 'background'];
+ const hasValidKeys = validKeys.some(key => key in avatar);
+ if (!hasValidKeys) {
+ return res.status(400).json({ error: 'Invalid avatar data' });
+ }
+
+ await Users.updateAvatar(req.user.id, avatar);
+
+ // Award profile customization achievement
+ const awarded = await Achievements.award(req.user.id, 'profile_customized');
+ if (awarded) {
+ await Notifications.notifyAchievement(req.user.id, 'Personal Touch', '✨');
+ }
+
+ res.json({
+ message: 'Avatar updated',
+ avatar
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's games
+router.get('/me/games',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const result = await pool.query(
+ `SELECT id, title, description, thumbnail_url, status, play_count,
+ favorite_count, total_ratings, rating_sum, created_at, published_at
+ FROM games WHERE creator_id = $1 ORDER BY created_at DESC`,
+ [req.user.id]
+ );
+
+ res.json({
+ games: result.rows.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ thumbnailUrl: g.thumbnail_url,
+ status: g.status,
+ playCount: g.play_count,
+ favoriteCount: g.favorite_count,
+ averageRating: g.total_ratings > 0 ? (g.rating_sum / g.total_ratings).toFixed(1) : null,
+ totalRatings: g.total_ratings,
+ createdAt: g.created_at,
+ publishedAt: g.published_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's favorites
+router.get('/me/favorites',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const favorites = await Favorites.getUserFavorites(req.user.id);
+
+ res.json({
+ favorites: favorites.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ thumbnailUrl: g.thumbnail_url,
+ playCount: g.play_count,
+ creatorUsername: g.creator_username,
+ creatorDisplayName: g.creator_display_name,
+ favoritedAt: g.favorited_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's collections
+router.get('/me/collections',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const collections = await Collections.getUserCollections(req.user.id);
+
+ res.json({
+ collections: collections.map(c => ({
+ id: c.id,
+ name: c.name,
+ description: c.description,
+ isPublic: c.is_public,
+ gameCount: parseInt(c.game_count),
+ createdAt: c.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's achievements
+router.get('/me/achievements',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const showAll = req.query.all === 'true';
+ let achievements;
+
+ if (showAll) {
+ // Get all achievements with user's earned status
+ achievements = await Achievements.getAllWithUserStatus(req.user.id);
+ } else {
+ // Get only earned achievements
+ achievements = await Achievements.getUserAchievements(req.user.id);
+ }
+
+ res.json({
+ achievements: achievements.map(a => ({
+ id: a.id,
+ code: a.code,
+ name: a.name,
+ description: a.description,
+ icon: a.icon,
+ xpReward: a.xp_reward,
+ category: a.category,
+ threshold: a.threshold || null,
+ earnedAt: a.earned_at || null
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's notifications
+router.get('/me/notifications',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const unreadOnly = req.query.unread === 'true';
+ const notifications = await Notifications.getForUser(req.user.id, 50, unreadOnly);
+ const unreadCount = await Notifications.getUnreadCount(req.user.id);
+
+ res.json({
+ unreadCount,
+ notifications: notifications.map(n => ({
+ id: n.id,
+ type: n.type,
+ title: n.title,
+ body: n.message,
+ link: n.link,
+ read_at: n.is_read ? n.created_at : null,
+ created_at: n.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Mark notification as read
+router.post('/me/notifications/:id/read',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ await Notifications.markAsRead(req.params.id, req.user.id);
+ res.json({ message: 'Notification marked as read' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Mark all notifications as read
+router.post('/me/notifications/read-all',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ await Notifications.markAllAsRead(req.user.id);
+ res.json({ message: 'All notifications marked as read' });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's play history
+router.get('/me/play-history',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const result = await pool.query(
+ `SELECT
+ g.id as game_id,
+ g.title,
+ g.thumbnail_url,
+ COUNT(p.id) as play_count,
+ MAX(p.score) as best_score,
+ MAX(p.started_at) as last_played,
+ hs.score as high_score,
+ hs.achieved_at as high_score_date
+ FROM plays p
+ JOIN games g ON p.game_id = g.id
+ LEFT JOIN high_scores hs ON hs.game_id = g.id AND hs.player_id = p.player_id
+ WHERE p.player_id = $1 AND g.status = 'published'
+ GROUP BY g.id, g.title, g.thumbnail_url, hs.score, hs.achieved_at
+ ORDER BY last_played DESC
+ LIMIT 50`,
+ [req.user.id]
+ );
+
+ const statsResult = await pool.query(
+ `SELECT
+ COUNT(DISTINCT game_id) as games_played,
+ COUNT(*) as total_plays,
+ SUM(duration_seconds) as total_time
+ FROM plays
+ WHERE player_id = $1`,
+ [req.user.id]
+ );
+
+ res.json({
+ stats: {
+ gamesPlayed: parseInt(statsResult.rows[0]?.games_played) || 0,
+ totalPlays: parseInt(statsResult.rows[0]?.total_plays) || 0,
+ totalTime: parseInt(statsResult.rows[0]?.total_time) || 0
+ },
+ history: result.rows.map(row => ({
+ gameId: row.game_id,
+ title: row.title,
+ thumbnailUrl: row.thumbnail_url,
+ playCount: parseInt(row.play_count),
+ bestScore: parseInt(row.best_score) || 0,
+ highScore: parseInt(row.high_score) || 0,
+ lastPlayed: row.last_played,
+ highScoreDate: row.high_score_date
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's XP transaction history
+router.get('/me/xp-history',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit) || 20, 100);
+ const transactions = await XP.getTransactions(req.user.id, limit);
+
+ // Format reason labels
+ const reasonLabels = {
+ game_created: 'Published a game',
+ game_played: 'Game was played',
+ game_favorited: 'Game was favorited',
+ game_rated_5: 'Game got 5-star rating',
+ game_remixed: 'Game was remixed',
+ play_game: 'Played a game',
+ played_game: 'Played a game',
+ high_score: 'Got high score',
+ achievement: 'Achievement earned'
+ };
+
+ // Helper to format reason into human-readable label
+ function formatReasonLabel(reason) {
+ // Check if it's a simple reason
+ if (reasonLabels[reason]) {
+ return reasonLabels[reason];
+ }
+
+ // Handle achievement:code format (e.g., "achievement:first_arcade_visit")
+ if (reason.startsWith('achievement:')) {
+ const code = reason.substring('achievement:'.length);
+ // Convert snake_case to Title Case
+ return code
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ }
+
+ // Fallback: convert snake_case/kebab-case to Title Case
+ return reason
+ .replace(/[-_]/g, ' ')
+ .replace(/\b\w/g, c => c.toUpperCase());
+ }
+
+ res.json({
+ transactions: transactions.map(t => ({
+ id: t.id,
+ amount: t.amount,
+ reason: t.reason,
+ reasonLabel: formatReasonLabel(t.reason),
+ referenceId: t.reference_id,
+ createdAt: t.created_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's onboarding status
+router.get('/me/onboarding',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const status = await Users.getOnboardingStatus(req.user.id);
+ res.json({
+ step: status.onboarding_step || 0,
+ complete: status.onboarding_complete || false
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Update current user's onboarding progress
+router.post('/me/onboarding',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const { step, complete } = req.body;
+
+ if (complete) {
+ // Mark onboarding as complete and award achievements
+ await Users.completeOnboarding(req.user.id);
+
+ // Award welcome tour achievement
+ await Achievements.award(req.user.id, 'welcome_tour');
+ await Notifications.notifyAchievement(req.user.id, 'Welcome Tour', '🎉');
+
+ // Award first arcade visit achievement (they'll land on arcade after tutorial)
+ await Achievements.award(req.user.id, 'first_arcade_visit');
+ await Notifications.notifyAchievement(req.user.id, 'Explorer', '🗺️');
+
+ res.json({
+ message: 'Onboarding completed',
+ step: 4,
+ complete: true
+ });
+ } else if (typeof step === 'number' && step >= 0 && step <= 4) {
+ const result = await Users.updateOnboardingStep(req.user.id, step);
+ res.json({
+ message: 'Onboarding step updated',
+ step: result.onboarding_step,
+ complete: result.onboarding_complete
+ });
+ } else {
+ res.status(400).json({ error: 'Invalid step value' });
+ }
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get current user's creator stats (dashboard)
+router.get('/me/stats',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const stats = await Users.getCreatorStats(req.user.id);
+ const xpStats = await XP.getUserStats(req.user.id);
+
+ // Get recent plays on user's games
+ const recentPlaysResult = await pool.query(
+ `SELECT g.title, p.started_at, p.score
+ FROM plays p
+ JOIN games g ON p.game_id = g.id
+ WHERE g.creator_id = $1 AND p.is_creator_play = false
+ ORDER BY p.started_at DESC
+ LIMIT 10`,
+ [req.user.id]
+ );
+
+ res.json({
+ stats: {
+ gamesPublished: parseInt(stats.games_published) || 0,
+ totalPlays: parseInt(stats.total_plays) || 0,
+ totalFavorites: parseInt(stats.total_favorites) || 0,
+ tokensEarned: parseInt(stats.tokens_earned) || 0,
+ followers: parseInt(stats.followers) || 0,
+ avgRating: parseFloat(stats.avg_rating) || 0
+ },
+ xp: xpStats,
+ recentPlays: recentPlaysResult.rows.map(r => ({
+ gameTitle: r.title,
+ playedAt: r.started_at,
+ score: r.score
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// ==========================================
+// PUBLIC USER ENDPOINTS
+// ==========================================
+
+// Get public user profile by username (for /creator/:username)
+router.get('/by-username/:username',
+ optionalAuth,
+ async (req, res, next) => {
+ try {
+ const { username } = req.params;
+
+ // Find user by username
+ const userResult = await pool.query(
+ 'SELECT id FROM users WHERE LOWER(username) = LOWER($1) AND is_banned = false',
+ [username]
+ );
+
+ if (userResult.rows.length === 0) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const userId = userResult.rows[0].id;
+ const profile = await Users.getPublicProfile(userId);
+
+ if (!profile) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ let isFollowing = false;
+ if (req.user) {
+ isFollowing = await Follows.isFollowing(req.user.id, userId);
+ }
+
+ res.json({
+ user: {
+ id: profile.id,
+ username: profile.username,
+ displayName: profile.display_name,
+ avatarData: profile.avatar_data,
+ bio: profile.bio,
+ level: profile.creator_level,
+ badge: profile.creator_badge,
+ xp: profile.xp_total,
+ gamesCount: parseInt(profile.games_count),
+ totalPlays: parseInt(profile.total_plays),
+ followersCount: parseInt(profile.followers_count),
+ followingCount: parseInt(profile.following_count),
+ createdAt: profile.created_at
+ },
+ isFollowing
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get user's published games by username
+router.get('/by-username/:username/games',
+ async (req, res, next) => {
+ try {
+ const { username } = req.params;
+
+ // Find user by username
+ const userResult = await pool.query(
+ 'SELECT id FROM users WHERE LOWER(username) = LOWER($1) AND is_banned = false',
+ [username]
+ );
+
+ if (userResult.rows.length === 0) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const userId = userResult.rows[0].id;
+ const games = await Games.getByUser(userId);
+
+ res.json({
+ games: games.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ thumbnailUrl: g.thumbnail_url,
+ thumbnailData: g.thumbnail_data,
+ playCount: g.play_count,
+ favoriteCount: g.favorite_count,
+ averageRating: g.total_ratings > 0
+ ? (g.rating_sum / g.total_ratings).toFixed(1)
+ : 0,
+ totalRatings: g.total_ratings,
+ gameStyle: g.game_style,
+ publishedAt: g.published_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get public user profile by ID
+router.get('/:userId',
+ optionalAuth,
+ async (req, res, next) => {
+ try {
+ const { userId } = req.params;
+ const profile = await Users.getPublicProfile(userId);
+
+ if (!profile) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ let isFollowing = false;
+ if (req.user) {
+ isFollowing = await Follows.isFollowing(req.user.id, userId);
+ }
+
+ res.json({
+ user: {
+ id: profile.id,
+ username: profile.username,
+ displayName: profile.display_name,
+ avatarData: profile.avatar_data,
+ level: profile.creator_level,
+ badge: profile.creator_badge,
+ xp: profile.xp_total,
+ gamesCount: parseInt(profile.games_count),
+ totalPlays: parseInt(profile.total_plays),
+ followersCount: parseInt(profile.followers_count),
+ followingCount: parseInt(profile.following_count),
+ createdAt: profile.created_at
+ },
+ isFollowing
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get user's published games
+router.get('/:userId/games',
+ async (req, res, next) => {
+ try {
+ const { userId } = req.params;
+ const games = await Games.getByUser(userId);
+
+ res.json({
+ games: games.map(g => ({
+ id: g.id,
+ title: g.title,
+ description: g.description,
+ thumbnailUrl: g.thumbnail_url,
+ playCount: g.play_count,
+ averageRating: g.total_ratings > 0
+ ? (g.rating_sum / g.total_ratings).toFixed(1)
+ : null,
+ totalRatings: g.total_ratings,
+ publishedAt: g.published_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Follow a user
+router.post('/:userId/follow',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const followingId = parseInt(req.params.userId);
+
+ if (followingId === req.user.id) {
+ return res.status(400).json({ error: 'Cannot follow yourself' });
+ }
+
+ const targetUser = await Users.findById(followingId);
+ if (!targetUser) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ await Follows.follow(req.user.id, followingId);
+
+ // Check for first follow achievement
+ const followingCount = await Follows.getFollowingCount(req.user.id);
+ await Achievements.checkAndAward(req.user.id, 'first_follow', followingCount);
+
+ // Notify the followed user
+ await Notifications.notifyNewFollower(followingId, req.user.username);
+
+ res.json({ message: 'Now following', following: true });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Unfollow a user
+router.delete('/:userId/follow',
+ authenticate,
+ async (req, res, next) => {
+ try {
+ const followingId = parseInt(req.params.userId);
+ await Follows.unfollow(req.user.id, followingId);
+
+ res.json({ message: 'Unfollowed', following: false });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get user's followers
+router.get('/:userId/followers',
+ async (req, res, next) => {
+ try {
+ const followers = await Follows.getFollowers(parseInt(req.params.userId));
+
+ res.json({
+ followers: followers.map(f => ({
+ id: f.id,
+ username: f.username,
+ displayName: f.display_name,
+ avatarData: f.avatar_data,
+ level: f.creator_level,
+ badge: f.creator_badge,
+ followedAt: f.followed_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+// Get users this user is following
+router.get('/:userId/following',
+ async (req, res, next) => {
+ try {
+ const following = await Follows.getFollowing(parseInt(req.params.userId));
+
+ res.json({
+ following: following.map(f => ({
+ id: f.id,
+ username: f.username,
+ displayName: f.display_name,
+ avatarData: f.avatar_data,
+ level: f.creator_level,
+ badge: f.creator_badge,
+ followedAt: f.followed_at
+ }))
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+);
+
+module.exports = router;
diff --git a/backend/src/utils/claude.js b/backend/src/utils/claude.js
new file mode 100644
index 0000000..a38b7da
--- /dev/null
+++ b/backend/src/utils/claude.js
@@ -0,0 +1,451 @@
+const Anthropic = require('@anthropic-ai/sdk');
+
+const client = new Anthropic({
+ apiKey: process.env.CLAUDE_API_KEY,
+ timeout: 10 * 60 * 1000 // 10 minutes
+});
+
+// Model configuration - easy to switch for self-hosted
+const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
+const MAX_OUTPUT_TOKENS = parseInt(process.env.MAX_OUTPUT_TOKENS) || 16000;
+
+// Ultra-compact system prompt - every word counts
+const SYSTEM_PROMPT = `Game dev for kids 8-16. Return ONLY working HTML5 canvas code.
+
+RULES:
+- Canvas 100vw×100vh, safe zones 10% top/bottom
+- Start EASY (8yr old playable 30s), gradual difficulty
+- Controls: touch+keyboard+mouse (ALL THREE required)
+- Bright colors, large text, kid-friendly only
+- Auto-start, game over shows score+"Play Again"
+
+GAME STATE (critical):
+- Track gameState: 'playing', 'gameOver' (start playing immediately)
+- Canvas tap/click to restart ONLY works when gameState === 'gameOver'
+- During gameplay (gameState === 'playing'), taps must NOT restart the game
+- Use on-screen buttons for in-game controls, NOT canvas taps
+
+CONTROLS (mandatory):
+- Keyboard: arrow keys + space/enter
+- Touch: on-screen buttons (60px min, bottom corners) for movement/actions
+- Canvas tap ONLY for "Play Again" after game over
+- Mouse: click/drag support where applicable
+- Touch buttons: touchstart/touchend with e.preventDefault()
+
+PHYSICS (for action/racing/physics/sports games):
+- Use requestAnimationFrame for smooth 60fps
+- Gravity: velY += 0.5 to 1.0 per frame for falling objects
+- Velocity: track velX/velY, apply friction (multiply by 0.95-0.98)
+- Collisions: AABB box collision with generous hitboxes
+- Bounce: on collision, reverse velocity * 0.7 for elasticity
+- Ground check: player.y + height >= groundY
+
+AUDIO (Web Audio API):
+- Background music: 16+ note melody loop, not just 2-3 repeating tones
+- Use pentatonic scale for pleasant tunes: C4,D4,E4,G4,A4,C5
+- SFX: short sounds for actions (jump, shoot, collect, hit)
+- Exports: window.setMusicEnabled(bool), window.setSfxEnabled(bool)
+- Exports: window.pauseMusic(), window.resumeMusic()
+- Music should pause when game paused, resume when unpaused
+
+REQUIRED EXPORTS:
+- window.gameScore (number)
+- window.pauseGame(), window.resumeGame()`;
+
+// Token budgets - guides allocation, never causes failures
+const TOKEN_BUDGETS = {
+ action: 10000,
+ puzzle: 12000,
+ story: 16000,
+ racing: 10000,
+ pet: 12000,
+ trivia: 14000,
+ music: 10000,
+ creative: 10000,
+ rpg: 16000,
+ sports: 10000,
+ physics: 10000,
+ freeform: 14000
+};
+
+// Complexity multipliers
+const COMPLEXITY_FACTORS = {
+ '5+': 1.4, '3': 1.2, '1': 1.0, // endings
+ '20': 1.3, '10': 1.1, '5': 1.0, 'endless': 1.05, // levels
+ 'all': 1.1, 'easy-medium': 1.05, 'easy': 1.0, // difficulty
+ 'local2p': 1.2 // features
+};
+
+/**
+ * Estimate complexity and recommend token budget
+ */
+function estimateComplexity(styleId, answers = {}) {
+ const baseBudget = TOKEN_BUDGETS[styleId] || TOKEN_BUDGETS.freeform;
+ let multiplier = 1.0;
+ const warnings = [];
+
+ for (const [key, value] of Object.entries(answers)) {
+ if (typeof value === 'string' && COMPLEXITY_FACTORS[value]) {
+ multiplier *= COMPLEXITY_FACTORS[value];
+ if (value === '5+') warnings.push('5+ endings is very complex - consider starting with 3');
+ if (value === '20') warnings.push('20 levels is a lot - consider 10 to start');
+ }
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ if (COMPLEXITY_FACTORS[v]) multiplier *= COMPLEXITY_FACTORS[v];
+ if (v === 'local2p') warnings.push('2-player mode adds significant complexity');
+ }
+ }
+ }
+
+ if (answers.fullDescription?.length > 1000) {
+ multiplier *= 1.15;
+ warnings.push('Very detailed description - AI may not include everything');
+ }
+
+ const recommendedBudget = Math.round(baseBudget * multiplier);
+ const complexity = Math.min(5, Math.round(multiplier * 2.5));
+
+ return {
+ tokenBudget: recommendedBudget,
+ complexity,
+ warnings,
+ isHighComplexity: complexity >= 4
+ };
+}
+
+/**
+ * Strip unnecessary whitespace and comments from code to reduce tokens
+ */
+function compressCode(code) {
+ if (!code) return code;
+ return code
+ // Remove single-line comments (but keep URLs)
+ .replace(/(? l.trimEnd()).join('\n')
+ .trim();
+}
+
+/**
+ * Rough token estimate (~4 chars per token)
+ */
+function estimateTokens(text) {
+ return Math.ceil((text || '').length / 4);
+}
+
+async function generateGame(description, styleId = null, answers = {}) {
+ try {
+ const { tokenBudget, complexity, warnings, isHighComplexity } =
+ estimateComplexity(styleId, answers);
+
+ console.log(`Generate: style=${styleId}, complexity=${complexity}, budget=${tokenBudget}`);
+
+ const message = await client.messages.create({
+ model: MODEL,
+ max_tokens: MAX_OUTPUT_TOKENS,
+ system: SYSTEM_PROMPT,
+ messages: [{
+ role: 'user',
+ content: `Create game: ${description}\n\nReturn ONLY complete HTML.`
+ }]
+ });
+
+ let gameCode = message.content[0].text;
+
+ // Clean markdown wrappers
+ gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
+
+ // Ensure valid HTML structure
+ if (!gameCode.includes('Game ${gameCode}`;
+ }
+ }
+
+ // Check for truncation - warn but don't fail
+ const wasTruncated = message.stop_reason === 'max_tokens';
+ if (wasTruncated) {
+ console.warn(`Generation truncated at ${message.usage.output_tokens} tokens`);
+ warnings.push('Game may be incomplete - try simplifying if issues occur');
+ }
+
+ // Cost calculation (Claude Sonnet: $3/M input, $15/M output)
+ const inputCostCents = Math.ceil((message.usage.input_tokens / 1000000) * 300);
+ const outputCostCents = Math.ceil((message.usage.output_tokens / 1000000) * 1500);
+
+ return {
+ success: true,
+ code: gameCode,
+ tokensUsed: message.usage.input_tokens + message.usage.output_tokens,
+ inputTokens: message.usage.input_tokens,
+ outputTokens: message.usage.output_tokens,
+ apiCostCents: inputCostCents + outputCostCents,
+ complexity,
+ warnings,
+ wasTruncated
+ };
+ } catch (error) {
+ console.error('Claude API error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// Compact system prompt just for refinements
+const REFINE_PROMPT = `Modify HTML5 canvas game. Keep ALL existing code working. ONLY change what's requested. Return complete HTML.`;
+
+async function regenerateGame(existingCode, feedback) {
+ try {
+ // Compress existing code to reduce input tokens
+ const compressedCode = compressCode(existingCode);
+ const inputTokens = estimateTokens(compressedCode);
+
+ console.log(`Regenerate: ~${inputTokens} input tokens (compressed from ~${estimateTokens(existingCode)})`);
+
+ const message = await client.messages.create({
+ model: MODEL,
+ max_tokens: MAX_OUTPUT_TOKENS,
+ system: REFINE_PROMPT,
+ messages: [{
+ role: 'user',
+ content: `GAME:\n${compressedCode}\n\nCHANGES: ${feedback}\n\nOutput complete updated HTML only, no explanation.`
+ }]
+ });
+
+ let gameCode = message.content[0].text;
+ gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
+
+ const wasTruncated = message.stop_reason === 'max_tokens';
+ if (wasTruncated) {
+ console.warn(`Regeneration truncated at ${message.usage.output_tokens} tokens`);
+ }
+
+ const inputCostCents = Math.ceil((message.usage.input_tokens / 1000000) * 300);
+ const outputCostCents = Math.ceil((message.usage.output_tokens / 1000000) * 1500);
+
+ return {
+ success: true,
+ code: gameCode,
+ tokensUsed: message.usage.input_tokens + message.usage.output_tokens,
+ inputTokens: message.usage.input_tokens,
+ outputTokens: message.usage.output_tokens,
+ apiCostCents: inputCostCents + outputCostCents,
+ wasTruncated,
+ warnings: wasTruncated ? ['Game may be incomplete - try smaller changes'] : []
+ };
+ } catch (error) {
+ console.error('Claude API error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+/**
+ * Detect code structure phase from streaming chunks for real-time progress
+ */
+function detectPhase(accumulated) {
+ // Check from most specific to least specific
+ if (accumulated.includes('pauseGame') || accumulated.includes('resumeGame'))
+ return { phase: 'pause', message: 'Adding pause functionality...' };
+ if (accumulated.includes('gameOver') || accumulated.includes('Game Over'))
+ return { phase: 'endgame', message: 'Adding win/lose conditions...' };
+ if (accumulated.includes('AudioContext') || accumulated.includes('oscillator'))
+ return { phase: 'audio', message: 'Composing music and sounds...' };
+ if (accumulated.includes('requestAnimationFrame') || accumulated.includes('setInterval'))
+ return { phase: 'gameloop', message: 'Building the game loop...' };
+ if (accumulated.includes('addEventListener') || accumulated.includes('onkeydown'))
+ return { phase: 'controls', message: 'Setting up controls...' };
+ if (/function\s+\w/.test(accumulated) || /class\s+\w/.test(accumulated))
+ return { phase: 'logic', message: 'Writing game logic...' };
+ if (accumulated.includes(' {
+ accumulated += text;
+ tokensReceived += Math.ceil(text.length / 4); // rough estimate
+
+ const phaseInfo = detectPhase(accumulated);
+ if (phaseInfo.phase !== lastPhase) {
+ lastPhase = phaseInfo.phase;
+ if (onProgress) {
+ onProgress({
+ type: 'phase',
+ phase: phaseInfo.phase,
+ message: phaseInfo.message,
+ progress: Math.min(95, Math.round((tokensReceived / tokenBudget) * 100))
+ });
+ }
+ }
+
+ // Send progress every ~500 tokens
+ if (tokensReceived % 500 < 4 && onProgress) {
+ onProgress({
+ type: 'progress',
+ progress: Math.min(95, Math.round((tokensReceived / tokenBudget) * 100)),
+ tokensReceived
+ });
+ }
+ });
+
+ const finalMessage = await stream.finalMessage();
+
+ let gameCode = finalMessage.content[0].text;
+ gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
+
+ if (!gameCode.includes('Game ${gameCode}`;
+ }
+ }
+
+ const wasTruncated = finalMessage.stop_reason === 'max_tokens';
+ if (wasTruncated) {
+ console.warn(`Stream generation truncated at ${finalMessage.usage.output_tokens} tokens`);
+ warnings.push('Game may be incomplete - try simplifying if issues occur');
+ }
+
+ const inputCostCents = Math.ceil((finalMessage.usage.input_tokens / 1000000) * 300);
+ const outputCostCents = Math.ceil((finalMessage.usage.output_tokens / 1000000) * 1500);
+
+ return {
+ success: true,
+ code: gameCode,
+ tokensUsed: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
+ inputTokens: finalMessage.usage.input_tokens,
+ outputTokens: finalMessage.usage.output_tokens,
+ apiCostCents: inputCostCents + outputCostCents,
+ complexity,
+ warnings,
+ wasTruncated
+ };
+ } catch (error) {
+ console.error('Claude streaming API error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+/**
+ * Streaming game refinement
+ */
+async function regenerateGameStream(existingCode, feedback, onProgress) {
+ try {
+ const compressedCode = compressCode(existingCode);
+ const inputTokens = estimateTokens(compressedCode);
+ const expectedBudget = Math.max(inputTokens, 8000);
+
+ console.log(`StreamRegenerate: ~${inputTokens} input tokens`);
+
+ let accumulated = '';
+ let lastPhase = '';
+ let tokensReceived = 0;
+
+ const stream = await client.messages.stream({
+ model: MODEL,
+ max_tokens: MAX_OUTPUT_TOKENS,
+ system: REFINE_PROMPT,
+ messages: [{
+ role: 'user',
+ content: `GAME:\n${compressedCode}\n\nCHANGES: ${feedback}\n\nOutput complete updated HTML only, no explanation.`
+ }]
+ });
+
+ stream.on('text', (text) => {
+ accumulated += text;
+ tokensReceived += Math.ceil(text.length / 4);
+
+ const phaseInfo = detectPhase(accumulated);
+ if (phaseInfo.phase !== lastPhase) {
+ lastPhase = phaseInfo.phase;
+ if (onProgress) {
+ onProgress({
+ type: 'phase',
+ phase: phaseInfo.phase,
+ message: phaseInfo.message,
+ progress: Math.min(95, Math.round((tokensReceived / expectedBudget) * 100))
+ });
+ }
+ }
+
+ if (tokensReceived % 500 < 4 && onProgress) {
+ onProgress({
+ type: 'progress',
+ progress: Math.min(95, Math.round((tokensReceived / expectedBudget) * 100)),
+ tokensReceived
+ });
+ }
+ });
+
+ const finalMessage = await stream.finalMessage();
+
+ let gameCode = finalMessage.content[0].text;
+ gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
+
+ const wasTruncated = finalMessage.stop_reason === 'max_tokens';
+ if (wasTruncated) {
+ console.warn(`Stream regeneration truncated at ${finalMessage.usage.output_tokens} tokens`);
+ }
+
+ const inputCostCents = Math.ceil((finalMessage.usage.input_tokens / 1000000) * 300);
+ const outputCostCents = Math.ceil((finalMessage.usage.output_tokens / 1000000) * 1500);
+
+ return {
+ success: true,
+ code: gameCode,
+ tokensUsed: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
+ inputTokens: finalMessage.usage.input_tokens,
+ outputTokens: finalMessage.usage.output_tokens,
+ apiCostCents: inputCostCents + outputCostCents,
+ wasTruncated,
+ warnings: wasTruncated ? ['Game may be incomplete - try smaller changes'] : []
+ };
+ } catch (error) {
+ console.error('Claude streaming API error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+module.exports = {
+ generateGame,
+ regenerateGame,
+ generateGameStream,
+ regenerateGameStream,
+ estimateComplexity,
+ estimateTokens,
+ compressCode,
+ TOKEN_BUDGETS,
+ MODEL,
+ MAX_OUTPUT_TOKENS
+};
diff --git a/backend/src/utils/contentFilter.js b/backend/src/utils/contentFilter.js
new file mode 100644
index 0000000..c7dafe6
--- /dev/null
+++ b/backend/src/utils/contentFilter.js
@@ -0,0 +1,440 @@
+/**
+ * Content Filter Module for GamerComp
+ * Filters inappropriate content for kids' platform (ages 8-16)
+ */
+
+// Core profanity list (common English profanity)
+const PROFANITY_LIST = [
+ // Strong profanity
+ 'fuck', 'fucking', 'fucked', 'fucker', 'fucks',
+ 'shit', 'shits', 'shitting', 'shitty',
+ 'bitch', 'bitches', 'bitching',
+ 'ass', 'asses', 'asshole', 'assholes',
+ 'damn', 'damned', 'dammit',
+ 'crap', 'crappy',
+ 'hell',
+ 'bastard', 'bastards',
+ 'piss', 'pissed', 'pissing',
+ 'cunt', 'cunts',
+ 'dick', 'dicks', 'dickhead',
+ 'cock', 'cocks',
+ 'whore', 'whores',
+ 'slut', 'sluts',
+ // Racial slurs (abbreviated for safety)
+ 'nigger', 'nigga', 'niggas',
+ 'faggot', 'fag', 'fags',
+ 'retard', 'retarded', 'retards',
+ // Additional
+ 'wtf', 'stfu', 'lmfao',
+ 'bullshit', 'horseshit',
+ 'jackass', 'dumbass', 'badass',
+ 'motherfucker', 'motherfucking',
+ // Drug references
+ 'cocaine', 'heroin', 'meth', 'methamphetamine',
+ 'weed', 'marijuana', 'stoned', 'pothead',
+ 'druggie', 'crackhead',
+ // Alcohol for minors
+ 'drunk', 'wasted', 'hammered', 'plastered',
+ 'beer', 'vodka', 'whiskey', 'tequila',
+ // Violence escalation
+ 'murder', 'murderer', 'murdering',
+ 'torture', 'torturing', 'tortured',
+ 'mutilate', 'mutilation',
+ 'rape', 'raped', 'raping', 'rapist',
+ // Hate terms
+ 'nazi', 'hitler', 'kkk',
+ // Grooming/predatory
+ 'sexy', 'hottie', 'babe'
+];
+
+// Gaming context allowlist - words that are OK in game creation
+const GAMING_ALLOWLIST = [
+ 'shoot', 'shooter', 'shooting',
+ 'kill', 'killed', 'killing', 'killer',
+ 'die', 'died', 'dying', 'death', 'dead',
+ 'weapon', 'weapons',
+ 'sword', 'swords',
+ 'gun', 'guns',
+ 'knife', 'knives',
+ 'bomb', 'bombs', 'explosion',
+ 'monster', 'monsters',
+ 'zombie', 'zombies',
+ 'ghost', 'ghosts',
+ 'skeleton', 'skeletons',
+ 'dragon', 'dragons',
+ 'demon', 'demons',
+ 'devil',
+ 'battle', 'battles', 'battling',
+ 'fight', 'fights', 'fighting', 'fighter',
+ 'combat',
+ 'war', 'wars',
+ 'attack', 'attacks', 'attacking',
+ 'destroy', 'destroys', 'destruction',
+ 'enemy', 'enemies',
+ 'boss', 'bosses',
+ 'defeat', 'defeated',
+ 'hit', 'hits',
+ 'punch', 'punches',
+ 'kick', 'kicks',
+ 'slash', 'slashes',
+ 'blood moon', 'bloodmoon'
+];
+
+// PII detection patterns
+const PII_PATTERNS = {
+ phone: /(\+?1?[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g,
+ email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi,
+ address: /\d{1,5}\s+[A-Za-z\s]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Court|Ct|Way|Place|Pl)\.?/gi,
+ ssn: /\d{3}[-\s]?\d{2}[-\s]?\d{4}/g
+};
+
+// Graphic violence keywords
+const GRAPHIC_VIOLENCE = [
+ 'gore', 'gory', 'bloody', 'bloodbath', 'massacre',
+ 'torture', 'torturing', 'mutilate', 'mutilation',
+ 'decapitate', 'decapitation', 'dismember',
+ 'disembowel', 'eviscerate', 'strangle', 'strangling',
+ 'suffocate'
+];
+
+// Sexual content keywords
+const SEXUAL_CONTENT = [
+ 'sex', 'sexual', 'porn', 'pornography', 'nude', 'naked',
+ 'strip', 'stripper', 'erotic', 'orgasm', 'masturbate',
+ 'penis', 'vagina', 'breasts', 'boobs',
+ 'horny', 'aroused', 'seduce', 'seductive'
+];
+
+// Kid-friendly error messages
+const ERROR_MESSAGES = {
+ profanity: "Oops! Let's keep our words friendly. Try a different way to say that!",
+ pii: "For your safety, please don't share personal info like phone numbers or addresses.",
+ violence: "Let's keep things fun! Try describing your game without scary details.",
+ sexual: "That content isn't appropriate for our platform. Please try something else.",
+ drugs: "Let's keep our games drug-free! Try a different idea.",
+ hate: "We want everyone to feel welcome here. Please use kind words.",
+ general: "Some of that content isn't allowed. Please try again with different words."
+};
+
+// Leet-speak character mappings
+const LEET_MAP = {
+ '0': 'o',
+ '1': 'i',
+ '3': 'e',
+ '4': 'a',
+ '5': 's',
+ '7': 't',
+ '8': 'b',
+ '@': 'a',
+ '$': 's',
+ '!': 'i',
+ '+': 't'
+};
+
+/**
+ * Normalize text by converting leet-speak to regular letters
+ */
+function normalizeLeetSpeak(text) {
+ let normalized = text.toLowerCase();
+ for (const [leet, letter] of Object.entries(LEET_MAP)) {
+ normalized = normalized.replace(new RegExp('\\' + leet, 'g'), letter);
+ }
+ // Remove repeated characters (e.g., "fuuuuck" -> "fuck")
+ normalized = normalized.replace(/(.)\1{2,}/g, '$1$1');
+ return normalized;
+}
+
+/**
+ * Check if text contains profanity
+ */
+function containsProfanity(text, allowGamingContext = false) {
+ const normalized = normalizeLeetSpeak(text);
+ const words = normalized.split(/[\s.,!?;:'"()\[\]{}]+/);
+
+ for (const word of words) {
+ const cleanWord = word.replace(/[^a-z]/g, '');
+ if (!cleanWord) continue;
+
+ // Skip if it's in the gaming allowlist and gaming context is allowed
+ if (allowGamingContext && GAMING_ALLOWLIST.includes(cleanWord)) {
+ continue;
+ }
+
+ // Check against profanity list
+ if (PROFANITY_LIST.includes(cleanWord)) {
+ return true;
+ }
+ }
+
+ // Also check for embedded profanity (e.g., "motherf*cker")
+ for (const badWord of PROFANITY_LIST) {
+ if (normalized.includes(badWord)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Check if text contains any words from a list
+ */
+function containsWord(text, wordList) {
+ const normalized = normalizeLeetSpeak(text);
+
+ for (const badWord of wordList) {
+ if (normalized.includes(badWord)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Check for PII in text
+ */
+function detectPII(text) {
+ const found = [];
+
+ for (const [type, pattern] of Object.entries(PII_PATTERNS)) {
+ const regex = new RegExp(pattern.source, pattern.flags);
+ if (regex.test(text)) {
+ found.push(type);
+ }
+ }
+
+ return found;
+}
+
+/**
+ * Clean text by replacing bad words with asterisks
+ */
+function cleanText(text) {
+ let cleaned = text;
+ const normalized = normalizeLeetSpeak(text);
+
+ for (const badWord of PROFANITY_LIST) {
+ const regex = new RegExp(badWord, 'gi');
+ if (regex.test(normalized)) {
+ // Replace with asterisks of same length
+ cleaned = cleaned.replace(regex, '*'.repeat(badWord.length));
+ }
+ }
+
+ return cleaned;
+}
+
+/**
+ * Main content filter function
+ */
+function filterText(text, options = {}) {
+ if (!text || typeof text !== 'string') {
+ return {
+ clean: true,
+ filtered: text || '',
+ blocked: false,
+ severity: 'safe',
+ categories: [],
+ message: null,
+ canRetry: true
+ };
+ }
+
+ const {
+ allowGamingContext = false,
+ contentType = 'general',
+ strictMode = false
+ } = options;
+
+ const categories = [];
+ let severity = 'safe';
+ let message = null;
+ let blocked = false;
+
+ // Check for PII (always block)
+ const piiTypes = detectPII(text);
+ if (piiTypes.length > 0) {
+ categories.push('pii');
+ severity = 'critical';
+ message = ERROR_MESSAGES.pii;
+ blocked = true;
+ }
+
+ // Check for sexual content (always block)
+ if (containsWord(text, SEXUAL_CONTENT)) {
+ categories.push('sexual');
+ severity = 'critical';
+ message = ERROR_MESSAGES.sexual;
+ blocked = true;
+ }
+
+ // Check for graphic violence
+ if (containsWord(text, GRAPHIC_VIOLENCE)) {
+ categories.push('violence');
+ if (severity !== 'critical') severity = 'high';
+ message = message || ERROR_MESSAGES.violence;
+ blocked = true;
+ }
+
+ // Check for profanity
+ if (containsProfanity(text, allowGamingContext)) {
+ categories.push('profanity');
+ if (severity === 'safe') severity = strictMode ? 'high' : 'medium';
+ message = message || ERROR_MESSAGES.profanity;
+ blocked = strictMode || severity === 'high' || severity === 'critical';
+ }
+
+ // If no issues found
+ if (categories.length === 0) {
+ return {
+ clean: true,
+ filtered: text,
+ blocked: false,
+ severity: 'safe',
+ categories: [],
+ message: null,
+ canRetry: true
+ };
+ }
+
+ // Generate filtered version
+ const filtered = cleanText(text);
+
+ return {
+ clean: false,
+ filtered,
+ blocked,
+ severity,
+ categories,
+ message: message || ERROR_MESSAGES.general,
+ canRetry: true
+ };
+}
+
+/**
+ * Filter game creation answers object
+ */
+function filterGamePrompt(answers) {
+ if (!answers || typeof answers !== 'object') {
+ return { clean: true, blocked: false, issues: [] };
+ }
+
+ const issues = [];
+
+ for (const [key, value] of Object.entries(answers)) {
+ if (typeof value === 'string' && value.trim()) {
+ const result = filterText(value, {
+ allowGamingContext: true,
+ contentType: 'game_prompt'
+ });
+
+ if (!result.clean) {
+ issues.push({
+ field: key,
+ value: value,
+ ...result
+ });
+ }
+ }
+ }
+
+ const blocked = issues.some(i => i.blocked);
+ const worstSeverity = issues.reduce((worst, issue) => {
+ const severityOrder = ['safe', 'low', 'medium', 'high', 'critical'];
+ return severityOrder.indexOf(issue.severity) > severityOrder.indexOf(worst)
+ ? issue.severity
+ : worst;
+ }, 'safe');
+
+ return {
+ clean: issues.length === 0,
+ blocked,
+ severity: worstSeverity,
+ issues,
+ message: issues[0]?.message || null
+ };
+}
+
+/**
+ * Filter username (strict mode)
+ */
+function filterUsername(username) {
+ return filterText(username, {
+ allowGamingContext: false,
+ contentType: 'username',
+ strictMode: true
+ });
+}
+
+/**
+ * Filter game/collection title
+ */
+function filterTitle(title) {
+ return filterText(title, {
+ allowGamingContext: true,
+ contentType: 'title',
+ strictMode: false
+ });
+}
+
+/**
+ * Filter description text
+ */
+function filterDescription(description) {
+ return filterText(description, {
+ allowGamingContext: true,
+ contentType: 'description',
+ strictMode: false
+ });
+}
+
+/**
+ * Quick check if text is clean
+ */
+function isClean(text, options = {}) {
+ const result = filterText(text, options);
+ return result.clean;
+}
+
+/**
+ * Get severity level of text
+ */
+function getSeverity(text, options = {}) {
+ const result = filterText(text, options);
+ return result.severity;
+}
+
+/**
+ * Log content filter action for moderation review
+ */
+function logFilterAction(userId, contentType, content, result) {
+ if (!result.clean) {
+ console.log('[CONTENT_FILTER]', JSON.stringify({
+ timestamp: new Date().toISOString(),
+ userId,
+ contentType,
+ blocked: result.blocked,
+ severity: result.severity,
+ categories: result.categories,
+ contentPreview: content.substring(0, 50) + (content.length > 50 ? '...' : '')
+ }));
+ }
+}
+
+module.exports = {
+ filterText,
+ filterGamePrompt,
+ filterUsername,
+ filterTitle,
+ filterDescription,
+ isClean,
+ getSeverity,
+ logFilterAction,
+ // Export for testing
+ normalizeLeetSpeak,
+ detectPII,
+ containsProfanity,
+ GAMING_ALLOWLIST,
+ ERROR_MESSAGES
+};
diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js
new file mode 100644
index 0000000..7e2398e
--- /dev/null
+++ b/backend/src/utils/email.js
@@ -0,0 +1,405 @@
+const nodemailer = require('nodemailer');
+
+// Mail API configuration
+const MAIL_API_URL = process.env.MAIL_API_URL || 'https://keylinkit.net/api/mail';
+const MAIL_API_KEY = process.env.MAIL_API_KEY;
+const FROM_EMAIL = process.env.SMTP_USER || 'info@gamercomp.com';
+const FROM_NAME = process.env.SMTP_FROM_NAME || 'GamerComp';
+
+// SMTP fallback configuration
+let smtpTransporter = null;
+let smtpEnabled = false;
+
+// Initialize SMTP transporter as fallback
+function initSmtpTransporter() {
+ if (!process.env.SMTP_PASS) {
+ console.log('Email: SMTP fallback not configured');
+ return;
+ }
+
+ smtpTransporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST || 'mail.keylinkit.net',
+ port: parseInt(process.env.SMTP_PORT) || 2525,
+ secure: process.env.SMTP_SECURE === 'true',
+ auth: {
+ user: process.env.SMTP_USER || 'info@gamercomp.com',
+ pass: process.env.SMTP_PASS
+ },
+ connectionTimeout: 10000,
+ greetingTimeout: 5000
+ });
+
+ smtpTransporter.verify((error) => {
+ if (error) {
+ console.log('Email: SMTP fallback not available -', error.message);
+ smtpEnabled = false;
+ } else {
+ console.log('Email: SMTP fallback ready');
+ smtpEnabled = true;
+ }
+ });
+}
+
+// Initialize on module load
+initSmtpTransporter();
+
+// Send via Mail API
+async function sendViaApi({ to, subject, text, html, replyTo }) {
+ if (!MAIL_API_KEY) {
+ throw new Error('Mail API key not configured');
+ }
+
+ const response = await fetch(`${MAIL_API_URL}/send`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${MAIL_API_KEY}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ to,
+ from: FROM_EMAIL,
+ fromName: FROM_NAME,
+ subject,
+ html,
+ text,
+ replyTo
+ })
+ });
+
+ const data = await response.json();
+
+ if (!data.success) {
+ throw new Error(data.error || 'Mail API error');
+ }
+
+ return { success: true, messageId: data.messageId, via: 'api' };
+}
+
+// Send via SMTP (fallback)
+async function sendViaSmtp({ to, subject, text, html }) {
+ if (!smtpEnabled || !smtpTransporter) {
+ throw new Error('SMTP not available');
+ }
+
+ const info = await smtpTransporter.sendMail({
+ from: `"${FROM_NAME}" <${FROM_EMAIL}>`,
+ to,
+ subject,
+ text,
+ html
+ });
+
+ return { success: true, messageId: info.messageId, via: 'smtp' };
+}
+
+// Main send function - tries API first, falls back to SMTP
+async function sendEmail({ to, subject, text, html, replyTo }) {
+ // Try Mail API first
+ if (MAIL_API_KEY) {
+ try {
+ const result = await sendViaApi({ to, subject, text, html, replyTo });
+ console.log(`Email sent via API: ${result.messageId}`);
+ return result;
+ } catch (apiError) {
+ console.log('Email: API failed, trying SMTP fallback -', apiError.message);
+ }
+ }
+
+ // Fall back to SMTP
+ if (smtpEnabled) {
+ try {
+ const result = await sendViaSmtp({ to, subject, text, html });
+ console.log(`Email sent via SMTP: ${result.messageId}`);
+ return result;
+ } catch (smtpError) {
+ console.error('Email: SMTP fallback failed -', smtpError.message);
+ return { success: false, error: smtpError.message };
+ }
+ }
+
+ console.log('Email: No delivery method available, skipping -', subject);
+ return { success: false, error: 'No email delivery method configured' };
+}
+
+// Check if email is enabled
+function isEmailEnabled() {
+ return !!(MAIL_API_KEY || smtpEnabled);
+}
+
+// HTML escape utility to prevent XSS in emails
+function escapeHtml(str) {
+ if (!str) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+// Safety footer for all emails
+const SAFETY_FOOTER_HTML = `
+
+
+ E
+ Rated E for Everyone
+
+
+ No Chat • No Strangers • All Games Moderated
+
+
+`;
+
+const SAFETY_FOOTER_TEXT = '\n\n---\nRated E for Everyone • No Chat • No Strangers • All Games Moderated';
+
+// Forward user contact emails to admin
+async function forwardToAdmin(originalFrom, subject, body) {
+ const adminEmail = process.env.ADMIN_FORWARD_EMAIL || 'allen@keylinkit.com';
+
+ return sendEmail({
+ to: adminEmail,
+ subject: `[GamerComp Contact] ${subject}`,
+ text: `Forwarded message from: ${originalFrom}\n\n---\n\n${body}`,
+ html: `
+
+
Forwarded message from: ${escapeHtml(originalFrom)}
+
+
+ ${escapeHtml(body).replace(/\n/g, ' ')}
+
+
+ `
+ });
+}
+
+// Welcome email for new users
+async function sendWelcomeEmail(user) {
+ // Try template endpoint first if API is available
+ if (MAIL_API_KEY) {
+ try {
+ const response = await fetch(`${MAIL_API_URL}/send-template`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${MAIL_API_KEY}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ to: user.email,
+ from: FROM_EMAIL,
+ fromName: FROM_NAME,
+ template: 'welcome',
+ variables: {
+ username: user.displayName || user.username,
+ platformName: 'GamerComp',
+ loginUrl: 'https://gamercomp.com/arcade',
+ brandColor: '#6366f1'
+ }
+ })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ console.log(`Welcome email sent via template: ${data.messageId}`);
+ return { success: true, messageId: data.messageId };
+ }
+ } catch (err) {
+ console.log('Email: Template failed, using inline HTML -', err.message);
+ }
+ }
+
+ // Fall back to inline HTML
+ return sendEmail({
+ to: user.email,
+ subject: 'Welcome to GamerComp!',
+ text: `Hi ${user.displayName || user.username}!
+
+Welcome to GamerComp - the AI-powered game arcade!
+
+You can now:
+- Play games created by the community
+- Create your own games using our guided wizard
+- Earn XP and achievements
+- Join leaderboards and compete
+
+Start playing at: https://gamercomp.com/arcade
+
+Have fun!
+- The GamerComp Team${SAFETY_FOOTER_TEXT}`,
+ html: `
+
+
+
Welcome to GamerComp!
+
+
+
Hi ${user.displayName || user.username} !
+
Welcome to GamerComp - the AI-powered game arcade!
+
You can now:
+
+ Play games created by the community
+ Create your own games using our guided wizard
+ Earn XP and achievements
+ Join leaderboards and compete
+
+
+ Start Playing
+
+
Have fun! - The GamerComp Team
+ ${SAFETY_FOOTER_HTML}
+
+
+ `
+ });
+}
+
+// Notification email
+async function sendNotificationEmail(user, notification) {
+ // Try template endpoint first
+ if (MAIL_API_KEY) {
+ try {
+ const response = await fetch(`${MAIL_API_URL}/send-template`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${MAIL_API_KEY}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ to: user.email,
+ from: FROM_EMAIL,
+ fromName: FROM_NAME,
+ template: 'notification',
+ variables: {
+ title: notification.title,
+ message: notification.message,
+ actionUrl: notification.link || 'https://gamercomp.com',
+ actionText: 'View on GamerComp',
+ brandColor: '#6366f1'
+ }
+ })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ console.log(`Notification email sent via template: ${data.messageId}`);
+ return { success: true, messageId: data.messageId };
+ }
+ } catch (err) {
+ console.log('Email: Template failed, using inline -', err.message);
+ }
+ }
+
+ return sendEmail({
+ to: user.email,
+ subject: `GamerComp: ${notification.title}`,
+ text: notification.message + SAFETY_FOOTER_TEXT,
+ html: `
+
+
${notification.title}
+
${notification.message}
+ ${notification.link ? `
View on GamerComp
` : ''}
+ ${SAFETY_FOOTER_HTML}
+
+ `
+ });
+}
+
+// Password reset email
+async function sendPasswordResetEmail({ email, username, resetUrl }) {
+ // Try template endpoint first
+ if (MAIL_API_KEY) {
+ try {
+ const response = await fetch(`${MAIL_API_URL}/send-template`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${MAIL_API_KEY}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ to: email,
+ from: FROM_EMAIL,
+ fromName: FROM_NAME,
+ template: 'password-reset',
+ variables: {
+ username,
+ resetUrl,
+ expiresIn: '1 hour',
+ brandColor: '#6366f1',
+ platformName: 'GamerComp'
+ }
+ })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ console.log(`Password reset email sent via template: ${data.messageId}`);
+ return { success: true, messageId: data.messageId };
+ }
+ } catch (err) {
+ console.log('Email: Template failed, using inline -', err.message);
+ }
+ }
+
+ return sendEmail({
+ to: email,
+ subject: 'Reset your GamerComp password',
+ text: `Hi ${username},
+
+Someone requested a password reset for your GamerComp account.
+
+Click here to reset your password:
+${resetUrl}
+
+This link expires in 1 hour.
+
+If you didn't request this, you can safely ignore this email - your password will remain unchanged.
+
+- The GamerComp Team${SAFETY_FOOTER_TEXT}`,
+ html: `
+
+
+
Password Reset
+
+
+
Hi ${username} ,
+
Someone requested a password reset for your GamerComp account.
+
+ Reset Password
+
+
This link expires in 1 hour.
+
+
If you didn't request this, you can safely ignore this email - your password will remain unchanged.
+
- The GamerComp Team
+ ${SAFETY_FOOTER_HTML}
+
+
+ `
+ });
+}
+
+// Game ready email (sent when background generation completes)
+async function sendGameReadyEmail(user, gameTitle, gameId, failed = false) {
+ const frontendUrl = process.env.FRONTEND_URL || 'https://gamercomp.com';
+
+ if (failed) {
+ return sendNotificationEmail(user, {
+ title: 'Game Generation Failed',
+ message: `Your game "${gameTitle}" couldn't be generated. Don't worry - your prompt is saved! Head back to try again.`,
+ link: `${frontendUrl}/create`
+ });
+ }
+
+ return sendNotificationEmail(user, {
+ title: 'Your Game is Ready!',
+ message: `Great news! Your game "${gameTitle}" has been generated and is ready to play! Head over to test it and get it published.`,
+ link: `${frontendUrl}/play/${gameId}?newGame=true`
+ });
+}
+
+module.exports = {
+ sendEmail,
+ forwardToAdmin,
+ sendWelcomeEmail,
+ sendNotificationEmail,
+ sendGameReadyEmail,
+ sendPasswordResetEmail,
+ isEmailEnabled
+};
diff --git a/backend/src/utils/gameStyles.js b/backend/src/utils/gameStyles.js
new file mode 100644
index 0000000..7e02e18
--- /dev/null
+++ b/backend/src/utils/gameStyles.js
@@ -0,0 +1,427 @@
+// Game Styles Configuration
+// Defines the guided questions for each game style
+
+const GAME_STYLES = {
+ action: {
+ id: 'action',
+ name: 'Action/Arcade',
+ icon: '🎮',
+ description: 'Fast-paced games with quick reflexes',
+ examples: ['Space shooters', 'Platformers', 'Endless runners'],
+ questions: [
+ {
+ id: 'movement',
+ question: 'How does the player move or act?',
+ placeholder: 'e.g., jump and dodge, shoot lasers, fly through obstacles',
+ suggestions: ['jump and run', 'shoot and dodge', 'fly and collect', 'swing and grab']
+ },
+ {
+ id: 'damage',
+ question: 'What happens when you get hit?',
+ placeholder: 'e.g., lose a life, shrink smaller, slow down temporarily',
+ suggestions: ['lose a life', 'shrink smaller', 'get knocked back', 'lose points']
+ }
+ ]
+ },
+
+ puzzle: {
+ id: 'puzzle',
+ name: 'Puzzle',
+ icon: '🧩',
+ description: 'Brain teasers and logic games',
+ examples: ['Match-3', 'Sliding puzzles', 'Word games'],
+ questions: [
+ {
+ id: 'mechanic',
+ question: "What's the core puzzle mechanic?",
+ placeholder: 'e.g., match colors, slide tiles, connect dots',
+ suggestions: ['match 3 or more', 'slide tiles', 'rotate pieces', 'connect paths', 'find differences']
+ },
+ {
+ id: 'levels',
+ question: 'How many levels should this have?',
+ type: 'select',
+ options: [
+ { value: '5', label: '5 levels (quick game)' },
+ { value: '10', label: '10 levels (standard)' },
+ { value: '20', label: '20 levels (full game)' },
+ { value: 'endless', label: 'Endless (procedural)' }
+ ]
+ }
+ ]
+ },
+
+ story: {
+ id: 'story',
+ name: 'Story Adventure',
+ icon: '📖',
+ description: 'Narrative-driven experiences',
+ examples: ['Choose your adventure', 'Visual novels', 'Quest games'],
+ questions: [
+ {
+ id: 'characters',
+ question: 'Who does the player meet along the way?',
+ placeholder: 'e.g., a wise wizard, a friendly dragon, a sneaky thief',
+ suggestions: ['helpful guide', 'mysterious stranger', 'friendly animal companion', 'rival adventurer']
+ },
+ {
+ id: 'items',
+ question: 'Are there items to collect or use?',
+ placeholder: 'e.g., magic potions, keys, treasure maps',
+ suggestions: ['magic items', 'keys and locks', 'collectible coins', 'power-up gems']
+ },
+ {
+ id: 'endings',
+ question: 'How many different endings?',
+ type: 'select',
+ options: [
+ { value: '1', label: '1 ending (linear story)' },
+ { value: '3', label: '3 endings (good/neutral/bad)' },
+ { value: '5+', label: '5+ endings (branching paths)' }
+ ]
+ }
+ ]
+ },
+
+ racing: {
+ id: 'racing',
+ name: 'Racing',
+ icon: '🏎️',
+ description: 'Speed and competition',
+ examples: ['Car racing', 'Running games', 'Obstacle courses'],
+ questions: [
+ {
+ id: 'vehicle',
+ question: 'What does the player race with?',
+ placeholder: 'e.g., a cool car, a speedy bike, a rocket ship',
+ suggestions: ['sports car', 'motorcycle', 'spaceship', 'running character', 'animal']
+ },
+ {
+ id: 'obstacles',
+ question: 'What obstacles or hazards are on the track?',
+ placeholder: 'e.g., oil slicks, ramps, other racers',
+ suggestions: ['barriers', 'jumps and ramps', 'moving obstacles', 'speed boosts', 'shortcuts']
+ }
+ ]
+ },
+
+ pet: {
+ id: 'pet',
+ name: 'Pet/Simulator',
+ icon: '🐾',
+ description: 'Care for virtual creatures or manage systems',
+ examples: ['Virtual pets', 'Farm games', 'Tycoon games'],
+ questions: [
+ {
+ id: 'creature',
+ question: 'What creature or thing does the player care for?',
+ placeholder: 'e.g., a cute puppy, a magical dragon egg, a garden',
+ suggestions: ['cute pet', 'magical creature', 'garden/farm', 'restaurant', 'space station']
+ },
+ {
+ id: 'needs',
+ question: 'What needs does it have?',
+ placeholder: 'e.g., feeding, playing, cleaning, sleeping',
+ suggestions: ['food and water', 'play and exercise', 'cleaning', 'sleep', 'attention/love']
+ }
+ ]
+ },
+
+ trivia: {
+ id: 'trivia',
+ name: 'Trivia/Quiz',
+ icon: '❓',
+ description: 'Test knowledge and answer questions',
+ examples: ['Quiz shows', 'Fact games', 'Educational games'],
+ questions: [
+ {
+ id: 'topic',
+ question: 'What topic should the questions be about?',
+ placeholder: 'e.g., animals, space, movies, general knowledge',
+ suggestions: ['animals', 'science', 'geography', 'movies', 'sports', 'history', 'general knowledge']
+ },
+ {
+ id: 'format',
+ question: 'How should questions be presented?',
+ type: 'select',
+ options: [
+ { value: 'multiple', label: 'Multiple choice (4 options)' },
+ { value: 'truefalse', label: 'True or False' },
+ { value: 'mixed', label: 'Mix of both' }
+ ]
+ }
+ ]
+ },
+
+ music: {
+ id: 'music',
+ name: 'Music/Rhythm',
+ icon: '🎵',
+ description: 'Games synced to beats and music',
+ examples: ['Rhythm games', 'Dance games', 'Music puzzles'],
+ questions: [
+ {
+ id: 'musicStyle',
+ question: 'What style of music?',
+ placeholder: 'e.g., upbeat pop, electronic dance, relaxing jazz',
+ suggestions: ['upbeat pop', 'electronic', 'rock', 'classical', 'chiptune/8-bit']
+ },
+ {
+ id: 'interaction',
+ question: 'How does the player interact with the music?',
+ placeholder: 'e.g., tap to the beat, catch falling notes, dance moves',
+ suggestions: ['tap notes on beat', 'catch falling objects', 'follow patterns', 'build melodies']
+ }
+ ]
+ },
+
+ creative: {
+ id: 'creative',
+ name: 'Creative/Drawing',
+ icon: '🎨',
+ description: 'Express creativity through art',
+ examples: ['Drawing games', 'Design tools', 'Coloring'],
+ questions: [
+ {
+ id: 'medium',
+ question: 'What does the player create with?',
+ placeholder: 'e.g., paint and brushes, building blocks, musical notes',
+ suggestions: ['paint brushes', 'shapes and colors', 'stickers', 'building blocks', 'patterns']
+ },
+ {
+ id: 'goal',
+ question: 'Is there a goal or is it free-form?',
+ type: 'select',
+ options: [
+ { value: 'free', label: 'Free creative mode (no goals)' },
+ { value: 'challenges', label: 'Creative challenges to complete' },
+ { value: 'both', label: 'Both modes available' }
+ ]
+ }
+ ]
+ },
+
+ rpg: {
+ id: 'rpg',
+ name: 'RPG/Battle',
+ icon: '🏰',
+ description: 'Heroes, quests, and combat',
+ examples: ['Turn-based battles', 'Dungeon crawlers', 'Hero adventures'],
+ questions: [
+ {
+ id: 'hero',
+ question: 'Who is the hero?',
+ placeholder: 'e.g., a brave knight, a young wizard, a space explorer',
+ suggestions: ['brave knight', 'young wizard', 'ninja warrior', 'space explorer', 'animal hero']
+ },
+ {
+ id: 'combat',
+ question: 'How does combat work?',
+ type: 'select',
+ options: [
+ { value: 'turnbased', label: 'Turn-based (take turns attacking)' },
+ { value: 'realtime', label: 'Real-time (action combat)' },
+ { value: 'auto', label: 'Auto-battle (strategic choices)' }
+ ]
+ },
+ {
+ id: 'progression',
+ question: 'How does the hero get stronger?',
+ placeholder: 'e.g., level up, find better weapons, learn new spells',
+ suggestions: ['level up stats', 'find equipment', 'learn abilities', 'upgrade skills']
+ }
+ ]
+ },
+
+ sports: {
+ id: 'sports',
+ name: 'Sports',
+ icon: '🎯',
+ description: 'Athletic competitions and games',
+ examples: ['Ball games', 'Olympic events', 'Arcade sports'],
+ questions: [
+ {
+ id: 'sport',
+ question: 'What sport or activity?',
+ placeholder: 'e.g., basketball, soccer, bowling, mini-golf',
+ suggestions: ['basketball', 'soccer', 'bowling', 'mini-golf', 'archery', 'tennis']
+ },
+ {
+ id: 'mode',
+ question: 'Single player or vs computer?',
+ type: 'select',
+ options: [
+ { value: 'single', label: 'Single player (beat your score)' },
+ { value: 'vsai', label: 'Vs Computer opponent' },
+ { value: 'local', label: 'Local 2-player (same device)' }
+ ]
+ }
+ ]
+ },
+
+ physics: {
+ id: 'physics',
+ name: 'Physics/Pinball',
+ icon: '⚙️',
+ description: 'Games using realistic physics',
+ examples: ['Pinball', 'Angry Birds style', 'Marble games'],
+ questions: [
+ {
+ id: 'object',
+ question: 'What object does the player control or launch?',
+ placeholder: 'e.g., a bouncy ball, a slingshot, pinball flippers',
+ suggestions: ['bouncy ball', 'projectile launcher', 'pinball', 'rolling marble', 'swinging object']
+ },
+ {
+ id: 'environment',
+ question: 'What does it interact with?',
+ placeholder: 'e.g., bumpers and ramps, destructible blocks, water and platforms',
+ suggestions: ['bumpers and ramps', 'destructible targets', 'moving platforms', 'water physics', 'gravity zones']
+ }
+ ]
+ },
+
+ freeform: {
+ id: 'freeform',
+ name: 'Freeform (Advanced)',
+ icon: '✨',
+ description: 'Write your own complete game description',
+ examples: ['Any game idea', 'Complex mechanics', 'Unique combinations'],
+ skipCommonQuestions: true,
+ questions: [
+ {
+ id: 'fullDescription',
+ question: 'Describe your game in detail',
+ type: 'textarea',
+ placeholder: `Describe exactly what game you want. Be as detailed as possible!
+
+Example:
+A space shooter where you control a cat astronaut. Move with arrow keys, shoot lasers with spacebar. Enemies are alien mice that fly in wave patterns. Collect cheese powerups for rapid fire. The background scrolls through colorful nebulas. Start with 3 lives, game over when all lives lost.
+
+Include: theme, controls, enemies/obstacles, powerups, scoring, and any unique mechanics.`,
+ maxLength: 2000
+ }
+ ]
+ }
+};
+
+// Common questions asked for ALL game styles
+const COMMON_QUESTIONS = [
+ {
+ id: 'theme',
+ question: "What's the theme or setting?",
+ placeholder: 'e.g., outer space, underwater kingdom, magical forest',
+ suggestions: ['outer space', 'underwater', 'magical forest', 'haunted mansion', 'candy land', 'jungle', 'city', 'arctic']
+ },
+ {
+ id: 'player',
+ question: 'Who or what does the player control?',
+ placeholder: 'e.g., a brave astronaut, a friendly robot, a bouncing ball',
+ suggestions: ['brave hero', 'cute animal', 'robot', 'spaceship', 'ball/shape', 'wizard']
+ },
+ {
+ id: 'goal',
+ question: "What's the goal? How do you win?",
+ placeholder: 'e.g., reach the finish line, collect all the stars, defeat the boss',
+ suggestions: ['reach the end', 'collect items', 'survive waves', 'beat high score', 'solve the puzzle', 'defeat enemies']
+ },
+ {
+ id: 'challenge',
+ question: 'What makes it challenging?',
+ placeholder: 'e.g., time limit, tricky enemies, faster speeds over time',
+ suggestions: ['time pressure', 'enemies/obstacles', 'increasing speed', 'limited lives', 'complex puzzles']
+ }
+];
+
+// Final configuration questions
+const FINAL_QUESTIONS = [
+ {
+ id: 'avatar',
+ question: 'Should the game support custom player avatars?',
+ type: 'select',
+ options: [
+ { value: 'yes', label: 'Yes - Let players customize their look' },
+ { value: 'no', label: 'No - Use a fixed character design' }
+ ]
+ },
+ {
+ id: 'difficulty',
+ question: 'What difficulty levels should this have?',
+ type: 'select',
+ options: [
+ { value: 'easy', label: 'Easy only (great for young kids)' },
+ { value: 'easy-medium', label: 'Easy + Medium' },
+ { value: 'all', label: 'Easy, Medium, and Hard' }
+ ]
+ },
+ {
+ id: 'musicStyle',
+ question: 'What style of music should your game have?',
+ type: 'select',
+ options: [
+ { value: 'chiptune', label: 'Chiptune/8-bit (classic arcade)', icon: '🎵' },
+ { value: 'rock', label: 'Rock/energetic', icon: '🎸' },
+ { value: 'calm', label: 'Calm/peaceful', icon: '🎹' },
+ { value: 'epic', label: 'Epic/adventure', icon: '🌟' },
+ { value: 'spooky', label: 'Spooky/mysterious', icon: '👻' },
+ { value: 'electronic', label: 'Electronic/synth', icon: '🤖' },
+ { value: 'silly', label: 'Silly/fun', icon: '🎪' },
+ { value: 'none', label: 'No music', icon: '🔇' }
+ ]
+ },
+ {
+ id: 'features',
+ question: 'Any special features?',
+ type: 'multiselect',
+ options: [
+ { value: 'timer', label: 'Time limit' },
+ { value: 'notimer', label: 'No time pressure (zen mode)' },
+ { value: 'local2p', label: 'Local 2-player mode' },
+ { value: 'endless', label: 'Endless/survival mode' }
+ ]
+ },
+ {
+ id: 'accessibility',
+ question: 'Add accessibility features?',
+ type: 'multiselect',
+ options: [
+ { value: 'onehand', label: 'One-hand playable' },
+ { value: 'colorblind', label: 'Colorblind friendly (shapes + colors)' },
+ { value: 'audio', label: 'Audio descriptions' },
+ { value: 'largetargets', label: 'Large touch targets' },
+ { value: 'adjustspeed', label: 'Adjustable game speed' }
+ ]
+ }
+];
+
+module.exports = {
+ GAME_STYLES,
+ COMMON_QUESTIONS,
+ FINAL_QUESTIONS,
+
+ getStyleById(styleId) {
+ return GAME_STYLES[styleId] || null;
+ },
+
+ getAllStyles() {
+ return Object.values(GAME_STYLES).map(style => ({
+ id: style.id,
+ name: style.name,
+ icon: style.icon,
+ description: style.description,
+ examples: style.examples
+ }));
+ },
+
+ getQuestionsForStyle(styleId) {
+ const style = GAME_STYLES[styleId];
+ if (!style) return null;
+
+ return {
+ common: style.skipCommonQuestions ? [] : COMMON_QUESTIONS,
+ styleSpecific: style.questions,
+ final: FINAL_QUESTIONS,
+ skipCommonQuestions: style.skipCommonQuestions || false
+ };
+ }
+};
diff --git a/backend/src/utils/haiku.js b/backend/src/utils/haiku.js
new file mode 100644
index 0000000..83876d5
--- /dev/null
+++ b/backend/src/utils/haiku.js
@@ -0,0 +1,196 @@
+const Anthropic = require('@anthropic-ai/sdk');
+
+const client = new Anthropic({
+ apiKey: process.env.CLAUDE_API_KEY,
+ timeout: 15 * 1000 // 15 seconds max for Haiku calls
+});
+
+const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
+
+/**
+ * Preprocess a tweak request - check feasibility, clean up, ask for clarification
+ * Returns: { canTweak, needsClarification, cleanedRequest, question, reason }
+ */
+async function preprocessTweak(feedback, gameTitle) {
+ try {
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 300,
+ system: `You analyze game tweak requests for a kids' HTML5 game arcade. Respond with JSON only.
+
+A "tweak" is a small code change: colors, sizes, speeds, text, simple visual adjustments.
+NOT a tweak: adding new game mechanics, rewriting game logic, adding multiplayer, new levels, complete redesigns.
+
+Respond with exactly one JSON object:
+- If the request is vague/ambiguous: {"needsClarification":true,"question":""}
+- If too complex for a tweak: {"canTweak":false,"reason":""}
+- If it's a valid tweak: {"canTweak":true,"cleanedRequest":""}`,
+ messages: [{
+ role: 'user',
+ content: `Game: "${gameTitle}"\nTweak request: "${feedback}"`
+ }]
+ });
+
+ const text = response.content[0].text.trim();
+ // Extract JSON from response (handle markdown code blocks)
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ return { canTweak: true, cleanedRequest: feedback };
+ }
+
+ const result = JSON.parse(jsonMatch[0]);
+
+ if (result.needsClarification) {
+ return { needsClarification: true, question: result.question };
+ }
+ if (result.canTweak === false) {
+ return { canTweak: false, reason: result.reason, suggestOverhaul: true };
+ }
+ return { canTweak: true, cleanedRequest: result.cleanedRequest || feedback };
+ } catch (error) {
+ console.warn('Haiku preprocessTweak failed, falling through:', error.message);
+ return { canTweak: true, cleanedRequest: feedback };
+ }
+}
+
+/**
+ * Preprocess a game creation or refine prompt - clean up grammar, make vague ideas specific
+ * Context: "creation" or "refine"
+ * Returns: { success, cleanedText }
+ */
+async function preprocessPrompt(promptText, context) {
+ try {
+ const contextInstructions = context === 'creation'
+ ? 'This is a game creation prompt describing what game to build.'
+ : 'This is feedback for refining an existing game.';
+
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 1000,
+ system: `You clean up game prompts for a kids' HTML5 game arcade. ${contextInstructions}
+
+Your job:
+- Fix typos and grammar
+- Make vague descriptions slightly more specific (but don't add ideas the user didn't mention)
+- Keep the same meaning and intent
+- Keep it concise
+
+Return ONLY the cleaned-up text, nothing else. No quotes, no explanation.`,
+ messages: [{
+ role: 'user',
+ content: promptText
+ }]
+ });
+
+ const cleanedText = response.content[0].text.trim();
+ if (!cleanedText || cleanedText.length < 3) {
+ return { success: true, cleanedText: promptText };
+ }
+ return { success: true, cleanedText };
+ } catch (error) {
+ console.warn('Haiku preprocessPrompt failed, falling through:', error.message);
+ return { success: true, cleanedText: promptText };
+ }
+}
+
+/**
+ * Convert a technical error into a kid-friendly explanation
+ * Context: "tweak", "generation", or "refine"
+ * Returns: { success, friendlyError }
+ */
+async function explainError(rawError, context) {
+ try {
+ const contextLabel = {
+ tweak: 'applying a free tweak to their game',
+ generation: 'generating a new game',
+ refine: 'refining their game with AI'
+ }[context] || 'working on their game';
+
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 100,
+ system: `You write kid-friendly (ages 8-16) error messages for a game arcade website.
+The user was ${contextLabel} and got an error.
+Write a 1-2 sentence friendly explanation. Be encouraging. Don't mention technical details.
+Return ONLY the error message text.`,
+ messages: [{
+ role: 'user',
+ content: `Technical error: ${rawError}`
+ }]
+ });
+
+ const friendlyError = response.content[0].text.trim();
+ if (!friendlyError) {
+ return { success: false, friendlyError: 'Something went wrong, try again!' };
+ }
+ return { success: true, friendlyError };
+ } catch (error) {
+ console.warn('Haiku explainError failed, using generic message:', error.message);
+ return { success: false, friendlyError: 'Something went wrong, try again!' };
+ }
+}
+
+/**
+ * Summarize code changes after a tweak or refinement
+ * Returns kid-friendly 2-3 sentence description of what changed
+ */
+async function summarizeCodeChanges(oldCode, newCode, userRequest) {
+ try {
+ // Compute a simple diff summary by comparing lengths and key sections
+ const oldLen = (oldCode || '').length;
+ const newLen = (newCode || '').length;
+ const sizeDiff = newLen - oldLen;
+
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 200,
+ system: `You summarize game code changes for kids aged 8-16. Write 2-3 short, fun sentences about what changed. Use simple language. Don't mention code or technical details - describe the visible result.`,
+ messages: [{
+ role: 'user',
+ content: `The user asked: "${userRequest}"\nCode changed by ${sizeDiff > 0 ? '+' : ''}${sizeDiff} characters.\nSummarize what likely changed in the game.`
+ }]
+ });
+
+ return response.content[0].text.trim();
+ } catch (error) {
+ console.warn('Haiku summarizeCodeChanges failed:', error.message);
+ return userRequest; // Fall back to the original request as description
+ }
+}
+
+/**
+ * Summarize a newly generated game for the user
+ * Returns 2-3 sentence description
+ */
+async function summarizeNewGame(gameCode, originalPrompt) {
+ try {
+ // Extract key indicators from the code
+ const hasCanvas = gameCode.includes(' 0) {
+ userContent += 'Conversation so far:\n';
+ for (const msg of conversationHistory) {
+ userContent += `${msg.role === 'ai' ? 'You' : 'User'}: ${msg.text}\n`;
+ }
+ userContent += '\nAsk your next question, or if you have enough detail, output the complete specification prefixed with SPEC_COMPLETE:';
+ } else {
+ userContent += 'This is the start of the conversation. Ask your first clarifying question.';
+ }
+
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 2000,
+ system: systemPrompt,
+ messages: [{ role: 'user', content: userContent }]
+ });
+
+ const text = response.content[0].text.trim();
+ const isComplete = text.includes('SPEC_COMPLETE:');
+
+ if (isComplete) {
+ const specStart = text.indexOf('SPEC_COMPLETE:') + 'SPEC_COMPLETE:'.length;
+ const refinedSpec = text.substring(specStart).trim();
+ return {
+ success: true,
+ question: null,
+ isComplete: true,
+ refinedSpec,
+ durationMs: Date.now() - startTime
+ };
+ }
+
+ return {
+ success: true,
+ question: text,
+ isComplete: false,
+ refinedSpec: null,
+ durationMs: Date.now() - startTime
+ };
+ } catch (error) {
+ console.error('Haiku refinePrompt failed:', error.message);
+ return { success: false, error: error.message, durationMs: Date.now() - startTime };
+ }
+}
+
+/**
+ * Stage 3: Classify feedback as minor tweak or major change
+ */
+async function classifyTweak(feedback) {
+ try {
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 300,
+ system: `You classify game modification requests as either "minor_tweak" or "major_change".
+
+MINOR_TWEAK examples (simple value/style changes):
+- Change colors (background, player, enemies)
+- Adjust speed values (faster, slower)
+- Change sizes (bigger player, smaller enemies)
+- Change text/labels (title, score label, game over text)
+- Numeric tweaks (more lives, higher score, different timer)
+- Simple CSS changes (font size, borders, spacing)
+- Sound volume adjustments
+- Change starting values (initial speed, health, ammo)
+
+MAJOR_CHANGE examples (structural/logic changes):
+- Add new game mechanics (power-ups, shields, combos)
+- Add enemies or new enemy types
+- Add levels or a level system
+- Add multiplayer support
+- Change game genre or core gameplay
+- Restructure game loop or scoring system
+- Add new UI elements (menus, HUD, minimap)
+- Add animations or particle effects
+- Add save/load functionality
+
+Be CONSERVATIVE: if uncertain, classify as "major_change".
+
+Respond with ONLY a JSON object: {"classification": "minor_tweak" or "major_change", "confidence": 0.0-1.0, "reason": "brief explanation"}`,
+ messages: [{
+ role: 'user',
+ content: `Classify this game modification request:\n"${feedback}"`
+ }]
+ });
+
+ const text = response.content[0].text.trim();
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ return { classification: 'major_change', confidence: 0.5, reason: 'Could not parse classification' };
+ }
+ const parsed = JSON.parse(jsonMatch[0]);
+ return {
+ classification: parsed.classification === 'minor_tweak' ? 'minor_tweak' : 'major_change',
+ confidence: Math.min(1, Math.max(0, parsed.confidence || 0.5)),
+ reason: parsed.reason || 'No reason provided'
+ };
+ } catch (error) {
+ console.error('Haiku classifyTweak failed:', error.message);
+ return { classification: 'major_change', confidence: 0, reason: 'Classification unavailable', error: error.message };
+ }
+}
+
+/**
+ * Stage 3: Apply a minor tweak via diff/patch approach
+ */
+async function applyTweak(gameCode, tweakDescription) {
+ const startTime = Date.now();
+ try {
+ const response = await client.messages.create({
+ model: HAIKU_MODEL,
+ max_tokens: 4096,
+ system: `You output ONLY valid JSON arrays of find/replace pairs. No explanation, no markdown code blocks, no commentary. Just the raw JSON array.`,
+ messages: [{
+ role: 'user',
+ content: `Here is an HTML5 game:\n\n${gameCode}\n\nThe user wants: "${tweakDescription}"\n\nFind ALL places in the code that need to change. Return a JSON array of find/replace pairs:\n[{"find": "exact original text", "replace": "new text"}]\n\nRules:\n- Each "find" must be an EXACT copy-paste substring from the original code\n- Find ALL instances that need changing - check CSS, JS variables, canvas drawing code, etc.\n- Only include strings that actually need to change\n- Keep "find" strings short and precise - just the value/line that changes, not huge blocks\n- Return ONLY the JSON array`
+ }]
+ });
+
+ const text = response.content[0].text.trim();
+
+ // Parse JSON patches
+ let patches;
+ let cleanText = text;
+ if (cleanText.startsWith('```')) cleanText = cleanText.split('\n').slice(1).join('\n');
+ if (cleanText.endsWith('```')) cleanText = cleanText.slice(0, cleanText.lastIndexOf('```'));
+ cleanText = cleanText.trim();
+
+ const arrStart = cleanText.indexOf('[');
+ const arrEnd = cleanText.lastIndexOf(']');
+ if (arrStart === -1 || arrEnd === -1) {
+ return { success: false, error: 'AI did not return valid patches', durationMs: Date.now() - startTime };
+ }
+
+ patches = JSON.parse(cleanText.substring(arrStart, arrEnd + 1));
+
+ if (!Array.isArray(patches) || patches.length === 0) {
+ return { success: false, error: 'AI returned no patches', durationMs: Date.now() - startTime };
+ }
+
+ // Apply patches to original code
+ let modifiedCode = gameCode;
+ let applied = 0;
+ let missed = 0;
+ const appliedChanges = [];
+
+ for (const patch of patches) {
+ if (!patch.find || !patch.replace || patch.find === patch.replace) continue;
+
+ if (modifiedCode.includes(patch.find)) {
+ modifiedCode = modifiedCode.split(patch.find).join(patch.replace);
+ applied++;
+ appliedChanges.push(`"${patch.find.substring(0, 40)}" -> "${patch.replace.substring(0, 40)}"`);
+ } else {
+ missed++;
+ console.log(`Haiku patch miss: could not find "${patch.find.substring(0, 60)}" in game code`);
+ }
+ }
+
+ if (applied === 0) {
+ return {
+ success: false,
+ error: `None of the ${patches.length} patches matched the game code`,
+ durationMs: Date.now() - startTime
+ };
+ }
+
+ console.log(`Haiku tweak: ${applied} patches applied, ${missed} missed, ${patches.length} total`);
+
+ return {
+ success: true,
+ code: modifiedCode,
+ changesDescription: tweakDescription,
+ patchesApplied: applied,
+ patchesMissed: missed,
+ patchDetails: appliedChanges,
+ durationMs: Date.now() - startTime
+ };
+ } catch (error) {
+ console.error('Haiku applyTweak failed:', error.message);
+ return { success: false, error: error.message, durationMs: Date.now() - startTime };
+ }
+}
+
+/**
+ * Health check - Haiku is always available
+ */
+async function isAvailable() {
+ return { available: true };
+}
+
+module.exports = {
+ refinePrompt,
+ classifyTweak,
+ applyTweak,
+ isAvailable
+};
diff --git a/backend/src/utils/promptBuilder.js b/backend/src/utils/promptBuilder.js
new file mode 100644
index 0000000..4e9735b
--- /dev/null
+++ b/backend/src/utils/promptBuilder.js
@@ -0,0 +1,421 @@
+/**
+ * Prompt Builder
+ * Constructs Claude prompts from guided question answers
+ */
+
+const { GAME_STYLES } = require('./gameStyles');
+
+/**
+ * Build a detailed game prompt from user answers
+ */
+function buildPrompt(styleId, answers) {
+ const style = GAME_STYLES[styleId];
+ if (!style) {
+ throw new Error(`Unknown game style: ${styleId}`);
+ }
+
+ // Handle freeform style differently - use the full description directly
+ if (styleId === 'freeform') {
+ let prompt = `Create a game based on this description:\n\n${answers.fullDescription || ''}\n\n`;
+
+ // Add avatar customization
+ if (answers.avatar === 'yes') {
+ prompt += 'Avatar Customization: At the start of the game (or on the title screen), let the player pick their character appearance - offer at least 3-4 color/style options. Remember the choice throughout gameplay.\n\n';
+ }
+
+ // Still add difficulty, features, accessibility, and requirements
+ prompt += buildDifficultyPrompt(answers);
+ prompt += buildFeaturesPrompt(answers);
+ prompt += buildAccessibilityPrompt(answers);
+ prompt += buildRequirementsReminder(styleId, answers);
+
+ return prompt;
+ }
+
+ // Start with the game type and theme
+ let prompt = `Create a ${style.name.toLowerCase()} game`;
+
+ // Add theme if provided
+ if (answers.theme) {
+ prompt += ` set in ${answers.theme}`;
+ }
+ prompt += '.\n\n';
+
+ // Add player character
+ if (answers.player) {
+ prompt += `The player controls ${answers.player}. `;
+ }
+
+ // Add goal
+ if (answers.goal) {
+ prompt += `The goal is to ${answers.goal}. `;
+ }
+
+ // Add challenge
+ if (answers.challenge) {
+ prompt += `The challenge comes from ${answers.challenge}. `;
+ }
+
+ prompt += '\n\n';
+
+ // Add style-specific details
+ prompt += buildStyleSpecificPrompt(styleId, answers);
+
+ // Add avatar customization
+ if (answers.avatar === 'yes') {
+ prompt += 'Avatar Customization: At the start of the game (or on the title screen), let the player pick their character appearance - offer at least 3-4 color/style options. Remember the choice throughout gameplay.\n\n';
+ }
+
+ // Add difficulty configuration
+ prompt += buildDifficultyPrompt(answers);
+
+ // Add special features
+ prompt += buildFeaturesPrompt(answers);
+
+ // Add accessibility requirements
+ prompt += buildAccessibilityPrompt(answers);
+
+ // Add reinforcement of core requirements
+ prompt += buildRequirementsReminder(styleId, answers);
+
+ return prompt;
+}
+
+function buildStyleSpecificPrompt(styleId, answers) {
+ let prompt = '';
+
+ switch (styleId) {
+ case 'action':
+ if (answers.movement) {
+ prompt += `Player actions: ${answers.movement}. `;
+ }
+ if (answers.damage) {
+ prompt += `When hit: ${answers.damage}. `;
+ }
+ break;
+
+ case 'puzzle':
+ if (answers.mechanic) {
+ prompt += `Core mechanic: ${answers.mechanic}. `;
+ }
+ if (answers.levels) {
+ if (answers.levels === 'endless') {
+ prompt += 'Include endless procedurally generated levels. ';
+ } else {
+ prompt += `Include ${answers.levels} levels with increasing difficulty. `;
+ }
+ }
+ break;
+
+ case 'story':
+ if (answers.characters) {
+ prompt += `NPCs to meet: ${answers.characters}. `;
+ }
+ if (answers.items) {
+ prompt += `Collectible items: ${answers.items}. `;
+ }
+ if (answers.endings) {
+ if (answers.endings === '1') {
+ prompt += 'Linear story with one ending. ';
+ } else if (answers.endings === '3') {
+ prompt += 'Branching story with 3 endings (good, neutral, bad). ';
+ } else {
+ prompt += 'Branching story with 5+ unique endings based on player choices. ';
+ }
+ }
+ break;
+
+ case 'racing':
+ if (answers.vehicle) {
+ prompt += `Player races with: ${answers.vehicle}. `;
+ }
+ if (answers.obstacles) {
+ prompt += `Track features: ${answers.obstacles}. `;
+ }
+ break;
+
+ case 'pet':
+ if (answers.creature) {
+ prompt += `The player cares for: ${answers.creature}. `;
+ }
+ if (answers.needs) {
+ prompt += `Needs to manage: ${answers.needs}. `;
+ }
+ break;
+
+ case 'trivia':
+ if (answers.topic) {
+ prompt += `Questions about: ${answers.topic}. `;
+ }
+ if (answers.format) {
+ const formatMap = {
+ 'multiple': 'Use multiple choice questions with 4 options. ',
+ 'truefalse': 'Use true/false questions. ',
+ 'mixed': 'Mix multiple choice and true/false questions. '
+ };
+ prompt += formatMap[answers.format] || '';
+ }
+ prompt += 'Include at least 20 unique questions. ';
+ break;
+
+ case 'music':
+ if (answers.musicStyle) {
+ prompt += `Music style: ${answers.musicStyle}. `;
+ }
+ if (answers.interaction) {
+ prompt += `Player interaction: ${answers.interaction}. `;
+ }
+ break;
+
+ case 'creative':
+ if (answers.medium) {
+ prompt += `Creation tools: ${answers.medium}. `;
+ }
+ if (answers.goal === 'free') {
+ prompt += 'Pure sandbox mode with no goals, just creative freedom. ';
+ } else if (answers.goal === 'challenges') {
+ prompt += 'Include creative challenges and prompts for the player to complete. ';
+ } else {
+ prompt += 'Include both free mode and challenge mode. ';
+ }
+ break;
+
+ case 'rpg':
+ if (answers.hero) {
+ prompt += `The hero is: ${answers.hero}. `;
+ }
+ if (answers.combat) {
+ const combatMap = {
+ 'turnbased': 'Turn-based combat where player and enemies take turns. ',
+ 'realtime': 'Real-time action combat with dodging and attacking. ',
+ 'auto': 'Auto-battle with strategic ability choices. '
+ };
+ prompt += combatMap[answers.combat] || '';
+ }
+ if (answers.progression) {
+ prompt += `Progression system: ${answers.progression}. `;
+ }
+ break;
+
+ case 'sports':
+ if (answers.sport) {
+ prompt += `Sport: ${answers.sport}. `;
+ }
+ if (answers.mode) {
+ const modeMap = {
+ 'single': 'Single player mode to beat your high score. ',
+ 'vsai': 'Vs computer opponent with adjustable AI difficulty. ',
+ 'local': 'Local 2-player mode on the same device. '
+ };
+ prompt += modeMap[answers.mode] || '';
+ }
+ break;
+
+ case 'physics':
+ if (answers.object) {
+ prompt += `Player controls: ${answers.object}. `;
+ }
+ if (answers.environment) {
+ prompt += `Environment features: ${answers.environment}. `;
+ }
+ prompt += 'Use realistic physics simulation. ';
+ break;
+ }
+
+ return prompt + '\n\n';
+}
+
+function buildDifficultyPrompt(answers) {
+ let prompt = 'DIFFICULTY: ';
+
+ switch (answers.difficulty) {
+ case 'easy':
+ prompt += 'Easy mode only - very forgiving, suitable for young kids (ages 6-8). ';
+ prompt += 'Very slow speeds, large targets, generous timers if any. ';
+ break;
+ case 'easy-medium':
+ prompt += 'Include Easy and Medium difficulties. ';
+ prompt += 'Easy should be suitable for ages 6-8, Medium for ages 9-12. ';
+ break;
+ case 'all':
+ default:
+ prompt += 'Include Easy, Medium, and Hard difficulties. ';
+ prompt += 'Easy for ages 6-8, Medium for ages 9-12, Hard for teens who want a challenge. ';
+ break;
+ }
+
+ prompt += 'Start on Easy by default. Add difficulty selector on game over screen. ';
+ return prompt + '\n\n';
+}
+
+function buildFeaturesPrompt(answers) {
+ if (!answers.features || answers.features.length === 0) {
+ return '';
+ }
+
+ let prompt = 'SPECIAL FEATURES:\n';
+
+ if (answers.features.includes('timer')) {
+ prompt += '- Include a time limit that adds pressure\n';
+ }
+ if (answers.features.includes('notimer')) {
+ prompt += '- Zen mode with no time pressure\n';
+ }
+ if (answers.features.includes('local2p')) {
+ prompt += '- Local 2-player mode: Player 1 uses WASD, Player 2 uses Arrow keys\n';
+ }
+ if (answers.features.includes('endless')) {
+ prompt += '- Endless/survival mode that continues until game over\n';
+ }
+
+ return prompt + '\n';
+}
+
+function buildAccessibilityPrompt(answers) {
+ if (!answers.accessibility || answers.accessibility.length === 0) {
+ return '';
+ }
+
+ let prompt = 'ACCESSIBILITY FEATURES (REQUIRED):\n';
+
+ if (answers.accessibility.includes('onehand')) {
+ prompt += '- Must be fully playable with one hand (all controls on one side)\n';
+ }
+ if (answers.accessibility.includes('colorblind')) {
+ prompt += '- Use shapes AND colors (never color alone) for all game elements\n';
+ prompt += '- Add patterns or symbols to distinguish items\n';
+ }
+ if (answers.accessibility.includes('audio')) {
+ prompt += '- Add audio cues and spoken descriptions for key events\n';
+ }
+ if (answers.accessibility.includes('largetargets')) {
+ prompt += '- All touch targets must be extra large (60px minimum)\n';
+ }
+ if (answers.accessibility.includes('adjustspeed')) {
+ prompt += '- Add game speed slider (0.5x to 2x) in pause menu\n';
+ }
+
+ return prompt + '\n';
+}
+
+function buildRequirementsReminder(styleId, answers) {
+ let prompt = '\nIMPORTANT REQUIREMENTS:\n';
+ prompt += '- Game MUST start very easy - playable by an 8-year-old for at least 30 seconds\n';
+
+ // Handle music style
+ const musicStyles = {
+ chiptune: 'Include catchy 8-bit chiptune background music using Web Audio API - classic arcade style with square waves and simple melodies',
+ rock: 'Include energetic rock-style background music using Web Audio API - driving rhythms, power chord sounds, upbeat tempo',
+ calm: 'Include calm, peaceful background music using Web Audio API - gentle melodies, soft tones, relaxing atmosphere',
+ epic: 'Include epic adventure-style background music using Web Audio API - sweeping melodies, building intensity, heroic feel',
+ spooky: 'Include spooky, mysterious background music using Web Audio API - minor keys, eerie sounds, suspenseful atmosphere',
+ electronic: 'Include electronic/synth background music using Web Audio API - pulsing beats, synthesizer sounds, modern feel',
+ silly: 'Include silly, fun background music using Web Audio API - playful melodies, bouncy rhythms, whimsical sounds',
+ none: 'No background music - only sound effects for game actions'
+ };
+
+ const selectedMusic = answers.musicStyle || 'chiptune';
+ if (selectedMusic !== 'none') {
+ prompt += `- ${musicStyles[selectedMusic] || musicStyles.chiptune}\n`;
+ } else {
+ prompt += `- ${musicStyles.none}\n`;
+ }
+
+ prompt += '- Support all three controls: touch (on-screen buttons), keyboard (arrows + space), mouse\n';
+ prompt += '- Use bright, kid-friendly colors and graphics\n';
+ prompt += '- Add visual feedback when scoring (flash, particles)\n';
+ prompt += '- Large, forgiving hitboxes for collision detection\n';
+ prompt += '- 10% safe zones at top and bottom of screen\n';
+ prompt += '- Game over screen shows final score with large "Play Again" button\n';
+
+ // Add style-specific reminders
+ if (['action', 'racing', 'rpg'].includes(styleId)) {
+ prompt += '- Start with 5 lives, obstacles move slowly at first\n';
+ }
+ if (['puzzle', 'trivia'].includes(styleId)) {
+ prompt += '- First few levels/questions should be very easy\n';
+ }
+ if (styleId === 'story') {
+ prompt += '- Text should be large and readable, with a "continue" button\n';
+ }
+
+ // Count details for achievement tracking
+ const detailCount = Object.values(answers).filter(v => v && v.length > 0).length;
+ if (detailCount >= 5) {
+ prompt += `\n(User provided ${detailCount} specific details - create a detailed, polished game!)\n`;
+ }
+
+ return prompt;
+}
+
+/**
+ * Generate a summary of the game from answers (for user review)
+ */
+function generateSummary(styleId, answers) {
+ const style = GAME_STYLES[styleId];
+ if (!style) return null;
+
+ // For freeform, use a truncated version of the description
+ if (styleId === 'freeform' && answers.fullDescription) {
+ const desc = answers.fullDescription;
+ const truncated = desc.length > 150 ? desc.substring(0, 147) + '...' : desc;
+ return `Custom game: ${truncated}`;
+ }
+
+ const parts = [];
+
+ parts.push(`A ${style.name.toLowerCase()} game`);
+
+ if (answers.theme) {
+ parts.push(`set in ${answers.theme}`);
+ }
+
+ if (answers.player) {
+ parts.push(`where you control ${answers.player}`);
+ }
+
+ if (answers.goal) {
+ parts.push(`and try to ${answers.goal}`);
+ }
+
+ let summary = parts.join(' ') + '.';
+
+ // Add key features
+ const features = [];
+
+ if (answers.difficulty === 'all') {
+ features.push('3 difficulty levels');
+ }
+ if (answers.features?.includes('local2p')) {
+ features.push('2-player mode');
+ }
+ if (answers.features?.includes('endless')) {
+ features.push('endless mode');
+ }
+ if (answers.accessibility?.length > 0) {
+ features.push('accessibility options');
+ }
+
+ if (features.length > 0) {
+ summary += ` Features: ${features.join(', ')}.`;
+ }
+
+ return summary;
+}
+
+/**
+ * Count details provided (for achievements)
+ */
+function countDetails(answers) {
+ return Object.values(answers).filter(v => {
+ if (Array.isArray(v)) return v.length > 0;
+ if (typeof v === 'string') return v.trim().length > 0;
+ return false;
+ }).length;
+}
+
+module.exports = {
+ buildPrompt,
+ generateSummary,
+ countDetails
+};
diff --git a/backend/src/utils/pushNotify.js b/backend/src/utils/pushNotify.js
new file mode 100644
index 0000000..1ee07f2
--- /dev/null
+++ b/backend/src/utils/pushNotify.js
@@ -0,0 +1,101 @@
+const { pool } = require('../models/db');
+
+let webpush;
+let initialized = false;
+
+function init() {
+ if (initialized) return;
+ try {
+ webpush = require('web-push');
+ const vapidPublic = process.env.VAPID_PUBLIC_KEY;
+ const vapidPrivate = process.env.VAPID_PRIVATE_KEY;
+ const vapidEmail = process.env.VAPID_EMAIL || 'info@gamercomp.com';
+
+ if (vapidPublic && vapidPrivate) {
+ webpush.setVapidDetails(`mailto:${vapidEmail}`, vapidPublic, vapidPrivate);
+ initialized = true;
+ console.log('Push notifications: VAPID configured');
+ } else {
+ console.log('Push notifications: VAPID keys not configured, push disabled');
+ }
+ } catch (err) {
+ console.log('Push notifications: web-push not installed, push disabled');
+ }
+}
+
+// Initialize on first require
+init();
+
+/**
+ * Send push notification to all subscriptions for a user
+ */
+async function send(userId, { title, body, url }) {
+ if (!initialized || !webpush) return;
+
+ try {
+ const result = await pool.query(
+ 'SELECT id, endpoint, auth_key, p256dh_key FROM push_subscriptions WHERE user_id = $1',
+ [userId]
+ );
+
+ if (result.rows.length === 0) return;
+
+ const payload = JSON.stringify({ title, body, url });
+ const failedIds = [];
+
+ for (const sub of result.rows) {
+ try {
+ await webpush.sendNotification({
+ endpoint: sub.endpoint,
+ keys: {
+ auth: sub.auth_key,
+ p256dh: sub.p256dh_key
+ }
+ }, payload);
+ } catch (err) {
+ // 410 Gone or 404 means subscription expired
+ if (err.statusCode === 410 || err.statusCode === 404) {
+ failedIds.push(sub.id);
+ }
+ console.warn(`Push failed for subscription ${sub.id}:`, err.message);
+ }
+ }
+
+ // Prune expired subscriptions
+ if (failedIds.length > 0) {
+ await pool.query(
+ 'DELETE FROM push_subscriptions WHERE id = ANY($1)',
+ [failedIds]
+ );
+ console.log(`Pruned ${failedIds.length} expired push subscriptions for user ${userId}`);
+ }
+ } catch (err) {
+ console.error('Push send error:', err.message);
+ }
+}
+
+/**
+ * Save a push subscription for a user
+ */
+async function subscribe(userId, subscription) {
+ const { endpoint, keys } = subscription;
+ await pool.query(
+ `INSERT INTO push_subscriptions (user_id, endpoint, auth_key, p256dh_key)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (endpoint) DO UPDATE SET
+ user_id = $1, auth_key = $3, p256dh_key = $4`,
+ [userId, endpoint, keys.auth, keys.p256dh]
+ );
+}
+
+/**
+ * Remove a push subscription
+ */
+async function unsubscribe(userId, endpoint) {
+ await pool.query(
+ 'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
+ [userId, endpoint]
+ );
+}
+
+module.exports = { send, subscribe, unsubscribe, initialized: () => initialized };
diff --git a/backend/src/utils/usernames.js b/backend/src/utils/usernames.js
new file mode 100644
index 0000000..a1348d9
--- /dev/null
+++ b/backend/src/utils/usernames.js
@@ -0,0 +1,118 @@
+// Lists of words for generating fun usernames
+const adjectives = [
+ 'cosmic', 'turbo', 'mega', 'super', 'hyper', 'ultra', 'epic', 'blazing',
+ 'swift', 'clever', 'mighty', 'brave', 'fierce', 'sneaky', 'zippy', 'jolly',
+ 'sparkly', 'neon', 'cyber', 'pixel', 'retro', 'funky', 'groovy', 'radical',
+ 'stellar', 'cosmic', 'galactic', 'thunder', 'lightning', 'shadow', 'golden',
+ 'crystal', 'frozen', 'flaming', 'electric', 'magnetic', 'sonic', 'atomic',
+ 'ninja', 'wizard', 'dragon', 'phoenix', 'lucky', 'happy', 'crazy', 'wild'
+];
+
+const nouns = [
+ 'gamer', 'player', 'coder', 'hacker', 'ninja', 'wizard', 'pirate', 'knight',
+ 'dragon', 'phoenix', 'falcon', 'tiger', 'wolf', 'fox', 'panda', 'koala',
+ 'rocket', 'comet', 'star', 'nova', 'nebula', 'galaxy', 'pixel', 'byte',
+ 'quest', 'legend', 'hero', 'champion', 'master', 'ace', 'pro', 'boss',
+ 'storm', 'blaze', 'spark', 'flash', 'dash', 'bolt', 'wave', 'vortex',
+ 'arcade', 'joystick', 'controller', 'console', 'avatar', 'sprite', 'level'
+];
+
+// Common first names to block (partial list - expand as needed)
+const commonFirstNames = new Set([
+ 'james', 'john', 'robert', 'michael', 'william', 'david', 'richard', 'joseph',
+ 'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'anthony', 'mark',
+ 'donald', 'steven', 'paul', 'andrew', 'joshua', 'kenneth', 'kevin', 'brian',
+ 'mary', 'patricia', 'jennifer', 'linda', 'elizabeth', 'barbara', 'susan',
+ 'jessica', 'sarah', 'karen', 'nancy', 'lisa', 'betty', 'margaret', 'sandra',
+ 'ashley', 'dorothy', 'kimberly', 'emily', 'donna', 'michelle', 'carol',
+ 'emma', 'olivia', 'ava', 'isabella', 'sophia', 'mia', 'charlotte', 'amelia',
+ 'harper', 'evelyn', 'abigail', 'ella', 'scarlett', 'grace', 'chloe', 'camila',
+ 'liam', 'noah', 'oliver', 'elijah', 'lucas', 'mason', 'logan', 'alexander',
+ 'ethan', 'jacob', 'aiden', 'jackson', 'sebastian', 'jack', 'owen', 'henry',
+ 'samuel', 'ryan', 'nathan', 'adam', 'tyler', 'dylan', 'zachary', 'aaron',
+ 'allen', 'alex', 'jake', 'mike', 'tom', 'bob', 'joe', 'tim', 'sam', 'ben',
+ 'max', 'charlie', 'luke', 'leo', 'theo', 'finn', 'oscar', 'archie', 'alfie'
+]);
+
+// Patterns that look like real names
+const realNamePatterns = [
+ /^[a-z]+\s+[a-z]+$/i, // "John Smith"
+ /^[a-z]+_[a-z]+$/i, // "john_smith"
+ /^[a-z]+\.[a-z]+$/i, // "john.smith"
+ /^[a-z]{2,15}[0-9]{0,4}$/i, // Common pattern like "john123"
+];
+
+function generateUsername() {
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
+ const num = Math.floor(Math.random() * 999) + 1;
+ return `${adj}_${noun}${num}`;
+}
+
+function generateUsernameSuggestions(count = 5) {
+ const suggestions = new Set();
+ while (suggestions.size < count) {
+ suggestions.add(generateUsername());
+ }
+ return Array.from(suggestions);
+}
+
+function looksLikeRealName(username) {
+ const lower = username.toLowerCase().replace(/[0-9_]/g, '');
+
+ // Check if it contains a common first name
+ if (commonFirstNames.has(lower)) {
+ return true;
+ }
+
+ // Check for name patterns
+ for (const name of commonFirstNames) {
+ if (lower.startsWith(name) && lower.length <= name.length + 5) {
+ return true;
+ }
+ }
+
+ // Check if username is just letters (potential name)
+ if (/^[a-z]{4,15}$/i.test(username)) {
+ // Could be a name, flag for review but allow with underscore/numbers
+ const lower = username.toLowerCase();
+ if (commonFirstNames.has(lower)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function validateUsername(username) {
+ const errors = [];
+
+ // Basic validation
+ if (username.length < 3) {
+ errors.push('Username must be at least 3 characters');
+ }
+ if (username.length > 30) {
+ errors.push('Username must be 30 characters or less');
+ }
+ if (!/^[a-zA-Z0-9_]+$/.test(username)) {
+ errors.push('Username can only contain letters, numbers, and underscores');
+ }
+
+ // Real name check
+ if (looksLikeRealName(username)) {
+ errors.push('Please use a fun gaming name instead of a real name. Your privacy matters!');
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ suggestions: errors.length > 0 ? generateUsernameSuggestions(5) : []
+ };
+}
+
+module.exports = {
+ generateUsername,
+ generateUsernameSuggestions,
+ looksLikeRealName,
+ validateUsername
+};
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..efc50d2
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,112 @@
+#!/bin/bash
+# Deploy script for GamerComp.com
+# Usage: ./deploy.sh [--clear-cloudflare]
+#
+# Cache clearing order (correct sequence):
+# 1. Cloudflare CDN (if --clear-cloudflare) - Clear edge servers first
+# 2. Build new frontend assets (with new hashed filenames)
+# 3. Restart backend API
+# 4. Reload nginx (picks up new files)
+# 5. Clean old artifacts
+# 6. Users clear browser cache via /clear-cache.html
+
+set -e
+
+echo "=========================================="
+echo " GamerComp.com Deploy Script"
+echo "=========================================="
+
+# Colors
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Navigate to project root
+cd /home/gamearc/game-arcade
+
+# Load environment variables (for Cloudflare credentials)
+if [[ -f backend/.env ]]; then
+ export $(grep -E '^CLOUDFLARE_' backend/.env | xargs)
+fi
+
+# Step 1: Clear Cloudflare cache FIRST (if requested and configured)
+# This ensures edge servers don't serve stale content during deploy
+if [[ "$1" == "--clear-cloudflare" ]]; then
+ if [[ -n "$CLOUDFLARE_ZONE_ID" && -n "$CLOUDFLARE_API_TOKEN" ]]; then
+ echo -e "${YELLOW}Step 1: Clearing Cloudflare cache (before deploy)...${NC}"
+ RESULT=$(curl -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
+ -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data '{"purge_everything":true}' \
+ --silent)
+ if echo "$RESULT" | jq -e '.success' > /dev/null 2>&1; then
+ echo -e "${GREEN}Cloudflare cache cleared!${NC}"
+ else
+ echo -e "${YELLOW}Warning: Cloudflare purge may have failed${NC}"
+ echo "$RESULT" | jq .
+ fi
+ else
+ echo -e "${YELLOW}Step 1: Skipping Cloudflare - credentials not set${NC}"
+ echo "To enable, add to ~/.bashrc:"
+ echo " export CLOUDFLARE_ZONE_ID='your_zone_id'"
+ echo " export CLOUDFLARE_API_TOKEN='your_api_token'"
+ fi
+else
+ echo -e "${YELLOW}Step 1: Skipping Cloudflare (use --clear-cloudflare to purge CDN)${NC}"
+fi
+
+# Step 2: Build frontend (creates new hashed filenames)
+echo -e "${YELLOW}Step 2: Building frontend...${NC}"
+cd frontend
+npm run build
+cd ..
+echo -e "${GREEN}Frontend built successfully!${NC}"
+
+# Step 3: Restart backend
+echo -e "${YELLOW}Step 3: Restarting backend...${NC}"
+pm2 restart gamercomp-api
+echo -e "${GREEN}Backend restarted!${NC}"
+
+# Step 4: Reload nginx (serves new files)
+echo -e "${YELLOW}Step 4: Reloading nginx...${NC}"
+sudo nginx -t && sudo systemctl reload nginx
+echo -e "${GREEN}Nginx reloaded!${NC}"
+
+# Step 5: Clear old cached files
+echo -e "${YELLOW}Step 5: Cleaning up old build artifacts...${NC}"
+find /home/gamearc/game-arcade/frontend/dist/assets -name "*.js" -mtime +7 -delete 2>/dev/null || true
+find /home/gamearc/game-arcade/frontend/dist/assets -name "*.css" -mtime +7 -delete 2>/dev/null || true
+echo -e "${GREEN}Cleanup complete!${NC}"
+
+# Step 6: Purge Cloudflare AGAIN after new files are live
+# This ensures CDN fetches the new hashed files
+if [[ "$1" == "--clear-cloudflare" && -n "$CLOUDFLARE_ZONE_ID" && -n "$CLOUDFLARE_API_TOKEN" ]]; then
+ echo -e "${YELLOW}Step 6: Final Cloudflare purge (new files now live)...${NC}"
+ curl -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
+ -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ --data '{"purge_everything":true}' \
+ --silent > /dev/null
+ echo -e "${GREEN}Cloudflare cache purged!${NC}"
+fi
+
+echo ""
+echo "=========================================="
+echo -e "${GREEN} Deploy Complete!${NC}"
+echo "=========================================="
+echo ""
+echo "Cache clearing order completed:"
+echo " 1. Cloudflare CDN purged (if --clear-cloudflare)"
+echo " 2. New frontend built with fresh hashed filenames"
+echo " 3. Backend restarted"
+echo " 4. Nginx reloaded"
+echo " 5. Old artifacts cleaned"
+if [[ "$1" == "--clear-cloudflare" ]]; then
+ echo " 6. Final Cloudflare purge (ensures CDN has new files)"
+fi
+echo ""
+echo "For users to clear browser cache:"
+echo " • Visit /clear-cache.html"
+echo " • Or hard refresh: Ctrl+Shift+R (Win) / Cmd+Shift+R (Mac)"
+echo ""
+echo "Service worker auto-updates within seconds."
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..18bc70e
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..4fa125d
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..d417051
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GamerComp - Compose Games with AI
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..12de643
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,7548 @@
+{
+ "name": "frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "0.0.0",
+ "dependencies": {
+ "@fingerprintjs/fingerprintjs": "^5.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.13.0",
+ "tone": "^15.1.22"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "globals": "^16.5.0",
+ "vite": "^7.2.4",
+ "vite-plugin-pwa": "^1.2.0",
+ "workbox-window": "^7.4.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz",
+ "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+ "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/traverse": "^7.28.6",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz",
+ "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "regexpu-core": "^6.3.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz",
+ "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.22.11"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+ "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+ "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz",
+ "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-wrap-function": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+ "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+ "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+ "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
+ "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
+ "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
+ "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
+ "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/plugin-transform-optional-chaining": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+ "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.21.0-placeholder-for-preset-env.2",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+ "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz",
+ "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-generator-functions": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz",
+ "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1",
+ "@babel/traverse": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+ "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
+ "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+ "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-properties": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+ "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-static-block": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+ "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+ "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+ "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/template": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz",
+ "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+ "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
+ "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dynamic-import": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
+ "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-explicit-resource-management": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+ "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+ "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-export-namespace-from": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz",
+ "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz",
+ "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz",
+ "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-json-strings": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+ "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz",
+ "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+ "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
+ "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
+ "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+ "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
+ "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
+ "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
+ "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+ "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-numeric-separator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+ "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-rest-spread": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+ "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
+ "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-catch-binding": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+ "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-chaining": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+ "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.27.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz",
+ "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-methods": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+ "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-property-in-object": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+ "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
+ "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz",
+ "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regexp-modifiers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+ "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-reserved-words": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
+ "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz",
+ "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+ "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-sticky-regex": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz",
+ "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
+ "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
+ "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-property-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+ "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-regex": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz",
+ "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-sets-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+ "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz",
+ "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
+ "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
+ "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.0",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-dynamic-import": "^7.27.1",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
+ "@babel/plugin-transform-export-namespace-from": "^7.27.1",
+ "@babel/plugin-transform-for-of": "^7.27.1",
+ "@babel/plugin-transform-function-name": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
+ "@babel/plugin-transform-literals": "^7.27.1",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
+ "@babel/plugin-transform-member-expression-literals": "^7.27.1",
+ "@babel/plugin-transform-modules-amd": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.0",
+ "@babel/plugin-transform-modules-umd": "^7.27.1",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-new-target": "^7.27.1",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
+ "@babel/plugin-transform-object-super": "^7.27.1",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
+ "@babel/plugin-transform-property-literals": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.29.0",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
+ "@babel/plugin-transform-reserved-words": "^7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
+ "@babel/plugin-transform-sticky-regex": "^7.27.1",
+ "@babel/plugin-transform-template-literals": "^7.27.1",
+ "@babel/plugin-transform-typeof-symbol": "^7.27.1",
+ "@babel/plugin-transform-unicode-escapes": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
+ "@babel/plugin-transform-unicode-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
+ "@babel/preset-modules": "0.1.6-no-external-plugins",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.6-no-external-plugins",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
+ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
+ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@fingerprintjs/fingerprintjs": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz",
+ "integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==",
+ "license": "MIT"
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@isaacs/balanced-match": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
+ "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@isaacs/brace-expansion": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
+ "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@isaacs/balanced-match": "^4.0.1"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.53",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
+ "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+ "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-terser": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
+ "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "serialize-javascript": "^6.0.1",
+ "smob": "^1.0.0",
+ "terser": "^5.17.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+ "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+ "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+ "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+ "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+ "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+ "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+ "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+ "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+ "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+ "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+ "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+ "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+ "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+ "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+ "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+ "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+ "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+ "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+ "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+ "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+ "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+ "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@surma/rollup-plugin-off-main-thread": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
+ "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "ejs": "^3.1.6",
+ "json5": "^2.2.0",
+ "magic-string": "^0.25.0",
+ "string.prototype.matchall": "^4.0.6"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.10",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
+ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
+ "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.5",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.53",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/automation-events": {
+ "version": "7.1.15",
+ "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.15.tgz",
+ "integrity": "sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.2.0"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.4.15",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
+ "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.6",
+ "semver": "^6.3.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz",
+ "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.6",
+ "core-js-compat": "^3.48.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz",
+ "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.6"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+ "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001766",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
+ "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/common-tags": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+ "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/core-js-compat": {
+ "version": "3.48.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
+ "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/crypto-random-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
+ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.283",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz",
+ "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.1",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
+ "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.2",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
+ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.39.2",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+ "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
+ "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-own-enumerable-property-symbols": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
+ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
+ "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/brace-expansion": "^5.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+ "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+ "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
+ "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+ "dev": true,
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
+ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/jsonpointer": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
+ "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/leven": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.sortby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+ "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sourcemap-codec": "^1.4.8"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
+ "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "11.2.5",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
+ "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-bytes": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+ "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.13.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
+ "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.13.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
+ "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.13.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+ "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexpu-core": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
+ "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.2.2",
+ "regjsgen": "^0.8.0",
+ "regjsparser": "^0.13.0",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
+ "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regjsparser": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz",
+ "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "jsesc": "~3.1.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.57.1",
+ "@rollup/rollup-android-arm64": "4.57.1",
+ "@rollup/rollup-darwin-arm64": "4.57.1",
+ "@rollup/rollup-darwin-x64": "4.57.1",
+ "@rollup/rollup-freebsd-arm64": "4.57.1",
+ "@rollup/rollup-freebsd-x64": "4.57.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+ "@rollup/rollup-linux-arm64-musl": "4.57.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+ "@rollup/rollup-linux-loong64-musl": "4.57.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-musl": "4.57.1",
+ "@rollup/rollup-openbsd-x64": "4.57.1",
+ "@rollup/rollup-openharmony-arm64": "4.57.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+ "@rollup/rollup-win32-x64-gnu": "4.57.1",
+ "@rollup/rollup-win32-x64-msvc": "4.57.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/serialize-javascript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/smob": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz",
+ "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/source-map": {
+ "version": "0.8.0-beta.0",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
+ "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
+ "deprecated": "The work that was done in this beta branch won't be included in future versions",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "whatwg-url": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-support/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+ "deprecated": "Please use @jridgewell/sourcemap-codec instead",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/standardized-audio-context": {
+ "version": "25.3.77",
+ "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz",
+ "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.25.6",
+ "automation-events": "^7.0.9",
+ "tslib": "^2.7.0"
+ }
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/stringify-object": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
+ "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "get-own-enumerable-property-symbols": "^3.0.0",
+ "is-obj": "^1.0.1",
+ "is-regexp": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
+ "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/temp-dir": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+ "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tempy": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz",
+ "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-stream": "^2.0.0",
+ "temp-dir": "^2.0.0",
+ "type-fest": "^0.16.0",
+ "unique-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.46.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
+ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tone": {
+ "version": "15.1.22",
+ "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz",
+ "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "standardized-audio-context": "^25.3.70",
+ "tslib": "^2.3.1"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+ "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
+ "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+ "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unique-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
+ "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crypto-random-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4",
+ "yarn": "*"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-pwa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
+ "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.6",
+ "pretty-bytes": "^6.1.1",
+ "tinyglobby": "^0.2.10",
+ "workbox-build": "^7.4.0",
+ "workbox-window": "^7.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vite-pwa/assets-generator": "^1.0.0",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
+ "workbox-build": "^7.4.0",
+ "workbox-window": "^7.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@vite-pwa/assets-generator": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
+ "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash.sortby": "^4.7.0",
+ "tr46": "^1.0.1",
+ "webidl-conversions": "^4.0.2"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/workbox-background-sync": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
+ "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-broadcast-update": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz",
+ "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-build": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz",
+ "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@apideck/better-ajv-errors": "^0.3.1",
+ "@babel/core": "^7.24.4",
+ "@babel/preset-env": "^7.11.0",
+ "@babel/runtime": "^7.11.2",
+ "@rollup/plugin-babel": "^5.2.0",
+ "@rollup/plugin-node-resolve": "^15.2.3",
+ "@rollup/plugin-replace": "^2.4.1",
+ "@rollup/plugin-terser": "^0.4.3",
+ "@surma/rollup-plugin-off-main-thread": "^2.2.3",
+ "ajv": "^8.6.0",
+ "common-tags": "^1.8.0",
+ "fast-json-stable-stringify": "^2.1.0",
+ "fs-extra": "^9.0.1",
+ "glob": "^11.0.1",
+ "lodash": "^4.17.20",
+ "pretty-bytes": "^5.3.0",
+ "rollup": "^2.79.2",
+ "source-map": "^0.8.0-beta.0",
+ "stringify-object": "^3.3.0",
+ "strip-comments": "^2.0.1",
+ "tempy": "^0.6.0",
+ "upath": "^1.2.0",
+ "workbox-background-sync": "7.4.0",
+ "workbox-broadcast-update": "7.4.0",
+ "workbox-cacheable-response": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-expiration": "7.4.0",
+ "workbox-google-analytics": "7.4.0",
+ "workbox-navigation-preload": "7.4.0",
+ "workbox-precaching": "7.4.0",
+ "workbox-range-requests": "7.4.0",
+ "workbox-recipes": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0",
+ "workbox-streams": "7.4.0",
+ "workbox-sw": "7.4.0",
+ "workbox-window": "7.4.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz",
+ "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-schema": "^0.4.0",
+ "jsonpointer": "^5.0.0",
+ "leven": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "ajv": ">=8"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@rollup/plugin-babel": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
+ "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.10.4",
+ "@rollup/pluginutils": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "@types/babel__core": "^7.1.9",
+ "rollup": "^1.20.0||^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/babel__core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/workbox-build/node_modules/@rollup/plugin-replace": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
+ "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^3.1.0",
+ "magic-string": "^0.25.7"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0 || ^2.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@rollup/pluginutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
+ "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "0.0.39",
+ "estree-walker": "^1.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@types/estree": {
+ "version": "0.0.39",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
+ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/ajv": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/workbox-build/node_modules/estree-walker": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
+ "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/workbox-build/node_modules/pretty-bytes": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+ "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/workbox-build/node_modules/rollup": {
+ "version": "2.79.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
+ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/workbox-cacheable-response": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
+ "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-core": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
+ "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-expiration": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
+ "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-google-analytics": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz",
+ "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-background-sync": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
+ }
+ },
+ "node_modules/workbox-navigation-preload": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz",
+ "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-precaching": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
+ "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
+ }
+ },
+ "node_modules/workbox-range-requests": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz",
+ "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-recipes": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz",
+ "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-cacheable-response": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-expiration": "7.4.0",
+ "workbox-precaching": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
+ }
+ },
+ "node_modules/workbox-routing": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
+ "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-strategies": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
+ "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-streams": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz",
+ "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0"
+ }
+ },
+ "node_modules/workbox-sw": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz",
+ "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-window": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
+ "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2",
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..ee299a0
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@fingerprintjs/fingerprintjs": "^5.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.13.0",
+ "tone": "^15.1.22"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "globals": "^16.5.0",
+ "vite": "^7.2.4",
+ "vite-plugin-pwa": "^1.2.0",
+ "workbox-window": "^7.4.0"
+ }
+}
diff --git a/frontend/public/asset-browser.html b/frontend/public/asset-browser.html
new file mode 100644
index 0000000..c36c213
--- /dev/null
+++ b/frontend/public/asset-browser.html
@@ -0,0 +1,1842 @@
+
+
+
+
+
+ Asset Browser - GamerComp Asset Library
+
+
+
+
+
+
+
+ 🎵
+ Music
+
+
+ 🏞️
+ Backgrounds
+
+
+ 👤
+ Avatars
+
+
+ 🎮
+ Contexts
+
+
+ 📦
+ Presets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/assets/README.md b/frontend/public/assets/README.md
new file mode 100644
index 0000000..d43a5b7
--- /dev/null
+++ b/frontend/public/assets/README.md
@@ -0,0 +1,283 @@
+# GamerComp Asset Library
+
+A comprehensive procedural asset library for generating HTML5 canvas games. All assets are rendered in real-time using Canvas 2D API and Web Audio API.
+
+## Quick Stats
+
+| Asset Type | Count | Description |
+|------------|-------|-------------|
+| Backgrounds | 120 | 15 themes × 8 variants |
+| Music | 160 | 8 moods × 20 songs |
+| Avatar Modes | 4 | HEAD_ONLY, HEAD_AND_SHOULDERS, UPPER_BODY, FULL_BODY |
+| Game Contexts | 26 | 12 vehicles, 10 costumes, 4 pure |
+| Presets | 220 | 11 game styles × 20 presets |
+
+## Directory Structure
+
+```
+/assets/
+├── backgrounds/ # Procedural background system
+│ ├── backgroundEngine.js # Core rendering engine
+│ ├── effects.js # Particle/animation effects
+│ ├── backgroundCatalog.js # Metadata catalog
+│ └── themes/ # 15 theme files
+│ ├── space.js
+│ ├── nature.js
+│ ├── urban.js
+│ └── ... (12 more)
+│
+├── music/ # Procedural music system
+│ ├── moodCategories.js # Mood definitions
+│ ├── musicEngine.js # Tone.js synthesis engine
+│ └── library/
+│ ├── songs1-80.js # Songs 1-80
+│ └── songs81-160.js # Songs 81-160
+│
+├── avatar/ # Mii-style avatar system
+│ ├── avatarData.js # Face shapes, hair, colors
+│ ├── accessories.js # 45 unlockable accessories
+│ ├── animations.js # 31 animations
+│ ├── avatarRenderer.js # Core avatar rendering
+│ ├── accessoryRenderer.js # Accessory rendering
+│ ├── avatarSystem.js # Main avatar API
+│ ├── contextRenderer.js # Context rendering
+│ ├── contextCatalog.js # Context metadata
+│ └── contexts/
+│ ├── vehicles.js # 12 vehicle contexts
+│ ├── costumes.js # 10 costume contexts
+│ └── pure.js # 4 pure contexts
+│
+├── assetManager.js # Unified asset coordination
+├── assetCatalog.js # Combined metadata catalog
+├── presets.js # 220 preset combinations
+├── compatibility.js # Asset compatibility rules
+└── README.md # This file
+```
+
+## Usage
+
+### Basic Setup
+
+Include the required scripts in your HTML:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Rendering a Background
+
+```javascript
+// Get canvas context
+const ctx = canvas.getContext('2d');
+
+// Render background (animated)
+function render(time) {
+ BackgroundEngine.render(ctx, 'space', 'nebula', canvas.width, canvas.height, time);
+ requestAnimationFrame(render);
+}
+render(0);
+```
+
+### Playing Music
+
+```javascript
+// Play by mood
+MusicEngine.playByMood('Epic', 8); // mood, energy (1-10)
+
+// Play specific song
+MusicEngine.play('epic_001');
+
+// Stop music
+MusicEngine.stop();
+
+// Set volume
+MusicEngine.setVolume(0.7); // 0-1
+```
+
+### Rendering an Avatar
+
+```javascript
+// Define avatar configuration
+const avatar = {
+ faceShape: 'round',
+ skinTone: 'medium',
+ hairStyle: 'short',
+ hairColor: '#3d2314',
+ eyeShape: 'round',
+ eyeColor: '#4a7c59'
+};
+
+// Render avatar
+AvatarSystem.render(ctx, avatar, 'FULL_BODY', 'idle', animationTime);
+
+// With context (vehicle/costume)
+ContextRenderer.render(ctx, 'spaceship-cockpit', avatar, x, y, 'idle', time);
+```
+
+### Using Presets
+
+```javascript
+// Get a preset for a game style
+const preset = AssetPresets.getByStyle('action')[0];
+
+// Apply preset
+const config = {
+ music: preset.music,
+ background: preset.background,
+ avatarContext: preset.context
+};
+```
+
+### Using the Recommendation Engine
+
+```javascript
+// Get recommendations based on game description
+const recommendations = AssetManager.recommend(
+ "A fast-paced space shooter with epic battles",
+ "action"
+);
+
+// Returns: { background, music, context, preset }
+```
+
+## Game Templates
+
+Two HTML templates are provided:
+
+### gameTemplateAssets.html
+Full-featured template with:
+- Asset library integration
+- Loading screen
+- Pause menu
+- Input handling (keyboard + touch)
+- Score/health tracking
+
+Best for: Games using avatars, procedural backgrounds, and music.
+
+### gameTemplateSimple.html
+Minimal template with:
+- Basic Canvas setup
+- Simple game loop
+- No external dependencies
+
+Best for: Puzzle games, simple arcade games, creative tools.
+
+## Examples
+
+See `/examples/` for complete working games:
+
+- **platformer-example.html** - FULL_BODY avatar, nature background
+- **flappy-example.html** - HEAD_ONLY avatar, sky background
+- **racer-example.html** - Vehicle context, urban background
+- **comparison-demo.html** - Same game with 4 different asset sets
+
+## Interactive Browser
+
+Visit `/asset-browser.html` for an interactive tool to:
+- Browse all 120 backgrounds with live previews
+- Preview all 160 music tracks
+- Test avatar configurations
+- Explore all 26 game contexts
+- Browse 220 presets
+- Generate random combinations
+
+## Avatar Modes
+
+| Mode | Dimensions | Use Case |
+|------|------------|----------|
+| HEAD_ONLY | 64×64 | Cockpit views, maze games |
+| HEAD_AND_SHOULDERS | 64×96 | Driving, seated vehicles |
+| UPPER_BODY | 64×128 | Hoverboard, jetpack games |
+| FULL_BODY | 64×160 | Platformers, RPGs, sports |
+
+## Music Moods
+
+| Mood | Energy Range | Best For |
+|------|--------------|----------|
+| Epic | 7-10 | Boss battles, action |
+| Chill | 2-4 | Puzzle, casual |
+| Intense | 8-10 | Racing, shooters |
+| Playful | 5-7 | Kids games, casual |
+| Mysterious | 3-6 | Horror, exploration |
+| Heroic | 6-8 | Adventure, platformers |
+| Quirky | 4-7 | Puzzle, comedy |
+| Ambient | 1-3 | Meditation, creative |
+
+## Background Themes
+
+15 themes, each with 8 variants:
+
+- **space**: nebula, asteroidField, deepSpace, planets, spaceStation, warpSpeed, satellites, blackHole
+- **nature**: forest, jungle, desert, mountains, ocean, underwater, waterfall, meadow
+- **urban**: citySkyline, street, rooftop, neonDistrict, subway, park, mall, alley
+- **fantasy**: castle, dungeon, enchantedForest, floatingIslands, magicalLibrary, crystalCave, temple, tower
+- **abstract**: geometricPatterns, gradients, particleFields, minimalist, waves, fractals, kaleidoscope, matrix
+- **retro**: pixelGrid, arcadeCabinet, crtEffects, vaporwave, scanlines, glitch, eightBit, synthwave
+- **indoor**: laboratory, classroom, bedroom, kitchen, arcade, office, library, gym
+- **weather**: rain, snow, storm, sunset, sunrise, fog, lightning, aurora
+- **sports**: stadium, raceTrack, court, field, arena, pool, gym, skatepark
+- **seasonal**: springMeadow, autumnLeaves, winterWonderland, summerBeach, harvest, blossom, snowfall, tropical
+- **underwater**: coralReef, deepSea, kelpForest, submarine, shipwreck, abyss, iceCave, treasure
+- **sky**: clouds, flyingHigh, hotAirBalloon, airplaneView, stratosphere, birdsEye, horizon, starfield
+- **mechanical**: gears, circuits, factory, robotFactory, conveyorBelts, pipes, steampunk, techLab
+- **spooky**: hauntedHouse, graveyard, darkForest, abandonedBuilding, crypt, manor, fog, shadows
+- **colorful**: rainbow, paintSplatter, candyWorld, disco, neon, tieDye, gradient, prism
+
+## Game Contexts (26)
+
+### Vehicles (12)
+spaceship-cockpit, race-car, airplane, flappy-style, tank, pacman-style, hoverboard, jetpack, mech-suit, submarine, dragon-rider, motorcycle
+
+### Costumes (10)
+knight-armor, space-suit, ninja-outfit, wizard-robes, superhero-cape, athlete-uniform, pirate-outfit, scientist-labcoat, chef-outfit, cowboy-gear
+
+### Pure (4)
+platformer-standard, sports-player, adventure-hero, runner
+
+## Documentation
+
+See `/docs/` for detailed documentation:
+
+- **ASSET_LIBRARY_OVERVIEW.md** - Complete system overview
+- **GAME_GENERATION_GUIDE.md** - AI generation instructions
+- **ADDING_NEW_ASSETS.md** - How to extend the library
+- **API_REFERENCE.md** - Full API documentation
+
+## Browser Support
+
+- Chrome 80+
+- Firefox 78+
+- Safari 14+
+- Edge 80+
+
+Requires Canvas 2D and Web Audio API support.
+
+## Performance Notes
+
+- All assets are procedurally generated (no image loading)
+- Backgrounds animate at 60fps
+- Music uses Web Audio synthesis (low memory)
+- Avatar rendering is optimized for multiple instances
+- Use `requestAnimationFrame` for smooth animation
+
+## License
+
+Proprietary - GamerComp.com
diff --git a/frontend/public/assets/assetCatalog.js b/frontend/public/assets/assetCatalog.js
new file mode 100644
index 0000000..a5c5bf2
--- /dev/null
+++ b/frontend/public/assets/assetCatalog.js
@@ -0,0 +1,354 @@
+(function() {
+ 'use strict';
+
+ // =========================================================================
+ // GAME STYLES (from GamerComp)
+ // =========================================================================
+
+ var GAME_STYLES = [
+ {
+ id: 'action-arcade',
+ name: 'Action/Arcade',
+ description: 'Space shooters, platformers, fast-paced action',
+ icon: '🎮',
+ keywords: ['space', 'shooter', 'platformer', 'action', 'arcade', 'fast'],
+ recommendedMoods: ['Epic', 'Intense', 'Heroic'],
+ recommendedThemes: ['space', 'retro', 'urban'],
+ recommendedContexts: ['spaceship-cockpit', 'platformer-standard', 'jetpack']
+ },
+ {
+ id: 'puzzle',
+ name: 'Puzzle',
+ description: 'Match-3, sliding puzzles, brain teasers',
+ icon: '🧩',
+ keywords: ['puzzle', 'match', 'brain', 'logic', 'thinking'],
+ recommendedMoods: ['Chill', 'Ambient', 'Playful'],
+ recommendedThemes: ['abstract', 'colorful', 'indoor'],
+ recommendedContexts: ['platformer-standard', 'scientist-labcoat']
+ },
+ {
+ id: 'story-adventure',
+ name: 'Story Adventure',
+ description: 'Visual novels, choose-your-adventure',
+ icon: '📖',
+ keywords: ['story', 'adventure', 'narrative', 'choice', 'visual'],
+ recommendedMoods: ['Mysterious', 'Ambient', 'Epic'],
+ recommendedThemes: ['fantasy', 'nature', 'urban'],
+ recommendedContexts: ['adventure-hero', 'wizard-robes', 'knight-armor']
+ },
+ {
+ id: 'racing',
+ name: 'Racing',
+ description: 'Car racing, running games, speed challenges',
+ icon: '🏎️',
+ keywords: ['race', 'racing', 'car', 'speed', 'run', 'fast'],
+ recommendedMoods: ['Intense', 'Epic', 'Heroic'],
+ recommendedThemes: ['urban', 'nature', 'sky'],
+ recommendedContexts: ['race-car', 'motorcycle', 'hoverboard', 'runner']
+ },
+ {
+ id: 'pet-simulator',
+ name: 'Pet/Simulator',
+ description: 'Virtual pets, farm games, life simulation',
+ icon: '🐕',
+ keywords: ['pet', 'animal', 'farm', 'care', 'virtual', 'simulation'],
+ recommendedMoods: ['Playful', 'Chill', 'Ambient'],
+ recommendedThemes: ['nature', 'indoor', 'colorful'],
+ recommendedContexts: ['platformer-standard', 'chef-outfit']
+ },
+ {
+ id: 'trivia-quiz',
+ name: 'Trivia/Quiz',
+ description: 'Quiz shows, fact games, knowledge tests',
+ icon: '❓',
+ keywords: ['trivia', 'quiz', 'question', 'knowledge', 'fact'],
+ recommendedMoods: ['Playful', 'Quirky', 'Ambient'],
+ recommendedThemes: ['abstract', 'indoor', 'colorful'],
+ recommendedContexts: ['scientist-labcoat', 'platformer-standard']
+ },
+ {
+ id: 'music-rhythm',
+ name: 'Music/Rhythm',
+ description: 'Rhythm games, dance games, music creation',
+ icon: '🎵',
+ keywords: ['music', 'rhythm', 'dance', 'beat', 'song'],
+ recommendedMoods: ['Playful', 'Intense', 'Epic'],
+ recommendedThemes: ['colorful', 'retro', 'abstract'],
+ recommendedContexts: ['athlete-uniform', 'superhero-cape']
+ },
+ {
+ id: 'creative-drawing',
+ name: 'Creative/Drawing',
+ description: 'Drawing games, design tools, art creation',
+ icon: '🎨',
+ keywords: ['draw', 'create', 'art', 'design', 'color', 'paint'],
+ recommendedMoods: ['Chill', 'Playful', 'Ambient'],
+ recommendedThemes: ['colorful', 'abstract', 'nature'],
+ recommendedContexts: ['platformer-standard', 'chef-outfit']
+ },
+ {
+ id: 'rpg-battle',
+ name: 'RPG/Battle',
+ description: 'Turn-based battles, dungeon crawlers, adventures',
+ icon: '⚔️',
+ keywords: ['rpg', 'battle', 'dungeon', 'quest', 'hero', 'monster'],
+ recommendedMoods: ['Epic', 'Heroic', 'Mysterious'],
+ recommendedThemes: ['fantasy', 'spooky', 'nature'],
+ recommendedContexts: ['knight-armor', 'wizard-robes', 'ninja-outfit', 'dragon-rider']
+ },
+ {
+ id: 'sports',
+ name: 'Sports',
+ description: 'Ball games, olympic events, athletic challenges',
+ icon: '⚽',
+ keywords: ['sports', 'ball', 'soccer', 'basketball', 'athletic'],
+ recommendedMoods: ['Intense', 'Epic', 'Heroic'],
+ recommendedThemes: ['sports', 'nature', 'urban'],
+ recommendedContexts: ['athlete-uniform', 'sports-player']
+ },
+ {
+ id: 'physics-pinball',
+ name: 'Physics/Pinball',
+ description: 'Pinball, angry birds style, physics puzzles',
+ icon: '🎱',
+ keywords: ['physics', 'pinball', 'bounce', 'launch', 'gravity'],
+ recommendedMoods: ['Playful', 'Quirky', 'Intense'],
+ recommendedThemes: ['mechanical', 'colorful', 'retro'],
+ recommendedContexts: ['platformer-standard', 'mech-suit']
+ }
+ ];
+
+ // =========================================================================
+ // MOOD TO THEME MAPPINGS
+ // =========================================================================
+
+ var MOOD_THEME_AFFINITIES = {
+ Epic: ['space', 'fantasy', 'sports', 'sky'],
+ Chill: ['nature', 'underwater', 'indoor', 'seasonal'],
+ Intense: ['urban', 'mechanical', 'sports', 'weather'],
+ Playful: ['colorful', 'retro', 'nature', 'indoor'],
+ Mysterious: ['spooky', 'fantasy', 'underwater', 'weather'],
+ Heroic: ['space', 'fantasy', 'urban', 'sky'],
+ Quirky: ['retro', 'colorful', 'abstract', 'indoor'],
+ Ambient: ['nature', 'underwater', 'sky', 'seasonal']
+ };
+
+ // =========================================================================
+ // THEME TO CONTEXT MAPPINGS
+ // =========================================================================
+
+ var THEME_CONTEXT_AFFINITIES = {
+ space: ['spaceship-cockpit', 'space-suit', 'mech-suit', 'jetpack'],
+ nature: ['platformer-standard', 'adventure-hero', 'cowboy-gear'],
+ urban: ['race-car', 'motorcycle', 'runner', 'superhero-cape'],
+ fantasy: ['knight-armor', 'wizard-robes', 'dragon-rider'],
+ spooky: ['ninja-outfit', 'wizard-robes', 'platformer-standard'],
+ underwater: ['submarine', 'space-suit', 'adventure-hero'],
+ retro: ['pacman-style', 'platformer-standard', 'athlete-uniform'],
+ sports: ['athlete-uniform', 'sports-player', 'runner'],
+ sky: ['airplane', 'flappy-style', 'jetpack', 'dragon-rider'],
+ colorful: ['superhero-cape', 'athlete-uniform', 'platformer-standard'],
+ mechanical: ['mech-suit', 'tank', 'scientist-labcoat'],
+ indoor: ['chef-outfit', 'scientist-labcoat', 'platformer-standard'],
+ weather: ['adventure-hero', 'cowboy-gear', 'pirate-outfit'],
+ seasonal: ['platformer-standard', 'athlete-uniform', 'chef-outfit'],
+ abstract: ['platformer-standard', 'scientist-labcoat']
+ };
+
+ // =========================================================================
+ // COLOR PALETTES
+ // =========================================================================
+
+ var COLOR_PALETTES = {
+ 'space-blue': ['#0a0a2e', '#1a1a4e', '#2a2a6e', '#4a4a9e', '#6a6abe'],
+ 'forest-green': ['#1a3a1a', '#2a4a2a', '#3a5a3a', '#4a7a4a', '#5a9a5a'],
+ 'sunset-orange': ['#2a1a0a', '#4a2a1a', '#6a3a2a', '#9a5a3a', '#ca7a4a'],
+ 'ocean-teal': ['#0a2a2a', '#1a3a4a', '#2a4a5a', '#3a6a7a', '#4a8a9a'],
+ 'neon-pink': ['#2a0a2a', '#4a1a4a', '#6a2a6a', '#9a3a9a', '#ca4aca'],
+ 'retro-arcade': ['#0a0a0a', '#1a1a3a', '#3a3a6a', '#6a6a9a', '#9a9aca'],
+ 'fantasy-purple': ['#1a0a2a', '#2a1a4a', '#4a2a6a', '#6a3a9a', '#8a4aca'],
+ 'spooky-grey': ['#0a0a0a', '#1a1a1a', '#2a2a2a', '#3a3a3a', '#4a4a4a'],
+ 'sports-red': ['#2a0a0a', '#4a1a1a', '#6a2a2a', '#9a3a3a', '#ca4a4a'],
+ 'nature-brown': ['#2a1a0a', '#3a2a1a', '#4a3a2a', '#5a4a3a', '#6a5a4a'],
+ 'candy-rainbow': ['#ff6b6b', '#ffd93d', '#6bcf7f', '#4ecdc4', '#a06cd5']
+ };
+
+ // =========================================================================
+ // CATALOG QUERIES
+ // =========================================================================
+
+ function getGameStyles() {
+ return GAME_STYLES.slice();
+ }
+
+ function getGameStyleById(id) {
+ return GAME_STYLES.find(function(s) { return s.id === id; }) || null;
+ }
+
+ function getGameStyleByKeyword(keyword) {
+ keyword = keyword.toLowerCase();
+ return GAME_STYLES.filter(function(s) {
+ return s.keywords.some(function(k) {
+ return k.includes(keyword) || keyword.includes(k);
+ });
+ });
+ }
+
+ function getMoodsForTheme(theme) {
+ var moods = [];
+ for (var mood in MOOD_THEME_AFFINITIES) {
+ if (MOOD_THEME_AFFINITIES[mood].includes(theme)) {
+ moods.push(mood);
+ }
+ }
+ return moods;
+ }
+
+ function getThemesForMood(mood) {
+ return MOOD_THEME_AFFINITIES[mood] || [];
+ }
+
+ function getContextsForTheme(theme) {
+ return THEME_CONTEXT_AFFINITIES[theme] || [];
+ }
+
+ function getColorPalette(id) {
+ return COLOR_PALETTES[id] || COLOR_PALETTES['space-blue'];
+ }
+
+ function getAllColorPalettes() {
+ return Object.keys(COLOR_PALETTES).map(function(id) {
+ return { id: id, colors: COLOR_PALETTES[id] };
+ });
+ }
+
+ function suggestPalette(theme, mood) {
+ // Theme-based suggestions
+ var themeMap = {
+ space: 'space-blue',
+ nature: 'forest-green',
+ urban: 'neon-pink',
+ fantasy: 'fantasy-purple',
+ spooky: 'spooky-grey',
+ underwater: 'ocean-teal',
+ retro: 'retro-arcade',
+ sports: 'sports-red',
+ sky: 'space-blue',
+ colorful: 'candy-rainbow',
+ mechanical: 'retro-arcade',
+ indoor: 'nature-brown',
+ weather: 'sunset-orange',
+ seasonal: 'forest-green'
+ };
+
+ return themeMap[theme] || 'space-blue';
+ }
+
+ // =========================================================================
+ // UNIFIED SEARCH
+ // =========================================================================
+
+ function search(query) {
+ query = query.toLowerCase();
+ var results = {
+ gameStyles: [],
+ moods: [],
+ themes: [],
+ contexts: [],
+ palettes: []
+ };
+
+ // Search game styles
+ GAME_STYLES.forEach(function(style) {
+ if (style.name.toLowerCase().includes(query) ||
+ style.description.toLowerCase().includes(query)) {
+ results.gameStyles.push(style);
+ }
+ });
+
+ // Search moods
+ Object.keys(MOOD_THEME_AFFINITIES).forEach(function(mood) {
+ if (mood.toLowerCase().includes(query)) {
+ results.moods.push(mood);
+ }
+ });
+
+ // Search themes
+ Object.keys(THEME_CONTEXT_AFFINITIES).forEach(function(theme) {
+ if (theme.includes(query)) {
+ results.themes.push(theme);
+ }
+ });
+
+ // Search palettes
+ Object.keys(COLOR_PALETTES).forEach(function(palette) {
+ if (palette.includes(query)) {
+ results.palettes.push(palette);
+ }
+ });
+
+ return results;
+ }
+
+ // =========================================================================
+ // STATISTICS
+ // =========================================================================
+
+ function getStats() {
+ var musicCount = 0, bgCount = 0, contextCount = 0;
+
+ if (window.MusicEngine) {
+ musicCount = MusicEngine.getCatalog ? MusicEngine.getCatalog().length : 160;
+ }
+ if (window.BackgroundEngine) {
+ bgCount = BackgroundEngine.getTotalCount ? BackgroundEngine.getTotalCount() : 120;
+ }
+ if (window.ContextCatalog) {
+ contextCount = ContextCatalog.getAll ? ContextCatalog.getAll().length : 26;
+ }
+
+ return {
+ gameStyles: GAME_STYLES.length,
+ musicSongs: musicCount,
+ backgrounds: bgCount,
+ avatarContexts: contextCount,
+ colorPalettes: Object.keys(COLOR_PALETTES).length,
+ moods: Object.keys(MOOD_THEME_AFFINITIES).length,
+ themes: Object.keys(THEME_CONTEXT_AFFINITIES).length
+ };
+ }
+
+ // =========================================================================
+ // PUBLIC API
+ // =========================================================================
+
+ window.AssetCatalog = {
+ // Game styles
+ GAME_STYLES: GAME_STYLES,
+ getGameStyles: getGameStyles,
+ getGameStyleById: getGameStyleById,
+ getGameStyleByKeyword: getGameStyleByKeyword,
+
+ // Mood/Theme mappings
+ getMoodsForTheme: getMoodsForTheme,
+ getThemesForMood: getThemesForMood,
+ getContextsForTheme: getContextsForTheme,
+
+ // Color palettes
+ getColorPalette: getColorPalette,
+ getAllColorPalettes: getAllColorPalettes,
+ suggestPalette: suggestPalette,
+
+ // Search
+ search: search,
+
+ // Stats
+ getStats: getStats,
+
+ // Direct data access
+ MOOD_THEME_AFFINITIES: MOOD_THEME_AFFINITIES,
+ THEME_CONTEXT_AFFINITIES: THEME_CONTEXT_AFFINITIES,
+ COLOR_PALETTES: COLOR_PALETTES
+ };
+
+})();
diff --git a/frontend/public/assets/assetManager.js b/frontend/public/assets/assetManager.js
new file mode 100644
index 0000000..d918401
--- /dev/null
+++ b/frontend/public/assets/assetManager.js
@@ -0,0 +1,1272 @@
+/**
+ * AssetManager - Main Coordinator for All Asset Systems
+ *
+ * Coordinates music, backgrounds, avatars, and contexts into unified
+ * asset management with recommendations based on game descriptions.
+ *
+ * Dependencies (must be loaded before this file):
+ * - window.MusicEngine (from /assets/music/)
+ * - window.BackgroundEngine (from /assets/backgrounds/)
+ * - window.AvatarSystem (from /assets/avatar/)
+ * - window.ContextRenderer (from /assets/avatar/)
+ * - window.ContextCatalog (from /assets/avatar/)
+ * - window.AssetCatalog (unified catalog)
+ * - window.AssetPresets (preset combinations)
+ * - window.AssetCompatibility (compatibility rules)
+ */
+(function() {
+ 'use strict';
+
+ var initialized = false;
+ var loadedAssets = {
+ music: null,
+ background: null,
+ avatar: null,
+ context: null
+ };
+ var cache = new Map();
+
+ // =========================================================================
+ // INITIALIZATION
+ // =========================================================================
+
+ /**
+ * Initialize the AssetManager and verify all dependencies
+ * @param {Object} options - Configuration options
+ * @returns {Promise} Resolves with system stats on success
+ */
+ function initialize(options) {
+ options = options || {};
+
+ return new Promise(function(resolve, reject) {
+ try {
+ // Verify all dependencies loaded
+ var missing = [];
+ if (!window.MusicEngine) missing.push('MusicEngine');
+ if (!window.BackgroundEngine) missing.push('BackgroundEngine');
+ if (!window.AvatarSystem) missing.push('AvatarSystem');
+ if (!window.ContextRenderer) missing.push('ContextRenderer');
+ if (!window.ContextCatalog) missing.push('ContextCatalog');
+ if (!window.AssetCatalog) missing.push('AssetCatalog');
+ if (!window.AssetPresets) missing.push('AssetPresets');
+ if (!window.AssetCompatibility) missing.push('AssetCompatibility');
+
+ if (missing.length > 0) {
+ reject(new Error('Missing dependencies: ' + missing.join(', ')));
+ return;
+ }
+
+ // Initialize subsystems if they have init methods
+ var initPromises = [];
+
+ // MusicEngine needs Tone.js - may be async
+ if (window.MusicEngine.initialize && !window.MusicEngine.isInitialized()) {
+ initPromises.push(window.MusicEngine.initialize());
+ }
+
+ // BackgroundEngine - typically sync
+ if (window.BackgroundEngine.initialize && !window.BackgroundEngine.isInitialized()) {
+ var bgInit = window.BackgroundEngine.initialize();
+ if (bgInit && bgInit.then) {
+ initPromises.push(bgInit);
+ }
+ }
+
+ // AvatarSystem - typically sync
+ if (window.AvatarSystem.initialize && !window.AvatarSystem.isInitialized()) {
+ var avInit = window.AvatarSystem.initialize();
+ if (avInit && avInit.then) {
+ initPromises.push(avInit);
+ }
+ }
+
+ // Wait for all subsystem initializations
+ Promise.all(initPromises).then(function() {
+ initialized = true;
+
+ // Gather stats
+ var stats = {
+ music: {
+ songs: window.MusicEngine.getCatalog ? window.MusicEngine.getCatalog().length : 0
+ },
+ backgrounds: {
+ count: window.BackgroundEngine.getTotalCount ? window.BackgroundEngine.getTotalCount() : 0
+ },
+ avatarContexts: {
+ count: window.ContextCatalog.getAll ? window.ContextCatalog.getAll().length : 0
+ },
+ presets: {
+ count: window.AssetPresets.getAll ? window.AssetPresets.getAll().length : 0
+ }
+ };
+
+ resolve(stats);
+ }).catch(function(err) {
+ reject(new Error('Subsystem initialization failed: ' + err.message));
+ });
+
+ } catch (err) {
+ reject(err);
+ }
+ });
+ }
+
+ // =========================================================================
+ // KEYWORD PARSING HELPERS
+ // =========================================================================
+
+ /**
+ * Parse keywords from string or array input
+ * @param {string|Array} input - Keywords as string or array
+ * @returns {Array} Normalized array of keywords
+ */
+ function parseKeywords(input) {
+ if (Array.isArray(input)) {
+ return input.map(function(w) {
+ return String(w).toLowerCase().trim();
+ }).filter(function(w) {
+ return w.length > 2;
+ });
+ }
+ if (typeof input !== 'string') return [];
+ return input.toLowerCase()
+ .replace(/[^a-z0-9\s]/g, '')
+ .split(/\s+/)
+ .filter(function(w) { return w.length > 2; });
+ }
+
+ // =========================================================================
+ // THEME DETECTION
+ // =========================================================================
+
+ /**
+ * Detect theme from keywords
+ * @param {Array} keywords - Parsed keywords
+ * @returns {string} Detected theme name
+ */
+ function detectTheme(keywords) {
+ var themeKeywords = {
+ space: ['space', 'star', 'planet', 'galaxy', 'alien', 'rocket', 'asteroid', 'nebula', 'cosmic', 'orbit', 'moon', 'mars', 'saturn', 'comet', 'meteor', 'ufo', 'astronaut', 'spaceship'],
+ nature: ['forest', 'jungle', 'tree', 'mountain', 'ocean', 'river', 'nature', 'animal', 'garden', 'flower', 'grass', 'wildlife', 'deer', 'bear', 'wolf', 'leaf', 'plant', 'wilderness'],
+ urban: ['city', 'street', 'building', 'urban', 'neon', 'downtown', 'traffic', 'skyscraper', 'rooftop', 'subway', 'metro', 'alley', 'nightlife', 'cyberpunk', 'futuristic'],
+ fantasy: ['magic', 'wizard', 'dragon', 'castle', 'knight', 'fairy', 'enchanted', 'mythical', 'unicorn', 'elf', 'dwarf', 'potion', 'spell', 'medieval', 'kingdom', 'sorcerer', 'quest'],
+ spooky: ['ghost', 'haunted', 'scary', 'zombie', 'halloween', 'dark', 'creepy', 'monster', 'vampire', 'skeleton', 'graveyard', 'horror', 'nightmare', 'witch', 'pumpkin', 'bat'],
+ underwater: ['ocean', 'fish', 'coral', 'submarine', 'underwater', 'sea', 'diving', 'aquatic', 'whale', 'shark', 'dolphin', 'reef', 'seaweed', 'mermaid', 'treasure', 'deep'],
+ retro: ['retro', 'pixel', 'arcade', '8bit', 'classic', 'vintage', 'oldschool', 'nostalgia', 'gameboy', 'nes', 'snes', 'atari', 'commodore', 'synthwave'],
+ sports: ['soccer', 'basketball', 'football', 'tennis', 'sports', 'athlete', 'stadium', 'baseball', 'hockey', 'golf', 'racing', 'olympics', 'gym', 'fitness', 'championship'],
+ sky: ['sky', 'cloud', 'flying', 'bird', 'airplane', 'air', 'balloon', 'wind', 'glider', 'parachute', 'kite', 'helicopter', 'jet', 'eagle', 'hawk', 'soar'],
+ colorful: ['rainbow', 'colorful', 'bright', 'candy', 'party', 'disco', 'fun', 'vibrant', 'neon', 'carnival', 'festival', 'celebration', 'confetti', 'balloon', 'fireworks'],
+ desert: ['desert', 'sand', 'pyramid', 'egypt', 'cactus', 'oasis', 'camel', 'dune', 'sahara', 'temple', 'ancient', 'scorpion', 'mummy'],
+ arctic: ['ice', 'snow', 'arctic', 'polar', 'penguin', 'frozen', 'winter', 'glacier', 'cold', 'blizzard', 'igloo', 'eskimo', 'north', 'south', 'pole'],
+ western: ['western', 'cowboy', 'desert', 'saloon', 'sheriff', 'outlaw', 'horse', 'rodeo', 'wild', 'west', 'cactus', 'tumbleweed']
+ };
+
+ var scores = {};
+ for (var theme in themeKeywords) {
+ scores[theme] = 0;
+ themeKeywords[theme].forEach(function(kw) {
+ keywords.forEach(function(word) {
+ // Exact match
+ if (word === kw) {
+ scores[theme] += 3;
+ }
+ // Partial match
+ else if (word.includes(kw) || kw.includes(word)) {
+ scores[theme] += 1;
+ }
+ });
+ });
+ }
+
+ // Find highest scoring theme
+ var best = 'nature'; // default
+ var bestScore = 0;
+ for (var t in scores) {
+ if (scores[t] > bestScore) {
+ best = t;
+ bestScore = scores[t];
+ }
+ }
+ return best;
+ }
+
+ // =========================================================================
+ // MOOD DETECTION
+ // =========================================================================
+
+ /**
+ * Detect mood from keywords
+ * @param {Array} keywords - Parsed keywords
+ * @returns {string} Detected mood name
+ */
+ function detectMood(keywords) {
+ var moodKeywords = {
+ epic: ['epic', 'battle', 'hero', 'adventure', 'quest', 'warrior', 'legendary', 'conquest', 'victory', 'triumph', 'glory', 'champion', 'mighty', 'powerful'],
+ chill: ['chill', 'relax', 'calm', 'peaceful', 'zen', 'meditation', 'serene', 'tranquil', 'mellow', 'easy', 'gentle', 'soothing', 'quiet'],
+ intense: ['intense', 'fast', 'action', 'rush', 'speed', 'chase', 'urgent', 'adrenaline', 'extreme', 'rapid', 'frantic', 'wild', 'crazy'],
+ playful: ['fun', 'playful', 'silly', 'cute', 'happy', 'joy', 'cheerful', 'whimsical', 'bouncy', 'bubbly', 'friendly', 'goofy', 'cartoon'],
+ mysterious: ['mystery', 'scary', 'dark', 'haunted', 'ghost', 'creepy', 'strange', 'enigma', 'secret', 'hidden', 'shadow', 'fog', 'eerie'],
+ heroic: ['hero', 'brave', 'save', 'rescue', 'champion', 'courage', 'noble', 'defender', 'protect', 'guardian', 'justice', 'valor'],
+ quirky: ['weird', 'quirky', 'strange', 'unusual', 'unique', 'odd', 'wacky', 'bizarre', 'eccentric', 'offbeat', 'random'],
+ ambient: ['ambient', 'atmosphere', 'background', 'subtle', 'minimal', 'soft', 'dreamy', 'floating', 'ethereal', 'space'],
+ dramatic: ['dramatic', 'tension', 'suspense', 'climax', 'serious', 'grave', 'tense', 'thriller', 'danger'],
+ upbeat: ['upbeat', 'energetic', 'lively', 'peppy', 'dynamic', 'exciting', 'vibrant', 'active', 'bouncy'],
+ nostalgic: ['nostalgic', 'retro', 'classic', 'vintage', 'memories', 'throwback', 'oldschool', 'reminiscent']
+ };
+
+ var scores = {};
+ for (var mood in moodKeywords) {
+ scores[mood] = 0;
+ moodKeywords[mood].forEach(function(kw) {
+ keywords.forEach(function(word) {
+ // Exact match
+ if (word === kw) {
+ scores[mood] += 3;
+ }
+ // Partial match
+ else if (word.includes(kw) || kw.includes(word)) {
+ scores[mood] += 1;
+ }
+ });
+ });
+ }
+
+ // Find highest scoring mood
+ var best = 'playful'; // default for games
+ var bestScore = 0;
+ for (var m in scores) {
+ if (scores[m] > bestScore) {
+ best = m;
+ bestScore = scores[m];
+ }
+ }
+ return best;
+ }
+
+ // =========================================================================
+ // MECHANICS DETECTION
+ // =========================================================================
+
+ /**
+ * Detect game mechanics from keywords to determine avatar display mode
+ * @param {Array} keywords - Parsed keywords
+ * @returns {Object} { mode: string, context: string|null }
+ */
+ function detectMechanics(keywords) {
+ var result = { mode: 'FULL_BODY', context: null };
+
+ // Flying mechanics → HEAD_ONLY with flappy-style
+ var flyingKeywords = ['fly', 'flying', 'flap', 'bird', 'wings', 'soar', 'glide', 'hover', 'flutter', 'airborne'];
+ if (keywords.some(function(w) { return flyingKeywords.includes(w); })) {
+ result.mode = 'HEAD_ONLY';
+ result.context = 'flappy-style';
+ return result;
+ }
+
+ // Racing mechanics → HEAD_AND_SHOULDERS with race-car
+ var racingKeywords = ['race', 'racing', 'car', 'drive', 'kart', 'drift', 'speed', 'vehicle', 'steering', 'track'];
+ if (keywords.some(function(w) { return racingKeywords.includes(w); })) {
+ result.mode = 'HEAD_AND_SHOULDERS';
+ result.context = 'race-car';
+ return result;
+ }
+
+ // Space combat → HEAD_ONLY with spaceship-cockpit
+ var spaceKeywords = ['spaceship', 'spacecraft', 'cockpit', 'pilot', 'starship', 'fighter', 'turret'];
+ if (keywords.some(function(w) { return spaceKeywords.includes(w); })) {
+ result.mode = 'HEAD_ONLY';
+ result.context = 'spaceship-cockpit';
+ return result;
+ }
+
+ // Submarine/underwater vehicle → HEAD_AND_SHOULDERS
+ var subKeywords = ['submarine', 'sub', 'bathysphere', 'underwater', 'diving'];
+ if (keywords.some(function(w) { return subKeywords.includes(w); })) {
+ result.mode = 'HEAD_AND_SHOULDERS';
+ result.context = 'submarine-cockpit';
+ return result;
+ }
+
+ // Platformer → FULL_BODY
+ var platformerKeywords = ['jump', 'platform', 'run', 'runner', 'side', 'scrolling', 'platformer', 'hop', 'bounce'];
+ if (keywords.some(function(w) { return platformerKeywords.includes(w); })) {
+ result.mode = 'FULL_BODY';
+ result.context = 'platformer-standard';
+ return result;
+ }
+
+ // Puzzle/static → HEAD_AND_SHOULDERS
+ var puzzleKeywords = ['puzzle', 'match', 'block', 'tetris', 'tile', 'grid', 'board'];
+ if (keywords.some(function(w) { return puzzleKeywords.includes(w); })) {
+ result.mode = 'HEAD_AND_SHOULDERS';
+ result.context = 'puzzle-frame';
+ return result;
+ }
+
+ // Shooter → HEAD_ONLY with targeting-hud
+ var shooterKeywords = ['shoot', 'shooter', 'gun', 'blast', 'laser', 'fire', 'bullet', 'aim'];
+ if (keywords.some(function(w) { return shooterKeywords.includes(w); })) {
+ result.mode = 'HEAD_ONLY';
+ result.context = 'targeting-hud';
+ return result;
+ }
+
+ // Sports → FULL_BODY
+ var sportsKeywords = ['soccer', 'basketball', 'football', 'tennis', 'sports', 'athlete', 'ball'];
+ if (keywords.some(function(w) { return sportsKeywords.includes(w); })) {
+ result.mode = 'FULL_BODY';
+ result.context = 'sports-arena';
+ return result;
+ }
+
+ // Swimming → FULL_BODY with swim context
+ var swimKeywords = ['swim', 'swimming', 'diver', 'snorkel', 'fins'];
+ if (keywords.some(function(w) { return swimKeywords.includes(w); })) {
+ result.mode = 'FULL_BODY';
+ result.context = 'swimmer-underwater';
+ return result;
+ }
+
+ return result;
+ }
+
+ // =========================================================================
+ // RECOMMENDATION ENGINE
+ // =========================================================================
+
+ /**
+ * Get asset recommendations based on game description
+ * @param {string} gameStyle - One of the 11 game styles
+ * @param {string|Array} keywords - Description keywords or array of keywords
+ * @param {string} mood - Optional mood override
+ * @returns {Array} Top 3 recommendations with explanations
+ */
+ function getRecommendations(gameStyle, keywords, mood) {
+ // Parse keywords if string
+ var parsedKeywords = parseKeywords(keywords);
+
+ // Detect theme from keywords
+ var detectedTheme = detectTheme(parsedKeywords);
+ var detectedMood = mood || detectMood(parsedKeywords);
+ var detectedMechanics = detectMechanics(parsedKeywords);
+
+ // Get compatible combinations from AssetCompatibility
+ var compatibleMusic = [];
+ var compatibleBackgrounds = [];
+ var compatibleContexts = [];
+
+ if (window.AssetCompatibility) {
+ if (window.AssetCompatibility.getMusicForMood) {
+ compatibleMusic = window.AssetCompatibility.getMusicForMood(detectedMood);
+ }
+ if (window.AssetCompatibility.getBackgroundsForTheme) {
+ compatibleBackgrounds = window.AssetCompatibility.getBackgroundsForTheme(detectedTheme);
+ }
+ if (window.AssetCompatibility.getContextsForMechanics) {
+ compatibleContexts = window.AssetCompatibility.getContextsForMechanics(detectedMechanics);
+ }
+ }
+
+ // Score and rank combinations
+ var candidates = [];
+
+ // Use presets as base candidates
+ var presets = [];
+ if (window.AssetPresets && window.AssetPresets.getByGameStyle) {
+ presets = window.AssetPresets.getByGameStyle(gameStyle);
+ }
+
+ // If no presets for this game style, get all presets
+ if (presets.length === 0 && window.AssetPresets && window.AssetPresets.getAll) {
+ presets = window.AssetPresets.getAll();
+ }
+
+ presets.forEach(function(preset) {
+ var score = scorePreset(preset, detectedTheme, detectedMood, parsedKeywords, compatibleMusic, compatibleBackgrounds, compatibleContexts);
+ candidates.push({
+ preset: preset,
+ score: score,
+ explanation: generateExplanation(preset, parsedKeywords, detectedTheme, detectedMood)
+ });
+ });
+
+ // Also create dynamic combinations from compatible assets if we have few presets
+ if (candidates.length < 3 && compatibleMusic.length > 0 && compatibleBackgrounds.length > 0) {
+ for (var i = 0; i < Math.min(3, compatibleMusic.length); i++) {
+ for (var j = 0; j < Math.min(3, compatibleBackgrounds.length); j++) {
+ var dynamicPreset = {
+ id: 'dynamic-' + i + '-' + j,
+ name: detectedTheme + ' ' + detectedMood + ' combo',
+ description: 'Auto-generated combination',
+ music: compatibleMusic[i],
+ background: compatibleBackgrounds[j],
+ avatarContext: compatibleContexts[0] || { mode: detectedMechanics.mode, context: detectedMechanics.context }
+ };
+ var score = scorePreset(dynamicPreset, detectedTheme, detectedMood, parsedKeywords, compatibleMusic, compatibleBackgrounds, compatibleContexts);
+ // Slight penalty for dynamic presets
+ score = score * 0.9;
+ candidates.push({
+ preset: dynamicPreset,
+ score: score,
+ explanation: generateExplanation(dynamicPreset, parsedKeywords, detectedTheme, detectedMood)
+ });
+ }
+ }
+ }
+
+ // Sort by score descending
+ candidates.sort(function(a, b) { return b.score - a.score; });
+
+ // Remove duplicates based on preset ID
+ var seen = {};
+ var uniqueCandidates = candidates.filter(function(c) {
+ if (seen[c.preset.id]) return false;
+ seen[c.preset.id] = true;
+ return true;
+ });
+
+ // Return top 3
+ return uniqueCandidates.slice(0, 3).map(function(c) {
+ return {
+ config: presetToConfig(c.preset),
+ score: c.score,
+ explanation: c.explanation,
+ presetId: c.preset.id,
+ detectedTheme: detectedTheme,
+ detectedMood: detectedMood,
+ detectedMechanics: detectedMechanics
+ };
+ });
+ }
+
+ // =========================================================================
+ // SCORING
+ // =========================================================================
+
+ /**
+ * Score a preset based on detected attributes
+ * @param {Object} preset - The preset to score
+ * @param {string} theme - Detected theme
+ * @param {string} mood - Detected mood
+ * @param {Array} keywords - Parsed keywords
+ * @param {Array} compatibleMusic - Compatible music from AssetCompatibility
+ * @param {Array} compatibleBackgrounds - Compatible backgrounds
+ * @param {Array} compatibleContexts - Compatible contexts
+ * @returns {number} Score value
+ */
+ function scorePreset(preset, theme, mood, keywords, compatibleMusic, compatibleBackgrounds, compatibleContexts) {
+ var score = 0;
+
+ // Theme match with background (high weight)
+ if (preset.background) {
+ var bgTheme = (preset.background.theme || '').toLowerCase();
+ if (bgTheme === theme.toLowerCase()) {
+ score += 15;
+ } else if (bgTheme.includes(theme.toLowerCase()) || theme.toLowerCase().includes(bgTheme)) {
+ score += 8;
+ }
+
+ // Check if background is in compatible list
+ if (compatibleBackgrounds && compatibleBackgrounds.length > 0) {
+ var isCompatibleBg = compatibleBackgrounds.some(function(bg) {
+ return bg && bg.theme === preset.background.theme;
+ });
+ if (isCompatibleBg) score += 5;
+ }
+ }
+
+ // Mood match with music (high weight)
+ if (preset.music) {
+ var musicMood = (preset.music.mood || '').toLowerCase();
+ if (musicMood === mood.toLowerCase()) {
+ score += 15;
+ } else if (musicMood.includes(mood.toLowerCase()) || mood.toLowerCase().includes(musicMood)) {
+ score += 8;
+ }
+
+ // Check if music is in compatible list
+ if (compatibleMusic && compatibleMusic.length > 0) {
+ var isCompatibleMusic = compatibleMusic.some(function(m) {
+ return m && m.mood === preset.music.mood;
+ });
+ if (isCompatibleMusic) score += 5;
+ }
+ }
+
+ // Context compatibility
+ if (preset.avatarContext && compatibleContexts && compatibleContexts.length > 0) {
+ var isCompatibleContext = compatibleContexts.some(function(ctx) {
+ return ctx && ctx.context === preset.avatarContext.context;
+ });
+ if (isCompatibleContext) score += 10;
+ }
+
+ // Keyword matches in description (medium weight)
+ if (preset.description) {
+ var desc = preset.description.toLowerCase();
+ keywords.forEach(function(kw) {
+ if (desc.includes(kw)) {
+ score += 2;
+ }
+ });
+ }
+
+ // Keyword matches in name (high weight for direct matches)
+ if (preset.name) {
+ var name = preset.name.toLowerCase();
+ keywords.forEach(function(kw) {
+ if (name.includes(kw)) {
+ score += 4;
+ }
+ });
+ }
+
+ // Keyword matches in tags
+ if (preset.tags && Array.isArray(preset.tags)) {
+ var presetTags = preset.tags.map(function(t) { return t.toLowerCase(); });
+ keywords.forEach(function(kw) {
+ if (presetTags.includes(kw)) {
+ score += 3;
+ }
+ });
+ }
+
+ // Energy level considerations (if present)
+ if (preset.music && preset.music.energy !== undefined) {
+ // Intense moods prefer high energy
+ if (mood === 'intense' || mood === 'epic' || mood === 'dramatic') {
+ if (preset.music.energy >= 7) score += 3;
+ }
+ // Chill moods prefer low energy
+ else if (mood === 'chill' || mood === 'ambient') {
+ if (preset.music.energy <= 4) score += 3;
+ }
+ // Playful prefers medium energy
+ else if (mood === 'playful' || mood === 'upbeat') {
+ if (preset.music.energy >= 5 && preset.music.energy <= 8) score += 3;
+ }
+ }
+
+ return score;
+ }
+
+ // =========================================================================
+ // EXPLANATION GENERATION
+ // =========================================================================
+
+ /**
+ * Generate human-readable explanation for why a preset was recommended
+ * @param {Object} preset - The recommended preset
+ * @param {Array} keywords - User's keywords
+ * @param {string} theme - Detected theme
+ * @param {string} mood - Detected mood
+ * @returns {string} Explanation text
+ */
+ function generateExplanation(preset, keywords, theme, mood) {
+ var reasons = [];
+
+ // Music reasoning
+ if (preset.music) {
+ var musicMood = preset.music.mood || 'dynamic';
+ var energy = preset.music.energy || 5;
+ var energyDesc = energy >= 7 ? 'high-energy' : (energy <= 3 ? 'calm' : 'balanced');
+ reasons.push(musicMood + ' ' + energyDesc + ' music sets the mood');
+ }
+
+ // Background reasoning
+ if (preset.background) {
+ var bgTheme = preset.background.theme || 'themed';
+ var variant = preset.background.variant ? ' (' + preset.background.variant + ')' : '';
+ if (bgTheme.toLowerCase() === theme.toLowerCase()) {
+ reasons.push(bgTheme + variant + ' background matches your ' + theme + ' theme');
+ } else {
+ reasons.push(bgTheme + variant + ' background provides atmosphere');
+ }
+ }
+
+ // Context reasoning
+ if (preset.avatarContext) {
+ var contextName = preset.avatarContext.context || preset.avatarContext.mode;
+ reasons.push(contextName + ' avatar context complements the gameplay');
+ }
+
+ // Add keyword matches if found
+ var matchedKeywords = [];
+ if (preset.name) {
+ keywords.forEach(function(kw) {
+ if (preset.name.toLowerCase().includes(kw)) {
+ matchedKeywords.push(kw);
+ }
+ });
+ }
+ if (matchedKeywords.length > 0) {
+ reasons.push('matches keywords: ' + matchedKeywords.slice(0, 3).join(', '));
+ }
+
+ return reasons.join('; ') || 'Good general-purpose combination';
+ }
+
+ // =========================================================================
+ // PRESET LOOKUP
+ // =========================================================================
+
+ /**
+ * Find a preset by game style with variety index
+ * @param {string} gameStyle - Game style to search for
+ * @param {number} variety - Index for variation (cycles through available presets)
+ * @returns {Object|null} Preset object or null
+ */
+ function findPreset(gameStyle, variety) {
+ variety = variety || 0;
+
+ if (!window.AssetPresets || !window.AssetPresets.getByGameStyle) {
+ return null;
+ }
+
+ var presets = window.AssetPresets.getByGameStyle(gameStyle);
+ if (presets.length === 0) {
+ // Fallback to all presets
+ presets = window.AssetPresets.getAll ? window.AssetPresets.getAll() : [];
+ }
+
+ if (presets.length === 0) return null;
+
+ var index = Math.abs(variety) % presets.length;
+ return presets[index];
+ }
+
+ /**
+ * Convert a preset to a configuration object
+ * @param {Object} preset - Preset to convert
+ * @returns {Object} Configuration object
+ */
+ function presetToConfig(preset) {
+ if (!preset) return null;
+
+ return {
+ music: preset.music ? {
+ mood: preset.music.mood,
+ energy: preset.music.energy,
+ songId: preset.music.songId || preset.music.id,
+ tags: preset.music.tags
+ } : null,
+ background: preset.background ? {
+ theme: preset.background.theme,
+ variant: preset.background.variant,
+ animated: preset.background.animated !== false
+ } : null,
+ avatarContext: preset.avatarContext ? {
+ mode: preset.avatarContext.mode,
+ context: preset.avatarContext.context,
+ props: preset.avatarContext.props
+ } : null,
+ colorPalette: preset.colorPalette || null,
+ presetId: preset.id,
+ name: preset.name,
+ description: preset.description
+ };
+ }
+
+ // =========================================================================
+ // ASSET LOADING
+ // =========================================================================
+
+ /**
+ * Load game assets based on configuration
+ * @param {Object} config - Asset configuration
+ * @returns {Promise} Resolves with loaded assets
+ */
+ function loadGameAssets(config) {
+ return new Promise(function(resolve, reject) {
+ try {
+ var results = {};
+ var loadPromises = [];
+
+ // Load music
+ if (config.music && window.MusicEngine) {
+ var songQuery = {
+ mood: config.music.mood,
+ energy: config.music.energy
+ };
+
+ var songs = [];
+ if (window.MusicEngine.findSongs) {
+ songs = window.MusicEngine.findSongs(songQuery);
+ }
+
+ if (songs.length > 0) {
+ results.music = songs[0];
+
+ // If MusicEngine has async load method
+ if (window.MusicEngine.loadSong) {
+ loadPromises.push(
+ window.MusicEngine.loadSong(songs[0].id || songs[0].songId).then(function(loaded) {
+ results.music = loaded;
+ }).catch(function() {
+ // Keep the basic song info even if full load fails
+ })
+ );
+ }
+ }
+ }
+
+ // Load background
+ if (config.background && window.BackgroundEngine) {
+ results.background = {
+ theme: config.background.theme,
+ variant: config.background.variant,
+ animated: config.background.animated !== false,
+ render: function(ctx, width, height, time) {
+ if (window.BackgroundEngine.render) {
+ window.BackgroundEngine.render(
+ ctx,
+ config.background.theme,
+ config.background.variant,
+ width,
+ height,
+ time
+ );
+ }
+ }
+ };
+
+ // Preload background if method exists
+ if (window.BackgroundEngine.preload) {
+ loadPromises.push(
+ Promise.resolve(window.BackgroundEngine.preload(config.background.theme, config.background.variant))
+ );
+ }
+ }
+
+ // Load avatar context
+ if (config.avatarContext && config.avatar && window.ContextRenderer) {
+ if (window.ContextRenderer.apply) {
+ results.avatarWithContext = window.ContextRenderer.apply(
+ config.avatar,
+ config.avatarContext.context,
+ config.avatarContext.props
+ );
+ }
+ results.avatarContext = config.avatarContext;
+ }
+
+ // Wait for all async loads
+ Promise.all(loadPromises).then(function() {
+ loadedAssets = results;
+
+ // Cache the loaded assets
+ if (config.presetId) {
+ cache.set(config.presetId, results);
+ }
+
+ resolve(results);
+ }).catch(function(err) {
+ // Still return what we have even if some loads failed
+ loadedAssets = results;
+ resolve(results);
+ });
+
+ } catch (err) {
+ reject(err);
+ }
+ });
+ }
+
+ // =========================================================================
+ // VALIDATION
+ // =========================================================================
+
+ /**
+ * Validate an asset combination for compatibility
+ * @param {Object} config - Configuration to validate
+ * @returns {Object} { valid: boolean, issues: Array, warnings: Array }
+ */
+ function validateCombination(config) {
+ // Use AssetCompatibility if available
+ if (window.AssetCompatibility && window.AssetCompatibility.validate) {
+ return window.AssetCompatibility.validate(config);
+ }
+
+ // Basic validation fallback
+ var result = {
+ valid: true,
+ issues: [],
+ warnings: []
+ };
+
+ // Check required fields
+ if (!config) {
+ result.valid = false;
+ result.issues.push('No configuration provided');
+ return result;
+ }
+
+ // Validate music config
+ if (config.music) {
+ if (!config.music.mood) {
+ result.warnings.push('Music mood not specified');
+ }
+ if (config.music.energy !== undefined) {
+ if (config.music.energy < 1 || config.music.energy > 10) {
+ result.issues.push('Music energy must be between 1 and 10');
+ result.valid = false;
+ }
+ }
+ }
+
+ // Validate background config
+ if (config.background) {
+ if (!config.background.theme) {
+ result.warnings.push('Background theme not specified');
+ }
+ }
+
+ // Validate avatar context
+ if (config.avatarContext) {
+ var validModes = ['FULL_BODY', 'HEAD_AND_SHOULDERS', 'HEAD_ONLY'];
+ if (config.avatarContext.mode && validModes.indexOf(config.avatarContext.mode) === -1) {
+ result.issues.push('Invalid avatar mode: ' + config.avatarContext.mode);
+ result.valid = false;
+ }
+ }
+
+ return result;
+ }
+
+ // =========================================================================
+ // DESCRIPTION
+ // =========================================================================
+
+ /**
+ * Generate a human-readable description of asset configuration
+ * @param {Object} config - Configuration to describe
+ * @returns {string} Description text
+ */
+ function describeAssets(config) {
+ if (!config) return 'No assets configured';
+
+ var parts = [];
+
+ if (config.music) {
+ var musicDesc = 'Music: ';
+ if (config.music.mood) {
+ musicDesc += config.music.mood + ' mood';
+ }
+ if (config.music.energy !== undefined) {
+ musicDesc += ' (energy ' + config.music.energy + '/10)';
+ }
+ if (config.music.songId) {
+ musicDesc += ' [' + config.music.songId + ']';
+ }
+ parts.push(musicDesc);
+ }
+
+ if (config.background) {
+ var bgDesc = 'Background: ';
+ if (config.background.theme) {
+ bgDesc += config.background.theme;
+ }
+ if (config.background.variant) {
+ bgDesc += ' - ' + config.background.variant;
+ }
+ if (config.background.animated === false) {
+ bgDesc += ' (static)';
+ }
+ parts.push(bgDesc);
+ }
+
+ if (config.avatarContext) {
+ var avDesc = 'Avatar: ';
+ if (config.avatarContext.mode) {
+ avDesc += config.avatarContext.mode;
+ }
+ if (config.avatarContext.context) {
+ avDesc += ' in ' + config.avatarContext.context;
+ }
+ parts.push(avDesc);
+ }
+
+ if (config.name) {
+ parts.unshift('Preset: ' + config.name);
+ }
+
+ return parts.join(' | ') || 'Default configuration';
+ }
+
+ // =========================================================================
+ // VARIATIONS
+ // =========================================================================
+
+ /**
+ * Get a variation of the current configuration
+ * @param {Object} config - Current configuration
+ * @param {string} variationType - Type of variation: 'music', 'background', 'context', or 'all'
+ * @returns {Object} New configuration with variation
+ */
+ function getVariation(config, variationType) {
+ if (!config) return null;
+
+ // Deep clone the config
+ var newConfig = JSON.parse(JSON.stringify(config));
+
+ switch (variationType) {
+ case 'music':
+ if (newConfig.music && window.MusicEngine && window.MusicEngine.findSongs) {
+ var altSongs = window.MusicEngine.findSongs({ mood: config.music.mood });
+ if (altSongs.length > 1) {
+ // Pick a random different song
+ var filtered = altSongs.filter(function(s) {
+ return s.id !== config.music.songId && s.songId !== config.music.songId;
+ });
+ if (filtered.length > 0) {
+ var picked = filtered[Math.floor(Math.random() * filtered.length)];
+ newConfig.music = {
+ mood: picked.mood || config.music.mood,
+ energy: picked.energy || config.music.energy,
+ songId: picked.id || picked.songId,
+ tags: picked.tags
+ };
+ }
+ }
+ }
+ break;
+
+ case 'background':
+ if (newConfig.background && window.BackgroundEngine && window.BackgroundEngine.getVariants) {
+ var variants = window.BackgroundEngine.getVariants(config.background.theme);
+ if (variants && variants.length > 1) {
+ var currentIdx = variants.indexOf(config.background.variant);
+ var nextIdx = (currentIdx + 1) % variants.length;
+ newConfig.background = Object.assign({}, config.background, {
+ variant: variants[nextIdx]
+ });
+ }
+ }
+ break;
+
+ case 'context':
+ if (newConfig.avatarContext && window.ContextCatalog && window.ContextCatalog.getByMode) {
+ var contexts = window.ContextCatalog.getByMode(config.avatarContext.mode);
+ if (contexts && contexts.length > 1) {
+ var filtered = contexts.filter(function(c) {
+ return c.id !== config.avatarContext.context;
+ });
+ if (filtered.length > 0) {
+ var alt = filtered[Math.floor(Math.random() * filtered.length)];
+ newConfig.avatarContext = Object.assign({}, config.avatarContext, {
+ context: alt.id
+ });
+ }
+ }
+ }
+ break;
+
+ case 'all':
+ default:
+ // Apply all variations
+ newConfig = getVariation(newConfig, 'music');
+ newConfig = getVariation(newConfig, 'background');
+ newConfig = getVariation(newConfig, 'context');
+ break;
+ }
+
+ // Clear preset info since this is now a custom combination
+ if (variationType) {
+ newConfig.presetId = null;
+ newConfig.name = (config.name || 'Custom') + ' (variation)';
+ }
+
+ return newConfig;
+ }
+
+ // =========================================================================
+ // SEARCH
+ // =========================================================================
+
+ /**
+ * Search for assets across all subsystems
+ * @param {string} query - Search query
+ * @returns {Object} Search results by category
+ */
+ function searchAssets(query) {
+ if (!query || typeof query !== 'string') {
+ return { presets: [], music: [], backgrounds: [], contexts: [] };
+ }
+
+ var normalizedQuery = query.toLowerCase().trim();
+ var results = {
+ presets: [],
+ music: [],
+ backgrounds: [],
+ contexts: []
+ };
+
+ // Search presets
+ if (window.AssetPresets) {
+ if (window.AssetPresets.search) {
+ results.presets = window.AssetPresets.search(normalizedQuery);
+ } else if (window.AssetPresets.getAll) {
+ // Manual search fallback
+ results.presets = window.AssetPresets.getAll().filter(function(p) {
+ return (p.name && p.name.toLowerCase().includes(normalizedQuery)) ||
+ (p.description && p.description.toLowerCase().includes(normalizedQuery)) ||
+ (p.tags && p.tags.some(function(t) { return t.toLowerCase().includes(normalizedQuery); }));
+ });
+ }
+ }
+
+ // Search music
+ if (window.MusicEngine) {
+ if (window.MusicEngine.findSongs) {
+ results.music = window.MusicEngine.findSongs({ tags: [normalizedQuery] });
+ }
+ // Also search by mood
+ if (results.music.length === 0 && window.MusicEngine.findSongs) {
+ results.music = window.MusicEngine.findSongs({ mood: normalizedQuery });
+ }
+ }
+
+ // Search backgrounds
+ if (window.BackgroundEngine) {
+ if (window.BackgroundEngine.find) {
+ results.backgrounds = window.BackgroundEngine.find({ tags: [normalizedQuery] });
+ } else if (window.BackgroundEngine.getThemes) {
+ // Manual search fallback
+ var themes = window.BackgroundEngine.getThemes();
+ results.backgrounds = themes.filter(function(t) {
+ return t.toLowerCase().includes(normalizedQuery);
+ }).map(function(t) {
+ return { theme: t };
+ });
+ }
+ }
+
+ // Search contexts
+ if (window.ContextCatalog) {
+ if (window.ContextCatalog.search) {
+ results.contexts = window.ContextCatalog.search(normalizedQuery);
+ } else if (window.ContextCatalog.getAll) {
+ // Manual search fallback
+ results.contexts = window.ContextCatalog.getAll().filter(function(c) {
+ return (c.id && c.id.toLowerCase().includes(normalizedQuery)) ||
+ (c.name && c.name.toLowerCase().includes(normalizedQuery)) ||
+ (c.tags && c.tags.some(function(t) { return t.toLowerCase().includes(normalizedQuery); }));
+ });
+ }
+ }
+
+ return results;
+ }
+
+ // =========================================================================
+ // RANDOM COMBINATION
+ // =========================================================================
+
+ /**
+ * Get a random asset combination, optionally filtered by game style
+ * @param {string} gameStyle - Optional game style filter
+ * @returns {Object} Random configuration
+ */
+ function getRandomCombination(gameStyle) {
+ var presets = [];
+
+ if (window.AssetPresets) {
+ if (gameStyle && window.AssetPresets.getByGameStyle) {
+ presets = window.AssetPresets.getByGameStyle(gameStyle);
+ }
+ if (presets.length === 0 && window.AssetPresets.getAll) {
+ presets = window.AssetPresets.getAll();
+ }
+ }
+
+ if (presets.length > 0) {
+ var preset = presets[Math.floor(Math.random() * presets.length)];
+ return presetToConfig(preset);
+ }
+
+ // Fallback: build random combination from individual subsystems
+ var config = {};
+
+ // Random music
+ if (window.MusicEngine && window.MusicEngine.getCatalog) {
+ var songs = window.MusicEngine.getCatalog();
+ if (songs.length > 0) {
+ var song = songs[Math.floor(Math.random() * songs.length)];
+ config.music = {
+ mood: song.mood,
+ energy: song.energy,
+ songId: song.id || song.songId
+ };
+ }
+ }
+
+ // Random background
+ if (window.BackgroundEngine && window.BackgroundEngine.getThemes) {
+ var themes = window.BackgroundEngine.getThemes();
+ if (themes.length > 0) {
+ var theme = themes[Math.floor(Math.random() * themes.length)];
+ var variants = window.BackgroundEngine.getVariants ? window.BackgroundEngine.getVariants(theme) : ['default'];
+ var variant = variants[Math.floor(Math.random() * variants.length)];
+ config.background = {
+ theme: theme,
+ variant: variant
+ };
+ }
+ }
+
+ // Random context
+ if (window.ContextCatalog && window.ContextCatalog.getAll) {
+ var contexts = window.ContextCatalog.getAll();
+ if (contexts.length > 0) {
+ var context = contexts[Math.floor(Math.random() * contexts.length)];
+ config.avatarContext = {
+ mode: context.mode || 'FULL_BODY',
+ context: context.id
+ };
+ }
+ }
+
+ config.name = 'Random Combination';
+ config.description = 'Randomly generated asset combination';
+
+ return config;
+ }
+
+ // =========================================================================
+ // CACHE MANAGEMENT
+ // =========================================================================
+
+ /**
+ * Clear the asset cache
+ * @param {string} key - Optional specific key to clear
+ */
+ function clearCache(key) {
+ if (key) {
+ cache.delete(key);
+ } else {
+ cache.clear();
+ }
+ }
+
+ /**
+ * Get cached assets
+ * @param {string} key - Cache key (usually presetId)
+ * @returns {Object|undefined} Cached assets
+ */
+ function getCached(key) {
+ return cache.get(key);
+ }
+
+ // =========================================================================
+ // UTILITY FUNCTIONS
+ // =========================================================================
+
+ /**
+ * Get statistics about available assets
+ * @returns {Object} Asset statistics
+ */
+ function getStats() {
+ var stats = {
+ initialized: initialized,
+ cache: {
+ size: cache.size
+ },
+ subsystems: {}
+ };
+
+ if (window.MusicEngine) {
+ stats.subsystems.music = {
+ available: true,
+ songs: window.MusicEngine.getCatalog ? window.MusicEngine.getCatalog().length : 'unknown'
+ };
+ }
+
+ if (window.BackgroundEngine) {
+ stats.subsystems.backgrounds = {
+ available: true,
+ count: window.BackgroundEngine.getTotalCount ? window.BackgroundEngine.getTotalCount() : 'unknown'
+ };
+ }
+
+ if (window.AvatarSystem) {
+ stats.subsystems.avatars = {
+ available: true
+ };
+ }
+
+ if (window.ContextCatalog) {
+ stats.subsystems.contexts = {
+ available: true,
+ count: window.ContextCatalog.getAll ? window.ContextCatalog.getAll().length : 'unknown'
+ };
+ }
+
+ if (window.AssetPresets) {
+ stats.subsystems.presets = {
+ available: true,
+ count: window.AssetPresets.getAll ? window.AssetPresets.getAll().length : 'unknown'
+ };
+ }
+
+ return stats;
+ }
+
+ // =========================================================================
+ // PUBLIC API
+ // =========================================================================
+
+ window.AssetManager = {
+ // Initialization
+ initialize: initialize,
+ isInitialized: function() { return initialized; },
+
+ // Recommendations
+ getRecommendations: getRecommendations,
+
+ // Detection helpers (exposed for testing/debugging)
+ detectTheme: detectTheme,
+ detectMood: detectMood,
+ detectMechanics: detectMechanics,
+ parseKeywords: parseKeywords,
+
+ // Preset lookup
+ findPreset: findPreset,
+ presetToConfig: presetToConfig,
+
+ // Asset loading
+ loadGameAssets: loadGameAssets,
+ getLoadedAssets: function() { return loadedAssets; },
+
+ // Validation
+ validateCombination: validateCombination,
+
+ // Description
+ describeAssets: describeAssets,
+
+ // Variations
+ getVariation: getVariation,
+
+ // Search
+ searchAssets: searchAssets,
+
+ // Random
+ getRandomCombination: getRandomCombination,
+
+ // Cache management
+ clearCache: clearCache,
+ getCached: getCached,
+
+ // Statistics
+ getStats: getStats,
+
+ // Direct access to subsystems
+ music: function() { return window.MusicEngine; },
+ backgrounds: function() { return window.BackgroundEngine; },
+ avatars: function() { return window.AvatarSystem; },
+ contexts: function() { return window.ContextRenderer; },
+ catalog: function() { return window.AssetCatalog; },
+ presets: function() { return window.AssetPresets; },
+ compatibility: function() { return window.AssetCompatibility; }
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/accessories.js b/frontend/public/assets/avatar/accessories.js
new file mode 100644
index 0000000..da2cfa3
--- /dev/null
+++ b/frontend/public/assets/avatar/accessories.js
@@ -0,0 +1,762 @@
+/**
+ * Avatar Accessories System
+ * Defines all unlockable accessories for the avatar customization system
+ */
+(function() {
+ 'use strict';
+
+ // =============================================================================
+ // RARITY COLORS
+ // =============================================================================
+ var RARITY_COLORS = {
+ common: '#9d9d9d',
+ uncommon: '#1eff00',
+ rare: '#0070dd',
+ epic: '#a335ee',
+ legendary: '#ff8000',
+ mythic: '#e6cc80'
+ };
+
+ // =============================================================================
+ // EYEWEAR (15 items)
+ // =============================================================================
+ var EYEWEAR = [
+ {
+ id: 'sunglasses-classic',
+ name: 'Classic Sunglasses',
+ category: 'eyewear',
+ unlockLevel: 5,
+ rarity: 'common',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -2 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Timeless shades for any occasion'
+ },
+ {
+ id: 'aviator',
+ name: 'Aviator Glasses',
+ category: 'eyewear',
+ unlockLevel: 10,
+ rarity: 'common',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -1 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Top Gun approved eyewear'
+ },
+ {
+ id: 'nerd-glasses',
+ name: 'Nerd Glasses',
+ category: 'eyewear',
+ unlockLevel: 15,
+ rarity: 'common',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: 0 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Intelligence +100 (cosmetic only)'
+ },
+ {
+ id: '3d-glasses',
+ name: '3D Glasses',
+ category: 'eyewear',
+ unlockLevel: 20,
+ rarity: 'uncommon',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -1 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'See the world in a whole new dimension'
+ },
+ {
+ id: 'goggles',
+ name: 'Goggles',
+ category: 'eyewear',
+ unlockLevel: 25,
+ rarity: 'uncommon',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -3 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Ready for adventure or science experiments'
+ },
+ {
+ id: 'monocle',
+ name: 'Monocle',
+ category: 'eyewear',
+ unlockLevel: 30,
+ rarity: 'uncommon',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 8, y: 0 },
+ scale: 0.8,
+ hasEffect: false,
+ description: 'Distinguished and sophisticated'
+ },
+ {
+ id: 'star-glasses',
+ name: 'Star Glasses',
+ category: 'eyewear',
+ unlockLevel: 35,
+ rarity: 'rare',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -2 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'You are a star!'
+ },
+ {
+ id: 'heart-glasses',
+ name: 'Heart Glasses',
+ category: 'eyewear',
+ unlockLevel: 40,
+ rarity: 'rare',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -2 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Spread the love wherever you go'
+ },
+ {
+ id: 'sport-visor',
+ name: 'Sport Visor',
+ category: 'eyewear',
+ unlockLevel: 45,
+ rarity: 'rare',
+ zIndex: 10,
+ anchor: 'eyes',
+ offset: { x: 0, y: -5 },
+ scale: 1.2,
+ hasEffect: false,
+ description: 'Professional gamer gear'
+ },
+ {
+ id: 'vr-headset',
+ name: 'VR Headset',
+ category: 'eyewear',
+ unlockLevel: 50,
+ rarity: 'epic',
+ zIndex: 11,
+ anchor: 'eyes',
+ offset: { x: 0, y: -4 },
+ scale: 1.3,
+ hasEffect: false,
+ description: 'Immerse yourself in virtual worlds'
+ },
+ {
+ id: 'cyber-visor',
+ name: 'Cyber Visor',
+ category: 'eyewear',
+ unlockLevel: 55,
+ rarity: 'epic',
+ zIndex: 11,
+ anchor: 'eyes',
+ offset: { x: 0, y: -3 },
+ scale: 1.2,
+ hasEffect: false,
+ description: 'Sleek futuristic eye protection'
+ },
+ {
+ id: 'x-ray-specs',
+ name: 'X-Ray Specs',
+ category: 'eyewear',
+ unlockLevel: 60,
+ rarity: 'epic',
+ zIndex: 11,
+ anchor: 'eyes',
+ offset: { x: 0, y: -2 },
+ scale: 1.1,
+ hasEffect: true,
+ description: 'See through walls... probably'
+ },
+ {
+ id: 'laser-eyes',
+ name: 'Laser Eyes',
+ category: 'eyewear',
+ unlockLevel: 70,
+ rarity: 'legendary',
+ zIndex: 12,
+ anchor: 'eyes',
+ offset: { x: 0, y: 0 },
+ scale: 1.0,
+ hasEffect: true,
+ description: 'Pew pew! Lasers shoot from your eyes'
+ },
+ {
+ id: 'diamond-glasses',
+ name: 'Diamond Glasses',
+ category: 'eyewear',
+ unlockLevel: 80,
+ rarity: 'legendary',
+ zIndex: 12,
+ anchor: 'eyes',
+ offset: { x: 0, y: -2 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Pure crystallized luxury'
+ },
+ {
+ id: 'cosmic-lenses',
+ name: 'Cosmic Lenses',
+ category: 'eyewear',
+ unlockLevel: 90,
+ rarity: 'mythic',
+ zIndex: 13,
+ anchor: 'eyes',
+ offset: { x: 0, y: -2 },
+ scale: 1.2,
+ hasEffect: true,
+ description: 'Gaze upon the infinite universe'
+ }
+ ];
+
+ // =============================================================================
+ // HEADWEAR (20 items)
+ // =============================================================================
+ var HEADWEAR = [
+ {
+ id: 'baseball-cap',
+ name: 'Baseball Cap',
+ category: 'headwear',
+ unlockLevel: 3,
+ rarity: 'common',
+ zIndex: 8,
+ anchor: 'top',
+ offset: { x: 0, y: -15 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'A classic casual cap'
+ },
+ {
+ id: 'backwards-cap',
+ name: 'Backwards Cap',
+ category: 'headwear',
+ unlockLevel: 8,
+ rarity: 'common',
+ zIndex: 8,
+ anchor: 'top',
+ offset: { x: 0, y: -15 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Cool kids wear it backwards'
+ },
+ {
+ id: 'beanie',
+ name: 'Beanie',
+ category: 'headwear',
+ unlockLevel: 12,
+ rarity: 'common',
+ zIndex: 8,
+ anchor: 'top',
+ offset: { x: 0, y: -12 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Cozy knitted headwear'
+ },
+ {
+ id: 'party-hat',
+ name: 'Party Hat',
+ category: 'headwear',
+ unlockLevel: 18,
+ rarity: 'uncommon',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -20 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Every day is a celebration!'
+ },
+ {
+ id: 'chef-hat',
+ name: 'Chef Hat',
+ category: 'headwear',
+ unlockLevel: 22,
+ rarity: 'uncommon',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -25 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Master of the kitchen'
+ },
+ {
+ id: 'crown-bronze',
+ name: 'Bronze Crown',
+ category: 'headwear',
+ unlockLevel: 25,
+ rarity: 'uncommon',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -18 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'A humble crown for rising royalty'
+ },
+ {
+ id: 'hard-hat',
+ name: 'Hard Hat',
+ category: 'headwear',
+ unlockLevel: 26,
+ rarity: 'uncommon',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -14 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Safety first on the job site'
+ },
+ {
+ id: 'wizard-hat',
+ name: 'Wizard Hat',
+ category: 'headwear',
+ unlockLevel: 30,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -30 },
+ scale: 1.2,
+ hasEffect: false,
+ description: 'Channel your inner sorcerer'
+ },
+ {
+ id: 'pirate-hat',
+ name: 'Pirate Hat',
+ category: 'headwear',
+ unlockLevel: 35,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -18 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Arrr! Set sail for adventure'
+ },
+ {
+ id: 'santa-hat',
+ name: 'Santa Hat',
+ category: 'headwear',
+ unlockLevel: 38,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 3, y: -16 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Ho ho ho! Spread holiday cheer'
+ },
+ {
+ id: 'crown-silver',
+ name: 'Silver Crown',
+ category: 'headwear',
+ unlockLevel: 40,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -18 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'A noble crown for dedicated players'
+ },
+ {
+ id: 'graduation-cap',
+ name: 'Graduation Cap',
+ category: 'headwear',
+ unlockLevel: 42,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -16 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Scholar of the gaming arts'
+ },
+ {
+ id: 'bunny-ears',
+ name: 'Bunny Ears',
+ category: 'headwear',
+ unlockLevel: 45,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -22 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Hop into the fun!'
+ },
+ {
+ id: 'devil-horns',
+ name: 'Devil Horns',
+ category: 'headwear',
+ unlockLevel: 48,
+ rarity: 'rare',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -14 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'A little mischief never hurt anyone'
+ },
+ {
+ id: 'halo',
+ name: 'Halo',
+ category: 'headwear',
+ unlockLevel: 52,
+ rarity: 'epic',
+ zIndex: 7,
+ anchor: 'top',
+ offset: { x: 0, y: -25 },
+ scale: 1.0,
+ hasEffect: true,
+ description: 'An angelic golden ring of light'
+ },
+ {
+ id: 'top-hat',
+ name: 'Top Hat',
+ category: 'headwear',
+ unlockLevel: 56,
+ rarity: 'epic',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -28 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'Dapper and distinguished'
+ },
+ {
+ id: 'crown-gold',
+ name: 'Gold Crown',
+ category: 'headwear',
+ unlockLevel: 60,
+ rarity: 'epic',
+ zIndex: 10,
+ anchor: 'top',
+ offset: { x: 0, y: -18 },
+ scale: 1.1,
+ hasEffect: false,
+ description: 'True royalty in golden splendor'
+ },
+ {
+ id: 'viking-helmet',
+ name: 'Viking Helmet',
+ category: 'headwear',
+ unlockLevel: 62,
+ rarity: 'epic',
+ zIndex: 9,
+ anchor: 'top',
+ offset: { x: 0, y: -16 },
+ scale: 1.2,
+ hasEffect: false,
+ description: 'Conquer games like a Norse warrior'
+ },
+ {
+ id: 'crown-diamond',
+ name: 'Diamond Crown',
+ category: 'headwear',
+ unlockLevel: 80,
+ rarity: 'legendary',
+ zIndex: 10,
+ anchor: 'top',
+ offset: { x: 0, y: -20 },
+ scale: 1.2,
+ hasEffect: true,
+ description: 'A magnificent crown of pure diamonds'
+ },
+ {
+ id: 'crown-cosmic',
+ name: 'Cosmic Crown',
+ category: 'headwear',
+ unlockLevel: 100,
+ rarity: 'mythic',
+ zIndex: 11,
+ anchor: 'top',
+ offset: { x: 0, y: -22 },
+ scale: 1.3,
+ hasEffect: true,
+ description: 'The ultimate crown, forged from stardust'
+ }
+ ];
+
+ // =============================================================================
+ // EFFECTS/AURAS (10 items)
+ // =============================================================================
+ var EFFECTS = [
+ {
+ id: 'sparkle-trail',
+ name: 'Sparkle Trail',
+ category: 'effect',
+ unlockLevel: 15,
+ rarity: 'uncommon',
+ zIndex: 1,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.0,
+ hasEffect: true,
+ effectType: 'particles',
+ color: '#FFD700',
+ description: 'Leave a trail of golden sparkles'
+ },
+ {
+ id: 'fire-aura',
+ name: 'Fire Aura',
+ category: 'effect',
+ unlockLevel: 25,
+ rarity: 'rare',
+ zIndex: 0,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.2,
+ hasEffect: true,
+ effectType: 'glow',
+ color: '#FF4500',
+ description: 'Surrounded by fierce flames'
+ },
+ {
+ id: 'lightning-crackle',
+ name: 'Lightning Crackle',
+ category: 'effect',
+ unlockLevel: 35,
+ rarity: 'rare',
+ zIndex: 2,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.1,
+ hasEffect: true,
+ effectType: 'electric',
+ color: '#00BFFF',
+ description: 'Electricity crackles around you'
+ },
+ {
+ id: 'rainbow-glow',
+ name: 'Rainbow Glow',
+ category: 'effect',
+ unlockLevel: 45,
+ rarity: 'epic',
+ zIndex: 0,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.3,
+ hasEffect: true,
+ effectType: 'glow',
+ color: 'rainbow',
+ description: 'Radiate all colors of the spectrum'
+ },
+ {
+ id: 'star-particles',
+ name: 'Star Particles',
+ category: 'effect',
+ unlockLevel: 55,
+ rarity: 'epic',
+ zIndex: 2,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.2,
+ hasEffect: true,
+ effectType: 'particles',
+ color: '#FFFF00',
+ description: 'Tiny stars orbit around you'
+ },
+ {
+ id: 'ice-crystals',
+ name: 'Ice Crystals',
+ category: 'effect',
+ unlockLevel: 65,
+ rarity: 'legendary',
+ zIndex: 2,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.2,
+ hasEffect: true,
+ effectType: 'particles',
+ color: '#87CEEB',
+ description: 'Frozen crystals shimmer around you'
+ },
+ {
+ id: 'shadow-effect',
+ name: 'Shadow Effect',
+ category: 'effect',
+ unlockLevel: 70,
+ rarity: 'legendary',
+ zIndex: -1,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.4,
+ hasEffect: true,
+ effectType: 'shadow',
+ color: '#2F2F4F',
+ description: 'Darkness follows your every move'
+ },
+ {
+ id: 'golden-glow',
+ name: 'Golden Glow',
+ category: 'effect',
+ unlockLevel: 75,
+ rarity: 'legendary',
+ zIndex: 0,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.3,
+ hasEffect: true,
+ effectType: 'glow',
+ color: '#FFD700',
+ description: 'Bathe in pure golden light'
+ },
+ {
+ id: 'void-aura',
+ name: 'Void Aura',
+ category: 'effect',
+ unlockLevel: 85,
+ rarity: 'mythic',
+ zIndex: -1,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.5,
+ hasEffect: true,
+ effectType: 'void',
+ color: '#4B0082',
+ description: 'The void itself surrounds you'
+ },
+ {
+ id: 'cosmic-trail',
+ name: 'Cosmic Trail',
+ category: 'effect',
+ unlockLevel: 95,
+ rarity: 'mythic',
+ zIndex: 1,
+ anchor: 'head',
+ offset: { x: 0, y: 0 },
+ scale: 1.4,
+ hasEffect: true,
+ effectType: 'cosmic',
+ color: 'cosmic',
+ description: 'Trail galaxies and nebulae in your wake'
+ }
+ ];
+
+ // =============================================================================
+ // HELPER FUNCTIONS
+ // =============================================================================
+
+ /**
+ * Get all accessories combined into a single array
+ */
+ function getAll() {
+ return [].concat(EYEWEAR, HEADWEAR, EFFECTS);
+ }
+
+ /**
+ * Get accessories filtered by category
+ * @param {string} category - 'eyewear', 'headwear', or 'effect'
+ */
+ function getByCategory(category) {
+ switch (category) {
+ case 'eyewear':
+ return EYEWEAR.slice();
+ case 'headwear':
+ return HEADWEAR.slice();
+ case 'effect':
+ return EFFECTS.slice();
+ default:
+ return [];
+ }
+ }
+
+ /**
+ * Find an accessory by its unique ID
+ * @param {string} id - The accessory ID
+ */
+ function getById(id) {
+ var all = getAll();
+ for (var i = 0; i < all.length; i++) {
+ if (all[i].id === id) {
+ return all[i];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get all accessories of a specific rarity
+ * @param {string} rarity - 'common', 'uncommon', 'rare', 'epic', 'legendary', or 'mythic'
+ */
+ function getByRarity(rarity) {
+ return getAll().filter(function(item) {
+ return item.rarity === rarity;
+ });
+ }
+
+ /**
+ * Get all accessories unlockable at or before the given level
+ * @param {number} level - The player's current level
+ */
+ function getUnlockedAtLevel(level) {
+ return getAll().filter(function(item) {
+ return item.unlockLevel <= level;
+ });
+ }
+
+ /**
+ * Get accessories that unlock exactly at the specified level
+ * @param {number} level - The level to check
+ */
+ function getNewlyUnlockedAtLevel(level) {
+ return getAll().filter(function(item) {
+ return item.unlockLevel === level;
+ });
+ }
+
+ /**
+ * Get the color associated with a rarity
+ * @param {string} rarity - The rarity level
+ */
+ function getRarityColor(rarity) {
+ return RARITY_COLORS[rarity] || RARITY_COLORS.common;
+ }
+
+ /**
+ * Get an organized catalog for UI display
+ * Returns accessories grouped by category and sorted by unlock level
+ */
+ function getCatalog() {
+ var sortByLevel = function(a, b) {
+ return a.unlockLevel - b.unlockLevel;
+ };
+
+ return {
+ eyewear: EYEWEAR.slice().sort(sortByLevel),
+ headwear: HEADWEAR.slice().sort(sortByLevel),
+ effects: EFFECTS.slice().sort(sortByLevel),
+ totalCount: EYEWEAR.length + HEADWEAR.length + EFFECTS.length,
+ rarityColors: Object.assign({}, RARITY_COLORS)
+ };
+ }
+
+ // =============================================================================
+ // PUBLIC API
+ // =============================================================================
+ window.AvatarAccessories = {
+ // Data arrays
+ EYEWEAR: EYEWEAR,
+ HEADWEAR: HEADWEAR,
+ EFFECTS: EFFECTS,
+ RARITY_COLORS: RARITY_COLORS,
+
+ // Methods
+ getAll: getAll,
+ getByCategory: getByCategory,
+ getById: getById,
+ getByRarity: getByRarity,
+ getUnlockedAtLevel: getUnlockedAtLevel,
+ getNewlyUnlockedAtLevel: getNewlyUnlockedAtLevel,
+ getRarityColor: getRarityColor,
+ getCatalog: getCatalog
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/accessoryRenderer.js b/frontend/public/assets/avatar/accessoryRenderer.js
new file mode 100644
index 0000000..7b10596
--- /dev/null
+++ b/frontend/public/assets/avatar/accessoryRenderer.js
@@ -0,0 +1,1571 @@
+/**
+ * AccessoryRenderer - Renders unlockable accessories on avatars
+ * Depends on: AvatarAccessories, AvatarRenderer
+ */
+(function() {
+ 'use strict';
+
+ // Z-order constants for rendering layers
+ var Z_ORDER = {
+ BEHIND_HEAD: -10,
+ BEHIND_HAIR: -5,
+ ON_FACE: 10,
+ ON_HEAD: 20,
+ ABOVE_HEAD: 30,
+ EFFECTS: 100
+ };
+
+ // Cached particle state per avatar
+ var particleCache = new Map();
+
+ // ============================================================================
+ // UTILITY FUNCTIONS
+ // ============================================================================
+
+ function roundRect(ctx, x, y, w, h, r) {
+ if (w < 2 * r) r = w / 2;
+ if (h < 2 * r) r = h / 2;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ function darken(color, amount) {
+ var hex = color.replace('#', '');
+ var r = parseInt(hex.substr(0, 2), 16);
+ var g = parseInt(hex.substr(2, 2), 16);
+ var b = parseInt(hex.substr(4, 2), 16);
+ r = Math.max(0, Math.floor(r * (1 - amount)));
+ g = Math.max(0, Math.floor(g * (1 - amount)));
+ b = Math.max(0, Math.floor(b * (1 - amount)));
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
+ }
+
+ function lighten(color, amount) {
+ var hex = color.replace('#', '');
+ var r = parseInt(hex.substr(0, 2), 16);
+ var g = parseInt(hex.substr(2, 2), 16);
+ var b = parseInt(hex.substr(4, 2), 16);
+ r = Math.min(255, Math.floor(r + (255 - r) * amount));
+ g = Math.min(255, Math.floor(g + (255 - g) * amount));
+ b = Math.min(255, Math.floor(b + (255 - b) * amount));
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
+ }
+
+ function drawStar(ctx, cx, cy, r, points) {
+ ctx.beginPath();
+ for (var i = 0; i < points * 2; i++) {
+ var angle = (i * Math.PI) / points - Math.PI / 2;
+ var radius = i % 2 === 0 ? r : r * 0.5;
+ var x = cx + Math.cos(angle) * radius;
+ var y = cy + Math.sin(angle) * radius;
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ function drawHeart(ctx, cx, cy, size) {
+ ctx.beginPath();
+ ctx.moveTo(cx, cy + size * 0.3);
+ ctx.bezierCurveTo(cx - size * 0.5, cy - size * 0.3, cx - size, cy + size * 0.3, cx, cy + size);
+ ctx.bezierCurveTo(cx + size, cy + size * 0.3, cx + size * 0.5, cy - size * 0.3, cx, cy + size * 0.3);
+ ctx.fill();
+ }
+
+ function drawLightningBolt(ctx, x1, y1, x2, y2, scale) {
+ var midX = (x1 + x2) / 2 + (Math.random() - 0.5) * 20 * scale;
+ var midY = (y1 + y2) / 2;
+
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 3 * scale;
+ ctx.shadowColor = '#00bfff';
+ ctx.shadowBlur = 10;
+
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(midX + 5 * scale, midY - 5 * scale);
+ ctx.lineTo(midX - 5 * scale, midY + 5 * scale);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+
+ ctx.shadowBlur = 0;
+ }
+
+ // ============================================================================
+ // CROWN HELPER
+ // ============================================================================
+
+ function drawCrown(ctx, x, y, scale, color, points, hasGems) {
+ var w = 30 * scale;
+ var h = 15 * scale;
+ var baseY = y - 5 * scale;
+
+ // Crown base
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(x - w / 2, baseY);
+
+ // Points
+ for (var i = 0; i < points; i++) {
+ var px = x - w / 2 + (i + 0.5) * (w / points);
+ var tipY = baseY - h - (i === Math.floor(points / 2) ? 5 * scale : 0);
+ ctx.lineTo(px - 3 * scale, baseY - 5 * scale);
+ ctx.lineTo(px, tipY);
+ ctx.lineTo(px + 3 * scale, baseY - 5 * scale);
+ }
+
+ ctx.lineTo(x + w / 2, baseY);
+ ctx.closePath();
+ ctx.fill();
+
+ // Rim
+ ctx.fillStyle = darken(color, 0.15);
+ roundRect(ctx, x - w / 2, baseY, w, 4 * scale, 1);
+
+ // Gems
+ if (hasGems) {
+ var gemColors = ['#e74c3c', '#3498db', '#2ecc71'];
+ for (var g = 0; g < Math.min(points, 3); g++) {
+ var gx = x - w / 3 + g * (w / 3);
+ ctx.fillStyle = gemColors[g];
+ ctx.beginPath();
+ ctx.arc(gx, baseY - 3 * scale, 2.5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ }
+
+ function drawSparkles(ctx, x, y, scale, time) {
+ var sparkleCount = 5;
+ for (var i = 0; i < sparkleCount; i++) {
+ var angle = (i / sparkleCount) * Math.PI * 2 + time;
+ var dist = 8 * scale + Math.sin(time * 3 + i) * 3 * scale;
+ var sx = x + Math.cos(angle) * dist;
+ var sy = y + Math.sin(angle) * dist;
+ var alpha = 0.5 + Math.sin(time * 4 + i * 2) * 0.5;
+ ctx.fillStyle = 'rgba(255,255,255,' + alpha + ')';
+ drawStar(ctx, sx, sy, 2 * scale, 4);
+ }
+ }
+
+ function drawCosmicEffect(ctx, x, y, scale, time) {
+ // Swirling stars
+ for (var i = 0; i < 8; i++) {
+ var angle = (i / 8) * Math.PI * 2 + time * 0.5;
+ var dist = 12 * scale + Math.sin(time * 2 + i) * 4 * scale;
+ var sx = x + Math.cos(angle) * dist;
+ var sy = y + Math.sin(angle) * dist * 0.5;
+ var hue = (time * 30 + i * 45) % 360;
+ ctx.fillStyle = 'hsla(' + hue + ',100%,70%,0.8)';
+ drawStar(ctx, sx, sy, 2.5 * scale, 4);
+ }
+
+ // Central glow
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 15 * scale);
+ gradient.addColorStop(0, 'rgba(155,89,182,0.3)');
+ gradient.addColorStop(1, 'rgba(155,89,182,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 15 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // ============================================================================
+ // PARTICLE SYSTEMS
+ // ============================================================================
+
+ function getSparkleParticles(time, count) {
+ var particles = [];
+ for (var i = 0; i < count; i++) {
+ var seed = i * 137.5;
+ var lifetime = (time + seed) % 2;
+ particles.push({
+ x: Math.sin(seed) * 30 + Math.sin(time + seed) * 10,
+ y: -lifetime * 40 + 20,
+ alpha: 1 - lifetime / 2,
+ size: 0.5 + Math.sin(seed) * 0.5
+ });
+ }
+ return particles;
+ }
+
+ function getFlameParticles(time, count) {
+ var particles = [];
+ for (var i = 0; i < count; i++) {
+ var seed = i * 97.3;
+ var lifetime = (time * 2 + seed) % 1;
+ particles.push({
+ x: Math.sin(seed) * 15 + Math.sin(time * 3 + seed) * 5,
+ y: -lifetime * 50,
+ alpha: (1 - lifetime) * 0.6,
+ size: (1 - lifetime) * 1.5,
+ hue: 30 + Math.sin(seed) * 20
+ });
+ }
+ return particles;
+ }
+
+ function getBubbleParticles(time, count) {
+ var particles = [];
+ for (var i = 0; i < count; i++) {
+ var seed = i * 73.7;
+ var lifetime = (time * 0.8 + seed) % 3;
+ particles.push({
+ x: Math.sin(seed) * 25 + Math.sin(time * 0.5 + seed) * 8,
+ y: -lifetime * 30,
+ alpha: (1 - lifetime / 3) * 0.4,
+ size: 3 + Math.sin(seed) * 2
+ });
+ }
+ return particles;
+ }
+
+ function getSnowParticles(time, count) {
+ var particles = [];
+ for (var i = 0; i < count; i++) {
+ var seed = i * 53.1;
+ var lifetime = (time * 0.5 + seed) % 4;
+ particles.push({
+ x: Math.sin(seed) * 35 + Math.sin(time + seed) * 10,
+ y: lifetime * 25 - 30,
+ alpha: 0.7,
+ size: 2 + Math.sin(seed) * 1.5
+ });
+ }
+ return particles;
+ }
+
+ function getMusicNotes(time, count) {
+ var notes = [];
+ for (var i = 0; i < count; i++) {
+ var seed = i * 127.3;
+ var lifetime = (time + seed) % 2.5;
+ notes.push({
+ x: Math.sin(seed) * 30 + Math.sin(time * 2 + seed) * 15,
+ y: -lifetime * 35 + 10,
+ alpha: 1 - lifetime / 2.5,
+ rotation: Math.sin(time * 3 + seed) * 0.3
+ });
+ }
+ return notes;
+ }
+
+ // ============================================================================
+ // EYEWEAR RENDERERS (15 items)
+ // ============================================================================
+
+ var EYEWEAR_RENDERERS = {
+ 'sunglasses-classic': function(ctx, x, y, scale, color, time) {
+ // Black rectangular frames with dark lenses
+ ctx.fillStyle = '#222';
+ roundRect(ctx, x - 20 * scale, y, 15 * scale, 8 * scale, 2);
+ roundRect(ctx, x + 5 * scale, y, 15 * scale, 8 * scale, 2);
+ // Bridge
+ ctx.fillRect(x - 5 * scale, y + 2 * scale, 10 * scale, 3 * scale);
+ // Lens tint
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
+ roundRect(ctx, x - 18 * scale, y + 2 * scale, 11 * scale, 4 * scale, 1);
+ roundRect(ctx, x + 7 * scale, y + 2 * scale, 11 * scale, 4 * scale, 1);
+ },
+
+ 'aviator': function(ctx, x, y, scale, color, time) {
+ // Gold frames
+ ctx.strokeStyle = '#d4af37';
+ ctx.lineWidth = 2 * scale;
+
+ // Left lens (teardrop)
+ ctx.beginPath();
+ ctx.moveTo(x - 18 * scale, y);
+ ctx.quadraticCurveTo(x - 22 * scale, y + 5 * scale, x - 18 * scale, y + 10 * scale);
+ ctx.quadraticCurveTo(x - 10 * scale, y + 12 * scale, x - 5 * scale, y + 8 * scale);
+ ctx.quadraticCurveTo(x - 3 * scale, y + 2 * scale, x - 8 * scale, y);
+ ctx.closePath();
+ ctx.stroke();
+
+ // Left lens fill (gradient)
+ var gradL = ctx.createLinearGradient(x - 20 * scale, y, x - 5 * scale, y + 10 * scale);
+ gradL.addColorStop(0, 'rgba(139,90,43,0.6)');
+ gradL.addColorStop(1, 'rgba(80,50,20,0.8)');
+ ctx.fillStyle = gradL;
+ ctx.fill();
+
+ // Right lens (teardrop)
+ ctx.beginPath();
+ ctx.moveTo(x + 18 * scale, y);
+ ctx.quadraticCurveTo(x + 22 * scale, y + 5 * scale, x + 18 * scale, y + 10 * scale);
+ ctx.quadraticCurveTo(x + 10 * scale, y + 12 * scale, x + 5 * scale, y + 8 * scale);
+ ctx.quadraticCurveTo(x + 3 * scale, y + 2 * scale, x + 8 * scale, y);
+ ctx.closePath();
+ ctx.stroke();
+
+ // Right lens fill
+ var gradR = ctx.createLinearGradient(x + 5 * scale, y, x + 20 * scale, y + 10 * scale);
+ gradR.addColorStop(0, 'rgba(139,90,43,0.6)');
+ gradR.addColorStop(1, 'rgba(80,50,20,0.8)');
+ ctx.fillStyle = gradR;
+ ctx.fill();
+
+ // Bridge
+ ctx.strokeStyle = '#d4af37';
+ ctx.beginPath();
+ ctx.moveTo(x - 5 * scale, y + 4 * scale);
+ ctx.quadraticCurveTo(x, y + 2 * scale, x + 5 * scale, y + 4 * scale);
+ ctx.stroke();
+ },
+
+ 'nerd-glasses': function(ctx, x, y, scale, color, time) {
+ // Thick black circular frames
+ ctx.strokeStyle = '#111';
+ ctx.lineWidth = 3 * scale;
+
+ // Left lens
+ ctx.beginPath();
+ ctx.arc(x - 12 * scale, y + 4 * scale, 10 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Right lens
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y + 4 * scale, 10 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Bridge
+ ctx.beginPath();
+ ctx.moveTo(x - 2 * scale, y + 4 * scale);
+ ctx.lineTo(x + 2 * scale, y + 4 * scale);
+ ctx.stroke();
+
+ // Clear lenses with slight reflection
+ ctx.fillStyle = 'rgba(255,255,255,0.1)';
+ ctx.beginPath();
+ ctx.arc(x - 12 * scale, y + 4 * scale, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y + 4 * scale, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Reflection glint
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.beginPath();
+ ctx.arc(x - 15 * scale, y + 1 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 9 * scale, y + 1 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ '3d-glasses': function(ctx, x, y, scale, color, time) {
+ // White frame
+ ctx.fillStyle = '#fff';
+ roundRect(ctx, x - 24 * scale, y - 1 * scale, 48 * scale, 12 * scale, 2);
+
+ // Frame outline
+ ctx.strokeStyle = '#ccc';
+ ctx.lineWidth = 1 * scale;
+ ctx.stroke();
+
+ // Left lens (red)
+ ctx.fillStyle = 'rgba(255,0,0,0.7)';
+ roundRect(ctx, x - 22 * scale, y + 1 * scale, 18 * scale, 8 * scale, 1);
+
+ // Right lens (blue/cyan)
+ ctx.fillStyle = 'rgba(0,200,255,0.7)';
+ roundRect(ctx, x + 4 * scale, y + 1 * scale, 18 * scale, 8 * scale, 1);
+ },
+
+ 'goggles': function(ctx, x, y, scale, color, time) {
+ // Strap
+ ctx.fillStyle = '#444';
+ ctx.fillRect(x - 35 * scale, y + 2 * scale, 70 * scale, 4 * scale);
+
+ // Goggle frames
+ ctx.fillStyle = '#2c3e50';
+ ctx.beginPath();
+ ctx.ellipse(x - 12 * scale, y + 4 * scale, 14 * scale, 10 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(x + 12 * scale, y + 4 * scale, 14 * scale, 10 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Lens
+ ctx.fillStyle = 'rgba(100,200,255,0.6)';
+ ctx.beginPath();
+ ctx.ellipse(x - 12 * scale, y + 4 * scale, 11 * scale, 7 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(x + 12 * scale, y + 4 * scale, 11 * scale, 7 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Reflection
+ ctx.fillStyle = 'rgba(255,255,255,0.4)';
+ ctx.beginPath();
+ ctx.ellipse(x - 15 * scale, y + 1 * scale, 4 * scale, 2 * scale, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(x + 9 * scale, y + 1 * scale, 4 * scale, 2 * scale, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'monocle': function(ctx, x, y, scale, color, time) {
+ // Monocle on right eye
+ ctx.strokeStyle = '#d4af37';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y + 4 * scale, 10 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Glass
+ ctx.fillStyle = 'rgba(255,255,255,0.15)';
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y + 4 * scale, 9 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Chain
+ ctx.strokeStyle = '#d4af37';
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.moveTo(x + 22 * scale, y + 4 * scale);
+ ctx.quadraticCurveTo(x + 30 * scale, y + 15 * scale, x + 25 * scale, y + 30 * scale);
+ ctx.stroke();
+
+ // Chain end detail
+ ctx.fillStyle = '#d4af37';
+ ctx.beginPath();
+ ctx.arc(x + 25 * scale, y + 30 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'star-glasses': function(ctx, x, y, scale, color, time) {
+ var frameColor = color || '#e91e63';
+
+ // Left star frame
+ ctx.fillStyle = frameColor;
+ drawStar(ctx, x - 12 * scale, y + 4 * scale, 12 * scale, 5);
+
+ // Right star frame
+ drawStar(ctx, x + 12 * scale, y + 4 * scale, 12 * scale, 5);
+
+ // Bridge
+ ctx.fillRect(x - 3 * scale, y + 3 * scale, 6 * scale, 3 * scale);
+
+ // Lens tint
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ drawStar(ctx, x - 12 * scale, y + 4 * scale, 8 * scale, 5);
+ drawStar(ctx, x + 12 * scale, y + 4 * scale, 8 * scale, 5);
+ },
+
+ 'laser-eyes': function(ctx, x, y, scale, color, time) {
+ var pulse = 0.7 + Math.sin(time * 8) * 0.3;
+
+ // Glowing red eyes
+ var eyeGlow = ctx.createRadialGradient(x - 10 * scale, y + 4 * scale, 0, x - 10 * scale, y + 4 * scale, 8 * scale);
+ eyeGlow.addColorStop(0, 'rgba(255,0,0,' + pulse + ')');
+ eyeGlow.addColorStop(0.5, 'rgba(255,50,0,' + (pulse * 0.5) + ')');
+ eyeGlow.addColorStop(1, 'rgba(255,0,0,0)');
+ ctx.fillStyle = eyeGlow;
+ ctx.beginPath();
+ ctx.arc(x - 10 * scale, y + 4 * scale, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ var eyeGlow2 = ctx.createRadialGradient(x + 10 * scale, y + 4 * scale, 0, x + 10 * scale, y + 4 * scale, 8 * scale);
+ eyeGlow2.addColorStop(0, 'rgba(255,0,0,' + pulse + ')');
+ eyeGlow2.addColorStop(0.5, 'rgba(255,50,0,' + (pulse * 0.5) + ')');
+ eyeGlow2.addColorStop(1, 'rgba(255,0,0,0)');
+ ctx.fillStyle = eyeGlow2;
+ ctx.beginPath();
+ ctx.arc(x + 10 * scale, y + 4 * scale, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Core bright spots
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(x - 10 * scale, y + 4 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 10 * scale, y + 4 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'heart-glasses': function(ctx, x, y, scale, color, time) {
+ var frameColor = color || '#e91e63';
+
+ // Left heart
+ ctx.fillStyle = frameColor;
+ ctx.save();
+ ctx.translate(x - 12 * scale, y + 6 * scale);
+ ctx.scale(scale, scale);
+ ctx.beginPath();
+ ctx.moveTo(0, -5);
+ ctx.bezierCurveTo(-10, -15, -20, 0, 0, 12);
+ ctx.bezierCurveTo(20, 0, 10, -15, 0, -5);
+ ctx.fill();
+ ctx.restore();
+
+ // Right heart
+ ctx.save();
+ ctx.translate(x + 12 * scale, y + 6 * scale);
+ ctx.scale(scale, scale);
+ ctx.beginPath();
+ ctx.moveTo(0, -5);
+ ctx.bezierCurveTo(-10, -15, -20, 0, 0, 12);
+ ctx.bezierCurveTo(20, 0, 10, -15, 0, -5);
+ ctx.fill();
+ ctx.restore();
+
+ // Bridge
+ ctx.fillRect(x - 3 * scale, y + 3 * scale, 6 * scale, 3 * scale);
+
+ // Lens tint
+ ctx.fillStyle = 'rgba(255,100,150,0.5)';
+ ctx.save();
+ ctx.translate(x - 12 * scale, y + 6 * scale);
+ ctx.scale(scale * 0.7, scale * 0.7);
+ ctx.beginPath();
+ ctx.moveTo(0, -5);
+ ctx.bezierCurveTo(-10, -15, -20, 0, 0, 12);
+ ctx.bezierCurveTo(20, 0, 10, -15, 0, -5);
+ ctx.fill();
+ ctx.restore();
+ ctx.save();
+ ctx.translate(x + 12 * scale, y + 6 * scale);
+ ctx.scale(scale * 0.7, scale * 0.7);
+ ctx.beginPath();
+ ctx.moveTo(0, -5);
+ ctx.bezierCurveTo(-10, -15, -20, 0, 0, 12);
+ ctx.bezierCurveTo(20, 0, 10, -15, 0, -5);
+ ctx.fill();
+ ctx.restore();
+ },
+
+ 'vr-headset': function(ctx, x, y, scale, color, time) {
+ // Main visor
+ ctx.fillStyle = '#1a1a2e';
+ roundRect(ctx, x - 28 * scale, y - 4 * scale, 56 * scale, 20 * scale, 4 * scale);
+
+ // Visor screen
+ var screenGlow = ctx.createLinearGradient(x - 24 * scale, y, x + 24 * scale, y);
+ screenGlow.addColorStop(0, 'rgba(0,150,255,0.4)');
+ screenGlow.addColorStop(0.5, 'rgba(0,200,255,0.6)');
+ screenGlow.addColorStop(1, 'rgba(0,150,255,0.4)');
+ ctx.fillStyle = screenGlow;
+ roundRect(ctx, x - 24 * scale, y - 1 * scale, 48 * scale, 14 * scale, 2 * scale);
+
+ // Side lights
+ var blink = Math.sin(time * 3) > 0 ? 1 : 0.3;
+ ctx.fillStyle = 'rgba(0,255,100,' + blink + ')';
+ ctx.beginPath();
+ ctx.arc(x - 25 * scale, y + 6 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 25 * scale, y + 6 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Strap hints
+ ctx.fillStyle = '#333';
+ ctx.fillRect(x - 32 * scale, y + 2 * scale, 4 * scale, 8 * scale);
+ ctx.fillRect(x + 28 * scale, y + 2 * scale, 4 * scale, 8 * scale);
+ },
+
+ 'pixel-glasses': function(ctx, x, y, scale, color, time) {
+ var pixelSize = 3 * scale;
+ ctx.fillStyle = '#000';
+
+ // Left lens (pixelated rectangle)
+ for (var py = 0; py < 3; py++) {
+ for (var px = 0; px < 5; px++) {
+ ctx.fillRect(
+ x - 22 * scale + px * pixelSize,
+ y + py * pixelSize,
+ pixelSize - 0.5,
+ pixelSize - 0.5
+ );
+ }
+ }
+
+ // Right lens
+ for (var py2 = 0; py2 < 3; py2++) {
+ for (var px2 = 0; px2 < 5; px2++) {
+ ctx.fillRect(
+ x + 4 * scale + px2 * pixelSize,
+ y + py2 * pixelSize,
+ pixelSize - 0.5,
+ pixelSize - 0.5
+ );
+ }
+ }
+
+ // Bridge
+ ctx.fillRect(x - 4 * scale, y + pixelSize, 8 * scale, pixelSize);
+ },
+
+ 'round-glasses': function(ctx, x, y, scale, color, time) {
+ var frameColor = color || '#8b4513';
+
+ // Left frame
+ ctx.strokeStyle = frameColor;
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.arc(x - 12 * scale, y + 4 * scale, 9 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Right frame
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y + 4 * scale, 9 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Bridge
+ ctx.beginPath();
+ ctx.moveTo(x - 3 * scale, y + 4 * scale);
+ ctx.lineTo(x + 3 * scale, y + 4 * scale);
+ ctx.stroke();
+
+ // Slight lens tint
+ ctx.fillStyle = 'rgba(255,255,255,0.1)';
+ ctx.beginPath();
+ ctx.arc(x - 12 * scale, y + 4 * scale, 7 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y + 4 * scale, 7 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'cat-eye-glasses': function(ctx, x, y, scale, color, time) {
+ var frameColor = color || '#9b59b6';
+
+ ctx.fillStyle = frameColor;
+
+ // Left cat-eye shape
+ ctx.beginPath();
+ ctx.moveTo(x - 22 * scale, y + 2 * scale);
+ ctx.lineTo(x - 18 * scale, y - 3 * scale);
+ ctx.quadraticCurveTo(x - 10 * scale, y - 2 * scale, x - 5 * scale, y + 4 * scale);
+ ctx.quadraticCurveTo(x - 10 * scale, y + 12 * scale, x - 18 * scale, y + 10 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Right cat-eye shape
+ ctx.beginPath();
+ ctx.moveTo(x + 22 * scale, y + 2 * scale);
+ ctx.lineTo(x + 18 * scale, y - 3 * scale);
+ ctx.quadraticCurveTo(x + 10 * scale, y - 2 * scale, x + 5 * scale, y + 4 * scale);
+ ctx.quadraticCurveTo(x + 10 * scale, y + 12 * scale, x + 18 * scale, y + 10 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Bridge
+ ctx.fillRect(x - 5 * scale, y + 3 * scale, 10 * scale, 3 * scale);
+
+ // Lens tint
+ ctx.fillStyle = 'rgba(0,0,0,0.6)';
+ ctx.beginPath();
+ ctx.moveTo(x - 20 * scale, y + 3 * scale);
+ ctx.quadraticCurveTo(x - 12 * scale, y, x - 7 * scale, y + 4 * scale);
+ ctx.quadraticCurveTo(x - 12 * scale, y + 9 * scale, x - 17 * scale, y + 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(x + 20 * scale, y + 3 * scale);
+ ctx.quadraticCurveTo(x + 12 * scale, y, x + 7 * scale, y + 4 * scale);
+ ctx.quadraticCurveTo(x + 12 * scale, y + 9 * scale, x + 17 * scale, y + 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+ },
+
+ 'cyber-visor': function(ctx, x, y, scale, color, time) {
+ // Sleek futuristic visor
+ ctx.fillStyle = '#1a1a2e';
+ ctx.beginPath();
+ ctx.moveTo(x - 28 * scale, y + 6 * scale);
+ ctx.quadraticCurveTo(x, y - 2 * scale, x + 28 * scale, y + 6 * scale);
+ ctx.quadraticCurveTo(x, y + 14 * scale, x - 28 * scale, y + 6 * scale);
+ ctx.fill();
+
+ // Visor glow
+ var scanLine = (time * 50) % 50 - 25;
+ var visorGlow = ctx.createLinearGradient(x - 25 * scale, y, x + 25 * scale, y);
+ visorGlow.addColorStop(0, 'rgba(0,255,255,0.3)');
+ visorGlow.addColorStop(0.5, 'rgba(0,255,255,0.7)');
+ visorGlow.addColorStop(1, 'rgba(0,255,255,0.3)');
+ ctx.fillStyle = visorGlow;
+ ctx.beginPath();
+ ctx.moveTo(x - 24 * scale, y + 6 * scale);
+ ctx.quadraticCurveTo(x, y, x + 24 * scale, y + 6 * scale);
+ ctx.quadraticCurveTo(x, y + 12 * scale, x - 24 * scale, y + 6 * scale);
+ ctx.fill();
+
+ // Scan line effect
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.fillRect(x + scanLine * scale, y + 2 * scale, 2 * scale, 8 * scale);
+ }
+ };
+
+ // ============================================================================
+ // HEADWEAR RENDERERS (20 items)
+ // ============================================================================
+
+ var HEADWEAR_RENDERERS = {
+ 'baseball-cap': function(ctx, x, y, scale, color, time) {
+ var capColor = color || '#c0392b';
+
+ // Cap dome
+ ctx.fillStyle = capColor;
+ ctx.beginPath();
+ ctx.arc(x, y - 5 * scale, 22 * scale, Math.PI, 0, false);
+ ctx.fill();
+
+ // Bill
+ ctx.fillStyle = darken(capColor, 0.2);
+ ctx.beginPath();
+ ctx.ellipse(x, y + 2 * scale, 25 * scale, 6 * scale, 0, 0, Math.PI);
+ ctx.fill();
+
+ // Button on top
+ ctx.fillStyle = darken(capColor, 0.3);
+ ctx.beginPath();
+ ctx.arc(x, y - 26 * scale, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'backwards-cap': function(ctx, x, y, scale, color, time) {
+ var capColor = color || '#3498db';
+
+ // Cap dome
+ ctx.fillStyle = capColor;
+ ctx.beginPath();
+ ctx.arc(x, y - 5 * scale, 22 * scale, Math.PI, 0, false);
+ ctx.fill();
+
+ // Bill (in back)
+ ctx.fillStyle = darken(capColor, 0.2);
+ ctx.beginPath();
+ ctx.ellipse(x, y - 12 * scale, 20 * scale, 5 * scale, 0, Math.PI, 0);
+ ctx.fill();
+
+ // Adjustment strap hint
+ ctx.fillStyle = darken(capColor, 0.3);
+ ctx.fillRect(x - 8 * scale, y - 2 * scale, 16 * scale, 3 * scale);
+
+ // Button
+ ctx.beginPath();
+ ctx.arc(x, y - 26 * scale, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'beanie': function(ctx, x, y, scale, color, time) {
+ var beanieColor = color || '#2c3e50';
+
+ // Main beanie
+ ctx.fillStyle = beanieColor;
+ ctx.beginPath();
+ ctx.moveTo(x - 24 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x - 26 * scale, y - 20 * scale, x, y - 28 * scale);
+ ctx.quadraticCurveTo(x + 26 * scale, y - 20 * scale, x + 24 * scale, y + 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Fold at bottom
+ ctx.fillStyle = darken(beanieColor, 0.15);
+ ctx.beginPath();
+ ctx.moveTo(x - 24 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x, y + 10 * scale, x + 24 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x, y, x - 24 * scale, y + 5 * scale);
+ ctx.fill();
+
+ // Knit texture lines
+ ctx.strokeStyle = lighten(beanieColor, 0.1);
+ ctx.lineWidth = 1 * scale;
+ for (var i = -2; i <= 2; i++) {
+ ctx.beginPath();
+ ctx.moveTo(x + i * 8 * scale, y + 3 * scale);
+ ctx.quadraticCurveTo(x + i * 6 * scale, y - 15 * scale, x + i * 4 * scale, y - 25 * scale);
+ ctx.stroke();
+ }
+ },
+
+ 'top-hat': function(ctx, x, y, scale, color, time) {
+ // Tall black top hat
+ ctx.fillStyle = '#111';
+
+ // Main cylinder
+ ctx.beginPath();
+ ctx.rect(x - 15 * scale, y - 40 * scale, 30 * scale, 35 * scale);
+ ctx.fill();
+
+ // Top ellipse
+ ctx.beginPath();
+ ctx.ellipse(x, y - 40 * scale, 15 * scale, 5 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Brim
+ ctx.fillStyle = '#1a1a1a';
+ ctx.beginPath();
+ ctx.ellipse(x, y - 5 * scale, 25 * scale, 7 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Band
+ ctx.fillStyle = '#8b0000';
+ ctx.fillRect(x - 15 * scale, y - 12 * scale, 30 * scale, 5 * scale);
+ },
+
+ 'crown-bronze': function(ctx, x, y, scale, color, time) {
+ drawCrown(ctx, x, y, scale, '#cd7f32', 3, false);
+ },
+
+ 'crown-silver': function(ctx, x, y, scale, color, time) {
+ drawCrown(ctx, x, y, scale, '#c0c0c0', 4, false);
+ },
+
+ 'crown-gold': function(ctx, x, y, scale, color, time) {
+ drawCrown(ctx, x, y, scale, '#ffd700', 5, true);
+ },
+
+ 'crown-diamond': function(ctx, x, y, scale, color, time) {
+ drawCrown(ctx, x, y, scale, '#b9f2ff', 6, true);
+ drawSparkles(ctx, x, y - 10 * scale, scale, time);
+ },
+
+ 'crown-cosmic': function(ctx, x, y, scale, color, time) {
+ drawCrown(ctx, x, y, scale, '#9b59b6', 7, true);
+ drawCosmicEffect(ctx, x, y - 15 * scale, scale, time);
+ },
+
+ 'wizard-hat': function(ctx, x, y, scale, color, time) {
+ var hatColor = color || '#2c3e50';
+
+ // Tall pointed hat
+ ctx.fillStyle = hatColor;
+ ctx.beginPath();
+ ctx.moveTo(x - 25 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x - 10 * scale, y - 30 * scale, x + 5 * scale, y - 55 * scale);
+ ctx.quadraticCurveTo(x + 15 * scale, y - 30 * scale, x + 25 * scale, y + 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Brim
+ ctx.fillStyle = darken(hatColor, 0.2);
+ ctx.beginPath();
+ ctx.ellipse(x, y + 5 * scale, 28 * scale, 8 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Stars decoration
+ ctx.fillStyle = '#ffd700';
+ drawStar(ctx, x - 8 * scale, y - 20 * scale, 4 * scale, 5);
+ drawStar(ctx, x + 5 * scale, y - 35 * scale, 3 * scale, 5);
+ drawStar(ctx, x - 3 * scale, y - 10 * scale, 2.5 * scale, 5);
+
+ // Moon
+ ctx.fillStyle = '#f1c40f';
+ ctx.beginPath();
+ ctx.arc(x + 10 * scale, y - 25 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = hatColor;
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y - 26 * scale, 4 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'pirate-hat': function(ctx, x, y, scale, color, time) {
+ // Tricorn base
+ ctx.fillStyle = '#1a1a1a';
+ ctx.beginPath();
+ ctx.moveTo(x - 30 * scale, y);
+ ctx.quadraticCurveTo(x - 35 * scale, y - 15 * scale, x - 20 * scale, y - 25 * scale);
+ ctx.lineTo(x, y - 15 * scale);
+ ctx.lineTo(x + 20 * scale, y - 25 * scale);
+ ctx.quadraticCurveTo(x + 35 * scale, y - 15 * scale, x + 30 * scale, y);
+ ctx.quadraticCurveTo(x, y + 5 * scale, x - 30 * scale, y);
+ ctx.fill();
+
+ // Gold trim
+ ctx.strokeStyle = '#d4af37';
+ ctx.lineWidth = 2 * scale;
+ ctx.stroke();
+
+ // Skull
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(x, y - 12 * scale, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Skull eyes
+ ctx.fillStyle = '#000';
+ ctx.beginPath();
+ ctx.arc(x - 2 * scale, y - 13 * scale, 1.5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 2 * scale, y - 13 * scale, 1.5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Crossbones hint
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(x - 8 * scale, y - 8 * scale);
+ ctx.lineTo(x + 8 * scale, y - 16 * scale);
+ ctx.moveTo(x + 8 * scale, y - 8 * scale);
+ ctx.lineTo(x - 8 * scale, y - 16 * scale);
+ ctx.stroke();
+ },
+
+ 'halo': function(ctx, x, y, scale, color, time) {
+ var bob = Math.sin(time * 2) * 2;
+
+ // Glow effect
+ ctx.save();
+ ctx.shadowColor = '#ffd700';
+ ctx.shadowBlur = 15;
+
+ // Main ring
+ ctx.strokeStyle = '#ffd700';
+ ctx.lineWidth = 4 * scale;
+ ctx.beginPath();
+ ctx.ellipse(x, y - 25 * scale + bob, 18 * scale, 5 * scale, 0, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Inner highlight
+ ctx.strokeStyle = '#fff8dc';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.ellipse(x, y - 25 * scale + bob, 16 * scale, 4 * scale, 0, 0, Math.PI * 2);
+ ctx.stroke();
+
+ ctx.restore();
+ },
+
+ 'devil-horns': function(ctx, x, y, scale, color, time) {
+ var hornColor = color || '#8b0000';
+
+ // Left horn
+ ctx.fillStyle = hornColor;
+ ctx.beginPath();
+ ctx.moveTo(x - 20 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x - 30 * scale, y - 15 * scale, x - 25 * scale, y - 30 * scale);
+ ctx.quadraticCurveTo(x - 20 * scale, y - 20 * scale, x - 15 * scale, y + 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Right horn
+ ctx.beginPath();
+ ctx.moveTo(x + 20 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x + 30 * scale, y - 15 * scale, x + 25 * scale, y - 30 * scale);
+ ctx.quadraticCurveTo(x + 20 * scale, y - 20 * scale, x + 15 * scale, y + 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Horn ridges
+ ctx.strokeStyle = darken(hornColor, 0.3);
+ ctx.lineWidth = 1 * scale;
+ for (var i = 0; i < 3; i++) {
+ var hy = y - 5 * scale - i * 8 * scale;
+ ctx.beginPath();
+ ctx.moveTo(x - 22 * scale + i * 2 * scale, hy);
+ ctx.lineTo(x - 17 * scale + i * scale, hy);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x + 22 * scale - i * 2 * scale, hy);
+ ctx.lineTo(x + 17 * scale - i * scale, hy);
+ ctx.stroke();
+ }
+ },
+
+ 'party-hat': function(ctx, x, y, scale, color, time) {
+ var hatColor = color || '#e91e63';
+
+ // Cone
+ ctx.fillStyle = hatColor;
+ ctx.beginPath();
+ ctx.moveTo(x - 20 * scale, y + 5 * scale);
+ ctx.lineTo(x, y - 40 * scale);
+ ctx.lineTo(x + 20 * scale, y + 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Stripes
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 3 * scale;
+ ctx.beginPath();
+ ctx.moveTo(x - 15 * scale, y);
+ ctx.lineTo(x - 5 * scale, y - 25 * scale);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x + 5 * scale, y - 5 * scale);
+ ctx.lineTo(x + 2 * scale, y - 30 * scale);
+ ctx.stroke();
+
+ // Pom pom
+ ctx.fillStyle = '#ffd700';
+ ctx.beginPath();
+ ctx.arc(x, y - 42 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Elastic band
+ ctx.strokeStyle = 'rgba(255,255,255,0.5)';
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.moveTo(x - 20 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x, y + 12 * scale, x + 20 * scale, y + 5 * scale);
+ ctx.stroke();
+ },
+
+ 'cowboy-hat': function(ctx, x, y, scale, color, time) {
+ var hatColor = color || '#8b4513';
+
+ // Brim
+ ctx.fillStyle = hatColor;
+ ctx.beginPath();
+ ctx.ellipse(x, y + 2 * scale, 32 * scale, 10 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Crown
+ ctx.beginPath();
+ ctx.moveTo(x - 18 * scale, y);
+ ctx.quadraticCurveTo(x - 20 * scale, y - 25 * scale, x - 10 * scale, y - 28 * scale);
+ ctx.quadraticCurveTo(x, y - 22 * scale, x + 10 * scale, y - 28 * scale);
+ ctx.quadraticCurveTo(x + 20 * scale, y - 25 * scale, x + 18 * scale, y);
+ ctx.closePath();
+ ctx.fill();
+
+ // Band
+ ctx.fillStyle = darken(hatColor, 0.3);
+ ctx.fillRect(x - 18 * scale, y - 5 * scale, 36 * scale, 4 * scale);
+
+ // Band buckle
+ ctx.fillStyle = '#d4af37';
+ ctx.fillRect(x - 4 * scale, y - 6 * scale, 8 * scale, 6 * scale);
+ },
+
+ 'santa-hat': function(ctx, x, y, scale, color, time) {
+ // Red hat
+ ctx.fillStyle = '#c0392b';
+ ctx.beginPath();
+ ctx.moveTo(x - 22 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x - 5 * scale, y - 20 * scale, x + 25 * scale, y - 35 * scale);
+ ctx.quadraticCurveTo(x + 15 * scale, y - 15 * scale, x + 22 * scale, y + 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // White trim
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.moveTo(x - 25 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x, y + 12 * scale, x + 25 * scale, y + 5 * scale);
+ ctx.quadraticCurveTo(x, y, x - 25 * scale, y + 5 * scale);
+ ctx.fill();
+
+ // Pom pom
+ ctx.beginPath();
+ ctx.arc(x + 27 * scale, y - 35 * scale, 7 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'chef-hat': function(ctx, x, y, scale, color, time) {
+ // Puffy top
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(x - 12 * scale, y - 30 * scale, 12 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x + 12 * scale, y - 30 * scale, 12 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(x, y - 35 * scale, 14 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Base cylinder
+ ctx.fillRect(x - 20 * scale, y - 20 * scale, 40 * scale, 25 * scale);
+
+ // Band
+ ctx.fillStyle = '#ecf0f1';
+ ctx.fillRect(x - 20 * scale, y, 40 * scale, 5 * scale);
+ },
+
+ 'viking-helmet': function(ctx, x, y, scale, color, time) {
+ // Helmet dome
+ ctx.fillStyle = '#7f8c8d';
+ ctx.beginPath();
+ ctx.arc(x, y - 5 * scale, 24 * scale, Math.PI, 0, false);
+ ctx.fill();
+
+ // Nose guard
+ ctx.fillStyle = '#95a5a6';
+ ctx.beginPath();
+ ctx.moveTo(x - 3 * scale, y - 5 * scale);
+ ctx.lineTo(x, y + 15 * scale);
+ ctx.lineTo(x + 3 * scale, y - 5 * scale);
+ ctx.fill();
+
+ // Horns
+ ctx.fillStyle = '#f5f5dc';
+ // Left horn
+ ctx.beginPath();
+ ctx.moveTo(x - 22 * scale, y - 5 * scale);
+ ctx.quadraticCurveTo(x - 35 * scale, y - 20 * scale, x - 40 * scale, y - 35 * scale);
+ ctx.quadraticCurveTo(x - 30 * scale, y - 25 * scale, x - 20 * scale, y - 10 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Right horn
+ ctx.beginPath();
+ ctx.moveTo(x + 22 * scale, y - 5 * scale);
+ ctx.quadraticCurveTo(x + 35 * scale, y - 20 * scale, x + 40 * scale, y - 35 * scale);
+ ctx.quadraticCurveTo(x + 30 * scale, y - 25 * scale, x + 20 * scale, y - 10 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Horn stripes
+ ctx.strokeStyle = '#d4c4a8';
+ ctx.lineWidth = 1 * scale;
+ for (var i = 0; i < 3; i++) {
+ ctx.beginPath();
+ ctx.moveTo(x - 25 * scale - i * 5 * scale, y - 12 * scale - i * 8 * scale);
+ ctx.lineTo(x - 22 * scale - i * 5 * scale, y - 8 * scale - i * 8 * scale);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x + 25 * scale + i * 5 * scale, y - 12 * scale - i * 8 * scale);
+ ctx.lineTo(x + 22 * scale + i * 5 * scale, y - 8 * scale - i * 8 * scale);
+ ctx.stroke();
+ }
+ },
+
+ 'headphones': function(ctx, x, y, scale, color, time) {
+ var hpColor = color || '#2c3e50';
+
+ // Headband
+ ctx.strokeStyle = hpColor;
+ ctx.lineWidth = 4 * scale;
+ ctx.beginPath();
+ ctx.arc(x, y - 5 * scale, 25 * scale, Math.PI, 0, false);
+ ctx.stroke();
+
+ // Left ear cup
+ ctx.fillStyle = hpColor;
+ ctx.beginPath();
+ ctx.ellipse(x - 26 * scale, y + 5 * scale, 10 * scale, 14 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Left cushion
+ ctx.fillStyle = '#34495e';
+ ctx.beginPath();
+ ctx.ellipse(x - 26 * scale, y + 5 * scale, 7 * scale, 11 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Right ear cup
+ ctx.fillStyle = hpColor;
+ ctx.beginPath();
+ ctx.ellipse(x + 26 * scale, y + 5 * scale, 10 * scale, 14 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Right cushion
+ ctx.fillStyle = '#34495e';
+ ctx.beginPath();
+ ctx.ellipse(x + 26 * scale, y + 5 * scale, 7 * scale, 11 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+
+ // ============================================================================
+ // EFFECT RENDERERS (10 items)
+ // ============================================================================
+
+ var EFFECT_RENDERERS = {
+ 'sparkle-trail': function(ctx, avatar, x, y, scale, time) {
+ var particles = getSparkleParticles(time, 20);
+ particles.forEach(function(p) {
+ var px = x + p.x * scale;
+ var py = y + p.y * scale;
+ ctx.fillStyle = 'rgba(255,215,0,' + p.alpha + ')';
+ drawStar(ctx, px, py, 3 * scale * p.size, 4);
+ });
+ },
+
+ 'fire-aura': function(ctx, avatar, x, y, scale, time) {
+ // Outer glow
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 45 * scale);
+ gradient.addColorStop(0, 'rgba(255,69,0,0)');
+ gradient.addColorStop(0.5, 'rgba(255,69,0,0.15)');
+ gradient.addColorStop(1, 'rgba(255,69,0,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 45 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Fire particles
+ var flames = getFlameParticles(time, 15);
+ flames.forEach(function(f) {
+ var fx = x + f.x * scale;
+ var fy = y + f.y * scale;
+ var flameGrad = ctx.createRadialGradient(fx, fy, 0, fx, fy, 6 * scale * f.size);
+ flameGrad.addColorStop(0, 'hsla(' + f.hue + ',100%,60%,' + f.alpha + ')');
+ flameGrad.addColorStop(1, 'hsla(' + f.hue + ',100%,50%,0)');
+ ctx.fillStyle = flameGrad;
+ ctx.beginPath();
+ ctx.arc(fx, fy, 6 * scale * f.size, 0, Math.PI * 2);
+ ctx.fill();
+ });
+ },
+
+ 'lightning-crackle': function(ctx, avatar, x, y, scale, time) {
+ // Background electric glow
+ var pulse = 0.3 + Math.abs(Math.sin(time * 5)) * 0.2;
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 40 * scale);
+ gradient.addColorStop(0, 'rgba(0,191,255,0)');
+ gradient.addColorStop(0.6, 'rgba(0,191,255,' + pulse + ')');
+ gradient.addColorStop(1, 'rgba(0,191,255,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 40 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Random lightning bolts
+ if (Math.random() < 0.15) {
+ var startX = x + (Math.random() - 0.5) * 30 * scale;
+ var startY = y - 35 * scale;
+ var endX = x + (Math.random() - 0.5) * 50 * scale;
+ var endY = y + 35 * scale;
+ drawLightningBolt(ctx, startX, startY, endX, endY, scale);
+ }
+ },
+
+ 'rainbow-glow': function(ctx, avatar, x, y, scale, time) {
+ var hue = (time * 50) % 360;
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 50 * scale);
+ gradient.addColorStop(0, 'hsla(' + hue + ',100%,50%,0)');
+ gradient.addColorStop(0.5, 'hsla(' + hue + ',100%,50%,0.2)');
+ gradient.addColorStop(0.7, 'hsla(' + ((hue + 60) % 360) + ',100%,50%,0.15)');
+ gradient.addColorStop(0.85, 'hsla(' + ((hue + 120) % 360) + ',100%,50%,0.1)');
+ gradient.addColorStop(1, 'hsla(' + ((hue + 180) % 360) + ',100%,50%,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 50 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ 'golden-glow': function(ctx, avatar, x, y, scale, time) {
+ var pulse = 0.8 + Math.sin(time * 2) * 0.2;
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 50 * scale * pulse);
+ gradient.addColorStop(0, 'rgba(255,215,0,0)');
+ gradient.addColorStop(0.4, 'rgba(255,215,0,0.2)');
+ gradient.addColorStop(0.7, 'rgba(255,215,0,0.15)');
+ gradient.addColorStop(1, 'rgba(255,215,0,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 50 * scale * pulse, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Subtle sparkles
+ for (var i = 0; i < 5; i++) {
+ var angle = (time + i * 1.2) * 0.5;
+ var dist = 30 * scale + Math.sin(time * 2 + i) * 10 * scale;
+ var sx = x + Math.cos(angle) * dist;
+ var sy = y + Math.sin(angle) * dist;
+ var alpha = 0.3 + Math.sin(time * 3 + i * 2) * 0.3;
+ ctx.fillStyle = 'rgba(255,255,200,' + alpha + ')';
+ drawStar(ctx, sx, sy, 2 * scale, 4);
+ }
+ },
+
+ 'ice-aura': function(ctx, avatar, x, y, scale, time) {
+ // Cold blue glow
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 45 * scale);
+ gradient.addColorStop(0, 'rgba(173,216,230,0)');
+ gradient.addColorStop(0.5, 'rgba(173,216,230,0.2)');
+ gradient.addColorStop(1, 'rgba(173,216,230,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 45 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Snowflakes
+ var snow = getSnowParticles(time, 12);
+ snow.forEach(function(s) {
+ var sx = x + s.x * scale;
+ var sy = y + s.y * scale;
+ ctx.fillStyle = 'rgba(255,255,255,' + s.alpha + ')';
+ drawStar(ctx, sx, sy, s.size * scale, 6);
+ });
+ },
+
+ 'shadow-aura': function(ctx, avatar, x, y, scale, time) {
+ // Dark pulsing aura
+ var pulse = 0.9 + Math.sin(time * 1.5) * 0.1;
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, 50 * scale * pulse);
+ gradient.addColorStop(0, 'rgba(0,0,0,0)');
+ gradient.addColorStop(0.4, 'rgba(20,0,40,0.3)');
+ gradient.addColorStop(0.7, 'rgba(40,0,60,0.2)');
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, 50 * scale * pulse, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Wispy shadows
+ for (var i = 0; i < 6; i++) {
+ var angle = time * 0.3 + i * Math.PI / 3;
+ var dist = 25 * scale + Math.sin(time + i) * 10 * scale;
+ var wx = x + Math.cos(angle) * dist;
+ var wy = y + Math.sin(angle) * dist;
+ var wispGrad = ctx.createRadialGradient(wx, wy, 0, wx, wy, 8 * scale);
+ wispGrad.addColorStop(0, 'rgba(30,0,50,0.4)');
+ wispGrad.addColorStop(1, 'rgba(30,0,50,0)');
+ ctx.fillStyle = wispGrad;
+ ctx.beginPath();
+ ctx.arc(wx, wy, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ },
+
+ 'bubble-float': function(ctx, avatar, x, y, scale, time) {
+ var bubbles = getBubbleParticles(time, 10);
+ bubbles.forEach(function(b) {
+ var bx = x + b.x * scale;
+ var by = y + b.y * scale;
+
+ // Bubble outline
+ ctx.strokeStyle = 'rgba(100,200,255,' + b.alpha + ')';
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.arc(bx, by, b.size * scale, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Bubble highlight
+ ctx.fillStyle = 'rgba(255,255,255,' + (b.alpha * 0.5) + ')';
+ ctx.beginPath();
+ ctx.arc(bx - b.size * 0.3 * scale, by - b.size * 0.3 * scale, b.size * 0.3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ });
+ },
+
+ 'music-notes': function(ctx, avatar, x, y, scale, time) {
+ var notes = getMusicNotes(time, 8);
+ notes.forEach(function(n) {
+ var nx = x + n.x * scale;
+ var ny = y + n.y * scale;
+
+ ctx.save();
+ ctx.translate(nx, ny);
+ ctx.rotate(n.rotation);
+ ctx.fillStyle = 'rgba(155,89,182,' + n.alpha + ')';
+
+ // Note head
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 4 * scale, 3 * scale, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Note stem
+ ctx.fillRect(3 * scale, -12 * scale, 1.5 * scale, 12 * scale);
+
+ // Note flag
+ ctx.beginPath();
+ ctx.moveTo(4.5 * scale, -12 * scale);
+ ctx.quadraticCurveTo(8 * scale, -8 * scale, 4.5 * scale, -6 * scale);
+ ctx.fill();
+
+ ctx.restore();
+ });
+ },
+
+ 'heart-float': function(ctx, avatar, x, y, scale, time) {
+ for (var i = 0; i < 8; i++) {
+ var seed = i * 83.7;
+ var lifetime = (time * 0.8 + seed) % 2.5;
+ var hx = x + Math.sin(seed) * 25 * scale + Math.sin(time + seed) * 8 * scale;
+ var hy = y - lifetime * 30 * scale + 15 * scale;
+ var alpha = (1 - lifetime / 2.5) * 0.7;
+ var heartScale = (0.5 + Math.sin(seed) * 0.3) * scale;
+
+ ctx.fillStyle = 'rgba(231,76,60,' + alpha + ')';
+ ctx.save();
+ ctx.translate(hx, hy);
+ ctx.scale(heartScale, heartScale);
+ ctx.beginPath();
+ ctx.moveTo(0, -3);
+ ctx.bezierCurveTo(-5, -8, -10, -3, 0, 8);
+ ctx.bezierCurveTo(10, -3, 5, -8, 0, -3);
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+ };
+
+ // ============================================================================
+ // MAIN RENDER FUNCTION
+ // ============================================================================
+
+ function renderAccessories(ctx, avatar, mode, x, y, scale, time) {
+ if (!avatar || !avatar.equipped) return;
+
+ time = time || 0;
+
+ // Calculate head position based on mode
+ var headY;
+ if (mode === 'FULL_BODY') {
+ headY = y - 60 * scale;
+ } else if (mode === 'UPPER_BODY') {
+ headY = y - 32 * scale;
+ } else if (mode === 'HEAD_AND_SHOULDERS') {
+ headY = y - 16 * scale;
+ } else {
+ headY = y;
+ }
+
+ // 1. Render effects (they go behind/around everything)
+ if (avatar.equipped.effect) {
+ var effectId = avatar.equipped.effect;
+ if (typeof window.AvatarAccessories !== 'undefined') {
+ var effect = window.AvatarAccessories.getById(effectId);
+ if (effect && EFFECT_RENDERERS[effect.id]) {
+ EFFECT_RENDERERS[effect.id](ctx, avatar, x, headY, scale, time);
+ }
+ } else if (EFFECT_RENDERERS[effectId]) {
+ EFFECT_RENDERERS[effectId](ctx, avatar, x, headY, scale, time);
+ }
+ }
+
+ // 2. Render eyewear
+ if (avatar.equipped.eyewear) {
+ var eyewearId = avatar.equipped.eyewear;
+ var eyeY = headY + 5 * scale;
+ if (typeof window.AvatarAccessories !== 'undefined') {
+ var eyewear = window.AvatarAccessories.getById(eyewearId);
+ if (eyewear && EYEWEAR_RENDERERS[eyewear.id]) {
+ EYEWEAR_RENDERERS[eyewear.id](ctx, x, eyeY, scale, null, time);
+ }
+ } else if (EYEWEAR_RENDERERS[eyewearId]) {
+ EYEWEAR_RENDERERS[eyewearId](ctx, x, eyeY, scale, null, time);
+ }
+ }
+
+ // 3. Render headwear
+ if (avatar.equipped.headwear) {
+ var headwearId = avatar.equipped.headwear;
+ var hatY = headY - 20 * scale;
+ if (typeof window.AvatarAccessories !== 'undefined') {
+ var headwear = window.AvatarAccessories.getById(headwearId);
+ if (headwear && HEADWEAR_RENDERERS[headwear.id]) {
+ HEADWEAR_RENDERERS[headwear.id](ctx, x, hatY, scale, null, time);
+ }
+ } else if (HEADWEAR_RENDERERS[headwearId]) {
+ HEADWEAR_RENDERERS[headwearId](ctx, x, hatY, scale, null, time);
+ }
+ }
+ }
+
+ // ============================================================================
+ // INDIVIDUAL RENDER FUNCTIONS
+ // ============================================================================
+
+ function renderEyewear(ctx, itemId, x, y, scale, time) {
+ time = time || 0;
+ if (EYEWEAR_RENDERERS[itemId]) {
+ EYEWEAR_RENDERERS[itemId](ctx, x, y, scale, null, time);
+ }
+ }
+
+ function renderHeadwear(ctx, itemId, x, y, scale, time) {
+ time = time || 0;
+ if (HEADWEAR_RENDERERS[itemId]) {
+ HEADWEAR_RENDERERS[itemId](ctx, x, y, scale, null, time);
+ }
+ }
+
+ function renderEffect(ctx, itemId, avatar, x, y, scale, time) {
+ time = time || 0;
+ if (EFFECT_RENDERERS[itemId]) {
+ EFFECT_RENDERERS[itemId](ctx, avatar, x, y, scale, time);
+ }
+ }
+
+ // ============================================================================
+ // PREVIEW FUNCTION (for shop/inventory UI)
+ // ============================================================================
+
+ function renderPreview(ctx, itemId, x, y, size) {
+ var scale = size / 60;
+ var time = Date.now() / 1000;
+
+ // Try to find what type of item this is
+ if (EYEWEAR_RENDERERS[itemId]) {
+ renderEyewear(ctx, itemId, x, y, scale, time);
+ } else if (HEADWEAR_RENDERERS[itemId]) {
+ renderHeadwear(ctx, itemId, x, y - 10 * scale, scale, time);
+ } else if (EFFECT_RENDERERS[itemId]) {
+ renderEffect(ctx, itemId, null, x, y, scale, time);
+ } else if (typeof window.AvatarAccessories !== 'undefined') {
+ // Check via AvatarAccessories if available
+ var item = window.AvatarAccessories.getById(itemId);
+ if (item) {
+ if (item.category === 'eyewear') {
+ renderEyewear(ctx, itemId, x, y, scale, time);
+ } else if (item.category === 'headwear') {
+ renderHeadwear(ctx, itemId, x, y - 10 * scale, scale, time);
+ } else if (item.category === 'effect') {
+ renderEffect(ctx, itemId, null, x, y, scale, time);
+ }
+ }
+ }
+ }
+
+ // ============================================================================
+ // BOUNDING BOX CALCULATION
+ // ============================================================================
+
+ function getBounds(itemId, scale) {
+ scale = scale || 1;
+
+ // Default bounds
+ var bounds = { x: -30 * scale, y: -30 * scale, width: 60 * scale, height: 60 * scale };
+
+ // Eyewear bounds (relatively small, centered on face)
+ if (EYEWEAR_RENDERERS[itemId]) {
+ bounds = { x: -25 * scale, y: -5 * scale, width: 50 * scale, height: 20 * scale };
+ }
+
+ // Headwear bounds (above head, varies by type)
+ if (HEADWEAR_RENDERERS[itemId]) {
+ if (itemId === 'top-hat') {
+ bounds = { x: -25 * scale, y: -45 * scale, width: 50 * scale, height: 50 * scale };
+ } else if (itemId === 'wizard-hat') {
+ bounds = { x: -28 * scale, y: -60 * scale, width: 56 * scale, height: 65 * scale };
+ } else if (itemId === 'viking-helmet') {
+ bounds = { x: -45 * scale, y: -40 * scale, width: 90 * scale, height: 55 * scale };
+ } else if (itemId.includes('crown')) {
+ bounds = { x: -20 * scale, y: -25 * scale, width: 40 * scale, height: 30 * scale };
+ } else if (itemId === 'halo') {
+ bounds = { x: -20 * scale, y: -35 * scale, width: 40 * scale, height: 20 * scale };
+ } else {
+ bounds = { x: -30 * scale, y: -35 * scale, width: 60 * scale, height: 45 * scale };
+ }
+ }
+
+ // Effect bounds (larger, encompasses aura)
+ if (EFFECT_RENDERERS[itemId]) {
+ bounds = { x: -50 * scale, y: -50 * scale, width: 100 * scale, height: 100 * scale };
+ }
+
+ return bounds;
+ }
+
+ // ============================================================================
+ // PUBLIC API
+ // ============================================================================
+
+ window.AccessoryRenderer = {
+ render: renderAccessories,
+ renderEyewear: renderEyewear,
+ renderHeadwear: renderHeadwear,
+ renderEffect: renderEffect,
+ renderPreview: renderPreview,
+ getBounds: getBounds,
+ Z_ORDER: Z_ORDER
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/animations.js b/frontend/public/assets/avatar/animations.js
new file mode 100644
index 0000000..9761f99
--- /dev/null
+++ b/frontend/public/assets/avatar/animations.js
@@ -0,0 +1,1422 @@
+/**
+ * Avatar Animation System
+ * Defines all animation sequences for the avatar system across 4 rendering modes.
+ */
+(function() {
+ 'use strict';
+
+ // =============================================================================
+ // RENDERING MODES
+ // =============================================================================
+
+ var MODES = {
+ HEAD_ONLY: { width: 64, height: 64, showBody: false, showShoulders: false },
+ HEAD_AND_SHOULDERS: { width: 64, height: 96, showBody: false, showShoulders: true },
+ UPPER_BODY: { width: 64, height: 128, showBody: 'upper', showShoulders: true },
+ FULL_BODY: { width: 64, height: 160, showBody: 'full', showShoulders: true },
+ };
+
+ // =============================================================================
+ // ACCESSORY ANIMATION RULES
+ // =============================================================================
+
+ var ACCESSORY_ANIMATION = {
+ headwear: {
+ followHead: true,
+ bounceMultiplier: 1.2,
+ rotationDamping: 0.8,
+ },
+ eyewear: {
+ followHead: true,
+ bounceMultiplier: 1.0,
+ rotationDamping: 1.0,
+ },
+ effects: {
+ independent: true,
+ particleSpeed: 1.0,
+ },
+ };
+
+ // =============================================================================
+ // DEFAULT FRAME TRANSFORM
+ // =============================================================================
+
+ function defaultFrame() {
+ return {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ };
+ }
+
+ // =============================================================================
+ // HEAD_ONLY ANIMATIONS (5 animations)
+ // =============================================================================
+
+ var HEAD_ONLY_ANIMATIONS = [
+ // 1. bob - Gentle up/down bobbing
+ {
+ id: 'bob',
+ name: 'Bob',
+ modes: ['HEAD_ONLY'],
+ loop: true,
+ frameCount: 3,
+ frameDuration: 150,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: -2, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: -1, rotation: 0 },
+ },
+ ],
+ },
+
+ // 2. flap - For Flappy Bird style (ears/hair wiggle)
+ {
+ id: 'flap',
+ name: 'Flap',
+ modes: ['HEAD_ONLY'],
+ loop: true,
+ frameCount: 2,
+ frameDuration: 80,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: -3, scaleX: 1.05, scaleY: 0.95 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 2, rotation: -5 },
+ },
+ {
+ head: { x: 0, y: -3, rotation: 3, scaleX: 0.95, scaleY: 1.05 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: -3, rotation: 5 },
+ },
+ ],
+ },
+
+ // 3. blink - Eye blink
+ {
+ id: 'blink',
+ name: 'Blink',
+ modes: ['HEAD_ONLY'],
+ loop: false,
+ frameCount: 3,
+ frameDuration: 60,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 0.1 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 4. celebrate - Happy bounce
+ {
+ id: 'celebrate',
+ name: 'Celebrate',
+ modes: ['HEAD_ONLY'],
+ loop: false,
+ frameCount: 4,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 0.9 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -6, rotation: 5, scaleX: 1, scaleY: 1.1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: -8, rotation: 5 },
+ },
+ {
+ head: { x: 0, y: -3, rotation: -3, scaleX: 1, scaleY: 1.05 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: -4, rotation: -3 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 5. hurt - Damage reaction
+ {
+ id: 'hurt',
+ name: 'Hurt',
+ modes: ['HEAD_ONLY'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 80,
+ frames: [
+ {
+ head: { x: -3, y: 0, rotation: -5, scaleX: 1.1, scaleY: 0.9 },
+ eyes: { scaleY: 0.5 },
+ accessories: { bounce: 0, rotation: -8 },
+ },
+ {
+ head: { x: 3, y: 0, rotation: 5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ accessories: { bounce: 0, rotation: 5 },
+ },
+ ],
+ },
+ ];
+
+ // =============================================================================
+ // HEAD_AND_SHOULDERS ANIMATIONS (6 animations)
+ // =============================================================================
+
+ var HEAD_AND_SHOULDERS_ANIMATIONS = [
+ // 1. idle - Subtle breathing
+ {
+ id: 'idle',
+ name: 'Idle',
+ modes: ['HEAD_AND_SHOULDERS'],
+ loop: true,
+ frameCount: 3,
+ frameDuration: 400,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 1, rotation: 0 },
+ accessories: { bounce: -1, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 2. turn-left - Head turns left
+ {
+ id: 'turn-left',
+ name: 'Turn Left',
+ modes: ['HEAD_AND_SHOULDERS'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: -2, y: 0, rotation: -15, scaleX: 0.95, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: -3 },
+ accessories: { bounce: 0, rotation: -12 },
+ },
+ ],
+ },
+
+ // 3. turn-right - Head turns right
+ {
+ id: 'turn-right',
+ name: 'Turn Right',
+ modes: ['HEAD_AND_SHOULDERS'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 2, y: 0, rotation: 15, scaleX: 0.95, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 3 },
+ accessories: { bounce: 0, rotation: 12 },
+ },
+ ],
+ },
+
+ // 4. look-up - Head tilts up
+ {
+ id: 'look-up',
+ name: 'Look Up',
+ modes: ['HEAD_AND_SHOULDERS'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -3, rotation: -10, scaleX: 1, scaleY: 0.95 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 0, y: 1, rotation: 0 },
+ accessories: { bounce: -4, rotation: -8 },
+ },
+ ],
+ },
+
+ // 5. celebrate - Bounce and smile
+ {
+ id: 'celebrate',
+ name: 'Celebrate',
+ modes: ['HEAD_AND_SHOULDERS'],
+ loop: false,
+ frameCount: 3,
+ frameDuration: 120,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 0.9 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 2, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -8, rotation: 5, scaleX: 1.05, scaleY: 1.1 },
+ eyes: { scaleY: 0.8 },
+ body: { x: 0, y: -2, rotation: 3 },
+ accessories: { bounce: -10, rotation: 5 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 6. hurt - Recoil
+ {
+ id: 'hurt',
+ name: 'Hurt',
+ modes: ['HEAD_AND_SHOULDERS'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: -4, y: 2, rotation: -10, scaleX: 1.1, scaleY: 0.9 },
+ eyes: { scaleY: 0.3 },
+ body: { x: -2, y: 2, rotation: -5 },
+ accessories: { bounce: 3, rotation: -15 },
+ },
+ {
+ head: { x: 2, y: 0, rotation: 5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 1, y: 0, rotation: 2 },
+ accessories: { bounce: 0, rotation: 5 },
+ },
+ ],
+ },
+ ];
+
+ // =============================================================================
+ // UPPER_BODY ANIMATIONS (8 animations)
+ // =============================================================================
+
+ var UPPER_BODY_ANIMATIONS = [
+ // 1. idle - Breathing with arm sway
+ {
+ id: 'idle',
+ name: 'Idle',
+ modes: ['UPPER_BODY'],
+ loop: true,
+ frameCount: 3,
+ frameDuration: 400,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 1, rotation: 0 },
+ leftArm: { rotation: 2, x: 0, y: 0 },
+ rightArm: { rotation: -2, x: 0, y: 0 },
+ accessories: { bounce: -1, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 2. wave - Wave one arm
+ {
+ id: 'wave',
+ name: 'Wave',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 4,
+ frameDuration: 120,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: -45, x: 0, y: -5 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 0.9 },
+ body: { x: 0, y: 0, rotation: 2 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: -120, x: 5, y: -10 },
+ accessories: { bounce: 0, rotation: 3 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 0.85 },
+ body: { x: 0, y: 0, rotation: 3 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: -90, x: 8, y: -12 },
+ accessories: { bounce: 0, rotation: 5 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 0.9 },
+ body: { x: 0, y: 0, rotation: 2 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: -120, x: 5, y: -10 },
+ accessories: { bounce: 0, rotation: 3 },
+ },
+ ],
+ },
+
+ // 3. point - Point forward
+ {
+ id: 'point',
+ name: 'Point',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 3,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 5 },
+ leftArm: { rotation: 10, x: 0, y: 0 },
+ rightArm: { rotation: -90, x: 10, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 1, y: 0, rotation: 3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 1, y: 0, rotation: 8 },
+ leftArm: { rotation: 15, x: 0, y: 0 },
+ rightArm: { rotation: -85, x: 15, y: 0 },
+ accessories: { bounce: 0, rotation: 3 },
+ },
+ ],
+ },
+
+ // 4. steer-left - Lean left for racing
+ {
+ id: 'steer-left',
+ name: 'Steer Left',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 80,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: -30, x: -5, y: 0 },
+ rightArm: { rotation: -30, x: -5, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: -3, y: 0, rotation: -12, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: -2, y: 0, rotation: -10 },
+ leftArm: { rotation: -45, x: -8, y: -2 },
+ rightArm: { rotation: -15, x: -3, y: 2 },
+ accessories: { bounce: 0, rotation: -10 },
+ },
+ ],
+ },
+
+ // 5. steer-right - Lean right for racing
+ {
+ id: 'steer-right',
+ name: 'Steer Right',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 80,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 30, x: 5, y: 0 },
+ rightArm: { rotation: 30, x: 5, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 3, y: 0, rotation: 12, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 2, y: 0, rotation: 10 },
+ leftArm: { rotation: 15, x: 3, y: 2 },
+ rightArm: { rotation: 45, x: 8, y: -2 },
+ accessories: { bounce: 0, rotation: 10 },
+ },
+ ],
+ },
+
+ // 6. shoot - Arm extends forward
+ {
+ id: 'shoot',
+ name: 'Shoot',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 3,
+ frameDuration: 60,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: -45, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: -1, y: 0, rotation: -2, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: -1, y: 0, rotation: -3 },
+ leftArm: { rotation: 5, x: -2, y: 0 },
+ rightArm: { rotation: -90, x: 15, y: 0 },
+ accessories: { bounce: 0, rotation: -2 },
+ },
+ {
+ head: { x: -2, y: 1, rotation: -3, scaleX: 1.02, scaleY: 0.98 },
+ eyes: { scaleY: 1 },
+ body: { x: -2, y: 1, rotation: -5 },
+ leftArm: { rotation: 10, x: -3, y: 1 },
+ rightArm: { rotation: -85, x: 12, y: 2 },
+ accessories: { bounce: 1, rotation: -3 },
+ },
+ ],
+ },
+
+ // 7. celebrate - Arms up celebration
+ {
+ id: 'celebrate',
+ name: 'Celebrate',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 4,
+ frameDuration: 120,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 0.9 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 2, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -8, rotation: 0, scaleX: 1, scaleY: 1.1 },
+ eyes: { scaleY: 0.8 },
+ body: { x: 0, y: -4, rotation: 0 },
+ leftArm: { rotation: -120, x: -5, y: -10 },
+ rightArm: { rotation: 120, x: 5, y: -10 },
+ accessories: { bounce: -10, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -6, rotation: 5, scaleX: 1, scaleY: 1.05 },
+ eyes: { scaleY: 0.85 },
+ body: { x: 0, y: -2, rotation: 3 },
+ leftArm: { rotation: -135, x: -8, y: -12 },
+ rightArm: { rotation: 135, x: 8, y: -12 },
+ accessories: { bounce: -8, rotation: 5 },
+ },
+ {
+ head: { x: 0, y: -6, rotation: -5, scaleX: 1, scaleY: 1.05 },
+ eyes: { scaleY: 0.85 },
+ body: { x: 0, y: -2, rotation: -3 },
+ leftArm: { rotation: -130, x: -6, y: -11 },
+ rightArm: { rotation: 130, x: 6, y: -11 },
+ accessories: { bounce: -8, rotation: -5 },
+ },
+ ],
+ },
+
+ // 8. hurt - Recoil backwards
+ {
+ id: 'hurt',
+ name: 'Hurt',
+ modes: ['UPPER_BODY'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: -5, y: 3, rotation: -15, scaleX: 1.1, scaleY: 0.9 },
+ eyes: { scaleY: 0.2 },
+ body: { x: -3, y: 3, rotation: -10 },
+ leftArm: { rotation: 30, x: -5, y: 5 },
+ rightArm: { rotation: 45, x: -3, y: 5 },
+ accessories: { bounce: 5, rotation: -20 },
+ },
+ {
+ head: { x: 2, y: 0, rotation: 5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 0.8 },
+ body: { x: 1, y: 0, rotation: 3 },
+ leftArm: { rotation: 10, x: 0, y: 0 },
+ rightArm: { rotation: -10, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 5 },
+ },
+ ],
+ },
+ ];
+
+ // =============================================================================
+ // FULL_BODY ANIMATIONS (12 animations)
+ // =============================================================================
+
+ var FULL_BODY_ANIMATIONS = [
+ // 1. idle - Standing breathing
+ {
+ id: 'idle',
+ name: 'Idle',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 3,
+ frameDuration: 400,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 1, rotation: 0 },
+ leftArm: { rotation: 2, x: 0, y: 0 },
+ rightArm: { rotation: -2, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: -1, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 2. walk - Walk cycle with leg/arm swing
+ {
+ id: 'walk',
+ name: 'Walk Cycle',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 4,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 15, x: 0, y: 0 },
+ rightArm: { rotation: -15, x: 0, y: 0 },
+ leftLeg: { rotation: -20, x: 0, y: 0 },
+ rightLeg: { rotation: 20, x: 0, y: -2 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: -2 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: -1, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: -15, x: 0, y: 0 },
+ rightArm: { rotation: 15, x: 0, y: 0 },
+ leftLeg: { rotation: 20, x: 0, y: -2 },
+ rightLeg: { rotation: -20, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: -2 },
+ accessories: { bounce: -1, rotation: 0 },
+ },
+ ],
+ },
+
+ // 3. run - Faster with more movement
+ {
+ id: 'run',
+ name: 'Run Cycle',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 6,
+ frameDuration: 60,
+ frames: [
+ {
+ head: { x: 0, y: -2, rotation: 2, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 8 },
+ leftArm: { rotation: 45, x: 3, y: -2 },
+ rightArm: { rotation: -60, x: -5, y: 0 },
+ leftLeg: { rotation: -40, x: -3, y: -5 },
+ rightLeg: { rotation: 35, x: 5, y: 0 },
+ accessories: { bounce: -3, rotation: 2 },
+ },
+ {
+ head: { x: 0, y: -4, rotation: 3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -3, rotation: 10 },
+ leftArm: { rotation: 30, x: 2, y: -3 },
+ rightArm: { rotation: -45, x: -3, y: -1 },
+ leftLeg: { rotation: -20, x: -1, y: -8 },
+ rightLeg: { rotation: 15, x: 2, y: -3 },
+ accessories: { bounce: -5, rotation: 3 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 2, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 8 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 10, x: 1, y: -4 },
+ rightLeg: { rotation: -15, x: -2, y: -1 },
+ accessories: { bounce: -3, rotation: 2 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: -2, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 8 },
+ leftArm: { rotation: -60, x: -5, y: 0 },
+ rightArm: { rotation: 45, x: 3, y: -2 },
+ leftLeg: { rotation: 35, x: 5, y: 0 },
+ rightLeg: { rotation: -40, x: -3, y: -5 },
+ accessories: { bounce: -3, rotation: -2 },
+ },
+ {
+ head: { x: 0, y: -4, rotation: -3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -3, rotation: 10 },
+ leftArm: { rotation: -45, x: -3, y: -1 },
+ rightArm: { rotation: 30, x: 2, y: -3 },
+ leftLeg: { rotation: 15, x: 2, y: -3 },
+ rightLeg: { rotation: -20, x: -1, y: -8 },
+ accessories: { bounce: -5, rotation: -3 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: -2, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 8 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: -15, x: -2, y: -1 },
+ rightLeg: { rotation: 10, x: 1, y: -4 },
+ accessories: { bounce: -3, rotation: -2 },
+ },
+ ],
+ },
+
+ // 4. jump - Crouch, leap, stretch
+ {
+ id: 'jump',
+ name: 'Jump',
+ modes: ['FULL_BODY'],
+ loop: false,
+ frameCount: 3,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 4, rotation: 0, scaleX: 1, scaleY: 0.9 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 6, rotation: 0 },
+ leftArm: { rotation: 20, x: -2, y: 3 },
+ rightArm: { rotation: -20, x: 2, y: 3 },
+ leftLeg: { rotation: 30, x: -3, y: 5 },
+ rightLeg: { rotation: -30, x: 3, y: 5 },
+ accessories: { bounce: 5, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -15, rotation: 0, scaleX: 1, scaleY: 1.1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 0, y: -12, rotation: 0 },
+ leftArm: { rotation: -120, x: -5, y: -10 },
+ rightArm: { rotation: 120, x: 5, y: -10 },
+ leftLeg: { rotation: 15, x: -1, y: -3 },
+ rightLeg: { rotation: -15, x: 1, y: -3 },
+ accessories: { bounce: -18, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -20, rotation: 0, scaleX: 1, scaleY: 1.15 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -18, rotation: 0 },
+ leftArm: { rotation: -90, x: -8, y: -12 },
+ rightArm: { rotation: 90, x: 8, y: -12 },
+ leftLeg: { rotation: 5, x: 0, y: -5 },
+ rightLeg: { rotation: -5, x: 0, y: -5 },
+ accessories: { bounce: -22, rotation: 0 },
+ },
+ ],
+ },
+
+ // 5. fall - Arms up, legs down
+ {
+ id: 'fall',
+ name: 'Fall',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 2,
+ frameDuration: 150,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.2 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: -150, x: -5, y: -5 },
+ rightArm: { rotation: 150, x: 5, y: -5 },
+ leftLeg: { rotation: 10, x: -2, y: 5 },
+ rightLeg: { rotation: -10, x: 2, y: 5 },
+ accessories: { bounce: -5, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.2 },
+ body: { x: 0, y: -1, rotation: 2 },
+ leftArm: { rotation: -140, x: -6, y: -6 },
+ rightArm: { rotation: 160, x: 4, y: -4 },
+ leftLeg: { rotation: 15, x: -3, y: 6 },
+ rightLeg: { rotation: -5, x: 1, y: 4 },
+ accessories: { bounce: -7, rotation: 3 },
+ },
+ ],
+ },
+
+ // 6. crouch - Duck down
+ {
+ id: 'crouch',
+ name: 'Crouch',
+ modes: ['FULL_BODY'],
+ loop: false,
+ frameCount: 2,
+ frameDuration: 80,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: 0, x: 0, y: 0 },
+ rightArm: { rotation: 0, x: 0, y: 0 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 15, rotation: -5, scaleX: 1, scaleY: 0.9 },
+ eyes: { scaleY: 0.8 },
+ body: { x: 0, y: 12, rotation: -8 },
+ leftArm: { rotation: 30, x: -3, y: 8 },
+ rightArm: { rotation: -30, x: 3, y: 8 },
+ leftLeg: { rotation: 60, x: -5, y: 10 },
+ rightLeg: { rotation: -60, x: 5, y: 10 },
+ accessories: { bounce: 12, rotation: -5 },
+ },
+ ],
+ },
+
+ // 7. celebrate - Jump and arm wave
+ {
+ id: 'celebrate',
+ name: 'Celebrate',
+ modes: ['FULL_BODY'],
+ loop: false,
+ frameCount: 6,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 3, rotation: 0, scaleX: 1, scaleY: 0.9 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 4, rotation: 0 },
+ leftArm: { rotation: 10, x: 0, y: 2 },
+ rightArm: { rotation: -10, x: 0, y: 2 },
+ leftLeg: { rotation: 15, x: -1, y: 3 },
+ rightLeg: { rotation: -15, x: 1, y: 3 },
+ accessories: { bounce: 4, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -12, rotation: 0, scaleX: 1.05, scaleY: 1.1 },
+ eyes: { scaleY: 0.8 },
+ body: { x: 0, y: -10, rotation: 0 },
+ leftArm: { rotation: -135, x: -8, y: -12 },
+ rightArm: { rotation: 135, x: 8, y: -12 },
+ leftLeg: { rotation: 10, x: -2, y: -5 },
+ rightLeg: { rotation: -10, x: 2, y: -5 },
+ accessories: { bounce: -15, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: -10, rotation: 8, scaleX: 1.03, scaleY: 1.08 },
+ eyes: { scaleY: 0.85 },
+ body: { x: 0, y: -8, rotation: 5 },
+ leftArm: { rotation: -120, x: -10, y: -10 },
+ rightArm: { rotation: 150, x: 6, y: -14 },
+ leftLeg: { rotation: 5, x: -1, y: -4 },
+ rightLeg: { rotation: -5, x: 1, y: -4 },
+ accessories: { bounce: -12, rotation: 8 },
+ },
+ {
+ head: { x: 0, y: -10, rotation: -8, scaleX: 1.03, scaleY: 1.08 },
+ eyes: { scaleY: 0.85 },
+ body: { x: 0, y: -8, rotation: -5 },
+ leftArm: { rotation: -150, x: -6, y: -14 },
+ rightArm: { rotation: 120, x: 10, y: -10 },
+ leftLeg: { rotation: 5, x: -1, y: -4 },
+ rightLeg: { rotation: -5, x: 1, y: -4 },
+ accessories: { bounce: -12, rotation: -8 },
+ },
+ {
+ head: { x: 0, y: -8, rotation: 5, scaleX: 1.02, scaleY: 1.05 },
+ eyes: { scaleY: 0.9 },
+ body: { x: 0, y: -6, rotation: 3 },
+ leftArm: { rotation: -130, x: -8, y: -11 },
+ rightArm: { rotation: 140, x: 7, y: -12 },
+ leftLeg: { rotation: 3, x: 0, y: -3 },
+ rightLeg: { rotation: -3, x: 0, y: -3 },
+ accessories: { bounce: -10, rotation: 5 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 0 },
+ leftArm: { rotation: -60, x: -3, y: -5 },
+ rightArm: { rotation: 60, x: 3, y: -5 },
+ leftLeg: { rotation: 0, x: 0, y: 0 },
+ rightLeg: { rotation: 0, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: 0 },
+ },
+ ],
+ },
+
+ // 8. attack - Punch or swing
+ {
+ id: 'attack',
+ name: 'Attack',
+ modes: ['FULL_BODY'],
+ loop: false,
+ frameCount: 4,
+ frameDuration: 60,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: -5 },
+ leftArm: { rotation: 15, x: -2, y: 0 },
+ rightArm: { rotation: -90, x: -5, y: -5 },
+ leftLeg: { rotation: 5, x: 0, y: 0 },
+ rightLeg: { rotation: -10, x: 0, y: 0 },
+ accessories: { bounce: 0, rotation: -3 },
+ },
+ {
+ head: { x: 2, y: 0, rotation: 5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 1, y: 0, rotation: 10 },
+ leftArm: { rotation: 20, x: -3, y: 0 },
+ rightArm: { rotation: -45, x: 15, y: 0 },
+ leftLeg: { rotation: -10, x: -2, y: 0 },
+ rightLeg: { rotation: 15, x: 3, y: -2 },
+ accessories: { bounce: 0, rotation: 5 },
+ },
+ {
+ head: { x: 3, y: -1, rotation: 8, scaleX: 1.02, scaleY: 0.98 },
+ eyes: { scaleY: 1 },
+ body: { x: 2, y: 0, rotation: 15 },
+ leftArm: { rotation: 25, x: -4, y: 0 },
+ rightArm: { rotation: -30, x: 20, y: 2 },
+ leftLeg: { rotation: -15, x: -3, y: 0 },
+ rightLeg: { rotation: 20, x: 5, y: -3 },
+ accessories: { bounce: -1, rotation: 8 },
+ },
+ {
+ head: { x: 1, y: 0, rotation: 3, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 5 },
+ leftArm: { rotation: 10, x: -1, y: 0 },
+ rightArm: { rotation: -60, x: 8, y: -2 },
+ leftLeg: { rotation: -5, x: -1, y: 0 },
+ rightLeg: { rotation: 8, x: 2, y: -1 },
+ accessories: { bounce: 0, rotation: 3 },
+ },
+ ],
+ },
+
+ // 9. hurt - Stagger back
+ {
+ id: 'hurt',
+ name: 'Hurt',
+ modes: ['FULL_BODY'],
+ loop: false,
+ frameCount: 3,
+ frameDuration: 80,
+ frames: [
+ {
+ head: { x: -5, y: 3, rotation: -15, scaleX: 1.1, scaleY: 0.9 },
+ eyes: { scaleY: 0.2 },
+ body: { x: -4, y: 3, rotation: -12 },
+ leftArm: { rotation: 40, x: -5, y: 5 },
+ rightArm: { rotation: 50, x: -3, y: 6 },
+ leftLeg: { rotation: 20, x: -3, y: 2 },
+ rightLeg: { rotation: -15, x: 2, y: 0 },
+ accessories: { bounce: 5, rotation: -20 },
+ },
+ {
+ head: { x: -8, y: 5, rotation: -20, scaleX: 1.05, scaleY: 0.95 },
+ eyes: { scaleY: 0.1 },
+ body: { x: -6, y: 5, rotation: -18 },
+ leftArm: { rotation: 60, x: -8, y: 8 },
+ rightArm: { rotation: 70, x: -5, y: 10 },
+ leftLeg: { rotation: 30, x: -5, y: 4 },
+ rightLeg: { rotation: -25, x: 3, y: 2 },
+ accessories: { bounce: 8, rotation: -25 },
+ },
+ {
+ head: { x: -2, y: 1, rotation: -5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 0.7 },
+ body: { x: -1, y: 1, rotation: -3 },
+ leftArm: { rotation: 15, x: -2, y: 2 },
+ rightArm: { rotation: 20, x: -1, y: 2 },
+ leftLeg: { rotation: 8, x: -1, y: 1 },
+ rightLeg: { rotation: -5, x: 1, y: 0 },
+ accessories: { bounce: 2, rotation: -5 },
+ },
+ ],
+ },
+
+ // 10. climb - Climbing motion
+ {
+ id: 'climb',
+ name: 'Climb',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 4,
+ frameDuration: 120,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: -5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: -3 },
+ leftArm: { rotation: -150, x: -3, y: -15 },
+ rightArm: { rotation: 60, x: 5, y: 5 },
+ leftLeg: { rotation: 40, x: -2, y: 8 },
+ rightLeg: { rotation: -30, x: 2, y: -5 },
+ accessories: { bounce: 0, rotation: -5 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -2, rotation: 0 },
+ leftArm: { rotation: -120, x: -4, y: -10 },
+ rightArm: { rotation: 30, x: 4, y: 0 },
+ leftLeg: { rotation: 20, x: -1, y: 3 },
+ rightLeg: { rotation: -10, x: 1, y: -2 },
+ accessories: { bounce: -2, rotation: 0 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 5, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 3 },
+ leftArm: { rotation: 60, x: -5, y: 5 },
+ rightArm: { rotation: -150, x: 3, y: -15 },
+ leftLeg: { rotation: -30, x: -2, y: -5 },
+ rightLeg: { rotation: 40, x: 2, y: 8 },
+ accessories: { bounce: 0, rotation: 5 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 0, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -2, rotation: 0 },
+ leftArm: { rotation: 30, x: -4, y: 0 },
+ rightArm: { rotation: -120, x: 4, y: -10 },
+ leftLeg: { rotation: -10, x: -1, y: -2 },
+ rightLeg: { rotation: 20, x: 1, y: 3 },
+ accessories: { bounce: -2, rotation: 0 },
+ },
+ ],
+ },
+
+ // 11. swim - Swimming strokes
+ {
+ id: 'swim',
+ name: 'Swim',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 4,
+ frameDuration: 150,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 10, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 15 },
+ leftArm: { rotation: -60, x: 10, y: -5 },
+ rightArm: { rotation: 30, x: -5, y: 5 },
+ leftLeg: { rotation: 20, x: 0, y: 5 },
+ rightLeg: { rotation: -20, x: 0, y: -5 },
+ accessories: { bounce: 0, rotation: 10 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 15, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 20 },
+ leftArm: { rotation: -30, x: 5, y: -8 },
+ rightArm: { rotation: -60, x: 10, y: -5 },
+ leftLeg: { rotation: -10, x: 0, y: -3 },
+ rightLeg: { rotation: 10, x: 0, y: 3 },
+ accessories: { bounce: -2, rotation: 15 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 10, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: 0, rotation: 15 },
+ leftArm: { rotation: 30, x: -5, y: 5 },
+ rightArm: { rotation: -60, x: 10, y: -5 },
+ leftLeg: { rotation: -20, x: 0, y: -5 },
+ rightLeg: { rotation: 20, x: 0, y: 5 },
+ accessories: { bounce: 0, rotation: 10 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 15, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1 },
+ body: { x: 0, y: -1, rotation: 20 },
+ leftArm: { rotation: -60, x: 10, y: -5 },
+ rightArm: { rotation: -30, x: 5, y: -8 },
+ leftLeg: { rotation: 10, x: 0, y: 3 },
+ rightLeg: { rotation: -10, x: 0, y: -3 },
+ accessories: { bounce: -2, rotation: 15 },
+ },
+ ],
+ },
+
+ // 12. fly - Superhero flying pose
+ {
+ id: 'fly',
+ name: 'Fly',
+ modes: ['FULL_BODY'],
+ loop: true,
+ frameCount: 4,
+ frameDuration: 100,
+ frames: [
+ {
+ head: { x: 0, y: 0, rotation: 25, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 0, y: 0, rotation: 30 },
+ leftArm: { rotation: -170, x: 10, y: -15 },
+ rightArm: { rotation: -160, x: 12, y: -12 },
+ leftLeg: { rotation: 20, x: 0, y: 10 },
+ rightLeg: { rotation: 25, x: 2, y: 12 },
+ accessories: { bounce: -5, rotation: 25 },
+ },
+ {
+ head: { x: 0, y: -2, rotation: 28, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 0, y: -1, rotation: 32 },
+ leftArm: { rotation: -175, x: 12, y: -17 },
+ rightArm: { rotation: -165, x: 14, y: -14 },
+ leftLeg: { rotation: 15, x: -1, y: 8 },
+ rightLeg: { rotation: 20, x: 1, y: 10 },
+ accessories: { bounce: -7, rotation: 28 },
+ },
+ {
+ head: { x: 0, y: 0, rotation: 25, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 0, y: 0, rotation: 30 },
+ leftArm: { rotation: -165, x: 8, y: -13 },
+ rightArm: { rotation: -155, x: 10, y: -10 },
+ leftLeg: { rotation: 25, x: 1, y: 12 },
+ rightLeg: { rotation: 30, x: 3, y: 14 },
+ accessories: { bounce: -5, rotation: 25 },
+ },
+ {
+ head: { x: 0, y: -1, rotation: 27, scaleX: 1, scaleY: 1 },
+ eyes: { scaleY: 1.1 },
+ body: { x: 0, y: -1, rotation: 31 },
+ leftArm: { rotation: -172, x: 11, y: -16 },
+ rightArm: { rotation: -162, x: 13, y: -13 },
+ leftLeg: { rotation: 18, x: 0, y: 9 },
+ rightLeg: { rotation: 22, x: 2, y: 11 },
+ accessories: { bounce: -6, rotation: 27 },
+ },
+ ],
+ },
+ ];
+
+ // =============================================================================
+ // ORGANIZE ANIMATIONS BY MODE
+ // =============================================================================
+
+ var ANIMATIONS = {
+ HEAD_ONLY: HEAD_ONLY_ANIMATIONS,
+ HEAD_AND_SHOULDERS: HEAD_AND_SHOULDERS_ANIMATIONS,
+ UPPER_BODY: UPPER_BODY_ANIMATIONS,
+ FULL_BODY: FULL_BODY_ANIMATIONS,
+ };
+
+ // Create a flat map for quick lookup by animation ID
+ var ANIMATION_MAP = {};
+ Object.keys(ANIMATIONS).forEach(function(modeName) {
+ ANIMATION_MAP[modeName] = {};
+ ANIMATIONS[modeName].forEach(function(anim) {
+ ANIMATION_MAP[modeName][anim.id] = anim;
+ });
+ });
+
+ // =============================================================================
+ // HELPER FUNCTIONS
+ // =============================================================================
+
+ /**
+ * Get mode configuration by name
+ */
+ function getMode(modeName) {
+ return MODES[modeName] || null;
+ }
+
+ /**
+ * Get a specific animation for a mode
+ */
+ function getAnimation(modeName, animationId) {
+ if (!ANIMATION_MAP[modeName]) return null;
+ return ANIMATION_MAP[modeName][animationId] || null;
+ }
+
+ /**
+ * Get all animations for a mode
+ */
+ function getAnimationsForMode(modeName) {
+ return ANIMATIONS[modeName] || [];
+ }
+
+ /**
+ * Get a specific frame from an animation
+ */
+ function getFrame(animation, frameIndex) {
+ if (!animation || !animation.frames) return defaultFrame();
+ var index = frameIndex % animation.frames.length;
+ return animation.frames[index];
+ }
+
+ /**
+ * Interpolate between two values
+ */
+ function lerp(a, b, t) {
+ return a + (b - a) * t;
+ }
+
+ /**
+ * Deep interpolate between two frame objects
+ */
+ function interpolateObjects(frameA, frameB, t) {
+ var result = {};
+
+ Object.keys(frameA).forEach(function(key) {
+ if (typeof frameA[key] === 'object' && frameA[key] !== null) {
+ result[key] = {};
+ Object.keys(frameA[key]).forEach(function(subKey) {
+ var valA = frameA[key][subKey];
+ var valB = frameB[key] && frameB[key][subKey] !== undefined ? frameB[key][subKey] : valA;
+ result[key][subKey] = lerp(valA, valB, t);
+ });
+ } else {
+ var valA = frameA[key];
+ var valB = frameB[key] !== undefined ? frameB[key] : valA;
+ result[key] = lerp(valA, valB, t);
+ }
+ });
+
+ // Include any keys in frameB that aren't in frameA
+ Object.keys(frameB).forEach(function(key) {
+ if (!(key in frameA)) {
+ if (typeof frameB[key] === 'object' && frameB[key] !== null) {
+ result[key] = {};
+ Object.keys(frameB[key]).forEach(function(subKey) {
+ result[key][subKey] = frameB[key][subKey];
+ });
+ } else {
+ result[key] = frameB[key];
+ }
+ }
+ });
+
+ return result;
+ }
+
+ /**
+ * Interpolate frame data based on animation progress (0-1)
+ * Returns smoothly interpolated transform values between frames
+ */
+ function interpolateFrame(animation, progress) {
+ if (!animation || !animation.frames || animation.frames.length === 0) {
+ return defaultFrame();
+ }
+
+ var frameCount = animation.frames.length;
+ var exactFrame = progress * frameCount;
+ var frameIndex = Math.floor(exactFrame);
+ var frameFraction = exactFrame - frameIndex;
+
+ // Handle looping
+ if (animation.loop) {
+ frameIndex = frameIndex % frameCount;
+ } else {
+ frameIndex = Math.min(frameIndex, frameCount - 1);
+ }
+
+ var nextFrameIndex;
+ if (animation.loop) {
+ nextFrameIndex = (frameIndex + 1) % frameCount;
+ } else {
+ nextFrameIndex = Math.min(frameIndex + 1, frameCount - 1);
+ }
+
+ var frameA = animation.frames[frameIndex];
+ var frameB = animation.frames[nextFrameIndex];
+
+ // If we're at the last frame of a non-looping animation, just return it
+ if (!animation.loop && frameIndex === frameCount - 1) {
+ return frameA;
+ }
+
+ return interpolateObjects(frameA, frameB, frameFraction);
+ }
+
+ /**
+ * Get the duration of each frame in milliseconds
+ */
+ function getFrameDuration(animation) {
+ return animation && animation.frameDuration ? animation.frameDuration : 100;
+ }
+
+ /**
+ * Get the total duration of an animation in milliseconds
+ */
+ function getTotalDuration(animation) {
+ if (!animation || !animation.frames) return 0;
+ return animation.frames.length * getFrameDuration(animation);
+ }
+
+ /**
+ * Check if an animation loops
+ */
+ function isLooping(animation) {
+ return animation && animation.loop === true;
+ }
+
+ // =============================================================================
+ // PUBLIC API
+ // =============================================================================
+
+ window.AvatarAnimations = {
+ MODES: MODES,
+ ANIMATIONS: ANIMATIONS,
+ ACCESSORY_ANIMATION: ACCESSORY_ANIMATION,
+
+ getMode: getMode,
+ getAnimation: getAnimation,
+ getAnimationsForMode: getAnimationsForMode,
+ getFrame: getFrame,
+ interpolateFrame: interpolateFrame,
+ getFrameDuration: getFrameDuration,
+ getTotalDuration: getTotalDuration,
+ isLooping: isLooping,
+ defaultFrame: defaultFrame,
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/avatarData.js b/frontend/public/assets/avatar/avatarData.js
new file mode 100644
index 0000000..af1e3bd
--- /dev/null
+++ b/frontend/public/assets/avatar/avatarData.js
@@ -0,0 +1,473 @@
+(function() {
+ 'use strict';
+
+ // Face shapes (8 options)
+ var FACE_SHAPES = [
+ { id: 'round', name: 'Round', widthRatio: 1.0, heightRatio: 1.0, jawCurve: 0.9 },
+ { id: 'oval', name: 'Oval', widthRatio: 0.85, heightRatio: 1.1, jawCurve: 0.7 },
+ { id: 'square', name: 'Square', widthRatio: 1.0, heightRatio: 0.95, jawCurve: 0.3 },
+ { id: 'heart', name: 'Heart', widthRatio: 0.9, heightRatio: 1.05, jawCurve: 0.8, chinPoint: 0.6 },
+ { id: 'long', name: 'Long', widthRatio: 0.8, heightRatio: 1.2, jawCurve: 0.5 },
+ { id: 'wide', name: 'Wide', widthRatio: 1.15, heightRatio: 0.9, jawCurve: 0.6 },
+ { id: 'angular', name: 'Angular', widthRatio: 0.95, heightRatio: 1.0, jawCurve: 0.2, cheekbones: 0.7 },
+ { id: 'soft', name: 'Soft', widthRatio: 1.05, heightRatio: 1.0, jawCurve: 0.95 }
+ ];
+
+ // Skin tones (20 options - full diversity)
+ var SKIN_TONES = [
+ { id: 'fair1', hex: '#FFECD1', name: 'Porcelain' },
+ { id: 'fair2', hex: '#FFE4C4', name: 'Ivory' },
+ { id: 'fair3', hex: '#FFDAB9', name: 'Peach' },
+ { id: 'fair4', hex: '#FFD5B5', name: 'Cream' },
+ { id: 'light1', hex: '#F5D0A9', name: 'Light Beige' },
+ { id: 'light2', hex: '#E8C49A', name: 'Warm Beige' },
+ { id: 'light3', hex: '#DEB887', name: 'Sand' },
+ { id: 'medium1', hex: '#D4A373', name: 'Golden' },
+ { id: 'medium2', hex: '#C9A06A', name: 'Honey' },
+ { id: 'medium3', hex: '#BC8F5F', name: 'Caramel' },
+ { id: 'medium4', hex: '#B07D4B', name: 'Toffee' },
+ { id: 'tan1', hex: '#A67B5B', name: 'Tan' },
+ { id: 'tan2', hex: '#996B4D', name: 'Bronze' },
+ { id: 'tan3', hex: '#8B6D4C', name: 'Cinnamon' },
+ { id: 'brown1', hex: '#7D5A3C', name: 'Chestnut' },
+ { id: 'brown2', hex: '#6F4E37', name: 'Cocoa' },
+ { id: 'brown3', hex: '#5D4532', name: 'Espresso' },
+ { id: 'dark1', hex: '#4E3B2D', name: 'Mocha' },
+ { id: 'dark2', hex: '#3D2B1F', name: 'Ebony' },
+ { id: 'dark3', hex: '#2C1F15', name: 'Onyx' }
+ ];
+
+ // Eye shapes (12 options)
+ var EYE_SHAPES = [
+ { id: 'round', name: 'Round', width: 0.15, height: 0.12, curve: 1.0 },
+ { id: 'almond', name: 'Almond', width: 0.18, height: 0.08, curve: 0.6 },
+ { id: 'oval', name: 'Oval', width: 0.16, height: 0.10, curve: 0.8 },
+ { id: 'narrow', name: 'Narrow', width: 0.20, height: 0.06, curve: 0.4 },
+ { id: 'wide', name: 'Wide', width: 0.19, height: 0.11, curve: 0.7 },
+ { id: 'droopy', name: 'Droopy', width: 0.17, height: 0.09, curve: 0.5, droop: 0.3 },
+ { id: 'upturned', name: 'Upturned', width: 0.17, height: 0.09, curve: 0.6, upturn: 0.3 },
+ { id: 'cat', name: 'Cat', width: 0.18, height: 0.07, curve: 0.4, upturn: 0.5 },
+ { id: 'sleepy', name: 'Sleepy', width: 0.16, height: 0.07, curve: 0.5, lidDroop: 0.4 },
+ { id: 'surprised', name: 'Surprised', width: 0.16, height: 0.14, curve: 1.0 },
+ { id: 'angry', name: 'Angry', width: 0.17, height: 0.08, curve: 0.5, innerAngle: -0.2 },
+ { id: 'happy', name: 'Happy', width: 0.17, height: 0.08, curve: 0.7, squint: 0.3 }
+ ];
+
+ // Eye colors (16 options)
+ var EYE_COLORS = [
+ { id: 'brown', hex: '#634e34', name: 'Brown' },
+ { id: 'darkBrown', hex: '#3d2314', name: 'Dark Brown' },
+ { id: 'hazel', hex: '#8b7355', name: 'Hazel' },
+ { id: 'green', hex: '#3d7a3d', name: 'Green' },
+ { id: 'blue', hex: '#4682b4', name: 'Blue' },
+ { id: 'lightBlue', hex: '#87ceeb', name: 'Light Blue' },
+ { id: 'gray', hex: '#708090', name: 'Gray' },
+ { id: 'amber', hex: '#b8860b', name: 'Amber' },
+ { id: 'violet', hex: '#8b008b', name: 'Violet' },
+ { id: 'emerald', hex: '#50c878', name: 'Emerald' },
+ { id: 'iceBlue', hex: '#a5d8e6', name: 'Ice Blue' },
+ { id: 'gold', hex: '#daa520', name: 'Gold' },
+ { id: 'red', hex: '#8b0000', name: 'Red' },
+ { id: 'black', hex: '#1a1a1a', name: 'Black' },
+ { id: 'honey', hex: '#c9a06a', name: 'Honey' },
+ { id: 'turquoise', hex: '#40e0d0', name: 'Turquoise' }
+ ];
+
+ // Eyebrow shapes (10 options)
+ var EYEBROW_SHAPES = [
+ { id: 'natural', name: 'Natural', thickness: 0.5, arch: 0.3 },
+ { id: 'arched', name: 'Arched', thickness: 0.5, arch: 0.6 },
+ { id: 'straight', name: 'Straight', thickness: 0.5, arch: 0.0 },
+ { id: 'thick', name: 'Thick', thickness: 0.8, arch: 0.3 },
+ { id: 'thin', name: 'Thin', thickness: 0.25, arch: 0.3 },
+ { id: 'angry', name: 'Angry', thickness: 0.5, arch: 0.2, innerAngle: -0.4 },
+ { id: 'worried', name: 'Worried', thickness: 0.5, arch: 0.4, innerAngle: 0.3 },
+ { id: 'bushy', name: 'Bushy', thickness: 0.9, arch: 0.2, wild: true },
+ { id: 'sleek', name: 'Sleek', thickness: 0.3, arch: 0.5 },
+ { id: 'curved', name: 'Curved', thickness: 0.5, arch: 0.7 }
+ ];
+
+ // Nose shapes (8 options)
+ var NOSE_SHAPES = [
+ { id: 'button', name: 'Button', width: 0.12, length: 0.15 },
+ { id: 'pointed', name: 'Pointed', width: 0.10, length: 0.22 },
+ { id: 'roman', name: 'Roman', width: 0.14, length: 0.25, bridge: 0.6 },
+ { id: 'snub', name: 'Snub', width: 0.13, length: 0.12, upturn: 0.4 },
+ { id: 'wide', name: 'Wide', width: 0.18, length: 0.18 },
+ { id: 'narrow', name: 'Narrow', width: 0.08, length: 0.20 },
+ { id: 'aquiline', name: 'Aquiline', width: 0.12, length: 0.24, bridge: 0.8 },
+ { id: 'flat', name: 'Flat', width: 0.16, length: 0.10 }
+ ];
+
+ // Mouth shapes (10 options)
+ var MOUTH_SHAPES = [
+ { id: 'smile', name: 'Smile', width: 0.25, curve: 0.3 },
+ { id: 'neutral', name: 'Neutral', width: 0.22, curve: 0 },
+ { id: 'grin', name: 'Grin', width: 0.30, curve: 0.5, showTeeth: true },
+ { id: 'smirk', name: 'Smirk', width: 0.22, curve: 0.2, asymmetric: true },
+ { id: 'pout', name: 'Pout', width: 0.20, curve: -0.2, lipFullness: 0.8 },
+ { id: 'open', name: 'Open', width: 0.24, curve: 0.1, openness: 0.3 },
+ { id: 'small', name: 'Small', width: 0.16, curve: 0.1 },
+ { id: 'wide', name: 'Wide', width: 0.32, curve: 0.1 },
+ { id: 'thin', name: 'Thin', width: 0.24, curve: 0, lipFullness: 0.3 },
+ { id: 'full', name: 'Full', width: 0.26, curve: 0.15, lipFullness: 0.9 }
+ ];
+
+ // Ear shapes (6 options)
+ var EAR_SHAPES = [
+ { id: 'normal', name: 'Normal', size: 1.0, angle: 0 },
+ { id: 'small', name: 'Small', size: 0.7, angle: 0 },
+ { id: 'large', name: 'Large', size: 1.3, angle: 0 },
+ { id: 'pointed', name: 'Pointed', size: 1.0, angle: 0, pointed: true },
+ { id: 'round', name: 'Round', size: 1.0, angle: 0, roundness: 0.9 },
+ { id: 'flat', name: 'Flat', size: 0.9, angle: 15 }
+ ];
+
+ // Hair styles (30 options)
+ var HAIR_STYLES = [
+ { id: 'short-messy', name: 'Short Messy', length: 'short', coverage: 0.3 },
+ { id: 'short-neat', name: 'Short Neat', length: 'short', coverage: 0.25 },
+ { id: 'short-curly', name: 'Short Curly', length: 'short', coverage: 0.35 },
+ { id: 'medium-straight', name: 'Medium Straight', length: 'medium', coverage: 0.5 },
+ { id: 'medium-wavy', name: 'Medium Wavy', length: 'medium', coverage: 0.55 },
+ { id: 'long-straight', name: 'Long Straight', length: 'long', coverage: 0.7 },
+ { id: 'long-wavy', name: 'Long Wavy', length: 'long', coverage: 0.75 },
+ { id: 'ponytail', name: 'Ponytail', length: 'medium', coverage: 0.4 },
+ { id: 'pigtails', name: 'Pigtails', length: 'medium', coverage: 0.45 },
+ { id: 'bun', name: 'Bun', length: 'long', coverage: 0.35 },
+ { id: 'mohawk', name: 'Mohawk', length: 'short', coverage: 0.2 },
+ { id: 'afro', name: 'Afro', length: 'medium', coverage: 0.8 },
+ { id: 'buzz', name: 'Buzz Cut', length: 'short', coverage: 0.15 },
+ { id: 'bald', name: 'Bald', length: 'none', coverage: 0 },
+ { id: 'sidepart', name: 'Side Part', length: 'short', coverage: 0.3 },
+ { id: 'spiky', name: 'Spiky', length: 'short', coverage: 0.35, spikes: true },
+ { id: 'slicked', name: 'Slicked Back', length: 'short', coverage: 0.25 },
+ { id: 'bob', name: 'Bob', length: 'medium', coverage: 0.5 },
+ { id: 'pixie', name: 'Pixie', length: 'short', coverage: 0.35 },
+ { id: 'braids', name: 'Braids', length: 'long', coverage: 0.6 },
+ { id: 'dreads', name: 'Dreads', length: 'long', coverage: 0.7 },
+ { id: 'undercut', name: 'Undercut', length: 'short', coverage: 0.25, sides: 'shaved' },
+ { id: 'fauxhawk', name: 'Faux Hawk', length: 'short', coverage: 0.3 },
+ { id: 'mullet', name: 'Mullet', length: 'medium', coverage: 0.45, backLength: 'long' },
+ { id: 'bowl', name: 'Bowl Cut', length: 'medium', coverage: 0.55 },
+ { id: 'combover', name: 'Comb Over', length: 'short', coverage: 0.2 },
+ { id: 'bangs', name: 'Bangs', length: 'medium', coverage: 0.5, bangs: true },
+ { id: 'sidesweep', name: 'Side Sweep', length: 'medium', coverage: 0.45 },
+ { id: 'topknot', name: 'Top Knot', length: 'long', coverage: 0.3 },
+ { id: 'cornrows', name: 'Cornrows', length: 'medium', coverage: 0.4 }
+ ];
+
+ // Hair colors (20 options)
+ var HAIR_COLORS = [
+ { id: 'black', hex: '#1a1a1a', name: 'Black' },
+ { id: 'darkBrown', hex: '#2c1810', name: 'Dark Brown' },
+ { id: 'brown', hex: '#4a3728', name: 'Brown' },
+ { id: 'lightBrown', hex: '#7a5a3a', name: 'Light Brown' },
+ { id: 'auburn', hex: '#8b4513', name: 'Auburn' },
+ { id: 'ginger', hex: '#b5651d', name: 'Ginger' },
+ { id: 'strawberry', hex: '#c67171', name: 'Strawberry' },
+ { id: 'blonde', hex: '#d4b896', name: 'Blonde' },
+ { id: 'platinum', hex: '#e8e4c9', name: 'Platinum' },
+ { id: 'white', hex: '#f0f0f0', name: 'White' },
+ { id: 'gray', hex: '#808080', name: 'Gray' },
+ { id: 'red', hex: '#cc0000', name: 'Red' },
+ { id: 'blue', hex: '#0066cc', name: 'Blue' },
+ { id: 'purple', hex: '#7b2d8e', name: 'Purple' },
+ { id: 'green', hex: '#228b22', name: 'Green' },
+ { id: 'pink', hex: '#ff69b4', name: 'Pink' },
+ { id: 'teal', hex: '#008b8b', name: 'Teal' },
+ { id: 'orange', hex: '#ff6600', name: 'Orange' },
+ { id: 'silver', hex: '#c0c0c0', name: 'Silver' },
+ { id: 'rainbow', hex: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #8b00ff)', name: 'Rainbow', isGradient: true }
+ ];
+
+ // Facial hair (10 options)
+ var FACIAL_HAIR = [
+ { id: 'none', name: 'None' },
+ { id: 'stubble', name: 'Stubble', density: 0.3, length: 0.1 },
+ { id: 'goatee', name: 'Goatee', coverage: 'chin', length: 0.4 },
+ { id: 'mustache', name: 'Mustache', coverage: 'upper-lip', length: 0.3, style: 'classic' },
+ { id: 'fullBeard', name: 'Full Beard', coverage: 'full', length: 0.7 },
+ { id: 'shortBeard', name: 'Short Beard', coverage: 'full', length: 0.3 },
+ { id: 'soulPatch', name: 'Soul Patch', coverage: 'chin-center', length: 0.3 },
+ { id: 'mutton', name: 'Mutton Chops', coverage: 'sides', length: 0.5 },
+ { id: 'handlebar', name: 'Handlebar', coverage: 'upper-lip', length: 0.4, style: 'handlebar' },
+ { id: 'vandyke', name: 'Van Dyke', coverage: 'chin-mustache', length: 0.5 }
+ ];
+
+ // Body types (6 options)
+ var BODY_TYPES = [
+ { id: 'slim', name: 'Slim', widthRatio: 0.8, heightRatio: 1.0 },
+ { id: 'average', name: 'Average', widthRatio: 1.0, heightRatio: 1.0 },
+ { id: 'athletic', name: 'Athletic', widthRatio: 1.1, heightRatio: 1.0 },
+ { id: 'stocky', name: 'Stocky', widthRatio: 1.2, heightRatio: 0.95 },
+ { id: 'tall', name: 'Tall', widthRatio: 0.95, heightRatio: 1.15 },
+ { id: 'short', name: 'Short', widthRatio: 1.0, heightRatio: 0.85 }
+ ];
+
+ // Clothing styles (4 options)
+ var CLOTHING_STYLES = [
+ { id: 'casual', name: 'Casual', hasCollar: false, sleeveLength: 'short' },
+ { id: 'sporty', name: 'Sporty', hasCollar: false, sleeveLength: 'short', hasStripes: true },
+ { id: 'formal', name: 'Formal', hasCollar: true, sleeveLength: 'long' },
+ { id: 'scifi', name: 'Sci-Fi', hasCollar: true, sleeveLength: 'long', hasGlow: true }
+ ];
+
+ // Preset colors for clothing
+ var CLOTHING_COLORS = [
+ '#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c',
+ '#3498db', '#9b59b6', '#34495e', '#95a5a6', '#ecf0f1',
+ '#c0392b', '#d35400', '#f39c12', '#27ae60', '#16a085',
+ '#2980b9', '#8e44ad', '#2c3e50', '#7f8c8d', '#bdc3c7'
+ ];
+
+ // 10 Default guest avatars
+ var GUEST_AVATARS = [
+ {
+ id: 'guest_1',
+ name: 'Guest Blue',
+ base: {
+ faceShape: 'round',
+ skinTone: 'medium1',
+ eyes: { shape: 'round', color: 'blue' },
+ eyebrows: 'natural',
+ nose: 'button',
+ mouth: 'smile',
+ ears: 'normal',
+ hair: { style: 'short-messy', color: 'darkBrown' },
+ facialHair: 'none',
+ body: { type: 'average' },
+ clothing: { style: 'casual', shirtColor: '#3498db', pantsColor: '#2c3e50' }
+ }
+ },
+ {
+ id: 'guest_2',
+ name: 'Guest Red',
+ base: {
+ faceShape: 'oval',
+ skinTone: 'light2',
+ eyes: { shape: 'almond', color: 'brown' },
+ eyebrows: 'arched',
+ nose: 'pointed',
+ mouth: 'neutral',
+ ears: 'normal',
+ hair: { style: 'long-straight', color: 'black' },
+ facialHair: 'none',
+ body: { type: 'slim' },
+ clothing: { style: 'casual', shirtColor: '#e74c3c', pantsColor: '#34495e' }
+ }
+ },
+ {
+ id: 'guest_3',
+ name: 'Guest Green',
+ base: {
+ faceShape: 'square',
+ skinTone: 'tan1',
+ eyes: { shape: 'narrow', color: 'darkBrown' },
+ eyebrows: 'thick',
+ nose: 'wide',
+ mouth: 'grin',
+ ears: 'large',
+ hair: { style: 'buzz', color: 'black' },
+ facialHair: 'stubble',
+ body: { type: 'athletic' },
+ clothing: { style: 'sporty', shirtColor: '#2ecc71', pantsColor: '#2c3e50' }
+ }
+ },
+ {
+ id: 'guest_4',
+ name: 'Guest Purple',
+ base: {
+ faceShape: 'heart',
+ skinTone: 'fair2',
+ eyes: { shape: 'wide', color: 'green' },
+ eyebrows: 'curved',
+ nose: 'snub',
+ mouth: 'small',
+ ears: 'small',
+ hair: { style: 'bob', color: 'auburn' },
+ facialHair: 'none',
+ body: { type: 'average' },
+ clothing: { style: 'casual', shirtColor: '#9b59b6', pantsColor: '#7f8c8d' }
+ }
+ },
+ {
+ id: 'guest_5',
+ name: 'Guest Orange',
+ base: {
+ faceShape: 'long',
+ skinTone: 'brown1',
+ eyes: { shape: 'oval', color: 'amber' },
+ eyebrows: 'straight',
+ nose: 'roman',
+ mouth: 'full',
+ ears: 'normal',
+ hair: { style: 'afro', color: 'black' },
+ facialHair: 'goatee',
+ body: { type: 'tall' },
+ clothing: { style: 'casual', shirtColor: '#e67e22', pantsColor: '#34495e' }
+ }
+ },
+ {
+ id: 'guest_6',
+ name: 'Guest Teal',
+ base: {
+ faceShape: 'soft',
+ skinTone: 'dark1',
+ eyes: { shape: 'happy', color: 'darkBrown' },
+ eyebrows: 'natural',
+ nose: 'flat',
+ mouth: 'wide',
+ ears: 'round',
+ hair: { style: 'dreads', color: 'black' },
+ facialHair: 'none',
+ body: { type: 'stocky' },
+ clothing: { style: 'casual', shirtColor: '#1abc9c', pantsColor: '#2c3e50' }
+ }
+ },
+ {
+ id: 'guest_7',
+ name: 'Guest Gray',
+ base: {
+ faceShape: 'angular',
+ skinTone: 'light3',
+ eyes: { shape: 'cat', color: 'gray' },
+ eyebrows: 'sleek',
+ nose: 'narrow',
+ mouth: 'smirk',
+ ears: 'pointed',
+ hair: { style: 'undercut', color: 'platinum' },
+ facialHair: 'none',
+ body: { type: 'slim' },
+ clothing: { style: 'scifi', shirtColor: '#95a5a6', pantsColor: '#34495e' }
+ }
+ },
+ {
+ id: 'guest_8',
+ name: 'Guest Yellow',
+ base: {
+ faceShape: 'wide',
+ skinTone: 'medium3',
+ eyes: { shape: 'surprised', color: 'hazel' },
+ eyebrows: 'worried',
+ nose: 'button',
+ mouth: 'open',
+ ears: 'normal',
+ hair: { style: 'spiky', color: 'blonde' },
+ facialHair: 'none',
+ body: { type: 'short' },
+ clothing: { style: 'casual', shirtColor: '#f1c40f', pantsColor: '#7f8c8d' }
+ }
+ },
+ {
+ id: 'guest_9',
+ name: 'Guest Navy',
+ base: {
+ faceShape: 'oval',
+ skinTone: 'tan2',
+ eyes: { shape: 'droopy', color: 'brown' },
+ eyebrows: 'bushy',
+ nose: 'aquiline',
+ mouth: 'neutral',
+ ears: 'flat',
+ hair: { style: 'combover', color: 'gray' },
+ facialHair: 'fullBeard',
+ body: { type: 'stocky' },
+ clothing: { style: 'formal', shirtColor: '#2c3e50', pantsColor: '#1a1a2e' }
+ }
+ },
+ {
+ id: 'guest_10',
+ name: 'Guest Pink',
+ base: {
+ faceShape: 'round',
+ skinTone: 'fair3',
+ eyes: { shape: 'round', color: 'lightBlue' },
+ eyebrows: 'thin',
+ nose: 'snub',
+ mouth: 'pout',
+ ears: 'small',
+ hair: { style: 'pigtails', color: 'strawberry' },
+ facialHair: 'none',
+ body: { type: 'average' },
+ clothing: { style: 'casual', shirtColor: '#ff69b4', pantsColor: '#bdc3c7' }
+ }
+ }
+ ];
+
+ // Helper function to find item by ID
+ function findById(array, id) {
+ for (var i = 0; i < array.length; i++) {
+ if (array[i].id === id) {
+ return array[i];
+ }
+ }
+ return null;
+ }
+
+ // Public API
+ window.AvatarData = {
+ FACE_SHAPES: FACE_SHAPES,
+ SKIN_TONES: SKIN_TONES,
+ EYE_SHAPES: EYE_SHAPES,
+ EYE_COLORS: EYE_COLORS,
+ EYEBROW_SHAPES: EYEBROW_SHAPES,
+ NOSE_SHAPES: NOSE_SHAPES,
+ MOUTH_SHAPES: MOUTH_SHAPES,
+ EAR_SHAPES: EAR_SHAPES,
+ HAIR_STYLES: HAIR_STYLES,
+ HAIR_COLORS: HAIR_COLORS,
+ FACIAL_HAIR: FACIAL_HAIR,
+ BODY_TYPES: BODY_TYPES,
+ CLOTHING_STYLES: CLOTHING_STYLES,
+ CLOTHING_COLORS: CLOTHING_COLORS,
+ GUEST_AVATARS: GUEST_AVATARS,
+
+ // Helper methods
+ getFaceShape: function(id) { return findById(FACE_SHAPES, id); },
+ getSkinTone: function(id) { return findById(SKIN_TONES, id); },
+ getEyeShape: function(id) { return findById(EYE_SHAPES, id); },
+ getEyeColor: function(id) { return findById(EYE_COLORS, id); },
+ getEyebrowShape: function(id) { return findById(EYEBROW_SHAPES, id); },
+ getNoseShape: function(id) { return findById(NOSE_SHAPES, id); },
+ getMouthShape: function(id) { return findById(MOUTH_SHAPES, id); },
+ getEarShape: function(id) { return findById(EAR_SHAPES, id); },
+ getHairStyle: function(id) { return findById(HAIR_STYLES, id); },
+ getHairColor: function(id) { return findById(HAIR_COLORS, id); },
+ getFacialHair: function(id) { return findById(FACIAL_HAIR, id); },
+ getBodyType: function(id) { return findById(BODY_TYPES, id); },
+ getClothingStyle: function(id) { return findById(CLOTHING_STYLES, id); },
+
+ getRandomGuestAvatar: function() {
+ return GUEST_AVATARS[Math.floor(Math.random() * GUEST_AVATARS.length)];
+ },
+
+ createDefaultAvatar: function() {
+ return {
+ faceShape: 'round',
+ skinTone: 'medium1',
+ eyes: {
+ shape: 'round',
+ color: 'brown'
+ },
+ eyebrows: 'natural',
+ nose: 'button',
+ mouth: 'smile',
+ ears: 'normal',
+ hair: {
+ style: 'short-neat',
+ color: 'brown'
+ },
+ facialHair: 'none',
+ body: {
+ type: 'average'
+ },
+ clothing: {
+ style: 'casual',
+ shirtColor: '#3498db',
+ pantsColor: '#2c3e50'
+ }
+ };
+ }
+ };
+})();
diff --git a/frontend/public/assets/avatar/avatarRenderer.js b/frontend/public/assets/avatar/avatarRenderer.js
new file mode 100644
index 0000000..b578c90
--- /dev/null
+++ b/frontend/public/assets/avatar/avatarRenderer.js
@@ -0,0 +1,1798 @@
+/**
+ * AvatarRenderer - Core rendering engine for Mii-style avatar system
+ * Draws avatars on canvas in different modes using Canvas 2D API
+ *
+ * Depends on: AvatarData, AvatarAnimations (must be loaded first)
+ */
+(function() {
+ 'use strict';
+
+ // Rendering modes with their dimensions
+ var MODES = {
+ HEAD_ONLY: { width: 64, height: 64 },
+ HEAD_AND_SHOULDERS: { width: 64, height: 96 },
+ UPPER_BODY: { width: 64, height: 128 },
+ FULL_BODY: { width: 64, height: 160 }
+ };
+
+ // Gradient cache for performance
+ var gradientCache = {};
+
+ // ============================================
+ // DRAWING HELPERS
+ // ============================================
+
+ /**
+ * Draw a rounded rectangle
+ */
+ function roundRect(ctx, x, y, w, h, r) {
+ if (typeof r === 'number') {
+ r = { tl: r, tr: r, br: r, bl: r };
+ } else {
+ r = Object.assign({ tl: 0, tr: 0, br: 0, bl: 0 }, r);
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(x + r.tl, y);
+ ctx.lineTo(x + w - r.tr, y);
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
+ ctx.lineTo(x + w, y + h - r.br);
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
+ ctx.lineTo(x + r.bl, y + h);
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
+ ctx.lineTo(x, y + r.tl);
+ ctx.quadraticCurveTo(x, y, x + r.tl, y);
+ ctx.closePath();
+ }
+
+ /**
+ * Draw a bezier curve through points
+ */
+ function drawCurve(ctx, points) {
+ if (points.length < 2) return;
+
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+
+ if (points.length === 2) {
+ ctx.lineTo(points[1].x, points[1].y);
+ } else if (points.length === 3) {
+ ctx.quadraticCurveTo(points[1].x, points[1].y, points[2].x, points[2].y);
+ } else {
+ for (var i = 1; i < points.length - 2; i++) {
+ var xc = (points[i].x + points[i + 1].x) / 2;
+ var yc = (points[i].y + points[i + 1].y) / 2;
+ ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
+ }
+ ctx.quadraticCurveTo(
+ points[points.length - 2].x,
+ points[points.length - 2].y,
+ points[points.length - 1].x,
+ points[points.length - 1].y
+ );
+ }
+ }
+
+ /**
+ * Create a gradient fill
+ */
+ function gradientFill(ctx, x, y, w, h, color1, color2, vertical) {
+ var cacheKey = color1 + '-' + color2 + '-' + (vertical ? 'v' : 'h');
+ var gradient;
+
+ if (vertical) {
+ gradient = ctx.createLinearGradient(x, y, x, y + h);
+ } else {
+ gradient = ctx.createLinearGradient(x, y, x + w, y);
+ }
+
+ gradient.addColorStop(0, color1);
+ gradient.addColorStop(1, color2);
+
+ return gradient;
+ }
+
+ /**
+ * Adjust skin color for shading
+ */
+ function skinShade(baseColor, amount) {
+ var hex = baseColor.replace('#', '');
+ var r = parseInt(hex.substr(0, 2), 16);
+ var g = parseInt(hex.substr(2, 2), 16);
+ var b = parseInt(hex.substr(4, 2), 16);
+
+ r = Math.max(0, Math.min(255, r + amount));
+ g = Math.max(0, Math.min(255, g + amount));
+ b = Math.max(0, Math.min(255, b + amount));
+
+ return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
+ }
+
+ /**
+ * Adjust any color brightness
+ */
+ function adjustBrightness(color, amount) {
+ return skinShade(color, amount);
+ }
+
+ /**
+ * Add shine highlight to hair
+ */
+ function addHairShine(ctx, x, y, w, h, color) {
+ var lighter = adjustBrightness(color, 40);
+
+ ctx.save();
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = lighter;
+ ctx.beginPath();
+ ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 3, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /**
+ * Draw an ellipse (polyfill for older browsers)
+ */
+ function drawEllipse(ctx, x, y, radiusX, radiusY) {
+ ctx.beginPath();
+ if (ctx.ellipse) {
+ ctx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2);
+ } else {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.scale(radiusX, radiusY);
+ ctx.arc(0, 0, 1, 0, Math.PI * 2);
+ ctx.restore();
+ }
+ ctx.closePath();
+ }
+
+ // ============================================
+ // FACE RENDERING
+ // ============================================
+
+ /**
+ * Render the base face shape
+ */
+ function renderFaceBase(ctx, avatar, x, y, scale) {
+ var faceShape = window.AvatarData ? window.AvatarData.getFaceShape(avatar.base.faceShape) : null;
+ var skinTone = avatar.base.skinTone || '#FFD5B8';
+ var shapeId = faceShape ? faceShape.id : avatar.base.faceShape || 'oval';
+
+ var faceWidth = 44 * scale;
+ var faceHeight = 48 * scale;
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ ctx.fillStyle = skinTone;
+
+ switch (shapeId) {
+ case 'round':
+ drawEllipse(ctx, cx, cy, faceWidth / 2, faceWidth / 2);
+ ctx.fill();
+ break;
+
+ case 'oval':
+ drawEllipse(ctx, cx, cy, faceWidth / 2, faceHeight / 2);
+ ctx.fill();
+ break;
+
+ case 'square':
+ roundRect(ctx, cx - faceWidth / 2, cy - faceHeight / 2, faceWidth, faceHeight, 6 * scale);
+ ctx.fill();
+ break;
+
+ case 'heart':
+ ctx.beginPath();
+ ctx.moveTo(cx, cy + faceHeight / 2);
+ ctx.quadraticCurveTo(cx - faceWidth / 2, cy + faceHeight / 4, cx - faceWidth / 2, cy - faceHeight / 6);
+ ctx.quadraticCurveTo(cx - faceWidth / 2, cy - faceHeight / 2, cx, cy - faceHeight / 3);
+ ctx.quadraticCurveTo(cx + faceWidth / 2, cy - faceHeight / 2, cx + faceWidth / 2, cy - faceHeight / 6);
+ ctx.quadraticCurveTo(cx + faceWidth / 2, cy + faceHeight / 4, cx, cy + faceHeight / 2);
+ ctx.closePath();
+ ctx.fill();
+ break;
+
+ case 'long':
+ drawEllipse(ctx, cx, cy, faceWidth / 2 - 2 * scale, faceHeight / 2 + 4 * scale);
+ ctx.fill();
+ break;
+
+ case 'diamond':
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - faceHeight / 2);
+ ctx.lineTo(cx + faceWidth / 2, cy);
+ ctx.lineTo(cx, cy + faceHeight / 2);
+ ctx.lineTo(cx - faceWidth / 2, cy);
+ ctx.closePath();
+ ctx.fill();
+ break;
+
+ default:
+ drawEllipse(ctx, cx, cy, faceWidth / 2, faceHeight / 2);
+ ctx.fill();
+ }
+
+ // Add subtle shading
+ ctx.save();
+ ctx.globalAlpha = 0.1;
+ ctx.fillStyle = skinShade(skinTone, -30);
+ drawEllipse(ctx, cx, cy + 5 * scale, faceWidth / 2 - 5 * scale, faceHeight / 3);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /**
+ * Render ears
+ */
+ function renderEars(ctx, avatar, x, y, scale, transforms) {
+ var skinTone = avatar.base.skinTone || '#FFD5B8';
+ var earSize = 8 * scale;
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ ctx.fillStyle = skinTone;
+
+ // Left ear
+ drawEllipse(ctx, cx - 22 * scale, cy, earSize / 2, earSize);
+ ctx.fill();
+
+ // Right ear
+ drawEllipse(ctx, cx + 22 * scale, cy, earSize / 2, earSize);
+ ctx.fill();
+
+ // Inner ear detail
+ ctx.fillStyle = skinShade(skinTone, -20);
+ drawEllipse(ctx, cx - 22 * scale, cy, earSize / 4, earSize / 2);
+ ctx.fill();
+ drawEllipse(ctx, cx + 22 * scale, cy, earSize / 4, earSize / 2);
+ ctx.fill();
+ }
+
+ /**
+ * Render eyes
+ */
+ function renderEyes(ctx, avatar, x, y, scale, transforms) {
+ var eyes = avatar.base.eyes || {};
+ var eyeStyle = window.AvatarData ? window.AvatarData.getEyeStyle(eyes.style) : null;
+ var eyeColor = eyes.color || '#4A90D9';
+ var styleId = eyeStyle ? eyeStyle.id : eyes.style || 'normal';
+
+ var cx = x + 32 * scale;
+ var cy = y + 28 * scale;
+ var eyeSpacing = 12 * scale;
+ var eyeWidth = 6 * scale;
+ var eyeHeight = 8 * scale;
+
+ // Apply eye transforms if animating
+ ctx.save();
+ if (transforms && transforms.eyes) {
+ applyTransforms(ctx, transforms, 'eyes');
+ }
+
+ // Draw based on style
+ switch (styleId) {
+ case 'round':
+ eyeWidth = 7 * scale;
+ eyeHeight = 7 * scale;
+ break;
+ case 'narrow':
+ eyeWidth = 8 * scale;
+ eyeHeight = 5 * scale;
+ break;
+ case 'wide':
+ eyeWidth = 9 * scale;
+ eyeHeight = 7 * scale;
+ break;
+ case 'almond':
+ eyeWidth = 8 * scale;
+ eyeHeight = 6 * scale;
+ break;
+ }
+
+ // Left eye white
+ ctx.fillStyle = '#FFFFFF';
+ drawEllipse(ctx, cx - eyeSpacing, cy, eyeWidth, eyeHeight);
+ ctx.fill();
+ ctx.strokeStyle = '#333333';
+ ctx.lineWidth = 0.5 * scale;
+ ctx.stroke();
+
+ // Left iris
+ ctx.fillStyle = eyeColor;
+ drawEllipse(ctx, cx - eyeSpacing, cy + 1 * scale, eyeWidth * 0.6, eyeHeight * 0.7);
+ ctx.fill();
+
+ // Left pupil
+ ctx.fillStyle = '#000000';
+ drawEllipse(ctx, cx - eyeSpacing, cy + 1 * scale, eyeWidth * 0.3, eyeHeight * 0.4);
+ ctx.fill();
+
+ // Left highlight
+ ctx.fillStyle = '#FFFFFF';
+ drawEllipse(ctx, cx - eyeSpacing - 1 * scale, cy - 1 * scale, eyeWidth * 0.2, eyeHeight * 0.2);
+ ctx.fill();
+
+ // Right eye white
+ ctx.fillStyle = '#FFFFFF';
+ drawEllipse(ctx, cx + eyeSpacing, cy, eyeWidth, eyeHeight);
+ ctx.fill();
+ ctx.strokeStyle = '#333333';
+ ctx.lineWidth = 0.5 * scale;
+ ctx.stroke();
+
+ // Right iris
+ ctx.fillStyle = eyeColor;
+ drawEllipse(ctx, cx + eyeSpacing, cy + 1 * scale, eyeWidth * 0.6, eyeHeight * 0.7);
+ ctx.fill();
+
+ // Right pupil
+ ctx.fillStyle = '#000000';
+ drawEllipse(ctx, cx + eyeSpacing, cy + 1 * scale, eyeWidth * 0.3, eyeHeight * 0.4);
+ ctx.fill();
+
+ // Right highlight
+ ctx.fillStyle = '#FFFFFF';
+ drawEllipse(ctx, cx + eyeSpacing - 1 * scale, cy - 1 * scale, eyeWidth * 0.2, eyeHeight * 0.2);
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ /**
+ * Render eyebrows
+ */
+ function renderEyebrows(ctx, avatar, x, y, scale) {
+ var eyebrows = avatar.base.eyebrows || {};
+ var browStyle = window.AvatarData ? window.AvatarData.getEyebrowStyle(eyebrows.style) : null;
+ var browColor = eyebrows.color || avatar.base.hair.color || '#3D2314';
+ var styleId = browStyle ? browStyle.id : eyebrows.style || 'normal';
+
+ var cx = x + 32 * scale;
+ var cy = y + 20 * scale;
+ var browSpacing = 12 * scale;
+ var browWidth = 10 * scale;
+
+ ctx.strokeStyle = browColor;
+ ctx.lineWidth = 2 * scale;
+ ctx.lineCap = 'round';
+
+ switch (styleId) {
+ case 'thick':
+ ctx.lineWidth = 3 * scale;
+ break;
+ case 'thin':
+ ctx.lineWidth = 1.5 * scale;
+ break;
+ case 'arched':
+ // Draw arched brows
+ ctx.beginPath();
+ ctx.moveTo(cx - browSpacing - browWidth / 2, cy + 2 * scale);
+ ctx.quadraticCurveTo(cx - browSpacing, cy - 3 * scale, cx - browSpacing + browWidth / 2, cy);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + browSpacing - browWidth / 2, cy);
+ ctx.quadraticCurveTo(cx + browSpacing, cy - 3 * scale, cx + browSpacing + browWidth / 2, cy + 2 * scale);
+ ctx.stroke();
+ return;
+ case 'angry':
+ // Draw angled angry brows
+ ctx.beginPath();
+ ctx.moveTo(cx - browSpacing - browWidth / 2, cy - 2 * scale);
+ ctx.lineTo(cx - browSpacing + browWidth / 2, cy + 2 * scale);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + browSpacing - browWidth / 2, cy + 2 * scale);
+ ctx.lineTo(cx + browSpacing + browWidth / 2, cy - 2 * scale);
+ ctx.stroke();
+ return;
+ }
+
+ // Default straight brows
+ ctx.beginPath();
+ ctx.moveTo(cx - browSpacing - browWidth / 2, cy);
+ ctx.lineTo(cx - browSpacing + browWidth / 2, cy);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + browSpacing - browWidth / 2, cy);
+ ctx.lineTo(cx + browSpacing + browWidth / 2, cy);
+ ctx.stroke();
+ }
+
+ /**
+ * Render nose
+ */
+ function renderNose(ctx, avatar, x, y, scale) {
+ var nose = avatar.base.nose || {};
+ var skinTone = avatar.base.skinTone || '#FFD5B8';
+ var noseStyle = window.AvatarData ? window.AvatarData.getNoseStyle(nose.style) : null;
+ var styleId = noseStyle ? noseStyle.id : nose.style || 'normal';
+
+ var cx = x + 32 * scale;
+ var cy = y + 36 * scale;
+
+ ctx.fillStyle = skinShade(skinTone, -15);
+ ctx.strokeStyle = skinShade(skinTone, -25);
+ ctx.lineWidth = 1 * scale;
+
+ switch (styleId) {
+ case 'small':
+ ctx.beginPath();
+ ctx.arc(cx, cy, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ break;
+
+ case 'large':
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - 6 * scale);
+ ctx.lineTo(cx + 5 * scale, cy + 3 * scale);
+ ctx.lineTo(cx - 5 * scale, cy + 3 * scale);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ break;
+
+ case 'pointed':
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - 5 * scale);
+ ctx.lineTo(cx + 3 * scale, cy + 2 * scale);
+ ctx.lineTo(cx, cy + 4 * scale);
+ ctx.lineTo(cx - 3 * scale, cy + 2 * scale);
+ ctx.closePath();
+ ctx.fill();
+ break;
+
+ case 'button':
+ drawEllipse(ctx, cx, cy, 3 * scale, 2 * scale);
+ ctx.fill();
+ break;
+
+ default:
+ // Normal nose - simple curved line
+ ctx.beginPath();
+ ctx.moveTo(cx - 2 * scale, cy - 3 * scale);
+ ctx.quadraticCurveTo(cx, cy + 2 * scale, cx + 2 * scale, cy - 3 * scale);
+ ctx.stroke();
+
+ // Nostrils hint
+ ctx.beginPath();
+ ctx.arc(cx - 2 * scale, cy, 1 * scale, 0, Math.PI);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(cx + 2 * scale, cy, 1 * scale, 0, Math.PI);
+ ctx.stroke();
+ }
+ }
+
+ /**
+ * Render mouth
+ */
+ function renderMouth(ctx, avatar, x, y, scale) {
+ var mouth = avatar.base.mouth || {};
+ var mouthStyle = window.AvatarData ? window.AvatarData.getMouthStyle(mouth.style) : null;
+ var styleId = mouthStyle ? mouthStyle.id : mouth.style || 'smile';
+
+ var cx = x + 32 * scale;
+ var cy = y + 46 * scale;
+
+ ctx.strokeStyle = '#8B4513';
+ ctx.fillStyle = '#CC6666';
+ ctx.lineWidth = 1.5 * scale;
+ ctx.lineCap = 'round';
+
+ switch (styleId) {
+ case 'smile':
+ ctx.beginPath();
+ ctx.arc(cx, cy - 2 * scale, 6 * scale, 0.2 * Math.PI, 0.8 * Math.PI);
+ ctx.stroke();
+ break;
+
+ case 'grin':
+ ctx.beginPath();
+ ctx.arc(cx, cy - 4 * scale, 8 * scale, 0.15 * Math.PI, 0.85 * Math.PI);
+ ctx.stroke();
+ // Teeth
+ ctx.fillStyle = '#FFFFFF';
+ ctx.beginPath();
+ ctx.arc(cx, cy - 4 * scale, 7 * scale, 0.2 * Math.PI, 0.8 * Math.PI);
+ ctx.fill();
+ break;
+
+ case 'neutral':
+ ctx.beginPath();
+ ctx.moveTo(cx - 5 * scale, cy);
+ ctx.lineTo(cx + 5 * scale, cy);
+ ctx.stroke();
+ break;
+
+ case 'frown':
+ ctx.beginPath();
+ ctx.arc(cx, cy + 6 * scale, 6 * scale, 1.2 * Math.PI, 1.8 * Math.PI);
+ ctx.stroke();
+ break;
+
+ case 'open':
+ ctx.fillStyle = '#4A2020';
+ drawEllipse(ctx, cx, cy, 5 * scale, 4 * scale);
+ ctx.fill();
+ // Tongue hint
+ ctx.fillStyle = '#CC6666';
+ drawEllipse(ctx, cx, cy + 2 * scale, 3 * scale, 2 * scale);
+ ctx.fill();
+ break;
+
+ case 'smirk':
+ ctx.beginPath();
+ ctx.moveTo(cx - 5 * scale, cy);
+ ctx.quadraticCurveTo(cx, cy, cx + 5 * scale, cy - 3 * scale);
+ ctx.stroke();
+ break;
+
+ default:
+ ctx.beginPath();
+ ctx.arc(cx, cy - 2 * scale, 5 * scale, 0.2 * Math.PI, 0.8 * Math.PI);
+ ctx.stroke();
+ }
+ }
+
+ /**
+ * Render facial hair
+ */
+ function renderFacialHair(ctx, avatar, x, y, scale) {
+ var facialHair = avatar.base.facialHair;
+ var hairColor = avatar.base.hair ? avatar.base.hair.color : '#3D2314';
+
+ if (!facialHair || facialHair === 'none') return;
+
+ var cx = x + 32 * scale;
+ var cy = y + 46 * scale;
+
+ ctx.fillStyle = hairColor;
+ ctx.strokeStyle = hairColor;
+ ctx.lineWidth = 1 * scale;
+
+ switch (facialHair) {
+ case 'stubble':
+ ctx.save();
+ ctx.globalAlpha = 0.4;
+ for (var i = 0; i < 30; i++) {
+ var sx = cx + (Math.random() - 0.5) * 24 * scale;
+ var sy = cy + Math.random() * 12 * scale;
+ ctx.beginPath();
+ ctx.arc(sx, sy, 0.5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ break;
+
+ case 'mustache':
+ ctx.beginPath();
+ ctx.moveTo(cx - 10 * scale, cy - 2 * scale);
+ ctx.quadraticCurveTo(cx - 5 * scale, cy - 5 * scale, cx, cy - 3 * scale);
+ ctx.quadraticCurveTo(cx + 5 * scale, cy - 5 * scale, cx + 10 * scale, cy - 2 * scale);
+ ctx.quadraticCurveTo(cx + 5 * scale, cy, cx, cy - 1 * scale);
+ ctx.quadraticCurveTo(cx - 5 * scale, cy, cx - 10 * scale, cy - 2 * scale);
+ ctx.fill();
+ break;
+
+ case 'goatee':
+ ctx.beginPath();
+ ctx.moveTo(cx - 6 * scale, cy + 2 * scale);
+ ctx.quadraticCurveTo(cx, cy + 12 * scale, cx + 6 * scale, cy + 2 * scale);
+ ctx.quadraticCurveTo(cx, cy + 4 * scale, cx - 6 * scale, cy + 2 * scale);
+ ctx.fill();
+ break;
+
+ case 'full-beard':
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, cy - 8 * scale);
+ ctx.quadraticCurveTo(cx - 20 * scale, cy + 5 * scale, cx - 10 * scale, cy + 15 * scale);
+ ctx.quadraticCurveTo(cx, cy + 20 * scale, cx + 10 * scale, cy + 15 * scale);
+ ctx.quadraticCurveTo(cx + 20 * scale, cy + 5 * scale, cx + 18 * scale, cy - 8 * scale);
+ ctx.quadraticCurveTo(cx, cy + 2 * scale, cx - 18 * scale, cy - 8 * scale);
+ ctx.fill();
+ break;
+
+ case 'sideburns':
+ // Left sideburn
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, cy - 20 * scale);
+ ctx.lineTo(cx - 22 * scale, cy + 5 * scale);
+ ctx.lineTo(cx - 18 * scale, cy + 5 * scale);
+ ctx.lineTo(cx - 16 * scale, cy - 20 * scale);
+ ctx.fill();
+ // Right sideburn
+ ctx.beginPath();
+ ctx.moveTo(cx + 20 * scale, cy - 20 * scale);
+ ctx.lineTo(cx + 22 * scale, cy + 5 * scale);
+ ctx.lineTo(cx + 18 * scale, cy + 5 * scale);
+ ctx.lineTo(cx + 16 * scale, cy - 20 * scale);
+ ctx.fill();
+ break;
+ }
+ }
+
+ // ============================================
+ // HAIR RENDERING
+ // ============================================
+
+ /**
+ * Main hair rendering function
+ */
+ function renderHair(ctx, avatar, x, y, scale, layer) {
+ var hair = avatar.base.hair || {};
+ var hairStyle = window.AvatarData ? window.AvatarData.getHairStyle(hair.style) : null;
+ var hairColor = hair.color || '#3D2314';
+ var styleId = hairStyle ? hairStyle.id : hair.style || 'short-messy';
+
+ if (!styleId || styleId === 'bald') return;
+
+ ctx.fillStyle = hairColor;
+ ctx.strokeStyle = adjustBrightness(hairColor, -20);
+ ctx.lineWidth = 1 * scale;
+
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ // Dispatch to specific hair drawing function
+ switch (styleId) {
+ case 'short-messy':
+ drawShortMessyHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'short-curly':
+ drawShortCurlyHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'short-neat':
+ drawShortNeatHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'ponytail':
+ drawPonytailHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'long-straight':
+ drawLongStraightHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'long-wavy':
+ drawLongWavyHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'mohawk':
+ drawMohawkHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'afro':
+ drawAfroHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'buzz':
+ drawBuzzHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'pigtails':
+ drawPigtailsHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'bob':
+ drawBobHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ case 'spiky':
+ drawSpikyHair(ctx, x, y, scale, hairColor, layer);
+ break;
+ default:
+ // Default to short messy
+ drawShortMessyHair(ctx, x, y, scale, hairColor, layer);
+ }
+ }
+
+ /**
+ * Short messy hair style
+ */
+ function drawShortMessyHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') return; // No back layer for short hair
+
+ ctx.fillStyle = hairColor;
+
+ // Base hair shape
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx - 22 * scale, cy - 20 * scale, cx - 10 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 10 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx + 22 * scale, cy - 20 * scale, cx + 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx + 15 * scale, cy - 10 * scale, cx, cy - 12 * scale);
+ ctx.quadraticCurveTo(cx - 15 * scale, cy - 10 * scale, cx - 20 * scale, cy - 5 * scale);
+ ctx.fill();
+
+ // Messy tufts
+ ctx.beginPath();
+ ctx.moveTo(cx - 8 * scale, cy - 22 * scale);
+ ctx.lineTo(cx - 12 * scale, cy - 30 * scale);
+ ctx.lineTo(cx - 5 * scale, cy - 24 * scale);
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 3 * scale, cy - 24 * scale);
+ ctx.lineTo(cx + 8 * scale, cy - 32 * scale);
+ ctx.lineTo(cx + 12 * scale, cy - 25 * scale);
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 15 * scale, cy - 20 * scale);
+ ctx.lineTo(cx + 22 * scale, cy - 26 * scale);
+ ctx.lineTo(cx + 18 * scale, cy - 18 * scale);
+ ctx.fill();
+
+ // Add shine
+ addHairShine(ctx, cx - 5 * scale, cy - 22 * scale, 15 * scale, 8 * scale, hairColor);
+ }
+
+ /**
+ * Short curly hair style
+ */
+ function drawShortCurlyHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') return;
+
+ ctx.fillStyle = hairColor;
+
+ // Draw curly texture with multiple small circles
+ var curls = [
+ { x: -15, y: -18 }, { x: -8, y: -22 }, { x: 0, y: -24 },
+ { x: 8, y: -22 }, { x: 15, y: -18 }, { x: -18, y: -10 },
+ { x: -12, y: -16 }, { x: -4, y: -20 }, { x: 4, y: -20 },
+ { x: 12, y: -16 }, { x: 18, y: -10 }, { x: -10, y: -12 },
+ { x: 0, y: -16 }, { x: 10, y: -12 }
+ ];
+
+ curls.forEach(function(curl) {
+ ctx.beginPath();
+ ctx.arc(cx + curl.x * scale, cy + curl.y * scale, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ });
+
+ // Add highlights
+ ctx.save();
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = adjustBrightness(hairColor, 40);
+ curls.slice(0, 5).forEach(function(curl) {
+ ctx.beginPath();
+ ctx.arc(cx + curl.x * scale - 1 * scale, cy + curl.y * scale - 1 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ });
+ ctx.restore();
+ }
+
+ /**
+ * Short neat hair style
+ */
+ function drawShortNeatHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') return;
+
+ ctx.fillStyle = hairColor;
+
+ // Clean curved shape
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx - 22 * scale, cy - 18 * scale, cx - 12 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx, cy - 26 * scale, cx + 12 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx + 22 * scale, cy - 18 * scale, cx + 20 * scale, cy - 5 * scale);
+ ctx.lineTo(cx + 18 * scale, cy - 8 * scale);
+ ctx.quadraticCurveTo(cx, cy - 14 * scale, cx - 18 * scale, cy - 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Part line
+ ctx.strokeStyle = adjustBrightness(hairColor, -30);
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.moveTo(cx - 5 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx - 8 * scale, cy - 18 * scale, cx - 15 * scale, cy - 10 * scale);
+ ctx.stroke();
+
+ addHairShine(ctx, cx, cy - 20 * scale, 12 * scale, 6 * scale, hairColor);
+ }
+
+ /**
+ * Ponytail hair style
+ */
+ function drawPonytailHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ ctx.fillStyle = hairColor;
+
+ if (layer === 'back') {
+ // Ponytail behind head
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - 10 * scale);
+ ctx.quadraticCurveTo(cx + 25 * scale, cy - 5 * scale, cx + 20 * scale, cy + 30 * scale);
+ ctx.quadraticCurveTo(cx + 15 * scale, cy + 40 * scale, cx + 10 * scale, cy + 35 * scale);
+ ctx.quadraticCurveTo(cx + 5 * scale, cy + 25 * scale, cx + 8 * scale, cy);
+ ctx.quadraticCurveTo(cx + 5 * scale, cy - 8 * scale, cx, cy - 10 * scale);
+ ctx.fill();
+
+ // Hair tie
+ ctx.fillStyle = '#CC4444';
+ ctx.beginPath();
+ ctx.arc(cx + 12 * scale, cy - 2 * scale, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ return;
+ }
+
+ // Front hair
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx - 22 * scale, cy - 18 * scale, cx - 10 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx, cy - 26 * scale, cx + 10 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx + 22 * scale, cy - 18 * scale, cx + 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx + 10 * scale, cy - 12 * scale, cx, cy - 14 * scale);
+ ctx.quadraticCurveTo(cx - 10 * scale, cy - 12 * scale, cx - 20 * scale, cy - 5 * scale);
+ ctx.fill();
+
+ // Side pieces
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx - 22 * scale, cy + 5 * scale, cx - 18 * scale, cy + 10 * scale);
+ ctx.lineTo(cx - 16 * scale, cy + 5 * scale);
+ ctx.quadraticCurveTo(cx - 18 * scale, cy, cx - 20 * scale, cy - 5 * scale);
+ ctx.fill();
+
+ addHairShine(ctx, cx - 5 * scale, cy - 20 * scale, 15 * scale, 8 * scale, hairColor);
+ }
+
+ /**
+ * Long straight hair style
+ */
+ function drawLongStraightHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ ctx.fillStyle = hairColor;
+
+ if (layer === 'back') {
+ // Long hair behind body
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy - 10 * scale);
+ ctx.lineTo(cx - 25 * scale, cy + 60 * scale);
+ ctx.quadraticCurveTo(cx, cy + 70 * scale, cx + 25 * scale, cy + 60 * scale);
+ ctx.lineTo(cx + 22 * scale, cy - 10 * scale);
+ ctx.quadraticCurveTo(cx, cy - 5 * scale, cx - 22 * scale, cy - 10 * scale);
+ ctx.fill();
+ return;
+ }
+
+ // Front hair cap
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy);
+ ctx.quadraticCurveTo(cx - 24 * scale, cy - 20 * scale, cx - 12 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 12 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx + 24 * scale, cy - 20 * scale, cx + 22 * scale, cy);
+ ctx.lineTo(cx + 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx, cy - 16 * scale, cx - 20 * scale, cy - 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Side strands
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy);
+ ctx.lineTo(cx - 24 * scale, cy + 35 * scale);
+ ctx.lineTo(cx - 18 * scale, cy + 30 * scale);
+ ctx.lineTo(cx - 18 * scale, cy - 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 22 * scale, cy);
+ ctx.lineTo(cx + 24 * scale, cy + 35 * scale);
+ ctx.lineTo(cx + 18 * scale, cy + 30 * scale);
+ ctx.lineTo(cx + 18 * scale, cy - 5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ addHairShine(ctx, cx - 8 * scale, cy - 18 * scale, 20 * scale, 10 * scale, hairColor);
+ }
+
+ /**
+ * Long wavy hair style
+ */
+ function drawLongWavyHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ ctx.fillStyle = hairColor;
+
+ if (layer === 'back') {
+ // Wavy back hair
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy - 10 * scale);
+ ctx.bezierCurveTo(
+ cx - 28 * scale, cy + 20 * scale,
+ cx - 20 * scale, cy + 40 * scale,
+ cx - 22 * scale, cy + 60 * scale
+ );
+ ctx.quadraticCurveTo(cx, cy + 65 * scale, cx + 22 * scale, cy + 60 * scale);
+ ctx.bezierCurveTo(
+ cx + 20 * scale, cy + 40 * scale,
+ cx + 28 * scale, cy + 20 * scale,
+ cx + 22 * scale, cy - 10 * scale
+ );
+ ctx.quadraticCurveTo(cx, cy - 5 * scale, cx - 22 * scale, cy - 10 * scale);
+ ctx.fill();
+ return;
+ }
+
+ // Front wavy cap
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy);
+ ctx.quadraticCurveTo(cx - 24 * scale, cy - 20 * scale, cx - 10 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 10 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx + 24 * scale, cy - 20 * scale, cx + 22 * scale, cy);
+ ctx.lineTo(cx + 18 * scale, cy - 8 * scale);
+ ctx.quadraticCurveTo(cx, cy - 16 * scale, cx - 18 * scale, cy - 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Wavy side strands
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy);
+ ctx.bezierCurveTo(
+ cx - 26 * scale, cy + 15 * scale,
+ cx - 20 * scale, cy + 25 * scale,
+ cx - 24 * scale, cy + 40 * scale
+ );
+ ctx.lineTo(cx - 18 * scale, cy + 35 * scale);
+ ctx.bezierCurveTo(
+ cx - 16 * scale, cy + 20 * scale,
+ cx - 20 * scale, cy + 10 * scale,
+ cx - 18 * scale, cy - 5 * scale
+ );
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 22 * scale, cy);
+ ctx.bezierCurveTo(
+ cx + 26 * scale, cy + 15 * scale,
+ cx + 20 * scale, cy + 25 * scale,
+ cx + 24 * scale, cy + 40 * scale
+ );
+ ctx.lineTo(cx + 18 * scale, cy + 35 * scale);
+ ctx.bezierCurveTo(
+ cx + 16 * scale, cy + 20 * scale,
+ cx + 20 * scale, cy + 10 * scale,
+ cx + 18 * scale, cy - 5 * scale
+ );
+ ctx.closePath();
+ ctx.fill();
+
+ addHairShine(ctx, cx - 5 * scale, cy - 18 * scale, 15 * scale, 8 * scale, hairColor);
+ }
+
+ /**
+ * Mohawk hair style
+ */
+ function drawMohawkHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') return;
+
+ ctx.fillStyle = hairColor;
+
+ // Shaved sides (subtle)
+ ctx.save();
+ ctx.globalAlpha = 0.3;
+ ctx.beginPath();
+ ctx.arc(cx, cy - 10 * scale, 22 * scale, Math.PI, 0);
+ ctx.fill();
+ ctx.restore();
+
+ // Mohawk strip
+ ctx.beginPath();
+ ctx.moveTo(cx - 6 * scale, cy - 5 * scale);
+ ctx.lineTo(cx - 8 * scale, cy - 35 * scale);
+ ctx.quadraticCurveTo(cx, cy - 42 * scale, cx + 8 * scale, cy - 35 * scale);
+ ctx.lineTo(cx + 6 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx, cy - 10 * scale, cx - 6 * scale, cy - 5 * scale);
+ ctx.fill();
+
+ // Spiky details
+ var spikes = [
+ { x: -4, y: -30, h: 8 },
+ { x: 0, y: -32, h: 12 },
+ { x: 4, y: -30, h: 8 }
+ ];
+
+ spikes.forEach(function(spike) {
+ ctx.beginPath();
+ ctx.moveTo(cx + (spike.x - 2) * scale, cy + spike.y * scale);
+ ctx.lineTo(cx + spike.x * scale, cy + (spike.y - spike.h) * scale);
+ ctx.lineTo(cx + (spike.x + 2) * scale, cy + spike.y * scale);
+ ctx.fill();
+ });
+
+ addHairShine(ctx, cx - 3 * scale, cy - 28 * scale, 8 * scale, 6 * scale, hairColor);
+ }
+
+ /**
+ * Afro hair style
+ */
+ function drawAfroHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') {
+ // Back of afro
+ ctx.fillStyle = hairColor;
+ ctx.beginPath();
+ ctx.arc(cx, cy - 5 * scale, 32 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ return;
+ }
+
+ ctx.fillStyle = hairColor;
+
+ // Main afro shape
+ ctx.beginPath();
+ ctx.arc(cx, cy - 10 * scale, 30 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Texture with small circles
+ ctx.save();
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = adjustBrightness(hairColor, -20);
+ for (var i = 0; i < 20; i++) {
+ var angle = Math.random() * Math.PI * 2;
+ var dist = Math.random() * 20 * scale;
+ var tx = cx + Math.cos(angle) * dist;
+ var ty = cy - 10 * scale + Math.sin(angle) * dist;
+ ctx.beginPath();
+ ctx.arc(tx, ty, 4 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+
+ // Cutout for face
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ drawEllipse(ctx, cx, cy + 5 * scale, 18 * scale, 22 * scale);
+ ctx.fill();
+ ctx.restore();
+
+ addHairShine(ctx, cx - 10 * scale, cy - 25 * scale, 20 * scale, 12 * scale, hairColor);
+ }
+
+ /**
+ * Buzz cut hair style
+ */
+ function drawBuzzHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') return;
+
+ ctx.save();
+ ctx.globalAlpha = 0.6;
+ ctx.fillStyle = hairColor;
+
+ // Very short hair - just a cap
+ ctx.beginPath();
+ ctx.arc(cx, cy - 8 * scale, 22 * scale, Math.PI, 0);
+ ctx.fill();
+
+ // Texture dots
+ ctx.globalAlpha = 0.4;
+ for (var i = 0; i < 40; i++) {
+ var angle = Math.PI + Math.random() * Math.PI;
+ var dist = 10 + Math.random() * 10;
+ var bx = cx + Math.cos(angle) * dist * scale;
+ var by = cy - 8 * scale + Math.sin(angle) * dist * scale;
+ if (by < cy - 2 * scale) {
+ ctx.beginPath();
+ ctx.arc(bx, by, 0.8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+ }
+
+ /**
+ * Pigtails hair style
+ */
+ function drawPigtailsHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ ctx.fillStyle = hairColor;
+
+ if (layer === 'back') {
+ // Back portions of pigtails
+ // Left pigtail
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx - 35 * scale, cy + 10 * scale, cx - 30 * scale, cy + 40 * scale);
+ ctx.quadraticCurveTo(cx - 25 * scale, cy + 50 * scale, cx - 20 * scale, cy + 40 * scale);
+ ctx.quadraticCurveTo(cx - 18 * scale, cy + 15 * scale, cx - 20 * scale, cy - 5 * scale);
+ ctx.fill();
+
+ // Right pigtail
+ ctx.beginPath();
+ ctx.moveTo(cx + 22 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx + 35 * scale, cy + 10 * scale, cx + 30 * scale, cy + 40 * scale);
+ ctx.quadraticCurveTo(cx + 25 * scale, cy + 50 * scale, cx + 20 * scale, cy + 40 * scale);
+ ctx.quadraticCurveTo(cx + 18 * scale, cy + 15 * scale, cx + 20 * scale, cy - 5 * scale);
+ ctx.fill();
+ return;
+ }
+
+ // Front hair cap
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx - 22 * scale, cy - 18 * scale, cx - 10 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx, cy - 26 * scale, cx + 10 * scale, cy - 22 * scale);
+ ctx.quadraticCurveTo(cx + 22 * scale, cy - 18 * scale, cx + 20 * scale, cy - 5 * scale);
+ ctx.quadraticCurveTo(cx + 10 * scale, cy - 12 * scale, cx, cy - 14 * scale);
+ ctx.quadraticCurveTo(cx - 10 * scale, cy - 12 * scale, cx - 20 * scale, cy - 5 * scale);
+ ctx.fill();
+
+ // Hair ties
+ ctx.fillStyle = '#FF69B4';
+ ctx.beginPath();
+ ctx.arc(cx - 24 * scale, cy, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 24 * scale, cy, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ addHairShine(ctx, cx - 5 * scale, cy - 20 * scale, 12 * scale, 6 * scale, hairColor);
+ }
+
+ /**
+ * Bob hair style
+ */
+ function drawBobHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') {
+ ctx.fillStyle = hairColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 24 * scale, cy - 15 * scale);
+ ctx.lineTo(cx - 26 * scale, cy + 15 * scale);
+ ctx.quadraticCurveTo(cx, cy + 20 * scale, cx + 26 * scale, cy + 15 * scale);
+ ctx.lineTo(cx + 24 * scale, cy - 15 * scale);
+ ctx.quadraticCurveTo(cx, cy - 10 * scale, cx - 24 * scale, cy - 15 * scale);
+ ctx.fill();
+ return;
+ }
+
+ ctx.fillStyle = hairColor;
+
+ // Front cap
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy);
+ ctx.quadraticCurveTo(cx - 24 * scale, cy - 20 * scale, cx - 10 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx, cy - 28 * scale, cx + 10 * scale, cy - 24 * scale);
+ ctx.quadraticCurveTo(cx + 24 * scale, cy - 20 * scale, cx + 22 * scale, cy);
+ ctx.lineTo(cx + 18 * scale, cy - 8 * scale);
+ ctx.quadraticCurveTo(cx, cy - 16 * scale, cx - 18 * scale, cy - 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Side flips
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, cy);
+ ctx.quadraticCurveTo(cx - 26 * scale, cy + 10 * scale, cx - 20 * scale, cy + 18 * scale);
+ ctx.quadraticCurveTo(cx - 18 * scale, cy + 15 * scale, cx - 18 * scale, cy);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 22 * scale, cy);
+ ctx.quadraticCurveTo(cx + 26 * scale, cy + 10 * scale, cx + 20 * scale, cy + 18 * scale);
+ ctx.quadraticCurveTo(cx + 18 * scale, cy + 15 * scale, cx + 18 * scale, cy);
+ ctx.closePath();
+ ctx.fill();
+
+ addHairShine(ctx, cx - 8 * scale, cy - 20 * scale, 18 * scale, 8 * scale, hairColor);
+ }
+
+ /**
+ * Spiky hair style
+ */
+ function drawSpikyHair(ctx, x, y, scale, hairColor, layer) {
+ var cx = x + 32 * scale;
+ var cy = y + 32 * scale;
+
+ if (layer === 'back') return;
+
+ ctx.fillStyle = hairColor;
+
+ // Base
+ ctx.beginPath();
+ ctx.arc(cx, cy - 12 * scale, 20 * scale, Math.PI, 0);
+ ctx.fill();
+
+ // Spikes
+ var spikes = [
+ { x: -18, y: -12, dx: -8, dy: -20 },
+ { x: -12, y: -18, dx: -4, dy: -28 },
+ { x: -5, y: -20, dx: 0, dy: -32 },
+ { x: 5, y: -20, dx: 5, dy: -30 },
+ { x: 12, y: -18, dx: 12, dy: -26 },
+ { x: 18, y: -12, dx: 20, dy: -18 },
+ { x: -15, y: -8, dx: -22, dy: -12 },
+ { x: 15, y: -8, dx: 24, dy: -10 }
+ ];
+
+ spikes.forEach(function(spike) {
+ ctx.beginPath();
+ ctx.moveTo(cx + spike.x * scale, cy + spike.y * scale);
+ ctx.lineTo(cx + spike.dx * scale, cy + spike.dy * scale);
+ ctx.lineTo(cx + (spike.x + 4) * scale, cy + spike.y * scale);
+ ctx.fill();
+ });
+
+ addHairShine(ctx, cx - 3 * scale, cy - 20 * scale, 10 * scale, 6 * scale, hairColor);
+ }
+
+ /**
+ * Render back hair layer
+ */
+ function renderHairBack(ctx, avatar, x, y, scale) {
+ renderHair(ctx, avatar, x, y, scale, 'back');
+ }
+
+ /**
+ * Render front hair layer
+ */
+ function renderHairFront(ctx, avatar, x, y, scale) {
+ renderHair(ctx, avatar, x, y, scale, 'front');
+ }
+
+ // ============================================
+ // BODY RENDERING
+ // ============================================
+
+ /**
+ * Render neck
+ */
+ function renderNeck(ctx, avatar, x, y, scale) {
+ var skinTone = avatar.base.skinTone || '#FFD5B8';
+ var cx = x + 32 * scale;
+ var neckTop = y + 56 * scale;
+
+ ctx.fillStyle = skinTone;
+ roundRect(ctx, cx - 8 * scale, neckTop, 16 * scale, 12 * scale, 2 * scale);
+ ctx.fill();
+
+ // Neck shadow
+ ctx.fillStyle = skinShade(skinTone, -15);
+ ctx.beginPath();
+ ctx.moveTo(cx - 6 * scale, neckTop);
+ ctx.lineTo(cx + 6 * scale, neckTop);
+ ctx.lineTo(cx + 4 * scale, neckTop + 4 * scale);
+ ctx.lineTo(cx - 4 * scale, neckTop + 4 * scale);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ /**
+ * Render shoulders
+ */
+ function renderShoulders(ctx, avatar, x, y, scale, bodyType, clothing) {
+ var cx = x + 32 * scale;
+ var shoulderY = y + 64 * scale;
+ var clothingColor = clothing ? clothing.color : '#3366CC';
+ var clothingStyle = clothing ? clothing.style : 'casual';
+
+ var shoulderWidth = 48 * scale;
+ if (bodyType && bodyType.id === 'large') shoulderWidth = 56 * scale;
+ if (bodyType && bodyType.id === 'slim') shoulderWidth = 42 * scale;
+
+ ctx.fillStyle = clothingColor;
+ roundRect(ctx, cx - shoulderWidth / 2, shoulderY, shoulderWidth, 20 * scale, { tl: 8, tr: 8, bl: 2, br: 2 });
+ ctx.fill();
+
+ // Add style details
+ renderClothingDetails(ctx, clothingStyle, clothingColor, cx - shoulderWidth / 2, shoulderY, shoulderWidth, 20 * scale, scale);
+ }
+
+ /**
+ * Render torso
+ */
+ function renderTorso(ctx, avatar, x, y, scale, bodyType, clothing, transforms) {
+ var cx = x + 32 * scale;
+ var torsoY = y + 84 * scale;
+ var clothingColor = clothing ? clothing.color : '#3366CC';
+ var clothingStyle = clothing ? clothing.style : 'casual';
+
+ var torsoWidth = 44 * scale;
+ var torsoHeight = 40 * scale;
+ if (bodyType && bodyType.id === 'large') {
+ torsoWidth = 52 * scale;
+ torsoHeight = 44 * scale;
+ }
+ if (bodyType && bodyType.id === 'slim') {
+ torsoWidth = 38 * scale;
+ torsoHeight = 38 * scale;
+ }
+
+ ctx.save();
+ if (transforms && transforms.torso) {
+ ctx.translate(cx, torsoY);
+ applyTransforms(ctx, transforms, 'torso');
+ ctx.translate(-cx, -torsoY);
+ }
+
+ ctx.fillStyle = clothingColor;
+ roundRect(ctx, cx - torsoWidth / 2, torsoY, torsoWidth, torsoHeight, 4 * scale);
+ ctx.fill();
+
+ // Clothing details
+ renderClothingDetails(ctx, clothingStyle, clothingColor, cx - torsoWidth / 2, torsoY, torsoWidth, torsoHeight, scale);
+
+ ctx.restore();
+ }
+
+ /**
+ * Render arms
+ */
+ function renderArms(ctx, avatar, x, y, scale, bodyType, clothing, transforms) {
+ var skinTone = avatar.base.skinTone || '#FFD5B8';
+ var cx = x + 32 * scale;
+ var armY = y + 68 * scale;
+ var clothingColor = clothing ? clothing.color : '#3366CC';
+
+ var armWidth = 10 * scale;
+ var armLength = 45 * scale;
+ var armOffset = 26 * scale;
+
+ if (bodyType && bodyType.id === 'large') {
+ armWidth = 12 * scale;
+ armOffset = 30 * scale;
+ }
+ if (bodyType && bodyType.id === 'slim') {
+ armWidth = 8 * scale;
+ armOffset = 22 * scale;
+ }
+
+ // Left arm
+ ctx.save();
+ if (transforms && transforms.leftArm) {
+ ctx.translate(cx - armOffset, armY);
+ applyTransforms(ctx, transforms, 'leftArm');
+ ctx.translate(-(cx - armOffset), -armY);
+ }
+
+ // Sleeve
+ ctx.fillStyle = clothingColor;
+ roundRect(ctx, cx - armOffset - armWidth / 2, armY, armWidth, armLength * 0.5, 3 * scale);
+ ctx.fill();
+
+ // Skin part
+ ctx.fillStyle = skinTone;
+ roundRect(ctx, cx - armOffset - armWidth / 2 + 1 * scale, armY + armLength * 0.45, armWidth - 2 * scale, armLength * 0.55, 3 * scale);
+ ctx.fill();
+
+ // Hand
+ ctx.beginPath();
+ ctx.arc(cx - armOffset, armY + armLength, armWidth / 2 + 1 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ // Right arm
+ ctx.save();
+ if (transforms && transforms.rightArm) {
+ ctx.translate(cx + armOffset, armY);
+ applyTransforms(ctx, transforms, 'rightArm');
+ ctx.translate(-(cx + armOffset), -armY);
+ }
+
+ // Sleeve
+ ctx.fillStyle = clothingColor;
+ roundRect(ctx, cx + armOffset - armWidth / 2, armY, armWidth, armLength * 0.5, 3 * scale);
+ ctx.fill();
+
+ // Skin part
+ ctx.fillStyle = skinTone;
+ roundRect(ctx, cx + armOffset - armWidth / 2 + 1 * scale, armY + armLength * 0.45, armWidth - 2 * scale, armLength * 0.55, 3 * scale);
+ ctx.fill();
+
+ // Hand
+ ctx.beginPath();
+ ctx.arc(cx + armOffset, armY + armLength, armWidth / 2 + 1 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /**
+ * Render legs
+ */
+ function renderLegs(ctx, avatar, x, y, scale, bodyType, clothing, transforms) {
+ var cx = x + 32 * scale;
+ var legY = y + 124 * scale;
+ var pantsColor = clothing && clothing.pantsColor ? clothing.pantsColor : '#2D2D2D';
+ var shoeColor = clothing && clothing.shoeColor ? clothing.shoeColor : '#4A4A4A';
+
+ var legWidth = 12 * scale;
+ var legLength = 32 * scale;
+ var legSpacing = 6 * scale;
+
+ if (bodyType && bodyType.id === 'large') {
+ legWidth = 14 * scale;
+ }
+ if (bodyType && bodyType.id === 'slim') {
+ legWidth = 10 * scale;
+ }
+
+ // Left leg
+ ctx.save();
+ if (transforms && transforms.leftLeg) {
+ ctx.translate(cx - legSpacing - legWidth / 2, legY);
+ applyTransforms(ctx, transforms, 'leftLeg');
+ ctx.translate(-(cx - legSpacing - legWidth / 2), -legY);
+ }
+
+ ctx.fillStyle = pantsColor;
+ roundRect(ctx, cx - legSpacing - legWidth, legY, legWidth, legLength, 3 * scale);
+ ctx.fill();
+
+ // Left shoe
+ ctx.fillStyle = shoeColor;
+ roundRect(ctx, cx - legSpacing - legWidth - 2 * scale, legY + legLength - 4 * scale, legWidth + 4 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+ ctx.restore();
+
+ // Right leg
+ ctx.save();
+ if (transforms && transforms.rightLeg) {
+ ctx.translate(cx + legSpacing + legWidth / 2, legY);
+ applyTransforms(ctx, transforms, 'rightLeg');
+ ctx.translate(-(cx + legSpacing + legWidth / 2), -legY);
+ }
+
+ ctx.fillStyle = pantsColor;
+ roundRect(ctx, cx + legSpacing, legY, legWidth, legLength, 3 * scale);
+ ctx.fill();
+
+ // Right shoe
+ ctx.fillStyle = shoeColor;
+ roundRect(ctx, cx + legSpacing - 2 * scale, legY + legLength - 4 * scale, legWidth + 4 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /**
+ * Render clothing details based on style
+ */
+ function renderClothingDetails(ctx, style, baseColor, x, y, w, h, scale) {
+ var styleData = window.AvatarData ? window.AvatarData.getClothingStyle(style) : null;
+ var styleId = styleData ? styleData.id : style || 'casual';
+
+ switch (styleId) {
+ case 'sporty':
+ // Sport stripes
+ ctx.fillStyle = '#FFFFFF';
+ ctx.globalAlpha = 0.7;
+ roundRect(ctx, x + 2 * scale, y + h * 0.3, 3 * scale, h * 0.5, 1 * scale);
+ ctx.fill();
+ roundRect(ctx, x + w - 5 * scale, y + h * 0.3, 3 * scale, h * 0.5, 1 * scale);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ break;
+
+ case 'formal':
+ // Collar
+ ctx.fillStyle = '#FFFFFF';
+ var collarY = y - 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(x + w / 2 - 8 * scale, collarY);
+ ctx.lineTo(x + w / 2 - 12 * scale, collarY + 10 * scale);
+ ctx.lineTo(x + w / 2 - 4 * scale, collarY + 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(x + w / 2 + 8 * scale, collarY);
+ ctx.lineTo(x + w / 2 + 12 * scale, collarY + 10 * scale);
+ ctx.lineTo(x + w / 2 + 4 * scale, collarY + 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Buttons
+ ctx.fillStyle = '#DDDDDD';
+ for (var i = 0; i < 3; i++) {
+ ctx.beginPath();
+ ctx.arc(x + w / 2, y + 12 * scale + i * 10 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ break;
+
+ case 'scifi':
+ // Glowing trim lines
+ ctx.save();
+ ctx.strokeStyle = '#00FFFF';
+ ctx.lineWidth = 1.5 * scale;
+ ctx.shadowColor = '#00FFFF';
+ ctx.shadowBlur = 5 * scale;
+
+ // Horizontal line
+ ctx.beginPath();
+ ctx.moveTo(x + 4 * scale, y + h * 0.4);
+ ctx.lineTo(x + w - 4 * scale, y + h * 0.4);
+ ctx.stroke();
+
+ // Diagonal accents
+ ctx.beginPath();
+ ctx.moveTo(x + 4 * scale, y + h * 0.6);
+ ctx.lineTo(x + w * 0.3, y + h * 0.8);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(x + w - 4 * scale, y + h * 0.6);
+ ctx.lineTo(x + w * 0.7, y + h * 0.8);
+ ctx.stroke();
+
+ ctx.restore();
+ break;
+
+ case 'casual':
+ default:
+ // Simple neckline
+ ctx.strokeStyle = adjustBrightness(baseColor, -30);
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.arc(x + w / 2, y - 2 * scale, 8 * scale, 0.1 * Math.PI, 0.9 * Math.PI);
+ ctx.stroke();
+ break;
+ }
+ }
+
+ // ============================================
+ // HEAD COMPOSITE RENDERING
+ // ============================================
+
+ /**
+ * Render complete head
+ */
+ function renderHead(ctx, avatar, x, y, scale, transforms) {
+ // Ears (behind head)
+ renderEars(ctx, avatar, x, y, scale, transforms);
+
+ // Face base
+ renderFaceBase(ctx, avatar, x, y, scale);
+
+ // Face features
+ renderEyes(ctx, avatar, x, y, scale, transforms);
+ renderEyebrows(ctx, avatar, x, y, scale);
+ renderNose(ctx, avatar, x, y, scale);
+ renderMouth(ctx, avatar, x, y, scale);
+
+ // Facial hair
+ if (avatar.base && avatar.base.facialHair && avatar.base.facialHair !== 'none') {
+ renderFacialHair(ctx, avatar, x, y, scale);
+ }
+ }
+
+ // ============================================
+ // BODY COMPOSITE RENDERING
+ // ============================================
+
+ /**
+ * Render body based on mode
+ */
+ function renderBody(ctx, avatar, x, y, scale, mode, transforms) {
+ var bodyType = window.AvatarData ? window.AvatarData.getBodyType(avatar.base.body ? avatar.base.body.type : 'average') : null;
+ var clothing = avatar.base.clothing || {};
+
+ if (mode === 'HEAD_AND_SHOULDERS' || mode === 'UPPER_BODY' || mode === 'FULL_BODY') {
+ renderNeck(ctx, avatar, x, y, scale);
+ renderShoulders(ctx, avatar, x, y, scale, bodyType, clothing);
+ }
+
+ if (mode === 'UPPER_BODY' || mode === 'FULL_BODY') {
+ renderTorso(ctx, avatar, x, y, scale, bodyType, clothing, transforms);
+ renderArms(ctx, avatar, x, y, scale, bodyType, clothing, transforms);
+ }
+
+ if (mode === 'FULL_BODY') {
+ renderLegs(ctx, avatar, x, y, scale, bodyType, clothing, transforms);
+ }
+ }
+
+ // ============================================
+ // TRANSFORM HELPERS
+ // ============================================
+
+ /**
+ * Apply animation transforms to context
+ */
+ function applyTransforms(ctx, transforms, partName) {
+ if (!transforms || !transforms[partName]) return;
+ var t = transforms[partName];
+
+ if (t.x || t.y) {
+ ctx.translate(t.x || 0, t.y || 0);
+ }
+ if (t.rotation) {
+ ctx.rotate(t.rotation * Math.PI / 180);
+ }
+ if (t.scaleX !== undefined || t.scaleY !== undefined) {
+ ctx.scale(t.scaleX !== undefined ? t.scaleX : 1, t.scaleY !== undefined ? t.scaleY : 1);
+ }
+ }
+
+ // ============================================
+ // MAIN RENDER FUNCTION
+ // ============================================
+
+ /**
+ * Main render function
+ */
+ function render(ctx, avatar, mode, x, y, options) {
+ options = options || {};
+ var scale = options.scale || 1;
+ var transforms = options.transforms || {};
+ var modeData = MODES[mode] || MODES.HEAD_ONLY;
+
+ // Validate avatar data
+ if (!avatar || !avatar.base) {
+ console.warn('AvatarRenderer: Invalid avatar data');
+ return;
+ }
+
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.scale(scale, scale);
+
+ // 1. Back hair layer (for long hair behind body)
+ if (mode !== 'HEAD_ONLY') {
+ renderHairBack(ctx, avatar, 0, 0, 1);
+ }
+
+ // 2. Body (if applicable)
+ if (mode !== 'HEAD_ONLY') {
+ renderBody(ctx, avatar, 0, 0, 1, mode, transforms);
+ }
+
+ // 3. Head
+ ctx.save();
+ applyTransforms(ctx, transforms, 'head');
+ renderHead(ctx, avatar, 0, 0, 1, transforms);
+ ctx.restore();
+
+ // 4. Front hair layer
+ renderHairFront(ctx, avatar, 0, 0, 1);
+
+ ctx.restore();
+ }
+
+ // ============================================
+ // PUBLIC API
+ // ============================================
+
+ window.AvatarRenderer = {
+ MODES: MODES,
+
+ /**
+ * Render an avatar to a canvas context
+ */
+ render: render,
+
+ /**
+ * Render just the head
+ */
+ renderHead: function(ctx, avatar, x, y, scale) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.scale(scale || 1, scale || 1);
+ renderHead(ctx, avatar, 0, 0, 1, {});
+ renderHairFront(ctx, avatar, 0, 0, 1);
+ ctx.restore();
+ },
+
+ /**
+ * Render just the body
+ */
+ renderBody: function(ctx, avatar, x, y, scale, mode) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.scale(scale || 1, scale || 1);
+ renderBody(ctx, avatar, 0, 0, 1, mode || 'FULL_BODY', {});
+ ctx.restore();
+ },
+
+ /**
+ * Get dimensions for a rendering mode
+ */
+ getModeSize: function(mode) {
+ return MODES[mode] || MODES.HEAD_ONLY;
+ },
+
+ /**
+ * Create an offscreen canvas with rendered avatar (for caching)
+ */
+ createSprite: function(avatar, mode, scale) {
+ scale = scale || 1;
+ var size = MODES[mode] || MODES.HEAD_ONLY;
+ var canvas = document.createElement('canvas');
+ canvas.width = size.width * scale;
+ canvas.height = size.height * scale;
+ var ctx = canvas.getContext('2d');
+ this.render(ctx, avatar, mode, 0, 0, { scale: scale });
+ return canvas;
+ },
+
+ /**
+ * Batch render multiple avatars for performance
+ */
+ renderBatch: function(ctx, avatars, mode, positions) {
+ if (!avatars || !positions || avatars.length !== positions.length) {
+ console.warn('AvatarRenderer.renderBatch: Invalid parameters');
+ return;
+ }
+
+ var canvasWidth = ctx.canvas.width;
+ var canvasHeight = ctx.canvas.height;
+ var modeSize = MODES[mode] || MODES.HEAD_ONLY;
+
+ for (var i = 0; i < avatars.length; i++) {
+ var pos = positions[i];
+
+ // Skip off-screen avatars
+ if (pos.x + modeSize.width < 0 || pos.x > canvasWidth ||
+ pos.y + modeSize.height < 0 || pos.y > canvasHeight) {
+ continue;
+ }
+
+ this.render(ctx, avatars[i], mode, pos.x, pos.y, pos.options || {});
+ }
+ },
+
+ /**
+ * Pre-render avatars to sprite cache for performance
+ */
+ createSpriteCache: function(avatars, mode, scale) {
+ var cache = {};
+ scale = scale || 1;
+
+ for (var i = 0; i < avatars.length; i++) {
+ var avatar = avatars[i];
+ if (avatar && avatar.id) {
+ cache[avatar.id] = this.createSprite(avatar, mode, scale);
+ }
+ }
+
+ return cache;
+ },
+
+ /**
+ * Render from cached sprite
+ */
+ renderFromCache: function(ctx, cache, avatarId, x, y) {
+ var sprite = cache[avatarId];
+ if (sprite) {
+ ctx.drawImage(sprite, x, y);
+ return true;
+ }
+ return false;
+ },
+
+ // Expose helper functions for custom rendering
+ helpers: {
+ roundRect: roundRect,
+ drawCurve: drawCurve,
+ drawEllipse: drawEllipse,
+ gradientFill: gradientFill,
+ skinShade: skinShade,
+ adjustBrightness: adjustBrightness,
+ addHairShine: addHairShine,
+ applyTransforms: applyTransforms
+ }
+ };
+})();
diff --git a/frontend/public/assets/avatar/avatarSystem.js b/frontend/public/assets/avatar/avatarSystem.js
new file mode 100644
index 0000000..01b29a3
--- /dev/null
+++ b/frontend/public/assets/avatar/avatarSystem.js
@@ -0,0 +1,997 @@
+/**
+ * AvatarSystem - Main API for Mii-style avatar system
+ * Coordinates data, rendering, animations, and accessories
+ *
+ * Depends on: AvatarData, AvatarAnimations, AvatarRenderer, AvatarAccessories, AccessoryRenderer
+ */
+(function() {
+ 'use strict';
+
+ // ─── Private State ───────────────────────────────────
+
+ // In-memory cache of loaded avatars
+ var avatarCache = new Map();
+
+ // Animation state per avatar
+ var animationState = new Map();
+
+ // ─── Private Helpers ─────────────────────────────────
+
+ /**
+ * Validate avatar data structure
+ * @param {Object} data - Avatar data to validate
+ * @returns {boolean} True if valid
+ */
+ function validateAvatar(data) {
+ if (!data || typeof data !== 'object') return false;
+ if (!data.base || typeof data.base !== 'object') return false;
+
+ // Check required base fields exist
+ var requiredFields = ['faceShape', 'skinTone', 'eyes', 'eyebrows', 'nose', 'mouth', 'ears', 'hair', 'body', 'clothing'];
+ for (var i = 0; i < requiredFields.length; i++) {
+ if (data.base[requiredFields[i]] === undefined) {
+ return false;
+ }
+ }
+
+ // Check nested objects
+ if (!data.base.eyes || typeof data.base.eyes !== 'object') return false;
+ if (!data.base.eyes.shape || !data.base.eyes.color) return false;
+
+ if (!data.base.hair || typeof data.base.hair !== 'object') return false;
+ if (!data.base.hair.style || !data.base.hair.color) return false;
+
+ if (!data.base.body || typeof data.base.body !== 'object') return false;
+ if (!data.base.body.type) return false;
+
+ if (!data.base.clothing || typeof data.base.clothing !== 'object') return false;
+ if (!data.base.clothing.style || !data.base.clothing.shirtColor || !data.base.clothing.pantsColor) return false;
+
+ return true;
+ }
+
+ /**
+ * Create a default avatar with standard settings
+ * @returns {Object} Default avatar object
+ */
+ function createDefault() {
+ return {
+ userId: null,
+ base: {
+ faceShape: 'oval',
+ skinTone: '#d4a373',
+ eyes: { shape: 'round', color: 'brown' },
+ eyebrows: 'natural',
+ nose: 'button',
+ mouth: 'smile',
+ ears: 'normal',
+ hair: { style: 'short-messy', color: '#2c1810' },
+ facialHair: 'none',
+ body: { type: 'average' },
+ clothing: { style: 'casual', shirtColor: '#3498db', pantsColor: '#2c3e50' }
+ },
+ earned: { eyewear: [], headwear: [], effects: [] },
+ equipped: { eyewear: null, headwear: null, effect: null },
+ level: 1,
+ xp: 0
+ };
+ }
+
+ /**
+ * Deep clone an avatar object
+ * @param {Object} avatar - Avatar to clone
+ * @returns {Object} Cloned avatar
+ */
+ function cloneAvatar(avatar) {
+ return JSON.parse(JSON.stringify(avatar));
+ }
+
+ /**
+ * Merge customization into base avatar
+ * @param {Object} target - Target avatar base
+ * @param {Object} source - Source customization
+ * @returns {Object} Merged base object
+ */
+ function deepMergeBase(target, source) {
+ var result = Object.assign({}, target);
+
+ for (var key in source) {
+ if (source.hasOwnProperty(key)) {
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
+ result[key] = Object.assign({}, target[key] || {}, source[key]);
+ } else {
+ result[key] = source[key];
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Get animation frame based on time
+ * @param {Object} anim - Animation definition
+ * @param {number} startTime - Animation start time
+ * @param {boolean} loop - Whether to loop
+ * @returns {Object} Frame info with index and complete flag
+ */
+ function getAnimationFrame(anim, startTime, loop) {
+ var elapsed = performance.now() - startTime;
+ var totalDuration = anim.frameDuration * anim.frameCount;
+
+ if (!loop && elapsed >= totalDuration) {
+ return { frame: anim.frameCount - 1, complete: true };
+ }
+
+ var frameIndex = Math.floor((elapsed / anim.frameDuration) % anim.frameCount);
+ return { frame: frameIndex, complete: false };
+ }
+
+ // ─── Public API ──────────────────────────────────────
+
+ window.AvatarSystem = {
+
+ // ─── Creation ──────────────────────────────────────
+
+ /**
+ * Create a new avatar from customization choices
+ * @param {Object} customization - Full base customization object
+ * @returns {Object} Complete avatar object
+ */
+ create: function(customization) {
+ var avatar = createDefault();
+ if (customization) {
+ avatar.base = deepMergeBase(avatar.base, customization);
+ }
+ return avatar;
+ },
+
+ /**
+ * Load user's saved avatar (from API or storage)
+ * @param {string} userId - User ID to load avatar for
+ * @returns {Promise} Avatar object
+ */
+ loadUserAvatar: function(userId) {
+ var self = this;
+
+ return new Promise(function(resolve) {
+ // Check cache first
+ if (avatarCache.has(userId)) {
+ resolve(cloneAvatar(avatarCache.get(userId)));
+ return;
+ }
+
+ // In production, this would fetch from API
+ // For now, check localStorage as fallback
+ var storageKey = 'avatar_' + userId;
+ var stored = localStorage.getItem(storageKey);
+
+ if (stored) {
+ try {
+ var parsed = JSON.parse(stored);
+ if (validateAvatar(parsed)) {
+ avatarCache.set(userId, parsed);
+ resolve(cloneAvatar(parsed));
+ return;
+ }
+ } catch (e) {
+ console.warn('AvatarSystem: Failed to parse stored avatar', e);
+ }
+ }
+
+ // Return a guest avatar for unknown users
+ var guest = typeof AvatarData !== 'undefined' && AvatarData.getRandomGuestAvatar
+ ? AvatarData.getRandomGuestAvatar()
+ : { base: createDefault().base };
+
+ var avatar = createDefault();
+ avatar.userId = userId;
+ avatar.base = Object.assign({}, avatar.base, guest.base);
+
+ resolve(avatar);
+ });
+ },
+
+ /**
+ * Save avatar to cache (and would persist to API)
+ * @param {Object} avatar - Avatar to save
+ * @returns {Object} Saved avatar
+ */
+ saveAvatar: function(avatar) {
+ if (avatar.userId) {
+ avatarCache.set(avatar.userId, cloneAvatar(avatar));
+
+ // Also save to localStorage for persistence
+ var storageKey = 'avatar_' + avatar.userId;
+ try {
+ localStorage.setItem(storageKey, JSON.stringify(avatar));
+ } catch (e) {
+ console.warn('AvatarSystem: Failed to save avatar to localStorage', e);
+ }
+ }
+ return avatar;
+ },
+
+ /**
+ * Clear avatar from cache
+ * @param {string} userId - User ID to clear
+ */
+ clearCache: function(userId) {
+ if (userId) {
+ avatarCache.delete(userId);
+ } else {
+ avatarCache.clear();
+ }
+ },
+
+ // ─── Rendering ─────────────────────────────────────
+
+ /**
+ * Render avatar in specified mode
+ * @param {Object} avatar - Avatar object
+ * @param {string} mode - 'HEAD_ONLY', 'HEAD_AND_SHOULDERS', 'UPPER_BODY', 'FULL_BODY'
+ * @param {CanvasRenderingContext2D} ctx - Canvas context
+ * @param {number} x - Center X position
+ * @param {number} y - Center Y position
+ * @param {Object} options - { scale, animation, frame, time }
+ */
+ render: function(avatar, mode, ctx, x, y, options) {
+ options = options || {};
+ var scale = options.scale || 1;
+ var time = options.time !== undefined ? options.time : performance.now() / 1000;
+
+ // Get animation transforms if animating
+ var transforms = {};
+ if (options.animation && typeof AvatarAnimations !== 'undefined') {
+ var anim = AvatarAnimations.getAnimation(mode, options.animation);
+ if (anim) {
+ var frame;
+ if (options.frame !== undefined) {
+ frame = options.frame;
+ } else {
+ frame = Math.floor((time * 1000 / anim.frameDuration) % anim.frameCount);
+ }
+ transforms = anim.frames[frame] || {};
+ }
+ }
+
+ // Render base avatar
+ if (typeof AvatarRenderer !== 'undefined') {
+ AvatarRenderer.render(ctx, avatar, mode, x, y, {
+ scale: scale,
+ transforms: transforms
+ });
+ } else {
+ console.warn('AvatarSystem: AvatarRenderer not loaded');
+ }
+
+ // Render accessories on top
+ if (typeof AccessoryRenderer !== 'undefined') {
+ AccessoryRenderer.render(ctx, avatar, mode, x, y, scale, time);
+ }
+ },
+
+ /**
+ * Render avatar to an offscreen canvas
+ * @param {Object} avatar - Avatar object
+ * @param {string} mode - Rendering mode
+ * @param {Object} options - Render options
+ * @returns {HTMLCanvasElement} Canvas with rendered avatar
+ */
+ renderToCanvas: function(avatar, mode, options) {
+ options = options || {};
+ var modeData = this.getModeSize(mode);
+ var scale = options.scale || 1;
+
+ var canvas = document.createElement('canvas');
+ canvas.width = modeData.width * scale;
+ canvas.height = modeData.height * scale;
+
+ var ctx = canvas.getContext('2d');
+ var centerX = canvas.width / 2;
+ var centerY = canvas.height / 2;
+
+ this.render(avatar, mode, ctx, centerX, centerY, options);
+
+ return canvas;
+ },
+
+ /**
+ * Render avatar to a data URL
+ * @param {Object} avatar - Avatar object
+ * @param {string} mode - Rendering mode
+ * @param {Object} options - Render options
+ * @returns {string} Data URL of rendered avatar
+ */
+ renderToDataURL: function(avatar, mode, options) {
+ var canvas = this.renderToCanvas(avatar, mode, options);
+ return canvas.toDataURL('image/png');
+ },
+
+ // ─── Animation ─────────────────────────────────────
+
+ /**
+ * Get current animation frame transforms
+ * @param {Object} avatar - Avatar object (unused but kept for API consistency)
+ * @param {string} animation - Animation ID
+ * @param {number} frame - Frame number
+ * @returns {Object} Transform data for the frame
+ */
+ animate: function(avatar, animation, frame) {
+ if (typeof AvatarAnimations === 'undefined') {
+ return {};
+ }
+
+ var anim = AvatarAnimations.getAnimation('FULL_BODY', animation);
+ if (!anim) return {};
+
+ var f = frame % anim.frameCount;
+ return anim.frames[f] || {};
+ },
+
+ /**
+ * Start an animation for an avatar
+ * @param {string} avatarId - Avatar/user ID
+ * @param {string} animation - Animation ID
+ * @param {Object} options - { loop, onComplete, mode }
+ */
+ startAnimation: function(avatarId, animation, options) {
+ options = options || {};
+
+ animationState.set(avatarId, {
+ animation: animation,
+ startTime: performance.now(),
+ loop: options.loop !== false,
+ mode: options.mode || 'FULL_BODY',
+ onComplete: options.onComplete || null
+ });
+ },
+
+ /**
+ * Stop animation for an avatar
+ * @param {string} avatarId - Avatar/user ID
+ */
+ stopAnimation: function(avatarId) {
+ var state = animationState.get(avatarId);
+ if (state && state.onComplete) {
+ state.onComplete();
+ }
+ animationState.delete(avatarId);
+ },
+
+ /**
+ * Get current animation state for an avatar
+ * @param {string} avatarId - Avatar/user ID
+ * @returns {Object|null} Current animation state or null
+ */
+ getAnimationState: function(avatarId) {
+ var state = animationState.get(avatarId);
+ if (!state) return null;
+
+ if (typeof AvatarAnimations === 'undefined') {
+ return { animation: state.animation, frame: 0, complete: false };
+ }
+
+ var anim = AvatarAnimations.getAnimation(state.mode, state.animation);
+ if (!anim) return null;
+
+ var frameInfo = getAnimationFrame(anim, state.startTime, state.loop);
+
+ // Handle completion
+ if (frameInfo.complete && !state.loop) {
+ if (state.onComplete) {
+ state.onComplete();
+ }
+ animationState.delete(avatarId);
+ }
+
+ return {
+ animation: state.animation,
+ frame: frameInfo.frame,
+ complete: frameInfo.complete,
+ transforms: anim.frames[frameInfo.frame] || {}
+ };
+ },
+
+ /**
+ * Check if avatar is currently animating
+ * @param {string} avatarId - Avatar/user ID
+ * @returns {boolean} True if animating
+ */
+ isAnimating: function(avatarId) {
+ return animationState.has(avatarId);
+ },
+
+ // ─── Accessories ───────────────────────────────────
+
+ /**
+ * Equip an accessory
+ * @param {Object} avatar - Avatar object
+ * @param {string} slot - 'eyewear', 'headwear', 'effect'
+ * @param {string} itemId - Accessory ID or null to unequip
+ * @returns {boolean} True if successfully equipped
+ */
+ equipAccessory: function(avatar, slot, itemId) {
+ if (!avatar.equipped) {
+ avatar.equipped = { eyewear: null, headwear: null, effect: null };
+ }
+
+ // Allow unequipping (null/undefined)
+ if (!itemId) {
+ avatar.equipped[slot] = null;
+ return true;
+ }
+
+ // Verify user has earned this item
+ var earnedKey = slot === 'effect' ? 'effects' : slot;
+ var earned = avatar.earned && avatar.earned[earnedKey];
+
+ if (!earned || !earned.includes(itemId)) {
+ console.warn('AvatarSystem: Cannot equip unearned item', itemId);
+ return false;
+ }
+
+ avatar.equipped[slot] = itemId;
+ return true;
+ },
+
+ /**
+ * Unequip an accessory from a slot
+ * @param {Object} avatar - Avatar object
+ * @param {string} slot - 'eyewear', 'headwear', 'effect'
+ * @returns {boolean} True if successfully unequipped
+ */
+ unequipAccessory: function(avatar, slot) {
+ return this.equipAccessory(avatar, slot, null);
+ },
+
+ /**
+ * Unlock/award an accessory to a user
+ * @param {Object} avatar - Avatar object
+ * @param {string} itemId - Accessory ID to unlock
+ * @returns {boolean} True if newly unlocked, false if already owned
+ */
+ unlockAccessory: function(avatar, itemId) {
+ if (typeof AvatarAccessories === 'undefined') {
+ console.warn('AvatarSystem: AvatarAccessories not loaded');
+ return false;
+ }
+
+ var item = AvatarAccessories.getById(itemId);
+ if (!item) {
+ console.warn('AvatarSystem: Unknown accessory', itemId);
+ return false;
+ }
+
+ var category = item.category === 'effect' ? 'effects' : item.category;
+
+ // Initialize earned structure if needed
+ if (!avatar.earned) {
+ avatar.earned = { eyewear: [], headwear: [], effects: [] };
+ }
+ if (!avatar.earned[category]) {
+ avatar.earned[category] = [];
+ }
+
+ // Check if already owned
+ if (avatar.earned[category].includes(itemId)) {
+ return false;
+ }
+
+ avatar.earned[category].push(itemId);
+ return true;
+ },
+
+ /**
+ * Get all accessories a user has earned
+ * @param {Object} avatar - Avatar object
+ * @returns {Object} Object with eyewear, headwear, effects arrays of accessory objects
+ */
+ getEarnedAccessories: function(avatar) {
+ var result = { eyewear: [], headwear: [], effects: [] };
+
+ if (!avatar.earned) return result;
+
+ if (typeof AvatarAccessories === 'undefined') {
+ return result;
+ }
+
+ if (avatar.earned.eyewear) {
+ result.eyewear = avatar.earned.eyewear
+ .map(function(id) { return AvatarAccessories.getById(id); })
+ .filter(function(item) { return item !== null; });
+ }
+
+ if (avatar.earned.headwear) {
+ result.headwear = avatar.earned.headwear
+ .map(function(id) { return AvatarAccessories.getById(id); })
+ .filter(function(item) { return item !== null; });
+ }
+
+ if (avatar.earned.effects) {
+ result.effects = avatar.earned.effects
+ .map(function(id) { return AvatarAccessories.getById(id); })
+ .filter(function(item) { return item !== null; });
+ }
+
+ return result;
+ },
+
+ /**
+ * Get currently equipped accessories
+ * @param {Object} avatar - Avatar object
+ * @returns {Object} Object with equipped accessory objects
+ */
+ getEquippedAccessories: function(avatar) {
+ var result = { eyewear: null, headwear: null, effect: null };
+
+ if (!avatar.equipped) return result;
+
+ if (typeof AvatarAccessories === 'undefined') {
+ return result;
+ }
+
+ if (avatar.equipped.eyewear) {
+ result.eyewear = AvatarAccessories.getById(avatar.equipped.eyewear);
+ }
+ if (avatar.equipped.headwear) {
+ result.headwear = AvatarAccessories.getById(avatar.equipped.headwear);
+ }
+ if (avatar.equipped.effect) {
+ result.effect = AvatarAccessories.getById(avatar.equipped.effect);
+ }
+
+ return result;
+ },
+
+ /**
+ * Check what accessories should unlock at a level
+ * @param {Object} avatar - Avatar object
+ * @param {number} newLevel - New level reached
+ * @returns {Array} Array of newly unlocked accessory objects
+ */
+ checkLevelUnlocks: function(avatar, newLevel) {
+ if (typeof AvatarAccessories === 'undefined') {
+ return [];
+ }
+
+ var newItems = AvatarAccessories.getNewlyUnlockedAtLevel(newLevel);
+ var unlocked = [];
+ var self = this;
+
+ newItems.forEach(function(item) {
+ if (self.unlockAccessory(avatar, item.id)) {
+ unlocked.push(item);
+ }
+ });
+
+ return unlocked;
+ },
+
+ /**
+ * Get the full accessory catalog
+ * @returns {Object} Full accessory catalog
+ */
+ getAccessoryCatalog: function() {
+ if (typeof AvatarAccessories === 'undefined') {
+ return { eyewear: [], headwear: [], effects: [] };
+ }
+ return AvatarAccessories.getCatalog();
+ },
+
+ /**
+ * Check if avatar has a specific accessory
+ * @param {Object} avatar - Avatar object
+ * @param {string} itemId - Accessory ID
+ * @returns {boolean} True if owned
+ */
+ hasAccessory: function(avatar, itemId) {
+ if (!avatar.earned) return false;
+
+ for (var category in avatar.earned) {
+ if (avatar.earned[category] && avatar.earned[category].includes(itemId)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ // ─── Export for Games ──────────────────────────────
+
+ /**
+ * Prepare avatar for use in a game
+ * Returns a simplified object optimized for game rendering
+ * @param {Object} avatar - Avatar object
+ * @param {string} mode - Rendering mode
+ * @returns {Object} Game-ready avatar object
+ */
+ exportForGame: function(avatar, mode) {
+ var self = this;
+ var modeData = this.getModeSize(mode);
+ var cloned = cloneAvatar(avatar);
+
+ var animations = {};
+ if (typeof AvatarAnimations !== 'undefined') {
+ animations = AvatarAnimations.getAnimationsForMode(mode);
+ }
+
+ return {
+ mode: mode,
+ width: modeData.width,
+ height: modeData.height,
+ avatar: cloned,
+ animations: animations,
+
+ /**
+ * Render the avatar
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - X position
+ * @param {number} y - Y position
+ * @param {Object} options - Render options
+ */
+ render: function(ctx, x, y, options) {
+ self.render(cloned, mode, ctx, x, y, options);
+ },
+
+ /**
+ * Get animation transforms
+ * @param {string} animation - Animation ID
+ * @param {number} frame - Frame number
+ * @returns {Object} Transform data
+ */
+ animate: function(animation, frame) {
+ return self.animate(cloned, animation, frame);
+ },
+
+ /**
+ * Get animation info
+ * @param {string} animationId - Animation ID
+ * @returns {Object|null} Animation info
+ */
+ getAnimationInfo: function(animationId) {
+ if (typeof AvatarAnimations === 'undefined') return null;
+ return AvatarAnimations.getAnimation(mode, animationId);
+ }
+ };
+ },
+
+ /**
+ * Export avatar as minimal data for network transmission
+ * @param {Object} avatar - Avatar object
+ * @returns {Object} Minimal avatar data
+ */
+ exportMinimal: function(avatar) {
+ return {
+ base: avatar.base,
+ equipped: avatar.equipped,
+ level: avatar.level
+ };
+ },
+
+ /**
+ * Import avatar from minimal data
+ * @param {Object} minimalData - Minimal avatar data
+ * @param {string} userId - User ID
+ * @returns {Object} Full avatar object
+ */
+ importMinimal: function(minimalData, userId) {
+ var avatar = createDefault();
+ avatar.userId = userId;
+
+ if (minimalData.base) {
+ avatar.base = deepMergeBase(avatar.base, minimalData.base);
+ }
+ if (minimalData.equipped) {
+ avatar.equipped = Object.assign({}, avatar.equipped, minimalData.equipped);
+ }
+ if (minimalData.level !== undefined) {
+ avatar.level = minimalData.level;
+ }
+
+ return avatar;
+ },
+
+ // ─── Guest Avatars ─────────────────────────────────
+
+ /**
+ * Get a random guest avatar (for non-logged-in users)
+ * @returns {Object} Guest avatar object
+ */
+ getGuestAvatar: function() {
+ var guest;
+
+ if (typeof AvatarData !== 'undefined' && AvatarData.getRandomGuestAvatar) {
+ guest = AvatarData.getRandomGuestAvatar();
+ } else {
+ guest = { base: createDefault().base };
+ }
+
+ var avatar = createDefault();
+ avatar.userId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+ avatar.base = Object.assign({}, avatar.base, guest.base);
+ avatar.isGuest = true;
+
+ return avatar;
+ },
+
+ /**
+ * Check if avatar is a guest
+ * @param {Object} avatar - Avatar object
+ * @returns {boolean} True if guest avatar
+ */
+ isGuest: function(avatar) {
+ return avatar.isGuest === true || (avatar.userId && avatar.userId.startsWith('guest_'));
+ },
+
+ /**
+ * Convert guest avatar to registered user avatar
+ * @param {Object} guestAvatar - Guest avatar
+ * @param {string} userId - New user ID
+ * @returns {Object} Converted avatar
+ */
+ convertGuestToUser: function(guestAvatar, userId) {
+ var avatar = cloneAvatar(guestAvatar);
+ avatar.userId = userId;
+ delete avatar.isGuest;
+ return avatar;
+ },
+
+ // ─── Level & XP ────────────────────────────────────
+
+ /**
+ * Add XP to avatar and check for level ups
+ * @param {Object} avatar - Avatar object
+ * @param {number} xpAmount - XP to add
+ * @returns {Object} Result with newLevel, leveledUp, unlockedItems
+ */
+ addXP: function(avatar, xpAmount) {
+ var oldLevel = avatar.level || 1;
+ avatar.xp = (avatar.xp || 0) + xpAmount;
+
+ // Calculate new level (100 XP per level, increasing by 50 each level)
+ var xpNeeded = 0;
+ var level = 0;
+ var remainingXP = avatar.xp;
+
+ while (remainingXP >= 0) {
+ level++;
+ var xpForLevel = 100 + (level - 1) * 50;
+ if (remainingXP < xpForLevel) break;
+ remainingXP -= xpForLevel;
+ }
+
+ avatar.level = level;
+
+ var result = {
+ newLevel: level,
+ leveledUp: level > oldLevel,
+ unlockedItems: []
+ };
+
+ // Check for unlocks at each new level
+ if (result.leveledUp) {
+ for (var l = oldLevel + 1; l <= level; l++) {
+ var unlocked = this.checkLevelUnlocks(avatar, l);
+ result.unlockedItems = result.unlockedItems.concat(unlocked);
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Get XP needed for next level
+ * @param {Object} avatar - Avatar object
+ * @returns {Object} XP info
+ */
+ getXPInfo: function(avatar) {
+ var level = avatar.level || 1;
+ var totalXP = avatar.xp || 0;
+
+ // Calculate XP used for previous levels
+ var xpUsed = 0;
+ for (var l = 1; l < level; l++) {
+ xpUsed += 100 + (l - 1) * 50;
+ }
+
+ var currentLevelXP = totalXP - xpUsed;
+ var xpForCurrentLevel = 100 + (level - 1) * 50;
+
+ return {
+ level: level,
+ totalXP: totalXP,
+ currentLevelXP: currentLevelXP,
+ xpNeededForLevel: xpForCurrentLevel,
+ progress: currentLevelXP / xpForCurrentLevel
+ };
+ },
+
+ // ─── Utilities ─────────────────────────────────────
+
+ /**
+ * Create a sprite sheet for an avatar
+ * Useful for game performance
+ * @param {Object} avatar - Avatar object
+ * @param {string} mode - Rendering mode
+ * @param {Array} animations - Animation IDs (optional, defaults to all)
+ * @returns {Object} Sprite sheet data
+ */
+ createSpriteSheet: function(avatar, mode, animations) {
+ var modeData = this.getModeSize(mode);
+ var self = this;
+
+ // Get animation list
+ if (!animations && typeof AvatarAnimations !== 'undefined') {
+ var allAnims = AvatarAnimations.getAnimationsForMode(mode);
+ animations = Object.keys(allAnims);
+ }
+ animations = animations || [];
+
+ // Calculate sprite sheet dimensions
+ var maxFrames = 0;
+ var animData = [];
+
+ animations.forEach(function(animId) {
+ if (typeof AvatarAnimations !== 'undefined') {
+ var anim = AvatarAnimations.getAnimation(mode, animId);
+ if (anim) {
+ maxFrames = Math.max(maxFrames, anim.frameCount);
+ animData.push({ id: animId, frames: anim.frameCount, duration: anim.frameDuration });
+ }
+ }
+ });
+
+ // Handle case with no animations
+ if (animData.length === 0) {
+ animData.push({ id: 'idle', frames: 1, duration: 100 });
+ maxFrames = 1;
+ }
+
+ // Create canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = modeData.width * maxFrames;
+ canvas.height = modeData.height * animData.length;
+ var ctx = canvas.getContext('2d');
+
+ // Render each animation frame
+ animData.forEach(function(anim, row) {
+ for (var f = 0; f < anim.frames; f++) {
+ var x = modeData.width * f + modeData.width / 2;
+ var y = modeData.height * row + modeData.height / 2;
+ self.render(avatar, mode, ctx, x, y, { animation: anim.id, frame: f });
+ }
+ });
+
+ return {
+ canvas: canvas,
+ image: canvas,
+ animations: animData,
+ frameWidth: modeData.width,
+ frameHeight: modeData.height,
+
+ /**
+ * Get frame coordinates for an animation frame
+ * @param {string} animId - Animation ID
+ * @param {number} frame - Frame number
+ * @returns {Object} Source rectangle {x, y, width, height}
+ */
+ getFrameRect: function(animId, frame) {
+ var row = 0;
+ for (var i = 0; i < animData.length; i++) {
+ if (animData[i].id === animId) {
+ row = i;
+ break;
+ }
+ }
+ var f = frame % (animData[row] ? animData[row].frames : 1);
+ return {
+ x: f * modeData.width,
+ y: row * modeData.height,
+ width: modeData.width,
+ height: modeData.height
+ };
+ }
+ };
+ },
+
+ /**
+ * Get rendering mode dimensions
+ * @param {string} mode - Rendering mode
+ * @returns {Object} Mode dimensions {width, height}
+ */
+ getModeSize: function(mode) {
+ if (typeof AvatarAnimations !== 'undefined' && AvatarAnimations.getMode) {
+ return AvatarAnimations.getMode(mode);
+ }
+
+ // Fallback defaults
+ var defaults = {
+ 'HEAD_ONLY': { width: 64, height: 64 },
+ 'HEAD_AND_SHOULDERS': { width: 96, height: 96 },
+ 'UPPER_BODY': { width: 128, height: 128 },
+ 'FULL_BODY': { width: 128, height: 192 }
+ };
+
+ return defaults[mode] || defaults['HEAD_ONLY'];
+ },
+
+ /**
+ * Validate avatar data structure
+ * @param {Object} avatar - Avatar to validate
+ * @returns {boolean} True if valid
+ */
+ validate: validateAvatar,
+
+ /**
+ * Create default avatar
+ * @returns {Object} Default avatar object
+ */
+ createDefault: createDefault,
+
+ /**
+ * Clone an avatar object
+ * @param {Object} avatar - Avatar to clone
+ * @returns {Object} Cloned avatar
+ */
+ clone: cloneAvatar,
+
+ /**
+ * Update avatar base properties
+ * @param {Object} avatar - Avatar to update
+ * @param {Object} updates - Properties to update
+ * @returns {Object} Updated avatar
+ */
+ updateBase: function(avatar, updates) {
+ avatar.base = deepMergeBase(avatar.base, updates);
+ return avatar;
+ },
+
+ /**
+ * Get available customization options
+ * @returns {Object} Available options from AvatarData
+ */
+ getCustomizationOptions: function() {
+ if (typeof AvatarData === 'undefined') {
+ return {};
+ }
+
+ return {
+ faceShapes: AvatarData.FACE_SHAPES || [],
+ skinTones: AvatarData.SKIN_TONES || [],
+ eyeShapes: AvatarData.EYE_SHAPES || [],
+ eyeColors: AvatarData.EYE_COLORS || [],
+ eyebrowShapes: AvatarData.EYEBROW_SHAPES || [],
+ noseShapes: AvatarData.NOSE_SHAPES || [],
+ mouthShapes: AvatarData.MOUTH_SHAPES || [],
+ earShapes: AvatarData.EAR_SHAPES || [],
+ hairStyles: AvatarData.HAIR_STYLES || [],
+ hairColors: AvatarData.HAIR_COLORS || [],
+ facialHair: AvatarData.FACIAL_HAIR || [],
+ bodyTypes: AvatarData.BODY_TYPES || [],
+ clothingStyles: AvatarData.CLOTHING_STYLES || []
+ };
+ },
+
+ /**
+ * Compare two avatars for equality
+ * @param {Object} a - First avatar
+ * @param {Object} b - Second avatar
+ * @returns {boolean} True if equivalent
+ */
+ equals: function(a, b) {
+ return JSON.stringify(a) === JSON.stringify(b);
+ },
+
+ /**
+ * Get version info
+ * @returns {Object} Version information
+ */
+ getVersion: function() {
+ return {
+ version: '1.0.0',
+ dependencies: ['AvatarData', 'AvatarAnimations', 'AvatarRenderer', 'AvatarAccessories', 'AccessoryRenderer']
+ };
+ }
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/contextCatalog.js b/frontend/public/assets/avatar/contextCatalog.js
new file mode 100644
index 0000000..aab3afb
--- /dev/null
+++ b/frontend/public/assets/avatar/contextCatalog.js
@@ -0,0 +1,262 @@
+(function() {
+ 'use strict';
+
+ // =========================================================================
+ // AGGREGATE ALL CONTEXTS
+ // =========================================================================
+
+ var ALL_CONTEXTS = [];
+
+ // Add vehicle contexts
+ if (window.VehicleContexts) {
+ VehicleContexts.getAll().forEach(function(ctx) {
+ ALL_CONTEXTS.push(ctx);
+ });
+ }
+
+ // Add costume contexts
+ if (window.CostumeContexts) {
+ CostumeContexts.getAll().forEach(function(ctx) {
+ ALL_CONTEXTS.push(ctx);
+ });
+ }
+
+ // Add pure contexts
+ if (window.PureContexts) {
+ PureContexts.getAll().forEach(function(ctx) {
+ ALL_CONTEXTS.push(ctx);
+ });
+ }
+
+ // Create lookup map
+ var CONTEXT_MAP = {};
+ ALL_CONTEXTS.forEach(function(ctx) {
+ CONTEXT_MAP[ctx.id] = ctx;
+ });
+
+ // =========================================================================
+ // CATEGORY METADATA
+ // =========================================================================
+
+ var CATEGORIES = {
+ vehicle: {
+ id: 'vehicle',
+ name: 'Vehicles',
+ description: 'Place your avatar in vehicles and cockpits',
+ icon: '🚗',
+ count: 0 // calculated
+ },
+ costume: {
+ id: 'costume',
+ name: 'Costumes',
+ description: 'Dress your avatar in themed outfits',
+ icon: '👔',
+ count: 0
+ },
+ pure: {
+ id: 'pure',
+ name: 'Standard',
+ description: 'Your avatar with minimal modifications',
+ icon: '🏃',
+ count: 0
+ }
+ };
+
+ // Count per category
+ ALL_CONTEXTS.forEach(function(ctx) {
+ if (CATEGORIES[ctx.category]) {
+ CATEGORIES[ctx.category].count++;
+ }
+ });
+
+ // =========================================================================
+ // GAME TYPE MAPPINGS
+ // =========================================================================
+
+ var GAME_TYPE_CONTEXTS = {
+ platformer: ['platformer-standard', 'adventure-hero', 'knight-armor', 'ninja-outfit', 'superhero-cape'],
+ racing: ['race-car', 'motorcycle', 'hoverboard', 'airplane'],
+ shooter: ['spaceship-cockpit', 'tank', 'mech-suit', 'space-suit'],
+ flying: ['flappy-style', 'airplane', 'jetpack', 'dragon-rider'],
+ arcade: ['pacman-style', 'flappy-style', 'spaceship-cockpit', 'runner'],
+ rpg: ['knight-armor', 'wizard-robes', 'ninja-outfit', 'pirate-outfit', 'adventure-hero'],
+ sports: ['sports-player', 'athlete-uniform', 'runner'],
+ puzzle: ['scientist-labcoat', 'wizard-robes', 'platformer-standard'],
+ adventure: ['adventure-hero', 'pirate-outfit', 'cowboy-gear', 'dragon-rider'],
+ action: ['superhero-cape', 'ninja-outfit', 'mech-suit', 'jetpack'],
+ underwater: ['submarine', 'space-suit'],
+ space: ['spaceship-cockpit', 'space-suit', 'mech-suit'],
+ cooking: ['chef-outfit'],
+ simulation: ['airplane', 'submarine', 'race-car', 'tank']
+ };
+
+ // =========================================================================
+ // MODE GROUPINGS
+ // =========================================================================
+
+ var MODE_CONTEXTS = {
+ HEAD_ONLY: [],
+ HEAD_AND_SHOULDERS: [],
+ UPPER_BODY: [],
+ FULL_BODY: []
+ };
+
+ ALL_CONTEXTS.forEach(function(ctx) {
+ if (MODE_CONTEXTS[ctx.avatarMode]) {
+ MODE_CONTEXTS[ctx.avatarMode].push(ctx.id);
+ }
+ });
+
+ // =========================================================================
+ // CATALOG STATISTICS
+ // =========================================================================
+
+ var STATS = {
+ totalContexts: ALL_CONTEXTS.length,
+ vehicleCount: CATEGORIES.vehicle.count,
+ costumeCount: CATEGORIES.costume.count,
+ pureCount: CATEGORIES.pure.count,
+ version: '1.0.0',
+ lastUpdated: '2026-02-05'
+ };
+
+ // =========================================================================
+ // PUBLIC API
+ // =========================================================================
+
+ window.ContextCatalog = {
+ // Core getters
+ getAll: function() {
+ return ALL_CONTEXTS.slice();
+ },
+
+ getById: function(id) {
+ return CONTEXT_MAP[id] || null;
+ },
+
+ // Category queries
+ getByCategory: function(category) {
+ return ALL_CONTEXTS.filter(function(ctx) {
+ return ctx.category === category;
+ });
+ },
+
+ getCategories: function() {
+ return Object.values(CATEGORIES);
+ },
+
+ getCategoryInfo: function(categoryId) {
+ return CATEGORIES[categoryId] || null;
+ },
+
+ // Mode queries
+ getByMode: function(mode) {
+ var ids = MODE_CONTEXTS[mode] || [];
+ return ids.map(function(id) {
+ return CONTEXT_MAP[id];
+ }).filter(Boolean);
+ },
+
+ getModes: function() {
+ return Object.keys(MODE_CONTEXTS);
+ },
+
+ // Game type recommendations
+ getRecommendedFor: function(gameType) {
+ var ids = GAME_TYPE_CONTEXTS[gameType] || [];
+ return ids.map(function(id) {
+ return CONTEXT_MAP[id];
+ }).filter(Boolean);
+ },
+
+ getGameTypes: function() {
+ return Object.keys(GAME_TYPE_CONTEXTS);
+ },
+
+ // Search/filter
+ search: function(query) {
+ query = query.toLowerCase();
+ return ALL_CONTEXTS.filter(function(ctx) {
+ return ctx.id.toLowerCase().includes(query) ||
+ ctx.name.toLowerCase().includes(query) ||
+ ctx.description.toLowerCase().includes(query);
+ });
+ },
+
+ filter: function(filters) {
+ return ALL_CONTEXTS.filter(function(ctx) {
+ if (filters.category && ctx.category !== filters.category) return false;
+ if (filters.mode && ctx.avatarMode !== filters.mode) return false;
+ if (filters.gameType) {
+ var recommended = GAME_TYPE_CONTEXTS[filters.gameType] || [];
+ if (recommended.indexOf(ctx.id) === -1) return false;
+ }
+ return true;
+ });
+ },
+
+ // For UI display
+ getCatalogForUI: function() {
+ return {
+ categories: Object.values(CATEGORIES).map(function(cat) {
+ return {
+ id: cat.id,
+ name: cat.name,
+ description: cat.description,
+ icon: cat.icon,
+ contexts: ALL_CONTEXTS.filter(function(ctx) {
+ return ctx.category === cat.id;
+ }).map(function(ctx) {
+ return {
+ id: ctx.id,
+ name: ctx.name,
+ description: ctx.description,
+ mode: ctx.avatarMode,
+ recommendedFor: ctx.recommendedFor
+ };
+ })
+ };
+ })
+ };
+ },
+
+ // Random selection
+ getRandom: function(filters) {
+ var pool = filters ? this.filter(filters) : ALL_CONTEXTS;
+ if (pool.length === 0) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ },
+
+ getRandomForGameType: function(gameType) {
+ var recommended = this.getRecommendedFor(gameType);
+ if (recommended.length === 0) return null;
+ return recommended[Math.floor(Math.random() * recommended.length)];
+ },
+
+ // Statistics
+ getStats: function() {
+ return Object.assign({}, STATS);
+ },
+
+ // Validation
+ validate: function() {
+ var issues = [];
+
+ ALL_CONTEXTS.forEach(function(ctx) {
+ if (!ctx.id) issues.push('Context missing id');
+ if (!ctx.name) issues.push(ctx.id + ': missing name');
+ if (!ctx.avatarMode) issues.push(ctx.id + ': missing avatarMode');
+ if (!ctx.category) issues.push(ctx.id + ': missing category');
+ if (!ctx.animations || ctx.animations.length === 0) {
+ issues.push(ctx.id + ': missing animations');
+ }
+ });
+
+ return {
+ valid: issues.length === 0,
+ issues: issues
+ };
+ }
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/contextRenderer.js b/frontend/public/assets/avatar/contextRenderer.js
new file mode 100644
index 0000000..07089bf
--- /dev/null
+++ b/frontend/public/assets/avatar/contextRenderer.js
@@ -0,0 +1,959 @@
+/**
+ * ContextRenderer.js - Main renderer for applying contexts to avatars
+ *
+ * Handles the complete rendering pipeline for avatars with contexts,
+ * including vehicles, costumes, and pure animation contexts.
+ *
+ * Dependencies (must be loaded before this file):
+ * - window.AvatarSystem
+ * - window.AvatarRenderer
+ * - window.AvatarAnimations
+ * - window.AccessoryRenderer
+ * - window.VehicleContexts
+ * - window.CostumeContexts
+ * - window.PureContexts
+ */
+
+(function() {
+ 'use strict';
+
+ // ============================================================
+ // CONTEXT APPLICATION
+ // ============================================================
+
+ /**
+ * Apply a context to an avatar, creating a renderable object
+ * @param {Object} avatar - The avatar object
+ * @param {string} contextId - The context ID to apply
+ * @param {Object} options - Optional configuration
+ * @returns {Object} Avatar with context object
+ */
+ function apply(avatar, contextId, options) {
+ var context = getContextById(contextId);
+
+ if (!context) {
+ console.error('ContextRenderer: Unknown context ID:', contextId);
+ return null;
+ }
+
+ // Validate avatar has required mode or can be rendered in it
+ var mode = context.avatarMode;
+ if (!mode) {
+ console.warn('ContextRenderer: Context missing avatarMode:', contextId);
+ mode = 'profile'; // Default fallback
+ }
+
+ return {
+ avatar: avatar,
+ context: context,
+ mode: mode,
+ state: {
+ animation: context.defaultAnimation || 'idle',
+ frame: 0,
+ time: 0,
+ animationStartTime: 0,
+ animationOptions: {},
+ effects: [],
+ props: {},
+ custom: {}
+ },
+ options: options || {}
+ };
+ }
+
+ // ============================================================
+ // MAIN RENDER FUNCTION
+ // ============================================================
+
+ /**
+ * Render complete scene with avatar and context
+ * @param {Object} avatarWithContext - The avatar+context object from apply()
+ * @param {CanvasRenderingContext2D} ctx - Canvas context
+ * @param {number} x - Center X position
+ * @param {number} y - Center Y position
+ * @param {Object} options - Render options (scale, time, etc)
+ */
+ function render(avatarWithContext, ctx, x, y, options) {
+ if (!avatarWithContext || !avatarWithContext.context) {
+ console.error('ContextRenderer.render: Invalid avatarWithContext');
+ return;
+ }
+
+ options = options || {};
+ var scale = options.scale || 1;
+ var time = options.time || 0;
+
+ var awc = avatarWithContext;
+ var context = awc.context;
+ var avatar = awc.avatar;
+ var mode = awc.mode;
+
+ // Get context dimensions
+ var contextWidth = context.width || 64;
+ var contextHeight = context.height || 64;
+ var scaledWidth = contextWidth * scale;
+ var scaledHeight = contextHeight * scale;
+
+ // Get animation transforms
+ var transforms = {};
+ if (awc.state.animation && window.AvatarAnimations) {
+ var anim = window.AvatarAnimations.getAnimation(mode, awc.state.animation);
+ if (anim) {
+ var progress = anim.frameCount > 0 ? awc.state.frame / anim.frameCount : 0;
+ transforms = window.AvatarAnimations.interpolateFrame(anim, progress);
+ }
+ }
+
+ // Calculate avatar position within context
+ var avatarPosX = context.avatarPosition ? context.avatarPosition.x : 0.5;
+ var avatarPosY = context.avatarPosition ? context.avatarPosition.y : 0.5;
+ var avatarX = x + (avatarPosX - 0.5) * scaledWidth;
+ var avatarY = y + (avatarPosY - 0.5) * scaledHeight;
+ var avatarScale = scale * (context.avatarScale || 1);
+
+ // Route to appropriate render pipeline based on category
+ var category = context.category || 'pure';
+
+ if (category === 'vehicle') {
+ renderVehicleContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
+ scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
+ } else if (category === 'costume') {
+ renderCostumeContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
+ scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
+ } else {
+ // Pure context (default)
+ renderPureContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
+ scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
+ }
+ }
+
+ // ============================================================
+ // VEHICLE CONTEXT RENDERING
+ // ============================================================
+
+ function renderVehicleContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
+ scale, avatarScale, width, height, transforms, time) {
+ // 1. Context background (vehicle body behind avatar)
+ if (context.renderBackground && typeof context.renderBackground === 'function') {
+ ctx.save();
+ context.renderBackground(ctx, width, height, time, awc.state);
+ ctx.restore();
+ }
+
+ // 2. Render avatar
+ if (window.AvatarRenderer && window.AvatarRenderer.render) {
+ window.AvatarRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, {
+ scale: avatarScale,
+ transforms: transforms
+ });
+ }
+
+ // 3. Render accessories respecting visibility rules
+ renderAccessoriesForContext(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
+
+ // 4. Context foreground (steering wheel, windshield, etc)
+ if (context.renderForeground && typeof context.renderForeground === 'function') {
+ ctx.save();
+ context.renderForeground(ctx, width, height, time, awc.state);
+ ctx.restore();
+ }
+
+ // 5. Context effects (exhaust, speed lines, etc)
+ renderContextEffects(ctx, awc, x, y, scale, time);
+ }
+
+ // ============================================================
+ // COSTUME CONTEXT RENDERING
+ // ============================================================
+
+ function renderCostumeContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
+ scale, avatarScale, width, height, transforms, time) {
+ // 1. Costume under-layer (body armor, suit body, etc)
+ if (context.renderCostumeUnder && typeof context.renderCostumeUnder === 'function') {
+ ctx.save();
+ context.renderCostumeUnder(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time);
+ ctx.restore();
+ }
+
+ // 2. Avatar HEAD ONLY (body is covered by costume)
+ renderAvatarHeadOnly(ctx, avatar, avatarX, avatarY, avatarScale, transforms);
+
+ // 3. Costume over-layer (helmet, held items, cape)
+ if (context.renderCostumeOver && typeof context.renderCostumeOver === 'function') {
+ ctx.save();
+ context.renderCostumeOver(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time);
+ ctx.restore();
+ }
+
+ // 4. EARNED ACCESSORIES ON TOP (crown shows above helmet, etc)
+ renderEarnedAccessoriesOnTop(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
+
+ // 5. Effects (auras are always visible)
+ renderEffectsForContext(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
+
+ // 6. Context-specific effects
+ renderContextEffects(ctx, awc, x, y, scale, time);
+ }
+
+ // ============================================================
+ // PURE CONTEXT RENDERING
+ // ============================================================
+
+ function renderPureContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
+ scale, avatarScale, width, height, transforms, time) {
+ // 1. Behind effects (shadows, dust clouds, etc)
+ if (context.renderEffectsBehind && typeof context.renderEffectsBehind === 'function') {
+ ctx.save();
+ context.renderEffectsBehind(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time, awc.state);
+ ctx.restore();
+ }
+
+ // 2. Full avatar with all accessories
+ if (window.AvatarRenderer && window.AvatarRenderer.render) {
+ window.AvatarRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, {
+ scale: avatarScale,
+ transforms: transforms
+ });
+ }
+
+ if (window.AccessoryRenderer && window.AccessoryRenderer.render) {
+ window.AccessoryRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, avatarScale, time);
+ }
+
+ // 3. Over effects (speed lines, stars, sparkles)
+ if (context.renderEffectsOver && typeof context.renderEffectsOver === 'function') {
+ ctx.save();
+ context.renderEffectsOver(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time, awc.state);
+ ctx.restore();
+ }
+
+ // 4. Active state effects
+ renderContextEffects(ctx, awc, x, y, scale, time);
+ }
+
+ // ============================================================
+ // ANIMATION CONTROL
+ // ============================================================
+
+ /**
+ * Start an animation on an avatar with context
+ * @param {Object} avatarWithContext - The avatar+context object
+ * @param {string} animationId - Animation ID to play
+ * @param {Object} options - Animation options
+ * @returns {boolean} True if animation started successfully
+ */
+ function animate(avatarWithContext, animationId, options) {
+ if (!avatarWithContext || !avatarWithContext.context) {
+ console.error('ContextRenderer.animate: Invalid avatarWithContext');
+ return false;
+ }
+
+ var awc = avatarWithContext;
+ var context = awc.context;
+
+ // Validate animation is available for this context
+ if (context.animations && Array.isArray(context.animations)) {
+ if (context.animations.indexOf(animationId) === -1) {
+ console.warn('ContextRenderer: Animation not available for context:', animationId, 'in', context.id);
+ return false;
+ }
+ }
+
+ // Check if animation exists in the animation system
+ if (window.AvatarAnimations) {
+ var anim = window.AvatarAnimations.getAnimation(awc.mode, animationId);
+ if (!anim) {
+ console.warn('ContextRenderer: Animation not found:', animationId, 'for mode', awc.mode);
+ return false;
+ }
+ }
+
+ // Set animation state
+ awc.state.animation = animationId;
+ awc.state.frame = 0;
+ awc.state.time = 0;
+ awc.state.animationStartTime = performance.now();
+ awc.state.animationOptions = options || {};
+
+ // Trigger context-specific effects for this animation
+ if (context.contextEffects && context.contextEffects[animationId]) {
+ triggerEffect(awc, context.contextEffects[animationId]);
+ }
+
+ return true;
+ }
+
+ /**
+ * Update animation state based on elapsed time
+ * @param {Object} avatarWithContext - The avatar+context object
+ * @param {number} deltaTime - Time elapsed in seconds
+ */
+ function updateAnimation(avatarWithContext, deltaTime) {
+ if (!avatarWithContext) return;
+
+ var awc = avatarWithContext;
+
+ if (!awc.state.animation) return;
+
+ // Get animation definition
+ var anim = null;
+ if (window.AvatarAnimations) {
+ anim = window.AvatarAnimations.getAnimation(awc.mode, awc.state.animation);
+ }
+
+ if (!anim) return;
+
+ // Update time
+ awc.state.time += deltaTime;
+
+ // Calculate frame based on time
+ var frameDuration = (anim.frameDuration || 100) / 1000; // Convert ms to seconds
+ var totalFrames = anim.frameCount || 1;
+
+ if (anim.loop) {
+ // Looping animation
+ awc.state.frame = (awc.state.time / frameDuration) % totalFrames;
+ } else {
+ // One-shot animation
+ awc.state.frame = Math.min(awc.state.time / frameDuration, totalFrames - 1);
+
+ // Check if animation completed
+ if (awc.state.frame >= totalFrames - 1) {
+ // Trigger completion callback if set
+ if (awc.state.animationOptions && awc.state.animationOptions.onComplete) {
+ awc.state.animationOptions.onComplete();
+ }
+ }
+ }
+
+ // Update active effects
+ updateEffects(awc, deltaTime);
+ }
+
+ // ============================================================
+ // EFFECT SYSTEM
+ // ============================================================
+
+ /**
+ * Trigger a visual effect
+ * @param {Object} avatarWithContext - The avatar+context object
+ * @param {Object} effectDef - Effect definition
+ */
+ function triggerEffect(avatarWithContext, effectDef) {
+ if (!avatarWithContext || !effectDef) return;
+
+ var awc = avatarWithContext;
+
+ // Handle effect definition as string (lookup) or object
+ var effect;
+ if (typeof effectDef === 'string') {
+ effect = {
+ type: effectDef,
+ color: '#ffffff',
+ startTime: performance.now(),
+ duration: 0.5,
+ intensity: 1,
+ data: {}
+ };
+ } else {
+ effect = {
+ type: effectDef.type || 'flash',
+ color: effectDef.color || '#ffffff',
+ startTime: performance.now(),
+ duration: effectDef.duration || 0.5,
+ intensity: effectDef.intensity || 1,
+ data: effectDef.data || {}
+ };
+ }
+
+ awc.state.effects.push(effect);
+ }
+
+ /**
+ * Update all active effects
+ * @param {Object} avatarWithContext - The avatar+context object
+ * @param {number} deltaTime - Time elapsed in seconds
+ */
+ function updateEffects(avatarWithContext, deltaTime) {
+ if (!avatarWithContext) return;
+
+ var awc = avatarWithContext;
+ var now = performance.now();
+
+ // Filter out expired effects and update active ones
+ awc.state.effects = awc.state.effects.filter(function(effect) {
+ var elapsed = (now - effect.startTime) / 1000;
+
+ if (elapsed >= effect.duration) {
+ return false; // Remove expired effect
+ }
+
+ // Update effect progress
+ effect.progress = elapsed / effect.duration;
+
+ return true;
+ });
+ }
+
+ /**
+ * Clear all active effects
+ * @param {Object} avatarWithContext - The avatar+context object
+ */
+ function clearEffects(avatarWithContext) {
+ if (!avatarWithContext) return;
+ avatarWithContext.state.effects = [];
+ }
+
+ /**
+ * Render context effects
+ */
+ function renderContextEffects(ctx, awc, x, y, scale, time) {
+ if (!awc.state.effects || awc.state.effects.length === 0) return;
+
+ var now = performance.now();
+
+ awc.state.effects.forEach(function(effect) {
+ var elapsed = (now - effect.startTime) / 1000;
+ var progress = Math.min(elapsed / effect.duration, 1);
+
+ ctx.save();
+
+ switch (effect.type) {
+ case 'flash':
+ renderFlashEffect(ctx, x, y, scale, effect, progress);
+ break;
+ case 'burst':
+ renderBurstEffect(ctx, x, y, scale, effect, progress);
+ break;
+ case 'trail':
+ renderTrailEffect(ctx, x, y, scale, effect, progress);
+ break;
+ case 'glow':
+ renderGlowEffect(ctx, x, y, scale, effect, progress);
+ break;
+ case 'particles':
+ renderParticleEffect(ctx, x, y, scale, effect, progress, time);
+ break;
+ case 'shake':
+ // Shake is handled via transforms, not direct rendering
+ break;
+ case 'speedLines':
+ renderSpeedLinesEffect(ctx, x, y, scale, effect, progress);
+ break;
+ default:
+ // Unknown effect type - custom effects can be rendered by context
+ break;
+ }
+
+ ctx.restore();
+ });
+ }
+
+ // Effect renderers
+ function renderFlashEffect(ctx, x, y, scale, effect, progress) {
+ var alpha = (1 - progress) * effect.intensity;
+ var radius = 30 * scale * (1 + progress);
+
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = effect.color;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ function renderBurstEffect(ctx, x, y, scale, effect, progress) {
+ var alpha = (1 - progress) * effect.intensity;
+ var rayCount = effect.data.rayCount || 8;
+ var maxLength = (effect.data.maxLength || 40) * scale;
+ var length = maxLength * progress;
+
+ ctx.globalAlpha = alpha;
+ ctx.strokeStyle = effect.color;
+ ctx.lineWidth = 2 * scale;
+
+ for (var i = 0; i < rayCount; i++) {
+ var angle = (i / rayCount) * Math.PI * 2;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(
+ x + Math.cos(angle) * length,
+ y + Math.sin(angle) * length
+ );
+ ctx.stroke();
+ }
+ }
+
+ function renderTrailEffect(ctx, x, y, scale, effect, progress) {
+ var alpha = (1 - progress) * effect.intensity * 0.5;
+ var trailLength = (effect.data.length || 20) * scale;
+ var direction = effect.data.direction || -1; // -1 = trailing left
+
+ var gradient = ctx.createLinearGradient(
+ x, y,
+ x + trailLength * direction, y
+ );
+ gradient.addColorStop(0, effect.color);
+ gradient.addColorStop(1, 'transparent');
+
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = gradient;
+ ctx.fillRect(x, y - 10 * scale, trailLength * direction, 20 * scale);
+ }
+
+ function renderGlowEffect(ctx, x, y, scale, effect, progress) {
+ var alpha = effect.intensity * (0.5 + 0.5 * Math.sin(progress * Math.PI * 2));
+ var radius = (effect.data.radius || 25) * scale;
+
+ var gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
+ gradient.addColorStop(0, effect.color);
+ gradient.addColorStop(1, 'transparent');
+
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ function renderParticleEffect(ctx, x, y, scale, effect, progress, time) {
+ var particleCount = effect.data.count || 10;
+ var alpha = (1 - progress) * effect.intensity;
+ var spread = (effect.data.spread || 30) * scale;
+
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = effect.color;
+
+ // Use seeded random based on effect start time for consistency
+ var seed = effect.startTime;
+
+ for (var i = 0; i < particleCount; i++) {
+ var pseudoRandom = Math.sin(seed + i * 12.9898) * 43758.5453;
+ pseudoRandom = pseudoRandom - Math.floor(pseudoRandom);
+
+ var angle = pseudoRandom * Math.PI * 2;
+ var distance = spread * progress * (0.5 + pseudoRandom * 0.5);
+ var size = 2 * scale * (1 - progress);
+
+ ctx.beginPath();
+ ctx.arc(
+ x + Math.cos(angle) * distance,
+ y + Math.sin(angle) * distance - progress * 20 * scale, // Rise up
+ size,
+ 0, Math.PI * 2
+ );
+ ctx.fill();
+ }
+ }
+
+ function renderSpeedLinesEffect(ctx, x, y, scale, effect, progress) {
+ var alpha = effect.intensity * (1 - progress * 0.5);
+ var lineCount = effect.data.lineCount || 5;
+ var lineLength = (effect.data.lineLength || 30) * scale;
+
+ ctx.globalAlpha = alpha;
+ ctx.strokeStyle = effect.color;
+ ctx.lineWidth = 1 * scale;
+
+ for (var i = 0; i < lineCount; i++) {
+ var yOffset = (i - lineCount / 2) * 8 * scale;
+ var xStart = x - 20 * scale - lineLength * progress;
+
+ ctx.beginPath();
+ ctx.moveTo(xStart, y + yOffset);
+ ctx.lineTo(xStart + lineLength, y + yOffset);
+ ctx.stroke();
+ }
+ }
+
+ // ============================================================
+ // HELPER RENDERING FUNCTIONS
+ // ============================================================
+
+ /**
+ * Render only the avatar's head (for costumes that cover body)
+ */
+ function renderAvatarHeadOnly(ctx, avatar, x, y, scale, transforms) {
+ if (window.AvatarRenderer && window.AvatarRenderer.renderHead) {
+ window.AvatarRenderer.renderHead(ctx, avatar, x, y, {
+ scale: scale,
+ transforms: transforms
+ });
+ } else if (window.AvatarRenderer && window.AvatarRenderer.render) {
+ // Fallback: render full avatar if renderHead not available
+ // This is a compromise - costume should hide body anyway
+ ctx.save();
+
+ // Clip to head region (approximate)
+ var headY = y - 12 * scale; // Head is above center
+ var headRadius = 10 * scale;
+
+ ctx.beginPath();
+ ctx.arc(x, headY, headRadius * 1.5, 0, Math.PI * 2);
+ ctx.clip();
+
+ window.AvatarRenderer.render(ctx, avatar, 'profile', x, y, {
+ scale: scale,
+ transforms: transforms
+ });
+
+ ctx.restore();
+ }
+ }
+
+ /**
+ * Render accessories respecting context visibility rules
+ */
+ function renderAccessoriesForContext(ctx, avatar, context, x, y, scale, time) {
+ if (!window.AccessoryRenderer) return;
+
+ var vis = context.accessoryVisibility || {};
+
+ // Default visibility if not specified
+ var showHeadwear = vis.headwear !== false;
+ var showHandheld = vis.handheld !== false;
+ var showEffects = vis.effects !== false;
+ var showFace = vis.face !== false;
+
+ // Use AccessoryRenderer with visibility filter
+ if (window.AccessoryRenderer.renderFiltered) {
+ window.AccessoryRenderer.renderFiltered(ctx, avatar, context.avatarMode || 'profile', x, y, scale, time, {
+ headwear: showHeadwear,
+ handheld: showHandheld,
+ effects: showEffects,
+ face: showFace
+ });
+ } else if (window.AccessoryRenderer.render) {
+ // Fallback to standard render if filtered not available
+ // Context-specific visibility would need to be handled differently
+ window.AccessoryRenderer.render(ctx, avatar, context.avatarMode || 'profile', x, y, scale, time);
+ }
+ }
+
+ /**
+ * Render EARNED accessories on top of everything (crowns above helmets, etc)
+ */
+ function renderEarnedAccessoriesOnTop(ctx, avatar, context, x, y, scale, time) {
+ if (!window.AccessoryRenderer) return;
+ if (!avatar.accessories) return;
+
+ var vis = context.accessoryVisibility || {};
+ var costumeOffset = context.costumeHeadOffset || { x: 0, y: -5 }; // Crown sits higher on helmet
+
+ // Render headwear if visibility allows and avatar has it
+ if (vis.headwear === true && avatar.accessories.headwear) {
+ var headwearY = y + costumeOffset.y * scale;
+ var headwearX = x + costumeOffset.x * scale;
+
+ if (window.AccessoryRenderer.renderSingle) {
+ window.AccessoryRenderer.renderSingle(ctx, avatar, 'headwear', headwearX, headwearY, scale, time);
+ }
+ }
+
+ // Render effect accessories (auras, trails) if allowed
+ if (vis.effects === true && avatar.accessories.effect) {
+ if (window.AccessoryRenderer.renderSingle) {
+ window.AccessoryRenderer.renderSingle(ctx, avatar, 'effect', x, y, scale, time);
+ }
+ }
+ }
+
+ /**
+ * Render effect accessories (auras, etc) for costume context
+ */
+ function renderEffectsForContext(ctx, avatar, context, x, y, scale, time) {
+ if (!window.AccessoryRenderer) return;
+ if (!avatar.accessories || !avatar.accessories.effect) return;
+
+ var vis = context.accessoryVisibility || {};
+
+ // Effects (auras) are almost always visible
+ if (vis.effects !== false) {
+ if (window.AccessoryRenderer.renderSingle) {
+ window.AccessoryRenderer.renderSingle(ctx, avatar, 'effect', x, y, scale, time);
+ }
+ }
+ }
+
+ // ============================================================
+ // CONTEXT LOOKUP
+ // ============================================================
+
+ /**
+ * Get context definition by ID
+ */
+ function getContextById(contextId) {
+ // Check ContextCatalog first
+ if (window.ContextCatalog && window.ContextCatalog.getById) {
+ var context = window.ContextCatalog.getById(contextId);
+ if (context) return context;
+ }
+
+ // Fallback to individual context modules
+ if (window.VehicleContexts && window.VehicleContexts[contextId]) {
+ return window.VehicleContexts[contextId];
+ }
+ if (window.CostumeContexts && window.CostumeContexts[contextId]) {
+ return window.CostumeContexts[contextId];
+ }
+ if (window.PureContexts && window.PureContexts[contextId]) {
+ return window.PureContexts[contextId];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get required avatar mode for a context
+ */
+ function getRequiredMode(contextId) {
+ var context = getContextById(contextId);
+ return context ? context.avatarMode : null;
+ }
+
+ /**
+ * Get available animations for a context
+ */
+ function getAvailableAnimations(contextId) {
+ var context = getContextById(contextId);
+ return context && context.animations ? context.animations.slice() : [];
+ }
+
+ // ============================================================
+ // STATE MANAGEMENT
+ // ============================================================
+
+ /**
+ * Set custom state value
+ */
+ function setState(avatarWithContext, key, value) {
+ if (!avatarWithContext || !avatarWithContext.state) return;
+ avatarWithContext.state.custom[key] = value;
+ }
+
+ /**
+ * Get custom state value
+ */
+ function getState(avatarWithContext, key) {
+ if (!avatarWithContext || !avatarWithContext.state) return undefined;
+ return avatarWithContext.state.custom[key];
+ }
+
+ /**
+ * Set prop state (for interactive elements)
+ */
+ function setProp(avatarWithContext, propId, state) {
+ if (!avatarWithContext || !avatarWithContext.state) return;
+ avatarWithContext.state.props[propId] = state;
+ }
+
+ /**
+ * Get prop state
+ */
+ function getProp(avatarWithContext, propId) {
+ if (!avatarWithContext || !avatarWithContext.state) return undefined;
+ return avatarWithContext.state.props[propId];
+ }
+
+ // ============================================================
+ // EXPORT UTILITIES
+ // ============================================================
+
+ /**
+ * Create a sprite sheet from avatar with context
+ * @param {Object} avatarWithContext - The avatar+context object
+ * @param {Array} animations - Array of animation IDs to include
+ * @param {Object} options - Sprite sheet options
+ * @returns {Object} Sprite sheet data { canvas, frames, meta }
+ */
+ function createSpriteSheet(avatarWithContext, animations, options) {
+ if (!avatarWithContext) return null;
+
+ options = options || {};
+ var frameWidth = options.frameWidth || 64;
+ var frameHeight = options.frameHeight || 64;
+ var scale = options.scale || 1;
+
+ var awc = avatarWithContext;
+ var allFrames = [];
+
+ // Collect all frames for each animation
+ animations.forEach(function(animId) {
+ var anim = null;
+ if (window.AvatarAnimations) {
+ anim = window.AvatarAnimations.getAnimation(awc.mode, animId);
+ }
+
+ if (!anim) return;
+
+ for (var i = 0; i < anim.frameCount; i++) {
+ allFrames.push({
+ animation: animId,
+ frame: i,
+ frameCount: anim.frameCount
+ });
+ }
+ });
+
+ if (allFrames.length === 0) return null;
+
+ // Calculate sprite sheet dimensions
+ var cols = Math.ceil(Math.sqrt(allFrames.length));
+ var rows = Math.ceil(allFrames.length / cols);
+ var sheetWidth = cols * frameWidth;
+ var sheetHeight = rows * frameHeight;
+
+ // Create canvas
+ var canvas = document.createElement('canvas');
+ canvas.width = sheetWidth;
+ canvas.height = sheetHeight;
+ var ctx = canvas.getContext('2d');
+
+ // Render each frame
+ var frames = [];
+ allFrames.forEach(function(frameData, index) {
+ var col = index % cols;
+ var row = Math.floor(index / cols);
+ var x = col * frameWidth + frameWidth / 2;
+ var y = row * frameHeight + frameHeight / 2;
+
+ // Temporarily set animation state
+ awc.state.animation = frameData.animation;
+ awc.state.frame = frameData.frame;
+
+ // Render
+ render(awc, ctx, x, y, { scale: scale, time: 0 });
+
+ frames.push({
+ animation: frameData.animation,
+ frame: frameData.frame,
+ x: col * frameWidth,
+ y: row * frameHeight,
+ width: frameWidth,
+ height: frameHeight
+ });
+ });
+
+ return {
+ canvas: canvas,
+ frames: frames,
+ meta: {
+ width: sheetWidth,
+ height: sheetHeight,
+ frameWidth: frameWidth,
+ frameHeight: frameHeight,
+ animations: animations
+ }
+ };
+ }
+
+ /**
+ * Export avatar with context for game use
+ * @param {Object} avatarWithContext - The avatar+context object
+ * @returns {Object} Exportable data
+ */
+ function exportForGame(avatarWithContext) {
+ if (!avatarWithContext) return null;
+
+ var awc = avatarWithContext;
+ var context = awc.context;
+
+ return {
+ avatarId: awc.avatar.id || null,
+ contextId: context.id,
+ mode: awc.mode,
+ category: context.category,
+ animations: context.animations || [],
+ defaultAnimation: context.defaultAnimation || 'idle',
+ dimensions: {
+ width: context.width || 64,
+ height: context.height || 64
+ },
+ avatarPosition: context.avatarPosition || { x: 0.5, y: 0.5 },
+ avatarScale: context.avatarScale || 1,
+ accessoryVisibility: context.accessoryVisibility || {},
+ customState: Object.assign({}, awc.state.custom)
+ };
+ }
+
+ // ============================================================
+ // CATALOG ACCESS (delegates to ContextCatalog)
+ // ============================================================
+
+ function getCatalog() {
+ if (window.ContextCatalog && window.ContextCatalog.getAll) {
+ return window.ContextCatalog.getAll();
+ }
+
+ // Fallback: aggregate from individual modules
+ var catalog = [];
+
+ if (window.VehicleContexts) {
+ Object.keys(window.VehicleContexts).forEach(function(id) {
+ if (typeof window.VehicleContexts[id] === 'object') {
+ catalog.push(window.VehicleContexts[id]);
+ }
+ });
+ }
+
+ if (window.CostumeContexts) {
+ Object.keys(window.CostumeContexts).forEach(function(id) {
+ if (typeof window.CostumeContexts[id] === 'object') {
+ catalog.push(window.CostumeContexts[id]);
+ }
+ });
+ }
+
+ if (window.PureContexts) {
+ Object.keys(window.PureContexts).forEach(function(id) {
+ if (typeof window.PureContexts[id] === 'object') {
+ catalog.push(window.PureContexts[id]);
+ }
+ });
+ }
+
+ return catalog;
+ }
+
+ function getContext(id) {
+ return getContextById(id);
+ }
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ window.ContextRenderer = {
+ // Core methods
+ apply: apply,
+ render: render,
+ animate: animate,
+ updateAnimation: updateAnimation,
+
+ // Effect control
+ triggerEffect: triggerEffect,
+ updateEffects: updateEffects,
+ clearEffects: clearEffects,
+
+ // State management
+ setState: setState,
+ getState: getState,
+ setProp: setProp,
+ getProp: getProp,
+
+ // Context queries
+ getRequiredMode: getRequiredMode,
+ getAvailableAnimations: getAvailableAnimations,
+
+ // Catalog access (delegates to ContextCatalog)
+ getCatalog: getCatalog,
+ getContext: getContext,
+
+ // Utility
+ createSpriteSheet: createSpriteSheet,
+ exportForGame: exportForGame,
+
+ // Version
+ version: '1.0.0'
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/contexts/costumes.js b/frontend/public/assets/avatar/contexts/costumes.js
new file mode 100644
index 0000000..a556218
--- /dev/null
+++ b/frontend/public/assets/avatar/contexts/costumes.js
@@ -0,0 +1,1875 @@
+/**
+ * CostumeContexts - Themed costume overlays for the avatar system
+ * Depends on: AvatarRenderer, AvatarAnimations
+ */
+(function() {
+ 'use strict';
+
+ // ============================================================================
+ // UTILITY FUNCTIONS
+ // ============================================================================
+
+ function roundRect(ctx, x, y, w, h, r) {
+ if (w < 2 * r) r = w / 2;
+ if (h < 2 * r) r = h / 2;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ }
+
+ function darken(color, amount) {
+ var hex = color.replace('#', '');
+ var r = Math.max(0, Math.floor(parseInt(hex.substr(0, 2), 16) * (1 - amount)));
+ var g = Math.max(0, Math.floor(parseInt(hex.substr(2, 2), 16) * (1 - amount)));
+ var b = Math.max(0, Math.floor(parseInt(hex.substr(4, 2), 16) * (1 - amount)));
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
+ }
+
+ function lighten(color, amount) {
+ var hex = color.replace('#', '');
+ var r = Math.min(255, Math.floor(parseInt(hex.substr(0, 2), 16) + (255 - parseInt(hex.substr(0, 2), 16)) * amount));
+ var g = Math.min(255, Math.floor(parseInt(hex.substr(2, 2), 16) + (255 - parseInt(hex.substr(2, 2), 16)) * amount));
+ var b = Math.min(255, Math.floor(parseInt(hex.substr(4, 2), 16) + (255 - parseInt(hex.substr(4, 2), 16)) * amount));
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
+ }
+
+ function getArmTransform(transforms, arm) {
+ if (!transforms || !transforms[arm]) return { rotation: 0, x: 0, y: 0 };
+ return transforms[arm];
+ }
+
+ // ============================================================================
+ // COSTUME: KNIGHT ARMOR
+ // ============================================================================
+
+ var knightArmor = {
+ id: 'knight-armor',
+ name: 'Knight Armor',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: false,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['rpg', 'adventure', 'action'],
+ description: 'Medieval knight in shining armor',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'sword', anchor: 'rightArm', offset: { x: 5, y: 20 } },
+ { id: 'shield', anchor: 'leftArm', offset: { x: -10, y: 0 } }
+ ],
+ contextEffects: {
+ attack: { type: 'slash', color: '#c0c0c0' },
+ hurt: { type: 'sparks', color: '#ffa500' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var metalColor = '#a8a9ad';
+ var metalDark = '#7d7f83';
+ var metalLight = '#d4d5d9';
+
+ // Cape (behind everything)
+ ctx.fillStyle = '#8b0000';
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 68 * scale);
+ ctx.quadraticCurveTo(cx - 25 * scale, y + 100 * scale, cx - 20 * scale + Math.sin(time * 2) * 3 * scale, y + 145 * scale);
+ ctx.lineTo(cx + 20 * scale + Math.sin(time * 2 + 1) * 3 * scale, y + 145 * scale);
+ ctx.quadraticCurveTo(cx + 25 * scale, y + 100 * scale, cx + 18 * scale, y + 68 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Cape trim
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(cx - 20 * scale, y + 140 * scale, 40 * scale, 5 * scale);
+
+ // Body armor (chestplate)
+ ctx.fillStyle = metalColor;
+ roundRect(ctx, cx - 22 * scale, y + 68 * scale, 44 * scale, 50 * scale, 4 * scale);
+ ctx.fill();
+
+ // Chestplate details
+ ctx.strokeStyle = metalDark;
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(cx, y + 70 * scale);
+ ctx.lineTo(cx, y + 115 * scale);
+ ctx.stroke();
+
+ // Armor ridges
+ ctx.strokeStyle = metalLight;
+ ctx.lineWidth = 1 * scale;
+ for (var i = 0; i < 3; i++) {
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 78 * scale + i * 12 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 78 * scale + i * 12 * scale);
+ ctx.stroke();
+ }
+
+ // Leg armor (greaves)
+ ctx.fillStyle = metalColor;
+ roundRect(ctx, cx - 16 * scale, y + 120 * scale, 12 * scale, 35 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 120 * scale, 12 * scale, 35 * scale, 2 * scale);
+ ctx.fill();
+
+ // Knee guards
+ ctx.fillStyle = metalLight;
+ ctx.beginPath();
+ ctx.arc(cx - 10 * scale, y + 125 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 10 * scale, y + 125 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Armored boots
+ ctx.fillStyle = metalDark;
+ roundRect(ctx, cx - 18 * scale, y + 152 * scale, 14 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 152 * scale, 14 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var metalColor = '#a8a9ad';
+ var metalDark = '#7d7f83';
+ var metalLight = '#d4d5d9';
+
+ // Shoulder pauldrons
+ ctx.fillStyle = metalColor;
+ ctx.beginPath();
+ ctx.ellipse(cx - 26 * scale, y + 70 * scale, 12 * scale, 8 * scale, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(cx + 26 * scale, y + 70 * scale, 12 * scale, 8 * scale, 0.3, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Pauldron spikes
+ ctx.fillStyle = metalLight;
+ ctx.beginPath();
+ ctx.moveTo(cx - 32 * scale, y + 65 * scale);
+ ctx.lineTo(cx - 35 * scale, y + 58 * scale);
+ ctx.lineTo(cx - 28 * scale, y + 65 * scale);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(cx + 32 * scale, y + 65 * scale);
+ ctx.lineTo(cx + 35 * scale, y + 58 * scale);
+ ctx.lineTo(cx + 28 * scale, y + 65 * scale);
+ ctx.fill();
+
+ // Helmet
+ ctx.fillStyle = metalColor;
+ ctx.beginPath();
+ ctx.arc(cx, y + 32 * scale, 24 * scale, Math.PI, 0, false);
+ ctx.fill();
+
+ // Helmet face plate
+ ctx.fillStyle = metalDark;
+ roundRect(ctx, cx - 18 * scale, y + 20 * scale, 36 * scale, 28 * scale, 4 * scale);
+ ctx.fill();
+
+ // Visor slits (eyes peek through)
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(cx - 14 * scale, y + 28 * scale, 10 * scale, 3 * scale);
+ ctx.fillRect(cx + 4 * scale, y + 28 * scale, 10 * scale, 3 * scale);
+
+ // Helmet crest
+ ctx.fillStyle = '#8b0000';
+ ctx.beginPath();
+ ctx.moveTo(cx - 3 * scale, y + 8 * scale);
+ ctx.lineTo(cx, y - 5 * scale);
+ ctx.lineTo(cx + 3 * scale, y + 8 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Sword (right hand)
+ var rightArm = getArmTransform(transforms, 'rightArm');
+ ctx.save();
+ ctx.translate(cx + 26 * scale, y + 115 * scale);
+ ctx.rotate((rightArm.rotation || 0) * Math.PI / 180);
+
+ // Sword blade
+ ctx.fillStyle = metalLight;
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(-2 * scale, -35 * scale);
+ ctx.lineTo(0, -45 * scale);
+ ctx.lineTo(2 * scale, -35 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Sword guard
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(-6 * scale, -2 * scale, 12 * scale, 4 * scale);
+
+ // Sword handle
+ ctx.fillStyle = '#4a3728';
+ ctx.fillRect(-2 * scale, 2 * scale, 4 * scale, 10 * scale);
+
+ ctx.restore();
+
+ // Shield (left hand)
+ var leftArm = getArmTransform(transforms, 'leftArm');
+ ctx.save();
+ ctx.translate(cx - 30 * scale, y + 95 * scale);
+ ctx.rotate((leftArm.rotation || 0) * Math.PI / 180);
+
+ // Shield body
+ ctx.fillStyle = metalColor;
+ ctx.beginPath();
+ ctx.moveTo(0, -15 * scale);
+ ctx.lineTo(12 * scale, -10 * scale);
+ ctx.lineTo(12 * scale, 10 * scale);
+ ctx.lineTo(0, 20 * scale);
+ ctx.lineTo(-12 * scale, 10 * scale);
+ ctx.lineTo(-12 * scale, -10 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Shield emblem
+ ctx.fillStyle = '#8b0000';
+ ctx.beginPath();
+ ctx.arc(0, 0, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Shield cross
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(-1 * scale, -8 * scale, 2 * scale, 16 * scale);
+ ctx.fillRect(-6 * scale, -1 * scale, 12 * scale, 2 * scale);
+
+ ctx.restore();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: SPACE SUIT
+ // ============================================================================
+
+ var spaceSuit = {
+ id: 'space-suit',
+ name: 'Space Suit',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['action', 'adventure', 'physics'],
+ description: 'Astronaut ready for space exploration',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [],
+ contextEffects: {
+ hurt: { type: 'sparks', color: '#00ffff' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var suitColor = '#f0f0f0';
+ var accentColor = '#4a90d9';
+
+ // Oxygen pack (on back)
+ ctx.fillStyle = '#e0e0e0';
+ roundRect(ctx, cx - 12 * scale, y + 65 * scale, 24 * scale, 35 * scale, 4 * scale);
+ ctx.fill();
+
+ // Pack details
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 8 * scale, y + 70 * scale, 16 * scale, 4 * scale);
+ ctx.fillRect(cx - 8 * scale, y + 80 * scale, 16 * scale, 4 * scale);
+
+ // Pack lights
+ var blink = Math.sin(time * 3) > 0 ? 1 : 0.3;
+ ctx.fillStyle = 'rgba(0, 255, 100, ' + blink + ')';
+ ctx.beginPath();
+ ctx.arc(cx - 4 * scale, y + 92 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = 'rgba(255, 100, 0, ' + (1 - blink) + ')';
+ ctx.beginPath();
+ ctx.arc(cx + 4 * scale, y + 92 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Suit body
+ ctx.fillStyle = suitColor;
+ roundRect(ctx, cx - 24 * scale, y + 64 * scale, 48 * scale, 55 * scale, 6 * scale);
+ ctx.fill();
+
+ // Blue accents on torso
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 24 * scale, y + 64 * scale, 8 * scale, 55 * scale);
+ ctx.fillRect(cx + 16 * scale, y + 64 * scale, 8 * scale, 55 * scale);
+
+ // Suit legs
+ ctx.fillStyle = suitColor;
+ roundRect(ctx, cx - 18 * scale, y + 118 * scale, 14 * scale, 38 * scale, 4 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 118 * scale, 14 * scale, 38 * scale, 4 * scale);
+ ctx.fill();
+
+ // Blue leg stripes
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 18 * scale, y + 130 * scale, 14 * scale, 4 * scale);
+ ctx.fillRect(cx + 4 * scale, y + 130 * scale, 14 * scale, 4 * scale);
+
+ // Boots with magnetic plates
+ ctx.fillStyle = '#555';
+ roundRect(ctx, cx - 20 * scale, y + 152 * scale, 16 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 152 * scale, 16 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+
+ // Magnetic plate glow
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 18 * scale, y + 158 * scale, 12 * scale, 2 * scale);
+ ctx.fillRect(cx + 6 * scale, y + 158 * scale, 12 * scale, 2 * scale);
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var suitColor = '#f0f0f0';
+ var accentColor = '#4a90d9';
+
+ // Gloves
+ ctx.fillStyle = suitColor;
+ ctx.beginPath();
+ ctx.arc(cx - 26 * scale, y + 115 * scale, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 26 * scale, y + 115 * scale, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Glove accents
+ ctx.fillStyle = accentColor;
+ ctx.beginPath();
+ ctx.arc(cx - 26 * scale, y + 115 * scale, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 26 * scale, y + 115 * scale, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Helmet bubble
+ ctx.fillStyle = 'rgba(200, 230, 255, 0.3)';
+ ctx.strokeStyle = '#ccc';
+ ctx.lineWidth = 3 * scale;
+ ctx.beginPath();
+ ctx.arc(cx, y + 32 * scale, 28 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+
+ // Helmet reflection
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
+ ctx.beginPath();
+ ctx.ellipse(cx - 8 * scale, y + 22 * scale, 8 * scale, 12 * scale, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Helmet ring
+ ctx.fillStyle = '#ddd';
+ ctx.beginPath();
+ ctx.ellipse(cx, y + 55 * scale, 22 * scale, 6 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Helmet accent
+ ctx.fillStyle = accentColor;
+ ctx.beginPath();
+ ctx.arc(cx, y + 5 * scale, 4 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Antenna
+ ctx.strokeStyle = '#888';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(cx + 20 * scale, y + 15 * scale);
+ ctx.lineTo(cx + 28 * scale, y - 5 * scale);
+ ctx.stroke();
+
+ ctx.fillStyle = '#ff3333';
+ ctx.beginPath();
+ ctx.arc(cx + 28 * scale, y - 5 * scale, 3 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: NINJA OUTFIT
+ // ============================================================================
+
+ var ninjaOutfit = {
+ id: 'ninja-outfit',
+ name: 'Ninja Outfit',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: false,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['action', 'adventure', 'rpg'],
+ description: 'Stealthy ninja warrior',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'katana', anchor: 'back', offset: { x: 0, y: -10 } }
+ ],
+ contextEffects: {
+ attack: { type: 'slash', color: '#333' },
+ hurt: { type: 'smoke', color: '#555' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var ninjaBlack = '#1a1a1a';
+ var ninjaGray = '#333';
+
+ // Katana on back
+ ctx.save();
+ ctx.translate(cx + 15 * scale, y + 50 * scale);
+ ctx.rotate(-0.4);
+
+ // Scabbard
+ ctx.fillStyle = '#2c1810';
+ roundRect(ctx, -3 * scale, 0, 6 * scale, 50 * scale, 2 * scale);
+ ctx.fill();
+
+ // Handle wrap
+ ctx.fillStyle = '#8b0000';
+ for (var i = 0; i < 5; i++) {
+ ctx.fillRect(-3 * scale, -10 * scale + i * 4 * scale, 6 * scale, 2 * scale);
+ }
+
+ // Guard
+ ctx.fillStyle = '#ffd700';
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 5 * scale, 2 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.restore();
+
+ // Body suit
+ ctx.fillStyle = ninjaBlack;
+ roundRect(ctx, cx - 22 * scale, y + 64 * scale, 44 * scale, 52 * scale, 4 * scale);
+ ctx.fill();
+
+ // Belt with throwing stars
+ ctx.fillStyle = ninjaGray;
+ ctx.fillRect(cx - 22 * scale, y + 100 * scale, 44 * scale, 8 * scale);
+
+ // Throwing stars on belt
+ ctx.fillStyle = '#888';
+ for (var s = 0; s < 3; s++) {
+ var sx = cx - 12 * scale + s * 12 * scale;
+ var sy = y + 104 * scale;
+ ctx.beginPath();
+ for (var p = 0; p < 4; p++) {
+ var angle = p * Math.PI / 2;
+ var px = sx + Math.cos(angle) * 3 * scale;
+ var py = sy + Math.sin(angle) * 3 * scale;
+ if (p === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ // Legs
+ ctx.fillStyle = ninjaBlack;
+ roundRect(ctx, cx - 16 * scale, y + 116 * scale, 12 * scale, 36 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 116 * scale, 12 * scale, 36 * scale, 3 * scale);
+ ctx.fill();
+
+ // Leg wraps
+ ctx.strokeStyle = ninjaGray;
+ ctx.lineWidth = 2 * scale;
+ for (var w = 0; w < 4; w++) {
+ ctx.beginPath();
+ ctx.moveTo(cx - 16 * scale, y + 125 * scale + w * 8 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 128 * scale + w * 8 * scale);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx + 4 * scale, y + 125 * scale + w * 8 * scale);
+ ctx.lineTo(cx + 16 * scale, y + 128 * scale + w * 8 * scale);
+ ctx.stroke();
+ }
+
+ // Tabi boots
+ ctx.fillStyle = ninjaBlack;
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 152 * scale);
+ ctx.lineTo(cx - 18 * scale, y + 160 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 160 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 155 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 160 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 152 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 4 * scale, y + 152 * scale);
+ ctx.lineTo(cx + 4 * scale, y + 160 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 160 * scale);
+ ctx.lineTo(cx + 12 * scale, y + 155 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 160 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 152 * scale);
+ ctx.closePath();
+ ctx.fill();
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var ninjaBlack = '#1a1a1a';
+
+ // Arm wraps
+ ctx.fillStyle = ninjaBlack;
+ roundRect(ctx, cx - 32 * scale, y + 70 * scale, 10 * scale, 45 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 22 * scale, y + 70 * scale, 10 * scale, 45 * scale, 3 * scale);
+ ctx.fill();
+
+ // Face mask
+ ctx.fillStyle = ninjaBlack;
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, y + 35 * scale);
+ ctx.lineTo(cx - 22 * scale, y + 55 * scale);
+ ctx.quadraticCurveTo(cx, y + 60 * scale, cx + 22 * scale, y + 55 * scale);
+ ctx.lineTo(cx + 22 * scale, y + 35 * scale);
+ ctx.quadraticCurveTo(cx, y + 38 * scale, cx - 22 * scale, y + 35 * scale);
+ ctx.fill();
+
+ // Eye slit
+ ctx.fillStyle = '#000';
+ ctx.fillRect(cx - 18 * scale, y + 28 * scale, 36 * scale, 6 * scale);
+
+ // Headband
+ ctx.fillStyle = '#8b0000';
+ ctx.fillRect(cx - 24 * scale, y + 15 * scale, 48 * scale, 6 * scale);
+
+ // Headband tail
+ ctx.beginPath();
+ ctx.moveTo(cx + 24 * scale, y + 15 * scale);
+ ctx.quadraticCurveTo(cx + 35 * scale + Math.sin(time * 3) * 3 * scale, y + 25 * scale, cx + 40 * scale + Math.sin(time * 3) * 5 * scale, y + 35 * scale);
+ ctx.lineTo(cx + 36 * scale + Math.sin(time * 3) * 5 * scale, y + 38 * scale);
+ ctx.quadraticCurveTo(cx + 32 * scale + Math.sin(time * 3) * 3 * scale, y + 28 * scale, cx + 24 * scale, y + 21 * scale);
+ ctx.closePath();
+ ctx.fill();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: WIZARD ROBES
+ // ============================================================================
+
+ var wizardRobes = {
+ id: 'wizard-robes',
+ name: 'Wizard Robes',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['rpg', 'puzzle', 'adventure'],
+ description: 'Mystical wizard with flowing robes',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'staff', anchor: 'rightArm', offset: { x: 5, y: 0 } }
+ ],
+ contextEffects: {
+ attack: { type: 'magic', color: '#9b59b6' },
+ celebrate: { type: 'sparkles', color: '#ffd700' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var robeColor = '#2c3e50';
+ var robeLight = '#3d566e';
+ var accent = '#9b59b6';
+
+ // Main robe body
+ ctx.fillStyle = robeColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, y + 64 * scale);
+ ctx.lineTo(cx - 30 * scale + Math.sin(time * 1.5) * 2 * scale, y + 158 * scale);
+ ctx.quadraticCurveTo(cx, y + 162 * scale, cx + 30 * scale + Math.sin(time * 1.5 + 1) * 2 * scale, y + 158 * scale);
+ ctx.lineTo(cx + 22 * scale, y + 64 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Robe inner fold
+ ctx.fillStyle = robeLight;
+ ctx.beginPath();
+ ctx.moveTo(cx - 5 * scale, y + 70 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 155 * scale);
+ ctx.lineTo(cx + 8 * scale, y + 155 * scale);
+ ctx.lineTo(cx + 5 * scale, y + 70 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Belt
+ ctx.fillStyle = '#8b4513';
+ ctx.fillRect(cx - 22 * scale, y + 100 * scale, 44 * scale, 8 * scale);
+
+ // Belt buckle
+ ctx.fillStyle = '#ffd700';
+ roundRect(ctx, cx - 6 * scale, y + 99 * scale, 12 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+
+ // Belt pouches
+ ctx.fillStyle = '#6b3e26';
+ roundRect(ctx, cx - 20 * scale, y + 105 * scale, 8 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 12 * scale, y + 105 * scale, 8 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+
+ // Mystical runes on robe
+ ctx.strokeStyle = accent;
+ ctx.lineWidth = 1 * scale;
+ ctx.globalAlpha = 0.5 + Math.sin(time * 2) * 0.3;
+
+ // Left rune
+ ctx.beginPath();
+ ctx.arc(cx - 15 * scale, y + 130 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx - 15 * scale, y + 125 * scale);
+ ctx.lineTo(cx - 15 * scale, y + 135 * scale);
+ ctx.moveTo(cx - 20 * scale, y + 130 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 130 * scale);
+ ctx.stroke();
+
+ // Right rune
+ ctx.beginPath();
+ ctx.arc(cx + 15 * scale, y + 130 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx + 12 * scale, y + 127 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 133 * scale);
+ ctx.moveTo(cx + 18 * scale, y + 127 * scale);
+ ctx.lineTo(cx + 12 * scale, y + 133 * scale);
+ ctx.stroke();
+
+ ctx.globalAlpha = 1;
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var robeColor = '#2c3e50';
+ var accent = '#9b59b6';
+
+ // Sleeves
+ ctx.fillStyle = robeColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, y + 68 * scale);
+ ctx.quadraticCurveTo(cx - 35 * scale, y + 90 * scale, cx - 32 * scale, y + 118 * scale);
+ ctx.lineTo(cx - 25 * scale, y + 115 * scale);
+ ctx.quadraticCurveTo(cx - 28 * scale, y + 90 * scale, cx - 18 * scale, y + 72 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 22 * scale, y + 68 * scale);
+ ctx.quadraticCurveTo(cx + 35 * scale, y + 90 * scale, cx + 32 * scale, y + 118 * scale);
+ ctx.lineTo(cx + 25 * scale, y + 115 * scale);
+ ctx.quadraticCurveTo(cx + 28 * scale, y + 90 * scale, cx + 18 * scale, y + 72 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Staff (right hand)
+ var rightArm = getArmTransform(transforms, 'rightArm');
+ ctx.save();
+ ctx.translate(cx + 28 * scale, y + 90 * scale);
+ ctx.rotate((rightArm.rotation || 0) * Math.PI / 180);
+
+ // Staff shaft
+ ctx.fillStyle = '#5d4037';
+ roundRect(ctx, -3 * scale, -50 * scale, 6 * scale, 80 * scale, 2 * scale);
+ ctx.fill();
+
+ // Staff orb holder
+ ctx.fillStyle = '#3d2817';
+ ctx.beginPath();
+ ctx.moveTo(-6 * scale, -50 * scale);
+ ctx.quadraticCurveTo(-8 * scale, -58 * scale, 0, -62 * scale);
+ ctx.quadraticCurveTo(8 * scale, -58 * scale, 6 * scale, -50 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Staff orb
+ var orbGlow = ctx.createRadialGradient(0, -68 * scale, 0, 0, -68 * scale, 10 * scale);
+ orbGlow.addColorStop(0, '#e0b0ff');
+ orbGlow.addColorStop(0.5, accent);
+ orbGlow.addColorStop(1, '#4a235a');
+ ctx.fillStyle = orbGlow;
+ ctx.beginPath();
+ ctx.arc(0, -68 * scale, 8 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Orb glow effect
+ ctx.globalAlpha = 0.3 + Math.sin(time * 3) * 0.2;
+ ctx.fillStyle = accent;
+ ctx.beginPath();
+ ctx.arc(0, -68 * scale, 12 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+
+ ctx.restore();
+
+ // Hood/collar
+ ctx.fillStyle = robeColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 24 * scale, y + 55 * scale);
+ ctx.quadraticCurveTo(cx - 28 * scale, y + 45 * scale, cx - 20 * scale, y + 40 * scale);
+ ctx.lineTo(cx + 20 * scale, y + 40 * scale);
+ ctx.quadraticCurveTo(cx + 28 * scale, y + 45 * scale, cx + 24 * scale, y + 55 * scale);
+ ctx.quadraticCurveTo(cx, y + 50 * scale, cx - 24 * scale, y + 55 * scale);
+ ctx.fill();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: SUPERHERO CAPE
+ // ============================================================================
+
+ var superheroCape = {
+ id: 'superhero-cape',
+ name: 'Superhero Cape',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['action', 'adventure'],
+ description: 'Classic superhero with billowing cape',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [],
+ contextEffects: {
+ attack: { type: 'impact', color: '#ffff00' },
+ celebrate: { type: 'sparkles', color: '#ffd700' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var suitColor = avatar && avatar.base && avatar.base.clothing && avatar.base.clothing.color ? avatar.base.clothing.color : '#c0392b';
+ var capeColor = darken(suitColor, 0.3);
+
+ // Cape (billowing behind)
+ ctx.fillStyle = capeColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, y + 62 * scale);
+ var wave1 = Math.sin(time * 2) * 5 * scale;
+ var wave2 = Math.sin(time * 2 + 1) * 5 * scale;
+ var wave3 = Math.sin(time * 2 + 2) * 5 * scale;
+ ctx.quadraticCurveTo(cx - 30 * scale + wave1, y + 100 * scale, cx - 35 * scale + wave2, y + 155 * scale);
+ ctx.quadraticCurveTo(cx, y + 160 * scale + wave3, cx + 35 * scale + wave2, y + 155 * scale);
+ ctx.quadraticCurveTo(cx + 30 * scale + wave1, y + 100 * scale, cx + 20 * scale, y + 62 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Cape inner lining
+ ctx.fillStyle = lighten(capeColor, 0.2);
+ ctx.beginPath();
+ ctx.moveTo(cx - 15 * scale, y + 65 * scale);
+ ctx.quadraticCurveTo(cx - 22 * scale + wave1 * 0.5, y + 100 * scale, cx - 25 * scale + wave2 * 0.5, y + 145 * scale);
+ ctx.lineTo(cx + 25 * scale + wave2 * 0.5, y + 145 * scale);
+ ctx.quadraticCurveTo(cx + 22 * scale + wave1 * 0.5, y + 100 * scale, cx + 15 * scale, y + 65 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Bodysuit
+ ctx.fillStyle = suitColor;
+ roundRect(ctx, cx - 22 * scale, y + 64 * scale, 44 * scale, 52 * scale, 4 * scale);
+ ctx.fill();
+
+ // Belt
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(cx - 22 * scale, y + 105 * scale, 44 * scale, 6 * scale);
+
+ // Belt buckle
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(cx, y + 108 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Trunks
+ ctx.fillStyle = darken(suitColor, 0.2);
+ roundRect(ctx, cx - 18 * scale, y + 108 * scale, 36 * scale, 12 * scale, 2 * scale);
+ ctx.fill();
+
+ // Legs (suit continues)
+ ctx.fillStyle = suitColor;
+ roundRect(ctx, cx - 16 * scale, y + 118 * scale, 12 * scale, 36 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 118 * scale, 12 * scale, 36 * scale, 3 * scale);
+ ctx.fill();
+
+ // Boots
+ ctx.fillStyle = darken(suitColor, 0.4);
+ roundRect(ctx, cx - 18 * scale, y + 145 * scale, 14 * scale, 15 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 145 * scale, 14 * scale, 15 * scale, 3 * scale);
+ ctx.fill();
+
+ // Boot tops
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(cx - 18 * scale, y + 145 * scale, 14 * scale, 3 * scale);
+ ctx.fillRect(cx + 4 * scale, y + 145 * scale, 14 * scale, 3 * scale);
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var suitColor = avatar && avatar.base && avatar.base.clothing && avatar.base.clothing.color ? avatar.base.clothing.color : '#c0392b';
+
+ // Arms
+ ctx.fillStyle = suitColor;
+ roundRect(ctx, cx - 32 * scale, y + 68 * scale, 12 * scale, 45 * scale, 4 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 20 * scale, y + 68 * scale, 12 * scale, 45 * scale, 4 * scale);
+ ctx.fill();
+
+ // Gloves
+ ctx.fillStyle = darken(suitColor, 0.4);
+ ctx.beginPath();
+ ctx.arc(cx - 26 * scale, y + 115 * scale, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 26 * scale, y + 115 * scale, 6 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Glove cuffs
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(cx - 32 * scale, y + 105 * scale, 12 * scale, 4 * scale);
+ ctx.fillRect(cx + 20 * scale, y + 105 * scale, 12 * scale, 4 * scale);
+
+ // Chest emblem
+ ctx.fillStyle = '#ffd700';
+ ctx.beginPath();
+ ctx.moveTo(cx, y + 70 * scale);
+ ctx.lineTo(cx - 12 * scale, y + 78 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 95 * scale);
+ ctx.lineTo(cx, y + 88 * scale);
+ ctx.lineTo(cx + 8 * scale, y + 95 * scale);
+ ctx.lineTo(cx + 12 * scale, y + 78 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Mask
+ ctx.fillStyle = darken(suitColor, 0.3);
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, y + 25 * scale);
+ ctx.quadraticCurveTo(cx - 25 * scale, y + 32 * scale, cx - 18 * scale, y + 38 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 32 * scale);
+ ctx.lineTo(cx, y + 35 * scale);
+ ctx.lineTo(cx + 8 * scale, y + 32 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 38 * scale);
+ ctx.quadraticCurveTo(cx + 25 * scale, y + 32 * scale, cx + 22 * scale, y + 25 * scale);
+ ctx.quadraticCurveTo(cx, y + 22 * scale, cx - 22 * scale, y + 25 * scale);
+ ctx.fill();
+
+ // Eye holes
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.ellipse(cx - 12 * scale, y + 30 * scale, 6 * scale, 4 * scale, -0.2, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(cx + 12 * scale, y + 30 * scale, 6 * scale, 4 * scale, 0.2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: ATHLETE UNIFORM
+ // ============================================================================
+
+ var athleteUniform = {
+ id: 'athlete-uniform',
+ name: 'Athlete Uniform',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['sports', 'action', 'racing'],
+ description: 'Athletic sports uniform',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'ball', anchor: 'rightArm', offset: { x: 10, y: 5 } }
+ ],
+ contextEffects: {
+ celebrate: { type: 'confetti', color: '#ffd700' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var jerseyColor = '#e74c3c';
+ var accentColor = '#fff';
+
+ // Jersey
+ ctx.fillStyle = jerseyColor;
+ roundRect(ctx, cx - 22 * scale, y + 64 * scale, 44 * scale, 45 * scale, 4 * scale);
+ ctx.fill();
+
+ // Jersey stripes
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 22 * scale, y + 75 * scale, 44 * scale, 4 * scale);
+ ctx.fillRect(cx - 22 * scale, y + 95 * scale, 44 * scale, 4 * scale);
+
+ // Number on back
+ ctx.fillStyle = accentColor;
+ ctx.font = 'bold ' + (16 * scale) + 'px Arial';
+ ctx.textAlign = 'center';
+ ctx.fillText('23', cx, y + 92 * scale);
+
+ // Shorts
+ ctx.fillStyle = '#2c3e50';
+ roundRect(ctx, cx - 18 * scale, y + 108 * scale, 36 * scale, 22 * scale, 3 * scale);
+ ctx.fill();
+
+ // Shorts stripe
+ ctx.fillStyle = jerseyColor;
+ ctx.fillRect(cx - 18 * scale, y + 108 * scale, 4 * scale, 22 * scale);
+ ctx.fillRect(cx + 14 * scale, y + 108 * scale, 4 * scale, 22 * scale);
+
+ // Legs
+ ctx.fillStyle = avatar && avatar.base ? avatar.base.skinTone || '#FFD5B8' : '#FFD5B8';
+ roundRect(ctx, cx - 14 * scale, y + 128 * scale, 10 * scale, 25 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 128 * scale, 10 * scale, 25 * scale, 3 * scale);
+ ctx.fill();
+
+ // Socks
+ ctx.fillStyle = accentColor;
+ roundRect(ctx, cx - 16 * scale, y + 140 * scale, 12 * scale, 15 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 140 * scale, 12 * scale, 15 * scale, 2 * scale);
+ ctx.fill();
+
+ // Sock stripes
+ ctx.fillStyle = jerseyColor;
+ ctx.fillRect(cx - 16 * scale, y + 145 * scale, 12 * scale, 2 * scale);
+ ctx.fillRect(cx + 4 * scale, y + 145 * scale, 12 * scale, 2 * scale);
+
+ // Athletic shoes
+ ctx.fillStyle = '#1a1a1a';
+ roundRect(ctx, cx - 18 * scale, y + 152 * scale, 14 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 152 * scale, 14 * scale, 8 * scale, 2 * scale);
+ ctx.fill();
+
+ // Shoe accents
+ ctx.fillStyle = jerseyColor;
+ ctx.fillRect(cx - 16 * scale, y + 154 * scale, 10 * scale, 2 * scale);
+ ctx.fillRect(cx + 6 * scale, y + 154 * scale, 10 * scale, 2 * scale);
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var jerseyColor = '#e74c3c';
+ var accentColor = '#fff';
+
+ // Sleeves
+ ctx.fillStyle = jerseyColor;
+ roundRect(ctx, cx - 30 * scale, y + 66 * scale, 12 * scale, 20 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 18 * scale, y + 66 * scale, 12 * scale, 20 * scale, 3 * scale);
+ ctx.fill();
+
+ // Sleeve stripes
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 30 * scale, y + 78 * scale, 12 * scale, 3 * scale);
+ ctx.fillRect(cx + 18 * scale, y + 78 * scale, 12 * scale, 3 * scale);
+
+ // Headband
+ ctx.fillStyle = jerseyColor;
+ ctx.fillRect(cx - 22 * scale, y + 12 * scale, 44 * scale, 6 * scale);
+
+ // Sweatbands on wrists
+ ctx.fillStyle = accentColor;
+ ctx.fillRect(cx - 30 * scale, y + 100 * scale, 10 * scale, 6 * scale);
+ ctx.fillRect(cx + 20 * scale, y + 100 * scale, 10 * scale, 6 * scale);
+
+ // Number on front (smaller)
+ ctx.fillStyle = accentColor;
+ ctx.font = 'bold ' + (12 * scale) + 'px Arial';
+ ctx.textAlign = 'center';
+ ctx.fillText('23', cx, y + 82 * scale);
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: PIRATE OUTFIT
+ // ============================================================================
+
+ var pirateOutfit = {
+ id: 'pirate-outfit',
+ name: 'Pirate Outfit',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['adventure', 'action', 'rpg'],
+ description: 'Swashbuckling pirate captain',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'cutlass', anchor: 'rightArm', offset: { x: 5, y: 15 } }
+ ],
+ contextEffects: {
+ attack: { type: 'slash', color: '#c0c0c0' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var coatColor = '#8b0000';
+ var coatDark = '#5c0000';
+
+ // Long coat (back)
+ ctx.fillStyle = coatColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 24 * scale, y + 64 * scale);
+ ctx.lineTo(cx - 28 * scale + Math.sin(time * 1.5) * 2 * scale, y + 155 * scale);
+ ctx.quadraticCurveTo(cx, y + 158 * scale, cx + 28 * scale + Math.sin(time * 1.5 + 1) * 2 * scale, y + 155 * scale);
+ ctx.lineTo(cx + 24 * scale, y + 64 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Coat back detail
+ ctx.fillStyle = coatDark;
+ ctx.beginPath();
+ ctx.moveTo(cx - 5 * scale, y + 90 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 150 * scale);
+ ctx.lineTo(cx + 8 * scale, y + 150 * scale);
+ ctx.lineTo(cx + 5 * scale, y + 90 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // White shirt
+ ctx.fillStyle = '#f5f5dc';
+ roundRect(ctx, cx - 18 * scale, y + 64 * scale, 36 * scale, 40 * scale, 3 * scale);
+ ctx.fill();
+
+ // Shirt ruffles
+ ctx.strokeStyle = '#ddd';
+ ctx.lineWidth = 1 * scale;
+ for (var r = 0; r < 4; r++) {
+ ctx.beginPath();
+ ctx.moveTo(cx - 8 * scale, y + 68 * scale + r * 8 * scale);
+ ctx.quadraticCurveTo(cx - 4 * scale, y + 72 * scale + r * 8 * scale, cx, y + 68 * scale + r * 8 * scale);
+ ctx.quadraticCurveTo(cx + 4 * scale, y + 72 * scale + r * 8 * scale, cx + 8 * scale, y + 68 * scale + r * 8 * scale);
+ ctx.stroke();
+ }
+
+ // Belt with buckle
+ ctx.fillStyle = '#2c1810';
+ ctx.fillRect(cx - 22 * scale, y + 102 * scale, 44 * scale, 8 * scale);
+
+ ctx.fillStyle = '#ffd700';
+ roundRect(ctx, cx - 8 * scale, y + 100 * scale, 16 * scale, 12 * scale, 2 * scale);
+ ctx.fill();
+ ctx.fillStyle = '#2c1810';
+ roundRect(ctx, cx - 4 * scale, y + 103 * scale, 8 * scale, 6 * scale, 1 * scale);
+ ctx.fill();
+
+ // Pants
+ ctx.fillStyle = '#2c3e50';
+ roundRect(ctx, cx - 16 * scale, y + 108 * scale, 32 * scale, 45 * scale, 3 * scale);
+ ctx.fill();
+
+ // Boots
+ ctx.fillStyle = '#3d2314';
+ roundRect(ctx, cx - 18 * scale, y + 140 * scale, 14 * scale, 20 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 140 * scale, 14 * scale, 20 * scale, 3 * scale);
+ ctx.fill();
+
+ // Boot tops (folded)
+ ctx.fillStyle = '#5d4037';
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 145 * scale);
+ ctx.lineTo(cx - 20 * scale, y + 140 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 140 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 145 * scale);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(cx + 4 * scale, y + 145 * scale);
+ ctx.lineTo(cx + 4 * scale, y + 140 * scale);
+ ctx.lineTo(cx + 20 * scale, y + 140 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 145 * scale);
+ ctx.closePath();
+ ctx.fill();
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var coatColor = '#8b0000';
+
+ // Coat front panels
+ ctx.fillStyle = coatColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 22 * scale, y + 64 * scale);
+ ctx.lineTo(cx - 26 * scale, y + 110 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 110 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 64 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 22 * scale, y + 64 * scale);
+ ctx.lineTo(cx + 26 * scale, y + 110 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 110 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 64 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Gold trim on coat
+ ctx.strokeStyle = '#ffd700';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(cx - 10 * scale, y + 64 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 110 * scale);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx + 10 * scale, y + 64 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 110 * scale);
+ ctx.stroke();
+
+ // Gold buttons
+ ctx.fillStyle = '#ffd700';
+ for (var b = 0; b < 3; b++) {
+ ctx.beginPath();
+ ctx.arc(cx - 14 * scale, y + 72 * scale + b * 12 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 14 * scale, y + 72 * scale + b * 12 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Cutlass (right hand)
+ var rightArm = getArmTransform(transforms, 'rightArm');
+ ctx.save();
+ ctx.translate(cx + 28 * scale, y + 110 * scale);
+ ctx.rotate((rightArm.rotation || 0) * Math.PI / 180);
+
+ // Blade
+ ctx.fillStyle = '#c0c0c0';
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.quadraticCurveTo(8 * scale, -20 * scale, 5 * scale, -40 * scale);
+ ctx.lineTo(2 * scale, -40 * scale);
+ ctx.quadraticCurveTo(5 * scale, -20 * scale, -2 * scale, 0);
+ ctx.closePath();
+ ctx.fill();
+
+ // Guard
+ ctx.fillStyle = '#ffd700';
+ ctx.beginPath();
+ ctx.ellipse(0, 2 * scale, 8 * scale, 4 * scale, 0.3, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Handle
+ ctx.fillStyle = '#3d2314';
+ roundRect(ctx, -2 * scale, 4 * scale, 4 * scale, 12 * scale, 1 * scale);
+ ctx.fill();
+
+ ctx.restore();
+
+ // Pirate hat
+ ctx.fillStyle = '#1a1a1a';
+ ctx.beginPath();
+ ctx.moveTo(cx - 28 * scale, y + 8 * scale);
+ ctx.quadraticCurveTo(cx - 32 * scale, y - 8 * scale, cx - 18 * scale, y - 18 * scale);
+ ctx.lineTo(cx, y - 8 * scale);
+ ctx.lineTo(cx + 18 * scale, y - 18 * scale);
+ ctx.quadraticCurveTo(cx + 32 * scale, y - 8 * scale, cx + 28 * scale, y + 8 * scale);
+ ctx.quadraticCurveTo(cx, y + 12 * scale, cx - 28 * scale, y + 8 * scale);
+ ctx.fill();
+
+ // Gold hat trim
+ ctx.strokeStyle = '#ffd700';
+ ctx.lineWidth = 2 * scale;
+ ctx.stroke();
+
+ // Skull emblem
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(cx, y - 5 * scale, 5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#000';
+ ctx.beginPath();
+ ctx.arc(cx - 2 * scale, y - 6 * scale, 1 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 2 * scale, y - 6 * scale, 1 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: SCIENTIST LAB COAT
+ // ============================================================================
+
+ var scientistLabcoat = {
+ id: 'scientist-labcoat',
+ name: 'Scientist Lab Coat',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['puzzle', 'physics', 'creative'],
+ description: 'Mad scientist ready to experiment',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'beaker', anchor: 'rightArm', offset: { x: 5, y: 10 } }
+ ],
+ contextEffects: {
+ attack: { type: 'bubbles', color: '#00ff00' }
+ },
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+
+ // Shirt underneath
+ ctx.fillStyle = '#3498db';
+ roundRect(ctx, cx - 18 * scale, y + 64 * scale, 36 * scale, 35 * scale, 3 * scale);
+ ctx.fill();
+
+ // Tie
+ ctx.fillStyle = '#2c3e50';
+ ctx.beginPath();
+ ctx.moveTo(cx - 4 * scale, y + 64 * scale);
+ ctx.lineTo(cx + 4 * scale, y + 64 * scale);
+ ctx.lineTo(cx + 3 * scale, y + 72 * scale);
+ ctx.lineTo(cx, y + 100 * scale);
+ ctx.lineTo(cx - 3 * scale, y + 72 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Pants
+ ctx.fillStyle = '#34495e';
+ roundRect(ctx, cx - 16 * scale, y + 98 * scale, 32 * scale, 55 * scale, 3 * scale);
+ ctx.fill();
+
+ // Sensible shoes
+ ctx.fillStyle = '#2c1810';
+ roundRect(ctx, cx - 18 * scale, y + 150 * scale, 14 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 150 * scale, 14 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+
+ // Lab coat
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.moveTo(cx - 24 * scale, y + 62 * scale);
+ ctx.lineTo(cx - 26 * scale, y + 140 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 140 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 62 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 24 * scale, y + 62 * scale);
+ ctx.lineTo(cx + 26 * scale, y + 140 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 140 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 62 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Coat collar
+ ctx.fillStyle = '#fff';
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, y + 55 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 70 * scale);
+ ctx.lineTo(cx - 5 * scale, y + 60 * scale);
+ ctx.quadraticCurveTo(cx, y + 58 * scale, cx + 5 * scale, y + 60 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 70 * scale);
+ ctx.lineTo(cx + 20 * scale, y + 55 * scale);
+ ctx.quadraticCurveTo(cx, y + 50 * scale, cx - 20 * scale, y + 55 * scale);
+ ctx.fill();
+
+ // Coat sleeves
+ ctx.fillStyle = '#fff';
+ roundRect(ctx, cx - 32 * scale, y + 64 * scale, 12 * scale, 45 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 20 * scale, y + 64 * scale, 12 * scale, 45 * scale, 3 * scale);
+ ctx.fill();
+
+ // Pocket protector
+ ctx.fillStyle = '#ecf0f1';
+ roundRect(ctx, cx - 22 * scale, y + 72 * scale, 10 * scale, 15 * scale, 1 * scale);
+ ctx.fill();
+
+ // Pens in pocket
+ ctx.fillStyle = '#3498db';
+ ctx.fillRect(cx - 21 * scale, y + 70 * scale, 2 * scale, 12 * scale);
+ ctx.fillStyle = '#e74c3c';
+ ctx.fillRect(cx - 18 * scale, y + 71 * scale, 2 * scale, 11 * scale);
+ ctx.fillStyle = '#2c3e50';
+ ctx.fillRect(cx - 15 * scale, y + 70 * scale, 2 * scale, 10 * scale);
+
+ // ID badge
+ ctx.fillStyle = '#fff';
+ roundRect(ctx, cx + 12 * scale, y + 75 * scale, 12 * scale, 18 * scale, 1 * scale);
+ ctx.fill();
+ ctx.fillStyle = '#3498db';
+ ctx.fillRect(cx + 13 * scale, y + 76 * scale, 10 * scale, 6 * scale);
+ ctx.fillStyle = '#ddd';
+ ctx.fillRect(cx + 14 * scale, y + 84 * scale, 8 * scale, 2 * scale);
+ ctx.fillRect(cx + 14 * scale, y + 88 * scale, 6 * scale, 2 * scale);
+
+ // Safety goggles (on forehead)
+ ctx.fillStyle = '#2c3e50';
+ ctx.fillRect(cx - 24 * scale, y + 8 * scale, 48 * scale, 4 * scale);
+ ctx.fillStyle = 'rgba(100, 200, 255, 0.6)';
+ ctx.beginPath();
+ ctx.ellipse(cx - 12 * scale, y + 10 * scale, 10 * scale, 6 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(cx + 12 * scale, y + 10 * scale, 10 * scale, 6 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#1a1a2e';
+ ctx.lineWidth = 2 * scale;
+ ctx.stroke();
+
+ // Beaker in hand
+ var rightArm = getArmTransform(transforms, 'rightArm');
+ ctx.save();
+ ctx.translate(cx + 28 * scale, y + 110 * scale);
+ ctx.rotate((rightArm.rotation || 0) * Math.PI / 180);
+
+ // Beaker glass
+ ctx.fillStyle = 'rgba(200, 230, 255, 0.4)';
+ ctx.strokeStyle = '#aaa';
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.moveTo(-5 * scale, -20 * scale);
+ ctx.lineTo(-6 * scale, 0);
+ ctx.lineTo(6 * scale, 0);
+ ctx.lineTo(5 * scale, -20 * scale);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+
+ // Bubbling liquid
+ var liquidColor = 'rgba(0, 255, 100, 0.7)';
+ ctx.fillStyle = liquidColor;
+ ctx.beginPath();
+ ctx.moveTo(-5 * scale, -5 * scale);
+ ctx.lineTo(-6 * scale, 0);
+ ctx.lineTo(6 * scale, 0);
+ ctx.lineTo(5 * scale, -5 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Bubbles
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
+ for (var bub = 0; bub < 3; bub++) {
+ var bubY = -8 * scale - ((time * 20 + bub * 30) % 15) * scale;
+ var bubX = (bub - 1) * 3 * scale;
+ ctx.beginPath();
+ ctx.arc(bubX, bubY, 1.5 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: CHEF OUTFIT
+ // ============================================================================
+
+ var chefOutfit = {
+ id: 'chef-outfit',
+ name: 'Chef Outfit',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['creative', 'puzzle'],
+ description: 'Master chef ready to cook',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'spatula', anchor: 'rightArm', offset: { x: 5, y: 15 } }
+ ],
+ contextEffects: {},
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+
+ // Chef jacket
+ ctx.fillStyle = '#fff';
+ roundRect(ctx, cx - 22 * scale, y + 64 * scale, 44 * scale, 50 * scale, 4 * scale);
+ ctx.fill();
+
+ // Double-breasted buttons
+ ctx.fillStyle = '#ddd';
+ for (var row = 0; row < 2; row++) {
+ for (var col = 0; col < 3; col++) {
+ ctx.beginPath();
+ ctx.arc(cx + (row * 12 - 6) * scale, y + (75 + col * 12) * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ // Apron
+ ctx.fillStyle = '#f5f5f5';
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 90 * scale);
+ ctx.lineTo(cx - 20 * scale, y + 155 * scale);
+ ctx.lineTo(cx + 20 * scale, y + 155 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 90 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Apron strings
+ ctx.strokeStyle = '#ddd';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 90 * scale);
+ ctx.lineTo(cx - 25 * scale, y + 95 * scale);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx + 18 * scale, y + 90 * scale);
+ ctx.lineTo(cx + 25 * scale, y + 95 * scale);
+ ctx.stroke();
+
+ // Apron pocket
+ ctx.fillStyle = '#eee';
+ roundRect(ctx, cx - 10 * scale, y + 115 * scale, 20 * scale, 15 * scale, 2 * scale);
+ ctx.fill();
+
+ // Pants
+ ctx.fillStyle = '#2c3e50';
+ roundRect(ctx, cx - 16 * scale, y + 112 * scale, 32 * scale, 42 * scale, 3 * scale);
+ ctx.fill();
+
+ // Shoes
+ ctx.fillStyle = '#1a1a1a';
+ roundRect(ctx, cx - 18 * scale, y + 150 * scale, 14 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 4 * scale, y + 150 * scale, 14 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+
+ // Chef jacket sleeves
+ ctx.fillStyle = '#fff';
+ roundRect(ctx, cx - 30 * scale, y + 66 * scale, 12 * scale, 40 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 18 * scale, y + 66 * scale, 12 * scale, 40 * scale, 3 * scale);
+ ctx.fill();
+
+ // Kitchen towel over shoulder
+ ctx.fillStyle = '#e74c3c';
+ ctx.beginPath();
+ ctx.moveTo(cx + 20 * scale, y + 64 * scale);
+ ctx.quadraticCurveTo(cx + 30 * scale, y + 70 * scale, cx + 25 * scale, y + 90 * scale);
+ ctx.lineTo(cx + 20 * scale, y + 88 * scale);
+ ctx.quadraticCurveTo(cx + 24 * scale, y + 72 * scale, cx + 18 * scale, y + 66 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Towel stripes
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(cx + 21 * scale, y + 72 * scale, 2 * scale, 14 * scale);
+ ctx.fillRect(cx + 24 * scale, y + 74 * scale, 2 * scale, 12 * scale);
+
+ // Chef hat
+ ctx.fillStyle = '#fff';
+
+ // Puffy top
+ ctx.beginPath();
+ ctx.arc(cx - 10 * scale, y - 25 * scale, 12 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 10 * scale, y - 25 * scale, 12 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx, y - 30 * scale, 14 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Hat band
+ roundRect(ctx, cx - 20 * scale, y - 12 * scale, 40 * scale, 20 * scale, 2 * scale);
+ ctx.fill();
+
+ // Hat band line
+ ctx.fillStyle = '#ecf0f1';
+ ctx.fillRect(cx - 20 * scale, y + 3 * scale, 40 * scale, 5 * scale);
+
+ // Utensils in apron pocket
+ ctx.fillStyle = '#888';
+ ctx.fillRect(cx - 6 * scale, y + 110 * scale, 2 * scale, 12 * scale);
+ ctx.fillRect(cx + 2 * scale, y + 112 * scale, 2 * scale, 10 * scale);
+
+ // Spatula in hand
+ var rightArm = getArmTransform(transforms, 'rightArm');
+ ctx.save();
+ ctx.translate(cx + 26 * scale, y + 108 * scale);
+ ctx.rotate((rightArm.rotation || 0) * Math.PI / 180);
+
+ // Handle
+ ctx.fillStyle = '#5d4037';
+ roundRect(ctx, -2 * scale, 0, 4 * scale, 18 * scale, 1 * scale);
+ ctx.fill();
+
+ // Spatula head
+ ctx.fillStyle = '#888';
+ roundRect(ctx, -6 * scale, -15 * scale, 12 * scale, 18 * scale, 2 * scale);
+ ctx.fill();
+
+ // Slots
+ ctx.fillStyle = '#666';
+ ctx.fillRect(cx - 4 * scale - cx - 26 * scale, -12 * scale, 8 * scale, 2 * scale);
+ ctx.fillRect(cx - 4 * scale - cx - 26 * scale, -8 * scale, 8 * scale, 2 * scale);
+ ctx.fillRect(cx - 4 * scale - cx - 26 * scale, -4 * scale, 8 * scale, 2 * scale);
+
+ ctx.restore();
+ }
+ };
+
+ // ============================================================================
+ // COSTUME: COWBOY GEAR
+ // ============================================================================
+
+ var cowboyGear = {
+ id: 'cowboy-gear',
+ name: 'Cowboy Gear',
+ category: 'costume',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true,
+ headwear: true,
+ effects: true
+ },
+ layerOrder: ['avatar-body', 'costume-base', 'avatar-head', 'costume-overlay', 'earned-accessories'],
+ recommendedFor: ['action', 'adventure'],
+ description: 'Wild west cowboy',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'lasso', anchor: 'rightArm', offset: { x: 10, y: 0 } }
+ ],
+ contextEffects: {},
+
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+ var vestColor = '#8b4513';
+
+ // Shirt
+ ctx.fillStyle = '#c19a6b';
+ roundRect(ctx, cx - 20 * scale, y + 64 * scale, 40 * scale, 45 * scale, 3 * scale);
+ ctx.fill();
+
+ // Shirt details (collar points)
+ ctx.fillStyle = '#a08060';
+ ctx.beginPath();
+ ctx.moveTo(cx - 8 * scale, y + 62 * scale);
+ ctx.lineTo(cx - 15 * scale, y + 75 * scale);
+ ctx.lineTo(cx - 5 * scale, y + 70 * scale);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(cx + 8 * scale, y + 62 * scale);
+ ctx.lineTo(cx + 15 * scale, y + 75 * scale);
+ ctx.lineTo(cx + 5 * scale, y + 70 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Vest
+ ctx.fillStyle = vestColor;
+ ctx.beginPath();
+ ctx.moveTo(cx - 20 * scale, y + 65 * scale);
+ ctx.lineTo(cx - 22 * scale, y + 108 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 108 * scale);
+ ctx.lineTo(cx - 8 * scale, y + 75 * scale);
+ ctx.lineTo(cx - 12 * scale, y + 65 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 20 * scale, y + 65 * scale);
+ ctx.lineTo(cx + 22 * scale, y + 108 * scale);
+ ctx.lineTo(cx + 8 * scale, y + 108 * scale);
+ ctx.lineTo(cx + 8 * scale, y + 75 * scale);
+ ctx.lineTo(cx + 12 * scale, y + 65 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Belt
+ ctx.fillStyle = '#3d2314';
+ ctx.fillRect(cx - 20 * scale, y + 105 * scale, 40 * scale, 8 * scale);
+
+ // Belt buckle (large oval)
+ ctx.fillStyle = '#ffd700';
+ ctx.beginPath();
+ ctx.ellipse(cx, y + 109 * scale, 8 * scale, 5 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#b8860b';
+ ctx.beginPath();
+ ctx.ellipse(cx, y + 109 * scale, 5 * scale, 3 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Jeans
+ ctx.fillStyle = '#4a6fa5';
+ roundRect(ctx, cx - 16 * scale, y + 112 * scale, 32 * scale, 42 * scale, 3 * scale);
+ ctx.fill();
+
+ // Jean seams
+ ctx.strokeStyle = '#3d5a80';
+ ctx.lineWidth = 1 * scale;
+ ctx.beginPath();
+ ctx.moveTo(cx - 10 * scale, y + 115 * scale);
+ ctx.lineTo(cx - 10 * scale, y + 152 * scale);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(cx + 10 * scale, y + 115 * scale);
+ ctx.lineTo(cx + 10 * scale, y + 152 * scale);
+ ctx.stroke();
+
+ // Cowboy boots
+ ctx.fillStyle = '#5d4037';
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 150 * scale);
+ ctx.lineTo(cx - 20 * scale, y + 160 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 160 * scale);
+ ctx.lineTo(cx - 4 * scale, y + 150 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 4 * scale, y + 150 * scale);
+ ctx.lineTo(cx + 4 * scale, y + 160 * scale);
+ ctx.lineTo(cx + 20 * scale, y + 160 * scale);
+ ctx.lineTo(cx + 18 * scale, y + 150 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Boot heel
+ ctx.fillStyle = '#3d2314';
+ ctx.fillRect(cx - 18 * scale, y + 158 * scale, 4 * scale, 4 * scale);
+ ctx.fillRect(cx + 14 * scale, y + 158 * scale, 4 * scale, 4 * scale);
+
+ // Boot top decoration
+ ctx.fillStyle = '#8b7355';
+ ctx.fillRect(cx - 17 * scale, y + 150 * scale, 12 * scale, 3 * scale);
+ ctx.fillRect(cx + 5 * scale, y + 150 * scale, 12 * scale, 3 * scale);
+
+ // Spurs
+ ctx.fillStyle = '#c0c0c0';
+ ctx.beginPath();
+ ctx.arc(cx - 19 * scale, y + 158 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx + 19 * scale, y + 158 * scale, 2 * scale, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) {
+ var cx = x + 32 * scale;
+
+ // Shirt sleeves
+ ctx.fillStyle = '#c19a6b';
+ roundRect(ctx, cx - 28 * scale, y + 66 * scale, 10 * scale, 40 * scale, 3 * scale);
+ ctx.fill();
+ roundRect(ctx, cx + 18 * scale, y + 66 * scale, 10 * scale, 40 * scale, 3 * scale);
+ ctx.fill();
+
+ // Bandana around neck
+ ctx.fillStyle = '#e74c3c';
+ ctx.beginPath();
+ ctx.moveTo(cx - 15 * scale, y + 58 * scale);
+ ctx.quadraticCurveTo(cx, y + 65 * scale, cx + 15 * scale, y + 58 * scale);
+ ctx.lineTo(cx + 12 * scale, y + 62 * scale);
+ ctx.quadraticCurveTo(cx, y + 68 * scale, cx - 12 * scale, y + 62 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Bandana knot
+ ctx.beginPath();
+ ctx.arc(cx, y + 68 * scale, 4 * scale, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Bandana tails
+ ctx.beginPath();
+ ctx.moveTo(cx - 2 * scale, y + 72 * scale);
+ ctx.quadraticCurveTo(cx - 5 * scale, y + 85 * scale, cx - 8 * scale + Math.sin(time * 2) * 2 * scale, y + 92 * scale);
+ ctx.lineTo(cx - 5 * scale + Math.sin(time * 2) * 2 * scale, y + 90 * scale);
+ ctx.quadraticCurveTo(cx - 3 * scale, y + 82 * scale, cx, y + 72 * scale);
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(cx + 2 * scale, y + 72 * scale);
+ ctx.quadraticCurveTo(cx + 5 * scale, y + 85 * scale, cx + 8 * scale + Math.sin(time * 2 + 0.5) * 2 * scale, y + 92 * scale);
+ ctx.lineTo(cx + 5 * scale + Math.sin(time * 2 + 0.5) * 2 * scale, y + 90 * scale);
+ ctx.quadraticCurveTo(cx + 3 * scale, y + 82 * scale, cx, y + 72 * scale);
+ ctx.fill();
+
+ // Cowboy hat
+ var hatColor = '#8b4513';
+
+ // Brim
+ ctx.fillStyle = hatColor;
+ ctx.beginPath();
+ ctx.ellipse(cx, y + 8 * scale, 32 * scale, 8 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Crown
+ ctx.beginPath();
+ ctx.moveTo(cx - 18 * scale, y + 6 * scale);
+ ctx.quadraticCurveTo(cx - 20 * scale, y - 18 * scale, cx - 10 * scale, y - 22 * scale);
+ ctx.quadraticCurveTo(cx, y - 16 * scale, cx + 10 * scale, y - 22 * scale);
+ ctx.quadraticCurveTo(cx + 20 * scale, y - 18 * scale, cx + 18 * scale, y + 6 * scale);
+ ctx.closePath();
+ ctx.fill();
+
+ // Hat band
+ ctx.fillStyle = darken(hatColor, 0.3);
+ ctx.fillRect(cx - 18 * scale, y + 1 * scale, 36 * scale, 5 * scale);
+
+ // Hat band buckle
+ ctx.fillStyle = '#ffd700';
+ ctx.fillRect(cx - 4 * scale, y, 8 * scale, 7 * scale);
+
+ // Lasso in hand
+ var rightArm = getArmTransform(transforms, 'rightArm');
+ ctx.save();
+ ctx.translate(cx + 26 * scale, y + 108 * scale);
+ ctx.rotate((rightArm.rotation || 0) * Math.PI / 180);
+
+ // Coiled rope
+ ctx.strokeStyle = '#c19a6b';
+ ctx.lineWidth = 3 * scale;
+ ctx.beginPath();
+ for (var coil = 0; coil < 3; coil++) {
+ ctx.arc(0, -5 * scale + coil * 6 * scale, 8 * scale, 0, Math.PI * 1.8);
+ }
+ ctx.stroke();
+
+ // Rope end
+ ctx.beginPath();
+ ctx.moveTo(8 * scale, 10 * scale);
+ ctx.lineTo(15 * scale, 20 * scale);
+ ctx.stroke();
+
+ ctx.restore();
+ }
+ };
+
+ // ============================================================================
+ // CONTEXTS ARRAY
+ // ============================================================================
+
+ var CONTEXTS = [
+ knightArmor,
+ spaceSuit,
+ ninjaOutfit,
+ wizardRobes,
+ superheroCape,
+ athleteUniform,
+ pirateOutfit,
+ scientistLabcoat,
+ chefOutfit,
+ cowboyGear
+ ];
+
+ // ============================================================================
+ // PUBLIC API
+ // ============================================================================
+
+ window.CostumeContexts = {
+ CONTEXTS: CONTEXTS,
+
+ getById: function(id) {
+ for (var i = 0; i < CONTEXTS.length; i++) {
+ if (CONTEXTS[i].id === id) {
+ return CONTEXTS[i];
+ }
+ }
+ return null;
+ },
+
+ getAll: function() {
+ return CONTEXTS.slice();
+ },
+
+ getRecommendedFor: function(gameType) {
+ var recommended = [];
+ for (var i = 0; i < CONTEXTS.length; i++) {
+ if (CONTEXTS[i].recommendedFor && CONTEXTS[i].recommendedFor.indexOf(gameType) !== -1) {
+ recommended.push(CONTEXTS[i]);
+ }
+ }
+ return recommended;
+ },
+
+ isAccessoryVisible: function(costumeId, accessoryType) {
+ var costume = this.getById(costumeId);
+ if (!costume || !costume.accessoryVisibility) {
+ return true;
+ }
+ return costume.accessoryVisibility[accessoryType] !== false;
+ }
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/contexts/pure.js b/frontend/public/assets/avatar/contexts/pure.js
new file mode 100644
index 0000000..2bf7a35
--- /dev/null
+++ b/frontend/public/assets/avatar/contexts/pure.js
@@ -0,0 +1,1104 @@
+/**
+ * Pure Contexts - Minimal modifications to avatar for standard gameplay
+ * Uses FULL_BODY mode with the avatar fully visible
+ */
+(function() {
+ 'use strict';
+
+ // ============================================
+ // SHARED EFFECT RENDERERS
+ // ============================================
+
+ /**
+ * Render dust puff effects
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X position
+ * @param {number} y - Ground Y position
+ * @param {number} scale - Avatar scale
+ * @param {number} intensity - Effect intensity (0-1)
+ * @param {number} time - Animation time in seconds
+ * @param {Object} options - Additional options
+ */
+ function renderDust(ctx, x, y, scale, intensity, time, options = {}) {
+ const {
+ color = 'rgba(139, 119, 101, 0.6)',
+ particleCount = 5,
+ spread = 20 * scale,
+ riseSpeed = 30,
+ fadeTime = 0.5
+ } = options;
+
+ const progress = Math.min(time / fadeTime, 1);
+ const alpha = (1 - progress) * intensity;
+
+ if (alpha <= 0) return;
+
+ ctx.save();
+
+ for (let i = 0; i < particleCount; i++) {
+ const angle = (i / particleCount) * Math.PI + Math.random() * 0.2;
+ const distance = spread * progress * (0.5 + Math.random() * 0.5);
+ const px = x + Math.cos(angle) * distance;
+ const py = y - Math.sin(angle) * distance * 0.5 - riseSpeed * progress * scale;
+ const size = (8 + Math.random() * 6) * scale * (1 - progress * 0.5);
+
+ ctx.beginPath();
+ ctx.arc(px, py, size, 0, Math.PI * 2);
+ ctx.fillStyle = color.replace(/[\d.]+\)$/, (alpha * 0.6) + ')');
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render speed lines behind avatar
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Avatar center X
+ * @param {number} y - Avatar center Y
+ * @param {number} scale - Avatar scale
+ * @param {number} direction - Direction (-1 = left, 1 = right)
+ * @param {number} intensity - Effect intensity (0-1)
+ * @param {Object} options - Additional options
+ */
+ function renderSpeedLines(ctx, x, y, scale, direction, intensity, options = {}) {
+ const {
+ lineCount = 8,
+ maxLength = 60,
+ color = 'rgba(255, 255, 255, 0.7)',
+ spread = 80
+ } = options;
+
+ if (intensity <= 0) return;
+
+ ctx.save();
+ ctx.lineCap = 'round';
+
+ for (let i = 0; i < lineCount; i++) {
+ const yOffset = (i - lineCount / 2) * (spread / lineCount) * scale;
+ const lineY = y + yOffset;
+ const lineLength = (maxLength * (0.4 + Math.random() * 0.6)) * scale * intensity;
+ const lineWidth = (2 + Math.random() * 2) * scale;
+ const startX = x - direction * 30 * scale;
+ const alpha = intensity * (0.3 + Math.random() * 0.4);
+
+ ctx.beginPath();
+ ctx.moveTo(startX, lineY);
+ ctx.lineTo(startX - direction * lineLength, lineY);
+ ctx.strokeStyle = color.replace(/[\d.]+\)$/, alpha + ')');
+ ctx.lineWidth = lineWidth;
+ ctx.stroke();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render cartoon stars circling around head
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X (head position)
+ * @param {number} y - Center Y (head position)
+ * @param {number} scale - Avatar scale
+ * @param {number} count - Number of stars
+ * @param {number} time - Animation time
+ * @param {Object} options - Additional options
+ */
+ function renderStars(ctx, x, y, scale, count, time, options = {}) {
+ const {
+ radius = 25,
+ starSize = 8,
+ rotationSpeed = 3,
+ color = '#FFD700',
+ highlightColor = '#FFFFFF'
+ } = options;
+
+ ctx.save();
+
+ const orbitRadius = radius * scale;
+ const size = starSize * scale;
+
+ for (let i = 0; i < count; i++) {
+ const angle = (time * rotationSpeed) + (i * Math.PI * 2 / count);
+ const sx = x + Math.cos(angle) * orbitRadius;
+ const sy = y + Math.sin(angle) * orbitRadius * 0.4; // Elliptical orbit
+
+ // Draw 4-pointed star
+ ctx.beginPath();
+ for (let j = 0; j < 8; j++) {
+ const starAngle = (j * Math.PI / 4) + time * 2;
+ const pointRadius = j % 2 === 0 ? size : size * 0.4;
+ const px = sx + Math.cos(starAngle) * pointRadius;
+ const py = sy + Math.sin(starAngle) * pointRadius;
+ if (j === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ // Highlight
+ ctx.beginPath();
+ ctx.arc(sx - size * 0.2, sy - size * 0.2, size * 0.2, 0, Math.PI * 2);
+ ctx.fillStyle = highlightColor;
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render celebratory confetti
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X
+ * @param {number} y - Start Y
+ * @param {number} scale - Avatar scale
+ * @param {number} time - Animation time
+ * @param {Object} options - Additional options
+ */
+ function renderConfetti(ctx, x, y, scale, time, options = {}) {
+ const {
+ particleCount = 30,
+ spread = 100,
+ colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'],
+ gravity = 150,
+ initialVelocity = 200
+ } = options;
+
+ if (time > 3) return; // Stop after 3 seconds
+
+ ctx.save();
+
+ // Use seeded random for consistent particles
+ const seed = 12345;
+ const random = (i, offset = 0) => {
+ const x = Math.sin(seed + i * 9999 + offset) * 10000;
+ return x - Math.floor(x);
+ };
+
+ for (let i = 0; i < particleCount; i++) {
+ const angle = random(i, 0) * Math.PI - Math.PI / 2;
+ const velocity = initialVelocity * (0.5 + random(i, 1) * 0.5) * scale;
+ const vx = Math.cos(angle) * velocity * (random(i, 2) - 0.5) * 2;
+ const vy = -Math.abs(Math.sin(angle) * velocity);
+
+ const px = x + vx * time;
+ const py = y + vy * time + 0.5 * gravity * time * time * scale;
+
+ // Skip if fallen too far
+ if (py > y + 200 * scale) continue;
+
+ const rotation = time * (3 + random(i, 3) * 4) * (random(i, 4) > 0.5 ? 1 : -1);
+ const width = (4 + random(i, 5) * 4) * scale;
+ const height = (8 + random(i, 6) * 6) * scale;
+ const color = colors[Math.floor(random(i, 7) * colors.length)];
+
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(rotation);
+ ctx.fillStyle = color;
+ ctx.fillRect(-width / 2, -height / 2, width, height);
+ ctx.restore();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render shadow beneath avatar
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X
+ * @param {number} y - Ground Y
+ * @param {number} width - Shadow base width
+ * @param {number} height - Shadow base height
+ * @param {number} elevation - Avatar elevation (affects size)
+ * @param {Object} options - Additional options
+ */
+ function renderShadow(ctx, x, y, width, height, elevation, options = {}) {
+ const {
+ color = 'rgba(0, 0, 0, 0.3)',
+ maxElevation = 100,
+ minScale = 0.5
+ } = options;
+
+ const elevationFactor = Math.max(0, 1 - elevation / maxElevation);
+ const scale = minScale + (1 - minScale) * elevationFactor;
+ const alpha = 0.3 * elevationFactor;
+
+ if (alpha <= 0) return;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.ellipse(x, y, width * scale / 2, height * scale / 2, 0, 0, Math.PI * 2);
+ ctx.fillStyle = color.replace(/[\d.]+\)$/, alpha + ')');
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /**
+ * Render sparkle/shine effect
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X
+ * @param {number} y - Center Y
+ * @param {number} scale - Effect scale
+ * @param {number} time - Animation time
+ * @param {Object} options - Additional options
+ */
+ function renderSparkle(ctx, x, y, scale, time, options = {}) {
+ const {
+ count = 5,
+ spread = 40,
+ color = '#FFFFFF',
+ maxSize = 6,
+ twinkleSpeed = 4
+ } = options;
+
+ ctx.save();
+
+ const seed = 54321;
+ const random = (i, offset = 0) => {
+ const v = Math.sin(seed + i * 7777 + offset) * 10000;
+ return v - Math.floor(v);
+ };
+
+ for (let i = 0; i < count; i++) {
+ const px = x + (random(i, 0) - 0.5) * spread * 2 * scale;
+ const py = y + (random(i, 1) - 0.5) * spread * 2 * scale;
+ const phase = random(i, 2) * Math.PI * 2;
+ const twinkle = Math.abs(Math.sin(time * twinkleSpeed + phase));
+ const size = maxSize * scale * twinkle;
+
+ if (size < 0.5) continue;
+
+ // Draw 4-pointed sparkle
+ ctx.beginPath();
+ ctx.moveTo(px, py - size);
+ ctx.lineTo(px + size * 0.3, py);
+ ctx.lineTo(px, py + size);
+ ctx.lineTo(px - size * 0.3, py);
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(px - size, py);
+ ctx.lineTo(px, py + size * 0.3);
+ ctx.lineTo(px + size, py);
+ ctx.lineTo(px, py - size * 0.3);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render flash/impact effect
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X
+ * @param {number} y - Center Y
+ * @param {number} width - Flash width
+ * @param {number} height - Flash height
+ * @param {number} time - Animation time
+ * @param {Object} options - Additional options
+ */
+ function renderFlash(ctx, x, y, width, height, time, options = {}) {
+ const {
+ color = 'rgba(255, 255, 255, 0.8)',
+ duration = 0.3,
+ pulseCount = 2
+ } = options;
+
+ const progress = time / duration;
+ if (progress > 1) return;
+
+ const pulse = Math.sin(progress * Math.PI * pulseCount);
+ const alpha = pulse * (1 - progress);
+
+ if (alpha <= 0) return;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.ellipse(x, y, width / 2, height / 2, 0, 0, Math.PI * 2);
+ ctx.fillStyle = color.replace(/[\d.]+\)$/, alpha + ')');
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /**
+ * Render heroic glow effect
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X
+ * @param {number} y - Center Y
+ * @param {number} width - Glow width
+ * @param {number} height - Glow height
+ * @param {number} time - Animation time
+ * @param {Object} options - Additional options
+ */
+ function renderGlow(ctx, x, y, width, height, time, options = {}) {
+ const {
+ color = 'rgba(255, 215, 0, 0.15)',
+ pulseSpeed = 2,
+ pulseAmount = 0.2
+ } = options;
+
+ const pulse = 1 + Math.sin(time * pulseSpeed) * pulseAmount;
+
+ ctx.save();
+
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(width, height) * pulse / 2);
+ gradient.addColorStop(0, color);
+ gradient.addColorStop(1, 'rgba(255, 215, 0, 0)');
+
+ ctx.beginPath();
+ ctx.ellipse(x, y, width * pulse / 2, height * pulse / 2, 0, 0, Math.PI * 2);
+ ctx.fillStyle = gradient;
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ /**
+ * Render motion blur trail
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Current X
+ * @param {number} y - Current Y
+ * @param {number} scale - Avatar scale
+ * @param {number} direction - Direction (-1 = left, 1 = right)
+ * @param {number} intensity - Blur intensity
+ * @param {Object} options - Additional options
+ */
+ function renderMotionBlur(ctx, x, y, scale, direction, intensity, options = {}) {
+ const {
+ trailCount = 4,
+ spacing = 15,
+ color = 'rgba(200, 200, 255, 0.3)',
+ width = 20,
+ height = 60
+ } = options;
+
+ if (intensity <= 0) return;
+
+ ctx.save();
+
+ for (let i = 1; i <= trailCount; i++) {
+ const alpha = (1 - i / (trailCount + 1)) * intensity * 0.3;
+ const tx = x - direction * spacing * i * scale;
+
+ ctx.beginPath();
+ ctx.ellipse(tx, y, width * scale / 2, height * scale / 2, 0, 0, Math.PI * 2);
+ ctx.fillStyle = color.replace(/[\d.]+\)$/, alpha + ')');
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render friction sparks
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Center X
+ * @param {number} y - Ground Y
+ * @param {number} scale - Effect scale
+ * @param {number} direction - Direction of movement
+ * @param {number} time - Animation time
+ * @param {Object} options - Additional options
+ */
+ function renderSparks(ctx, x, y, scale, direction, time, options = {}) {
+ const {
+ count = 8,
+ color = '#FFA500',
+ highlightColor = '#FFFF00',
+ spread = 30
+ } = options;
+
+ ctx.save();
+
+ const seed = 98765;
+ const random = (i, offset = 0) => {
+ const v = Math.sin(seed + i * 3333 + offset + time * 100) * 10000;
+ return v - Math.floor(v);
+ };
+
+ for (let i = 0; i < count; i++) {
+ const lifetime = random(i, 0) * 0.3;
+ const sparkTime = (time % 0.3);
+ if (sparkTime > lifetime) continue;
+
+ const angle = -direction * (Math.PI / 6 + random(i, 1) * Math.PI / 3);
+ const velocity = (50 + random(i, 2) * 50) * scale;
+ const px = x + Math.cos(angle) * velocity * sparkTime;
+ const py = y + Math.sin(angle) * velocity * sparkTime - 20 * sparkTime * scale;
+ const size = (2 + random(i, 3) * 2) * scale * (1 - sparkTime / lifetime);
+
+ ctx.beginPath();
+ ctx.arc(px, py, size, 0, Math.PI * 2);
+ ctx.fillStyle = random(i, 4) > 0.5 ? color : highlightColor;
+ ctx.fill();
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Render small backpack
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Avatar center X
+ * @param {number} y - Avatar center Y
+ * @param {number} scale - Avatar scale
+ * @param {boolean} facingRight - Avatar facing direction
+ */
+ function renderBackpack(ctx, x, y, scale, facingRight) {
+ const offsetX = facingRight ? -25 : 25;
+ const bx = x + offsetX * scale;
+ const by = y - 10 * scale;
+
+ ctx.save();
+
+ // Main bag
+ ctx.fillStyle = '#8B4513';
+ ctx.beginPath();
+ ctx.roundRect(bx - 10 * scale, by - 15 * scale, 20 * scale, 25 * scale, 3 * scale);
+ ctx.fill();
+
+ // Straps
+ ctx.strokeStyle = '#654321';
+ ctx.lineWidth = 2 * scale;
+ ctx.beginPath();
+ ctx.moveTo(bx - 8 * scale, by - 15 * scale);
+ ctx.lineTo(bx - 5 * scale, by - 25 * scale);
+ ctx.moveTo(bx + 8 * scale, by - 15 * scale);
+ ctx.lineTo(bx + 5 * scale, by - 25 * scale);
+ ctx.stroke();
+
+ // Pocket
+ ctx.fillStyle = '#A0522D';
+ ctx.beginPath();
+ ctx.roundRect(bx - 6 * scale, by - 5 * scale, 12 * scale, 10 * scale, 2 * scale);
+ ctx.fill();
+
+ // Flap
+ ctx.fillStyle = '#654321';
+ ctx.beginPath();
+ ctx.roundRect(bx - 8 * scale, by - 15 * scale, 16 * scale, 6 * scale, [3 * scale, 3 * scale, 0, 0]);
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ /**
+ * Render utility belt
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Avatar center X
+ * @param {number} y - Avatar waist Y
+ * @param {number} scale - Avatar scale
+ */
+ function renderUtilityBelt(ctx, x, y, scale) {
+ ctx.save();
+
+ // Belt
+ ctx.fillStyle = '#4A4A4A';
+ ctx.fillRect(x - 25 * scale, y, 50 * scale, 6 * scale);
+
+ // Buckle
+ ctx.fillStyle = '#C0C0C0';
+ ctx.fillRect(x - 5 * scale, y - 1 * scale, 10 * scale, 8 * scale);
+
+ // Pouches
+ ctx.fillStyle = '#5A5A5A';
+ ctx.fillRect(x - 22 * scale, y + 4 * scale, 8 * scale, 10 * scale);
+ ctx.fillRect(x + 14 * scale, y + 4 * scale, 8 * scale, 10 * scale);
+
+ ctx.restore();
+ }
+
+ /**
+ * Render sweatband
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Head center X
+ * @param {number} y - Forehead Y
+ * @param {number} scale - Avatar scale
+ * @param {string} color - Band color
+ */
+ function renderSweatband(ctx, x, y, scale, color = '#FF4444') {
+ ctx.save();
+
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.ellipse(x, y, 22 * scale, 4 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Stripe
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
+ ctx.beginPath();
+ ctx.ellipse(x, y - 1 * scale, 20 * scale, 1.5 * scale, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ /**
+ * Render jersey number
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Back center X
+ * @param {number} y - Back center Y
+ * @param {number} scale - Avatar scale
+ * @param {number} number - Jersey number
+ * @param {boolean} facingRight - Avatar facing direction
+ */
+ function renderJerseyNumber(ctx, x, y, scale, number, facingRight) {
+ // Only show when avatar is facing away (back visible)
+ if (facingRight) return;
+
+ ctx.save();
+ ctx.font = `bold ${16 * scale}px Arial`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(String(number), x, y);
+ ctx.restore();
+ }
+
+ /**
+ * Render wind-swept hair effect
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {number} x - Head X
+ * @param {number} y - Head top Y
+ * @param {number} scale - Avatar scale
+ * @param {number} intensity - Wind intensity
+ * @param {number} time - Animation time
+ */
+ function renderWindEffect(ctx, x, y, scale, intensity, time) {
+ ctx.save();
+
+ const lineCount = 5;
+ ctx.strokeStyle = 'rgba(200, 220, 255, 0.4)';
+ ctx.lineWidth = 2 * scale;
+ ctx.lineCap = 'round';
+
+ for (let i = 0; i < lineCount; i++) {
+ const startY = y + (i - lineCount / 2) * 8 * scale;
+ const waveOffset = Math.sin(time * 8 + i) * 3 * scale;
+ const length = (20 + Math.random() * 15) * scale * intensity;
+
+ ctx.beginPath();
+ ctx.moveTo(x + 15 * scale, startY + waveOffset);
+ ctx.quadraticCurveTo(
+ x + 15 * scale + length / 2, startY + waveOffset + 5 * scale,
+ x + 15 * scale + length, startY + waveOffset
+ );
+ ctx.stroke();
+ }
+
+ ctx.restore();
+ }
+
+ // ============================================
+ // PURE CONTEXTS
+ // ============================================
+
+ const platformerStandard = {
+ id: 'platformer-standard',
+ name: 'Platformer Hero',
+ category: 'pure',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'run', 'jump', 'fall', 'crouch', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background-effects', 'avatar', 'foreground-effects'],
+ recommendedFor: ['platformer', 'adventure', 'action'],
+ description: 'Standard platforming character with no costume modifications',
+
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+
+ renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const groundY = y + (avatar.height || 100) * scale / 2;
+ const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0;
+
+ // Shadow
+ renderShadow(ctx, x, groundY, 50 * scale, 15 * scale, elevation);
+
+ // Launch dust when jumping starts
+ if (state.justJumped && state.jumpTime !== undefined) {
+ renderDust(ctx, x, groundY, scale, 1.0, state.jumpTime, {
+ particleCount: 8,
+ spread: 30 * scale,
+ color: 'rgba(139, 119, 101, 0.7)'
+ });
+ }
+
+ // Landing impact dust
+ if (state.justLanded && state.landTime !== undefined) {
+ renderDust(ctx, x, groundY, scale, 1.2, state.landTime, {
+ particleCount: 10,
+ spread: 40 * scale,
+ color: 'rgba(139, 119, 101, 0.8)'
+ });
+ }
+ },
+
+ renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const groundY = y + (avatar.height || 100) * scale / 2;
+ const headY = y - (avatar.height || 100) * scale / 2 + 15 * scale;
+
+ // Running dust
+ if (state.running && !state.jumping && !state.falling) {
+ const dustTime = (time * 10) % 0.5;
+ if (dustTime < 0.3) {
+ renderDust(ctx, x + (state.facingRight ? -15 : 15) * scale, groundY, scale, 0.5, dustTime, {
+ particleCount: 3,
+ spread: 15 * scale
+ });
+ }
+ }
+
+ // Hurt stars
+ if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.5) {
+ renderStars(ctx, x, headY, scale, 4, time);
+ }
+
+ // Celebration confetti
+ if (state.celebrating && state.celebrateTime !== undefined) {
+ renderConfetti(ctx, x, y - 50 * scale, scale, state.celebrateTime);
+ }
+ },
+
+ stateEffects: {
+ running: { type: 'dust', frequency: 0.1 },
+ jumping: { type: 'launch-dust', once: true },
+ landing: { type: 'impact-dust', once: true },
+ hurt: { type: 'stars', duration: 0.5 },
+ celebrating: { type: 'confetti', duration: 3.0 }
+ }
+ };
+
+ const sportsPlayer = {
+ id: 'sports-player',
+ name: 'Sports Player',
+ category: 'pure',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'run', 'jump', 'fall', 'crouch', 'hurt', 'celebrate', 'throw', 'catch', 'kick'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background-effects', 'costume-back', 'avatar', 'costume-front', 'foreground-effects'],
+ recommendedFor: ['sports', 'basketball', 'soccer', 'tennis', 'racing'],
+ description: 'Athletic character with minimal sports equipment',
+
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+
+ jerseyNumber: 7,
+
+ renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const groundY = y + (avatar.height || 100) * scale / 2;
+ const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0;
+
+ // Court/field shadow (slightly elongated)
+ renderShadow(ctx, x, groundY, 60 * scale, 12 * scale, elevation, {
+ color: 'rgba(0, 0, 0, 0.25)'
+ });
+
+ // Speed lines when running fast
+ if (state.running && state.speed > 0.7) {
+ const direction = state.facingRight ? 1 : -1;
+ renderSpeedLines(ctx, x, y, scale, direction, state.speed * 0.8, {
+ lineCount: 6,
+ maxLength: 50,
+ color: 'rgba(255, 255, 255, 0.5)'
+ });
+ }
+
+ // Athletic motion blur when jumping
+ if (state.jumping && state.speed > 0.5) {
+ const direction = state.facingRight ? 1 : -1;
+ renderMotionBlur(ctx, x, y, scale, direction, state.speed * 0.6);
+ }
+ },
+
+ renderCostumeBehind: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ // Jersey number on back
+ const backY = y - 10 * scale;
+ renderJerseyNumber(ctx, x, backY, scale, this.jerseyNumber, state.facingRight);
+ },
+
+ renderCostumeFront: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ // Sweatband on forehead
+ const headY = y - (avatar.height || 100) * scale / 2 + 20 * scale;
+ renderSweatband(ctx, x, headY, scale, '#FF4444');
+ },
+
+ renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const headY = y - (avatar.height || 100) * scale / 2 + 15 * scale;
+
+ // Scoring celebration sparkles
+ if (state.scoring && state.scoreTime !== undefined) {
+ renderSparkle(ctx, x, y - 30 * scale, scale, state.scoreTime, {
+ count: 8,
+ spread: 60,
+ color: '#FFD700',
+ maxSize: 10
+ });
+ }
+
+ // Hurt impact flash
+ if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.3) {
+ renderFlash(ctx, x, y, 80 * scale, 120 * scale, state.hurtTime, {
+ color: 'rgba(255, 100, 100, 0.5)',
+ duration: 0.3
+ });
+ }
+
+ // Celebration
+ if (state.celebrating && state.celebrateTime !== undefined) {
+ renderSparkle(ctx, x, y, scale * 1.5, state.celebrateTime, {
+ count: 12,
+ spread: 80,
+ color: '#FFFFFF'
+ });
+ }
+ },
+
+ stateEffects: {
+ running: { type: 'speed-lines', threshold: 0.7 },
+ jumping: { type: 'motion-blur', once: false },
+ scoring: { type: 'sparkles', duration: 1.0 },
+ hurt: { type: 'flash', duration: 0.3 },
+ celebrating: { type: 'sparkles', duration: 2.0 }
+ }
+ };
+
+ const adventureHero = {
+ id: 'adventure-hero',
+ name: 'Adventure Hero',
+ category: 'pure',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'walk', 'run', 'jump', 'fall', 'crouch', 'hurt', 'celebrate', 'climb', 'push', 'pull'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background-effects', 'costume-back', 'avatar', 'costume-front', 'foreground-effects'],
+ recommendedFor: ['adventure', 'puzzle', 'exploration', 'rpg-lite'],
+ description: 'Adventurous character with backpack and utility belt',
+
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+
+ lightSource: { x: -0.5, y: -1 }, // Top-left light
+
+ renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const groundY = y + (avatar.height || 100) * scale / 2;
+ const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0;
+
+ // Dynamic shadow based on light source
+ const shadowOffsetX = -this.lightSource.x * 20 * scale;
+ const shadowOffsetY = -this.lightSource.y * 5 * scale;
+
+ renderShadow(
+ ctx,
+ x + shadowOffsetX * (1 - elevation / 100),
+ groundY + shadowOffsetY,
+ 55 * scale,
+ 15 * scale,
+ elevation
+ );
+
+ // Subtle heroic glow when idle
+ if (state.idle || (!state.running && !state.jumping && !state.falling)) {
+ renderGlow(ctx, x, y, 100 * scale, 140 * scale, time, {
+ color: 'rgba(255, 215, 0, 0.08)',
+ pulseSpeed: 1.5,
+ pulseAmount: 0.15
+ });
+ }
+
+ // Determination lines when running
+ if (state.running) {
+ const direction = state.facingRight ? 1 : -1;
+ renderSpeedLines(ctx, x, y - 20 * scale, scale, direction, 0.4, {
+ lineCount: 4,
+ maxLength: 30,
+ color: 'rgba(255, 200, 100, 0.4)',
+ spread: 50
+ });
+ }
+ },
+
+ renderCostumeBehind: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ // Small backpack
+ renderBackpack(ctx, x, y, scale, state.facingRight !== false);
+ },
+
+ renderCostumeFront: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ // Thin utility belt
+ const waistY = y + 15 * scale;
+ renderUtilityBelt(ctx, x, waistY, scale * 0.8);
+ },
+
+ renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const headY = y - (avatar.height || 100) * scale / 2 + 15 * scale;
+
+ // Cape-like motion effect when jumping (no actual cape)
+ if (state.jumping || state.falling) {
+ const direction = state.facingRight ? -1 : 1;
+ ctx.save();
+ ctx.strokeStyle = 'rgba(100, 150, 255, 0.3)';
+ ctx.lineWidth = 3 * scale;
+ ctx.lineCap = 'round';
+
+ for (let i = 0; i < 3; i++) {
+ const startX = x + direction * 20 * scale;
+ const startY = y - 20 * scale + i * 15 * scale;
+ const waveTime = time * 5 + i * 0.5;
+
+ ctx.beginPath();
+ ctx.moveTo(startX, startY);
+ ctx.quadraticCurveTo(
+ startX + direction * 20 * scale,
+ startY + Math.sin(waveTime) * 10 * scale,
+ startX + direction * 35 * scale,
+ startY + 10 * scale
+ );
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ // Discovery sparkle/shine
+ if (state.discovering && state.discoverTime !== undefined) {
+ renderSparkle(ctx, x + 40 * scale, y - 20 * scale, scale * 1.2, state.discoverTime, {
+ count: 6,
+ spread: 30,
+ color: '#FFFF00',
+ maxSize: 8,
+ twinkleSpeed: 6
+ });
+ }
+
+ // Damage flash when hurt
+ if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.4) {
+ renderFlash(ctx, x, y, 90 * scale, 130 * scale, state.hurtTime, {
+ color: 'rgba(255, 50, 50, 0.4)',
+ duration: 0.4,
+ pulseCount: 3
+ });
+ }
+ },
+
+ stateEffects: {
+ idle: { type: 'glow', continuous: true },
+ running: { type: 'determination-lines', continuous: true },
+ jumping: { type: 'cape-motion', continuous: true },
+ discovering: { type: 'sparkle', duration: 1.5 },
+ hurt: { type: 'damage-flash', duration: 0.4 }
+ }
+ };
+
+ const runner = {
+ id: 'runner',
+ name: 'Speed Runner',
+ category: 'pure',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'run', 'jump', 'slide', 'fall', 'hurt', 'celebrate'],
+ defaultAnimation: 'run',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background-effects', 'avatar', 'foreground-effects'],
+ recommendedFor: ['runner', 'endless-runner', 'racing', 'speed'],
+ description: 'Streamlined character optimized for endless runner games',
+
+ avatarPosition: { x: 0.4, y: 0.5 }, // Slightly left to show more forward space
+ avatarScale: 1.0,
+
+ renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const groundY = y + (avatar.height || 100) * scale / 2;
+ const speed = state.speed || 0.5;
+ const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0;
+
+ // Motion-blurred shadow
+ ctx.save();
+ const shadowCount = Math.ceil(speed * 3);
+ for (let i = shadowCount; i >= 0; i--) {
+ const offsetX = i * 8 * scale;
+ const alpha = 0.2 * (1 - i / (shadowCount + 1)) * (1 - elevation / 100);
+ ctx.beginPath();
+ ctx.ellipse(x - offsetX, groundY, (50 - i * 5) * scale / 2, (12 - i * 2) * scale / 2, 0, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`;
+ ctx.fill();
+ }
+ ctx.restore();
+
+ // Constant speed lines (intensity based on speed)
+ renderSpeedLines(ctx, x, y, scale, 1, speed, {
+ lineCount: Math.ceil(6 + speed * 6),
+ maxLength: 40 + speed * 40,
+ color: 'rgba(255, 255, 255, 0.6)',
+ spread: 90
+ });
+
+ // Intensified speed effect when accelerating
+ if (state.accelerating) {
+ renderSpeedLines(ctx, x - 20 * scale, y, scale, 1, speed * 1.5, {
+ lineCount: 10,
+ maxLength: 80,
+ color: 'rgba(255, 200, 100, 0.5)',
+ spread: 100
+ });
+ }
+
+ // Air trail when jumping
+ if (state.jumping || state.falling) {
+ const trailCount = 5;
+ ctx.save();
+ for (let i = 1; i <= trailCount; i++) {
+ const trailX = x - i * 15 * scale;
+ const trailY = y + (state.falling ? -1 : 1) * i * 8 * scale;
+ const alpha = 0.3 * (1 - i / (trailCount + 1));
+
+ ctx.beginPath();
+ ctx.ellipse(trailX, trailY, 15 * scale, 25 * scale, 0, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(200, 220, 255, ${alpha})`;
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+ },
+
+ renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) {
+ const headY = y - (avatar.height || 100) * scale / 2;
+ const groundY = y + (avatar.height || 100) * scale / 2;
+
+ // Wind-swept hair effect
+ const windIntensity = (state.speed || 0.5) + (state.accelerating ? 0.3 : 0);
+ renderWindEffect(ctx, x, headY + 10 * scale, scale, windIntensity, time);
+
+ // Ground friction sparks when sliding
+ if (state.sliding && state.slideTime !== undefined) {
+ renderSparks(ctx, x, groundY, scale, 1, state.slideTime, {
+ count: 10,
+ color: '#FFA500',
+ highlightColor: '#FFFF00'
+ });
+ }
+
+ // Pickup sparkle when collecting
+ if (state.collecting && state.collectTime !== undefined && state.collectTime < 0.5) {
+ renderSparkle(ctx, x + 30 * scale, y - 20 * scale, scale, state.collectTime, {
+ count: 8,
+ spread: 25,
+ color: '#00FF00',
+ maxSize: 10,
+ twinkleSpeed: 8
+ });
+ }
+
+ // Stumble effect when hurt
+ if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.6) {
+ // Shake effect
+ const shakeIntensity = Math.max(0, 1 - state.hurtTime / 0.6);
+ const shakeX = Math.sin(state.hurtTime * 50) * 5 * scale * shakeIntensity;
+ const shakeY = Math.sin(state.hurtTime * 40) * 3 * scale * shakeIntensity;
+
+ // Motion lines indicating stumble
+ ctx.save();
+ ctx.strokeStyle = `rgba(255, 100, 100, ${shakeIntensity * 0.5})`;
+ ctx.lineWidth = 2 * scale;
+ ctx.setLineDash([5 * scale, 5 * scale]);
+
+ for (let i = 0; i < 4; i++) {
+ const angle = (i / 4) * Math.PI * 2 + state.hurtTime * 10;
+ const radius = 40 * scale * shakeIntensity;
+ ctx.beginPath();
+ ctx.moveTo(x + shakeX, y + shakeY);
+ ctx.lineTo(
+ x + shakeX + Math.cos(angle) * radius,
+ y + shakeY + Math.sin(angle) * radius
+ );
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ // Stars for big hits
+ if (state.bigHit) {
+ renderStars(ctx, x, headY + 10 * scale, scale, 3, time);
+ }
+ }
+ },
+
+ stateEffects: {
+ running: { type: 'speed-lines', continuous: true },
+ accelerating: { type: 'intensified-speed', continuous: true },
+ jumping: { type: 'air-trail', continuous: true },
+ sliding: { type: 'sparks', continuous: true },
+ collecting: { type: 'sparkle', duration: 0.5 },
+ hurt: { type: 'stumble', duration: 0.6 }
+ }
+ };
+
+ // ============================================
+ // PUBLIC API
+ // ============================================
+
+ const CONTEXTS = [
+ platformerStandard,
+ sportsPlayer,
+ adventureHero,
+ runner
+ ];
+
+ window.PureContexts = {
+ CONTEXTS: CONTEXTS,
+
+ /**
+ * Get a pure context by ID
+ * @param {string} id - Context ID
+ * @returns {Object|null} Context object or null if not found
+ */
+ getById: function(id) {
+ return CONTEXTS.find(function(ctx) { return ctx.id === id; }) || null;
+ },
+
+ /**
+ * Get all pure contexts
+ * @returns {Array} Array of all pure contexts
+ */
+ getAll: function() {
+ return CONTEXTS.slice();
+ },
+
+ /**
+ * Get contexts recommended for a specific game type
+ * @param {string} gameType - Game type to match
+ * @returns {Array} Array of matching contexts
+ */
+ getRecommendedFor: function(gameType) {
+ var type = gameType.toLowerCase();
+ return CONTEXTS.filter(function(ctx) {
+ return ctx.recommendedFor.some(function(rec) {
+ return rec.toLowerCase() === type || type.indexOf(rec.toLowerCase()) !== -1;
+ });
+ });
+ },
+
+ // Shared effect renderers for reuse by other modules
+ effects: {
+ dust: renderDust,
+ speedLines: renderSpeedLines,
+ stars: renderStars,
+ confetti: renderConfetti,
+ shadow: renderShadow,
+ sparkle: renderSparkle,
+ flash: renderFlash,
+ glow: renderGlow,
+ motionBlur: renderMotionBlur,
+ sparks: renderSparks,
+ windEffect: renderWindEffect
+ },
+
+ // Shared costume renderers for reuse
+ costumes: {
+ backpack: renderBackpack,
+ utilityBelt: renderUtilityBelt,
+ sweatband: renderSweatband,
+ jerseyNumber: renderJerseyNumber
+ }
+ };
+
+})();
diff --git a/frontend/public/assets/avatar/contexts/vehicles.js b/frontend/public/assets/avatar/contexts/vehicles.js
new file mode 100644
index 0000000..a619581
--- /dev/null
+++ b/frontend/public/assets/avatar/contexts/vehicles.js
@@ -0,0 +1,1799 @@
+(function() {
+ 'use strict';
+
+ // ============================================================
+ // VEHICLE CONTEXTS - Avatar placement in vehicle scenarios
+ // ============================================================
+
+ const CONTEXTS = [
+
+ // ============================================================
+ // 1. SPACESHIP COCKPIT
+ // ============================================================
+ {
+ id: 'spaceship-cockpit',
+ name: 'Spaceship Cockpit',
+ category: 'vehicle',
+ avatarMode: 'HEAD_ONLY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['space', 'shooter', 'arcade'],
+ description: 'Pilot a spacecraft through space',
+
+ avatarPosition: { x: 0.5, y: 0.4 },
+ avatarScale: 1.0,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Dark space background
+ ctx.fillStyle = '#0a0a1a';
+ ctx.fillRect(0, 0, width, height);
+
+ // Starfield
+ var starCount = 50;
+ for (var i = 0; i < starCount; i++) {
+ var seed = i * 7919;
+ var x = ((seed * 13) % 1000) / 1000 * width;
+ var baseY = ((seed * 17) % 1000) / 1000 * height;
+ var speed = 0.5 + ((seed * 23) % 100) / 100;
+ var y = (baseY + time * speed * 100) % height;
+ var brightness = 0.3 + ((seed * 31) % 70) / 100;
+ var size = 1 + ((seed * 37) % 3);
+
+ ctx.fillStyle = 'rgba(255, 255, 255, ' + brightness + ')';
+ ctx.beginPath();
+ ctx.arc(x, y, size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Cockpit interior frame
+ ctx.fillStyle = '#1a1a2e';
+ ctx.fillRect(0, 0, width * 0.15, height);
+ ctx.fillRect(width * 0.85, 0, width * 0.15, height);
+ ctx.fillRect(0, height * 0.75, width, height * 0.25);
+
+ // Interior panel details
+ ctx.fillStyle = '#2a2a4e';
+ for (var j = 0; j < 3; j++) {
+ ctx.fillRect(width * 0.02, height * 0.1 + j * height * 0.2, width * 0.08, height * 0.12);
+ ctx.fillRect(width * 0.9, height * 0.1 + j * height * 0.2, width * 0.08, height * 0.12);
+ }
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Cockpit glass with slight blue tint
+ ctx.strokeStyle = 'rgba(100, 150, 255, 0.3)';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.4, width * 0.35, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // HUD overlay elements
+ ctx.strokeStyle = 'rgba(0, 255, 200, 0.5)';
+ ctx.lineWidth = 2;
+
+ // Targeting reticle
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.35, width * 0.08, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(width * 0.5, height * 0.25);
+ ctx.lineTo(width * 0.5, height * 0.3);
+ ctx.moveTo(width * 0.5, height * 0.4);
+ ctx.lineTo(width * 0.5, height * 0.45);
+ ctx.moveTo(width * 0.4, height * 0.35);
+ ctx.lineTo(width * 0.45, height * 0.35);
+ ctx.moveTo(width * 0.55, height * 0.35);
+ ctx.lineTo(width * 0.6, height * 0.35);
+ ctx.stroke();
+
+ // Control panel at bottom
+ var gradient = ctx.createLinearGradient(0, height * 0.8, 0, height);
+ gradient.addColorStop(0, '#2a2a4e');
+ gradient.addColorStop(1, '#1a1a2e');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, height * 0.8, width, height * 0.2);
+
+ // Control buttons
+ var colors = ['#ff3333', '#33ff33', '#3333ff', '#ffff33'];
+ for (var i = 0; i < 4; i++) {
+ var blinkPhase = Math.sin(time * 3 + i) * 0.3 + 0.7;
+ ctx.fillStyle = colors[i];
+ ctx.globalAlpha = blinkPhase;
+ ctx.beginPath();
+ ctx.arc(width * 0.25 + i * width * 0.15, height * 0.9, width * 0.02, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+
+ // Speed indicator
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.8)';
+ ctx.font = (width * 0.04) + 'px monospace';
+ ctx.fillText('SPD: ' + Math.floor(500 + Math.sin(time) * 50), width * 0.05, height * 0.88);
+ },
+
+ contextEffects: {
+ idle: null,
+ boost: { type: 'particles', direction: 'back' },
+ damage: { type: 'shake', intensity: 5 }
+ }
+ },
+
+ // ============================================================
+ // 2. RACE CAR
+ // ============================================================
+ {
+ id: 'race-car',
+ name: 'Race Car',
+ category: 'vehicle',
+ avatarMode: 'HEAD_AND_SHOULDERS',
+ animations: ['idle', 'bob', 'celebrate', 'hurt', 'turn-left', 'turn-right'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['racing', 'driving', 'arcade'],
+ description: 'Race at high speed on the track',
+
+ avatarPosition: { x: 0.5, y: 0.35 },
+ avatarScale: 0.9,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Sky gradient
+ var skyGradient = ctx.createLinearGradient(0, 0, 0, height * 0.4);
+ skyGradient.addColorStop(0, '#87ceeb');
+ skyGradient.addColorStop(1, '#e0f0ff');
+ ctx.fillStyle = skyGradient;
+ ctx.fillRect(0, 0, width, height * 0.4);
+
+ // Road rushing past
+ var roadGradient = ctx.createLinearGradient(0, height * 0.4, 0, height);
+ roadGradient.addColorStop(0, '#555555');
+ roadGradient.addColorStop(1, '#333333');
+ ctx.fillStyle = roadGradient;
+ ctx.fillRect(0, height * 0.4, width, height * 0.6);
+
+ // Road lines (animated)
+ ctx.fillStyle = '#ffffff';
+ var lineOffset = (time * 300) % 80;
+ for (var i = -1; i < 8; i++) {
+ var y = height * 0.5 + i * 80 - lineOffset;
+ var lineWidth = 5 + (y - height * 0.4) * 0.05;
+ ctx.fillRect(width * 0.48, y, width * 0.04, 40);
+ }
+
+ // Speed lines on sides
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
+ ctx.lineWidth = 2;
+ for (var j = 0; j < 10; j++) {
+ var lineY = height * 0.5 + (j * 30 + time * 200) % (height * 0.5);
+ ctx.beginPath();
+ ctx.moveTo(width * 0.1, lineY);
+ ctx.lineTo(width * 0.2, lineY + 20);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(width * 0.9, lineY);
+ ctx.lineTo(width * 0.8, lineY + 20);
+ ctx.stroke();
+ }
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Dashboard
+ var dashGradient = ctx.createLinearGradient(0, height * 0.7, 0, height);
+ dashGradient.addColorStop(0, '#1a1a1a');
+ dashGradient.addColorStop(1, '#0a0a0a');
+ ctx.fillStyle = dashGradient;
+ ctx.beginPath();
+ ctx.moveTo(0, height * 0.75);
+ ctx.quadraticCurveTo(width * 0.5, height * 0.65, width, height * 0.75);
+ ctx.lineTo(width, height);
+ ctx.lineTo(0, height);
+ ctx.closePath();
+ ctx.fill();
+
+ // Steering wheel
+ ctx.strokeStyle = '#333333';
+ ctx.lineWidth = width * 0.03;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.95, width * 0.15, Math.PI * 0.8, Math.PI * 2.2);
+ ctx.stroke();
+
+ ctx.fillStyle = '#222222';
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.95, width * 0.06, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Side mirrors
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, height * 0.3, width * 0.12, height * 0.15);
+ ctx.fillRect(width * 0.88, height * 0.3, width * 0.12, height * 0.15);
+
+ // Mirror reflections (road)
+ ctx.fillStyle = '#444444';
+ ctx.fillRect(width * 0.01, height * 0.32, width * 0.1, height * 0.11);
+ ctx.fillRect(width * 0.89, height * 0.32, width * 0.1, height * 0.11);
+
+ // Speedometer
+ ctx.fillStyle = '#111111';
+ ctx.beginPath();
+ ctx.arc(width * 0.25, height * 0.85, width * 0.08, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#ff3300';
+ ctx.lineWidth = 2;
+ var speedAngle = Math.PI * 0.75 + (0.5 + Math.sin(time * 0.5) * 0.2) * Math.PI;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.25, height * 0.85);
+ ctx.lineTo(
+ width * 0.25 + Math.cos(speedAngle) * width * 0.06,
+ height * 0.85 + Math.sin(speedAngle) * width * 0.06
+ );
+ ctx.stroke();
+
+ // RPM gauge
+ ctx.fillStyle = '#111111';
+ ctx.beginPath();
+ ctx.arc(width * 0.75, height * 0.85, width * 0.08, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#00ff00';
+ ctx.lineWidth = 2;
+ var rpmAngle = Math.PI * 0.75 + (0.3 + Math.sin(time * 2) * 0.3) * Math.PI;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.75, height * 0.85);
+ ctx.lineTo(
+ width * 0.75 + Math.cos(rpmAngle) * width * 0.06,
+ height * 0.85 + Math.sin(rpmAngle) * width * 0.06
+ );
+ ctx.stroke();
+ },
+
+ contextEffects: {
+ idle: null,
+ accelerate: { type: 'blur', direction: 'vertical' },
+ drift: { type: 'particles', direction: 'side' },
+ damage: { type: 'shake', intensity: 8 }
+ }
+ },
+
+ // ============================================================
+ // 3. AIRPLANE
+ // ============================================================
+ {
+ id: 'airplane',
+ name: 'Airplane',
+ category: 'vehicle',
+ avatarMode: 'HEAD_AND_SHOULDERS',
+ animations: ['idle', 'bob', 'celebrate', 'hurt'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['flight', 'simulation', 'arcade'],
+ description: 'Fly through the clouds as a pilot',
+
+ avatarPosition: { x: 0.5, y: 0.4 },
+ avatarScale: 0.85,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Sky gradient
+ var skyGradient = ctx.createLinearGradient(0, 0, 0, height);
+ skyGradient.addColorStop(0, '#1e90ff');
+ skyGradient.addColorStop(0.5, '#87ceeb');
+ skyGradient.addColorStop(1, '#b0e0e6');
+ ctx.fillStyle = skyGradient;
+ ctx.fillRect(0, 0, width, height);
+
+ // Clouds streaming past
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
+ for (var i = 0; i < 8; i++) {
+ var seed = i * 3571;
+ var baseX = ((seed * 13) % 1000) / 1000 * width * 1.5;
+ var x = (baseX - time * 150) % (width * 1.5) - width * 0.25;
+ var y = ((seed * 17) % 1000) / 1000 * height * 0.6;
+ var cloudWidth = 60 + ((seed * 23) % 80);
+ var cloudHeight = 30 + ((seed * 29) % 30);
+
+ // Draw cloud puffs
+ for (var j = 0; j < 4; j++) {
+ var puffX = x + j * cloudWidth * 0.25;
+ var puffY = y + Math.sin(j * 1.5) * cloudHeight * 0.3;
+ var puffR = cloudHeight * 0.5 + ((seed * j) % 10);
+ ctx.beginPath();
+ ctx.arc(puffX, puffY, puffR, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ // Cockpit interior sides
+ ctx.fillStyle = '#2a3a4a';
+ ctx.fillRect(0, 0, width * 0.1, height);
+ ctx.fillRect(width * 0.9, 0, width * 0.1, height);
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Windshield frame
+ ctx.strokeStyle = '#4a5a6a';
+ ctx.lineWidth = width * 0.04;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.1, 0);
+ ctx.lineTo(width * 0.2, height * 0.7);
+ ctx.lineTo(width * 0.8, height * 0.7);
+ ctx.lineTo(width * 0.9, 0);
+ ctx.stroke();
+
+ // Center divider
+ ctx.beginPath();
+ ctx.moveTo(width * 0.5, 0);
+ ctx.lineTo(width * 0.5, height * 0.15);
+ ctx.stroke();
+
+ // Instrument panel
+ var panelGradient = ctx.createLinearGradient(0, height * 0.7, 0, height);
+ panelGradient.addColorStop(0, '#3a4a5a');
+ panelGradient.addColorStop(1, '#2a3a4a');
+ ctx.fillStyle = panelGradient;
+ ctx.fillRect(0, height * 0.7, width, height * 0.3);
+
+ // Instruments
+ var instrumentPositions = [0.2, 0.35, 0.5, 0.65, 0.8];
+ for (var i = 0; i < instrumentPositions.length; i++) {
+ ctx.fillStyle = '#1a2a3a';
+ ctx.beginPath();
+ ctx.arc(width * instrumentPositions[i], height * 0.82, width * 0.055, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Instrument needle
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 1;
+ var angle = Math.PI * (0.75 + Math.sin(time + i) * 0.3);
+ ctx.beginPath();
+ ctx.moveTo(width * instrumentPositions[i], height * 0.82);
+ ctx.lineTo(
+ width * instrumentPositions[i] + Math.cos(angle) * width * 0.04,
+ height * 0.82 + Math.sin(angle) * width * 0.04
+ );
+ ctx.stroke();
+ }
+
+ // Yoke/control column
+ ctx.fillStyle = '#2a2a2a';
+ ctx.fillRect(width * 0.45, height * 0.85, width * 0.1, height * 0.15);
+
+ ctx.fillStyle = '#3a3a3a';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, height * 0.88, width * 0.12, width * 0.04, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Altitude display
+ ctx.fillStyle = '#00ff00';
+ ctx.font = (width * 0.03) + 'px monospace';
+ ctx.fillText('ALT: ' + Math.floor(35000 + Math.sin(time * 0.5) * 500), width * 0.05, height * 0.95);
+ ctx.fillText('HDG: ' + Math.floor(270 + Math.sin(time * 0.3) * 10), width * 0.75, height * 0.95);
+ },
+
+ contextEffects: {
+ idle: null,
+ turbulence: { type: 'shake', intensity: 3 },
+ dive: { type: 'blur', direction: 'vertical' },
+ damage: { type: 'shake', intensity: 10 }
+ }
+ },
+
+ // ============================================================
+ // 4. FLAPPY STYLE
+ // ============================================================
+ {
+ id: 'flappy-style',
+ name: 'Flappy Bird Style',
+ category: 'vehicle',
+ avatarMode: 'HEAD_ONLY',
+ animations: ['idle', 'flap', 'fall', 'hurt'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: false, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['flappy', 'casual', 'arcade', 'mobile'],
+ description: 'Flap through obstacles as a flying character',
+
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.2,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Transparent background - game provides its own
+ ctx.clearRect(0, 0, width, height);
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Optional wing sprites
+ var flapOffset = Math.sin(time * 15) * 0.15;
+
+ // Left wing
+ ctx.fillStyle = 'rgba(255, 200, 100, 0.9)';
+ ctx.save();
+ ctx.translate(width * 0.25, height * 0.5);
+ ctx.rotate(-0.3 + flapOffset);
+ ctx.beginPath();
+ ctx.ellipse(0, 0, width * 0.15, width * 0.06, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ // Right wing
+ ctx.save();
+ ctx.translate(width * 0.75, height * 0.5);
+ ctx.rotate(0.3 - flapOffset);
+ ctx.beginPath();
+ ctx.ellipse(0, 0, width * 0.15, width * 0.06, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ // Wing details
+ ctx.strokeStyle = 'rgba(200, 150, 50, 0.7)';
+ ctx.lineWidth = 2;
+ ctx.save();
+ ctx.translate(width * 0.25, height * 0.5);
+ ctx.rotate(-0.3 + flapOffset);
+ ctx.beginPath();
+ ctx.moveTo(-width * 0.1, 0);
+ ctx.lineTo(width * 0.1, 0);
+ ctx.stroke();
+ ctx.restore();
+
+ ctx.save();
+ ctx.translate(width * 0.75, height * 0.5);
+ ctx.rotate(0.3 - flapOffset);
+ ctx.beginPath();
+ ctx.moveTo(-width * 0.1, 0);
+ ctx.lineTo(width * 0.1, 0);
+ ctx.stroke();
+ ctx.restore();
+ },
+
+ contextEffects: {
+ idle: null,
+ flap: { type: 'blur', direction: 'vertical', intensity: 2 },
+ fall: { type: 'stretch', direction: 'down' },
+ damage: { type: 'flash', color: '#ff0000' }
+ }
+ },
+
+ // ============================================================
+ // 5. TANK
+ // ============================================================
+ {
+ id: 'tank',
+ name: 'Tank',
+ category: 'vehicle',
+ avatarMode: 'HEAD_ONLY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt', 'aim'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['military', 'shooter', 'strategy', 'arcade'],
+ description: 'Command a battle tank',
+
+ avatarPosition: { x: 0.5, y: 0.35 },
+ avatarScale: 0.8,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Military green interior
+ var interiorGradient = ctx.createRadialGradient(
+ width * 0.5, height * 0.5, 0,
+ width * 0.5, height * 0.5, width * 0.6
+ );
+ interiorGradient.addColorStop(0, '#3a4a2a');
+ interiorGradient.addColorStop(1, '#2a3a1a');
+ ctx.fillStyle = interiorGradient;
+ ctx.fillRect(0, 0, width, height);
+
+ // Rivets and panels
+ ctx.fillStyle = '#4a5a3a';
+ for (var i = 0; i < 4; i++) {
+ for (var j = 0; j < 4; j++) {
+ ctx.beginPath();
+ ctx.arc(
+ width * 0.1 + i * width * 0.05,
+ height * 0.1 + j * height * 0.25,
+ 4, 0, Math.PI * 2
+ );
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(
+ width * 0.9 - i * width * 0.05,
+ height * 0.1 + j * height * 0.25,
+ 4, 0, Math.PI * 2
+ );
+ ctx.fill();
+ }
+ }
+
+ // Interior screens
+ ctx.fillStyle = '#1a2a1a';
+ ctx.fillRect(width * 0.05, height * 0.6, width * 0.2, height * 0.15);
+ ctx.fillRect(width * 0.75, height * 0.6, width * 0.2, height * 0.15);
+
+ // Screen static
+ ctx.fillStyle = '#2a4a2a';
+ for (var k = 0; k < 20; k++) {
+ var sx = width * 0.07 + Math.random() * width * 0.16;
+ var sy = height * 0.62 + Math.random() * height * 0.11;
+ ctx.fillRect(sx, sy, 2, 2);
+ }
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Tank hatch frame (circular opening)
+ ctx.strokeStyle = '#5a6a4a';
+ ctx.lineWidth = width * 0.08;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.35, width * 0.28, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Hatch lip detail
+ ctx.strokeStyle = '#4a5a3a';
+ ctx.lineWidth = width * 0.02;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.35, width * 0.32, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Hatch bolts
+ ctx.fillStyle = '#3a4a2a';
+ for (var i = 0; i < 8; i++) {
+ var angle = (i / 8) * Math.PI * 2;
+ var bx = width * 0.5 + Math.cos(angle) * width * 0.3;
+ var by = height * 0.35 + Math.sin(angle) * width * 0.3;
+ ctx.beginPath();
+ ctx.arc(bx, by, 6, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Periscope overlay (top)
+ ctx.fillStyle = '#2a3a1a';
+ ctx.fillRect(width * 0.35, 0, width * 0.3, height * 0.1);
+
+ ctx.fillStyle = '#1a2a0a';
+ ctx.fillRect(width * 0.38, height * 0.02, width * 0.24, height * 0.06);
+
+ // Periscope view
+ ctx.fillStyle = '#87ceeb';
+ ctx.fillRect(width * 0.4, height * 0.03, width * 0.2, height * 0.04);
+
+ // Crosshair in periscope
+ ctx.strokeStyle = '#ff0000';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.5, height * 0.03);
+ ctx.lineTo(width * 0.5, height * 0.07);
+ ctx.moveTo(width * 0.4, height * 0.05);
+ ctx.lineTo(width * 0.6, height * 0.05);
+ ctx.stroke();
+
+ // Control panel at bottom
+ ctx.fillStyle = '#3a4a2a';
+ ctx.fillRect(0, height * 0.8, width, height * 0.2);
+
+ // Warning lights
+ var warningOn = Math.sin(time * 5) > 0;
+ ctx.fillStyle = warningOn ? '#ff3333' : '#331111';
+ ctx.beginPath();
+ ctx.arc(width * 0.2, height * 0.9, width * 0.025, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = '#33ff33';
+ ctx.beginPath();
+ ctx.arc(width * 0.35, height * 0.9, width * 0.025, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Ammo counter
+ ctx.fillStyle = '#ffcc00';
+ ctx.font = (width * 0.05) + 'px monospace';
+ ctx.fillText('AMMO: 24', width * 0.6, height * 0.92);
+ },
+
+ contextEffects: {
+ idle: null,
+ fire: { type: 'flash', color: '#ffff00', intensity: 10 },
+ rumble: { type: 'shake', intensity: 4 },
+ damage: { type: 'shake', intensity: 12 }
+ }
+ },
+
+ // ============================================================
+ // 6. PACMAN STYLE
+ // ============================================================
+ {
+ id: 'pacman-style',
+ name: 'Pac-Man Style',
+ category: 'vehicle',
+ avatarMode: 'HEAD_ONLY',
+ animations: ['idle', 'chomp', 'hurt', 'power'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: false, headwear: false, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['maze', 'arcade', 'retro', 'casual'],
+ description: 'Navigate mazes and eat pellets',
+
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Fully transparent - game provides maze
+ ctx.clearRect(0, 0, width, height);
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // No foreground elements - avatar IS the character
+ // The chomp animation is handled by avatar animations
+
+ // Optional: subtle mouth effect overlay during chomp
+ var chompPhase = Math.abs(Math.sin(time * 12));
+ if (chompPhase < 0.3) {
+ // Mouth "bite" effect - darkens bottom portion slightly
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
+ ctx.beginPath();
+ ctx.moveTo(width * 0.5, height * 0.5);
+ ctx.lineTo(width * 0.8, height * 0.6);
+ ctx.lineTo(width * 0.8, height * 0.7);
+ ctx.lineTo(width * 0.5, height * 0.6);
+ ctx.closePath();
+ ctx.fill();
+ }
+ },
+
+ contextEffects: {
+ idle: null,
+ chomp: { type: 'scale', pulseRate: 12 },
+ power: { type: 'glow', color: '#00ffff' },
+ damage: { type: 'flash', color: '#ff0000' }
+ }
+ },
+
+ // ============================================================
+ // 7. HOVERBOARD
+ // ============================================================
+ {
+ id: 'hoverboard',
+ name: 'Hoverboard',
+ category: 'vehicle',
+ avatarMode: 'UPPER_BODY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt', 'lean-left', 'lean-right', 'jump'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'foreground', 'avatar'],
+ recommendedFor: ['runner', 'racing', 'arcade', 'endless'],
+ description: 'Ride a futuristic hoverboard',
+
+ avatarPosition: { x: 0.5, y: 0.35 },
+ avatarScale: 0.75,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Ground/environment rushing beneath
+ var groundGradient = ctx.createLinearGradient(0, height * 0.6, 0, height);
+ groundGradient.addColorStop(0, '#4a4a5a');
+ groundGradient.addColorStop(1, '#2a2a3a');
+ ctx.fillStyle = groundGradient;
+ ctx.fillRect(0, height * 0.6, width, height * 0.4);
+
+ // Speed lines on ground
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
+ ctx.lineWidth = 3;
+ for (var i = 0; i < 12; i++) {
+ var seed = i * 1847;
+ var lx = ((seed * 13) % 1000) / 1000 * width;
+ var ly = height * 0.7 + ((seed * 17) % 1000) / 1000 * height * 0.25;
+ var lineLen = 30 + ((seed * 23) % 50);
+ var offset = (time * 400 + seed) % (width * 1.5);
+ var drawX = lx - offset;
+ if (drawX < -lineLen) drawX += width * 1.5;
+
+ ctx.beginPath();
+ ctx.moveTo(drawX, ly);
+ ctx.lineTo(drawX + lineLen, ly);
+ ctx.stroke();
+ }
+
+ // Shadow beneath board
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, height * 0.85, width * 0.25, height * 0.03, 0, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Hoverboard
+ var hoverOffset = Math.sin(time * 4) * 3;
+ var boardY = height * 0.72 + hoverOffset;
+
+ // Board shadow/glow
+ var glowGradient = ctx.createRadialGradient(
+ width * 0.5, boardY + 15, 0,
+ width * 0.5, boardY + 15, width * 0.3
+ );
+ glowGradient.addColorStop(0, 'rgba(0, 200, 255, 0.6)');
+ glowGradient.addColorStop(0.5, 'rgba(0, 200, 255, 0.2)');
+ glowGradient.addColorStop(1, 'rgba(0, 200, 255, 0)');
+ ctx.fillStyle = glowGradient;
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, boardY + 15, width * 0.3, height * 0.06, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Board body
+ var boardGradient = ctx.createLinearGradient(0, boardY - 10, 0, boardY + 10);
+ boardGradient.addColorStop(0, '#3a3a4a');
+ boardGradient.addColorStop(0.5, '#5a5a6a');
+ boardGradient.addColorStop(1, '#2a2a3a');
+ ctx.fillStyle = boardGradient;
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, boardY, width * 0.28, height * 0.04, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Board top surface
+ ctx.fillStyle = '#6a6a7a';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, boardY - 3, width * 0.25, height * 0.025, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Board lights
+ var lightPhase = (time * 3) % 1;
+ ctx.fillStyle = 'rgba(0, 255, 255, ' + (0.5 + lightPhase * 0.5) + ')';
+ ctx.beginPath();
+ ctx.arc(width * 0.3, boardY, 4, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(width * 0.7, boardY, 4, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Hover jets
+ ctx.fillStyle = 'rgba(0, 200, 255, 0.8)';
+ var jetFlicker = 0.7 + Math.random() * 0.3;
+ ctx.globalAlpha = jetFlicker;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.35, boardY + 8);
+ ctx.lineTo(width * 0.32, boardY + 20 + hoverOffset);
+ ctx.lineTo(width * 0.38, boardY + 20 + hoverOffset);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(width * 0.65, boardY + 8);
+ ctx.lineTo(width * 0.62, boardY + 20 + hoverOffset);
+ ctx.lineTo(width * 0.68, boardY + 20 + hoverOffset);
+ ctx.closePath();
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ },
+
+ contextEffects: {
+ idle: { type: 'bob', amplitude: 3, rate: 4 },
+ boost: { type: 'particles', direction: 'back', color: '#00ffff' },
+ jump: { type: 'stretch', direction: 'up' },
+ damage: { type: 'shake', intensity: 6 }
+ }
+ },
+
+ // ============================================================
+ // 8. JETPACK
+ // ============================================================
+ {
+ id: 'jetpack',
+ name: 'Jetpack',
+ category: 'vehicle',
+ avatarMode: 'UPPER_BODY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt', 'boost'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'jetpack', 'avatar', 'foreground'],
+ recommendedFor: ['flying', 'endless', 'arcade', 'shooter'],
+ description: 'Fly with a jetpack strapped to your back',
+
+ avatarPosition: { x: 0.5, y: 0.4 },
+ avatarScale: 0.7,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Sky gradient
+ var skyGradient = ctx.createLinearGradient(0, 0, 0, height);
+ skyGradient.addColorStop(0, '#1e90ff');
+ skyGradient.addColorStop(1, '#87ceeb');
+ ctx.fillStyle = skyGradient;
+ ctx.fillRect(0, 0, width, height);
+
+ // Clouds below
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
+ for (var i = 0; i < 5; i++) {
+ var seed = i * 2347;
+ var cx = ((seed * 13) % 1000) / 1000 * width * 1.5 - width * 0.25;
+ var cy = height * 0.7 + ((seed * 17) % 1000) / 1000 * height * 0.2;
+ var cw = 80 + ((seed * 23) % 60);
+
+ // Animate clouds moving down
+ cy = (cy + time * 30) % (height * 0.5) + height * 0.6;
+
+ for (var j = 0; j < 4; j++) {
+ ctx.beginPath();
+ ctx.arc(cx + j * cw * 0.3, cy + Math.sin(j) * 10, cw * 0.3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ // JETPACK (rendered behind avatar)
+ var packX = width * 0.5;
+ var packY = height * 0.45;
+
+ // Main pack body
+ var packGradient = ctx.createLinearGradient(packX - 30, 0, packX + 30, 0);
+ packGradient.addColorStop(0, '#4a4a5a');
+ packGradient.addColorStop(0.5, '#6a6a7a');
+ packGradient.addColorStop(1, '#4a4a5a');
+ ctx.fillStyle = packGradient;
+
+ // Pack shape
+ ctx.beginPath();
+ ctx.roundRect(packX - width * 0.12, packY - height * 0.05, width * 0.24, height * 0.25, 8);
+ ctx.fill();
+
+ // Pack details
+ ctx.fillStyle = '#3a3a4a';
+ ctx.fillRect(packX - width * 0.08, packY, width * 0.16, height * 0.02);
+ ctx.fillRect(packX - width * 0.08, packY + height * 0.08, width * 0.16, height * 0.02);
+
+ // Fuel gauge
+ ctx.fillStyle = '#2a2a3a';
+ ctx.fillRect(packX - width * 0.06, packY + height * 0.12, width * 0.12, height * 0.04);
+ ctx.fillStyle = '#00ff00';
+ var fuelLevel = 0.7 + Math.sin(time * 0.5) * 0.1;
+ ctx.fillRect(packX - width * 0.055, packY + height * 0.125, width * 0.11 * fuelLevel, height * 0.03);
+
+ // Exhaust nozzles
+ ctx.fillStyle = '#5a5a6a';
+ ctx.beginPath();
+ ctx.moveTo(packX - width * 0.08, packY + height * 0.2);
+ ctx.lineTo(packX - width * 0.1, packY + height * 0.28);
+ ctx.lineTo(packX - width * 0.04, packY + height * 0.28);
+ ctx.lineTo(packX - width * 0.04, packY + height * 0.2);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(packX + width * 0.04, packY + height * 0.2);
+ ctx.lineTo(packX + width * 0.04, packY + height * 0.28);
+ ctx.lineTo(packX + width * 0.1, packY + height * 0.28);
+ ctx.lineTo(packX + width * 0.08, packY + height * 0.2);
+ ctx.closePath();
+ ctx.fill();
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Jet flames (rendered in front layer for visibility)
+ var packX = width * 0.5;
+ var packY = height * 0.45;
+ var flameHeight = height * 0.15 + Math.random() * height * 0.05;
+
+ // Left flame
+ var flameGradient1 = ctx.createLinearGradient(0, packY + height * 0.28, 0, packY + height * 0.28 + flameHeight);
+ flameGradient1.addColorStop(0, 'rgba(255, 200, 50, 0.9)');
+ flameGradient1.addColorStop(0.3, 'rgba(255, 100, 0, 0.8)');
+ flameGradient1.addColorStop(1, 'rgba(255, 50, 0, 0)');
+ ctx.fillStyle = flameGradient1;
+
+ ctx.beginPath();
+ ctx.moveTo(packX - width * 0.09, packY + height * 0.28);
+ ctx.quadraticCurveTo(
+ packX - width * 0.07 + Math.sin(time * 20) * 5,
+ packY + height * 0.35,
+ packX - width * 0.07,
+ packY + height * 0.28 + flameHeight
+ );
+ ctx.quadraticCurveTo(
+ packX - width * 0.07 + Math.sin(time * 25) * 5,
+ packY + height * 0.35,
+ packX - width * 0.05,
+ packY + height * 0.28
+ );
+ ctx.closePath();
+ ctx.fill();
+
+ // Right flame
+ ctx.beginPath();
+ ctx.moveTo(packX + width * 0.05, packY + height * 0.28);
+ ctx.quadraticCurveTo(
+ packX + width * 0.07 + Math.sin(time * 22) * 5,
+ packY + height * 0.35,
+ packX + width * 0.07,
+ packY + height * 0.28 + flameHeight
+ );
+ ctx.quadraticCurveTo(
+ packX + width * 0.07 + Math.sin(time * 27) * 5,
+ packY + height * 0.35,
+ packX + width * 0.09,
+ packY + height * 0.28
+ );
+ ctx.closePath();
+ ctx.fill();
+
+ // Smoke trail
+ ctx.fillStyle = 'rgba(100, 100, 100, 0.3)';
+ for (var i = 0; i < 6; i++) {
+ var smokeY = packY + height * 0.35 + i * 15 + (time * 50) % 20;
+ var smokeX = packX + Math.sin(time * 3 + i) * 10;
+ var smokeSize = 8 + i * 3;
+ ctx.globalAlpha = 0.3 - i * 0.04;
+ ctx.beginPath();
+ ctx.arc(smokeX, smokeY, smokeSize, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ },
+
+ contextEffects: {
+ idle: { type: 'bob', amplitude: 2, rate: 2 },
+ boost: { type: 'particles', direction: 'down', color: '#ff6600' },
+ damage: { type: 'shake', intensity: 8 }
+ }
+ },
+
+ // ============================================================
+ // 9. MECH SUIT
+ // ============================================================
+ {
+ id: 'mech-suit',
+ name: 'Mech Suit',
+ category: 'vehicle',
+ avatarMode: 'HEAD_ONLY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: false, headwear: false, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['mech', 'shooter', 'action', 'combat'],
+ description: 'Pilot a powerful mech suit',
+
+ avatarPosition: { x: 0.5, y: 0.35 },
+ avatarScale: 0.7,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Mech interior with screens
+ var interiorGradient = ctx.createRadialGradient(
+ width * 0.5, height * 0.4, 0,
+ width * 0.5, height * 0.4, width * 0.6
+ );
+ interiorGradient.addColorStop(0, '#2a2a3a');
+ interiorGradient.addColorStop(1, '#1a1a2a');
+ ctx.fillStyle = interiorGradient;
+ ctx.fillRect(0, 0, width, height);
+
+ // Side screens
+ ctx.fillStyle = '#0a1a2a';
+ ctx.fillRect(width * 0.02, height * 0.15, width * 0.15, height * 0.25);
+ ctx.fillRect(width * 0.83, height * 0.15, width * 0.15, height * 0.25);
+
+ // Screen content - radar
+ ctx.strokeStyle = '#00ff00';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(width * 0.095, height * 0.275, width * 0.05, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Radar sweep
+ var sweepAngle = (time * 2) % (Math.PI * 2);
+ ctx.beginPath();
+ ctx.moveTo(width * 0.095, height * 0.275);
+ ctx.lineTo(
+ width * 0.095 + Math.cos(sweepAngle) * width * 0.05,
+ height * 0.275 + Math.sin(sweepAngle) * width * 0.05
+ );
+ ctx.stroke();
+
+ // Right screen - systems
+ ctx.fillStyle = '#00ff00';
+ ctx.font = (width * 0.02) + 'px monospace';
+ ctx.fillText('PWR: 87%', width * 0.84, height * 0.2);
+ ctx.fillText('ARM: OK', width * 0.84, height * 0.25);
+ ctx.fillText('SHD: 65%', width * 0.84, height * 0.3);
+ ctx.fillText('FUEL: 72%', width * 0.84, height * 0.35);
+
+ // Interior frame elements
+ ctx.fillStyle = '#3a3a4a';
+ ctx.fillRect(0, 0, width * 0.08, height);
+ ctx.fillRect(width * 0.92, 0, width * 0.08, height);
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Mech chest window frame (hexagonal)
+ ctx.strokeStyle = '#5a5a6a';
+ ctx.lineWidth = width * 0.06;
+ ctx.beginPath();
+ var centerX = width * 0.5;
+ var centerY = height * 0.35;
+ var radius = width * 0.28;
+ for (var i = 0; i < 6; i++) {
+ var angle = (i / 6) * Math.PI * 2 - Math.PI / 2;
+ var x = centerX + Math.cos(angle) * radius;
+ var y = centerY + Math.sin(angle) * radius * 0.8;
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.closePath();
+ ctx.stroke();
+
+ // Inner frame
+ ctx.strokeStyle = '#4a4a5a';
+ ctx.lineWidth = width * 0.02;
+ ctx.beginPath();
+ for (var j = 0; j < 6; j++) {
+ var angle2 = (j / 6) * Math.PI * 2 - Math.PI / 2;
+ var x2 = centerX + Math.cos(angle2) * radius * 1.1;
+ var y2 = centerY + Math.sin(angle2) * radius * 0.88;
+ if (j === 0) {
+ ctx.moveTo(x2, y2);
+ } else {
+ ctx.lineTo(x2, y2);
+ }
+ }
+ ctx.closePath();
+ ctx.stroke();
+
+ // HUD elements
+ ctx.strokeStyle = 'rgba(0, 200, 255, 0.6)';
+ ctx.lineWidth = 2;
+
+ // Targeting brackets
+ ctx.beginPath();
+ ctx.moveTo(width * 0.35, height * 0.2);
+ ctx.lineTo(width * 0.35, height * 0.15);
+ ctx.lineTo(width * 0.4, height * 0.15);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.65, height * 0.2);
+ ctx.lineTo(width * 0.65, height * 0.15);
+ ctx.lineTo(width * 0.6, height * 0.15);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.35, height * 0.5);
+ ctx.lineTo(width * 0.35, height * 0.55);
+ ctx.lineTo(width * 0.4, height * 0.55);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.65, height * 0.5);
+ ctx.lineTo(width * 0.65, height * 0.55);
+ ctx.lineTo(width * 0.6, height * 0.55);
+ ctx.stroke();
+
+ // Control panel
+ ctx.fillStyle = '#3a3a4a';
+ ctx.fillRect(0, height * 0.7, width, height * 0.3);
+
+ // Control sticks
+ ctx.fillStyle = '#2a2a3a';
+ ctx.beginPath();
+ ctx.arc(width * 0.25, height * 0.85, width * 0.06, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(width * 0.75, height * 0.85, width * 0.06, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = '#5a5a6a';
+ ctx.beginPath();
+ ctx.arc(width * 0.25, height * 0.85, width * 0.025, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(width * 0.75, height * 0.85, width * 0.025, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Warning lights (blinking)
+ var warningPhase = Math.sin(time * 4);
+ if (warningPhase > 0.5) {
+ ctx.fillStyle = '#ff3333';
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.75, width * 0.015, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Status text
+ ctx.fillStyle = '#00ffff';
+ ctx.font = (width * 0.035) + 'px monospace';
+ ctx.fillText('MECH SYSTEMS ONLINE', width * 0.32, height * 0.78);
+ },
+
+ contextEffects: {
+ idle: null,
+ walk: { type: 'shake', intensity: 2 },
+ fire: { type: 'flash', color: '#ffff00' },
+ damage: { type: 'shake', intensity: 10 }
+ }
+ },
+
+ // ============================================================
+ // 10. SUBMARINE
+ // ============================================================
+ {
+ id: 'submarine',
+ name: 'Submarine',
+ category: 'vehicle',
+ avatarMode: 'HEAD_AND_SHOULDERS',
+ animations: ['idle', 'bob', 'celebrate', 'hurt'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['underwater', 'exploration', 'arcade'],
+ description: 'Explore the depths in a submarine',
+
+ avatarPosition: { x: 0.5, y: 0.4 },
+ avatarScale: 0.8,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Underwater gradient
+ var waterGradient = ctx.createLinearGradient(0, 0, 0, height);
+ waterGradient.addColorStop(0, '#0a3a5a');
+ waterGradient.addColorStop(0.5, '#0a2a4a');
+ waterGradient.addColorStop(1, '#0a1a3a');
+ ctx.fillStyle = waterGradient;
+ ctx.fillRect(0, 0, width, height);
+
+ // Bubbles rising
+ ctx.fillStyle = 'rgba(150, 200, 255, 0.4)';
+ for (var i = 0; i < 15; i++) {
+ var seed = i * 1373;
+ var bx = ((seed * 13) % 1000) / 1000 * width;
+ var by = height - ((time * 40 + seed) % height);
+ var bSize = 3 + ((seed * 17) % 8);
+ var wobble = Math.sin(time * 2 + i) * 5;
+
+ ctx.beginPath();
+ ctx.arc(bx + wobble, by, bSize, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Submarine interior
+ ctx.fillStyle = '#3a4a5a';
+ ctx.fillRect(0, 0, width * 0.12, height);
+ ctx.fillRect(width * 0.88, 0, width * 0.12, height);
+
+ // Interior pipes
+ ctx.strokeStyle = '#5a6a7a';
+ ctx.lineWidth = 8;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.06, 0);
+ ctx.lineTo(width * 0.06, height);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(width * 0.94, 0);
+ ctx.lineTo(width * 0.94, height);
+ ctx.stroke();
+
+ // Pipe joints
+ ctx.fillStyle = '#4a5a6a';
+ for (var j = 0; j < 4; j++) {
+ ctx.beginPath();
+ ctx.arc(width * 0.06, height * 0.2 + j * height * 0.25, 12, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(width * 0.94, height * 0.2 + j * height * 0.25, 12, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Porthole frame (circular submarine window)
+ ctx.strokeStyle = '#6a7a8a';
+ ctx.lineWidth = width * 0.08;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.4, width * 0.3, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Porthole rivets
+ ctx.fillStyle = '#5a6a7a';
+ for (var i = 0; i < 12; i++) {
+ var angle = (i / 12) * Math.PI * 2;
+ var rx = width * 0.5 + Math.cos(angle) * width * 0.34;
+ var ry = height * 0.4 + Math.sin(angle) * width * 0.34;
+ ctx.beginPath();
+ ctx.arc(rx, ry, 5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Glass reflection
+ ctx.strokeStyle = 'rgba(150, 200, 255, 0.3)';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.arc(width * 0.4, height * 0.3, width * 0.15, Math.PI * 1.2, Math.PI * 1.7);
+ ctx.stroke();
+
+ // Water distortion overlay
+ var distortionAlpha = 0.05 + Math.sin(time) * 0.02;
+ ctx.fillStyle = 'rgba(100, 150, 200, ' + distortionAlpha + ')';
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.4, width * 0.26, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Control panel
+ ctx.fillStyle = '#4a5a6a';
+ ctx.fillRect(0, height * 0.75, width, height * 0.25);
+
+ // Depth gauge
+ ctx.fillStyle = '#2a3a4a';
+ ctx.beginPath();
+ ctx.arc(width * 0.2, height * 0.87, width * 0.07, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#00ff00';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(width * 0.2, height * 0.87, width * 0.05, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Depth needle
+ var depthAngle = Math.PI * 0.75 + (0.4 + Math.sin(time * 0.3) * 0.2) * Math.PI;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.2, height * 0.87);
+ ctx.lineTo(
+ width * 0.2 + Math.cos(depthAngle) * width * 0.045,
+ height * 0.87 + Math.sin(depthAngle) * width * 0.045
+ );
+ ctx.stroke();
+
+ // Depth reading
+ ctx.fillStyle = '#00ff00';
+ ctx.font = (width * 0.03) + 'px monospace';
+ ctx.fillText('DEPTH', width * 0.155, height * 0.95);
+ ctx.fillText(Math.floor(500 + Math.sin(time * 0.3) * 50) + 'm', width * 0.16, height * 0.98);
+
+ // Pressure gauge
+ ctx.fillStyle = '#2a3a4a';
+ ctx.beginPath();
+ ctx.arc(width * 0.8, height * 0.87, width * 0.07, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#ffaa00';
+ ctx.beginPath();
+ ctx.arc(width * 0.8, height * 0.87, width * 0.05, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Sonar ping effect
+ var pingPhase = (time * 0.5) % 1;
+ ctx.strokeStyle = 'rgba(0, 255, 100, ' + (1 - pingPhase) * 0.5 + ')';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.87, pingPhase * width * 0.15, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Periscope controls
+ ctx.fillStyle = '#5a6a7a';
+ ctx.fillRect(width * 0.45, height * 0.78, width * 0.1, height * 0.08);
+ ctx.fillStyle = '#3a4a5a';
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height * 0.82, width * 0.03, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ contextEffects: {
+ idle: { type: 'bob', amplitude: 2, rate: 1 },
+ pressure: { type: 'distort', intensity: 3 },
+ damage: { type: 'shake', intensity: 6 }
+ }
+ },
+
+ // ============================================================
+ // 11. DRAGON RIDER
+ // ============================================================
+ {
+ id: 'dragon-rider',
+ name: 'Dragon Rider',
+ category: 'vehicle',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt', 'lean'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['fantasy', 'adventure', 'flying', 'rpg'],
+ description: 'Soar through the skies on a dragon',
+
+ avatarPosition: { x: 0.5, y: 0.3 },
+ avatarScale: 0.5,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Sky gradient
+ var skyGradient = ctx.createLinearGradient(0, 0, 0, height);
+ skyGradient.addColorStop(0, '#ff7700');
+ skyGradient.addColorStop(0.3, '#ff9944');
+ skyGradient.addColorStop(0.6, '#87ceeb');
+ skyGradient.addColorStop(1, '#5588bb');
+ ctx.fillStyle = skyGradient;
+ ctx.fillRect(0, 0, width, height);
+
+ // Clouds
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
+ for (var i = 0; i < 6; i++) {
+ var seed = i * 2741;
+ var cx = (((seed * 13) % 1000) / 1000 * width * 2 - time * 30) % (width * 1.5) - width * 0.25;
+ var cy = ((seed * 17) % 1000) / 1000 * height * 0.4;
+ var cw = 60 + ((seed * 23) % 40);
+
+ for (var j = 0; j < 3; j++) {
+ ctx.beginPath();
+ ctx.arc(cx + j * cw * 0.4, cy, cw * 0.35, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ // Dragon body/neck beneath rider
+ var wingFlap = Math.sin(time * 3) * 0.15;
+
+ // Dragon neck
+ var neckGradient = ctx.createLinearGradient(width * 0.3, height * 0.4, width * 0.7, height * 0.6);
+ neckGradient.addColorStop(0, '#2a5a3a');
+ neckGradient.addColorStop(0.5, '#3a7a4a');
+ neckGradient.addColorStop(1, '#2a5a3a');
+ ctx.fillStyle = neckGradient;
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.35, height * 0.5);
+ ctx.quadraticCurveTo(width * 0.5, height * 0.45, width * 0.65, height * 0.5);
+ ctx.lineTo(width * 0.6, height * 0.65);
+ ctx.quadraticCurveTo(width * 0.5, height * 0.6, width * 0.4, height * 0.65);
+ ctx.closePath();
+ ctx.fill();
+
+ // Dragon scales on neck
+ ctx.fillStyle = '#1a4a2a';
+ for (var k = 0; k < 5; k++) {
+ var scaleX = width * 0.42 + k * width * 0.04;
+ var scaleY = height * 0.52 + Math.sin(k * 0.5) * 5;
+ ctx.beginPath();
+ ctx.arc(scaleX, scaleY, 6, 0, Math.PI, true);
+ ctx.fill();
+ }
+
+ // Dragon wings (behind rider)
+ ctx.fillStyle = '#2a6a3a';
+
+ // Left wing
+ ctx.save();
+ ctx.translate(width * 0.3, height * 0.5);
+ ctx.rotate(-0.5 + wingFlap);
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.quadraticCurveTo(-width * 0.3, -height * 0.1, -width * 0.4, height * 0.1);
+ ctx.quadraticCurveTo(-width * 0.2, height * 0.05, 0, height * 0.1);
+ ctx.closePath();
+ ctx.fill();
+
+ // Wing membrane lines
+ ctx.strokeStyle = '#1a5a2a';
+ ctx.lineWidth = 2;
+ for (var l = 0; l < 4; l++) {
+ ctx.beginPath();
+ ctx.moveTo(0, height * 0.02 * l);
+ ctx.quadraticCurveTo(-width * 0.2, 0, -width * 0.3 + l * 20, height * 0.08);
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ // Right wing
+ ctx.save();
+ ctx.translate(width * 0.7, height * 0.5);
+ ctx.rotate(0.5 - wingFlap);
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.quadraticCurveTo(width * 0.3, -height * 0.1, width * 0.4, height * 0.1);
+ ctx.quadraticCurveTo(width * 0.2, height * 0.05, 0, height * 0.1);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.strokeStyle = '#1a5a2a';
+ for (var m = 0; m < 4; m++) {
+ ctx.beginPath();
+ ctx.moveTo(0, height * 0.02 * m);
+ ctx.quadraticCurveTo(width * 0.2, 0, width * 0.3 - m * 20, height * 0.08);
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ // Saddle
+ ctx.fillStyle = '#5a3a2a';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, height * 0.48, width * 0.1, height * 0.04, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = '#7a4a3a';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, height * 0.46, width * 0.08, height * 0.025, 0, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Dragon head/horns framing view (top)
+ var headBob = Math.sin(time * 2) * 3;
+
+ // Dragon horns
+ ctx.fillStyle = '#4a3a2a';
+
+ // Left horn
+ ctx.beginPath();
+ ctx.moveTo(width * 0.15, height * 0.15 + headBob);
+ ctx.quadraticCurveTo(width * 0.1, 0, width * 0.2, -height * 0.05);
+ ctx.quadraticCurveTo(width * 0.18, height * 0.05, width * 0.22, height * 0.12 + headBob);
+ ctx.closePath();
+ ctx.fill();
+
+ // Right horn
+ ctx.beginPath();
+ ctx.moveTo(width * 0.85, height * 0.15 + headBob);
+ ctx.quadraticCurveTo(width * 0.9, 0, width * 0.8, -height * 0.05);
+ ctx.quadraticCurveTo(width * 0.82, height * 0.05, width * 0.78, height * 0.12 + headBob);
+ ctx.closePath();
+ ctx.fill();
+
+ // Dragon ears/spines
+ ctx.fillStyle = '#3a6a4a';
+ ctx.beginPath();
+ ctx.moveTo(width * 0.05, height * 0.3 + headBob);
+ ctx.lineTo(0, height * 0.15);
+ ctx.lineTo(width * 0.12, height * 0.25 + headBob);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.95, height * 0.3 + headBob);
+ ctx.lineTo(width, height * 0.15);
+ ctx.lineTo(width * 0.88, height * 0.25 + headBob);
+ ctx.closePath();
+ ctx.fill();
+
+ // Dragon body below (tail hint)
+ ctx.fillStyle = '#2a5a3a';
+ ctx.beginPath();
+ ctx.moveTo(width * 0.3, height * 0.85);
+ ctx.quadraticCurveTo(width * 0.5, height * 0.75, width * 0.7, height * 0.85);
+ ctx.lineTo(width * 0.65, height);
+ ctx.lineTo(width * 0.35, height);
+ ctx.closePath();
+ ctx.fill();
+
+ // Dragon legs/claws
+ ctx.fillStyle = '#2a5a3a';
+ ctx.beginPath();
+ ctx.moveTo(width * 0.2, height * 0.7);
+ ctx.quadraticCurveTo(width * 0.15, height * 0.85, width * 0.1, height);
+ ctx.lineTo(width * 0.25, height);
+ ctx.quadraticCurveTo(width * 0.25, height * 0.8, width * 0.28, height * 0.7);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.8, height * 0.7);
+ ctx.quadraticCurveTo(width * 0.85, height * 0.85, width * 0.9, height);
+ ctx.lineTo(width * 0.75, height);
+ ctx.quadraticCurveTo(width * 0.75, height * 0.8, width * 0.72, height * 0.7);
+ ctx.closePath();
+ ctx.fill();
+
+ // Claws
+ ctx.fillStyle = '#3a3a3a';
+ for (var i = 0; i < 3; i++) {
+ ctx.beginPath();
+ ctx.moveTo(width * 0.08 + i * 8, height);
+ ctx.lineTo(width * 0.1 + i * 8, height * 0.95);
+ ctx.lineTo(width * 0.12 + i * 8, height);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(width * 0.88 - i * 8, height);
+ ctx.lineTo(width * 0.9 - i * 8, height * 0.95);
+ ctx.lineTo(width * 0.92 - i * 8, height);
+ ctx.closePath();
+ ctx.fill();
+ }
+ },
+
+ contextEffects: {
+ idle: { type: 'bob', amplitude: 5, rate: 2 },
+ fireBreath: { type: 'glow', color: '#ff6600' },
+ dive: { type: 'blur', direction: 'vertical' },
+ damage: { type: 'shake', intensity: 8 }
+ }
+ },
+
+ // ============================================================
+ // 12. MOTORCYCLE
+ // ============================================================
+ {
+ id: 'motorcycle',
+ name: 'Motorcycle',
+ category: 'vehicle',
+ avatarMode: 'FULL_BODY',
+ animations: ['idle', 'bob', 'celebrate', 'hurt', 'lean-left', 'lean-right'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['racing', 'endless', 'arcade', 'action'],
+ description: 'Race on a high-speed motorcycle',
+
+ avatarPosition: { x: 0.5, y: 0.3 },
+ avatarScale: 0.55,
+
+ renderBackground: function(ctx, width, height, time, options) {
+ // Sky
+ var skyGradient = ctx.createLinearGradient(0, 0, 0, height * 0.4);
+ skyGradient.addColorStop(0, '#ff7700');
+ skyGradient.addColorStop(1, '#ffaa44');
+ ctx.fillStyle = skyGradient;
+ ctx.fillRect(0, 0, width, height * 0.4);
+
+ // Road
+ var roadGradient = ctx.createLinearGradient(0, height * 0.4, 0, height);
+ roadGradient.addColorStop(0, '#444444');
+ roadGradient.addColorStop(1, '#222222');
+ ctx.fillStyle = roadGradient;
+ ctx.fillRect(0, height * 0.4, width, height * 0.6);
+
+ // Road lines
+ ctx.fillStyle = '#ffffff';
+ var lineOffset = (time * 400) % 100;
+ for (var i = -1; i < 6; i++) {
+ var y = height * 0.5 + i * 100 - lineOffset;
+ var perspectiveScale = (y - height * 0.4) / (height * 0.6);
+ var lineWidth = 4 + perspectiveScale * 10;
+ ctx.fillRect(width * 0.48, y, width * 0.04, 50);
+ }
+
+ // Environment blur (side)
+ ctx.fillStyle = 'rgba(100, 150, 100, 0.3)';
+ for (var j = 0; j < 8; j++) {
+ var blurY = height * 0.3 + j * 40;
+ var blurOffset = (time * 300 + j * 50) % 200;
+ ctx.fillRect(0, blurY, width * 0.1 - blurOffset * 0.05, 30);
+ ctx.fillRect(width * 0.9 + blurOffset * 0.05, blurY, width * 0.1, 30);
+ }
+
+ // Motion blur lines
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
+ ctx.lineWidth = 2;
+ for (var k = 0; k < 15; k++) {
+ var seed = k * 1931;
+ var lx = ((seed * 13) % 1000) / 1000 * width;
+ var ly = height * 0.45 + ((seed * 17) % 1000) / 1000 * height * 0.5;
+ ctx.beginPath();
+ ctx.moveTo(lx, ly);
+ ctx.lineTo(lx, ly + 40);
+ ctx.stroke();
+ }
+ },
+
+ renderForeground: function(ctx, width, height, time, options) {
+ // Motorcycle
+ var bikeY = height * 0.55;
+ var wheelSpin = time * 20;
+
+ // Bike body
+ var bodyGradient = ctx.createLinearGradient(width * 0.3, bikeY, width * 0.7, bikeY);
+ bodyGradient.addColorStop(0, '#cc0000');
+ bodyGradient.addColorStop(0.5, '#ff3333');
+ bodyGradient.addColorStop(1, '#cc0000');
+ ctx.fillStyle = bodyGradient;
+
+ // Main body shape
+ ctx.beginPath();
+ ctx.moveTo(width * 0.25, bikeY);
+ ctx.quadraticCurveTo(width * 0.35, bikeY - height * 0.08, width * 0.5, bikeY - height * 0.1);
+ ctx.quadraticCurveTo(width * 0.65, bikeY - height * 0.08, width * 0.75, bikeY);
+ ctx.lineTo(width * 0.7, bikeY + height * 0.05);
+ ctx.lineTo(width * 0.3, bikeY + height * 0.05);
+ ctx.closePath();
+ ctx.fill();
+
+ // Fuel tank
+ ctx.fillStyle = '#aa0000';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.5, bikeY - height * 0.05, width * 0.12, height * 0.04, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Tank stripe
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(width * 0.48, bikeY - height * 0.08, width * 0.04, height * 0.06);
+
+ // Engine
+ ctx.fillStyle = '#333333';
+ ctx.beginPath();
+ ctx.rect(width * 0.4, bikeY + height * 0.02, width * 0.2, height * 0.1);
+ ctx.fill();
+
+ // Engine details
+ ctx.fillStyle = '#555555';
+ for (var i = 0; i < 4; i++) {
+ ctx.fillRect(width * 0.42 + i * width * 0.04, bikeY + height * 0.03, width * 0.02, height * 0.08);
+ }
+
+ // Exhaust pipes
+ ctx.fillStyle = '#666666';
+ ctx.beginPath();
+ ctx.moveTo(width * 0.6, bikeY + height * 0.08);
+ ctx.quadraticCurveTo(width * 0.75, bikeY + height * 0.1, width * 0.85, bikeY + height * 0.15);
+ ctx.lineTo(width * 0.85, bikeY + height * 0.18);
+ ctx.quadraticCurveTo(width * 0.75, bikeY + height * 0.13, width * 0.6, bikeY + height * 0.11);
+ ctx.closePath();
+ ctx.fill();
+
+ // Front wheel
+ var frontWheelX = width * 0.25;
+ var wheelY = bikeY + height * 0.15;
+ var wheelRadius = width * 0.1;
+
+ ctx.fillStyle = '#1a1a1a';
+ ctx.beginPath();
+ ctx.arc(frontWheelX, wheelY, wheelRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Wheel spokes
+ ctx.strokeStyle = '#444444';
+ ctx.lineWidth = 2;
+ for (var j = 0; j < 8; j++) {
+ var spokeAngle = wheelSpin + (j / 8) * Math.PI * 2;
+ ctx.beginPath();
+ ctx.moveTo(frontWheelX, wheelY);
+ ctx.lineTo(
+ frontWheelX + Math.cos(spokeAngle) * wheelRadius * 0.8,
+ wheelY + Math.sin(spokeAngle) * wheelRadius * 0.8
+ );
+ ctx.stroke();
+ }
+
+ // Wheel hub
+ ctx.fillStyle = '#888888';
+ ctx.beginPath();
+ ctx.arc(frontWheelX, wheelY, wheelRadius * 0.25, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Rear wheel
+ var rearWheelX = width * 0.75;
+
+ ctx.fillStyle = '#1a1a1a';
+ ctx.beginPath();
+ ctx.arc(rearWheelX, wheelY, wheelRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#444444';
+ for (var k = 0; k < 8; k++) {
+ var rearSpokeAngle = wheelSpin + (k / 8) * Math.PI * 2;
+ ctx.beginPath();
+ ctx.moveTo(rearWheelX, wheelY);
+ ctx.lineTo(
+ rearWheelX + Math.cos(rearSpokeAngle) * wheelRadius * 0.8,
+ wheelY + Math.sin(rearSpokeAngle) * wheelRadius * 0.8
+ );
+ ctx.stroke();
+ }
+
+ ctx.fillStyle = '#888888';
+ ctx.beginPath();
+ ctx.arc(rearWheelX, wheelY, wheelRadius * 0.25, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Front fork
+ ctx.strokeStyle = '#666666';
+ ctx.lineWidth = 6;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.32, bikeY - height * 0.05);
+ ctx.lineTo(frontWheelX, wheelY);
+ ctx.stroke();
+
+ // Handlebars
+ ctx.strokeStyle = '#444444';
+ ctx.lineWidth = 4;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.25, bikeY - height * 0.12);
+ ctx.lineTo(width * 0.4, bikeY - height * 0.1);
+ ctx.stroke();
+
+ // Mirrors
+ ctx.fillStyle = '#333333';
+ ctx.beginPath();
+ ctx.ellipse(width * 0.22, bikeY - height * 0.14, width * 0.02, height * 0.015, -0.3, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Headlight
+ ctx.fillStyle = '#ffff88';
+ ctx.beginPath();
+ ctx.arc(width * 0.2, bikeY - height * 0.02, width * 0.025, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Seat
+ ctx.fillStyle = '#1a1a1a';
+ ctx.beginPath();
+ ctx.moveTo(width * 0.42, bikeY - height * 0.1);
+ ctx.quadraticCurveTo(width * 0.55, bikeY - height * 0.14, width * 0.68, bikeY - height * 0.08);
+ ctx.lineTo(width * 0.65, bikeY - height * 0.06);
+ ctx.quadraticCurveTo(width * 0.55, bikeY - height * 0.1, width * 0.45, bikeY - height * 0.08);
+ ctx.closePath();
+ ctx.fill();
+
+ // Speed blur from wheels
+ ctx.fillStyle = 'rgba(150, 150, 150, 0.3)';
+ ctx.beginPath();
+ ctx.ellipse(frontWheelX, wheelY + wheelRadius * 0.8, wheelRadius * 0.8, wheelRadius * 0.15, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(rearWheelX, wheelY + wheelRadius * 0.8, wheelRadius * 0.8, wheelRadius * 0.15, 0, 0, Math.PI * 2);
+ ctx.fill();
+ },
+
+ contextEffects: {
+ idle: null,
+ accelerate: { type: 'blur', direction: 'vertical' },
+ wheelie: { type: 'rotate', angle: -15 },
+ lean: { type: 'rotate', angle: 20 },
+ damage: { type: 'shake', intensity: 8 }
+ }
+ }
+
+ ];
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ window.VehicleContexts = {
+ CONTEXTS: CONTEXTS,
+
+ /**
+ * Get a vehicle context by its ID
+ * @param {string} id - The context ID
+ * @returns {Object|null} The context object or null if not found
+ */
+ getById: function(id) {
+ for (var i = 0; i < CONTEXTS.length; i++) {
+ if (CONTEXTS[i].id === id) {
+ return CONTEXTS[i];
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Get all vehicle contexts
+ * @returns {Array} Array of all context objects
+ */
+ getAll: function() {
+ return CONTEXTS.slice();
+ },
+
+ /**
+ * Get contexts filtered by avatar mode
+ * @param {string} mode - The avatar mode (HEAD_ONLY, HEAD_AND_SHOULDERS, UPPER_BODY, FULL_BODY)
+ * @returns {Array} Array of matching context objects
+ */
+ getByMode: function(mode) {
+ var results = [];
+ for (var i = 0; i < CONTEXTS.length; i++) {
+ if (CONTEXTS[i].avatarMode === mode) {
+ results.push(CONTEXTS[i]);
+ }
+ }
+ return results;
+ },
+
+ /**
+ * Get contexts recommended for a specific game type
+ * @param {string} gameType - The game type to match
+ * @returns {Array} Array of matching context objects
+ */
+ getRecommendedFor: function(gameType) {
+ var results = [];
+ var lowerType = gameType.toLowerCase();
+ for (var i = 0; i < CONTEXTS.length; i++) {
+ var recommended = CONTEXTS[i].recommendedFor;
+ for (var j = 0; j < recommended.length; j++) {
+ if (recommended[j].toLowerCase() === lowerType) {
+ results.push(CONTEXTS[i]);
+ break;
+ }
+ }
+ }
+ return results;
+ }
+ };
+
+})();
diff --git a/frontend/public/assets/backgrounds/backgroundCatalog.js b/frontend/public/assets/backgrounds/backgroundCatalog.js
new file mode 100644
index 0000000..6ab9159
--- /dev/null
+++ b/frontend/public/assets/backgrounds/backgroundCatalog.js
@@ -0,0 +1,197 @@
+// backgroundCatalog.js — Metadata catalog for all 120 procedural backgrounds
+// Loaded after all theme files; provides queryable catalog of backgrounds
+// 15 themes × 8 variants = 120 unique backgrounds
+
+(function(engine) {
+ if (!engine) return;
+
+ // ─── Catalog Metadata ──────────────────────────────────────
+ //
+ // The catalog is auto-generated from theme registrations.
+ // Each background has:
+ // id - Unique identifier (theme_variant)
+ // theme - Parent theme name
+ // variant - Variant name within theme
+ // mood - Emotional tone (wonder, tense, serene, energetic, etc.)
+ // colors - Default color palette [4 hex colors]
+ // tags - Descriptive tags for filtering
+ // description - Human-readable description
+ // compatibleMusicMoods - Array of music moods that pair well
+ // recommendedFor - Array of game genres this works for
+
+ // ─── Theme → Variant Listing ───────────────────────────────
+
+ var THEME_VARIANTS = {
+ space: ['nebula', 'asteroidField', 'deepSpace', 'planets', 'spaceStation', 'warpSpeed', 'satellites', 'blackHole'],
+ nature: ['forest', 'jungle', 'desert', 'mountains', 'ocean', 'underwater', 'waterfall', 'meadow'],
+ urban: ['citySkyline', 'street', 'rooftop', 'neonDistrict', 'subway', 'park', 'mall', 'alley'],
+ fantasy: ['castle', 'dungeon', 'enchantedForest', 'floatingIslands', 'magicalLibrary', 'crystalCave', 'temple', 'tower'],
+ abstract: ['geometricPatterns', 'gradients', 'particleFields', 'minimalist', 'waves', 'fractals', 'kaleidoscope', 'matrix'],
+ retro: ['pixelGrid', 'arcadeCabinet', 'crtEffects', 'vaporwave', 'scanlines', 'glitch', 'eightBit', 'synthwave'],
+ indoor: ['laboratory', 'classroom', 'bedroom', 'kitchen', 'arcade', 'office', 'library', 'gym'],
+ weather: ['rain', 'snow', 'storm', 'sunset', 'sunrise', 'fog', 'lightning', 'aurora'],
+ sports: ['stadium', 'raceTrack', 'court', 'field', 'arena', 'pool', 'gym', 'skatepark'],
+ seasonal: ['springMeadow', 'autumnLeaves', 'winterWonderland', 'summerBeach', 'harvest', 'blossom', 'snowfall', 'tropical'],
+ underwater: ['coralReef', 'deepSea', 'kelpForest', 'submarine', 'shipwreck', 'abyss', 'iceCave', 'treasure'],
+ sky: ['clouds', 'flyingHigh', 'hotAirBalloon', 'airplaneView', 'stratosphere', 'birdsEye', 'horizon', 'starfield'],
+ mechanical: ['gears', 'circuits', 'factory', 'robotFactory', 'conveyorBelts', 'pipes', 'steampunk', 'techLab'],
+ spooky: ['hauntedHouse', 'graveyard', 'darkForest', 'abandonedBuilding', 'crypt', 'manor', 'fog', 'shadows'],
+ colorful: ['rainbow', 'paintSplatter', 'candyWorld', 'disco', 'neon', 'tieDye', 'gradient', 'prism'],
+ };
+
+ // ─── Mood Categories ───────────────────────────────────────
+
+ var MOOD_DESCRIPTIONS = {
+ wonder: 'Awe-inspiring and magical',
+ tense: 'Suspenseful and gripping',
+ serene: 'Calm and peaceful',
+ energetic: 'Fast-paced and exciting',
+ focused: 'Clear and concentrated',
+ playful: 'Fun and lighthearted',
+ mysterious:'Dark and intriguing',
+ nostalgic: 'Retro and reminiscent',
+ cozy: 'Warm and comfortable',
+ spooky: 'Eerie and unsettling',
+ epic: 'Grand and dramatic',
+ dreamy: 'Soft and ethereal',
+ vibrant: 'Bright and colorful',
+ industrial:'Mechanical and gritty',
+ tropical: 'Warm and exotic',
+ festive: 'Celebratory and joyful',
+ };
+
+ // ─── Game Genre Mappings ───────────────────────────────────
+
+ var GENRE_TAGS = {
+ action: ['fast', 'intense', 'danger', 'combat'],
+ puzzle: ['calm', 'focused', 'minimal', 'clean'],
+ racing: ['fast', 'streaks', 'motion', 'speed'],
+ rpg: ['epic', 'magical', 'adventure', 'story'],
+ sports: ['stadium', 'field', 'arena', 'competitive'],
+ creative:['colorful', 'playful', 'artistic', 'open'],
+ story: ['atmospheric', 'immersive', 'cinematic'],
+ physics: ['tech', 'mechanical', 'industrial', 'scientific'],
+ trivia: ['clean', 'simple', 'focused', 'bright'],
+ music: ['rhythmic', 'neon', 'disco', 'vibrant'],
+ };
+
+ // ─── Public API Extensions ─────────────────────────────────
+
+ /**
+ * Get all themes with their variant counts
+ */
+ engine.getThemeSummary = function() {
+ var summary = [];
+ for (var theme in THEME_VARIANTS) {
+ summary.push({
+ theme: theme,
+ variants: THEME_VARIANTS[theme],
+ count: THEME_VARIANTS[theme].length
+ });
+ }
+ return summary;
+ };
+
+ /**
+ * Get total background count
+ */
+ engine.getTotalCount = function() {
+ var count = 0;
+ for (var theme in THEME_VARIANTS) {
+ count += THEME_VARIANTS[theme].length;
+ }
+ return count;
+ };
+
+ /**
+ * Get all available moods
+ */
+ engine.getMoods = function() {
+ return Object.keys(MOOD_DESCRIPTIONS);
+ };
+
+ /**
+ * Get mood description
+ */
+ engine.getMoodDescription = function(mood) {
+ return MOOD_DESCRIPTIONS[mood] || null;
+ };
+
+ /**
+ * Find backgrounds by game genre
+ */
+ engine.findByGenre = function(genre) {
+ var tags = GENRE_TAGS[genre] || [];
+ if (!tags.length) return [];
+ return engine.find({ tags: tags });
+ };
+
+ /**
+ * Get a random background from a specific theme
+ */
+ engine.getRandomFromTheme = function(theme) {
+ var variants = THEME_VARIANTS[theme];
+ if (!variants || !variants.length) return null;
+ var variant = variants[Math.floor(Math.random() * variants.length)];
+ return { theme: theme, variant: variant };
+ };
+
+ /**
+ * Get a random background matching filters
+ */
+ engine.getRandomMatching = function(filters) {
+ var matches = engine.find(filters);
+ if (!matches.length) return null;
+ return matches[Math.floor(Math.random() * matches.length)];
+ };
+
+ /**
+ * Find backgrounds compatible with a music mood
+ */
+ engine.findByMusicMood = function(musicMood) {
+ return engine.getCatalog().filter(function(bg) {
+ return bg.compatibleMusicMoods && bg.compatibleMusicMoods.includes(musicMood);
+ });
+ };
+
+ /**
+ * Get backgrounds recommended for a game type
+ */
+ engine.getRecommendedFor = function(gameType) {
+ return engine.getCatalog().filter(function(bg) {
+ return bg.recommendedFor && bg.recommendedFor.includes(gameType);
+ });
+ };
+
+ /**
+ * Preload/validate all registered themes
+ * Returns array of any themes with missing variants
+ */
+ engine.validateCatalog = function() {
+ var issues = [];
+ for (var theme in THEME_VARIANTS) {
+ var variants = THEME_VARIANTS[theme];
+ variants.forEach(function(variant) {
+ var catalog = engine.getCatalog();
+ var found = catalog.find(function(bg) {
+ return bg.theme === theme && bg.variant === variant;
+ });
+ if (!found) {
+ issues.push({ theme: theme, variant: variant, issue: 'missing registration' });
+ }
+ });
+ }
+ return issues;
+ };
+
+ // ─── Catalog Statistics ────────────────────────────────────
+
+ engine.catalogStats = {
+ themes: Object.keys(THEME_VARIANTS).length,
+ variantsPerTheme: 8,
+ totalBackgrounds: 120,
+ version: '1.0.0',
+ lastUpdated: '2026-02-05'
+ };
+
+})(window.BackgroundEngine);
diff --git a/frontend/public/assets/backgrounds/backgroundEngine.js b/frontend/public/assets/backgrounds/backgroundEngine.js
new file mode 100644
index 0000000..bd97c5c
--- /dev/null
+++ b/frontend/public/assets/backgrounds/backgroundEngine.js
@@ -0,0 +1,745 @@
+// backgroundEngine.js — Procedural background rendering engine
+// Loaded by HTML5 games via
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/clear-cache.html b/frontend/public/clear-cache.html
new file mode 100644
index 0000000..312e6d5
--- /dev/null
+++ b/frontend/public/clear-cache.html
@@ -0,0 +1,210 @@
+
+
+
+
+
+ Clear Cache - GamerComp
+
+
+
+
+
🔄 Clear Cache
+
Having issues seeing updates? Clear all cached data for GamerComp.
+
+
Clear All Caches
+
Force Hard Reload
+
+
+
+
+
🔧 Manual Hard Refresh
+
If the buttons above don't work, try these keyboard shortcuts:
+
+ Windows/Linux:
+ Ctrl + Shift + R or Ctrl + F5
+
+
+ Mac:
+ Cmd + Shift + R
+
+
+
+
+
+
+ ← Back to GamerComp
+
+
+
+
+
+
diff --git a/frontend/public/docs/ADDING_NEW_ASSETS.md b/frontend/public/docs/ADDING_NEW_ASSETS.md
new file mode 100644
index 0000000..2c9e8f6
--- /dev/null
+++ b/frontend/public/docs/ADDING_NEW_ASSETS.md
@@ -0,0 +1,266 @@
+# Adding New Assets
+
+Guide for extending the GamerComp asset library.
+
+## Adding New Music
+
+### 1. Define Song in musicLibrary.js
+
+```javascript
+{
+ id: 'epic-21',
+ name: 'Dawn of Heroes',
+ mood: 'Epic',
+ energy: 8,
+ tempo: 130,
+ key: 'G',
+ scale: 'minor',
+ tags: ['battle', 'adventure', 'cinematic'],
+ desc: 'Triumphant orchestral piece for heroic moments',
+
+ // Instrument configuration
+ synths: {
+ melody: { type: 'AMSynth', options: { harmonicity: 2 } },
+ bass: { type: 'FMSynth', options: { modulationIndex: 10 } },
+ pad: { type: 'PolySynth', options: { voices: 4 } }
+ },
+
+ // Effects chain
+ fx: {
+ reverb: { wet: 0.4, decay: 2 },
+ delay: { wet: 0.2, time: '8n' }
+ },
+
+ // Song sections with chord progressions
+ sections: {
+ intro: {
+ chords: ['i', 'VI', 'III', 'VII'],
+ duration: 8,
+ instruments: ['pad']
+ },
+ verse: {
+ chords: ['i', 'iv', 'VI', 'V'],
+ duration: 16,
+ instruments: ['melody', 'bass', 'pad']
+ },
+ chorus: {
+ chords: ['VI', 'VII', 'i', 'i'],
+ duration: 16,
+ instruments: ['melody', 'bass', 'pad', 'drums']
+ }
+ },
+
+ // Section order
+ form: ['intro', 'verse', 'chorus', 'verse', 'chorus', 'chorus']
+}
+```
+
+### 2. Update Catalog Stats
+
+In `musicLibrary.js`, increment the count.
+
+### 3. Test
+
+```javascript
+MusicEngine.playSong('epic-21');
+```
+
+---
+
+## Adding New Background Theme
+
+### 1. Create Theme File
+
+Create `themes/newtheme.js`:
+
+```javascript
+(function(engine) {
+ if (!engine) return;
+
+ engine.registerTheme('newtheme', {
+ name: 'New Theme',
+ description: 'Description of theme',
+
+ variants: {
+ variant1: {
+ meta: {
+ name: 'Variant One',
+ mood: 'wonder',
+ tags: ['tag1', 'tag2'],
+ description: 'Description',
+ compatibleMusicMoods: ['Epic', 'Heroic'],
+ recommendedFor: ['action', 'adventure']
+ },
+ colors: ['#000000', '#111111', '#222222', '#333333'],
+ render: function(ctx, w, h, time, colors, opts) {
+ // Background layer
+ var gradient = ctx.createLinearGradient(0, 0, 0, h);
+ gradient.addColorStop(0, colors[0]);
+ gradient.addColorStop(1, colors[1]);
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, w, h);
+
+ // Animated elements
+ // Use 'time' for animation (seconds)
+ }
+ },
+
+ // Add 7 more variants for 8 total
+ }
+ });
+
+})(window.BackgroundEngine);
+```
+
+### 2. Add Script Include
+
+In templates, add:
+```html
+
+```
+
+### 3. Update Catalog
+
+Add to `THEME_VARIANTS` in `backgroundCatalog.js`.
+
+### 4. Test
+
+```javascript
+BackgroundEngine.render(ctx, 'newtheme', 'variant1', w, h, 0);
+```
+
+---
+
+## Adding New Avatar Accessory
+
+### 1. Add to accessories.js
+
+```javascript
+// In EYEWEAR, HEADWEAR, or EFFECTS array:
+{
+ id: 'new-item',
+ name: 'New Item',
+ category: 'headwear',
+ unlockLevel: 50,
+ rarity: 'epic',
+ zIndex: 10,
+ anchor: 'top',
+ offset: { x: 0, y: -15 },
+ scale: 1.0,
+ hasEffect: false,
+ description: 'Description of item'
+}
+```
+
+### 2. Add Renderer in accessoryRenderer.js
+
+```javascript
+// In appropriate RENDERERS object:
+'new-item': function(ctx, x, y, scale, color, time) {
+ // Draw the accessory
+ ctx.fillStyle = color || '#gold';
+ ctx.beginPath();
+ // ... drawing code
+ ctx.fill();
+}
+```
+
+### 3. Test
+
+```javascript
+AvatarSystem.equipAccessory(avatar, 'headwear', 'new-item');
+```
+
+---
+
+## Adding New Avatar Context
+
+### 1. Add to Appropriate File
+
+For vehicles, add to `contexts/vehicles.js`:
+
+```javascript
+{
+ id: 'new-vehicle',
+ name: 'New Vehicle',
+ category: 'vehicle',
+ avatarMode: 'HEAD_AND_SHOULDERS',
+ animations: ['idle', 'hurt', 'celebrate'],
+ defaultAnimation: 'idle',
+ accessoryVisibility: { eyewear: true, headwear: true, effects: true },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['action', 'racing'],
+ description: 'Description',
+
+ avatarPosition: { x: 0.5, y: 0.4 },
+ avatarScale: 1.0,
+
+ renderBackground: function(ctx, w, h, time, state) {
+ // Draw background
+ },
+
+ renderForeground: function(ctx, w, h, time, state) {
+ // Draw foreground overlay
+ },
+
+ contextEffects: {
+ hurt: { type: 'shake', intensity: 5 }
+ }
+}
+```
+
+### 2. Update Catalog Mappings
+
+In `contextCatalog.js`:
+- Add to `GAME_TYPE_CONTEXTS`
+- Will auto-aggregate via `getAll()`
+
+### 3. Test
+
+```javascript
+const awc = ContextRenderer.apply(avatar, 'new-vehicle');
+ContextRenderer.render(awc, ctx, x, y);
+```
+
+---
+
+## Adding New Preset
+
+### 1. Add to presets.js
+
+```javascript
+{
+ id: 'style-theme-##',
+ gameStyle: 'action-arcade',
+ name: 'Preset Name',
+ description: 'What this combination provides',
+ music: { mood: 'Epic', energy: 8 },
+ background: { theme: 'space', variant: 'nebula' },
+ avatarContext: { mode: 'HEAD_ONLY', context: 'spaceship-cockpit' },
+ colorPalette: 'space-blue',
+ tags: ['tag1', 'tag2']
+}
+```
+
+### 2. Validate
+
+```javascript
+const result = AssetCompatibility.validate(newPreset);
+console.log(result.score); // Should be > 70
+```
+
+---
+
+## Testing Guidelines
+
+1. **Visual Testing**: Use asset-browser.html to preview
+2. **Compatibility**: Run `AssetCompatibility.validate()`
+3. **Performance**: Test animation at 60fps
+4. **Mobile**: Test touch interactions
+5. **Integration**: Test in actual game template
+
+## File Size Guidelines
+
+- Keep individual JS files under 100KB
+- Music library can be larger (songs are data)
+- Optimize render functions (no complex math in loops)
diff --git a/frontend/public/docs/API_REFERENCE.md b/frontend/public/docs/API_REFERENCE.md
new file mode 100644
index 0000000..41476ea
--- /dev/null
+++ b/frontend/public/docs/API_REFERENCE.md
@@ -0,0 +1,2754 @@
+# API Reference
+
+Complete API reference for all asset systems in the Game Arcade platform.
+
+## Table of Contents
+1. [AssetManager](#assetmanager)
+2. [MusicEngine](#musicengine)
+3. [BackgroundEngine](#backgroundengine)
+4. [AvatarSystem](#avatarsystem)
+5. [ContextRenderer](#contextrenderer)
+6. [ContextCatalog](#contextcatalog)
+7. [AssetCatalog](#assetcatalog)
+8. [AssetPresets](#assetpresets)
+9. [AssetCompatibility](#assetcompatibility)
+10. [Common Patterns](#common-patterns)
+11. [Troubleshooting](#troubleshooting)
+
+---
+
+## AssetManager
+
+Main coordinator for all asset systems. Provides unified access to music, backgrounds, avatars, and contexts.
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `initialized` | boolean | Whether the manager has been initialized |
+| `stats` | Object | Statistics about loaded assets |
+
+### Methods
+
+#### initialize()
+```javascript
+AssetManager.initialize(options) → Promise<{stats}>
+```
+Initialize all asset systems. Call once at startup before using any other asset methods.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `options` | Object | No | Configuration options |
+| `options.preloadMusic` | boolean | No | Preload all music (default: false) |
+| `options.preloadBackgrounds` | boolean | No | Preload background assets (default: true) |
+| `options.cacheAvatars` | boolean | No | Enable avatar caching (default: true) |
+| `options.maxCacheSize` | number | No | Maximum cache entries (default: 100) |
+
+**Returns:** `Promise` - Stats object with loaded asset counts
+
+**Example:**
+```javascript
+const stats = await AssetManager.initialize({
+ preloadMusic: false,
+ preloadBackgrounds: true
+});
+console.log(stats.music.songs); // 160
+console.log(stats.backgrounds.themes); // 12
+console.log(stats.avatars.parts); // 340
+console.log(stats.contexts.total); // 44
+```
+
+---
+
+#### getRecommendations()
+```javascript
+AssetManager.getRecommendations(gameStyle, keywords, mood) → Array
+```
+Get asset recommendations based on game description. Returns the top 3 matching asset combinations.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `gameStyle` | string | Yes | One of the 11 game styles |
+| `keywords` | string\|Array | Yes | Description text or keyword array |
+| `mood` | string | No | Override auto-detected mood |
+
+**Valid Game Styles:**
+- `action-arcade`
+- `puzzle-logic`
+- `adventure-story`
+- `educational`
+- `simulation`
+- `sports-racing`
+- `strategy`
+- `rhythm-music`
+- `horror-survival`
+- `casual-relaxing`
+- `multiplayer-party`
+
+**Valid Moods:**
+- `Epic`
+- `Chill`
+- `Dark`
+- `Upbeat`
+- `Mysterious`
+- `Retro`
+- `Dramatic`
+- `Peaceful`
+
+**Returns:** `Array` - Top 3 recommendations
+
+**Recommendation Object:**
+```javascript
+{
+ config: {
+ music: { mood: string, songId: string },
+ background: { theme: string, variant: string },
+ context: { id: string, variant: string }
+ },
+ score: number, // 0-100 match score
+ explanation: string // Human-readable explanation
+}
+```
+
+**Example:**
+```javascript
+const recs = AssetManager.getRecommendations(
+ 'action-arcade',
+ 'space shooter aliens lasers explosions',
+ 'Epic'
+);
+
+console.log(recs[0]);
+// {
+// config: {
+// music: { mood: 'Epic', songId: 'epic-battle-04' },
+// background: { theme: 'space', variant: 'nebula' },
+// context: { id: 'shooter-standard', variant: 'default' }
+// },
+// score: 94,
+// explanation: 'Space theme with epic music matches action shooter keywords'
+// }
+```
+
+---
+
+#### findPreset()
+```javascript
+AssetManager.findPreset(name) → Preset | null
+```
+Find a named preset by exact name or partial match.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `name` | string | Yes | Preset name (case-insensitive) |
+
+**Returns:** `Preset | null` - Matching preset or null if not found
+
+**Preset Object:**
+```javascript
+{
+ id: string,
+ name: string,
+ description: string,
+ config: {
+ music: { mood: string, songId?: string },
+ background: { theme: string, variant: string, effects?: Array },
+ context: { id: string, variant?: string }
+ },
+ tags: Array,
+ gameStyles: Array
+}
+```
+
+**Example:**
+```javascript
+const preset = AssetManager.findPreset('retro-platformer');
+if (preset) {
+ await AssetManager.loadGameAssets(preset.config);
+}
+```
+
+---
+
+#### loadGameAssets()
+```javascript
+AssetManager.loadGameAssets(config) → Promise
+```
+Load and prepare all assets for a game based on configuration.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `config` | Object | Yes | Asset configuration object |
+| `config.music` | Object | No | Music configuration |
+| `config.music.mood` | string | No | Music mood category |
+| `config.music.songId` | string | No | Specific song ID |
+| `config.background` | Object | No | Background configuration |
+| `config.background.theme` | string | Yes | Background theme |
+| `config.background.variant` | string | Yes | Theme variant |
+| `config.background.effects` | Array | No | Visual effects to apply |
+| `config.context` | Object | No | Context configuration |
+| `config.context.id` | string | Yes | Context ID |
+| `config.context.variant` | string | No | Context variant |
+
+**Returns:** `Promise` - Object containing loaded asset references
+
+**LoadedAssets Object:**
+```javascript
+{
+ music: {
+ song: Song,
+ ready: boolean
+ },
+ background: {
+ theme: Theme,
+ variant: Variant,
+ renderFn: Function
+ },
+ context: {
+ context: Context,
+ sprites: Map
+ }
+}
+```
+
+**Example:**
+```javascript
+const assets = await AssetManager.loadGameAssets({
+ music: { mood: 'Upbeat' },
+ background: { theme: 'nature', variant: 'forest' },
+ context: { id: 'platformer-standard' }
+});
+
+// Start music
+MusicEngine.playSong(assets.music.song.id);
+
+// Use in game loop
+function render(time) {
+ assets.background.renderFn(ctx, width, height, time);
+}
+```
+
+---
+
+#### validateCombination()
+```javascript
+AssetManager.validateCombination(config) → ValidationResult
+```
+Validate that an asset combination is compatible and complete.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `config` | Object | Yes | Configuration to validate |
+
+**Returns:** `ValidationResult`
+```javascript
+{
+ valid: boolean,
+ errors: Array,
+ warnings: Array,
+ suggestions: Array
+}
+```
+
+**Example:**
+```javascript
+const result = AssetManager.validateCombination({
+ music: { mood: 'Epic' },
+ background: { theme: 'nature', variant: 'peaceful-meadow' },
+ context: { id: 'shooter-standard' }
+});
+
+console.log(result);
+// {
+// valid: true,
+// errors: [],
+// warnings: ['Epic music may not match peaceful background'],
+// suggestions: ['Consider using "forest" variant for better mood match']
+// }
+```
+
+---
+
+#### describeAssets()
+```javascript
+AssetManager.describeAssets(config) → AssetDescription
+```
+Get human-readable descriptions of an asset configuration.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `config` | Object | Yes | Asset configuration |
+
+**Returns:** `AssetDescription`
+```javascript
+{
+ summary: string,
+ music: {
+ name: string,
+ description: string,
+ mood: string,
+ tempo: string
+ },
+ background: {
+ name: string,
+ description: string,
+ colors: Array
+ },
+ context: {
+ name: string,
+ description: string,
+ animations: Array
+ }
+}
+```
+
+**Example:**
+```javascript
+const desc = AssetManager.describeAssets({
+ music: { songId: 'epic-battle-04' },
+ background: { theme: 'space', variant: 'nebula' },
+ context: { id: 'shooter-standard' }
+});
+
+console.log(desc.summary);
+// "Epic orchestral music with a colorful space nebula background,
+// featuring a shooter-style player with laser animations"
+```
+
+---
+
+#### getVariation()
+```javascript
+AssetManager.getVariation(config, variationType) → Object
+```
+Get a variation of the current configuration.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `config` | Object | Yes | Current configuration |
+| `variationType` | string | No | Type of variation (default: 'similar') |
+
+**Variation Types:**
+- `similar` - Same mood/theme, different specific assets
+- `contrast` - Opposite mood, complementary visuals
+- `random` - Random compatible combination
+- `seasonal` - Seasonal variation if available
+
+**Returns:** `Object` - New configuration object
+
+**Example:**
+```javascript
+const original = {
+ music: { mood: 'Epic' },
+ background: { theme: 'castle', variant: 'throne-room' }
+};
+
+const variation = AssetManager.getVariation(original, 'similar');
+// Returns different Epic song, different castle variant
+```
+
+---
+
+#### searchAssets()
+```javascript
+AssetManager.searchAssets(query, options) → SearchResults
+```
+Search across all asset types.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `query` | string | Yes | Search query |
+| `options` | Object | No | Search options |
+| `options.types` | Array | No | Asset types to search (default: all) |
+| `options.limit` | number | No | Maximum results per type (default: 10) |
+| `options.includeMetadata` | boolean | No | Include full metadata (default: false) |
+
+**Valid Types:** `['music', 'background', 'context', 'preset']`
+
+**Returns:** `SearchResults`
+```javascript
+{
+ music: Array,
+ backgrounds: Array<{theme, variant}>,
+ contexts: Array,
+ presets: Array,
+ totalCount: number
+}
+```
+
+**Example:**
+```javascript
+const results = AssetManager.searchAssets('forest', {
+ types: ['background', 'music'],
+ limit: 5
+});
+
+console.log(results.backgrounds);
+// [{ theme: 'nature', variant: 'forest' },
+// { theme: 'nature', variant: 'enchanted-forest' }]
+```
+
+---
+
+#### getRandomCombination()
+```javascript
+AssetManager.getRandomCombination(constraints) → Object
+```
+Generate a random compatible asset combination.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `constraints` | Object | No | Constraints for random selection |
+| `constraints.mood` | string | No | Required mood |
+| `constraints.gameStyle` | string | No | Required game style compatibility |
+| `constraints.excludeThemes` | Array | No | Themes to exclude |
+| `constraints.excludeContexts` | Array | No | Contexts to exclude |
+
+**Returns:** `Object` - Random configuration
+
+**Example:**
+```javascript
+const random = AssetManager.getRandomCombination({
+ mood: 'Upbeat',
+ gameStyle: 'casual-relaxing',
+ excludeThemes: ['horror', 'space']
+});
+```
+
+---
+
+## MusicEngine
+
+Procedural music synthesis using Tone.js. Generates and plays dynamic music in various moods and styles.
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `isPlaying` | boolean | Whether music is currently playing |
+| `currentSong` | Song\|null | Currently playing song |
+| `volume` | number | Current volume (0-1) |
+| `muted` | boolean | Whether audio is muted |
+
+### Methods
+
+#### initialize()
+```javascript
+MusicEngine.initialize() → Promise
+```
+Initialize the music engine. Called automatically by AssetManager.
+
+**Example:**
+```javascript
+await MusicEngine.initialize();
+```
+
+---
+
+#### playRandomSong()
+```javascript
+MusicEngine.playRandomSong(mood) → string
+```
+Play a random song from the specified mood category.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `mood` | string | Yes | Mood category |
+
+**Valid Moods:** `Epic`, `Chill`, `Dark`, `Upbeat`, `Mysterious`, `Retro`, `Dramatic`, `Peaceful`
+
+**Returns:** `string` - ID of the song that started playing
+
+**Example:**
+```javascript
+const songId = MusicEngine.playRandomSong('Epic');
+console.log(`Now playing: ${songId}`);
+```
+
+---
+
+#### playSong()
+```javascript
+MusicEngine.playSong(songId) → boolean
+```
+Play a specific song by its ID.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `songId` | string | Yes | Unique song identifier |
+
+**Returns:** `boolean` - True if song started playing, false if not found
+
+**Example:**
+```javascript
+if (MusicEngine.playSong('epic-battle-04')) {
+ console.log('Song started');
+} else {
+ console.log('Song not found');
+}
+```
+
+---
+
+#### stopMusic()
+```javascript
+MusicEngine.stopMusic() → void
+```
+Stop the currently playing music immediately.
+
+**Example:**
+```javascript
+MusicEngine.stopMusic();
+```
+
+---
+
+#### pauseMusic()
+```javascript
+MusicEngine.pauseMusic() → void
+```
+Pause the currently playing music. Can be resumed with `resumeMusic()`.
+
+**Example:**
+```javascript
+MusicEngine.pauseMusic();
+```
+
+---
+
+#### resumeMusic()
+```javascript
+MusicEngine.resumeMusic() → void
+```
+Resume paused music.
+
+**Example:**
+```javascript
+MusicEngine.resumeMusic();
+```
+
+---
+
+#### setVolume()
+```javascript
+MusicEngine.setVolume(level) → void
+```
+Set the music volume.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `level` | number | Yes | Volume level (0-1) |
+
+**Example:**
+```javascript
+MusicEngine.setVolume(0.7); // 70% volume
+```
+
+---
+
+#### getVolume()
+```javascript
+MusicEngine.getVolume() → number
+```
+Get the current volume level.
+
+**Returns:** `number` - Current volume (0-1)
+
+---
+
+#### mute()
+```javascript
+MusicEngine.mute() → void
+```
+Mute audio without changing volume setting.
+
+---
+
+#### unmute()
+```javascript
+MusicEngine.unmute() → void
+```
+Unmute audio, restoring previous volume.
+
+---
+
+#### fadeOut()
+```javascript
+MusicEngine.fadeOut(duration) → Promise
+```
+Gradually fade out the music.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `duration` | number | No | Fade duration in milliseconds (default: 1000) |
+
+**Returns:** `Promise` - Resolves when fade completes
+
+**Example:**
+```javascript
+await MusicEngine.fadeOut(2000); // 2 second fade
+MusicEngine.playRandomSong('Dark');
+```
+
+---
+
+#### fadeIn()
+```javascript
+MusicEngine.fadeIn(songId, duration) → Promise
+```
+Start a song with a fade in effect.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `songId` | string | Yes | Song to play |
+| `duration` | number | No | Fade duration in ms (default: 1000) |
+
+**Returns:** `Promise` - Resolves when fade completes
+
+**Example:**
+```javascript
+await MusicEngine.fadeIn('peaceful-meadow-02', 1500);
+```
+
+---
+
+#### crossfade()
+```javascript
+MusicEngine.crossfade(songId, duration) → Promise
+```
+Crossfade from current song to a new song.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `songId` | string | Yes | Song to crossfade to |
+| `duration` | number | No | Crossfade duration in ms (default: 2000) |
+
+**Returns:** `Promise` - Resolves when crossfade completes
+
+**Example:**
+```javascript
+await MusicEngine.crossfade('epic-battle-04', 1500);
+```
+
+---
+
+#### getCatalog()
+```javascript
+MusicEngine.getCatalog() → Array
+```
+Get all available songs.
+
+**Returns:** `Array` - All songs in the catalog
+
+**Song Object:**
+```javascript
+{
+ id: string,
+ name: string,
+ mood: string,
+ tempo: number, // BPM
+ duration: number, // seconds
+ instruments: Array,
+ tags: Array,
+ intensity: number // 1-10
+}
+```
+
+**Example:**
+```javascript
+const songs = MusicEngine.getCatalog();
+console.log(`Total songs: ${songs.length}`); // 160
+```
+
+---
+
+#### findSongs()
+```javascript
+MusicEngine.findSongs(filters) → Array
+```
+Find songs matching specified filters.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `filters` | Object | Yes | Filter criteria |
+| `filters.mood` | string | No | Filter by mood |
+| `filters.minTempo` | number | No | Minimum tempo (BPM) |
+| `filters.maxTempo` | number | No | Maximum tempo (BPM) |
+| `filters.instruments` | Array | No | Required instruments |
+| `filters.tags` | Array | No | Required tags (any match) |
+| `filters.intensity` | number\|Array | No | Exact or [min, max] range |
+
+**Returns:** `Array` - Matching songs
+
+**Example:**
+```javascript
+const songs = MusicEngine.findSongs({
+ mood: 'Epic',
+ minTempo: 120,
+ intensity: [7, 10]
+});
+```
+
+---
+
+#### getSongsByMood()
+```javascript
+MusicEngine.getSongsByMood(mood) → Array
+```
+Get all songs in a specific mood category.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `mood` | string | Yes | Mood category |
+
+**Returns:** `Array` - Songs in that mood
+
+**Example:**
+```javascript
+const epicSongs = MusicEngine.getSongsByMood('Epic');
+console.log(`Epic songs: ${epicSongs.length}`); // 20
+```
+
+---
+
+#### getMoods()
+```javascript
+MusicEngine.getMoods() → Array
+```
+Get all available mood categories.
+
+**Returns:** `Array` - Available moods
+
+**Example:**
+```javascript
+const moods = MusicEngine.getMoods();
+// ['Epic', 'Chill', 'Dark', 'Upbeat', 'Mysterious', 'Retro', 'Dramatic', 'Peaceful']
+```
+
+---
+
+#### getSongInfo()
+```javascript
+MusicEngine.getSongInfo(songId) → Song | null
+```
+Get detailed information about a specific song.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `songId` | string | Yes | Song identifier |
+
+**Returns:** `Song | null` - Song object or null if not found
+
+---
+
+#### onSongEnd()
+```javascript
+MusicEngine.onSongEnd(callback) → Function
+```
+Register a callback for when a song ends.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `callback` | Function | Yes | Called when song ends |
+
+**Returns:** `Function` - Unsubscribe function
+
+**Example:**
+```javascript
+const unsubscribe = MusicEngine.onSongEnd(() => {
+ MusicEngine.playRandomSong('Epic'); // Loop
+});
+
+// Later: unsubscribe();
+```
+
+---
+
+## BackgroundEngine
+
+Procedural background rendering system. Generates animated backgrounds using canvas drawing.
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `themes` | Array | Available theme names |
+| `activeEffects` | Array | Currently active effects |
+
+### Methods
+
+#### initialize()
+```javascript
+BackgroundEngine.initialize() → Promise
+```
+Initialize the background engine. Called automatically by AssetManager.
+
+---
+
+#### render()
+```javascript
+BackgroundEngine.render(ctx, theme, variant, width, height, time, options) → void
+```
+Render a background to a canvas context.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `ctx` | CanvasRenderingContext2D | Yes | Canvas 2D context |
+| `theme` | string | Yes | Theme name |
+| `variant` | string | Yes | Variant name |
+| `width` | number | Yes | Canvas width in pixels |
+| `height` | number | Yes | Canvas height in pixels |
+| `time` | number | Yes | Time in seconds (for animation) |
+| `options` | Object | No | Rendering options |
+| `options.effects` | Array | No | Visual effects to apply |
+| `options.parallax` | Object | No | Parallax configuration |
+| `options.colorShift` | number | No | Hue shift in degrees |
+
+**Available Effects:**
+- `particles` - Floating particles
+- `rain` - Rain effect
+- `snow` - Snow effect
+- `fog` - Fog overlay
+- `lightning` - Lightning flashes
+- `stars-twinkle` - Twinkling stars
+- `clouds-moving` - Moving clouds
+- `leaves-falling` - Falling leaves
+- `fireflies` - Glowing fireflies
+- `heat-distortion` - Heat wave effect
+
+**Example:**
+```javascript
+function gameLoop(timestamp) {
+ const time = timestamp / 1000; // Convert to seconds
+
+ BackgroundEngine.render(
+ ctx,
+ 'nature',
+ 'forest',
+ canvas.width,
+ canvas.height,
+ time,
+ { effects: ['leaves-falling', 'fog'] }
+ );
+
+ requestAnimationFrame(gameLoop);
+}
+requestAnimationFrame(gameLoop);
+```
+
+---
+
+#### renderStatic()
+```javascript
+BackgroundEngine.renderStatic(ctx, theme, variant, width, height, options) → void
+```
+Render a static (non-animated) background.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `ctx` | CanvasRenderingContext2D | Yes | Canvas 2D context |
+| `theme` | string | Yes | Theme name |
+| `variant` | string | Yes | Variant name |
+| `width` | number | Yes | Canvas width |
+| `height` | number | Yes | Canvas height |
+| `options` | Object | No | Rendering options |
+
+**Example:**
+```javascript
+// Render once for menu screens
+BackgroundEngine.renderStatic(ctx, 'castle', 'entrance', 800, 600);
+```
+
+---
+
+#### getThemes()
+```javascript
+BackgroundEngine.getThemes() → Array
+```
+Get all available theme names.
+
+**Returns:** `Array` - Theme names
+
+**Example:**
+```javascript
+const themes = BackgroundEngine.getThemes();
+// ['nature', 'space', 'underwater', 'castle', 'city', 'desert',
+// 'arctic', 'cave', 'sky', 'abstract', 'horror', 'fantasy']
+```
+
+---
+
+#### getVariants()
+```javascript
+BackgroundEngine.getVariants(theme) → Array
+```
+Get all variant names for a theme.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `theme` | string | Yes | Theme name |
+
+**Returns:** `Array` - Variant names for the theme
+
+**Example:**
+```javascript
+const variants = BackgroundEngine.getVariants('nature');
+// ['forest', 'meadow', 'mountain', 'river', 'beach', 'jungle', 'autumn-forest']
+```
+
+---
+
+#### getThemeInfo()
+```javascript
+BackgroundEngine.getThemeInfo(theme) → ThemeInfo | null
+```
+Get detailed information about a theme.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `theme` | string | Yes | Theme name |
+
+**Returns:** `ThemeInfo | null`
+```javascript
+{
+ name: string,
+ description: string,
+ variants: Array<{
+ name: string,
+ description: string,
+ colors: Array,
+ suggestedEffects: Array
+ }>,
+ compatibleMoods: Array,
+ tags: Array
+}
+```
+
+---
+
+#### getEffects()
+```javascript
+BackgroundEngine.getEffects() → Array
+```
+Get all available visual effects.
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ description: string,
+ performance: 'low' | 'medium' | 'high',
+ compatibleThemes: Array | 'all'
+}
+```
+
+---
+
+#### preloadTheme()
+```javascript
+BackgroundEngine.preloadTheme(theme) → Promise
+```
+Preload a theme's assets for smoother rendering.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `theme` | string | Yes | Theme to preload |
+
+**Returns:** `Promise` - Resolves when preloaded
+
+---
+
+#### createRenderFunction()
+```javascript
+BackgroundEngine.createRenderFunction(theme, variant, options) → Function
+```
+Create a reusable render function for a specific background.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `theme` | string | Yes | Theme name |
+| `variant` | string | Yes | Variant name |
+| `options` | Object | No | Permanent options |
+
+**Returns:** `Function` - `(ctx, width, height, time) => void`
+
+**Example:**
+```javascript
+const renderForest = BackgroundEngine.createRenderFunction(
+ 'nature',
+ 'forest',
+ { effects: ['fog'] }
+);
+
+function gameLoop(time) {
+ renderForest(ctx, canvas.width, canvas.height, time / 1000);
+ requestAnimationFrame(gameLoop);
+}
+```
+
+---
+
+#### setGlobalEffect()
+```javascript
+BackgroundEngine.setGlobalEffect(effectId, enabled) → void
+```
+Enable or disable a global effect.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `effectId` | string | Yes | Effect identifier |
+| `enabled` | boolean | Yes | Whether to enable |
+
+---
+
+#### getColorPalette()
+```javascript
+BackgroundEngine.getColorPalette(theme, variant) → Array
+```
+Get the color palette for a background.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `theme` | string | Yes | Theme name |
+| `variant` | string | Yes | Variant name |
+
+**Returns:** `Array` - Hex color codes
+
+**Example:**
+```javascript
+const colors = BackgroundEngine.getColorPalette('nature', 'forest');
+// ['#1a472a', '#2d5a27', '#228b22', '#90ee90', '#87ceeb']
+```
+
+---
+
+## AvatarSystem
+
+Avatar creation, customization, rendering, and management system.
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `currentAvatar` | Avatar\|null | Currently loaded avatar |
+| `parts` | Object | Available parts by category |
+
+### Methods
+
+#### initialize()
+```javascript
+AvatarSystem.initialize() → Promise
+```
+Initialize the avatar system. Called automatically by AssetManager.
+
+---
+
+#### create()
+```javascript
+AvatarSystem.create(customization) → Avatar
+```
+Create a new avatar with specified customization.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `customization` | Object | No | Customization options |
+| `customization.base` | string | No | Base body type |
+| `customization.skinTone` | string | No | Skin tone color |
+| `customization.hairStyle` | string | No | Hair style ID |
+| `customization.hairColor` | string | No | Hair color |
+| `customization.eyes` | string | No | Eye style ID |
+| `customization.eyeColor` | string | No | Eye color |
+| `customization.outfit` | string | No | Outfit ID |
+| `customization.accessories` | Array | No | Equipped accessories |
+
+**Returns:** `Avatar` - New avatar object
+
+**Avatar Object:**
+```javascript
+{
+ id: string,
+ version: number,
+ customization: Object,
+ accessories: Map,
+ createdAt: Date,
+ updatedAt: Date
+}
+```
+
+**Example:**
+```javascript
+const avatar = AvatarSystem.create({
+ base: 'humanoid',
+ skinTone: '#f5d0b0',
+ hairStyle: 'spiky',
+ hairColor: '#3d2314',
+ eyes: 'round',
+ eyeColor: '#4a90d9',
+ outfit: 'casual-tee'
+});
+```
+
+---
+
+#### createRandom()
+```javascript
+AvatarSystem.createRandom(constraints) → Avatar
+```
+Create a random avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `constraints` | Object | No | Constraints for randomization |
+| `constraints.base` | string | No | Fixed base type |
+| `constraints.style` | string | No | Style category |
+
+**Returns:** `Avatar` - Random avatar
+
+**Example:**
+```javascript
+const randomAvatar = AvatarSystem.createRandom({
+ style: 'fantasy'
+});
+```
+
+---
+
+#### loadUserAvatar()
+```javascript
+AvatarSystem.loadUserAvatar(userId) → Promise
+```
+Load a user's saved avatar from the server.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `userId` | string | Yes | User identifier |
+
+**Returns:** `Promise` - User's avatar
+
+**Example:**
+```javascript
+const avatar = await AvatarSystem.loadUserAvatar(currentUser.id);
+```
+
+---
+
+#### saveUserAvatar()
+```javascript
+AvatarSystem.saveUserAvatar(userId, avatar) → Promise
+```
+Save a user's avatar to the server.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `userId` | string | Yes | User identifier |
+| `avatar` | Avatar | Yes | Avatar to save |
+
+**Returns:** `Promise` - Success status
+
+---
+
+#### render()
+```javascript
+AvatarSystem.render(avatar, mode, ctx, x, y, options) → void
+```
+Render an avatar to a canvas context.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to render |
+| `mode` | string | Yes | Render mode |
+| `ctx` | CanvasRenderingContext2D | Yes | Canvas context |
+| `x` | number | Yes | X position |
+| `y` | number | Yes | Y position |
+| `options` | Object | No | Rendering options |
+| `options.scale` | number | No | Scale factor (default: 1) |
+| `options.flip` | boolean | No | Flip horizontally |
+| `options.animation` | string | No | Current animation |
+| `options.frame` | number | No | Animation frame |
+
+**Render Modes:**
+- `full` - Full avatar with all details
+- `portrait` - Head and shoulders only
+- `icon` - Small icon version
+- `silhouette` - Solid color silhouette
+- `game` - Optimized for gameplay
+
+**Example:**
+```javascript
+AvatarSystem.render(avatar, 'game', ctx, playerX, playerY, {
+ scale: 2,
+ flip: facingLeft,
+ animation: 'walk',
+ frame: Math.floor(time * 10) % 8
+});
+```
+
+---
+
+#### renderToImage()
+```javascript
+AvatarSystem.renderToImage(avatar, mode, options) → Promise
+```
+Render avatar to an image element.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to render |
+| `mode` | string | Yes | Render mode |
+| `options` | Object | No | Rendering options |
+| `options.width` | number | No | Output width |
+| `options.height` | number | No | Output height |
+
+**Returns:** `Promise` - Rendered image
+
+---
+
+#### equipAccessory()
+```javascript
+AvatarSystem.equipAccessory(avatar, slot, itemId) → boolean
+```
+Equip an accessory to an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to modify |
+| `slot` | string | Yes | Accessory slot |
+| `itemId` | string | Yes | Accessory ID |
+
+**Accessory Slots:**
+- `head` - Hats, helmets, headbands
+- `face` - Glasses, masks
+- `neck` - Necklaces, scarves
+- `hand-left` - Left hand items
+- `hand-right` - Right hand items
+- `back` - Capes, wings, backpacks
+- `pet` - Companion pets
+
+**Returns:** `boolean` - True if equipped successfully
+
+**Example:**
+```javascript
+AvatarSystem.equipAccessory(avatar, 'head', 'wizard-hat');
+AvatarSystem.equipAccessory(avatar, 'pet', 'dragon-hatchling');
+```
+
+---
+
+#### unequipAccessory()
+```javascript
+AvatarSystem.unequipAccessory(avatar, slot) → string | null
+```
+Remove an accessory from a slot.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to modify |
+| `slot` | string | Yes | Slot to clear |
+
+**Returns:** `string | null` - Removed item ID or null if empty
+
+---
+
+#### getAccessories()
+```javascript
+AvatarSystem.getAccessories(category) → Array
+```
+Get available accessories by category.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `category` | string | No | Filter by category |
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ slot: string,
+ category: string,
+ unlockLevel: number,
+ rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
+}
+```
+
+---
+
+#### customize()
+```javascript
+AvatarSystem.customize(avatar, changes) → Avatar
+```
+Apply customization changes to an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to modify |
+| `changes` | Object | Yes | Customization changes |
+
+**Returns:** `Avatar` - Modified avatar (same reference)
+
+**Example:**
+```javascript
+AvatarSystem.customize(avatar, {
+ hairColor: '#ff0000',
+ outfit: 'knight-armor'
+});
+```
+
+---
+
+#### validate()
+```javascript
+AvatarSystem.validate(avatar) → ValidationResult
+```
+Validate an avatar object.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to validate |
+
+**Returns:** `ValidationResult`
+```javascript
+{
+ valid: boolean,
+ errors: Array,
+ warnings: Array
+}
+```
+
+---
+
+#### clone()
+```javascript
+AvatarSystem.clone(avatar) → Avatar
+```
+Create a deep copy of an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to clone |
+
+**Returns:** `Avatar` - New avatar copy
+
+---
+
+#### export()
+```javascript
+AvatarSystem.export(avatar) → string
+```
+Export avatar to a JSON string.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to export |
+
+**Returns:** `string` - JSON representation
+
+---
+
+#### import()
+```javascript
+AvatarSystem.import(json) → Avatar
+```
+Import avatar from JSON string.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `json` | string | Yes | JSON avatar data |
+
+**Returns:** `Avatar` - Imported avatar
+
+---
+
+#### getPartOptions()
+```javascript
+AvatarSystem.getPartOptions(category) → Array
+```
+Get all options for an avatar part category.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `category` | string | Yes | Part category |
+
+**Categories:** `base`, `hair`, `eyes`, `mouth`, `outfit`, `shoes`
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ preview: string, // Preview image URL
+ unlockLevel: number,
+ colors: Array // Available color options
+}
+```
+
+---
+
+#### getAnimations()
+```javascript
+AvatarSystem.getAnimations(avatar) → Array
+```
+Get available animations for an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Avatar to check |
+
+**Returns:** `Array` - Animation names
+
+**Standard Animations:**
+- `idle`
+- `walk`
+- `run`
+- `jump`
+- `fall`
+- `land`
+- `attack`
+- `hurt`
+- `die`
+- `celebrate`
+
+---
+
+## ContextRenderer
+
+Applies and renders game contexts on avatars. Contexts define how avatars behave and appear in different game types.
+
+### Methods
+
+#### apply()
+```javascript
+ContextRenderer.apply(avatar, contextId, options) → AvatarWithContext
+```
+Apply a game context to an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatar` | Avatar | Yes | Base avatar |
+| `contextId` | string | Yes | Context to apply |
+| `options` | Object | No | Context options |
+| `options.variant` | string | No | Context variant |
+| `options.color` | string | No | Primary color override |
+| `options.preserveAccessories` | boolean | No | Keep equipped accessories |
+
+**Returns:** `AvatarWithContext` - Avatar with applied context
+
+**AvatarWithContext Object:**
+```javascript
+{
+ avatar: Avatar,
+ context: Context,
+ variant: string,
+ state: {
+ animation: string,
+ frame: number,
+ direction: 'left' | 'right',
+ effects: Array
+ }
+}
+```
+
+**Example:**
+```javascript
+const player = ContextRenderer.apply(avatar, 'platformer-standard', {
+ variant: 'hero',
+ preserveAccessories: true
+});
+```
+
+---
+
+#### remove()
+```javascript
+ContextRenderer.remove(avatarWithContext) → Avatar
+```
+Remove context from an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+
+**Returns:** `Avatar` - Original avatar
+
+---
+
+#### render()
+```javascript
+ContextRenderer.render(avatarWithContext, ctx, x, y, options) → void
+```
+Render an avatar with its context.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+| `ctx` | CanvasRenderingContext2D | Yes | Canvas context |
+| `x` | number | Yes | X position |
+| `y` | number | Yes | Y position |
+| `options` | Object | No | Rendering options |
+| `options.scale` | number | No | Scale factor |
+| `options.time` | number | No | Time for animation |
+| `options.debug` | boolean | No | Show hitboxes |
+
+**Example:**
+```javascript
+function gameLoop(time) {
+ ctx.clearRect(0, 0, width, height);
+ ContextRenderer.render(player, ctx, playerX, playerY, {
+ scale: 2,
+ time: time / 1000
+ });
+ requestAnimationFrame(gameLoop);
+}
+```
+
+---
+
+#### animate()
+```javascript
+ContextRenderer.animate(avatarWithContext, animationId, options) → boolean
+```
+Start or change an animation on an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+| `animationId` | string | Yes | Animation to play |
+| `options` | Object | No | Animation options |
+| `options.loop` | boolean | No | Loop animation (default: varies) |
+| `options.speed` | number | No | Speed multiplier (default: 1) |
+| `options.onComplete` | Function | No | Callback when animation ends |
+
+**Returns:** `boolean` - True if animation started
+
+**Example:**
+```javascript
+if (keys.ArrowRight) {
+ player.state.direction = 'right';
+ ContextRenderer.animate(player, 'run');
+} else if (keys.ArrowLeft) {
+ player.state.direction = 'left';
+ ContextRenderer.animate(player, 'run');
+} else {
+ ContextRenderer.animate(player, 'idle');
+}
+
+// One-shot animation with callback
+ContextRenderer.animate(player, 'attack', {
+ loop: false,
+ onComplete: () => canAttack = true
+});
+```
+
+---
+
+#### stopAnimation()
+```javascript
+ContextRenderer.stopAnimation(avatarWithContext) → void
+```
+Stop the current animation, reverting to idle.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+
+---
+
+#### addEffect()
+```javascript
+ContextRenderer.addEffect(avatarWithContext, effectId, options) → boolean
+```
+Add a visual effect to an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+| `effectId` | string | Yes | Effect to add |
+| `options` | Object | No | Effect options |
+| `options.duration` | number | No | Auto-remove after ms |
+| `options.color` | string | No | Effect color |
+| `options.intensity` | number | No | Effect intensity (0-1) |
+
+**Available Effects:**
+- `glow` - Outer glow
+- `trail` - Motion trail
+- `sparkle` - Sparkle particles
+- `fire` - Fire aura
+- `ice` - Ice crystals
+- `electric` - Electric sparks
+- `heal` - Healing particles
+- `shield` - Shield bubble
+- `speed-lines` - Speed effect
+- `damage-flash` - Red flash
+
+**Returns:** `boolean` - True if effect added
+
+**Example:**
+```javascript
+// Temporary power-up glow
+ContextRenderer.addEffect(player, 'glow', {
+ color: '#ffff00',
+ duration: 10000
+});
+
+// Damage feedback
+ContextRenderer.addEffect(player, 'damage-flash', {
+ duration: 200
+});
+```
+
+---
+
+#### removeEffect()
+```javascript
+ContextRenderer.removeEffect(avatarWithContext, effectId) → boolean
+```
+Remove an effect from an avatar.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+| `effectId` | string | Yes | Effect to remove |
+
+**Returns:** `boolean` - True if effect was removed
+
+---
+
+#### clearEffects()
+```javascript
+ContextRenderer.clearEffects(avatarWithContext) → void
+```
+Remove all effects from an avatar.
+
+---
+
+#### getHitbox()
+```javascript
+ContextRenderer.getHitbox(avatarWithContext, type) → Hitbox
+```
+Get collision hitbox for the avatar in current state.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+| `type` | string | No | Hitbox type (default: 'body') |
+
+**Hitbox Types:**
+- `body` - Main collision box
+- `feet` - Ground detection
+- `head` - Ceiling detection
+- `attack` - Attack range (if attacking)
+
+**Returns:** `Hitbox`
+```javascript
+{
+ x: number, // Relative to avatar position
+ y: number,
+ width: number,
+ height: number
+}
+```
+
+**Example:**
+```javascript
+const hitbox = ContextRenderer.getHitbox(player, 'body');
+const worldHitbox = {
+ x: playerX + hitbox.x,
+ y: playerY + hitbox.y,
+ width: hitbox.width,
+ height: hitbox.height
+};
+```
+
+---
+
+#### setDirection()
+```javascript
+ContextRenderer.setDirection(avatarWithContext, direction) → void
+```
+Set facing direction.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `avatarWithContext` | AvatarWithContext | Yes | Avatar with context |
+| `direction` | string | Yes | 'left' or 'right' |
+
+---
+
+#### getAvailableAnimations()
+```javascript
+ContextRenderer.getAvailableAnimations(avatarWithContext) → Array
+```
+Get all animations available for this context.
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ frames: number,
+ duration: number, // ms per frame
+ loops: boolean
+}
+```
+
+---
+
+## ContextCatalog
+
+Catalog of all available game contexts.
+
+### Methods
+
+#### getAll()
+```javascript
+ContextCatalog.getAll() → Array
+```
+Get all available contexts.
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ description: string,
+ category: string,
+ variants: Array,
+ animations: Array,
+ gameStyles: Array,
+ tags: Array
+}
+```
+
+---
+
+#### get()
+```javascript
+ContextCatalog.get(contextId) → Context | null
+```
+Get a specific context by ID.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `contextId` | string | Yes | Context identifier |
+
+**Returns:** `Context | null`
+
+---
+
+#### findByGameStyle()
+```javascript
+ContextCatalog.findByGameStyle(gameStyle) → Array
+```
+Find contexts compatible with a game style.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `gameStyle` | string | Yes | Game style |
+
+**Returns:** `Array` - Compatible contexts
+
+**Example:**
+```javascript
+const contexts = ContextCatalog.findByGameStyle('action-arcade');
+// Returns shooter, platformer, fighting contexts
+```
+
+---
+
+#### findByCategory()
+```javascript
+ContextCatalog.findByCategory(category) → Array
+```
+Find contexts by category.
+
+**Categories:**
+- `action` - Action game contexts
+- `puzzle` - Puzzle game contexts
+- `adventure` - Adventure contexts
+- `vehicle` - Vehicle/racing contexts
+- `sports` - Sports game contexts
+- `special` - Special/unique contexts
+
+---
+
+#### search()
+```javascript
+ContextCatalog.search(query) → Array
+```
+Search contexts by name, description, or tags.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `query` | string | Yes | Search query |
+
+**Returns:** `Array` - Matching contexts
+
+---
+
+#### getVariants()
+```javascript
+ContextCatalog.getVariants(contextId) → Array
+```
+Get all variants for a context.
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ description: string,
+ preview: string
+}
+```
+
+---
+
+## AssetCatalog
+
+Unified catalog for browsing all assets.
+
+### Methods
+
+#### browse()
+```javascript
+AssetCatalog.browse(category, options) → BrowseResult
+```
+Browse assets by category.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `category` | string | Yes | Asset category |
+| `options` | Object | No | Browse options |
+| `options.page` | number | No | Page number (default: 1) |
+| `options.perPage` | number | No | Items per page (default: 20) |
+| `options.sort` | string | No | Sort field |
+| `options.filter` | Object | No | Filter criteria |
+
+**Categories:** `music`, `backgrounds`, `contexts`, `accessories`, `presets`
+
+**Returns:** `BrowseResult`
+```javascript
+{
+ items: Array,
+ total: number,
+ page: number,
+ perPage: number,
+ totalPages: number
+}
+```
+
+---
+
+#### search()
+```javascript
+AssetCatalog.search(query, options) → SearchResult
+```
+Search across all asset categories.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `query` | string | Yes | Search query |
+| `options` | Object | No | Search options |
+| `options.categories` | Array | No | Limit to categories |
+| `options.limit` | number | No | Max results (default: 50) |
+
+**Returns:** `SearchResult`
+```javascript
+{
+ results: Array<{
+ category: string,
+ asset: Asset,
+ score: number
+ }>,
+ totalCount: number
+}
+```
+
+---
+
+#### getStats()
+```javascript
+AssetCatalog.getStats() → CatalogStats
+```
+Get statistics about the asset catalog.
+
+**Returns:** `CatalogStats`
+```javascript
+{
+ music: { total: 160, byMood: Object },
+ backgrounds: { themes: 12, variants: 84 },
+ contexts: { total: 44, byCategory: Object },
+ accessories: { total: 280, bySlot: Object },
+ presets: { total: 50 }
+}
+```
+
+---
+
+#### getRecent()
+```javascript
+AssetCatalog.getRecent(category, limit) → Array
+```
+Get recently added assets.
+
+---
+
+#### getPopular()
+```javascript
+AssetCatalog.getPopular(category, limit) → Array
+```
+Get most popular/used assets.
+
+---
+
+## AssetPresets
+
+Pre-configured asset combinations for quick setup.
+
+### Methods
+
+#### getAll()
+```javascript
+AssetPresets.getAll() → Array
+```
+Get all available presets.
+
+**Returns:** `Array`
+```javascript
+{
+ id: string,
+ name: string,
+ description: string,
+ config: {
+ music: Object,
+ background: Object,
+ context: Object
+ },
+ thumbnail: string,
+ tags: Array,
+ gameStyles: Array
+}
+```
+
+---
+
+#### get()
+```javascript
+AssetPresets.get(presetId) → Preset | null
+```
+Get a specific preset.
+
+---
+
+#### findByGameStyle()
+```javascript
+AssetPresets.findByGameStyle(gameStyle) → Array
+```
+Find presets matching a game style.
+
+---
+
+#### findByTags()
+```javascript
+AssetPresets.findByTags(tags, matchAll) → Array
+```
+Find presets by tags.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `tags` | Array | Yes | Tags to match |
+| `matchAll` | boolean | No | Require all tags (default: false) |
+
+---
+
+#### apply()
+```javascript
+AssetPresets.apply(presetId) → Promise
+```
+Apply a preset, loading all its assets.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `presetId` | string | Yes | Preset identifier |
+
+**Returns:** `Promise` - Loaded asset references
+
+**Example:**
+```javascript
+const assets = await AssetPresets.apply('retro-platformer');
+MusicEngine.playSong(assets.music.song.id);
+```
+
+---
+
+#### create()
+```javascript
+AssetPresets.create(name, config, metadata) → Preset
+```
+Create a custom preset (user presets).
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `name` | string | Yes | Preset name |
+| `config` | Object | Yes | Asset configuration |
+| `metadata` | Object | No | Additional metadata |
+
+**Returns:** `Preset` - Created preset
+
+---
+
+#### save()
+```javascript
+AssetPresets.save(preset) → Promise
+```
+Save a user preset to the server.
+
+---
+
+#### delete()
+```javascript
+AssetPresets.delete(presetId) → Promise
+```
+Delete a user preset.
+
+---
+
+#### getUserPresets()
+```javascript
+AssetPresets.getUserPresets(userId) → Promise>
+```
+Get a user's saved presets.
+
+---
+
+## AssetCompatibility
+
+Validates and checks compatibility between assets.
+
+### Methods
+
+#### validate()
+```javascript
+AssetCompatibility.validate(config) → ValidationResult
+```
+Validate an asset configuration for compatibility.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `config` | Object | Yes | Asset configuration |
+
+**Returns:** `ValidationResult`
+```javascript
+{
+ valid: boolean,
+ errors: Array<{
+ code: string,
+ message: string,
+ field: string
+ }>,
+ warnings: Array<{
+ code: string,
+ message: string,
+ suggestion: string
+ }>
+}
+```
+
+**Example:**
+```javascript
+const result = AssetCompatibility.validate({
+ music: { mood: 'Peaceful' },
+ background: { theme: 'horror', variant: 'haunted-house' },
+ context: { id: 'shooter-standard' }
+});
+
+console.log(result);
+// {
+// valid: true,
+// errors: [],
+// warnings: [
+// {
+// code: 'MOOD_MISMATCH',
+// message: 'Peaceful music may not match horror theme',
+// suggestion: 'Consider using Dark or Mysterious mood'
+// }
+// ]
+// }
+```
+
+---
+
+#### isCompatible()
+```javascript
+AssetCompatibility.isCompatible(asset1, asset2) → boolean
+```
+Check if two assets are compatible.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `asset1` | Object | Yes | First asset {type, id} |
+| `asset2` | Object | Yes | Second asset {type, id} |
+
+**Returns:** `boolean` - True if compatible
+
+**Example:**
+```javascript
+const compatible = AssetCompatibility.isCompatible(
+ { type: 'background', id: 'space:nebula' },
+ { type: 'context', id: 'shooter-standard' }
+);
+// true
+```
+
+---
+
+#### getCompatibleAssets()
+```javascript
+AssetCompatibility.getCompatibleAssets(asset, targetType) → Array
+```
+Get assets compatible with a given asset.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `asset` | Object | Yes | Source asset {type, id} |
+| `targetType` | string | Yes | Type of assets to find |
+
+**Returns:** `Array` - Compatible assets
+
+**Example:**
+```javascript
+const backgrounds = AssetCompatibility.getCompatibleAssets(
+ { type: 'context', id: 'platformer-standard' },
+ 'background'
+);
+// Returns backgrounds suitable for platformers
+```
+
+---
+
+#### suggestFixes()
+```javascript
+AssetCompatibility.suggestFixes(config) → Array
+```
+Get suggestions to improve an asset configuration.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `config` | Object | Yes | Current configuration |
+
+**Returns:** `Array`
+```javascript
+{
+ type: 'music' | 'background' | 'context',
+ current: Object,
+ suggested: Object,
+ reason: string,
+ improvement: string
+}
+```
+
+---
+
+#### getMoodCompatibility()
+```javascript
+AssetCompatibility.getMoodCompatibility(mood, theme) → number
+```
+Get compatibility score between a music mood and background theme.
+
+**Parameters:**
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `mood` | string | Yes | Music mood |
+| `theme` | string | Yes | Background theme |
+
+**Returns:** `number` - Compatibility score (0-100)
+
+**Example:**
+```javascript
+const score = AssetCompatibility.getMoodCompatibility('Epic', 'space');
+// 95 - highly compatible
+
+const score2 = AssetCompatibility.getMoodCompatibility('Peaceful', 'horror');
+// 15 - poor compatibility
+```
+
+---
+
+## Common Patterns
+
+### Basic Game Setup
+```javascript
+async function initGame() {
+ // Initialize all systems
+ await AssetManager.initialize();
+
+ // Load game-specific assets
+ const assets = await AssetManager.loadGameAssets({
+ music: { mood: 'Upbeat' },
+ background: { theme: 'nature', variant: 'forest' },
+ context: { id: 'platformer-standard' }
+ });
+
+ // Start music
+ MusicEngine.playRandomSong('Upbeat');
+
+ // Load player avatar
+ const avatar = await AvatarSystem.loadUserAvatar(userId);
+ const player = ContextRenderer.apply(avatar, 'platformer-standard');
+
+ // Game loop
+ function gameLoop(timestamp) {
+ const time = timestamp / 1000;
+
+ // Clear canvas
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ // Render background
+ BackgroundEngine.render(
+ ctx,
+ 'nature',
+ 'forest',
+ canvas.width,
+ canvas.height,
+ time
+ );
+
+ // Update and render player
+ updatePlayer(player);
+ ContextRenderer.render(player, ctx, playerX, playerY, { time });
+
+ requestAnimationFrame(gameLoop);
+ }
+
+ requestAnimationFrame(gameLoop);
+}
+```
+
+---
+
+### Switching Music Mood
+```javascript
+// Smooth transition when entering boss fight
+async function onBossFight() {
+ await MusicEngine.fadeOut(1000);
+ MusicEngine.playRandomSong('Epic');
+}
+
+// Crossfade for seamless transition
+function onAreaChange(newArea) {
+ const moodMap = {
+ forest: 'Peaceful',
+ dungeon: 'Dark',
+ boss: 'Epic',
+ victory: 'Upbeat'
+ };
+
+ const newMood = moodMap[newArea];
+ const songs = MusicEngine.findSongs({ mood: newMood });
+ const randomSong = songs[Math.floor(Math.random() * songs.length)];
+
+ MusicEngine.crossfade(randomSong.id, 2000);
+}
+```
+
+---
+
+### Animation Control
+```javascript
+function updatePlayer(player) {
+ // Movement animations
+ if (keys.ArrowRight) {
+ ContextRenderer.setDirection(player, 'right');
+ if (isGrounded) {
+ ContextRenderer.animate(player, keys.Shift ? 'run' : 'walk');
+ }
+ playerX += keys.Shift ? 8 : 4;
+ } else if (keys.ArrowLeft) {
+ ContextRenderer.setDirection(player, 'left');
+ if (isGrounded) {
+ ContextRenderer.animate(player, keys.Shift ? 'run' : 'walk');
+ }
+ playerX -= keys.Shift ? 8 : 4;
+ } else if (isGrounded) {
+ ContextRenderer.animate(player, 'idle');
+ }
+
+ // Jump animation
+ if (keys.Space && isGrounded) {
+ ContextRenderer.animate(player, 'jump');
+ velocityY = -15;
+ isGrounded = false;
+ }
+
+ // Falling animation
+ if (!isGrounded && velocityY > 0) {
+ ContextRenderer.animate(player, 'fall');
+ }
+
+ // Attack animation (one-shot)
+ if (keys.z && canAttack) {
+ canAttack = false;
+ ContextRenderer.animate(player, 'attack', {
+ loop: false,
+ onComplete: () => {
+ canAttack = true;
+ }
+ });
+ }
+}
+```
+
+---
+
+### Dynamic Background Effects
+```javascript
+// Weather system
+class WeatherSystem {
+ constructor() {
+ this.currentWeather = 'clear';
+ }
+
+ setWeather(weather) {
+ // Remove old effects
+ BackgroundEngine.setGlobalEffect('rain', false);
+ BackgroundEngine.setGlobalEffect('snow', false);
+ BackgroundEngine.setGlobalEffect('fog', false);
+ BackgroundEngine.setGlobalEffect('lightning', false);
+
+ // Apply new weather
+ switch (weather) {
+ case 'rain':
+ BackgroundEngine.setGlobalEffect('rain', true);
+ MusicEngine.crossfade('rain-ambience', 2000);
+ break;
+ case 'storm':
+ BackgroundEngine.setGlobalEffect('rain', true);
+ BackgroundEngine.setGlobalEffect('lightning', true);
+ MusicEngine.crossfade('storm-dramatic', 2000);
+ break;
+ case 'snow':
+ BackgroundEngine.setGlobalEffect('snow', true);
+ MusicEngine.crossfade('winter-peaceful', 2000);
+ break;
+ case 'fog':
+ BackgroundEngine.setGlobalEffect('fog', true);
+ MusicEngine.crossfade('mysterious-ambient', 2000);
+ break;
+ }
+
+ this.currentWeather = weather;
+ }
+}
+```
+
+---
+
+### Power-up Effects
+```javascript
+function applyPowerUp(player, powerUpType) {
+ switch (powerUpType) {
+ case 'speed':
+ ContextRenderer.addEffect(player, 'speed-lines', {
+ duration: 10000,
+ color: '#00ffff'
+ });
+ playerSpeed *= 2;
+ setTimeout(() => playerSpeed /= 2, 10000);
+ break;
+
+ case 'shield':
+ ContextRenderer.addEffect(player, 'shield', {
+ duration: 15000,
+ color: '#4444ff'
+ });
+ isInvulnerable = true;
+ setTimeout(() => isInvulnerable = false, 15000);
+ break;
+
+ case 'fire':
+ ContextRenderer.addEffect(player, 'fire', {
+ color: '#ff4400'
+ });
+ attackDamage *= 2;
+ break;
+
+ case 'heal':
+ ContextRenderer.addEffect(player, 'heal', {
+ duration: 2000
+ });
+ health = Math.min(health + 30, maxHealth);
+ break;
+ }
+}
+
+function onPlayerHit(player, damage) {
+ if (isInvulnerable) return;
+
+ health -= damage;
+
+ // Visual feedback
+ ContextRenderer.addEffect(player, 'damage-flash', {
+ duration: 200
+ });
+
+ // Screen shake
+ screenShake(10, 200);
+
+ if (health <= 0) {
+ ContextRenderer.animate(player, 'die', {
+ loop: false,
+ onComplete: showGameOver
+ });
+ }
+}
+```
+
+---
+
+### Preset-Based Game Loading
+```javascript
+async function loadGameWithPreset(presetName) {
+ const preset = AssetManager.findPreset(presetName);
+
+ if (!preset) {
+ console.error(`Preset "${presetName}" not found`);
+ return null;
+ }
+
+ // Validate the preset
+ const validation = AssetCompatibility.validate(preset.config);
+ if (!validation.valid) {
+ console.error('Invalid preset:', validation.errors);
+ return null;
+ }
+
+ // Show any warnings
+ if (validation.warnings.length > 0) {
+ console.warn('Preset warnings:', validation.warnings);
+ }
+
+ // Load all assets
+ const assets = await AssetManager.loadGameAssets(preset.config);
+
+ return {
+ preset,
+ assets,
+ description: AssetManager.describeAssets(preset.config)
+ };
+}
+
+// Usage
+const game = await loadGameWithPreset('retro-platformer');
+console.log(game.description.summary);
+// "8-bit chiptune music with pixelated forest background,
+// featuring a classic platformer hero with retro animations"
+```
+
+---
+
+### Responsive Asset Recommendations
+```javascript
+async function setupGameFromDescription(description, gameStyle) {
+ // Get AI-powered recommendations
+ const recommendations = AssetManager.getRecommendations(
+ gameStyle,
+ description
+ );
+
+ // Use the top recommendation
+ const topRec = recommendations[0];
+ console.log(`Selected: ${topRec.explanation} (score: ${topRec.score})`);
+
+ // Load the recommended assets
+ const assets = await AssetManager.loadGameAssets(topRec.config);
+
+ return {
+ assets,
+ config: topRec.config,
+ alternatives: recommendations.slice(1)
+ };
+}
+
+// Usage
+const game = await setupGameFromDescription(
+ 'A spooky adventure in a haunted mansion with ghosts and puzzles',
+ 'adventure-story'
+);
+// Automatically selects dark/mysterious music, horror mansion background,
+// and adventure context
+```
+
+---
+
+## Troubleshooting
+
+### Music Not Playing
+
+**Symptom:** `MusicEngine.playSong()` returns true but no sound.
+
+**Solutions:**
+1. **User interaction required:** Most browsers require user interaction before playing audio.
+```javascript
+document.addEventListener('click', () => {
+ MusicEngine.playRandomSong('Upbeat');
+}, { once: true });
+```
+
+2. **Check volume:**
+```javascript
+console.log('Volume:', MusicEngine.getVolume());
+console.log('Muted:', MusicEngine.muted);
+MusicEngine.setVolume(0.7);
+MusicEngine.unmute();
+```
+
+3. **Ensure initialization:**
+```javascript
+await AssetManager.initialize();
+// or
+await MusicEngine.initialize();
+```
+
+---
+
+### Background Not Animating
+
+**Symptom:** Background appears static, not moving.
+
+**Solutions:**
+1. **Pass time parameter:** The time must increase each frame.
+```javascript
+// Wrong - time is always 0
+BackgroundEngine.render(ctx, theme, variant, w, h, 0);
+
+// Correct - time increases
+function gameLoop(timestamp) {
+ const time = timestamp / 1000; // Convert to seconds
+ BackgroundEngine.render(ctx, theme, variant, w, h, time);
+ requestAnimationFrame(gameLoop);
+}
+```
+
+2. **Time should be in seconds:**
+```javascript
+// Wrong - milliseconds
+BackgroundEngine.render(ctx, theme, variant, w, h, Date.now());
+
+// Correct - seconds
+BackgroundEngine.render(ctx, theme, variant, w, h, Date.now() / 1000);
+```
+
+---
+
+### Avatar Not Rendering
+
+**Symptom:** Avatar appears blank or throws errors.
+
+**Solutions:**
+1. **Validate avatar object:**
+```javascript
+const validation = AvatarSystem.validate(avatar);
+if (!validation.valid) {
+ console.error('Avatar errors:', validation.errors);
+ avatar = AvatarSystem.create(); // Use default
+}
+```
+
+2. **Check mode is valid:**
+```javascript
+// Valid modes: 'full', 'portrait', 'icon', 'silhouette', 'game'
+AvatarSystem.render(avatar, 'game', ctx, x, y);
+```
+
+3. **Ensure context is applied for game rendering:**
+```javascript
+// If using ContextRenderer, must apply context first
+const player = ContextRenderer.apply(avatar, 'platformer-standard');
+ContextRenderer.render(player, ctx, x, y);
+```
+
+---
+
+### Context Mismatch
+
+**Symptom:** Avatar looks wrong or animations don't work.
+
+**Solutions:**
+1. **Validate configuration:**
+```javascript
+const result = AssetCompatibility.validate({
+ context: { id: 'shooter-standard' },
+ background: { theme: 'nature', variant: 'forest' }
+});
+
+if (result.warnings.length > 0) {
+ console.warn('Compatibility warnings:', result.warnings);
+}
+```
+
+2. **Check game style compatibility:**
+```javascript
+const contexts = ContextCatalog.findByGameStyle('action-arcade');
+console.log('Compatible contexts:', contexts.map(c => c.id));
+```
+
+3. **Use recommendations:**
+```javascript
+const recs = AssetManager.getRecommendations('action-arcade', 'space shooter');
+const bestContext = recs[0].config.context;
+```
+
+---
+
+### Performance Issues
+
+**Symptom:** Game runs slowly or stutters.
+
+**Solutions:**
+1. **Reduce background effects:**
+```javascript
+// Disable expensive effects
+BackgroundEngine.render(ctx, theme, variant, w, h, time, {
+ effects: [] // No effects
+});
+```
+
+2. **Use static backgrounds for menus:**
+```javascript
+// Render once instead of every frame
+BackgroundEngine.renderStatic(ctx, theme, variant, w, h);
+```
+
+3. **Preload assets:**
+```javascript
+await BackgroundEngine.preloadTheme('nature');
+await AssetManager.initialize({ preloadBackgrounds: true });
+```
+
+4. **Use simpler render modes:**
+```javascript
+// Use 'game' mode instead of 'full' for gameplay
+AvatarSystem.render(avatar, 'game', ctx, x, y);
+```
+
+---
+
+### Asset Not Found
+
+**Symptom:** Methods return null or false.
+
+**Solutions:**
+1. **Check asset exists:**
+```javascript
+const song = MusicEngine.getSongInfo('my-song-id');
+if (!song) {
+ console.error('Song not found. Available songs:');
+ console.log(MusicEngine.getCatalog().map(s => s.id));
+}
+```
+
+2. **Search for similar assets:**
+```javascript
+const results = AssetManager.searchAssets('forest', {
+ types: ['background']
+});
+console.log('Found backgrounds:', results.backgrounds);
+```
+
+3. **Use fallbacks:**
+```javascript
+const preset = AssetManager.findPreset('my-preset')
+ || AssetManager.findPreset('default-platformer');
+```
+
+---
+
+## Error Codes
+
+| Code | Description | Solution |
+|------|-------------|----------|
+| `ASSET_NOT_FOUND` | Requested asset doesn't exist | Check asset ID, use search |
+| `INVALID_MOOD` | Music mood not recognized | Use `MusicEngine.getMoods()` |
+| `INVALID_THEME` | Background theme not recognized | Use `BackgroundEngine.getThemes()` |
+| `INVALID_CONTEXT` | Context ID not recognized | Use `ContextCatalog.getAll()` |
+| `INCOMPATIBLE_ASSETS` | Assets don't work together | Use `AssetCompatibility.suggestFixes()` |
+| `NOT_INITIALIZED` | System not initialized | Call `AssetManager.initialize()` |
+| `AUDIO_BLOCKED` | Browser blocked audio | Require user interaction first |
+| `AVATAR_INVALID` | Malformed avatar object | Use `AvatarSystem.validate()` |
+| `CONTEXT_REQUIRED` | Operation requires context | Use `ContextRenderer.apply()` |
+| `ANIMATION_NOT_FOUND` | Animation doesn't exist for context | Use `ContextRenderer.getAvailableAnimations()` |
+
+---
+
+## Version History
+
+| Version | Date | Changes |
+|---------|------|---------|
+| 2.0.0 | 2026-02-05 | Added ContextRenderer, expanded MusicEngine |
+| 1.5.0 | 2026-01-15 | Added AssetCompatibility, presets system |
+| 1.0.0 | 2025-12-01 | Initial release |
diff --git a/frontend/public/docs/ASSET_LIBRARY_OVERVIEW.md b/frontend/public/docs/ASSET_LIBRARY_OVERVIEW.md
new file mode 100644
index 0000000..7cd7988
--- /dev/null
+++ b/frontend/public/docs/ASSET_LIBRARY_OVERVIEW.md
@@ -0,0 +1,598 @@
+# GamerComp Asset Library
+
+## Overview
+
+The GamerComp Asset Library is a comprehensive procedural asset system providing:
+
+- **160 Procedural Songs** - 8 moods x 20 songs each using Tone.js
+- **120 Procedural Backgrounds** - 15 themes x 8 variants using Canvas 2D
+- **Full Avatar System** - Mii-style customizable avatars with 45 accessories
+- **26 Game Contexts** - Place avatars in vehicles, costumes, or pure modes
+- **220 Presets** - Curated asset combinations for 11 game styles
+
+All assets are procedurally generated - no external images or audio files needed.
+
+---
+
+## System Architecture
+
+```
++--------------------------------------------------------------------------------+
+| LAYER 4: ASSET MANAGER |
+| |
+| +----------------+ +----------------+ +-----------+ +----------------+ |
+| | assetManager.js| |assetCatalog.js | | presets.js| |compatibility.js| |
+| | (coordination) | |(unified query) | | (220) | | (validation) | |
+| +-------+--------+ +-------+--------+ +-----+-----+ +--------+-------+ |
+| | | | | |
++-----------+-------------------+-----------------+-----------------+------------+
+ | | | |
+ v v v v
++--------------------------------------------------------------------------------+
+| LAYER 3: CONTEXT SYSTEM |
+| |
+| +-------------+ +-------------+ +-------------+ +------------------+ |
+| | vehicles.js | | costumes.js | | pure.js | | contextRenderer | |
+| | (12) | | (10) | | (4) | | .js | |
+| +-------------+ +-------------+ +-------------+ +------------------+ |
+| |
++--------------------------------------------------------------------------------+
+ | | |
+ v v v
++--------------------------------------------------------------------------------+
+| LAYER 2: CONTENT LIBRARIES |
+| |
+| +------------------+ +------------------------+ +-----------------+ |
+| | musicLibrary.js | | 15 Background Theme | | avatarData.js | |
+| | (160 songs) | | Files (8 variants ea) | | accessories.js | |
+| +------------------+ +------------------------+ | (45 items) | |
+| | space.js nature.js | +-----------------+ |
+| | urban.js fantasy.js | |
+| | abstract.js retro.js | |
+| | indoor.js weather.js | |
+| | sports.js seasonal.js | |
+| | underwater.js sky.js | |
+| | mechanical.js spooky.js| |
+| | colorful.js | |
+| +------------------------+ |
+| |
++--------------------------------------------------------------------------------+
+ | | |
+ v v v
++--------------------------------------------------------------------------------+
+| LAYER 1: CORE ENGINES |
+| |
+| +------------------+ +--------------------+ +-------------------+ |
+| | MusicEngine | | BackgroundEngine | | AvatarSystem | |
+| | (Tone.js) | | (Canvas 2D) | | (Canvas 2D) | |
+| +------------------+ +--------------------+ +-------------------+ |
+| |
++--------------------------------------------------------------------------------+
+```
+
+---
+
+## Quick Start
+
+### 1. Include Scripts
+
+Scripts must be loaded in dependency order. Here is the recommended `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 2. Initialize
+
+```javascript
+AssetManager.initialize().then(function(stats) {
+ console.log('Asset Library initialized:', stats);
+ // {
+ // music: { songs: 160 },
+ // backgrounds: { count: 120 },
+ // avatarContexts: { count: 26 },
+ // presets: { count: 220 }
+ // }
+});
+```
+
+### 3. Get Recommendations
+
+```javascript
+// Get AI-powered asset recommendations based on game description
+var recommendations = AssetManager.getRecommendations(
+ 'action-arcade', // Game style
+ 'space shooter with aliens', // Description/keywords
+ 'Epic' // Optional mood override
+);
+
+// Returns top 3 recommendations with explanations
+// [
+// {
+// config: { music: {...}, background: {...}, avatarContext: {...} },
+// score: 85,
+// explanation: 'Epic high-energy music sets the mood; space nebula background...',
+// detectedTheme: 'space',
+// detectedMood: 'epic',
+// detectedMechanics: { mode: 'HEAD_ONLY', context: 'spaceship-cockpit' }
+// },
+// ...
+// ]
+```
+
+### 4. Load Assets
+
+```javascript
+await AssetManager.loadGameAssets({
+ music: { mood: 'Epic', energy: 8 },
+ background: { theme: 'space', variant: 'nebula' },
+ avatarContext: { mode: 'HEAD_ONLY', context: 'spaceship-cockpit' }
+});
+
+// Access loaded assets
+var assets = AssetManager.getLoadedAssets();
+// {
+// music: { id: 'epic_001', name: 'Siege of Thunder', ... },
+// background: { theme: 'space', variant: 'nebula', render: function(...) },
+// avatarContext: { mode: 'HEAD_ONLY', context: 'spaceship-cockpit' }
+// }
+```
+
+---
+
+## Benefits
+
+| Benefit | Description |
+|---------|-------------|
+| **Zero External Dependencies** | Everything procedurally generated - no images, audio files, or CDN requirements |
+| **Consistent Style** | All assets designed to work together with unified color palettes and visual language |
+| **Intelligent Matching** | Compatibility system ensures good combinations with mood/theme/context scoring |
+| **Variety** | 220 presets provide instant diversity for any game style |
+| **Customizable** | Override any setting while keeping coherence through validation feedback |
+| **Lightweight** | All code compresses to ~150KB total (before gzip) |
+| **Offline-First** | Works without network after initial load |
+
+---
+
+## Asset Counts Summary
+
+```
++---------------------+--------+-----------------------------------------+
+| Category | Count | Details |
++---------------------+--------+-----------------------------------------+
+| Music Songs | 160 | 8 moods x 20 songs each |
+| Background Variants | 120 | 15 themes x 8 variants each |
+| Avatar Accessories | 45 | Eyewear, headwear, effects, particles |
+| Game Contexts | 26 | 12 vehicles + 10 costumes + 4 pure |
+| Presets | 220 | 20 per game style x 11 game styles |
+| Game Styles | 11 | Action, Puzzle, Story, Racing, etc. |
+| Music Moods | 8 | Epic, Chill, Intense, Playful, etc. |
+| Background Themes | 15 | Space, Nature, Urban, Fantasy, etc. |
+| Color Palettes | 11 | Pre-defined harmonious color sets |
++---------------------+--------+-----------------------------------------+
+```
+
+---
+
+## Music System
+
+### Moods (8 total, 20 songs each)
+
+| Mood | Energy Range | Description | Tags |
+|------|--------------|-------------|------|
+| **Epic** | 7-10 | Orchestral, triumphant, powerful | battle, hero, conquest |
+| **Chill** | 1-4 | Relaxed, ambient, peaceful | calm, zen, meditation |
+| **Intense** | 7-10 | Fast, aggressive, urgent | action, chase, combat |
+| **Playful** | 4-7 | Fun, bouncy, whimsical | happy, silly, cartoon |
+| **Mysterious** | 3-6 | Suspenseful, eerie, enigmatic | dark, secret, shadow |
+| **Heroic** | 6-9 | Triumphant, brave, noble | victory, champion, glory |
+| **Quirky** | 4-7 | Unusual, offbeat, eccentric | weird, wacky, unique |
+| **Ambient** | 1-4 | Atmospheric, floating, ethereal | space, dreamy, soft |
+
+### Song Structure
+
+Each song is defined with:
+- **id**: Unique identifier (e.g., `epic_001`)
+- **name**: Human-readable name
+- **mood**: One of the 8 mood categories
+- **energy**: 1-10 scale
+- **tempo**: BPM (60-180)
+- **key**: Musical key (C, D, E, etc.)
+- **scale**: Scale type (minor, major, dorian, phrygian, etc.)
+- **synths**: Lead, pad, and bass instrument types
+- **fx**: Reverb, delay, filter settings
+- **sections**: Intro, verse, chorus, bridge, outro with chord progressions
+- **form**: Section playback order
+
+---
+
+## Background System
+
+### Themes (15 total, 8 variants each)
+
+| Theme | Variants | Mood | Recommended For |
+|-------|----------|------|-----------------|
+| **space** | nebula, asteroidField, deepSpace, planets, spaceStation, warpSpeed, satellites, blackHole | wonder, epic | Shooters, Sci-Fi |
+| **nature** | forest, jungle, desert, mountains, ocean, underwater, waterfall, meadow | serene, peaceful | Adventure, Platformers |
+| **urban** | citySkyline, street, rooftop, neonDistrict, subway, park, mall, alley | energetic, gritty | Racing, Action |
+| **fantasy** | castle, dungeon, enchantedForest, floatingIslands, magicalLibrary, crystalCave, temple, tower | magical, wonder | RPG, Adventure |
+| **abstract** | geometricPatterns, gradients, particleFields, minimalist, waves, fractals, kaleidoscope, matrix | focused, trippy | Puzzle, Creative |
+| **retro** | pixelGrid, arcadeCabinet, crtEffects, vaporwave, scanlines, glitch, eightBit, synthwave | nostalgic, fun | Arcade, Classic |
+| **indoor** | laboratory, classroom, bedroom, kitchen, arcade, office, library, gym | cozy, focused | Puzzle, Educational |
+| **weather** | rain, snow, storm, sunset, sunrise, fog, lightning, aurora | atmospheric, dynamic | Adventure, Mood |
+| **sports** | stadium, raceTrack, court, field, arena, pool, gym, skatepark | energetic, competitive | Sports, Racing |
+| **seasonal** | springMeadow, autumnLeaves, winterWonderland, summerBeach, harvest, blossom, snowfall, tropical | nostalgic, themed | Casual, Seasonal |
+| **underwater** | coralReef, deepSea, kelpForest, submarine, shipwreck, abyss, iceCave, treasure | mysterious, serene | Adventure, Exploration |
+| **sky** | clouds, flyingHigh, hotAirBalloon, airplaneView, stratosphere, birdsEye, horizon, starfield | dreamy, free | Flying, Casual |
+| **mechanical** | gears, circuits, factory, robotFactory, conveyorBelts, pipes, steampunk, techLab | industrial, complex | Puzzle, Tech |
+| **spooky** | hauntedHouse, graveyard, darkForest, abandonedBuilding, crypt, manor, fog, shadows | eerie, tense | Horror-lite, Halloween |
+| **colorful** | rainbow, paintSplatter, candyWorld, disco, neon, tieDye, gradient, prism | vibrant, joyful | Kids, Casual |
+
+---
+
+## Avatar Context System
+
+### Context Categories
+
+#### Vehicles (12 contexts)
+Place your avatar inside vehicles with appropriate framing:
+
+| Context | Avatar Mode | Description | Recommended For |
+|---------|-------------|-------------|-----------------|
+| spaceship-cockpit | HEAD_ONLY | Pilot view in spacecraft | Space shooters |
+| race-car | HEAD_AND_SHOULDERS | Racing car driver | Racing games |
+| airplane | HEAD_ONLY | Airplane pilot | Flying games |
+| flappy-style | HEAD_ONLY | Bird/flying creature | Flappy-type games |
+| tank | HEAD_ONLY | Tank commander | Action games |
+| pacman-style | FULL_BODY | Classic maze runner | Arcade games |
+| hoverboard | FULL_BODY | Futuristic board rider | Racing, Action |
+| jetpack | FULL_BODY | Jetpack flyer | Flying, Action |
+| mech-suit | HEAD_ONLY | Mech pilot | Action, Sci-Fi |
+| submarine | HEAD_AND_SHOULDERS | Sub commander | Underwater |
+| dragon-rider | FULL_BODY | Dragon mount | Fantasy, Adventure |
+| motorcycle | FULL_BODY | Motorcycle rider | Racing |
+
+#### Costumes (10 contexts)
+Dress your avatar in themed outfits:
+
+| Context | Avatar Mode | Description | Recommended For |
+|---------|-------------|-------------|-----------------|
+| knight-armor | FULL_BODY | Medieval knight | RPG, Adventure |
+| space-suit | FULL_BODY | Astronaut suit | Space, Sci-Fi |
+| ninja-outfit | FULL_BODY | Stealth ninja | Action, Stealth |
+| wizard-robes | FULL_BODY | Magic user | Fantasy, RPG |
+| superhero-cape | FULL_BODY | Superhero costume | Action |
+| athlete-uniform | FULL_BODY | Sports gear | Sports |
+| pirate-outfit | FULL_BODY | Pirate costume | Adventure |
+| scientist-labcoat | FULL_BODY | Lab scientist | Puzzle, Educational |
+| chef-outfit | FULL_BODY | Chef uniform | Cooking, Casual |
+| cowboy-gear | FULL_BODY | Western attire | Adventure |
+
+#### Pure/Standard (4 contexts)
+Minimal modifications for classic gameplay:
+
+| Context | Avatar Mode | Description | Recommended For |
+|---------|-------------|-------------|-----------------|
+| platformer-standard | FULL_BODY | Classic platformer | Platformers |
+| sports-player | FULL_BODY | Generic athlete | Sports |
+| adventure-hero | FULL_BODY | Generic adventurer | Adventure |
+| runner | FULL_BODY | Running character | Endless runners |
+
+### Avatar Display Modes
+
+```
++----------------+ +-----------------------+ +-------------------+
+| HEAD_ONLY | | HEAD_AND_SHOULDERS | | FULL_BODY |
+| | | | | |
+| .---. | | .---. | | .---. |
+| ( o o ) | | ( o o ) | | ( o o ) |
+| '-.-' | | '-.-' | | '-.-' |
+| | | /| |\ | | /| |\ |
+| [Vehicle | | / | | \ | | / | | \ |
+| Cockpit] | | [Upper Body] | | / \ |
+| | | | | / \ |
++----------------+ +-----------------------+ +-------------------+
+ Used for: Used for: Used for:
+ - Cockpits - Racing vehicles - Platformers
+ - First-person - Seated contexts - Full character
+ - Tight frames - Partial views - Action games
+```
+
+---
+
+## Preset System
+
+### Game Styles (11 total, 20 presets each)
+
+| Style ID | Name | Description | Example Presets |
+|----------|------|-------------|-----------------|
+| action-arcade | Action/Arcade | Space shooters, platformers, fast action | Space Hero, Retro Runner, Tank Commander |
+| puzzle | Puzzle | Match-3, sliding, brain teasers | Zen Garden, Geometric Dreams, Candy Match |
+| story-adventure | Story Adventure | Visual novels, choose-your-adventure | Dragon's Quest, Enchanted Forest, Mystery Manor |
+| racing | Racing | Car racing, running, speed games | Speed Demon, Neon Racer, Desert Rally |
+| pet-simulator | Pet/Simulator | Virtual pets, farm games, life sim | Happy Farm, Pet Paradise, Garden Life |
+| trivia-quiz | Trivia/Quiz | Quiz shows, knowledge tests | Brain Challenge, Quiz Master, Fact Hunter |
+| music-rhythm | Music/Rhythm | Rhythm games, dance games | Disco Fever, Beat Drop, Neon Rhythm |
+| creative-drawing | Creative/Drawing | Drawing, design, art creation | Art Studio, Color Splash, Pixel Creator |
+| rpg-battle | RPG/Battle | Turn-based battles, dungeons | Dragon Slayer, Dark Dungeon, Hero's Quest |
+| sports | Sports | Ball games, athletics | Stadium Glory, Court King, Field Champion |
+| physics-pinball | Physics/Pinball | Pinball, physics puzzles | Pinball Wizard, Gravity Drop, Bounce Master |
+
+### Preset Structure
+
+Each preset defines a complete asset combination:
+
+```javascript
+{
+ id: "action-space-01",
+ gameStyle: "action-arcade",
+ name: "Space Hero",
+ description: "Epic space battle with your avatar piloting a fighter",
+ music: { mood: "Epic", energy: 9 },
+ background: { theme: "space", variant: "nebula" },
+ avatarContext: { mode: "HEAD_ONLY", context: "spaceship-cockpit" },
+ colorPalette: "space-blue",
+ tags: ["space", "shooter", "epic", "sci-fi"]
+}
+```
+
+---
+
+## Compatibility System
+
+### Compatibility Levels
+
+The system validates combinations across four levels:
+
+| Level | Description |
+|-------|-------------|
+| **Excellent** | Perfect match, highly recommended |
+| **Good** | Works well together |
+| **Neutral** | Acceptable, no issues |
+| **Poor** | May feel disjointed, suggests alternatives |
+
+### Mood-Background Compatibility Matrix
+
+```
+ | space | nature | urban | fantasy | spooky | colorful | retro |
+-------------|-------|--------|-------|---------|--------|----------|-------|
+Epic | E | G | G | E | N | N | N |
+Chill | N | E | P | N | P | G | N |
+Intense | G | N | E | N | G | P | G |
+Playful | N | E | P | N | P | E | E |
+Mysterious | N | G | G | E | E | P | P |
+Heroic | E | G | E | E | N | P | N |
+Quirky | P | G | N | N | P | E | E |
+Ambient | N | E | P | N | P | G | N |
+
+E = Excellent, G = Good, N = Neutral, P = Poor
+```
+
+### Validation API
+
+```javascript
+// Validate a combination
+var result = AssetCompatibility.validate({
+ music: { mood: 'Epic' },
+ background: { theme: 'space' },
+ avatarContext: { context: 'spaceship-cockpit' }
+});
+
+// {
+// valid: true,
+// score: 95,
+// issues: []
+// }
+
+// Get suggestions for improvement
+var suggestions = AssetCompatibility.suggestImprovements(config);
+```
+
+---
+
+## System Files
+
+### Complete File Listing
+
+```
+/frontend/public/assets/
+|
++-- assetManager.js ~45 KB Main coordinator, recommendations
++-- assetCatalog.js ~12 KB Unified queries, game styles
++-- compatibility.js ~35 KB Validation rules, suggestions
++-- presets.js ~65 KB 220 preset definitions
+|
++-- music/
+| +-- musicEngine.js ~25 KB Tone.js synthesis engine
+| +-- musicLibrary.js ~80 KB 160 song definitions
+| +-- moodCategories.js ~5 KB Mood metadata
+|
++-- backgrounds/
+| +-- backgroundEngine.js ~15 KB Canvas 2D render engine
+| +-- backgroundCatalog.js ~8 KB Catalog queries
+| +-- effects.js ~10 KB Shared visual effects
+| +-- themes/
+| +-- space.js ~12 KB 8 space variants
+| +-- nature.js ~15 KB 8 nature variants
+| +-- urban.js ~12 KB 8 urban variants
+| +-- fantasy.js ~14 KB 8 fantasy variants
+| +-- abstract.js ~10 KB 8 abstract variants
+| +-- retro.js ~11 KB 8 retro variants
+| +-- indoor.js ~13 KB 8 indoor variants
+| +-- weather.js ~12 KB 8 weather variants
+| +-- sports.js ~11 KB 8 sports variants
+| +-- seasonal.js ~12 KB 8 seasonal variants
+| +-- underwater.js ~13 KB 8 underwater variants
+| +-- sky.js ~10 KB 8 sky variants
+| +-- mechanical.js ~11 KB 8 mechanical variants
+| +-- spooky.js ~12 KB 8 spooky variants
+| +-- colorful.js ~10 KB 8 colorful variants
+|
++-- avatar/
+ +-- avatarSystem.js ~20 KB Avatar management
+ +-- avatarRenderer.js ~25 KB Canvas 2D avatar drawing
+ +-- avatarData.js ~15 KB Customization options
+ +-- accessories.js ~18 KB 45 accessory definitions
+ +-- animations.js ~12 KB Animation system
+ +-- accessoryRenderer.js ~10 KB Accessory drawing
+ +-- contextCatalog.js ~8 KB Context queries
+ +-- contextRenderer.js ~10 KB Context rendering
+ +-- contexts/
+ +-- vehicles.js ~45 KB 12 vehicle contexts
+ +-- costumes.js ~50 KB 10 costume contexts
+ +-- pure.js ~15 KB 4 standard contexts
+
+Total: ~650 KB (uncompressed JavaScript)
+ ~150 KB (gzipped)
+```
+
+---
+
+## Related Documentation
+
+- [Music Catalog](MUSIC_CATALOG.md) - Complete song listing and synthesis details
+- [Background Catalog](BACKGROUND_CATALOG.md) - All themes and variants
+- [Avatar System](AVATAR_SYSTEM.md) - Customization options and rendering
+- [Context Catalog](CONTEXT_CATALOG.md) - Vehicle, costume, and pure contexts
+- [API Reference](API_REFERENCE.md) - Complete API documentation
+- [Preset Guide](PRESET_GUIDE.md) - Using and creating presets
+- [Game Generation Guide](GAME_GENERATION_GUIDE.md) - Integrating assets into games
+- [Adding New Assets](ADDING_NEW_ASSETS.md) - Extending the library
+
+---
+
+## Interactive Tools
+
+- **[Asset Browser](/asset-browser.html)** - Visual exploration of all assets, preview combinations
+- **[Avatar Creator](/avatar-creator/index.html)** - Test avatar customization, try contexts
+
+---
+
+## API Quick Reference
+
+### AssetManager
+
+```javascript
+// Initialization
+AssetManager.initialize(options) // Promise - Initialize all systems
+AssetManager.isInitialized() // boolean - Check ready state
+
+// Recommendations
+AssetManager.getRecommendations( // Array - Get top 3 recommendations
+ gameStyle, keywords, mood
+)
+
+// Detection Helpers
+AssetManager.detectTheme(keywords) // string - Detect theme from keywords
+AssetManager.detectMood(keywords) // string - Detect mood from keywords
+AssetManager.detectMechanics(keywords) // Object - Detect avatar mode/context
+
+// Preset Lookup
+AssetManager.findPreset(gameStyle, index) // Object - Find preset by style
+AssetManager.presetToConfig(preset) // Object - Convert to config
+
+// Asset Loading
+AssetManager.loadGameAssets(config) // Promise - Load assets
+AssetManager.getLoadedAssets() // Object - Get current assets
+
+// Validation
+AssetManager.validateCombination(config) // Object - Validate compatibility
+
+// Utilities
+AssetManager.describeAssets(config) // string - Human description
+AssetManager.getVariation(config, type) // Object - Get variation
+AssetManager.searchAssets(query) // Object - Search all assets
+AssetManager.getRandomCombination(style) // Object - Random combo
+
+// Direct Subsystem Access
+AssetManager.music() // MusicEngine
+AssetManager.backgrounds() // BackgroundEngine
+AssetManager.avatars() // AvatarSystem
+AssetManager.contexts() // ContextRenderer
+AssetManager.catalog() // AssetCatalog
+AssetManager.presets() // AssetPresets
+AssetManager.compatibility() // AssetCompatibility
+```
+
+### AssetCompatibility
+
+```javascript
+// Validation
+validate(config) // Object - Full validation
+checkFullCompatibility(mood, theme, ctx) // Object - Quick check
+
+// Compatibility Queries
+getMoodBackgroundCompat(mood, theme) // string - Compatibility level
+getThemeContextCompat(theme, context) // string - Compatibility level
+getContextAnimationCompat(context, anim) // string - Animation compat
+getPaletteHarmony(palette1, palette2) // string - Color harmony
+
+// Suggestions
+getMusicForTheme(theme) // Array - Compatible moods
+getBackgroundsForMood(mood) // Array - Compatible themes
+getContextsForTheme(theme) // Array - Compatible contexts
+getContextsForMechanics(mechanics) // Array - Mechanics-based
+suggestImprovements(config) // Array - Improvement suggestions
+
+// Animation Helpers
+getRequiredAnimations(context) // Array - Required anims
+getRecommendedAnimations(context) // Array - Recommended anims
+getValidAnimations(context) // Object - All animation info
+```
+
+---
+
+## Version History
+
+| Version | Date | Changes |
+|---------|------|---------|
+| 1.0.0 | 2026-02-05 | Initial release with full asset library |
+
+---
+
+*GamerComp Asset Library - Procedural Game Assets for Everyone*
diff --git a/frontend/public/docs/AVATAR_SYSTEM.md b/frontend/public/docs/AVATAR_SYSTEM.md
new file mode 100644
index 0000000..7b5a027
--- /dev/null
+++ b/frontend/public/docs/AVATAR_SYSTEM.md
@@ -0,0 +1,734 @@
+# Avatar System
+
+## Overview
+
+Mii-style avatar system with full customization and level-based unlockable accessories. The system supports four rendering modes optimized for different game types, with smooth animations and real-time accessory rendering.
+
+## Architecture
+
+The avatar system consists of several modules:
+
+| Module | File | Purpose |
+|--------|------|---------|
+| AvatarData | `avatarData.js` | Customization options and guest avatars |
+| AvatarSystem | `avatarSystem.js` | Main API for creation, rendering, accessories |
+| AvatarRenderer | `avatarRenderer.js` | Canvas 2D rendering engine |
+| AvatarAnimations | `animations.js` | Animation definitions per mode |
+| AvatarAccessories | `accessories.js` | Unlockable accessories catalog |
+| AccessoryRenderer | `accessoryRenderer.js` | Accessory rendering on avatars |
+
+---
+
+## Customization Options
+
+### Face Shape (8 options)
+
+| ID | Name | Description |
+|----|------|-------------|
+| `round` | Round | Circular face with soft jawline |
+| `oval` | Oval | Classic elongated oval shape |
+| `square` | Square | Strong angular jawline |
+| `heart` | Heart | Wide forehead, pointed chin |
+| `long` | Long | Narrow, elongated face |
+| `wide` | Wide | Broader face shape |
+| `angular` | Angular | Defined cheekbones, sharp features |
+| `soft` | Soft | Rounded features throughout |
+
+### Skin Tone (20 options)
+
+| ID | Name | Hex Code |
+|----|------|----------|
+| `fair1` | Porcelain | `#FFECD1` |
+| `fair2` | Ivory | `#FFE4C4` |
+| `fair3` | Peach | `#FFDAB9` |
+| `fair4` | Cream | `#FFD5B5` |
+| `light1` | Light Beige | `#F5D0A9` |
+| `light2` | Warm Beige | `#E8C49A` |
+| `light3` | Sand | `#DEB887` |
+| `medium1` | Golden | `#D4A373` |
+| `medium2` | Honey | `#C9A06A` |
+| `medium3` | Caramel | `#BC8F5F` |
+| `medium4` | Toffee | `#B07D4B` |
+| `tan1` | Tan | `#A67B5B` |
+| `tan2` | Bronze | `#996B4D` |
+| `tan3` | Cinnamon | `#8B6D4C` |
+| `brown1` | Chestnut | `#7D5A3C` |
+| `brown2` | Cocoa | `#6F4E37` |
+| `brown3` | Espresso | `#5D4532` |
+| `dark1` | Mocha | `#4E3B2D` |
+| `dark2` | Ebony | `#3D2B1F` |
+| `dark3` | Onyx | `#2C1F15` |
+
+### Eye Shape (12 options)
+
+| ID | Name | Description |
+|----|------|-------------|
+| `round` | Round | Large, circular eyes |
+| `almond` | Almond | Classic almond shape |
+| `oval` | Oval | Soft oval shape |
+| `narrow` | Narrow | Slim, elongated eyes |
+| `wide` | Wide | Broad, expressive eyes |
+| `droopy` | Droopy | Downturned outer corners |
+| `upturned` | Upturned | Lifted outer corners |
+| `cat` | Cat | Sharp upturned cat-eye |
+| `sleepy` | Sleepy | Heavy-lidded, relaxed look |
+| `surprised` | Surprised | Wide open, alert |
+| `angry` | Angry | Angled inward brow line |
+| `happy` | Happy | Slightly squinted, joyful |
+
+### Eye Color (16 options)
+
+| ID | Name | Hex Code |
+|----|------|----------|
+| `brown` | Brown | `#634E34` |
+| `darkBrown` | Dark Brown | `#3D2314` |
+| `hazel` | Hazel | `#8B7355` |
+| `green` | Green | `#3D7A3D` |
+| `blue` | Blue | `#4682B4` |
+| `lightBlue` | Light Blue | `#87CEEB` |
+| `gray` | Gray | `#708090` |
+| `amber` | Amber | `#B8860B` |
+| `violet` | Violet | `#8B008B` |
+| `emerald` | Emerald | `#50C878` |
+| `iceBlue` | Ice Blue | `#A5D8E6` |
+| `gold` | Gold | `#DAA520` |
+| `red` | Red | `#8B0000` |
+| `black` | Black | `#1A1A1A` |
+| `honey` | Honey | `#C9A06A` |
+| `turquoise` | Turquoise | `#40E0D0` |
+
+**Total Eye Combinations**: 12 shapes x 16 colors = **192 unique eye options**
+
+### Eyebrow Shape (10 options)
+
+| ID | Name | Description |
+|----|------|-------------|
+| `natural` | Natural | Balanced, standard arch |
+| `arched` | Arched | High dramatic arch |
+| `straight` | Straight | Flat, horizontal brows |
+| `thick` | Thick | Full, bold brows |
+| `thin` | Thin | Delicate, fine brows |
+| `angry` | Angry | Angled down toward center |
+| `worried` | Worried | Angled up toward center |
+| `bushy` | Bushy | Wild, untamed look |
+| `sleek` | Sleek | Thin with elegant arch |
+| `curved` | Curved | Pronounced curved shape |
+
+### Nose Shape (8 options)
+
+| ID | Name | Description |
+|----|------|-------------|
+| `button` | Button | Small, rounded tip |
+| `pointed` | Pointed | Narrow with defined tip |
+| `roman` | Roman | Prominent bridge curve |
+| `snub` | Snub | Short with slight upturn |
+| `wide` | Wide | Broader nostril base |
+| `narrow` | Narrow | Thin, elongated bridge |
+| `aquiline` | Aquiline | Pronounced curved bridge |
+| `flat` | Flat | Low bridge, wide base |
+
+### Mouth Shape (10 options)
+
+| ID | Name | Description |
+|----|------|-------------|
+| `smile` | Smile | Gentle upward curve |
+| `neutral` | Neutral | Relaxed, straight line |
+| `grin` | Grin | Wide smile showing teeth |
+| `smirk` | Smirk | Asymmetric half-smile |
+| `pout` | Pout | Full lips, slight downturn |
+| `open` | Open | Slightly parted lips |
+| `small` | Small | Petite, compact mouth |
+| `wide` | Wide | Broader mouth width |
+| `thin` | Thin | Narrow lip fullness |
+| `full` | Full | Plump, pronounced lips |
+
+### Ear Shape (6 options)
+
+| ID | Name | Description |
+|----|------|-------------|
+| `normal` | Normal | Standard ear shape and size |
+| `small` | Small | Compact, closer to head |
+| `large` | Large | Prominent, larger ears |
+| `pointed` | Pointed | Elf-like pointed tips |
+| `round` | Round | Soft, rounded lobes |
+| `flat` | Flat | Close-set against head |
+
+### Hair Style (30 options)
+
+| ID | Name | Length | Coverage |
+|----|------|--------|----------|
+| `short-messy` | Short Messy | Short | 0.30 |
+| `short-neat` | Short Neat | Short | 0.25 |
+| `short-curly` | Short Curly | Short | 0.35 |
+| `medium-straight` | Medium Straight | Medium | 0.50 |
+| `medium-wavy` | Medium Wavy | Medium | 0.55 |
+| `long-straight` | Long Straight | Long | 0.70 |
+| `long-wavy` | Long Wavy | Long | 0.75 |
+| `ponytail` | Ponytail | Medium | 0.40 |
+| `pigtails` | Pigtails | Medium | 0.45 |
+| `bun` | Bun | Long | 0.35 |
+| `mohawk` | Mohawk | Short | 0.20 |
+| `afro` | Afro | Medium | 0.80 |
+| `buzz` | Buzz Cut | Short | 0.15 |
+| `bald` | Bald | None | 0.00 |
+| `sidepart` | Side Part | Short | 0.30 |
+| `spiky` | Spiky | Short | 0.35 |
+| `slicked` | Slicked Back | Short | 0.25 |
+| `bob` | Bob | Medium | 0.50 |
+| `pixie` | Pixie | Short | 0.35 |
+| `braids` | Braids | Long | 0.60 |
+| `dreads` | Dreads | Long | 0.70 |
+| `undercut` | Undercut | Short | 0.25 |
+| `fauxhawk` | Faux Hawk | Short | 0.30 |
+| `mullet` | Mullet | Medium | 0.45 |
+| `bowl` | Bowl Cut | Medium | 0.55 |
+| `combover` | Comb Over | Short | 0.20 |
+| `bangs` | Bangs | Medium | 0.50 |
+| `sidesweep` | Side Sweep | Medium | 0.45 |
+| `topknot` | Top Knot | Long | 0.30 |
+| `cornrows` | Cornrows | Medium | 0.40 |
+
+### Hair Color (20 options)
+
+| ID | Name | Hex Code | Type |
+|----|------|----------|------|
+| `black` | Black | `#1A1A1A` | Natural |
+| `darkBrown` | Dark Brown | `#2C1810` | Natural |
+| `brown` | Brown | `#4A3728` | Natural |
+| `lightBrown` | Light Brown | `#7A5A3A` | Natural |
+| `auburn` | Auburn | `#8B4513` | Natural |
+| `ginger` | Ginger | `#B5651D` | Natural |
+| `strawberry` | Strawberry | `#C67171` | Natural |
+| `blonde` | Blonde | `#D4B896` | Natural |
+| `platinum` | Platinum | `#E8E4C9` | Natural |
+| `white` | White | `#F0F0F0` | Natural |
+| `gray` | Gray | `#808080` | Natural |
+| `red` | Red | `#CC0000` | Fantasy |
+| `blue` | Blue | `#0066CC` | Fantasy |
+| `purple` | Purple | `#7B2D8E` | Fantasy |
+| `green` | Green | `#228B22` | Fantasy |
+| `pink` | Pink | `#FF69B4` | Fantasy |
+| `teal` | Teal | `#008B8B` | Fantasy |
+| `orange` | Orange | `#FF6600` | Fantasy |
+| `silver` | Silver | `#C0C0C0` | Fantasy |
+| `rainbow` | Rainbow | Gradient | Fantasy |
+
+### Facial Hair (10 options)
+
+| ID | Name | Coverage | Description |
+|----|------|----------|-------------|
+| `none` | None | - | Clean shaven |
+| `stubble` | Stubble | Light | 5 o'clock shadow |
+| `goatee` | Goatee | Chin | Chin beard only |
+| `mustache` | Mustache | Upper lip | Classic mustache |
+| `fullBeard` | Full Beard | Full face | Complete beard coverage |
+| `shortBeard` | Short Beard | Full face | Trimmed full beard |
+| `soulPatch` | Soul Patch | Chin center | Small chin patch |
+| `mutton` | Mutton Chops | Sides | Sideburn-connected style |
+| `handlebar` | Handlebar | Upper lip | Curled mustache tips |
+| `vandyke` | Van Dyke | Chin + mustache | Goatee with mustache |
+
+### Body Type (6 options)
+
+| ID | Name | Width Ratio | Height Ratio |
+|----|------|-------------|--------------|
+| `slim` | Slim | 0.80 | 1.00 |
+| `average` | Average | 1.00 | 1.00 |
+| `athletic` | Athletic | 1.10 | 1.00 |
+| `stocky` | Stocky | 1.20 | 0.95 |
+| `tall` | Tall | 0.95 | 1.15 |
+| `short` | Short | 1.00 | 0.85 |
+
+### Clothing Style (4 options)
+
+| ID | Name | Has Collar | Sleeve Length | Special |
+|----|------|------------|---------------|---------|
+| `casual` | Casual | No | Short | - |
+| `sporty` | Sporty | No | Short | Stripes |
+| `formal` | Formal | Yes | Long | Buttons |
+| `scifi` | Sci-Fi | Yes | Long | Glow effects |
+
+### Clothing Color (20 preset colors)
+
+```
+#E74C3C #E67E22 #F1C40F #2ECC71 #1ABC9C
+#3498DB #9B59B6 #34495E #95A5A6 #ECF0F1
+#C0392B #D35400 #F39C12 #27AE60 #16A085
+#2980B9 #8E44AD #2C3E50 #7F8C8D #BDC3C7
+```
+
+---
+
+## Earned Accessories
+
+### Rarity System
+
+| Rarity | Color | Level Range | Hex Code |
+|--------|-------|-------------|----------|
+| Common | Gray | 1-15 | `#9D9D9D` |
+| Uncommon | Green | 16-30 | `#1EFF00` |
+| Rare | Blue | 31-50 | `#0070DD` |
+| Epic | Purple | 51-70 | `#A335EE` |
+| Legendary | Orange | 71-90 | `#FF8000` |
+| Mythic | Gold | 91-100 | `#E6CC80` |
+
+### Eyewear (15 items)
+
+| ID | Name | Unlock Level | Rarity | Has Effect | Description |
+|----|------|--------------|--------|------------|-------------|
+| `sunglasses-classic` | Classic Sunglasses | 5 | Common | No | Timeless shades for any occasion |
+| `aviator` | Aviator Glasses | 10 | Common | No | Top Gun approved eyewear |
+| `nerd-glasses` | Nerd Glasses | 15 | Common | No | Intelligence +100 (cosmetic only) |
+| `3d-glasses` | 3D Glasses | 20 | Uncommon | No | See the world in a whole new dimension |
+| `goggles` | Goggles | 25 | Uncommon | No | Ready for adventure or science experiments |
+| `monocle` | Monocle | 30 | Uncommon | No | Distinguished and sophisticated |
+| `star-glasses` | Star Glasses | 35 | Rare | No | You are a star! |
+| `heart-glasses` | Heart Glasses | 40 | Rare | No | Spread the love wherever you go |
+| `sport-visor` | Sport Visor | 45 | Rare | No | Professional gamer gear |
+| `vr-headset` | VR Headset | 50 | Epic | No | Immerse yourself in virtual worlds |
+| `cyber-visor` | Cyber Visor | 55 | Epic | No | Sleek futuristic eye protection |
+| `x-ray-specs` | X-Ray Specs | 60 | Epic | Yes | See through walls... probably |
+| `laser-eyes` | Laser Eyes | 70 | Legendary | Yes | Pew pew! Lasers shoot from your eyes |
+| `diamond-glasses` | Diamond Glasses | 80 | Legendary | No | Pure crystallized luxury |
+| `cosmic-lenses` | Cosmic Lenses | 90 | Mythic | Yes | Gaze upon the infinite universe |
+
+### Headwear (20 items)
+
+| ID | Name | Unlock Level | Rarity | Has Effect | Description |
+|----|------|--------------|--------|------------|-------------|
+| `baseball-cap` | Baseball Cap | 3 | Common | No | A classic casual cap |
+| `backwards-cap` | Backwards Cap | 8 | Common | No | Cool kids wear it backwards |
+| `beanie` | Beanie | 12 | Common | No | Cozy knitted headwear |
+| `party-hat` | Party Hat | 18 | Uncommon | No | Every day is a celebration! |
+| `chef-hat` | Chef Hat | 22 | Uncommon | No | Master of the kitchen |
+| `crown-bronze` | Bronze Crown | 25 | Uncommon | No | A humble crown for rising royalty |
+| `hard-hat` | Hard Hat | 26 | Uncommon | No | Safety first on the job site |
+| `wizard-hat` | Wizard Hat | 30 | Rare | No | Channel your inner sorcerer |
+| `pirate-hat` | Pirate Hat | 35 | Rare | No | Arrr! Set sail for adventure |
+| `santa-hat` | Santa Hat | 38 | Rare | No | Ho ho ho! Spread holiday cheer |
+| `crown-silver` | Silver Crown | 40 | Rare | No | A noble crown for dedicated players |
+| `graduation-cap` | Graduation Cap | 42 | Rare | No | Scholar of the gaming arts |
+| `bunny-ears` | Bunny Ears | 45 | Rare | No | Hop into the fun! |
+| `devil-horns` | Devil Horns | 48 | Rare | No | A little mischief never hurt anyone |
+| `halo` | Halo | 52 | Epic | Yes | An angelic golden ring of light |
+| `top-hat` | Top Hat | 56 | Epic | No | Dapper and distinguished |
+| `crown-gold` | Gold Crown | 60 | Epic | No | True royalty in golden splendor |
+| `viking-helmet` | Viking Helmet | 62 | Epic | No | Conquer games like a Norse warrior |
+| `crown-diamond` | Diamond Crown | 80 | Legendary | Yes | A magnificent crown of pure diamonds |
+| `crown-cosmic` | Cosmic Crown | 100 | Mythic | Yes | The ultimate crown, forged from stardust |
+
+### Effects/Auras (10 items)
+
+| ID | Name | Unlock Level | Rarity | Effect Type | Color | Description |
+|----|------|--------------|--------|-------------|-------|-------------|
+| `sparkle-trail` | Sparkle Trail | 15 | Uncommon | Particles | `#FFD700` | Leave a trail of golden sparkles |
+| `fire-aura` | Fire Aura | 25 | Rare | Glow | `#FF4500` | Surrounded by fierce flames |
+| `lightning-crackle` | Lightning Crackle | 35 | Rare | Electric | `#00BFFF` | Electricity crackles around you |
+| `rainbow-glow` | Rainbow Glow | 45 | Epic | Glow | Rainbow | Radiate all colors of the spectrum |
+| `star-particles` | Star Particles | 55 | Epic | Particles | `#FFFF00` | Tiny stars orbit around you |
+| `ice-crystals` | Ice Crystals | 65 | Legendary | Particles | `#87CEEB` | Frozen crystals shimmer around you |
+| `shadow-effect` | Shadow Effect | 70 | Legendary | Shadow | `#2F2F4F` | Darkness follows your every move |
+| `golden-glow` | Golden Glow | 75 | Legendary | Glow | `#FFD700` | Bathe in pure golden light |
+| `void-aura` | Void Aura | 85 | Mythic | Void | `#4B0082` | The void itself surrounds you |
+| `cosmic-trail` | Cosmic Trail | 95 | Mythic | Cosmic | Cosmic | Trail galaxies and nebulae in your wake |
+
+**Total Accessories**: 15 + 20 + 10 = **45 unlockable items**
+
+---
+
+## Rendering Modes
+
+### HEAD_ONLY (64x64 pixels)
+
+**Best for**: Cockpit views, flying games (Flappy Bird style), maze games, puzzle games
+
+**Shows**: Face, hair, ears, accessories (eyewear, headwear, effects)
+
+**Animations** (5):
+| ID | Name | Frames | Duration | Loop | Description |
+|----|------|--------|----------|------|-------------|
+| `bob` | Bob | 3 | 150ms | Yes | Gentle up/down bobbing motion |
+| `flap` | Flap | 2 | 80ms | Yes | Flapping motion with head squash/stretch |
+| `blink` | Blink | 3 | 60ms | No | Eye blink animation |
+| `celebrate` | Celebrate | 4 | 100ms | No | Happy bouncing celebration |
+| `hurt` | Hurt | 2 | 80ms | No | Damage reaction shake |
+
+### HEAD_AND_SHOULDERS (64x96 pixels)
+
+**Best for**: Driving games, conversation scenes, submarine games, portrait views
+
+**Shows**: Head, neck, shoulders, clothing collar
+
+**Animations** (6):
+| ID | Name | Frames | Duration | Loop | Description |
+|----|------|--------|----------|------|-------------|
+| `idle` | Idle | 3 | 400ms | Yes | Subtle breathing motion |
+| `turn-left` | Turn Left | 2 | 100ms | No | Head turns to the left |
+| `turn-right` | Turn Right | 2 | 100ms | No | Head turns to the right |
+| `look-up` | Look Up | 2 | 100ms | No | Head tilts upward |
+| `celebrate` | Celebrate | 3 | 120ms | No | Bounce and smile celebration |
+| `hurt` | Hurt | 2 | 100ms | No | Recoil damage reaction |
+
+### UPPER_BODY (64x128 pixels)
+
+**Best for**: Racing games, shooting games, hoverboard games, action sequences
+
+**Shows**: Head, neck, shoulders, torso, arms, hands
+
+**Animations** (8):
+| ID | Name | Frames | Duration | Loop | Description |
+|----|------|--------|----------|------|-------------|
+| `idle` | Idle | 3 | 400ms | Yes | Breathing with subtle arm sway |
+| `wave` | Wave | 4 | 120ms | No | Friendly arm wave gesture |
+| `point` | Point | 3 | 100ms | No | Pointing forward gesture |
+| `steer-left` | Steer Left | 2 | 80ms | No | Leaning left for racing/steering |
+| `steer-right` | Steer Right | 2 | 80ms | No | Leaning right for racing/steering |
+| `shoot` | Shoot | 3 | 60ms | No | Arm extends forward shooting motion |
+| `celebrate` | Celebrate | 4 | 120ms | No | Arms up victory celebration |
+| `hurt` | Hurt | 2 | 100ms | No | Recoil backwards damage reaction |
+
+### FULL_BODY (64x160 pixels)
+
+**Best for**: Platformers, RPGs, sports games, adventure games, fighting games
+
+**Shows**: Complete character with head, body, arms, legs, shoes
+
+**Animations** (12):
+| ID | Name | Frames | Duration | Loop | Description |
+|----|------|--------|----------|------|-------------|
+| `idle` | Idle | 3 | 400ms | Yes | Standing with breathing motion |
+| `walk` | Walk Cycle | 4 | 100ms | Yes | Walking with arm/leg swing |
+| `run` | Run Cycle | 6 | 60ms | Yes | Running with full body movement |
+| `jump` | Jump | 3 | 100ms | No | Crouch, leap, stretch sequence |
+| `fall` | Fall | 2 | 150ms | Yes | Arms up, legs down falling pose |
+| `crouch` | Crouch | 2 | 80ms | No | Duck down into crouch position |
+| `celebrate` | Celebrate | 6 | 100ms | No | Jump and arm wave celebration |
+| `attack` | Attack | 4 | 60ms | No | Punch or swing attack motion |
+| `hurt` | Hurt | 3 | 80ms | No | Stagger backwards damage reaction |
+| `climb` | Climb | 4 | 120ms | Yes | Climbing ladder/wall motion |
+| `swim` | Swim | 4 | 150ms | Yes | Swimming stroke animation |
+| `fly` | Fly | 4 | 100ms | Yes | Superhero flying pose |
+
+---
+
+## API Reference
+
+### Creating Avatars
+
+```javascript
+// Create avatar with custom options
+const avatar = AvatarSystem.create({
+ faceShape: 'round',
+ skinTone: '#FFD5B8',
+ eyes: { shape: 'round', color: '#4A90D9' },
+ eyebrows: 'natural',
+ nose: 'button',
+ mouth: 'smile',
+ ears: 'normal',
+ hair: { style: 'short-messy', color: '#3D2314' },
+ facialHair: 'none',
+ body: { type: 'average' },
+ clothing: { style: 'casual', shirtColor: '#3498DB', pantsColor: '#2C3E50' }
+});
+
+// Create default avatar
+const defaultAvatar = AvatarSystem.createDefault();
+
+// Get random guest avatar
+const guestAvatar = AvatarSystem.getGuestAvatar();
+```
+
+### Loading and Saving
+
+```javascript
+// Load user's saved avatar
+const avatar = await AvatarSystem.loadUserAvatar(userId);
+
+// Save avatar
+AvatarSystem.saveAvatar(avatar);
+
+// Clear cache
+AvatarSystem.clearCache(userId); // Clear specific user
+AvatarSystem.clearCache(); // Clear all
+```
+
+### Rendering
+
+```javascript
+// Render to canvas context
+AvatarSystem.render(avatar, 'FULL_BODY', ctx, x, y, {
+ scale: 1,
+ animation: 'walk',
+ frame: 0
+});
+
+// Render to offscreen canvas
+const canvas = AvatarSystem.renderToCanvas(avatar, 'HEAD_ONLY', { scale: 2 });
+
+// Render to data URL (for images)
+const dataUrl = AvatarSystem.renderToDataURL(avatar, 'HEAD_AND_SHOULDERS');
+
+// Get mode dimensions
+const size = AvatarSystem.getModeSize('FULL_BODY');
+// Returns: { width: 64, height: 160 }
+```
+
+### Animation Control
+
+```javascript
+// Start animation
+AvatarSystem.startAnimation(avatarId, 'walk', {
+ loop: true,
+ mode: 'FULL_BODY',
+ onComplete: () => console.log('Animation finished')
+});
+
+// Stop animation
+AvatarSystem.stopAnimation(avatarId);
+
+// Get current animation state
+const state = AvatarSystem.getAnimationState(avatarId);
+// Returns: { animation: 'walk', frame: 2, complete: false, transforms: {...} }
+
+// Check if animating
+const isAnimating = AvatarSystem.isAnimating(avatarId);
+
+// Get animation frame transforms directly
+const transforms = AvatarSystem.animate(avatar, 'run', frameNumber);
+```
+
+### Accessory Management
+
+```javascript
+// Equip accessory
+AvatarSystem.equipAccessory(avatar, 'headwear', 'crown-gold');
+AvatarSystem.equipAccessory(avatar, 'eyewear', 'sunglasses-classic');
+AvatarSystem.equipAccessory(avatar, 'effect', 'sparkle-trail');
+
+// Unequip accessory
+AvatarSystem.unequipAccessory(avatar, 'headwear');
+
+// Unlock/award accessory
+const newlyUnlocked = AvatarSystem.unlockAccessory(avatar, 'vr-headset');
+// Returns: true if newly unlocked, false if already owned
+
+// Check if avatar has accessory
+const hasItem = AvatarSystem.hasAccessory(avatar, 'crown-gold');
+
+// Get earned accessories (full objects)
+const earned = AvatarSystem.getEarnedAccessories(avatar);
+// Returns: { eyewear: [...], headwear: [...], effects: [...] }
+
+// Get equipped accessories (full objects)
+const equipped = AvatarSystem.getEquippedAccessories(avatar);
+// Returns: { eyewear: {...}, headwear: {...}, effect: {...} }
+
+// Get full accessory catalog
+const catalog = AvatarSystem.getAccessoryCatalog();
+```
+
+### Level Unlocks
+
+```javascript
+// Check what unlocks at a specific level
+const newItems = AvatarSystem.checkLevelUnlocks(avatar, 50);
+// Returns: Array of newly unlocked accessory objects
+
+// Add XP and check for level ups
+const result = AvatarSystem.addXP(avatar, 500);
+// Returns: { newLevel: 12, leveledUp: true, unlockedItems: [...] }
+
+// Get XP progress info
+const xpInfo = AvatarSystem.getXPInfo(avatar);
+// Returns: { level, totalXP, currentLevelXP, xpNeededForLevel, progress }
+```
+
+### Game Integration
+
+```javascript
+// Export avatar for use in a game
+const gameAvatar = AvatarSystem.exportForGame(avatar, 'FULL_BODY');
+// Returns object with render() and animate() methods
+
+// Use in game loop
+gameAvatar.render(ctx, x, y, { animation: 'run', frame: frameNum });
+const animInfo = gameAvatar.getAnimationInfo('run');
+
+// Export minimal data for network transmission
+const minimal = AvatarSystem.exportMinimal(avatar);
+
+// Import from minimal data
+const fullAvatar = AvatarSystem.importMinimal(minimalData, userId);
+
+// Create sprite sheet for performance
+const spriteSheet = AvatarSystem.createSpriteSheet(avatar, 'FULL_BODY', ['idle', 'walk', 'run']);
+// Returns: { canvas, frameWidth, frameHeight, getFrameRect(animId, frame) }
+```
+
+### Utility Functions
+
+```javascript
+// Clone avatar
+const cloned = AvatarSystem.clone(avatar);
+
+// Update base properties
+AvatarSystem.updateBase(avatar, { hair: { style: 'afro', color: '#000000' } });
+
+// Validate avatar data
+const isValid = AvatarSystem.validate(avatarData);
+
+// Compare avatars
+const areEqual = AvatarSystem.equals(avatar1, avatar2);
+
+// Get all customization options
+const options = AvatarSystem.getCustomizationOptions();
+// Returns: { faceShapes, skinTones, eyeShapes, eyeColors, ... }
+
+// Check if guest avatar
+const isGuest = AvatarSystem.isGuest(avatar);
+
+// Convert guest to registered user
+const userAvatar = AvatarSystem.convertGuestToUser(guestAvatar, newUserId);
+
+// Get version info
+const version = AvatarSystem.getVersion();
+```
+
+---
+
+## Guest Avatars
+
+10 pre-defined guest avatars are available for non-logged-in users. Each has a unique appearance:
+
+| ID | Name | Skin Tone | Hair Style | Eye Color | Clothing Color |
+|----|------|-----------|------------|-----------|----------------|
+| `guest_1` | Guest Blue | Medium | Short Messy | Blue | `#3498DB` |
+| `guest_2` | Guest Red | Light | Long Straight | Brown | `#E74C3C` |
+| `guest_3` | Guest Green | Tan | Buzz Cut | Dark Brown | `#2ECC71` |
+| `guest_4` | Guest Purple | Fair | Bob | Green | `#9B59B6` |
+| `guest_5` | Guest Orange | Brown | Afro | Amber | `#E67E22` |
+| `guest_6` | Guest Teal | Dark | Dreads | Dark Brown | `#1ABC9C` |
+| `guest_7` | Guest Gray | Light | Undercut | Gray | `#95A5A6` |
+| `guest_8` | Guest Yellow | Medium | Spiky | Hazel | `#F1C40F` |
+| `guest_9` | Guest Navy | Tan | Comb Over | Brown | `#2C3E50` |
+| `guest_10` | Guest Pink | Fair | Pigtails | Light Blue | `#FF69B4` |
+
+```javascript
+// Get random guest avatar
+const guest = AvatarSystem.getGuestAvatar();
+
+// Or use AvatarData directly
+const guestData = AvatarData.getRandomGuestAvatar();
+```
+
+---
+
+## XP and Leveling System
+
+### XP Per Level Formula
+
+```
+XP needed for level N = 100 + (N - 1) * 50
+```
+
+| Level | XP Needed | Cumulative XP |
+|-------|-----------|---------------|
+| 1 | 100 | 100 |
+| 2 | 150 | 250 |
+| 3 | 200 | 450 |
+| 5 | 300 | 1,000 |
+| 10 | 550 | 3,500 |
+| 25 | 1,300 | 18,250 |
+| 50 | 2,550 | 68,000 |
+| 75 | 3,800 | 148,500 |
+| 100 | 5,050 | 259,750 |
+
+### Unlock Schedule
+
+All accessories unlock by reaching specific levels:
+
+- **Levels 3-15**: 6 Common items
+- **Levels 18-30**: 8 Uncommon items
+- **Levels 35-48**: 10 Rare items
+- **Levels 50-62**: 8 Epic items
+- **Levels 65-80**: 6 Legendary items
+- **Levels 85-100**: 4 Mythic items
+
+---
+
+## Accessory Animation Rules
+
+Accessories follow specific animation behavior:
+
+```javascript
+{
+ headwear: {
+ followHead: true, // Moves with head
+ bounceMultiplier: 1.2, // Extra bounce on jumps
+ rotationDamping: 0.8 // Slightly less rotation than head
+ },
+ eyewear: {
+ followHead: true, // Moves with head
+ bounceMultiplier: 1.0, // Same bounce as head
+ rotationDamping: 1.0 // Matches head rotation exactly
+ },
+ effects: {
+ independent: true, // Own animation cycle
+ particleSpeed: 1.0 // Particle emission rate
+ }
+}
+```
+
+---
+
+## Performance Tips
+
+### Sprite Caching
+
+For games with many avatars or frequent redraws:
+
+```javascript
+// Pre-render to sprite cache
+const cache = AvatarRenderer.createSpriteCache(avatars, 'HEAD_ONLY', 1);
+
+// Render from cache (much faster)
+AvatarRenderer.renderFromCache(ctx, cache, avatarId, x, y);
+```
+
+### Sprite Sheets
+
+For animation-heavy games:
+
+```javascript
+const sheet = AvatarSystem.createSpriteSheet(avatar, 'FULL_BODY', ['idle', 'walk', 'run', 'jump']);
+
+// In game loop
+const rect = sheet.getFrameRect('walk', currentFrame);
+ctx.drawImage(sheet.image, rect.x, rect.y, rect.width, rect.height, destX, destY, rect.width, rect.height);
+```
+
+### Batch Rendering
+
+For many avatars on screen:
+
+```javascript
+AvatarRenderer.renderBatch(ctx, avatars, 'HEAD_ONLY', positions);
+// Automatically skips off-screen avatars
+```
+
+---
+
+## File Dependencies
+
+Load order matters. Include scripts in this order:
+
+```html
+
+
+
+
+
+
+```
+
+Or use the asset manager which handles loading automatically.
diff --git a/frontend/public/docs/BACKGROUND_CATALOG.md b/frontend/public/docs/BACKGROUND_CATALOG.md
new file mode 100644
index 0000000..7148344
--- /dev/null
+++ b/frontend/public/docs/BACKGROUND_CATALOG.md
@@ -0,0 +1,470 @@
+# Background Catalog
+
+Complete catalog of all 120 procedural backgrounds available in the GamerComp game generation system.
+
+**15 themes x 8 variants = 120 unique backgrounds**
+
+---
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Theme Categories](#theme-categories)
+ - [Space](#space-theme)
+ - [Nature](#nature-theme)
+ - [Urban](#urban-theme)
+ - [Fantasy](#fantasy-theme)
+ - [Abstract](#abstract-theme)
+ - [Retro](#retro-theme)
+ - [Indoor](#indoor-theme)
+ - [Weather](#weather-theme)
+ - [Sports](#sports-theme)
+ - [Seasonal](#seasonal-theme)
+ - [Underwater](#underwater-theme)
+ - [Sky](#sky-theme)
+ - [Mechanical](#mechanical-theme)
+ - [Spooky](#spooky-theme)
+ - [Colorful](#colorful-theme)
+3. [Compatible Music Moods](#compatible-music-moods)
+4. [Compatible Avatar Contexts](#compatible-avatar-contexts)
+5. [API Usage](#api-usage)
+6. [Visual Preview Guide](#visual-preview-guide)
+
+---
+
+## Overview
+
+All backgrounds are procedurally generated using Canvas 2D rendering. Each background features:
+
+- **Animated elements** - Smooth animations driven by time parameter
+- **Layered rendering** - Base layer, mid layer, and particle effects
+- **Customizable colors** - 4-color palette per background
+- **Particle systems** - Optional ambient particles (stars, rain, sparkles, etc.)
+- **Mood-based filtering** - Find backgrounds by emotional tone
+- **Game genre recommendations** - Suggested game types for each background
+
+### Particle Types Available
+
+| Type | Description |
+|------|-------------|
+| `stars` | Twinkling star field |
+| `rain` | Falling rain drops |
+| `snow` | Gentle snowfall |
+| `particles` | Generic floating particles |
+| `fog` | Drifting fog wisps |
+| `bubbles` | Rising bubbles |
+| `leaves` | Falling leaves |
+| `sparkles` | Glittering sparkles |
+| `fireflies` | Glowing fireflies |
+| `dust` | Floating dust motes |
+
+---
+
+## Theme Categories
+
+### Space Theme
+
+Cosmic environments featuring nebulas, planets, and interstellar phenomena.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Nebula | `space_nebula` | Swirling nebula clouds with twinkling stars | wonder | `#0B0B2B` `#1A0A3E` `#3D1A78` `#7B2FBE` | sparkles | puzzle, story, creative |
+| Asteroid Field | `space_asteroidField` | Rocky asteroids drifting through dark space | tense | `#0A0A14` `#1C1C2E` `#4A3A2A` `#8B7355` | dust | action, racing, sports |
+| Deep Space | `space_deepSpace` | The void of deep space with distant starlight | serene | `#000005` `#050510` `#0A0A20` `#111133` | stars | puzzle, story, rpg |
+| Planets | `space_planets` | Colorful planets against a starry backdrop | wonder | `#0B0B2B` `#0F1640` `#CC5533` `#44BBAA` | sparkles | creative, rpg, story |
+| Space Station | `space_spaceStation` | Orbital station with geometric structures | focused | `#0A0A1E` `#151530` `#3A3A55` `#88AACC` | dust | puzzle, action, physics |
+| Warp Speed | `space_warpSpeed` | Light streaks from faster-than-light travel | energetic | `#000010` `#0A0A30` `#2244AA` `#66BBFF` | particles | racing, action, sports |
+| Satellites | `space_satellites` | Satellites orbiting above a dark Earth | calm | `#060615` `#101030` `#334466` `#AACCDD` | stars | puzzle, trivia, creative |
+| Black Hole | `space_blackHole` | A black hole bending light around its event horizon | intense | `#000000` `#0A0510` `#220A33` `#FF6622` | particles | action, rpg, physics |
+
+---
+
+### Nature Theme
+
+Natural outdoor environments from forests to oceans.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Forest | `nature_forest` | A dense forest with layered canopy and dappled light | peaceful | `#1A3320` `#2D5A27` `#4A7A3F` `#8FB86A` | fireflies | story, rpg, puzzle |
+| Jungle | `nature_jungle` | Thick tropical jungle with hanging vines and humid air | adventurous | `#0D2B0D` `#1A4D1A` `#2D7A2D` `#55AA33` | fireflies | action, rpg, pet |
+| Desert | `nature_desert` | Rolling sand dunes under a blazing sun | warm | `#F4A460` `#DAA06D` `#C2955A` `#8B6914` | dust | racing, action, rpg |
+| Mountains | `nature_mountains` | Snow-capped mountain peaks under sweeping skies | majestic | `#2C3E50` `#4A6B5A` `#7A9B6B` `#FFFFFF` | fog | rpg, sports, story |
+| Ocean | `nature_ocean` | Vast ocean stretching to the horizon at sunset | serene | `#1A5276` `#2E86C1` `#5DADE2` `#AED6F1` | fog | puzzle, story, creative |
+| Underwater | `nature_underwater` | Deep beneath the waves with shafts of sunlight | mystical | `#0A2342` `#1A4B6E` `#2E86C1` `#48D1CC` | bubbles | puzzle, pet, creative |
+| Waterfall | `nature_waterfall` | A cascading waterfall in a lush green cliff | refreshing | `#1B4332` `#2D6A4F` `#52B788` `#B7E4C7` | fog | puzzle, creative, story |
+| Meadow | `nature_meadow` | A sunny meadow with wildflowers and gentle breeze | joyful | `#4CAF50` `#81C784` `#A5D6A7` `#FFF59D` | leaves | pet, creative, story, puzzle |
+
+---
+
+### Urban Theme
+
+City environments from skylines to subways.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| City Skyline | `urban_citySkyline` | City skyline silhouetted against a glowing sunset | inspiring | `#1A1A2E` `#2D2D44` `#4A4A66` `#FF8844` | dust | story, rpg, creative |
+| Street | `urban_street` | Busy city street with lamp posts and passing lights | busy | `#2C3E50` `#566573` `#808B96` `#FFD700` | dust | racing, action, sports |
+| Rooftop | `urban_rooftop` | Rooftop view over a twinkling nighttime city | reflective | `#1B2631` `#2C3E50` `#5D6D7E` `#F4D03F` | fireflies | story, puzzle, creative |
+| Neon District | `urban_neonDistrict` | Rain-slicked streets glowing with neon signs | electric | `#0D0D1A` `#1A0A2E` `#FF00FF` `#00FFCC` | rain | action, racing, music |
+| Subway | `urban_subway` | Underground subway platform with arriving train lights | gritty | `#1C1C1C` `#333333` `#555555` `#FFCC00` | dust | action, puzzle, rpg |
+| Park | `urban_park` | A peaceful city park with trees and walking paths | relaxed | `#2E7D32` `#4CAF50` `#81C784` `#795548` | leaves | pet, puzzle, story, creative |
+| Mall | `urban_mall` | Bright mall interior with shop windows and escalators | lively | `#E8E8E8` `#CCCCCC` `#888888` `#FF6B6B` | sparkles | trivia, creative, pet |
+| Alley | `urban_alley` | A narrow alley with a single warm light overhead | mysterious | `#111118` `#1E1E28` `#333340` `#BB8844` | fog | rpg, action, story |
+
+---
+
+### Fantasy Theme
+
+Magical and fantastical environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Castle | `fantasy_castle` | A grand castle silhouette against a twilight sky with golden banners | heroic | `#1a1040` `#2d1b69` `#8b7355` `#ffd700` | fireflies | rpg, action, story |
+| Dungeon | `fantasy_dungeon` | Dark stone corridors lit by flickering torches and glowing embers | dark | `#0a0a0f` `#1a1520` `#4a3728` `#ff6a00` | particles | rpg, action, puzzle |
+| Enchanted Forest | `fantasy_enchantedForest` | A mystical forest with glowing mushrooms and magical light | mystical | `#0a1a0f` `#0d2818` `#1a4a2a` `#44ff88` | fireflies | rpg, story, puzzle |
+| Floating Islands | `fantasy_floatingIslands` | Majestic islands suspended in a violet sky with waterfalls | wonder | `#1a0a3a` `#2d1b69` `#5a8a5a` `#88ccff` | sparkles | rpg, puzzle, creative |
+| Magical Library | `fantasy_magicalLibrary` | Towering bookshelves with floating magical tomes and arcane symbols | scholarly | `#1a0f0a` `#2d1a10` `#6b4423` `#cc88ff` | sparkles | puzzle, trivia, story |
+| Crystal Cave | `fantasy_crystalCave` | An underground cavern filled with luminous crystal formations | ethereal | `#0a0a1a` `#0f1a2d` `#225588` `#44ddff` | sparkles | puzzle, rpg, creative |
+| Temple | `fantasy_temple` | An ancient temple with towering marble pillars and sacred golden light | sacred | `#1a1025` `#2d1a3a` `#c9b77a` `#ffd700` | dust | rpg, story, trivia |
+| Tower | `fantasy_tower` | A tall wizard tower under a starlit sky with arcane energy | arcane | `#0a0a20` `#151540` `#554488` `#aa66ff` | sparkles | rpg, puzzle, trivia |
+
+---
+
+### Abstract Theme
+
+Non-representational visual patterns and effects.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Geometric Patterns | `abstract_geometricPatterns` | Interlocking geometric shapes in warm contrasting tones | focused | `#0d1b2a` `#1b2838` `#e07a5f` `#f2cc8f` | dust | puzzle, trivia, creative |
+| Gradients | `abstract_gradients` | Smoothly blending gradient orbs with ethereal color transitions | dreamy | `#0f0c29` `#302b63` `#24243e` `#ff6b6b` | sparkles | creative, story, music |
+| Particle Fields | `abstract_particleFields` | A dynamic network of connected particle nodes with pulsing energy | energetic | `#000000` `#111122` `#00ff88` `#0088ff` | particles | action, puzzle, physics |
+| Minimalist | `abstract_minimalist` | Clean open space with a single bold accent line | calm | `#f5f5f0` `#e8e4dd` `#2d2d2d` `#c44536` | none | puzzle, trivia, creative |
+| Waves | `abstract_waves` | Layered sinusoidal waves undulating in cool blue tones | flowing | `#0a192f` `#172a45` `#00b4d8` `#90e0ef` | bubbles | creative, music, physics |
+| Fractals | `abstract_fractals` | Recursive branching patterns that grow and pulse | complex | `#0a0a0a` `#1a1a2e` `#e94560` `#0f3460` | sparkles | puzzle, physics, creative |
+| Kaleidoscope | `abstract_kaleidoscope` | Symmetric radial patterns rotating like a living kaleidoscope | playful | `#1a1a2e` `#16213e` `#e94560` `#ffd166` | sparkles | music, creative, puzzle |
+| Matrix | `abstract_matrix` | Cascading columns of digital characters streaming down | digital | `#000000` `#001100` `#00ff41` `#008f11` | none | action, puzzle, trivia |
+
+---
+
+### Retro Theme
+
+Nostalgic gaming aesthetics from past eras.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Pixel Grid | `retro_pixelGrid` | A chunky pixel grid with classic 8-bit color blocks | nostalgic | `#1a1a2e` `#16213e` `#00ff88` `#ff6600` | sparkles | action, puzzle, creative |
+| Arcade Cabinet | `retro_arcadeCabinet` | Inside view of a dark arcade with neon-lit cabinet edges | fun | `#0a0a15` `#1a0a2e` `#ff2277` `#ffcc00` | dust | action, racing, sports |
+| CRT Effects | `retro_crtEffects` | Authentic CRT monitor with phosphor glow and scan line interference | vintage | `#0a0a0a` `#0f1a0f` `#33ff33` `#88ff88` | none | action, puzzle, trivia |
+| Vaporwave | `retro_vaporwave` | Aesthetic sunset horizon with perspective grid and palm silhouettes | aesthetic | `#0d0221` `#261447` `#ff71ce` `#01cdfe` | stars | creative, music, racing |
+| Scanlines | `retro_scanlines` | Dense horizontal scan lines over a shifting color field | technical | `#0a0a1a` `#141428` `#00aaff` `#ff4444` | none | action, puzzle, physics |
+| Glitch | `retro_glitch` | Fragmented display with chromatic aberration and corrupted data | chaotic | `#0a0a0a` `#1a0a1a` `#ff0055` `#00ffcc` | particles | action, racing, physics |
+| 8-Bit | `retro_eightBit` | Charming 8-bit scene with blocky pixel clouds and hills | cheerful | `#2b2b6e` `#3d3d94` `#5bc0eb` `#fde74c` | none | action, puzzle, creative |
+| Synthwave | `retro_synthwave` | Outrun-style neon horizon with receding road and mountain silhouettes | epic | `#0d0015` `#1a0033` `#ff2975` `#f222ff` | stars | racing, action, sports |
+
+---
+
+### Indoor Theme
+
+Interior environments and spaces.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Laboratory | `indoor_laboratory` | Dark science lab with glowing equipment and bubbling vials | mysterious | `#1a1e2e` `#2a3040` `#0f6b5e` `#44eebb` | bubbles | puzzle, trivia, rpg |
+| Classroom | `indoor_classroom` | Bright classroom with chalkboard, desks, and warm sunlight | cheerful | `#f5f0e0` `#d4c9a8` `#5b8c5a` `#8b5e3c` | dust | trivia, puzzle, story |
+| Bedroom | `indoor_bedroom` | Cozy bedroom at night with warm lamp glow and string lights | cozy | `#2a1f3d` `#3d2b5a` `#e8a87c` `#ffd166` | fireflies | story, creative, pet |
+| Kitchen | `indoor_kitchen` | Bright kitchen with checkered floor and warm appliance glow | warm | `#fff8e7` `#f0d9a0` `#c97b3a` `#8b4513` | dust | creative, pet, puzzle |
+| Arcade | `indoor_arcade` | Dark arcade with neon-lit cabinets and glowing screens | energetic | `#0d0d1a` `#1a0a2e` `#ff00ff` `#00ffff` | sparkles | action, racing, sports |
+| Office | `indoor_office` | Modern office with monitor glow and large windows | focused | `#e8e4dc` `#c5bfb2` `#4a6fa5` `#2c3e50` | dust | puzzle, trivia, creative |
+| Library | `indoor_library` | Grand library with towering bookshelves and reading lamps | serene | `#2c1810` `#4a3020` `#c4956a` `#f4e4c1` | dust | story, trivia, puzzle, rpg |
+| Gym | `indoor_gym` | Modern gym with equipment silhouettes and bright overhead lights | energetic | `#1a1a24` `#2a2a3a` `#ff4444` `#ff8800` | dust | action, sports, racing |
+
+---
+
+### Weather Theme
+
+Atmospheric and meteorological conditions.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Rain | `weather_rain` | Overcast sky with steady rainfall, puddles, and muted city outlines | melancholy | `#3a4a5a` `#2a3a4a` `#5a6a7a` `#8899aa` | rain | story, puzzle, rpg |
+| Snow | `weather_snow` | Gentle snowfall over a soft winter landscape with frosted trees | peaceful | `#c8d8e8` `#a0b8cc` `#e8eef4` `#FFFFFF` | snow | puzzle, pet, creative, story |
+| Storm | `weather_storm` | Dark storm clouds with heavy rain, wind-bent trees, and thunder | intense | `#1a1a25` `#252535` `#3a3a55` `#667788` | rain | action, rpg, racing |
+| Sunset | `weather_sunset` | Vibrant sunset with orange-purple gradient and silhouette landscape | warm | `#1a0a2e` `#cc4400` `#ff8833` `#ffd166` | sparkles | story, creative, pet, music |
+| Sunrise | `weather_sunrise` | Early morning sunrise with pink-orange sky, mist, and dewy fields | hopeful | `#2a1a40` `#884466` `#ee8855` `#ffe4a0` | dust | puzzle, pet, creative, sports |
+| Fog | `weather_fog` | Thick fog with barely visible shapes and dim lights | eerie | `#8a8a8a` `#666666` `#aaaaaa` `#cccccc` | fog | story, rpg, puzzle |
+| Lightning | `weather_lightning` | Pitch-dark sky lit by dramatic lightning bolts | dramatic | `#0a0a18` `#1a1a30` `#3344aa` `#eeeeff` | rain | action, rpg, racing |
+| Aurora | `weather_aurora` | Dark arctic sky with shimmering aurora borealis | magical | `#0a0a1a` `#0d1b2a` `#22cc88` `#8844dd` | stars | creative, rpg, story, music |
+
+---
+
+### Sports Theme
+
+Athletic venues and environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Stadium | `sports_stadium` | Large outdoor stadium with green pitch and bright floodlights | exciting | `#1a3a1a` `#2a5a2a` `#88cc44` `#ffffff` | sparkles | sports, action, trivia |
+| Race Track | `sports_raceTrack` | Asphalt race track with lane markings and grandstand | fast | `#333333` `#555555` `#cc0000` `#ffffff` | dust | racing, action, sports |
+| Court | `sports_court` | Indoor basketball court with polished hardwood and arena lighting | competitive | `#c47a34` `#e8944a` `#ffffff` `#2255aa` | sparkles | sports, action, trivia |
+| Field | `sports_field` | Open soccer field under blue sky with goals and corner flags | fresh | `#2a7a2a` `#3a9a3a` `#ffffff` `#88cc44` | leaves | sports, action, pet |
+| Arena | `sports_arena` | Dark fighting arena with dramatic spotlights and ropes | intense | `#1a1a2e` `#2a2a44` `#ff4444` `#ffaa00` | dust | action, sports, rpg |
+| Pool | `sports_pool` | Olympic swimming pool with lane dividers and rippling water | calm | `#1a6088` `#2288aa` `#44bbdd` `#88eeff` | sparkles | sports, racing, puzzle |
+| Gym | `sports_gym` | Gymnastics arena with spring floor and apparatus silhouettes | competitive | `#ddc8a0` `#c4a870` `#ffffff` `#cc3333` | dust | sports, action, creative |
+| Skatepark | `sports_skatepark` | Urban skatepark with concrete ramps and graffiti | cool | `#555566` `#777788` `#ff6600` `#ffcc00` | leaves | action, sports, racing, creative |
+
+---
+
+### Seasonal Theme
+
+Time-of-year and holiday-themed environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Spring Meadow | `seasonal_springMeadow` | A sunny spring meadow with wildflowers and gentle hills | cheerful | `#87CEEB` `#98FB98` `#FFD700` `#FF69B4` | leaves | puzzle, creative, pet |
+| Autumn Leaves | `seasonal_autumnLeaves` | A warm autumn landscape with falling leaves and golden light | warm | `#2C1810` `#CC5500` `#DD8833` `#FFD700` | leaves | puzzle, story, rpg |
+| Winter Wonderland | `seasonal_winterWonderland` | A serene winter landscape with snow-covered hills | calm | `#1A2744` `#4A7CB5` `#C8E0F4` `#FFFFFF` | snow | puzzle, story, creative |
+| Summer Beach | `seasonal_summerBeach` | A vibrant summer beach scene with rolling waves and warm sand | energetic | `#0077BE` `#00BFFF` `#F4D03F` `#FF6347` | sparkles | action, sports, racing |
+| Harvest | `seasonal_harvest` | Golden harvest fields with wheat rows and warm sunset | warm | `#5C3317` `#D4A017` `#8B4513` `#FFD700` | dust | puzzle, story, creative |
+| Blossom | `seasonal_blossom` | Gentle cherry blossom petals drifting under pink-tinted skies | peaceful | `#FFB7C5` `#FF69B4` `#FFF0F5` `#98FB98` | leaves | puzzle, story, creative, music |
+| Snowfall | `seasonal_snowfall` | Heavy snowfall over a dark winter night with distant lights | calm | `#0D1B2A` `#1B2838` `#415A77` `#E0E1DD` | snow | puzzle, story, rpg |
+| Tropical | `seasonal_tropical` | A lush tropical scene with dense foliage and vivid colors | energetic | `#00CED1` `#FF6347` `#228B22` `#FFD700` | fireflies | action, racing, sports, pet |
+
+---
+
+### Underwater Theme
+
+Aquatic and marine environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Coral Reef | `underwater_coralReef` | A vibrant coral reef teeming with colorful marine life | cheerful | `#006994` `#00A8CC` `#FF6F61` `#FFB347` | bubbles | puzzle, pet, creative |
+| Deep Sea | `underwater_deepSea` | The mysterious deep sea with bioluminescent creatures | mysterious | `#0A0A2E` `#0D1B3E` `#1A3A5C` `#00FFCC` | fireflies | rpg, story, puzzle |
+| Kelp Forest | `underwater_kelpForest` | A towering kelp forest with dappled light filtering through | peaceful | `#0B4F3A` `#1A7A5C` `#2FA87A` `#7EC8A0` | bubbles | puzzle, creative, story |
+| Submarine | `underwater_submarine` | A submarine expedition through dark ocean depths | adventurous | `#0A1628` `#1A3050` `#D4AA44` `#CC4444` | bubbles | action, rpg, story |
+| Shipwreck | `underwater_shipwreck` | A sunken ship resting on the ocean floor, overgrown with sea life | mysterious | `#0A1A2A` `#1A3344` `#8B7355` `#44AA88` | bubbles | rpg, story, puzzle |
+| Abyss | `underwater_abyss` | The abyssal zone with volcanic vents and glowing magma cracks | dark | `#020208` `#050515` `#0A0A30` `#FF2200` | particles | action, rpg, story |
+| Ice Cave | `underwater_iceCave` | An underwater ice cavern with crystalline formations | calm | `#0A2A4A` `#1A5A8A` `#88CCEE` `#E0F0FF` | sparkles | puzzle, story, creative |
+| Treasure | `underwater_treasure` | A hidden underwater treasure trove glowing with golden riches | adventurous | `#0D2B45` `#1A4A6A` `#FFD700` `#FF8C00` | sparkles | rpg, action, puzzle |
+
+---
+
+### Sky Theme
+
+Aerial and atmospheric perspectives.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Clouds | `sky_clouds` | A bright blue sky filled with drifting puffy white clouds | peaceful | `#4A90D9` `#87CEEB` `#FFFFFF` `#F0F8FF` | dust | puzzle, creative, pet |
+| Flying High | `sky_flyingHigh` | High altitude perspective with streaking wind lines and brilliant sun | energetic | `#1E3A6E` `#4682B4` `#FFD700` `#FFFFFF` | sparkles | action, racing, sports |
+| Hot Air Balloon | `sky_hotAirBalloon` | Colorful hot air balloons floating gently across a warm sky | cheerful | `#87CEEB` `#FF6347` `#FFD700` `#FFFFFF` | dust | puzzle, creative, pet, music |
+| Airplane View | `sky_airplaneView` | View from an airplane window looking down at a sea of clouds | calm | `#1A3366` `#336699` `#FFFFFF` `#AACCEE` | dust | puzzle, story, creative |
+| Stratosphere | `sky_stratosphere` | The edge of space where the atmosphere fades and Earth curves below | mysterious | `#0A0A2E` `#1A1A5E` `#4444AA` `#8888DD` | stars | story, rpg, puzzle |
+| Bird's Eye | `sky_birdsEye` | A bird's eye view looking down at patchwork fields and winding rivers | peaceful | `#5588BB` `#88BBDD` `#228B22` `#DEB887` | dust | puzzle, creative, story |
+| Horizon | `sky_horizon` | A dramatic sunset horizon with layers of warm orange, pink, and gold | warm | `#1A0533` `#CC4488` `#FF8844` `#FFD700` | dust | story, music, creative, rpg |
+| Starfield | `sky_starfield` | A clear night sky bursting with stars and a faint galactic band | calm | `#000011` `#0A0A2A` `#1A1A44` `#FFFFFF` | stars | puzzle, story, rpg, music |
+
+---
+
+### Mechanical Theme
+
+Industrial and technological environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Gears | `mechanical_gears` | Interlocking gears turning against a dark steel backdrop | focused | `#1A1A24` `#2A2A38` `#7A7A88` `#C08040` | dust | puzzle, physics, rpg |
+| Circuits | `mechanical_circuits` | Circuit board traces with glowing data paths | focused | `#0A0F14` `#112222` `#00CC88` `#33FFAA` | sparkles | puzzle, trivia, physics |
+| Factory | `mechanical_factory` | Factory floor with smokestacks and warning stripes | busy | `#1A1510` `#2A2520` `#8B7355` `#DDAA44` | dust | action, physics, puzzle |
+| Robot Factory | `mechanical_robotFactory` | Robot assembly line with mechanical arms and sparks | playful | `#151520` `#222238` `#5588CC` `#FFAA33` | sparkles | action, puzzle, creative |
+| Conveyor Belts | `mechanical_conveyorBelts` | Layered conveyor belts moving crates and parts | busy | `#18181F` `#28283A` `#666680` `#DD8822` | dust | puzzle, action, physics |
+| Pipes | `mechanical_pipes` | Interconnected pipe network with valves and joints | complex | `#121218` `#1E2A1E` `#558855` `#88BB44` | dust | puzzle, physics, rpg |
+| Steampunk | `mechanical_steamPunk` | Brass gears, gauges, and steam vents in warm tones | adventurous | `#1A1008` `#2A1A0A` `#C08040` `#E8C060` | dust | rpg, story, action |
+| Tech Lab | `mechanical_techLab` | Clean tech laboratory with holographic displays | curious | `#0E0E1A` `#1A1A30` `#4455AA` `#66DDFF` | sparkles | puzzle, trivia, creative |
+
+---
+
+### Spooky Theme
+
+Eerie and horror-themed environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Haunted House | `spooky_hauntedHouse` | A looming haunted house silhouette against a moonlit sky | eerie | `#0A0812` `#1A1025` `#3A2255` `#CCCC88` | fog | story, puzzle, rpg |
+| Graveyard | `spooky_graveyard` | Moonlit graveyard with crooked tombstones and mist | somber | `#080810` `#121828` `#2A3348` `#99AA77` | fog | rpg, story, puzzle |
+| Dark Forest | `spooky_darkForest` | Dense dark forest with twisted trees and glowing eyes | foreboding | `#050808` `#0A1510` `#1A2A18` `#3A5530` | fireflies | rpg, action, story |
+| Abandoned Building | `spooky_abandonedBuilding` | Crumbling abandoned building with broken windows and debris | desolate | `#0A0A0E` `#181820` `#3A3A48` `#667766` | dust | action, puzzle, rpg |
+| Crypt | `spooky_crypt` | Underground stone crypt with torchlight and coffins | dread | `#060608` `#101015` `#2A2A35` `#558855` | dust | rpg, action, story |
+| Manor | `spooky_manor` | Gothic manor interior with candlelight and portraits | gothic | `#0A080C` `#1A1020` `#3A2040` `#886644` | dust | story, rpg, puzzle |
+| Fog | `spooky_fog` | Thick rolling fog obscuring dark shapes and dim lights | mysterious | `#0A0E12` `#151E28` `#2A3844` `#556688` | fog | story, puzzle, rpg |
+| Shadows | `spooky_shadows` | Near-total darkness with shifting shadows and faint light | paranoid | `#050506` `#0E0E14` `#1C1C2A` `#332244` | dust | action, puzzle, rpg |
+
+---
+
+### Colorful Theme
+
+Vibrant and visually striking environments.
+
+| Variant | ID | Description | Mood | Colors | Particles | Recommended For |
+|---------|-----|-------------|------|--------|-----------|-----------------|
+| Rainbow | `colorful_rainbow` | Bold rainbow arcs and bands across a bright sky | joyful | `#FF4444` `#FFAA33` `#FFEE33` `#44DD55` | sparkles | creative, puzzle, music |
+| Paint Splatter | `colorful_paintSplatter` | Colorful paint splatters on a light canvas | creative | `#FAFAE8` `#FF3366` `#3388FF` `#FFCC00` | particles | creative, music, action |
+| Candy World | `colorful_candyWorld` | Candy landscape with lollipops, gumdrops, and sugar hills | sweet | `#FFE0F0` `#FF88BB` `#88DDFF` `#AAFFAA` | sparkles | pet, creative, puzzle |
+| Disco | `colorful_disco` | Disco floor with mirror ball and sweeping colored lights | energetic | `#1A0A2E` `#2A1A44` `#FF33AA` `#33FFCC` | sparkles | music, action, sports |
+| Neon | `colorful_neon` | Neon signs and glowing outlines against dark city night | electric | `#0A0A18` `#151530` `#FF0088` `#00FFCC` | particles | action, racing, music |
+| Tie-Dye | `colorful_tieDye` | Swirling tie-dye patterns with organic color blending | psychedelic | `#FF4488` `#FFAA22` `#44DDBB` `#8844FF` | particles | creative, music, story |
+| Gradient | `colorful_gradient` | Smooth shifting color gradients with geometric accents | peaceful | `#FF6B6B` `#FECA57` `#48DBFB` `#FF9FF3` | sparkles | puzzle, creative, trivia |
+| Prism | `colorful_prism` | Light splitting through prisms into rainbow beams | mystical | `#1A1A2E` `#0F3460` `#E94560` `#FFFFFF` | sparkles | puzzle, physics, creative |
+
+---
+
+## Compatible Music Moods
+
+Backgrounds are tagged with compatible music moods for cohesive game experiences:
+
+| Music Mood | Example Backgrounds |
+|------------|---------------------|
+| ambient | space_nebula, nature_ocean, weather_fog |
+| calm | nature_meadow, weather_snow, sky_clouds |
+| dreamy | abstract_gradients, seasonal_blossom, weather_aurora |
+| energetic | space_warpSpeed, sports_stadium, colorful_disco |
+| epic | fantasy_castle, weather_sunset, mechanical_steamPunk |
+| electronic | abstract_particleFields, retro_synthwave, colorful_neon |
+| playful | colorful_candyWorld, retro_pixelGrid, mechanical_robotFactory |
+| tense | space_asteroidField, spooky_crypt, weather_storm |
+| mysterious | underwater_deepSea, spooky_fog, sky_stratosphere |
+| happy | seasonal_springMeadow, sky_hotAirBalloon, underwater_treasure |
+
+---
+
+## Compatible Avatar Contexts
+
+Recommended avatar context pairings for backgrounds:
+
+| Background Theme | Recommended Avatar Contexts |
+|------------------|----------------------------|
+| space | astronaut, robot, alien, superhero |
+| nature | explorer, hiker, animal, fairy |
+| urban | cyclist, skater, runner, casual |
+| fantasy | knight, wizard, fairy, dragon |
+| abstract | geometric, minimal, gamer |
+| retro | pixel, arcade, classic |
+| indoor | student, worker, gamer, casual |
+| weather | raincoat, winter, summer |
+| sports | athlete, racer, swimmer |
+| seasonal | holiday, themed |
+| underwater | diver, mermaid, fish |
+| sky | pilot, bird, balloon |
+| mechanical | robot, engineer, steampunk |
+| spooky | ghost, monster, vampire |
+| colorful | party, disco, rainbow |
+
+---
+
+## API Usage
+
+### Get All Themes
+
+```javascript
+const summary = BackgroundEngine.getThemeSummary();
+// Returns: [{ theme: 'space', variants: [...], count: 8 }, ...]
+```
+
+### Get Total Background Count
+
+```javascript
+const total = BackgroundEngine.getTotalCount();
+// Returns: 120
+```
+
+### Find Backgrounds by Game Genre
+
+```javascript
+const puzzleBackgrounds = BackgroundEngine.findByGenre('puzzle');
+// Returns array of backgrounds recommended for puzzle games
+```
+
+### Find by Music Mood
+
+```javascript
+const calmBackgrounds = BackgroundEngine.findByMusicMood('calm');
+// Returns backgrounds compatible with calm music
+```
+
+### Get Random Background from Theme
+
+```javascript
+const bg = BackgroundEngine.getRandomFromTheme('space');
+// Returns: { theme: 'space', variant: 'nebula' } (random)
+```
+
+### Get Random Matching Filters
+
+```javascript
+const bg = BackgroundEngine.getRandomMatching({
+ mood: 'energetic',
+ tags: ['neon']
+});
+```
+
+### Validate Catalog Integrity
+
+```javascript
+const issues = BackgroundEngine.validateCatalog();
+// Returns array of any missing/invalid registrations
+```
+
+---
+
+## Visual Preview Guide
+
+Each background can be previewed using the BackgroundEngine:
+
+```javascript
+// Initialize canvas
+const canvas = document.createElement('canvas');
+canvas.width = 800;
+canvas.height = 600;
+const ctx = canvas.getContext('2d');
+
+// Render a specific background
+BackgroundEngine.set('space', 'nebula');
+
+// In animation loop:
+function render(time) {
+ BackgroundEngine.render(ctx, canvas.width, canvas.height, time / 1000);
+ requestAnimationFrame(render);
+}
+render(0);
+```
+
+### Color Palette Structure
+
+Each background defines 4 colors:
+- **Color 1**: Primary/dominant background color
+- **Color 2**: Secondary background color
+- **Color 3**: Accent/highlight color
+- **Color 4**: Detail/contrast color
+
+### Render Layers
+
+1. **Base Layer** (`renderBase`) - Sky, ground, gradients
+2. **Mid Layer** (`renderMid`) - Objects, details, animations
+3. **Particle Layer** - Ambient particle effects (optional)
+
+---
+
+## Catalog Statistics
+
+| Metric | Value |
+|--------|-------|
+| Total Themes | 15 |
+| Variants Per Theme | 8 |
+| Total Backgrounds | 120 |
+| Particle Types | 10 |
+| Moods Available | 16 |
+| Game Genres Mapped | 10 |
+
+---
+
+*Last Updated: 2026-02-05*
+*Version: 1.0.0*
diff --git a/frontend/public/docs/CONTEXT_CATALOG.md b/frontend/public/docs/CONTEXT_CATALOG.md
new file mode 100644
index 0000000..9ecb178
--- /dev/null
+++ b/frontend/public/docs/CONTEXT_CATALOG.md
@@ -0,0 +1,721 @@
+# Context Catalog
+
+## Overview
+
+26 game contexts that place avatars in themed scenarios.
+3 categories: Vehicles (12), Costumes (10), Pure (4).
+
+Each context defines:
+- **Avatar Mode**: How much of the avatar is visible (HEAD_ONLY, HEAD_AND_SHOULDERS, UPPER_BODY, FULL_BODY)
+- **Animations**: Available animation states for the avatar
+- **Accessory Visibility**: Which avatar accessories (eyewear, headwear, effects) are visible
+- **Effects**: Visual effects triggered by game states
+- **Recommended For**: Game types that work well with this context
+
+---
+
+## Vehicle Contexts (12)
+
+Vehicles place the avatar inside cockpits, vehicles, or as part of a flying/riding scenario.
+
+---
+
+### spaceship-cockpit
+
+- **ID**: `spaceship-cockpit`
+- **Mode**: HEAD_ONLY
+- **Description**: Pilot a spacecraft through space
+- **Background**: Dark space with animated starfield, cockpit interior frame with side panels
+- **Foreground**: Cockpit glass with blue tint, HUD targeting reticle, control panel with blinking buttons, speed indicator
+- **Animations**: idle, bob, celebrate, hurt
+- **Effects**:
+ - `boost`: Particle flames from rear
+ - `damage`: Screen shake (intensity 5)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Space shooters, sci-fi games, arcade
+- **Recommended Music**: Epic/Intense
+- **Recommended Backgrounds**: Space, nebula, asteroid fields
+
+---
+
+### race-car
+
+- **ID**: `race-car`
+- **Mode**: HEAD_AND_SHOULDERS
+- **Description**: Race at high speed on the track
+- **Background**: Sky gradient, road rushing past with animated lane markers, speed lines on sides
+- **Foreground**: Dashboard with curve, steering wheel, side mirrors with road reflections, speedometer and RPM gauges with animated needles
+- **Animations**: idle, bob, celebrate, hurt, turn-left, turn-right
+- **Effects**:
+ - `accelerate`: Vertical blur
+ - `drift`: Side particle effects
+ - `damage`: Screen shake (intensity 8)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Racing games, driving simulations, arcade racers
+- **Recommended Music**: Intense, electronic
+- **Recommended Backgrounds**: Urban, track, highway
+
+---
+
+### airplane
+
+- **ID**: `airplane`
+- **Mode**: HEAD_AND_SHOULDERS
+- **Description**: Fly through the clouds as a pilot
+- **Background**: Sky gradient (blue), animated clouds streaming past, cockpit interior sides
+- **Foreground**: Windshield frame with center divider, instrument panel with 5 gauges (animated needles), yoke/control column, altitude and heading displays
+- **Animations**: idle, bob, celebrate, hurt
+- **Effects**:
+ - `turbulence`: Screen shake (intensity 3)
+ - `dive`: Vertical blur
+ - `damage`: Screen shake (intensity 10)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Flight simulators, arcade flyers
+- **Recommended Music**: Adventurous, orchestral
+- **Recommended Backgrounds**: Clouds, sky, mountains
+
+---
+
+### flappy-style
+
+- **ID**: `flappy-style`
+- **Mode**: HEAD_ONLY
+- **Description**: Flap through obstacles as a flying character
+- **Background**: Transparent (game provides its own)
+- **Foreground**: Animated wing sprites on left and right sides with flapping motion, wing detail lines
+- **Animations**: idle, flap, fall, hurt
+- **Effects**:
+ - `flap`: Vertical blur (intensity 2)
+ - `fall`: Downward stretch
+ - `damage`: Red flash
+- **Accessory Visibility**: Eyewear visible, Headwear hidden, Effects visible
+- **Best For**: Flappy-style games, casual mobile games, arcade
+- **Recommended Music**: Playful, casual
+- **Recommended Backgrounds**: Sky, pipes, obstacles
+
+---
+
+### tank
+
+- **ID**: `tank`
+- **Mode**: HEAD_ONLY
+- **Description**: Command a battle tank
+- **Background**: Military green interior with radial gradient, riveted panel details, side screens with static effect
+- **Foreground**: Circular tank hatch frame with bolts, periscope view with crosshair at top, control panel with warning lights, ammo counter display
+- **Animations**: idle, bob, celebrate, hurt, aim
+- **Effects**:
+ - `fire`: Yellow flash (intensity 10)
+ - `rumble`: Screen shake (intensity 4)
+ - `damage`: Screen shake (intensity 12)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Military shooters, strategy games, arcade combat
+- **Recommended Music**: Intense, military
+- **Recommended Backgrounds**: Battlefield, desert, urban warfare
+
+---
+
+### pacman-style
+
+- **ID**: `pacman-style`
+- **Mode**: HEAD_ONLY
+- **Description**: Navigate mazes and eat pellets
+- **Background**: Transparent (game provides maze)
+- **Foreground**: Subtle mouth/chomp overlay effect
+- **Animations**: idle, chomp, hurt, power
+- **Effects**:
+ - `chomp`: Scale pulse (rate 12)
+ - `power`: Cyan glow
+ - `damage`: Red flash
+- **Accessory Visibility**: Eyewear hidden, Headwear hidden, Effects visible
+- **Best For**: Maze games, arcade classics, retro games
+- **Recommended Music**: Retro, chiptune
+- **Recommended Backgrounds**: Maze walls, pellets
+
+---
+
+### hoverboard
+
+- **ID**: `hoverboard`
+- **Mode**: UPPER_BODY
+- **Description**: Ride a futuristic hoverboard
+- **Background**: Ground rushing beneath with speed lines, shadow beneath board
+- **Foreground**: Animated hoverboard with hover glow effect, board body with gradient, cyan lights, hover jets with flicker
+- **Animations**: idle, bob, celebrate, hurt, lean-left, lean-right, jump
+- **Effects**:
+ - `idle`: Bob motion (amplitude 3, rate 4)
+ - `boost`: Cyan particle trail behind
+ - `jump`: Upward stretch
+ - `damage`: Screen shake (intensity 6)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Endless runners, racing games, arcade
+- **Recommended Music**: Electronic, futuristic
+- **Recommended Backgrounds**: City streets, futuristic
+
+---
+
+### jetpack
+
+- **ID**: `jetpack`
+- **Mode**: UPPER_BODY
+- **Description**: Fly with a jetpack strapped to your back
+- **Background**: Sky gradient, clouds below, jetpack unit behind avatar (main body, fuel gauge, exhaust nozzles)
+- **Foreground**: Animated jet flames with flicker, smoke trail particles
+- **Animations**: idle, bob, celebrate, hurt, boost
+- **Effects**:
+ - `idle`: Bob motion (amplitude 2, rate 2)
+ - `boost`: Orange particle trail downward
+ - `damage`: Screen shake (intensity 8)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Flying games, endless flyers, arcade shooters
+- **Recommended Music**: Action, adventure
+- **Recommended Backgrounds**: Sky, clouds, obstacles
+
+---
+
+### mech-suit
+
+- **ID**: `mech-suit`
+- **Mode**: HEAD_ONLY
+- **Description**: Pilot a powerful mech suit
+- **Background**: Mech interior with radial gradient, side screens (radar with sweep, system status), interior frame elements
+- **Foreground**: Hexagonal chest window frame, HUD targeting brackets, control panel with joysticks, warning lights (blinking), status text
+- **Animations**: idle, bob, celebrate, hurt
+- **Effects**:
+ - `walk`: Screen shake (intensity 2)
+ - `fire`: Yellow flash
+ - `damage`: Screen shake (intensity 10)
+- **Accessory Visibility**: Eyewear hidden, Headwear hidden, Effects visible
+- **Best For**: Mech combat, shooters, action games
+- **Recommended Music**: Epic, industrial
+- **Recommended Backgrounds**: City destruction, battlefield
+
+---
+
+### submarine
+
+- **ID**: `submarine`
+- **Mode**: HEAD_AND_SHOULDERS
+- **Description**: Explore the depths in a submarine
+- **Background**: Underwater gradient (dark blue), rising bubbles with wobble animation, submarine interior with pipes and joints
+- **Foreground**: Circular porthole frame with rivets, glass reflection, water distortion overlay, control panel with depth gauge, pressure gauge, sonar ping effect, periscope controls
+- **Animations**: idle, bob, celebrate, hurt
+- **Effects**:
+ - `idle`: Bob motion (amplitude 2, rate 1)
+ - `pressure`: Distortion effect (intensity 3)
+ - `damage`: Screen shake (intensity 6)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Underwater exploration, arcade games
+- **Recommended Music**: Ambient, mysterious
+- **Recommended Backgrounds**: Ocean depths, coral, sea creatures
+
+---
+
+### dragon-rider
+
+- **ID**: `dragon-rider`
+- **Mode**: FULL_BODY
+- **Description**: Soar through the skies on a dragon
+- **Background**: Sunset sky gradient, clouds, dragon neck with scales, animated wings (flapping), saddle beneath rider
+- **Foreground**: Dragon horns and ears framing view, dragon body/tail below, dragon legs with claws
+- **Animations**: idle, bob, celebrate, hurt, lean
+- **Effects**:
+ - `idle`: Bob motion (amplitude 5, rate 2)
+ - `fireBreath`: Orange glow
+ - `dive`: Vertical blur
+ - `damage`: Screen shake (intensity 8)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Fantasy adventures, flying games, RPGs
+- **Recommended Music**: Epic, fantasy orchestral
+- **Recommended Backgrounds**: Mountains, castles, sky
+
+---
+
+### motorcycle
+
+- **ID**: `motorcycle`
+- **Mode**: FULL_BODY
+- **Description**: Race on a high-speed motorcycle
+- **Background**: Sunset sky, road with animated lane markers, environment blur on sides, motion blur lines
+- **Foreground**: Full motorcycle with body, fuel tank with stripe, engine with details, exhaust pipes, front and rear wheels with spinning spokes, handlebars, mirrors, headlight, seat, wheel speed blur
+- **Animations**: idle, bob, celebrate, hurt, lean-left, lean-right
+- **Effects**:
+ - `accelerate`: Vertical blur
+ - `wheelie`: Rotation (-15 degrees)
+ - `lean`: Rotation (20 degrees)
+ - `damage`: Screen shake (intensity 8)
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Racing games, endless runners, arcade action
+- **Recommended Music**: Rock, intense
+- **Recommended Backgrounds**: Highway, city streets
+
+---
+
+## Costume Contexts (10)
+
+Costumes dress the avatar in themed outfits with props and accessories.
+
+---
+
+### knight-armor
+
+- **ID**: `knight-armor`
+- **Mode**: FULL_BODY
+- **Description**: Medieval knight in shining armor
+- **Costume Details**:
+ - **Under Layer**: Flowing red cape with gold trim, silver chestplate with ridge details, leg armor (greaves) with knee guards, armored boots
+ - **Over Layer**: Shoulder pauldrons with spikes, helmet with visor (eye slits), red helmet crest
+- **Props**:
+ - Sword (right hand): Silver blade, gold guard, leather handle
+ - Shield (left arm): Silver with red emblem and gold cross
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `attack`: Silver slash
+ - `hurt`: Orange sparks
+- **Accessory Visibility**: Eyewear hidden (behind visor), Headwear visible (above helmet), Effects visible
+- **Best For**: RPGs, medieval games, action adventures
+- **Recommended Music**: Epic, heroic
+
+---
+
+### space-suit
+
+- **ID**: `space-suit`
+- **Mode**: FULL_BODY
+- **Description**: Astronaut ready for space exploration
+- **Costume Details**:
+ - **Under Layer**: White suit body with blue accent stripes, oxygen pack on back with blinking lights, legs with stripes, magnetic boots
+ - **Over Layer**: Transparent helmet bubble with reflection, helmet ring, blue accent dome, antenna with red tip, white gloves with blue centers
+- **Props**: None
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `hurt`: Cyan sparks
+- **Accessory Visibility**: Eyewear visible (through helmet), Headwear visible (above helmet), Effects visible
+- **Best For**: Action games, adventure, physics puzzles
+- **Recommended Music**: Ambient, electronic
+
+---
+
+### ninja-outfit
+
+- **ID**: `ninja-outfit`
+- **Mode**: FULL_BODY
+- **Description**: Stealthy ninja warrior
+- **Costume Details**:
+ - **Under Layer**: Black body suit, katana on back (scabbard with red handle wrap, gold guard), gray belt with throwing stars, black leg wraps, tabi boots (split-toe)
+ - **Over Layer**: Black arm wraps, face mask covering lower face, red headband with flowing tail (animated)
+- **Props**:
+ - Katana (on back)
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `attack`: Dark slash
+ - `hurt`: Gray smoke
+- **Accessory Visibility**: Eyewear visible (through eye slit), Headwear hidden (mask covers), Effects visible
+- **Best For**: Action games, stealth adventures, RPGs
+- **Recommended Music**: Tense, mysterious
+
+---
+
+### wizard-robes
+
+- **ID**: `wizard-robes`
+- **Mode**: FULL_BODY
+- **Description**: Mystical wizard with flowing robes
+- **Costume Details**:
+ - **Under Layer**: Dark blue flowing robe (animated), inner fold, leather belt with gold buckle, side pouches, glowing purple runes on fabric (pulsing)
+ - **Over Layer**: Wide sleeves, hood/collar, staff in right hand
+- **Props**:
+ - Staff (right hand): Brown wooden shaft, purple orb with glow effect
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `attack`: Purple magic burst
+ - `celebrate`: Gold sparkles
+- **Accessory Visibility**: Eyewear visible, Headwear visible (optional wizard hat), Effects visible
+- **Best For**: RPGs, puzzle games, adventures
+- **Recommended Music**: Mystical, fantasy
+
+---
+
+### superhero-cape
+
+- **ID**: `superhero-cape`
+- **Mode**: FULL_BODY
+- **Description**: Classic superhero with billowing cape
+- **Costume Details**:
+ - **Under Layer**: Billowing cape (animated with waves), bodysuit in avatar's clothing color, gold belt with white buckle, colored trunks, legs in suit color, colored boots with gold tops
+ - **Over Layer**: Suit arms, colored gloves with gold cuffs, gold chest emblem (star shape), colored mask with white eye holes
+- **Props**: None
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `attack`: Yellow impact burst
+ - `celebrate`: Gold sparkles
+- **Accessory Visibility**: Eyewear visible (as mask), Headwear visible, Effects visible
+- **Best For**: Action games, adventure
+- **Recommended Music**: Heroic, triumphant
+
+---
+
+### athlete-uniform
+
+- **ID**: `athlete-uniform`
+- **Mode**: FULL_BODY
+- **Description**: Athletic sports uniform with number 23
+- **Costume Details**:
+ - **Under Layer**: Red jersey with white stripes, number 23 on back, dark shorts with red side stripes, exposed legs, white socks with red stripes, black athletic shoes with red accents
+ - **Over Layer**: Red sleeves with white stripes, red headband, white wristbands, small number 23 on front
+- **Props**:
+ - Ball (optional, right arm)
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `celebrate`: Gold confetti
+- **Accessory Visibility**: Eyewear visible, Headwear visible, Effects visible
+- **Best For**: Sports games, action, racing
+- **Recommended Music**: Energetic, pump-up
+
+---
+
+### pirate-outfit
+
+- **ID**: `pirate-outfit`
+- **Mode**: FULL_BODY
+- **Description**: Swashbuckling pirate captain
+- **Costume Details**:
+ - **Under Layer**: Dark red long coat (animated back), white ruffled shirt, leather belt with gold buckle, dark pants, brown boots with folded tops
+ - **Over Layer**: Coat front panels with gold trim and buttons, cutlass in right hand (curved blade, gold guard, leather handle), black tricorn hat with gold trim and skull emblem
+- **Props**:
+ - Cutlass (right hand)
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `attack`: Silver slash
+- **Accessory Visibility**: Eyewear visible (eyepatch optional), Headwear visible, Effects visible
+- **Best For**: Adventure games, action, RPGs
+- **Recommended Music**: Adventurous, sea shanty
+
+---
+
+### scientist-labcoat
+
+- **ID**: `scientist-labcoat`
+- **Mode**: FULL_BODY
+- **Description**: Mad scientist ready to experiment
+- **Costume Details**:
+ - **Under Layer**: Blue shirt with dark tie, dark gray pants, brown sensible shoes
+ - **Over Layer**: White lab coat (open front with collar), sleeves, pocket protector with colored pens (blue, red, black), ID badge with photo, safety goggles on forehead
+- **Props**:
+ - Beaker (right hand): Glass container with bubbling green liquid and animated bubbles
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**:
+ - `attack`: Green bubbles
+- **Accessory Visibility**: Eyewear visible (can wear both goggles and glasses), Headwear visible, Effects visible
+- **Best For**: Puzzle games, physics games, creative games
+- **Recommended Music**: Quirky, experimental
+
+---
+
+### chef-outfit
+
+- **ID**: `chef-outfit`
+- **Mode**: FULL_BODY
+- **Description**: Master chef ready to cook
+- **Costume Details**:
+ - **Under Layer**: White double-breasted chef jacket with buttons, white apron with strings and pocket, dark pants, black shoes
+ - **Over Layer**: White sleeves, red kitchen towel over shoulder with white stripes, tall white chef hat (toque) with puffy top, utensils in apron pocket
+- **Props**:
+ - Spatula (right hand): Brown handle, metal slotted head
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**: None
+- **Accessory Visibility**: Eyewear visible, Headwear visible (under chef hat), Effects visible
+- **Best For**: Cooking games, creative games, puzzle
+- **Recommended Music**: Upbeat, cheerful
+
+---
+
+### cowboy-gear
+
+- **ID**: `cowboy-gear`
+- **Mode**: FULL_BODY
+- **Description**: Wild west cowboy
+- **Costume Details**:
+ - **Under Layer**: Tan shirt with pointed collar, brown vest, leather belt with large gold oval buckle, blue jeans with seam details, brown cowboy boots with heels and silver spurs
+ - **Over Layer**: Tan sleeves, red bandana around neck with knot and animated tails, brown cowboy hat with curved brim and gold buckle on band
+- **Props**:
+ - Lasso (right hand, optional)
+- **Animations**: idle, walk, attack, hurt, celebrate
+- **Effects**: None
+- **Accessory Visibility**: Eyewear visible, Headwear visible (under hat), Effects visible
+- **Best For**: Western action, adventure games
+- **Recommended Music**: Western, country
+
+---
+
+## Pure Contexts (4)
+
+Pure contexts apply minimal modifications to the avatar, using only effects and subtle accessories. Avatar is fully visible in FULL_BODY mode.
+
+---
+
+### platformer-standard
+
+- **ID**: `platformer-standard`
+- **Mode**: FULL_BODY
+- **Description**: Standard platforming character with no costume modifications
+- **Modifications**: Dynamic shadow, dust effects only
+- **Visual Effects**:
+ - Shadow beneath avatar (scales with elevation)
+ - Launch dust when jumping starts
+ - Landing impact dust when touching ground
+ - Running dust puffs
+ - Circling stars when hurt
+ - Celebratory confetti
+- **Animations**: idle, walk, run, jump, fall, crouch, hurt, celebrate
+- **State Effects**:
+ - `running`: Dust puffs (frequency 0.1)
+ - `jumping`: Launch dust (once)
+ - `landing`: Impact dust (once)
+ - `hurt`: Stars around head (duration 0.5s)
+ - `celebrating`: Confetti (duration 3.0s)
+- **Accessory Visibility**: All visible
+- **Best For**: Platformers, adventure games, action games
+- **Recommended Music**: Playful, heroic
+
+---
+
+### sports-player
+
+- **ID**: `sports-player`
+- **Mode**: FULL_BODY
+- **Description**: Athletic character with minimal sports equipment (jersey number 7)
+- **Modifications**: Sweatband on forehead, jersey number on back
+- **Visual Effects**:
+ - Elongated court/field shadow
+ - Speed lines when running fast (threshold 0.7)
+ - Motion blur when jumping at speed
+ - Sparkles when scoring
+ - Impact flash when hurt
+- **Animations**: idle, walk, run, jump, fall, crouch, hurt, celebrate, throw, catch, kick
+- **State Effects**:
+ - `running`: Speed lines (threshold 0.7)
+ - `jumping`: Motion blur
+ - `scoring`: Sparkles (duration 1.0s)
+ - `hurt`: Flash (duration 0.3s)
+ - `celebrating`: Sparkles (duration 2.0s)
+- **Accessory Visibility**: All visible
+- **Best For**: Sports games, basketball, soccer, tennis, racing
+- **Recommended Music**: Energetic
+
+---
+
+### adventure-hero
+
+- **ID**: `adventure-hero`
+- **Mode**: FULL_BODY
+- **Description**: Adventurous character with backpack and utility belt
+- **Modifications**: Small brown backpack on back, thin utility belt with pouches
+- **Visual Effects**:
+ - Dynamic shadow based on light source
+ - Subtle heroic glow when idle (pulsing gold)
+ - Determination lines (golden speed lines) when running
+ - Cape-like motion trails when jumping (blue trailing lines)
+ - Discovery sparkle effect
+ - Damage flash when hurt
+- **Animations**: idle, walk, run, jump, fall, crouch, hurt, celebrate, climb, push, pull
+- **State Effects**:
+ - `idle`: Glow (continuous)
+ - `running`: Determination lines (continuous)
+ - `jumping`: Cape motion trails (continuous)
+ - `discovering`: Sparkle (duration 1.5s)
+ - `hurt`: Damage flash (duration 0.4s)
+- **Accessory Visibility**: All visible
+- **Best For**: Adventure games, puzzle games, exploration, lite RPGs
+- **Recommended Music**: Adventurous
+
+---
+
+### runner
+
+- **ID**: `runner`
+- **Mode**: FULL_BODY
+- **Description**: Streamlined character optimized for endless runner games
+- **Modifications**: None (pure avatar)
+- **Visual Effects**:
+ - Motion-blurred shadow (trails behind based on speed)
+ - Constant speed lines (intensity based on speed)
+ - Intensified speed effect when accelerating
+ - Air trail when jumping/falling
+ - Wind-swept hair effect (wind lines from head)
+ - Ground friction sparks when sliding
+ - Pickup sparkle when collecting items
+ - Stumble effect with shake lines when hurt
+ - Stars for big hits
+- **Avatar Position**: Slightly left (x: 0.4) to show forward space
+- **Default Animation**: run (not idle)
+- **Animations**: idle, run, jump, slide, fall, hurt, celebrate
+- **State Effects**:
+ - `running`: Speed lines (continuous)
+ - `accelerating`: Intensified speed (continuous)
+ - `jumping`: Air trail (continuous)
+ - `sliding`: Sparks (continuous)
+ - `collecting`: Sparkle (duration 0.5s)
+ - `hurt`: Stumble effect (duration 0.6s)
+- **Accessory Visibility**: All visible
+- **Best For**: Endless runners, racing, speed games
+- **Recommended Music**: Fast-paced, electronic
+
+---
+
+## Context Selection Guide
+
+### By Game Type
+
+| Game Type | Recommended Contexts |
+|-----------|---------------------|
+| Space shooter | spaceship-cockpit, mech-suit |
+| Racing | race-car, motorcycle, hoverboard |
+| Flying/Flappy | flappy-style, airplane, jetpack, dragon-rider |
+| Platformer | platformer-standard, adventure-hero, superhero-cape |
+| Endless Runner | runner, hoverboard, motorcycle |
+| RPG | knight-armor, wizard-robes, ninja-outfit, adventure-hero |
+| Sports | athlete-uniform, sports-player |
+| Puzzle | platformer-standard, scientist-labcoat, wizard-robes |
+| Cooking | chef-outfit |
+| Western | cowboy-gear |
+| Underwater | submarine, space-suit |
+| Fantasy | dragon-rider, knight-armor, wizard-robes |
+| Arcade/Retro | pacman-style, flappy-style, spaceship-cockpit |
+| Military | tank, mech-suit |
+| Stealth | ninja-outfit |
+| Pirate/Adventure | pirate-outfit, adventure-hero |
+
+### By Avatar Mode
+
+| Mode | Contexts |
+|------|----------|
+| HEAD_ONLY | spaceship-cockpit, tank, pacman-style, mech-suit, flappy-style |
+| HEAD_AND_SHOULDERS | race-car, airplane, submarine |
+| UPPER_BODY | hoverboard, jetpack |
+| FULL_BODY | dragon-rider, motorcycle, all costumes (10), all pure (4) |
+
+### By Category
+
+| Category | Count | Contexts |
+|----------|-------|----------|
+| Vehicle | 12 | spaceship-cockpit, race-car, airplane, flappy-style, tank, pacman-style, hoverboard, jetpack, mech-suit, submarine, dragon-rider, motorcycle |
+| Costume | 10 | knight-armor, space-suit, ninja-outfit, wizard-robes, superhero-cape, athlete-uniform, pirate-outfit, scientist-labcoat, chef-outfit, cowboy-gear |
+| Pure | 4 | platformer-standard, sports-player, adventure-hero, runner |
+
+---
+
+## API Usage
+
+### Getting Context Information
+
+```javascript
+// Get all contexts
+const allContexts = ContextCatalog.getAll();
+
+// Get a specific context by ID
+const context = ContextCatalog.getById('spaceship-cockpit');
+
+// Get contexts by category
+const vehicles = ContextCatalog.getByCategory('vehicle');
+const costumes = ContextCatalog.getByCategory('costume');
+const pure = ContextCatalog.getByCategory('pure');
+
+// Get contexts by avatar mode
+const headOnly = ContextCatalog.getByMode('HEAD_ONLY');
+const fullBody = ContextCatalog.getByMode('FULL_BODY');
+
+// Get recommended contexts for a game type
+const rpgContexts = ContextCatalog.getRecommendedFor('rpg');
+const racingContexts = ContextCatalog.getRecommendedFor('racing');
+
+// Search contexts
+const matches = ContextCatalog.search('knight');
+
+// Filter contexts
+const filtered = ContextCatalog.filter({
+ category: 'costume',
+ mode: 'FULL_BODY',
+ gameType: 'rpg'
+});
+
+// Get random context
+const random = ContextCatalog.getRandom();
+const randomForType = ContextCatalog.getRandomForGameType('platformer');
+```
+
+### Applying Context to Avatar
+
+```javascript
+// Apply context to an avatar object
+const avatarWithContext = ContextRenderer.apply(avatar, 'spaceship-cockpit');
+
+// Render the avatar with context
+ContextRenderer.render(avatarWithContext, ctx, x, y, {
+ scale: 1.0,
+ time: elapsedTime
+});
+
+// Trigger an animation
+ContextRenderer.animate(avatarWithContext, 'celebrate');
+
+// Update state for effects
+ContextRenderer.setState(avatarWithContext, {
+ running: true,
+ speed: 0.8,
+ facingRight: true
+});
+```
+
+### Context Object Structure
+
+```javascript
+{
+ id: 'context-id',
+ name: 'Human Readable Name',
+ category: 'vehicle' | 'costume' | 'pure',
+ avatarMode: 'HEAD_ONLY' | 'HEAD_AND_SHOULDERS' | 'UPPER_BODY' | 'FULL_BODY',
+ animations: ['idle', 'walk', 'attack', ...],
+ defaultAnimation: 'idle',
+ accessoryVisibility: {
+ eyewear: true | false,
+ headwear: true | false,
+ effects: true | false
+ },
+ layerOrder: ['background', 'avatar', 'foreground'],
+ recommendedFor: ['game-type-1', 'game-type-2'],
+ description: 'Short description',
+ avatarPosition: { x: 0.5, y: 0.5 },
+ avatarScale: 1.0,
+ props: [
+ { id: 'prop-id', anchor: 'rightArm', offset: { x: 0, y: 0 } }
+ ],
+ contextEffects: {
+ effectName: { type: 'effect-type', ... }
+ },
+ renderBackground: function(ctx, width, height, time, options) { ... },
+ renderForeground: function(ctx, width, height, time, options) { ... },
+ renderCostumeUnder: function(ctx, avatar, x, y, scale, transforms, time) { ... },
+ renderCostumeOver: function(ctx, avatar, x, y, scale, transforms, time) { ... }
+}
+```
+
+---
+
+## Statistics
+
+- **Total Contexts**: 26
+- **Vehicle Contexts**: 12
+- **Costume Contexts**: 10
+- **Pure Contexts**: 4
+- **Version**: 1.0.0
+- **Last Updated**: 2026-02-05
+
+---
+
+## Source Files
+
+- `/frontend/public/assets/avatar/contextCatalog.js` - Main catalog aggregator
+- `/frontend/public/assets/avatar/contextRenderer.js` - Rendering engine
+- `/frontend/public/assets/avatar/contexts/vehicles.js` - Vehicle context definitions (12)
+- `/frontend/public/assets/avatar/contexts/costumes.js` - Costume context definitions (10)
+- `/frontend/public/assets/avatar/contexts/pure.js` - Pure context definitions (4)
diff --git a/frontend/public/docs/GAME_GENERATION_GUIDE.md b/frontend/public/docs/GAME_GENERATION_GUIDE.md
new file mode 100644
index 0000000..0850f06
--- /dev/null
+++ b/frontend/public/docs/GAME_GENERATION_GUIDE.md
@@ -0,0 +1,170 @@
+# Game Generation Guide for AI
+
+This guide helps Haiku (Claude AI) select appropriate assets when generating games.
+
+## Decision Tree
+
+### Step 1: Identify Game Style
+
+Based on user's prompt, classify into one of 11 styles:
+
+| Keywords | Game Style |
+|----------|------------|
+| shoot, space, action, fast, arcade | action-arcade |
+| puzzle, match, brain, logic | puzzle |
+| story, adventure, narrative, choice | story-adventure |
+| race, car, speed, run | racing |
+| pet, animal, care, farm | pet-simulator |
+| quiz, trivia, question, knowledge | trivia-quiz |
+| music, rhythm, dance, beat | music-rhythm |
+| draw, create, art, design | creative-drawing |
+| rpg, battle, quest, dungeon | rpg-battle |
+| sports, ball, soccer, basketball | sports |
+| physics, pinball, bounce, gravity | physics-pinball |
+
+### Step 2: Detect Theme
+
+| Theme Keywords | Background Theme |
+|----------------|-----------------|
+| space, alien, star, galaxy | space |
+| forest, nature, tree, outdoor | nature |
+| city, street, building, urban | urban |
+| magic, wizard, dragon, castle | fantasy |
+| ghost, haunted, scary, dark | spooky |
+| ocean, fish, underwater, sea | underwater |
+| retro, pixel, arcade, classic | retro |
+| sports, stadium, field, court | sports |
+| sky, flying, clouds, air | sky |
+| rainbow, colorful, bright, party | colorful |
+| robot, machine, factory, tech | mechanical |
+| home, room, kitchen, office | indoor |
+| rain, snow, storm, weather | weather |
+| spring, summer, autumn, winter | seasonal |
+| abstract, minimal, pattern | abstract |
+
+### Step 3: Detect Mood
+
+| Mood Keywords | Music Mood |
+|---------------|------------|
+| epic, battle, intense, boss | Epic |
+| calm, relax, peaceful, zen | Chill |
+| fast, action, rush, chase | Intense |
+| fun, happy, cute, silly | Playful |
+| mystery, scary, dark, creepy | Mysterious |
+| hero, adventure, brave | Heroic |
+| weird, unique, strange | Quirky |
+| background, ambient, subtle | Ambient |
+
+### Step 4: Determine Avatar Mode
+
+| Game Mechanic | Avatar Mode | Context |
+|---------------|-------------|---------|
+| Cockpit/driving view | HEAD_ONLY | spaceship-cockpit, tank, mech-suit |
+| Driving with body visible | HEAD_AND_SHOULDERS | race-car, airplane, submarine |
+| Upper body action | UPPER_BODY | hoverboard, jetpack |
+| Full character movement | FULL_BODY | platformer-standard, costumes |
+
+### Step 5: Select Context
+
+| Game Type | Recommended Context |
+|-----------|---------------------|
+| Space shooter | spaceship-cockpit |
+| Racing game | race-car, motorcycle |
+| Flappy clone | flappy-style |
+| Tank game | tank |
+| Maze game (pacman) | pacman-style |
+| Hoverboard/skateboard | hoverboard |
+| Jetpack game | jetpack |
+| Mech combat | mech-suit |
+| Underwater | submarine |
+| Dragon game | dragon-rider |
+| Platformer | platformer-standard |
+| RPG/Adventure | knight-armor, wizard-robes, ninja-outfit |
+| Sports | athlete-uniform, sports-player |
+| Cooking | chef-outfit |
+| Science | scientist-labcoat |
+
+## Best Practices
+
+### 1. Use Presets for Variety
+```javascript
+// DON'T: Always use the same combination
+background: { theme: 'space', variant: 'nebula' }
+
+// DO: Use different presets or variations
+const preset = AssetPresets.getRandom('action-arcade');
+```
+
+### 2. Match Mood and Theme
+```javascript
+// GOOD: Epic music + Space background
+{ music: { mood: 'Epic' }, background: { theme: 'space' } }
+
+// BAD: Chill music + Intense action background
+{ music: { mood: 'Chill' }, background: { theme: 'mechanical' } }
+```
+
+### 3. Match Context to Gameplay
+```javascript
+// GOOD: Flying game → HEAD_ONLY + flappy-style
+// GOOD: Platformer → FULL_BODY + platformer-standard
+// BAD: Racing game → FULL_BODY (should be HEAD_AND_SHOULDERS)
+```
+
+### 4. Validate Combinations
+```javascript
+const score = AssetCompatibility.validate(config).score;
+// Aim for score > 70
+```
+
+## Example Selections
+
+### "space shooter with aliens"
+```javascript
+{
+ music: { mood: 'Epic', energy: 8 },
+ background: { theme: 'space', variant: 'nebula' },
+ avatarContext: { mode: 'HEAD_ONLY', context: 'spaceship-cockpit' }
+}
+```
+
+### "relaxing puzzle game with nature"
+```javascript
+{
+ music: { mood: 'Chill', energy: 3 },
+ background: { theme: 'nature', variant: 'meadow' },
+ avatarContext: { mode: 'FULL_BODY', context: 'platformer-standard' }
+}
+```
+
+### "medieval RPG with dragon battles"
+```javascript
+{
+ music: { mood: 'Epic', energy: 9 },
+ background: { theme: 'fantasy', variant: 'castle' },
+ avatarContext: { mode: 'FULL_BODY', context: 'knight-armor' }
+}
+```
+
+### "scary haunted house escape"
+```javascript
+{
+ music: { mood: 'Mysterious', energy: 5 },
+ background: { theme: 'spooky', variant: 'hauntedHouse' },
+ avatarContext: { mode: 'FULL_BODY', context: 'adventure-hero' }
+}
+```
+
+## Tracking Variety
+
+To ensure games don't all look the same:
+
+1. Rotate through presets (don't always pick index 0)
+2. Use different background variants within same theme
+3. Mix costumes even for similar game types
+4. Vary music energy levels
+
+## Template Selection
+
+- Use `gameTemplateAssets.html` when avatar matters
+- Use `gameTemplateSimple.html` for abstract/puzzle games without avatars
diff --git a/frontend/public/docs/MUSIC_CATALOG.md b/frontend/public/docs/MUSIC_CATALOG.md
new file mode 100644
index 0000000..8003139
--- /dev/null
+++ b/frontend/public/docs/MUSIC_CATALOG.md
@@ -0,0 +1,406 @@
+# Music Catalog
+
+## Overview
+
+160 procedural songs generated using Tone.js.
+8 moods x 20 songs each.
+
+All songs are synthesized in real-time using the MusicEngine, which reads song definitions from MusicLibrary and generates audio procedurally using Tone.js synths, effects, and chord progressions.
+
+---
+
+## Mood Categories
+
+### Epic (20 songs)
+
+Energy range: 7-10
+Best for: Boss battles, climactic moments, action sequences
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| epic_001 | Siege of Thunder | 9 | 150 | C minor | action, siege, storm | Thunderous orchestral assault with relentless percussion and soaring brass lines |
+| epic_002 | Dragon's Wrath | 10 | 160 | D harmonic minor | dragon, boss-fight, powerful | Ferocious harmonic minor shred with explosive drum fills and scorching lead |
+| epic_003 | Kingdom's Fall | 8 | 135 | E dorian | kingdom, dramatic, war | Brooding dorian march that builds from sorrowful strings to a devastating climax |
+| epic_004 | Titan Ascendant | 9 | 145 | F minor | titan, heroic, climax | Ascending power theme with massive layered synths and triumphant resolution |
+| epic_005 | Forge of Legends | 8 | 130 | G melodic minor | forge, battle, powerful | Heavy melodic minor groove with hammering rhythms and a blazing lead melody |
+| epic_006 | Storm Legion | 9 | 155 | A harmonic minor | storm, legion, fierce | Rapid harmonic minor fury with militant snare rolls and piercing lead runs |
+| epic_007 | Conquest of Dawn | 8 | 140 | B minor | conquest, heroic, victory | Majestic dawn-rise theme with layered pads building to a grand fanfare |
+| epic_008 | Blood of the Arena | 10 | 165 | C# phrygian | battle, fierce, combat | Phrygian gladiator anthem with thundering kicks and savage melodic hooks |
+| epic_009 | Throne Eternal | 7 | 125 | D# dorian | throne, dramatic, kingdom | Stately dorian processional with regal brass swells and ceremonial percussion |
+| epic_010 | Wings of Valkyrie | 9 | 152 | F# harmonic minor | heroic, action, victory | Soaring harmonic minor flight with rapid arpeggios and a euphoric peak |
+| epic_011 | The Iron March | 8 | 132 | G# minor | war, legion, march | Crushing military march with iron-clad percussion and a resolute melodic theme |
+| epic_012 | Celestial Siege | 9 | 148 | A# melodic minor | siege, climax, powerful | Heavenly melodic minor ascent colliding with earth-shaking low end and choir-like pads |
+| epic_013 | Oathbreaker | 10 | 170 | E harmonic minor | boss-fight, aggressive, dramatic | Blistering boss battle anthem with relentless double-kicks and a searing harmonic minor riff |
+| epic_014 | Pillars of Eternity | 7 | 120 | F dorian | dramatic, kingdom, throne | Grand dorian hymn with towering pad layers and a slow-building, inevitable crescendo |
+| epic_015 | The Last Bastion | 9 | 142 | A minor | siege, heroic, battle | Desperate last-stand theme with urgent strings and a defiant, rising chorus |
+| epic_016 | Warcry of the North | 10 | 158 | B harmonic minor | war, fierce, conquest | Nordic war chant with thunderous toms, harsh harmonic minor runs, and tribal energy |
+| epic_017 | Shattered Crown | 8 | 138 | C# dorian | dramatic, throne, powerful | Regal dorian tragedy with broken chord progressions that rebuild into glory |
+| epic_018 | Dragonfire Overture | 9 | 162 | D melodic minor | dragon, action, climax | Scorching melodic minor overture with blazing fast arpeggios and massive drops |
+| epic_019 | Echoes of Valhalla | 8 | 128 | G minor | victory, heroic, legend | Heroic afterglow theme with echoing delays, reverent pads, and a noble melody |
+| epic_020 | Apex Predator | 10 | 168 | D# phrygian | boss-fight, titan, aggressive | Phrygian apex boss theme with crushing low end, frantic leads, and zero mercy |
+
+---
+
+### Chill (20 songs)
+
+Energy range: 1-4
+Best for: Puzzle games, menus, relaxation
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| chill_001 | Sunset Horizon | 2 | 80 | C major | sunset, calm, peaceful | Warm golden-hour pads with gentle plucked melodies drifting over a soft beat |
+| chill_002 | Cloud Nine | 2 | 85 | D lydian | cloud, zen, soft | Floating lydian dreamscape with airy pads and a delicate crystalline lead |
+| chill_003 | Gentle Stream | 1 | 72 | F major | stream, peaceful, meditation | Babbling brook ambience with minimal plucked notes and vast reverb spaces |
+| chill_004 | Amber Dusk | 3 | 90 | G mixolydian | sunset, smooth, lounge | Laid-back mixolydian groove with a silky lead line and soft shuffled percussion |
+| chill_005 | Velvet Rain | 2 | 78 | A dorian | rain, calm, soft | Tender dorian rainfall with muted plucks and warm sub-bass undertones |
+| chill_006 | Morning Dew | 3 | 95 | E pentatonic major | breeze, garden, easy | Fresh pentatonic morning with sparkling plucks and a gentle rhythmic pulse |
+| chill_007 | Sapphire Coast | 3 | 92 | B major | shore, smooth, relax | Coastal shimmer with wave-like pad swells and a melodic bass walk |
+| chill_008 | Paper Lanterns | 2 | 76 | D# lydian | zen, peaceful, meditation | Floating lydian lantern-light with shimmering overtones and barely-there percussion |
+| chill_009 | Lavender Fields | 2 | 82 | F# major | garden, calm, soft | Pastoral meadow soundscape with singing pads and tiny melodic blossoms |
+| chill_010 | Moonlit Canopy | 1 | 70 | C# dorian | zen, meditation, soft | Deep forest night ambience with owl-like calls and a hypnotic dorian drone |
+| chill_011 | Drifting Kites | 3 | 100 | G# pentatonic major | breeze, cheerful, easy | Breezy pentatonic float with a playful pluck melody and airy pad washes |
+| chill_012 | Still Waters | 1 | 74 | A# major | calm, peaceful, meditation | Mirror-flat lake ambience with slow reverb tails and barely audible melodic whispers |
+| chill_013 | Silken Thread | 3 | 96 | D mixolydian | smooth, lounge, easy | Sophisticated mixolydian lounge with buttery chords and a cool walking bass |
+| chill_014 | Porcelain Sky | 2 | 84 | E lydian | cloud, zen, peaceful | Delicate lydian sky with bell-like plucks floating over an endless pad horizon |
+| chill_015 | Terracotta Noon | 4 | 105 | A dorian | smooth, lounge, relax | Sun-baked dorian groove with a loose beat, mellow keys, and an easy bass line |
+| chill_016 | Snowfall Waltz | 2 | 88 | F major | soft, calm, peaceful | Gentle snowfall waltz with twinkling bells and a feather-light three-beat feel |
+| chill_017 | Jasmine Tea | 2 | 78 | G pentatonic major | zen, meditation, soft | Meditative pentatonic ritual with ceramic-toned plucks and warm bass hum |
+| chill_018 | Indigo Hour | 3 | 98 | B dorian | smooth, lounge, sunset | Deep dorian twilight groove with a muted lead, lush pads, and subtle swing |
+| chill_019 | Floating Feather | 1 | 70 | C lydian | cloud, peaceful, meditation | Weightless lydian drift with overtone-rich pads and the faintest melodic outline |
+| chill_020 | Harbor Lights | 4 | 110 | F# mixolydian | shore, smooth, relax | Warm harbor-side mixolydian jam with a steady pulse and reflective melodic phrases |
+
+---
+
+### Intense (20 songs)
+
+Energy range: 8-10
+Best for: Racing, action, time pressure
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| intense_001 | Midnight Chase | 9 | 160 | C minor | chase, urgent, adrenaline | Heart-pounding minor key pursuit with relentless four-on-floor and staccato stabs |
+| intense_002 | Fury Unleashed | 10 | 175 | D phrygian | fury, aggressive, rampage | Phrygian berserker rage with distorted bass, frantic leads, and pummeling kicks |
+| intense_003 | Overdrive | 9 | 155 | E minor | overdrive, fast, thrilling | Redlined engine energy with screaming synths and a breakneck tempo that never lets up |
+| intense_004 | Razor Edge | 10 | 170 | F harmonic minor | danger, aggressive, combat | Razor-sharp harmonic minor slashes with searing leads and machine-gun percussion |
+| intense_005 | Panic Protocol | 9 | 165 | G minor | panic, urgent, pursuit | Emergency klaxon energy with alarm-like leads and a propulsive bass engine |
+| intense_006 | Blitz Reaper | 10 | 180 | A phrygian | blitz, fierce, frenzy | Maximum-speed phrygian onslaught with unrelenting double-time drums and savage riffs |
+| intense_007 | Neon Inferno | 9 | 150 | B blues | adrenaline, thrilling, fast | Blues-infused neon hellscape with gritty lead bends and a stomping groove |
+| intense_008 | Shockwave | 10 | 172 | C# harmonic minor | combat, aggressive, fierce | Explosive harmonic minor shockblast with staccato stabs and obliterating drops |
+| intense_009 | Terminal Velocity | 9 | 162 | D# minor | fast, pursuit, adrenaline | Free-falling velocity rush with whipping arpeggios and an unstoppable beat |
+| intense_010 | Venom Strike | 10 | 168 | F# phrygian | danger, combat, rampage | Venomous phrygian attack with serpentine lead phrases and toxic bass drops |
+| intense_011 | Wraith Runner | 9 | 156 | G# harmonic minor | chase, thrilling, urgent | Ghostly harmonic minor sprint with phasing leads and a thundering rhythmic foundation |
+| intense_012 | Meltdown | 10 | 176 | A# chromatic | frenzy, aggressive, panic | Nuclear meltdown chaos with chromatic madness and total percussive devastation |
+| intense_013 | Iron Lung | 8 | 148 | C blues | fierce, thrilling, overdrive | Breathless blues-scale assault with a heavy swing and gritty overdriven bass |
+| intense_014 | Voltage Surge | 9 | 158 | D minor | adrenaline, fast, urgent | Electric voltage spike with buzzing FM leads and an unstoppable rhythmic charge |
+| intense_015 | Reckless Descent | 9 | 152 | E harmonic minor | danger, thrilling, pursuit | Spiraling harmonic minor descent with cascading leads and hammering kicks |
+| intense_016 | Firestorm Protocol | 10 | 174 | F phrygian | combat, aggressive, blitz | Scorched-earth phrygian devastation with relentless snare rolls and wall-of-sound bass |
+| intense_017 | Cascade Fury | 8 | 145 | G minor | fierce, pursuit, thrilling | Cascading minor fury with tumbling melodic runs and a rock-solid groove foundation |
+| intense_018 | Deathline Express | 10 | 178 | A harmonic minor | frenzy, rampage, aggressive | Runaway-train harmonic minor express with breakneck speed and zero stopping power |
+| intense_019 | Concrete Jungle | 8 | 142 | B blues | urgent, adrenaline, thrilling | Gritty urban blues assault with dirty bass growls and relentless breakbeat energy |
+| intense_020 | Event Horizon | 9 | 164 | D# chromatic | danger, panic, fast | Reality-warping chromatic vortex pulling everything into a relentless rhythmic singularity |
+
+---
+
+### Playful (20 songs)
+
+Energy range: 4-7
+Best for: Kids games, casual games, fun moments
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| playful_001 | Bubble Pop | 6 | 130 | C major | fun, bouncy, bubble | Bouncy pop confection with fizzy pluck melodies and a toe-tapping beat |
+| playful_002 | Candy Rush | 7 | 140 | D mixolydian | candy, happy, party | Sugar-coated mixolydian romp with rapid-fire melodies and a party-floor beat |
+| playful_003 | Skip & Jump | 5 | 120 | F major | skip, cheerful, lighthearted | Carefree skip-along with a lilting melody, simple chords, and a playful shuffle |
+| playful_004 | Sunshine Express | 6 | 135 | G lydian | sunshine, happy, dance | Bright lydian sunshine ride with sparkling chords and an infectious rhythmic hook |
+| playful_005 | Frog Hop | 5 | 118 | A pentatonic major | jump, silly, cartoon | Quirky pentatonic hop with bouncing bass notes and a whimsical plucked melody |
+| playful_006 | Pixel Parade | 6 | 132 | E major | fun, cheerful, party | Retro pixel-art parade with chiptuney leads and a marching festival vibe |
+| playful_007 | Jellybean Bounce | 7 | 142 | B mixolydian | bouncy, candy, dance | Maximum-bounce mixolydian groove with rubbery bass and a sugary melodic hook |
+| playful_008 | Rainbow Sprinkles | 5 | 125 | C# lydian | rainbow, happy, lighthearted | Colorful lydian celebration with chiming leads and a gentle, flowing groove |
+| playful_009 | Trampoline | 7 | 145 | D# major | jump, bouncy, fun | Sky-high trampoline energy with elastic bass and soaring staccato rebounds |
+| playful_010 | Whirlwind Waltz | 4 | 115 | F# major | dance, cheerful, lighthearted | Gentle whirlwind waltz with graceful plucks and a charming three-beat lilt |
+| playful_011 | Firefly Festival | 5 | 122 | G# pentatonic major | cheerful, fun, frolic | Twinkling firefly night with dancing pentatonic melodies and a warm, steady pulse |
+| playful_012 | Lollipop Lane | 6 | 128 | A# major | candy, silly, cartoon | Sweet lollipop stroll with cartoon-like sound effects and a skip-worthy rhythm |
+| playful_013 | Dizzy Spin | 7 | 148 | E dorian | fun, dance, party | Dizzying dorian spin cycle with whirling arpeggios and a head-bobbing groove |
+| playful_014 | Cotton Candy Clouds | 4 | 112 | F lydian | cloud, happy, lighthearted | Fluffy lydian cotton-candy float with soft plucks and dreamy pad wisps |
+| playful_015 | Pinball Wizard | 7 | 150 | A mixolydian | fun, bouncy, party | Rapid-fire pinball ricochet with dinging FM leads and a hyperactive bassline |
+| playful_016 | Confetti Cannon | 6 | 138 | B major | party, cheerful, dance | Explosive confetti burst with bright staccato hits and a celebratory chorus |
+| playful_017 | Rubber Duck | 5 | 116 | D pentatonic major | silly, cartoon, fun | Squeaky-clean pentatonic ditty with rubbery bass plucks and a goofy swing |
+| playful_018 | Kite Runner | 5 | 124 | G dorian | frolic, lighthearted, breeze | Breezy dorian kite flight with soaring lead phrases and a playful rhythmic tug |
+| playful_019 | Pancake Flip | 6 | 134 | C mixolydian | silly, fun, bouncy | Morning-kitchen mixolydian jam with flippy rhythms and a warm, buttery tone |
+| playful_020 | Starlight Carousel | 4 | 110 | F# lydian | dance, happy, lighthearted | Enchanted carousel spin with twinkling lydian bells and a gentle, revolving rhythm |
+
+---
+
+### Mysterious (20 songs)
+
+Energy range: 2-5
+Best for: Horror, exploration, puzzles
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| mysterious_001 | Shadow Waltz | 3 | 95 | D phrygian | mystery, dark, suspense | Eerie waltz with unsettling chromatic turns and ghostly reverberations |
+| mysterious_002 | Phantom's Riddle | 4 | 105 | A harmonic minor | phantom, riddle, enigma | Brooding harmonic minor theme with restless melodic questions |
+| mysterious_003 | Twilight Labyrinth | 3 | 88 | F# minor | twilight, labyrinth, shadow | Winding minor key passages through dim corridors of sound |
+| mysterious_004 | Crypt Whispers | 2 | 82 | B phrygian | crypt, whisper, eerie | Hushed phrygian murmurs echoing through ancient stone chambers |
+| mysterious_005 | Fog of Enigma | 3 | 100 | E whole tone | fog, enigma, mystery | Drifting whole-tone haze obscuring all sense of harmonic direction |
+| mysterious_006 | Spell Unraveled | 5 | 115 | G harmonic minor | spell, dark, haunt | Urgent harmonic minor incantation building to a feverish climax |
+| mysterious_007 | Void Gazer | 2 | 84 | C chromatic | void, dark, eerie | Staring into the abyss with atonal fragments dissolving into silence |
+| mysterious_008 | Detective Noir | 4 | 108 | C# minor | detective, suspense, shadow | Smoky noir investigation theme with sly chromatic bass lines |
+| mysterious_009 | Haunted Carousel | 4 | 112 | F phrygian | haunt, eerie, puzzle | A broken carousel spinning in phrygian darkness with warped melodies |
+| mysterious_010 | Riddle of Echoes | 3 | 92 | D# harmonic minor | riddle, enigma, whisper | Haunting call-and-response between shadowy melodic fragments |
+| mysterious_011 | Obsidian Mirror | 3 | 98 | G# minor | dark, mystery, shadow | Dark reflections shimmer in minor key with glassy sustained tones |
+| mysterious_012 | Serpent Passage | 5 | 118 | A# phrygian | suspense, dark, labyrinth | Slithering phrygian riff coiling through an underground labyrinth |
+| mysterious_013 | Moonless Night | 2 | 80 | E whole tone | eerie, void, twilight | Formless whole-tone drifts under a starless sky with no resolution |
+| mysterious_014 | Specter's Descent | 4 | 104 | B harmonic minor | phantom, dark, haunt | Descending harmonic minor spiral pulling deeper into spectral depths |
+| mysterious_015 | Puzzle Chamber | 4 | 110 | F minor | puzzle, mystery, riddle | Interlocking melodic puzzles clicking into place within stone walls |
+| mysterious_016 | Veiled Threshold | 3 | 90 | C phrygian | mystery, fog, twilight | Standing at the boundary between worlds in thick phrygian fog |
+| mysterious_017 | Waning Sigil | 3 | 96 | D harmonic minor | spell, enigma, crypt | A fading magical sigil pulsing with diminishing harmonic minor energy |
+| mysterious_018 | Forgotten Archive | 3 | 94 | G# chromatic | mystery, puzzle, shadow | Dusty chromatic fragments unearthed from a lost archive of secrets |
+| mysterious_019 | Oracle's Warning | 5 | 120 | A minor | suspense, dark, whisper | An urgent prophetic warning delivered in frantic minor key passages |
+| mysterious_020 | Abyssal Lullaby | 2 | 85 | F# whole tone | void, eerie, twilight | A gentle yet deeply unsettling lullaby drifting over an endless abyss |
+
+---
+
+### Heroic (20 songs)
+
+Energy range: 6-9
+Best for: Adventure, achievements, victories
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| heroic_001 | Champion's Anthem | 8 | 145 | C major | hero, triumph, anthem | A soaring major key anthem celebrating the ultimate champion |
+| heroic_002 | Dawn of Valor | 7 | 138 | G lydian | dawn, valor, courage | Radiant lydian sunrise heralding a new era of bravery and valor |
+| heroic_003 | Shield Bearer | 7 | 132 | D mixolydian | shield, brave, quest | Sturdy mixolydian march of a steadfast shield bearer on a noble quest |
+| heroic_004 | Glory Unchained | 9 | 155 | A major | glory, triumph, champion | Explosive high-energy celebration of total victory and unchained glory |
+| heroic_005 | Oath of the Paladin | 7 | 128 | F dorian | oath, paladin, courage | Solemn yet powerful dorian oath sworn by a noble paladin |
+| heroic_006 | Crusader's March | 8 | 142 | E major | crusade, hero, sword | Relentless marching rhythm driving a crusade forward to victory |
+| heroic_007 | Beacon of Hope | 6 | 125 | B lydian | beacon, hero, legend | A luminous lydian melody shining as a beacon through the darkness |
+| heroic_008 | Thunder Vanguard | 9 | 158 | D# minor | brave, quest, sword | Thunderous minor key charge of the vanguard into the heart of battle |
+| heroic_009 | Skyward Bound | 7 | 135 | F# major | adventure, glory, dawn | An uplifting ascent into the clouds with boundless major key optimism |
+| heroic_010 | Iron Resolve | 8 | 148 | C# dorian | champion, shield, valor | Unbreakable dorian determination with a hammering rhythmic backbone |
+| heroic_011 | Wingspan | 6 | 122 | G# lydian | adventure, hero, dawn | Soaring lydian theme evoking the spread of mighty wings at first light |
+| heroic_012 | Legend's Forge | 8 | 150 | A# minor | legend, sword, crusade | White-hot minor key intensity of a legendary weapon being forged |
+| heroic_013 | Rally Cry | 9 | 160 | D mixolydian | triumph, anthem, glory | A deafening mixolydian rally cry uniting an army before the final charge |
+| heroic_014 | Valor Rising | 7 | 130 | F major | valor, brave, quest | A steadily rising major key theme embodying growing courage |
+| heroic_015 | Fortress Eternal | 7 | 136 | B dorian | shield, paladin, oath | Impenetrable dorian fortress theme with layered harmonic defenses |
+| heroic_016 | Phoenix's Flight | 8 | 146 | E lydian | hero, legend, dawn | Blazing lydian ascent of a reborn phoenix soaring above the flames |
+| heroic_017 | Banner Unfurled | 7 | 134 | G major | glory, courage, champion | Majestic unfurling of a victorious banner in crisp major key splendor |
+| heroic_018 | Dragonslayer | 9 | 156 | C# minor | sword, brave, quest | Ferocious minor key battle theme of a legendary dragonslayer |
+| heroic_019 | Crowning Moment | 6 | 120 | A# major | triumph, anthem, glory | A stately coronation theme building to an emotional crowning climax |
+| heroic_020 | Eternal Vow | 6 | 126 | F# mixolydian | oath, paladin, beacon | A profound mixolydian pledge of eternal service and unwavering loyalty |
+
+---
+
+### Quirky (20 songs)
+
+Energy range: 5-8
+Best for: Comedy, unique games, surprises
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| quirky_001 | Wobble Walk | 6 | 118 | C mixolydian | wobble, funny, offbeat | A tipsy mixolydian stroll with syncopated bass and bouncy staccato hits |
+| quirky_002 | Glitch Parade | 7 | 128 | F blues | glitch, weird, surprise | A malfunctioning parade of blues riffs and unexpected rhythmic hiccups |
+| quirky_003 | Mischief Maker | 6 | 112 | D dorian | mischief, prank, eccentric | Sneaky dorian theme of a trickster plotting delightful chaos |
+| quirky_004 | Rubber Duck Boogie | 7 | 132 | A pentatonic minor | funny, bounce, comedy | Absurd pentatonic boogie with squeaky lead lines and rubber bass |
+| quirky_005 | Clockwork Confusion | 5 | 108 | G whole tone | odd, twist, eccentric | Malfunctioning clockwork mechanism ticking in whole-tone disorientation |
+| quirky_006 | Prankster Polka | 7 | 135 | E mixolydian | prank, wacky, clown | A manic polka-inspired romp driven by wacky syncopation and flat-seven swagger |
+| quirky_007 | Jellybean Jamboree | 8 | 140 | B lydian | bounce, zany, comedy | Sugary lydian explosion of color and bouncing melodic candy |
+| quirky_008 | Sneaky Squirrel | 5 | 105 | D# dorian | mischief, trick, offbeat | Furtive dorian scurrying with darting staccato leads and syncopated bass |
+| quirky_009 | Topsy Turvy | 6 | 120 | F# blues | chaos, twist, wacky | Everything flipped upside down in a chaotic blues-scale adventure |
+| quirky_010 | Noodle Soup | 5 | 102 | C# pentatonic minor | weird, odd, eccentric | A winding noodly pentatonic melody sloshing around in rhythmic broth |
+| quirky_011 | Pixel Pandemonium | 8 | 138 | G# mixolydian | glitch, chaos, surprise | Hyper 8-bit style mixolydian mayhem with frantic arpeggio bursts |
+| quirky_012 | Banana Peel Slide | 6 | 116 | A# blues | funny, clown, comedy | Slippery blues-scale comedy with pratfall rhythms and slide-whistle leads |
+| quirky_013 | Funhouse Mirror | 5 | 110 | E whole tone | twist, odd, eccentric | Distorted whole-tone reflections warping reality in a carnival funhouse |
+| quirky_014 | Hiccup Hop | 7 | 126 | D dorian | bounce, wacky, zany | Jerky dorian hop interrupted by melodic hiccups and rhythmic stumbles |
+| quirky_015 | Zigzag Zephyr | 6 | 122 | B lydian | surprise, offbeat, mischief | Unpredictable lydian wind gusts pushing melodies in zigzag directions |
+| quirky_016 | Rubber Band Riff | 6 | 114 | F pentatonic minor | wobble, trick, comedy | Stretchy pentatonic riffs snapping back and forth with elastic energy |
+| quirky_017 | Carousel Malfunction | 7 | 130 | G mixolydian | chaos, glitch, wacky | A malfunctioning fairground ride spinning at odd intervals in mixolydian frenzy |
+| quirky_018 | Jester Jig | 7 | 136 | A dorian | clown, prank, zany | A flamboyant dorian jig performed by a mischievous court jester |
+| quirky_019 | Slapstick Serenade | 5 | 100 | C# blues | funny, comedy, twist | A tender serenade gone hilariously wrong with slapstick timing and blue notes |
+| quirky_020 | Kaleidoscope Caper | 6 | 124 | D# lydian | surprise, eccentric, wobble | Shifting lydian patterns like a kaleidoscope of musical fragments |
+
+---
+
+### Ambient (20 songs)
+
+Energy range: 1-3
+Best for: Background, meditation, creative tools
+
+| ID | Name | Energy | Tempo | Key | Tags | Description |
+|----|------|--------|-------|-----|------|-------------|
+| ambient_001 | Nebula Drift | 2 | 68 | C major | space, cosmic, floating | Weightless major key pads drifting through a colorful cosmic nebula |
+| ambient_002 | Crystal Meadow | 2 | 72 | G pentatonic major | crystal, meadow, tranquil | Sparkling pentatonic chimes scattered across a sunlit meadow |
+| ambient_003 | Glacial Horizon | 1 | 62 | D lydian | glacier, vast, ethereal | Immense glacial soundscape stretching to an infinite lydian horizon |
+| ambient_004 | Aurora Veil | 2 | 75 | A pentatonic major | aurora, ethereal, dream | Shimmering aurora borealis painted in pentatonic washes of light |
+| ambient_005 | Tidal Murmur | 2 | 66 | F minor | tide, mist, tranquil | Gentle minor key tides lapping rhythmically in a misty coastal haze |
+| ambient_006 | Stardust Canopy | 1 | 60 | B major | stardust, cosmic, vast | An endless canopy of stars rendered in slow-moving major key pads |
+| ambient_007 | Silk River | 2 | 70 | E pentatonic minor | floating, dream, tranquil | Smooth pentatonic minor currents flowing like liquid silk through space |
+| ambient_008 | Morning Dew | 2 | 76 | D# lydian | meadow, crystal, atmosphere | Dewdrops catching first light in shimmering lydian bell tones |
+| ambient_009 | Deep Cavern Echo | 1 | 64 | F# minor | void, vast, atmosphere | Cavernous minor key echoes bouncing endlessly through underground spaces |
+| ambient_010 | Velvet Cosmos | 2 | 74 | C# whole tone | cosmic, nebula, floating | Luxurious whole-tone textures unfolding across a velvet cosmic canvas |
+| ambient_011 | Feather Fall | 1 | 63 | G# pentatonic major | ethereal, dream, floating | Weightless pentatonic notes falling like feathers through still air |
+| ambient_012 | Horizon Glow | 3 | 82 | A# major | horizon, aurora, tranquil | Warm major key glow spreading across a peaceful distant horizon |
+| ambient_013 | Moss Garden | 2 | 69 | D pentatonic minor | meadow, mist, tranquil | A damp moss garden where pentatonic tones grow like delicate plants |
+| ambient_014 | Solar Wind | 2 | 78 | F lydian | space, cosmic, vast | Streams of lydian particles carried by an invisible solar wind |
+| ambient_015 | Moonpool Reflection | 1 | 61 | B minor | dream, mist, ethereal | Perfectly still moonlit pool reflecting minor key pads in mirrored silence |
+| ambient_016 | Ember Glow | 3 | 85 | E pentatonic major | atmosphere, tranquil, meadow | Warm dying embers casting a pentatonic glow across a quiet room |
+| ambient_017 | Cloud Temple | 2 | 71 | A lydian | floating, ethereal, vast | An ancient temple floating among the clouds in shimmering lydian suspension |
+| ambient_018 | Frozen Bloom | 1 | 65 | G whole tone | glacier, crystal, void | Ice crystals blooming in slow motion with directionless whole-tone shimmer |
+| ambient_019 | Whispering Sands | 2 | 73 | C# pentatonic minor | tide, mist, atmosphere | Desert sands whispering pentatonic secrets carried on a warm breeze |
+| ambient_020 | Infinite Stillness | 1 | 60 | F# major | void, stardust, vast | Absolute stillness at the edge of the universe where sound barely exists |
+
+---
+
+## Compatibility Matrix
+
+### Mood to Background Theme
+
+| Mood | Excellent Themes | Good Themes | Avoid |
+|------|-----------------|-------------|-------|
+| Epic | space, fantasy, dungeon, volcanic | urban, stadium, castle | pastel, garden, beach |
+| Chill | garden, beach, sunset, meadow | forest, sky, underwater | dungeon, volcanic, industrial |
+| Intense | urban, industrial, volcanic, stadium | space, dungeon, desert | garden, beach, meadow |
+| Playful | candy, circus, playground, cartoon | beach, forest, sky | dungeon, volcanic, horror |
+| Mysterious | dungeon, forest-night, underwater-deep, ancient-ruins | castle, space-dark, swamp | candy, playground, beach |
+| Heroic | castle, stadium, mountain-peak, sky | fantasy, forest, space | dungeon, horror, swamp |
+| Quirky | circus, cartoon, laboratory, toy-room | urban, candy, beach | dungeon, horror, volcanic |
+| Ambient | space, underwater, forest, sky | desert, beach, meadow | urban, stadium, industrial |
+
+### Mood to Avatar Context
+
+| Mood | Recommended Contexts |
+|------|---------------------|
+| Epic | knight-armor, spaceship-cockpit, dragon-rider, warrior-arena, commander-bridge |
+| Chill | beach-chair, hammock, cafe-window, meditation-cushion, garden-bench |
+| Intense | race-car, fighter-jet, motorcycle, battle-station, control-room |
+| Playful | playground, trampoline, carnival-booth, ball-pit, treehouse |
+| Mysterious | detective-office, ancient-library, foggy-alley, crystal-cave, haunted-mansion |
+| Heroic | throne-room, mountain-summit, victory-podium, ship-helm, castle-balcony |
+| Quirky | mad-scientist-lab, upside-down-room, funhouse, time-machine, cartoon-world |
+| Ambient | starship-window, underwater-dome, cloud-platform, zen-garden, space-station |
+
+---
+
+## API Usage
+
+```javascript
+// Initialize (called automatically on first play)
+await MusicEngine.init();
+
+// Play random song from mood
+const song = await MusicEngine.playRandomSong('Epic');
+console.log(song.name); // e.g., "Siege of Thunder"
+
+// Play specific song by ID
+const song = await MusicEngine.playSong('epic_001');
+
+// Find songs by criteria
+const songs = MusicEngine.findSongs({
+ mood: 'Epic',
+ minEnergy: 8,
+ maxEnergy: 10,
+ minTempo: 150,
+ maxTempo: 180,
+ tags: ['battle']
+});
+
+// Get full catalog with metadata
+const allSongs = MusicEngine.getCatalog();
+// Returns: [{ id, name, mood, energy, tempo, key, scale, tags, desc }, ...]
+
+// Volume control (0.0 to 1.0)
+MusicEngine.setVolume(0.7);
+
+// Fade out current song (duration in seconds)
+MusicEngine.fadeOut(2);
+
+// Stop music immediately
+MusicEngine.stopMusic();
+
+// Check current state
+const isPlaying = MusicEngine.isPlaying();
+const currentSong = MusicEngine.getCurrentSong();
+
+// Get all available moods
+const moods = MusicLibrary.getMoods();
+// Returns: ['Epic', 'Chill', 'Intense', 'Playful', 'Mysterious', 'Heroic', 'Quirky', 'Ambient']
+
+// Get songs for a specific mood
+const epicSongs = MusicLibrary.getSongsByMood('Epic');
+
+// Get a specific song definition
+const song = MusicLibrary.getSong('epic_001');
+```
+
+---
+
+## Technical Details
+
+### Synthesis
+
+- **Engine**: Tone.js (loaded from CDN)
+- **Duration**: 60+ seconds per song (loops seamlessly)
+- **Sample Rate**: 44.1 kHz (browser default)
+
+### Synth Types Used
+
+| Type | Tone.js Class | Typical Use |
+|------|---------------|-------------|
+| FM | FMSynth | Bright leads, stabs, bells |
+| AM | AMSynth | Warm pads, subtle leads |
+| Mono | MonoSynth | Bass, monophonic leads |
+| Poly | PolySynth | Pads, chords |
+| Membrane | MembraneSynth | Drums, percussive bass |
+| Metal | MetalSynth | Metallic percussion |
+| Pluck | PluckSynth | Plucked strings, mallets |
+| Duo | DuoSynth | Rich leads, doubled tones |
+| Noise | NoiseSynth | Effects, ambience |
+
+### Effects Chain
+
+| Effect | Parameter | Range | Purpose |
+|--------|-----------|-------|---------|
+| Reverb | reverb | 0.1-0.8 | Room size/tail |
+| Delay | delay | 0.05-0.5 | Echo amount |
+| Filter | filter | 500-8000 | Low-pass cutoff (Hz) |
+
+### Song Structure
+
+Each song defines:
+- **Sections**: intro, verse, chorus, bridge, break, outro
+- **Per Section**: bars, chord progression, melody style, bass style, drum pattern, intensity
+
+### Scales Available
+
+| Scale | Intervals | Typical Mood |
+|-------|-----------|--------------|
+| major | 0,2,4,5,7,9,11 | Happy, triumphant |
+| minor | 0,2,3,5,7,8,10 | Sad, dramatic |
+| dorian | 0,2,3,5,7,9,10 | Jazzy, mysterious |
+| phrygian | 0,1,3,5,7,8,10 | Dark, exotic |
+| lydian | 0,2,4,6,7,9,11 | Dreamy, bright |
+| mixolydian | 0,2,4,5,7,9,10 | Bluesy, groovy |
+| harmonicMinor | 0,2,3,5,7,8,11 | Dramatic, Eastern |
+| melodicMinor | 0,2,3,5,7,9,11 | Jazz, sophisticated |
+| pentatonicMajor | 0,2,4,7,9 | Simple, folk |
+| pentatonicMinor | 0,3,5,7,10 | Blues, rock |
+| blues | 0,3,5,6,7,10 | Blues, funky |
+| wholeTone | 0,2,4,6,8,10 | Dreamy, unsettling |
+| chromatic | all 12 | Atonal, chaotic |
+
+---
+
+## Statistics Summary
+
+| Mood | Songs | Energy Range | Tempo Range | Top Tags |
+|------|-------|--------------|-------------|----------|
+| Epic | 20 | 7-10 | 120-170 | battle, heroic, powerful |
+| Chill | 20 | 1-4 | 70-110 | peaceful, calm, soft |
+| Intense | 20 | 8-10 | 142-180 | aggressive, fast, urgent |
+| Playful | 20 | 4-7 | 110-150 | fun, bouncy, happy |
+| Mysterious | 20 | 2-5 | 80-120 | dark, enigma, eerie |
+| Heroic | 20 | 6-9 | 120-160 | triumph, courage, glory |
+| Quirky | 20 | 5-8 | 100-140 | funny, wacky, eccentric |
+| Ambient | 20 | 1-3 | 60-85 | floating, tranquil, vast |
+
+**Total: 160 songs**
diff --git a/frontend/public/docs/PRESET_GUIDE.md b/frontend/public/docs/PRESET_GUIDE.md
new file mode 100644
index 0000000..b44b97d
--- /dev/null
+++ b/frontend/public/docs/PRESET_GUIDE.md
@@ -0,0 +1,546 @@
+# Preset Guide
+
+## Overview
+
+220 curated asset combinations providing instant variety for 11 game styles.
+Each preset includes coordinated music, background, and avatar context.
+
+**Total Presets:** 220
+**Presets per Style:** 20
+**Game Styles:** 11
+
+---
+
+## Game Styles
+
+### Action/Arcade (20 presets)
+
+Fast-paced action games with intense gameplay and dynamic visuals.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| action-space-01 | Space Hero | Epic, 9 | space/nebula | spaceship-cockpit | Space shooters |
+| action-space-02 | Asteroid Runner | Intense, 8 | space/asteroidField | spaceship-cockpit | Dodging games |
+| action-platform-01 | Retro Runner | Playful, 7 | retro/pixelGrid | platformer-standard | Platformers |
+| action-platform-02 | Jungle Dash | Heroic, 8 | nature/jungle | adventure-hero | Jungle runners |
+| action-tank-01 | Tank Commander | Heroic, 9 | urban/citySkyline | tank | Military games |
+| action-tank-02 | Desert Storm | Epic, 8 | nature/desert | tank | Desert warfare |
+| action-mech-01 | Mech Warrior | Epic, 10 | mechanical/robotFactory | mech-suit | Robot battles |
+| action-mech-02 | Titan Fall | Intense, 9 | space/planets | mech-suit | Sci-fi combat |
+| action-jetpack-01 | Jetpack Joyride | Playful, 7 | sky/clouds | jetpack | Flying games |
+| action-jetpack-02 | Volcano Escape | Intense, 9 | nature/mountains | jetpack | Escape games |
+| action-ninja-01 | Shadow Strike | Mysterious, 7 | fantasy/temple | ninja-outfit | Stealth action |
+| action-ninja-02 | Rooftop Runner | Intense, 8 | urban/rooftop | ninja-outfit | Parkour games |
+| action-shooter-01 | Alien Blaster | Epic, 9 | space/deepSpace | spaceship-cockpit | Shoot-em-ups |
+| action-shooter-02 | Robot Rampage | Intense, 8 | mechanical/factory | superhero-cape | Wave shooters |
+| action-flying-01 | Sky Ace | Heroic, 8 | sky/flyingHigh | airplane | Dogfight games |
+| action-flying-02 | Storm Chaser | Intense, 9 | weather/storm | airplane | Weather dodging |
+| action-underwater-01 | Deep Sea Diver | Mysterious, 6 | underwater/deepSea | submarine | Ocean exploration |
+| action-underwater-02 | Submarine Command | Heroic, 7 | underwater/submarine | submarine | Naval combat |
+| action-superhero-01 | Cape Crusader | Heroic, 9 | urban/citySkyline | superhero-cape | Superhero games |
+| action-arcade-classic | Arcade Classic | Playful, 8 | retro/arcadeCabinet | platformer-standard | Retro arcade |
+
+---
+
+### Puzzle (20 presets)
+
+Brain-teasing puzzle games with calm, focused atmospheres.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| puzzle-zen-01 | Zen Garden | Chill, 2 | nature/meadow | platformer-standard | Matching games |
+| puzzle-zen-02 | Bamboo Sanctuary | Ambient, 2 | nature/forest | platformer-standard | Tile puzzles |
+| puzzle-abstract-01 | Geometric Dreams | Ambient, 3 | abstract/geometricPatterns | platformer-standard | Pattern games |
+| puzzle-abstract-02 | Color Flow | Chill, 3 | abstract/gradients | platformer-standard | Color matching |
+| puzzle-lab-01 | Science Lab | Quirky, 4 | indoor/laboratory | scientist-labcoat | Chemistry puzzles |
+| puzzle-lab-02 | Brain Teasers | Quirky, 4 | indoor/library | scientist-labcoat | Logic puzzles |
+| puzzle-colorful-01 | Candy Crush | Playful, 5 | colorful/candyWorld | platformer-standard | Match-3 games |
+| puzzle-colorful-02 | Rainbow Bridge | Playful, 4 | colorful/rainbow | platformer-standard | Color puzzles |
+| puzzle-space-01 | Cosmic Puzzle | Ambient, 3 | space/nebula | space-suit | Space puzzles |
+| puzzle-space-02 | Starlight Logic | Mysterious, 3 | space/deepSpace | space-suit | Constellation puzzles |
+| puzzle-library-01 | Ancient Library | Mysterious, 4 | fantasy/magicalLibrary | wizard-robes | Word puzzles |
+| puzzle-library-02 | Scholar's Study | Chill, 3 | indoor/library | wizard-robes | Book puzzles |
+| puzzle-ocean-01 | Ocean Depths | Ambient, 2 | underwater/coralReef | platformer-standard | Marine puzzles |
+| puzzle-ocean-02 | Bubble Pop | Playful, 4 | underwater/kelpForest | platformer-standard | Bubble games |
+| puzzle-crystal-01 | Crystal Cave | Mysterious, 4 | fantasy/crystalCave | platformer-standard | Gem puzzles |
+| puzzle-crystal-02 | Jewel Quest | Playful, 5 | fantasy/crystalCave | adventure-hero | Gem matching |
+| puzzle-cloud-01 | Cloud Kingdom | Ambient, 2 | sky/clouds | platformer-standard | Sky puzzles |
+| puzzle-cloud-02 | Dream Weaver | Chill, 2 | sky/horizon | platformer-standard | Dream puzzles |
+| puzzle-minimalist-01 | Minimal Mind | Ambient, 2 | abstract/minimalist | platformer-standard | Minimal puzzles |
+| puzzle-garden-01 | Garden Puzzle | Chill, 3 | seasonal/springMeadow | platformer-standard | Nature puzzles |
+
+---
+
+### Story Adventure (20 presets)
+
+Narrative-driven adventures with immersive world-building.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| story-fantasy-01 | Dragon Quest | Heroic, 8 | fantasy/castle | knight-armor | Fantasy RPGs |
+| story-fantasy-02 | Enchanted Forest | Mysterious, 6 | fantasy/enchantedForest | adventure-hero | Fairy tales |
+| story-wizard-01 | Wizard Academy | Quirky, 6 | fantasy/magicalLibrary | wizard-robes | Magic schools |
+| story-wizard-02 | Spell Caster | Epic, 7 | fantasy/tower | wizard-robes | Magic adventures |
+| story-pirate-01 | Pirate's Treasure | Heroic, 7 | nature/ocean | pirate-outfit | Pirate adventures |
+| story-pirate-02 | Shipwreck Island | Mysterious, 6 | underwater/shipwreck | pirate-outfit | Island mysteries |
+| story-nature-01 | Forest Journey | Chill, 5 | nature/forest | adventure-hero | Nature quests |
+| story-nature-02 | Mountain Expedition | Heroic, 6 | nature/mountains | adventure-hero | Climbing adventures |
+| story-urban-01 | City Detective | Mysterious, 5 | urban/street | platformer-standard | Mystery solving |
+| story-urban-02 | Night City | Mysterious, 6 | urban/neonDistrict | ninja-outfit | Noir adventures |
+| story-spooky-01 | Haunted Manor | Mysterious, 5 | spooky/hauntedHouse | platformer-standard | Ghost stories |
+| story-spooky-02 | Graveyard Shift | Mysterious, 6 | spooky/graveyard | ninja-outfit | Horror adventures |
+| story-medieval-01 | Knight's Honor | Heroic, 7 | fantasy/castle | knight-armor | Medieval quests |
+| story-medieval-02 | Village Hero | Playful, 6 | fantasy/castle | adventure-hero | Village adventures |
+| story-scifi-01 | Space Explorer | Ambient, 5 | space/spaceStation | space-suit | Sci-fi exploration |
+| story-scifi-02 | Alien Contact | Mysterious, 6 | space/planets | space-suit | First contact stories |
+| story-fairy-01 | Fairy Tale | Playful, 4 | fantasy/enchantedForest | wizard-robes | Classic fairy tales |
+| story-fairy-02 | Magic Kingdom | Heroic, 5 | fantasy/floatingIslands | adventure-hero | Fantasy kingdoms |
+| story-desert-01 | Desert Wanderer | Ambient, 5 | nature/desert | cowboy-gear | Desert adventures |
+| story-desert-02 | Oasis Quest | Heroic, 6 | nature/desert | adventure-hero | Desert exploration |
+
+---
+
+### Racing (20 presets)
+
+High-speed racing games with adrenaline-pumping action.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| racing-street-01 | Street Racer | Intense, 9 | urban/street | race-car | Street racing |
+| racing-street-02 | Midnight Run | Intense, 8 | urban/neonDistrict | race-car | Night racing |
+| racing-track-01 | Grand Prix | Epic, 9 | sports/raceTrack | race-car | Track racing |
+| racing-track-02 | Speed Champion | Heroic, 9 | sports/stadium | race-car | Championship races |
+| racing-motorcycle-01 | Moto Mayhem | Intense, 9 | urban/street | motorcycle | Motorcycle racing |
+| racing-motorcycle-02 | Highway Rider | Heroic, 8 | nature/mountains | motorcycle | Highway racing |
+| racing-hover-01 | Hover Rush | Epic, 9 | abstract/waves | hoverboard | Futuristic racing |
+| racing-hover-02 | Neon Glide | Intense, 8 | retro/synthwave | hoverboard | Cyberpunk racing |
+| racing-runner-01 | Endless Runner | Playful, 7 | urban/street | runner | Endless runners |
+| racing-runner-02 | Temple Sprint | Heroic, 8 | fantasy/temple | runner | Temple runs |
+| racing-offroad-01 | Offroad Rally | Intense, 8 | nature/mountains | race-car | Rally racing |
+| racing-offroad-02 | Mud Madness | Heroic, 7 | nature/forest | race-car | Offroad racing |
+| racing-boat-01 | Wave Rider | Heroic, 8 | nature/ocean | platformer-standard | Boat racing |
+| racing-boat-02 | River Rush | Intense, 7 | nature/waterfall | platformer-standard | River racing |
+| racing-sky-01 | Sky Race | Epic, 9 | sky/flyingHigh | airplane | Air racing |
+| racing-sky-02 | Cloud Dash | Playful, 7 | sky/clouds | flappy-style | Sky dodging |
+| racing-kart-01 | Kart Kingdom | Playful, 7 | colorful/rainbow | race-car | Kart racing |
+| racing-kart-02 | Turbo Toons | Playful, 8 | colorful/candyWorld | race-car | Cartoon racing |
+| racing-winter-01 | Snow Drift | Heroic, 7 | seasonal/winterWonderland | race-car | Winter racing |
+| racing-winter-02 | Ice Racer | Intense, 8 | weather/snow | race-car | Ice track racing |
+
+---
+
+### Pet/Simulator (20 presets)
+
+Relaxing pet care and life simulation games.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| pet-home-01 | Cozy Home | Chill, 3 | indoor/bedroom | platformer-standard | Virtual pets |
+| pet-home-02 | Pet Palace | Playful, 4 | indoor/bedroom | platformer-standard | Pet mansions |
+| pet-garden-01 | Garden Friends | Chill, 3 | nature/meadow | platformer-standard | Outdoor pets |
+| pet-garden-02 | Butterfly Garden | Ambient, 2 | seasonal/springMeadow | platformer-standard | Insect pets |
+| pet-farm-01 | Farm Life | Playful, 4 | nature/meadow | platformer-standard | Farm animals |
+| pet-farm-02 | Barn Buddies | Playful, 5 | nature/forest | platformer-standard | Farm simulation |
+| pet-aquarium-01 | Aqua World | Ambient, 2 | underwater/coralReef | platformer-standard | Fish tanks |
+| pet-aquarium-02 | Deep Sea Friends | Chill, 3 | underwater/kelpForest | platformer-standard | Ocean pets |
+| pet-park-01 | Pet Park | Playful, 4 | urban/park | platformer-standard | Dog walking |
+| pet-park-02 | Playground Pals | Playful, 5 | urban/park | platformer-standard | Pet playgrounds |
+| pet-cafe-01 | Pet Cafe | Chill, 3 | indoor/kitchen | chef-outfit | Cat cafes |
+| pet-cafe-02 | Treat Shop | Playful, 4 | colorful/candyWorld | chef-outfit | Pet treats |
+| pet-exotic-01 | Exotic Sanctuary | Mysterious, 4 | nature/jungle | adventure-hero | Exotic pets |
+| pet-exotic-02 | Rainforest Refuge | Ambient, 3 | nature/jungle | adventure-hero | Jungle animals |
+| pet-wildlife-01 | Wildlife Rescue | Heroic, 5 | nature/forest | adventure-hero | Animal rescue |
+| pet-wildlife-02 | Safari Friends | Playful, 4 | nature/desert | adventure-hero | Safari animals |
+| pet-virtual-01 | Cyber Pet | Quirky, 4 | retro/pixelGrid | platformer-standard | Digital pets |
+| pet-virtual-02 | Pixel Pals | Playful, 5 | retro/eightBit | platformer-standard | Retro virtual pets |
+| pet-clinic-01 | Vet Clinic | Chill, 4 | indoor/laboratory | scientist-labcoat | Vet games |
+| pet-grooming-01 | Grooming Salon | Playful, 3 | colorful/paintSplatter | platformer-standard | Pet grooming |
+
+---
+
+### Trivia/Quiz (20 presets)
+
+Educational quiz games with engaging visual themes.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| trivia-gameshow-01 | Game Show Star | Playful, 6 | colorful/disco | platformer-standard | TV quiz shows |
+| trivia-gameshow-02 | Quiz Champion | Epic, 7 | colorful/neon | platformer-standard | Championship quizzes |
+| trivia-classroom-01 | Classroom Quiz | Quirky, 4 | indoor/classroom | scientist-labcoat | School quizzes |
+| trivia-classroom-02 | Study Session | Chill, 3 | indoor/library | wizard-robes | Study games |
+| trivia-space-01 | Space Academy | Mysterious, 5 | space/nebula | space-suit | Astronomy quizzes |
+| trivia-space-02 | Cosmic Quiz | Ambient, 4 | space/deepSpace | space-suit | Space trivia |
+| trivia-history-01 | Time Traveler | Heroic, 5 | fantasy/castle | knight-armor | History quizzes |
+| trivia-history-02 | Ancient Wisdom | Mysterious, 4 | fantasy/temple | wizard-robes | Ancient history |
+| trivia-nature-01 | Nature Explorer | Chill, 4 | nature/forest | adventure-hero | Nature quizzes |
+| trivia-nature-02 | Wildlife Quiz | Playful, 4 | nature/jungle | adventure-hero | Animal trivia |
+| trivia-pop-01 | Pop Culture | Playful, 6 | colorful/neon | platformer-standard | Pop culture quizzes |
+| trivia-pop-02 | Music Mania | Playful, 7 | colorful/disco | platformer-standard | Music trivia |
+| trivia-sports-01 | Sports Fanatic | Heroic, 6 | sports/stadium | sports-player | Sports quizzes |
+| trivia-sports-02 | Champion Trivia | Epic, 7 | sports/arena | sports-player | Sports history |
+| trivia-geography-01 | World Explorer | Heroic, 5 | sky/flyingHigh | adventure-hero | Geography quizzes |
+| trivia-geography-02 | Map Master | Chill, 4 | sky/horizon | adventure-hero | Map quizzes |
+| trivia-food-01 | Foodie Quiz | Playful, 5 | indoor/kitchen | chef-outfit | Food trivia |
+| trivia-food-02 | Chef Challenge | Quirky, 6 | colorful/candyWorld | chef-outfit | Cooking quizzes |
+| trivia-math-01 | Math Genius | Quirky, 5 | abstract/geometricPatterns | scientist-labcoat | Math quizzes |
+| trivia-general-01 | Know It All | Playful, 5 | colorful/rainbow | platformer-standard | General knowledge |
+
+---
+
+### Music/Rhythm (20 presets)
+
+Beat-matching and rhythm games with vibrant audio-visual experiences.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| rhythm-disco-01 | Disco Fever | Playful, 8 | colorful/disco | platformer-standard | Disco rhythm |
+| rhythm-disco-02 | Dance Floor | Intense, 8 | colorful/neon | platformer-standard | Dancing games |
+| rhythm-retro-01 | Arcade Beats | Playful, 7 | retro/arcadeCabinet | platformer-standard | Retro rhythm |
+| rhythm-retro-02 | Pixel Grooves | Quirky, 6 | retro/pixelGrid | platformer-standard | 8-bit music |
+| rhythm-concert-01 | Rock Concert | Epic, 9 | colorful/neon | superhero-cape | Rock rhythm |
+| rhythm-concert-02 | Stage Star | Heroic, 8 | colorful/disco | platformer-standard | Concert games |
+| rhythm-space-01 | Cosmic Beats | Ambient, 6 | space/nebula | space-suit | Space rhythm |
+| rhythm-space-02 | Stellar Symphony | Mysterious, 5 | space/deepSpace | space-suit | Ambient music |
+| rhythm-street-01 | Street Beat | Intense, 8 | urban/street | ninja-outfit | Hip-hop rhythm |
+| rhythm-street-02 | Urban Flow | Heroic, 7 | urban/neonDistrict | platformer-standard | Street dancing |
+| rhythm-tropical-01 | Island Vibes | Playful, 6 | seasonal/tropical | platformer-standard | Tropical rhythm |
+| rhythm-tropical-02 | Beach Party | Playful, 7 | seasonal/summerBeach | platformer-standard | Beach music |
+| rhythm-abstract-01 | Visual Beats | Ambient, 5 | abstract/waves | platformer-standard | Abstract rhythm |
+| rhythm-abstract-02 | Synth Wave | Quirky, 6 | abstract/kaleidoscope | platformer-standard | Synth music |
+| rhythm-classical-01 | Symphony Hall | Chill, 4 | indoor/library | wizard-robes | Classical music |
+| rhythm-classical-02 | Piano Master | Ambient, 3 | indoor/arcade | platformer-standard | Piano rhythm |
+| rhythm-festival-01 | Festival Fever | Epic, 9 | colorful/tieDye | platformer-standard | Festival music |
+| rhythm-festival-02 | Rave Night | Intense, 10 | colorful/neon | ninja-outfit | Rave games |
+| rhythm-jazz-01 | Jazz Lounge | Chill, 4 | urban/alley | platformer-standard | Jazz rhythm |
+| rhythm-drums-01 | Drum Circle | Heroic, 7 | nature/jungle | adventure-hero | Drum games |
+
+---
+
+### Creative/Drawing (20 presets)
+
+Art and creativity games encouraging self-expression.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| creative-studio-01 | Art Studio | Chill, 3 | indoor/classroom | platformer-standard | Drawing games |
+| creative-studio-02 | Paint Paradise | Playful, 4 | colorful/paintSplatter | platformer-standard | Painting games |
+| creative-nature-01 | Nature Sketch | Ambient, 3 | nature/meadow | adventure-hero | Nature drawing |
+| creative-nature-02 | Forest Canvas | Chill, 2 | nature/forest | adventure-hero | Landscape art |
+| creative-colorful-01 | Color Splash | Playful, 5 | colorful/rainbow | platformer-standard | Coloring games |
+| creative-colorful-02 | Neon Art | Quirky, 6 | colorful/neon | platformer-standard | Glow art |
+| creative-abstract-01 | Abstract Express | Ambient, 4 | abstract/gradients | platformer-standard | Abstract art |
+| creative-abstract-02 | Shape Maker | Chill, 3 | abstract/geometricPatterns | platformer-standard | Geometric art |
+| creative-kids-01 | Crayon World | Playful, 5 | colorful/candyWorld | platformer-standard | Kids drawing |
+| creative-kids-02 | Doodle Town | Playful, 6 | colorful/paintSplatter | platformer-standard | Doodling games |
+| creative-fantasy-01 | Dragon Painter | Heroic, 5 | fantasy/enchantedForest | wizard-robes | Fantasy art |
+| creative-fantasy-02 | Magic Canvas | Mysterious, 4 | fantasy/magicalLibrary | wizard-robes | Magical drawing |
+| creative-space-01 | Galaxy Art | Ambient, 3 | space/nebula | space-suit | Space art |
+| creative-space-02 | Cosmic Creator | Mysterious, 4 | space/deepSpace | space-suit | Universe creation |
+| creative-beach-01 | Beach Doodles | Chill, 3 | seasonal/summerBeach | platformer-standard | Beach art |
+| creative-beach-02 | Sand Art | Ambient, 2 | nature/desert | platformer-standard | Sand drawing |
+| creative-city-01 | City Sketch | Quirky, 4 | urban/citySkyline | platformer-standard | Urban art |
+| creative-city-02 | Street Art | Playful, 5 | urban/alley | ninja-outfit | Graffiti games |
+| creative-pixel-01 | Pixel Art | Quirky, 5 | retro/pixelGrid | platformer-standard | Pixel drawing |
+| creative-food-01 | Food Art | Playful, 4 | indoor/kitchen | chef-outfit | Food decoration |
+
+---
+
+### RPG/Battle (20 presets)
+
+Role-playing and combat games with epic adventures.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| rpg-fantasy-01 | Kingdom Quest | Epic, 8 | fantasy/castle | knight-armor | Fantasy RPGs |
+| rpg-fantasy-02 | Hero's Journey | Heroic, 7 | fantasy/enchantedForest | adventure-hero | Adventure RPGs |
+| rpg-wizard-01 | Arcane Academy | Mysterious, 6 | fantasy/magicalLibrary | wizard-robes | Magic RPGs |
+| rpg-wizard-02 | Sorcerer Supreme | Epic, 8 | fantasy/tower | wizard-robes | Wizard battles |
+| rpg-ninja-01 | Ninja Chronicles | Intense, 7 | fantasy/temple | ninja-outfit | Ninja RPGs |
+| rpg-ninja-02 | Shadow Warrior | Mysterious, 8 | spooky/darkForest | ninja-outfit | Stealth RPGs |
+| rpg-dragon-01 | Dragon Rider | Epic, 9 | sky/flyingHigh | dragon-rider | Dragon games |
+| rpg-dragon-02 | Dragon Slayer | Heroic, 9 | fantasy/floatingIslands | knight-armor | Dragon battles |
+| rpg-dungeon-01 | Dungeon Crawler | Mysterious, 7 | fantasy/dungeon | knight-armor | Dungeon games |
+| rpg-dungeon-02 | Crypt Explorer | Mysterious, 6 | spooky/crypt | adventure-hero | Crypt exploration |
+| rpg-spooky-01 | Ghost Hunter | Mysterious, 6 | spooky/hauntedHouse | adventure-hero | Ghost hunting |
+| rpg-spooky-02 | Vampire Slayer | Intense, 8 | spooky/manor | knight-armor | Monster hunting |
+| rpg-nature-01 | Forest Guardian | Heroic, 6 | nature/forest | adventure-hero | Nature RPGs |
+| rpg-nature-02 | Wilderness Hero | Playful, 5 | nature/meadow | adventure-hero | Exploration RPGs |
+| rpg-arena-01 | Battle Arena | Epic, 9 | sports/arena | knight-armor | Arena battles |
+| rpg-arena-02 | Gladiator Glory | Intense, 9 | sports/stadium | superhero-cape | Gladiator games |
+| rpg-ice-01 | Frost Warrior | Heroic, 7 | seasonal/winterWonderland | knight-armor | Ice RPGs |
+| rpg-ice-02 | Blizzard Quest | Intense, 8 | weather/snow | adventure-hero | Winter quests |
+| rpg-volcano-01 | Lava Lord | Epic, 9 | nature/mountains | knight-armor | Volcano adventures |
+| rpg-boss-01 | Boss Battle | Epic, 10 | fantasy/dungeon | superhero-cape | Boss fights |
+
+---
+
+### Sports (20 presets)
+
+Athletic competition games across various sports.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| sports-soccer-01 | Soccer Star | Heroic, 8 | sports/field | sports-player | Soccer games |
+| sports-soccer-02 | World Cup | Epic, 9 | sports/stadium | sports-player | Soccer tournaments |
+| sports-basketball-01 | Court Kings | Heroic, 8 | sports/court | sports-player | Basketball games |
+| sports-basketball-02 | Slam Dunk | Epic, 9 | sports/arena | sports-player | Basketball action |
+| sports-baseball-01 | Home Run Hero | Heroic, 7 | sports/field | sports-player | Baseball games |
+| sports-baseball-02 | World Series | Epic, 8 | sports/stadium | sports-player | Baseball tournaments |
+| sports-tennis-01 | Tennis Ace | Playful, 6 | sports/court | sports-player | Tennis games |
+| sports-tennis-02 | Grand Slam | Heroic, 7 | sports/stadium | sports-player | Tennis tournaments |
+| sports-golf-01 | Golf Master | Chill, 4 | nature/meadow | sports-player | Golf games |
+| sports-golf-02 | Championship Green | Ambient, 5 | sports/field | sports-player | Golf tournaments |
+| sports-swimming-01 | Pool Champion | Heroic, 6 | sports/pool | sports-player | Swimming races |
+| sports-swimming-02 | Dive Star | Playful, 5 | underwater/coralReef | sports-player | Diving games |
+| sports-football-01 | Touchdown Hero | Epic, 8 | sports/field | sports-player | Football games |
+| sports-football-02 | Super Bowl | Epic, 9 | sports/stadium | sports-player | Football tournaments |
+| sports-hockey-01 | Ice Champion | Intense, 8 | sports/arena | sports-player | Hockey games |
+| sports-hockey-02 | Puck Master | Heroic, 7 | seasonal/winterWonderland | sports-player | Ice hockey |
+| sports-boxing-01 | Ring Champion | Intense, 9 | sports/arena | superhero-cape | Boxing games |
+| sports-boxing-02 | Knockout King | Epic, 9 | sports/gym | superhero-cape | Fighting games |
+| sports-extreme-01 | Extreme Games | Intense, 8 | nature/mountains | adventure-hero | Extreme sports |
+| sports-extreme-02 | X-Games Hero | Heroic, 9 | sports/skatepark | adventure-hero | Skateboarding |
+
+---
+
+### Physics/Pinball (20 presets)
+
+Physics-based puzzle games and pinball simulations.
+
+| ID | Name | Music | Background | Context | Best For |
+|----|------|-------|------------|---------|----------|
+| physics-pinball-01 | Classic Pinball | Playful, 7 | retro/arcadeCabinet | platformer-standard | Classic pinball |
+| physics-pinball-02 | Neon Pinball | Intense, 8 | colorful/neon | platformer-standard | Modern pinball |
+| physics-retro-01 | Retro Physics | Quirky, 6 | retro/pixelGrid | platformer-standard | Retro physics |
+| physics-retro-02 | Arcade Physics | Playful, 7 | retro/arcadeCabinet | platformer-standard | Arcade physics |
+| physics-mechanical-01 | Gear Works | Quirky, 5 | mechanical/gears | scientist-labcoat | Gear puzzles |
+| physics-mechanical-02 | Factory Fun | Quirky, 6 | mechanical/factory | scientist-labcoat | Factory games |
+| physics-colorful-01 | Bouncy World | Playful, 6 | colorful/rainbow | platformer-standard | Bouncing games |
+| physics-colorful-02 | Candy Physics | Playful, 5 | colorful/candyWorld | platformer-standard | Candy physics |
+| physics-gravity-01 | Zero Gravity | Ambient, 4 | space/deepSpace | space-suit | Gravity puzzles |
+| physics-gravity-02 | Orbital Mechanics | Mysterious, 5 | space/planets | space-suit | Space physics |
+| physics-destruction-01 | Demolition Derby | Intense, 8 | urban/citySkyline | superhero-cape | Destruction games |
+| physics-destruction-02 | Crash Test | Heroic, 7 | mechanical/factory | platformer-standard | Crash physics |
+| physics-water-01 | Water Works | Chill, 4 | underwater/coralReef | platformer-standard | Water physics |
+| physics-water-02 | Fluid Dynamics | Ambient, 3 | abstract/waves | scientist-labcoat | Fluid games |
+| physics-balance-01 | Stack Master | Quirky, 5 | abstract/minimalist | platformer-standard | Stacking games |
+| physics-balance-02 | Tower Builder | Playful, 6 | urban/citySkyline | platformer-standard | Building games |
+| physics-launch-01 | Launcher Pro | Heroic, 7 | sky/clouds | platformer-standard | Launch games |
+| physics-launch-02 | Catapult King | Epic, 8 | fantasy/castle | knight-armor | Siege games |
+| physics-pendulum-01 | Pendulum Puzzle | Chill, 4 | abstract/geometricPatterns | scientist-labcoat | Pendulum games |
+| physics-rube-01 | Rube Goldberg | Quirky, 6 | mechanical/conveyorBelts | scientist-labcoat | Chain reaction |
+
+---
+
+## Using Presets
+
+### Get Preset by ID
+
+```javascript
+const preset = AssetPresets.getById('action-space-01');
+// Returns: { id, name, music, background, avatarContext, ... }
+```
+
+### Get All for Game Style
+
+```javascript
+const actionPresets = AssetPresets.getByGameStyle('action-arcade');
+// Returns array of 20 presets
+```
+
+### Get Random Preset
+
+```javascript
+const random = AssetPresets.getRandom('racing');
+// Returns single random preset from racing category
+```
+
+### Search Presets
+
+```javascript
+const matches = AssetPresets.search('dragon');
+// Returns all presets with "dragon" in name, description, or tags
+```
+
+### Load Preset
+
+```javascript
+const preset = AssetPresets.getById('action-space-01');
+const config = {
+ music: preset.music,
+ background: preset.background,
+ avatarContext: preset.avatarContext
+};
+await AssetManager.loadGameAssets(config);
+```
+
+---
+
+## Customizing Presets
+
+### Override Music
+
+Keep the visual theme but change the music mood:
+
+```javascript
+const preset = AssetPresets.getById('action-space-01');
+const custom = {
+ ...preset,
+ music: { mood: 'Mysterious', energy: 5 } // Different mood
+};
+```
+
+### Keep Music, Change Background
+
+Swap the background while maintaining audio coherence:
+
+```javascript
+const preset = AssetPresets.getById('puzzle-zen-01');
+const custom = {
+ music: preset.music,
+ background: { theme: 'underwater', variant: 'coralReef' },
+ avatarContext: preset.avatarContext
+};
+```
+
+### Change Avatar Context Only
+
+Different character presentation with same environment:
+
+```javascript
+const preset = AssetPresets.getById('racing-street-01');
+const custom = {
+ ...preset,
+ avatarContext: { mode: 'FULL_BODY', context: 'motorcycle' }
+};
+```
+
+---
+
+## Preset Variety Strategy
+
+For maximum variety in generated games:
+
+### 1. Track Recently Used Presets
+
+```javascript
+const usedPresets = new Set();
+
+function getUnusedPreset(gameStyle) {
+ const presets = AssetPresets.getByGameStyle(gameStyle);
+ const unused = presets.filter(p => !usedPresets.has(p.id));
+
+ if (unused.length === 0) {
+ usedPresets.clear(); // Reset when all used
+ return presets[0];
+ }
+
+ const selected = unused[Math.floor(Math.random() * unused.length)];
+ usedPresets.add(selected.id);
+ return selected;
+}
+```
+
+### 2. Cycle Through All 20 Presets
+
+```javascript
+const presetIndex = {};
+
+function getNextPreset(gameStyle) {
+ if (!presetIndex[gameStyle]) presetIndex[gameStyle] = 0;
+
+ const presets = AssetPresets.getByGameStyle(gameStyle);
+ const preset = presets[presetIndex[gameStyle]];
+
+ presetIndex[gameStyle] = (presetIndex[gameStyle] + 1) % presets.length;
+ return preset;
+}
+```
+
+### 3. Use Variation Functions
+
+```javascript
+// Get variation of a preset
+const variation = AssetManager.getVariation(preset, 'background');
+// Same music and context, different background variant
+```
+
+---
+
+## Validation
+
+All presets are pre-validated for compatibility. To check custom modifications:
+
+```javascript
+const result = AssetCompatibility.validate(customConfig);
+
+if (result.score < 70) {
+ console.warn('Low compatibility score:', result.score);
+ console.warn('Issues:', result.issues);
+}
+
+// result.score: 0-100 compatibility score
+// result.issues: array of compatibility warnings
+```
+
+### Compatibility Criteria
+
+| Score Range | Quality |
+|-------------|---------|
+| 90-100 | Excellent - perfect harmony |
+| 70-89 | Good - works well |
+| 50-69 | Fair - noticeable mismatch |
+| 0-49 | Poor - significant issues |
+
+---
+
+## Quick Reference
+
+### Music Moods
+
+| Mood | Energy Range | Best For |
+|------|--------------|----------|
+| Epic | 7-10 | Boss fights, climactic moments |
+| Heroic | 6-9 | Adventures, triumphs |
+| Intense | 8-10 | Chases, action sequences |
+| Playful | 4-7 | Fun, lighthearted games |
+| Mysterious | 2-5 | Puzzles, suspense |
+| Ambient | 1-3 | Calm, atmospheric |
+| Chill | 1-4 | Relaxation, menus |
+| Quirky | 4-8 | Comedy, eccentric |
+
+### Background Themes
+
+15 themes with 8 variants each (120 total):
+
+- **space**: nebula, asteroidField, deepSpace, planets, spaceStation, warpSpeed, satellites, blackHole
+- **nature**: forest, jungle, desert, mountains, ocean, underwater, waterfall, meadow
+- **urban**: citySkyline, street, rooftop, neonDistrict, subway, park, mall, alley
+- **fantasy**: castle, dungeon, enchantedForest, floatingIslands, magicalLibrary, crystalCave, temple, tower
+- **abstract**: geometricPatterns, gradients, particleFields, minimalist, waves, fractals, kaleidoscope, matrix
+- **retro**: pixelGrid, arcadeCabinet, crtEffects, vaporwave, scanlines, glitch, eightBit, synthwave
+- **indoor**: laboratory, classroom, bedroom, kitchen, arcade, office, library, gym
+- **weather**: rain, snow, storm, sunset, sunrise, fog, lightning, aurora
+- **sports**: stadium, raceTrack, court, field, arena, pool, gym, skatepark
+- **seasonal**: springMeadow, autumnLeaves, winterWonderland, summerBeach, harvest, blossom, snowfall, tropical
+- **underwater**: coralReef, deepSea, kelpForest, submarine, shipwreck, abyss, iceCave, treasure
+- **sky**: clouds, flyingHigh, hotAirBalloon, airplaneView, stratosphere, birdsEye, horizon, starfield
+- **mechanical**: gears, circuits, factory, robotFactory, conveyorBelts, pipes, steampunk, techLab
+- **spooky**: hauntedHouse, graveyard, darkForest, abandonedBuilding, crypt, manor, fog, shadows
+- **colorful**: rainbow, paintSplatter, candyWorld, disco, neon, tieDye, gradient, prism
+
+### Avatar Contexts
+
+| Category | Contexts |
+|----------|----------|
+| **Vehicles** | spaceship-cockpit, race-car, airplane, flappy-style, tank, pacman-style, hoverboard, jetpack, mech-suit, submarine, dragon-rider, motorcycle |
+| **Costumes** | knight-armor, space-suit, ninja-outfit, wizard-robes, pirate-outfit, superhero-cape, adventure-hero, scientist-labcoat, chef-outfit, sports-player, cowboy-gear |
+| **Pure** | platformer-standard, runner |
+
+---
+
+## See Also
+
+- [Asset Library Overview](./ASSET_LIBRARY_OVERVIEW.md) - Complete asset system documentation
+- [Adding New Assets](./ADDING_NEW_ASSETS.md) - How to create new assets
+- [Game Generation Guide](./GAME_GENERATION_GUIDE.md) - How assets are used in generation
diff --git a/frontend/public/examples/comparison-demo.html b/frontend/public/examples/comparison-demo.html
new file mode 100644
index 0000000..8c53c38
--- /dev/null
+++ b/frontend/public/examples/comparison-demo.html
@@ -0,0 +1,675 @@
+
+
+
+
+
+ Asset Comparison Demo - Same Game, Different Assets
+
+
+
+
+
+
+
+
+
+
Global Controls
+
+ Start All Games
+ Pause All
+ Reset All
+
+
+
+
+
How to Play
+
+ Use Arrow Keys or WASD to move. Avoid the obstacles!
+ Each panel shows the same game logic with different visual assets.
+ Notice how the background, music mood, avatar context, and rendering mode
+ completely change the feel of the game while the core mechanics remain identical.
+
+
+
+
+
Configuration Code
+
+ Space
+ Nature
+ Spooky
+ Retro
+
+
const GAME_CONFIG = {
+ title: "Space Dodger",
+ assets: {
+ music: { mood: "Epic", energy: 8, enabled: true },
+ background: { theme: "space", variant: "nebula", animated: true },
+ avatarContext: {
+ mode: "HEAD_ONLY",
+ context: "spaceship-cockpit",
+ showAccessories: true
+ }
+ }
+};
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/examples/flappy-example.html b/frontend/public/examples/flappy-example.html
new file mode 100644
index 0000000..aa581f7
--- /dev/null
+++ b/frontend/public/examples/flappy-example.html
@@ -0,0 +1,533 @@
+
+
+
+
+
+ Sky Glide - Flappy Example
+
+
+
+
+
Sky Glide
+
+
Loading assets...
+
+
+
Tap or Press Space to Start
+
Avoid the pipes!
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/examples/platformer-example.html b/frontend/public/examples/platformer-example.html
new file mode 100644
index 0000000..5542da4
--- /dev/null
+++ b/frontend/public/examples/platformer-example.html
@@ -0,0 +1,533 @@
+
+
+
+
+
+ Forest Jumper - Platformer Example
+
+
+
+
+
Forest Jumper
+
+
Loading assets...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/examples/racer-example.html b/frontend/public/examples/racer-example.html
new file mode 100644
index 0000000..5095a95
--- /dev/null
+++ b/frontend/public/examples/racer-example.html
@@ -0,0 +1,702 @@
+
+
+
+
+
+ Night Racer - Racing Example
+
+
+
+
+
NIGHT RACER
+
+
Initializing...
+
+
+
+
SCORE: 0
+
DISTANCE: 0 m
+
+ 0 km/h
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png
new file mode 100644
index 0000000..f9a229f
--- /dev/null
+++ b/frontend/public/icon-192.png
@@ -0,0 +1 @@
+This is a placeholder - we'll create a proper icon
diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg
new file mode 100644
index 0000000..bb3a758
--- /dev/null
+++ b/frontend/public/icon.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ 🎮
+
diff --git a/frontend/public/push-handler.js b/frontend/public/push-handler.js
new file mode 100644
index 0000000..0aa489d
--- /dev/null
+++ b/frontend/public/push-handler.js
@@ -0,0 +1,56 @@
+// Push notification handler for GamerComp PWA
+// This file is loaded by the service worker via importScripts
+
+self.addEventListener('push', function(event) {
+ if (!event.data) return;
+
+ try {
+ const data = event.data.json();
+ const options = {
+ body: data.body || 'You have a new notification',
+ icon: '/icon.svg',
+ badge: '/icon.svg',
+ vibrate: [100, 50, 100],
+ data: {
+ url: data.url || '/'
+ },
+ actions: [
+ { action: 'open', title: 'Open' }
+ ]
+ };
+
+ event.waitUntil(
+ self.registration.showNotification(data.title || 'GamerComp', options)
+ );
+ } catch (err) {
+ // Fallback for non-JSON payloads
+ event.waitUntil(
+ self.registration.showNotification('GamerComp', {
+ body: event.data.text(),
+ icon: '/icon.svg'
+ })
+ );
+ }
+});
+
+self.addEventListener('notificationclick', function(event) {
+ event.notification.close();
+
+ const url = event.notification.data?.url || '/';
+
+ event.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
+ // Try to focus an existing window
+ for (const client of clientList) {
+ if (client.url.includes('gamercomp.com') && 'focus' in client) {
+ client.navigate(url);
+ return client.focus();
+ }
+ }
+ // Open new window if none exists
+ if (clients.openWindow) {
+ return clients.openWindow(url);
+ }
+ })
+ );
+});
diff --git a/frontend/public/templates/TEMPLATE_GUIDE.md b/frontend/public/templates/TEMPLATE_GUIDE.md
new file mode 100644
index 0000000..46e6192
--- /dev/null
+++ b/frontend/public/templates/TEMPLATE_GUIDE.md
@@ -0,0 +1,451 @@
+# Game Template Guide for Haiku
+
+This guide explains how to use the game templates to generate HTML5 canvas games for GamerComp.
+
+## Template Selection
+
+### `gameTemplateAssets.html` - Full Asset Library Template
+Use this template when the game needs:
+- Procedural background themes (120 options)
+- Procedural music (160 songs, 8 moods)
+- User's avatar with game contexts (26 contexts)
+- Accessories and effects
+
+**Best for:** RPGs, adventure games, racing games, space shooters, platformers, any game where the player's avatar matters.
+
+### `gameTemplateSimple.html` - Minimal Template
+Use this template when the game needs:
+- Simple graphics (shapes, basic sprites)
+- No avatar/user identity
+- Fast loading (no asset libraries)
+
+**Best for:** Puzzle games, simple arcade games, physics toys, creative tools, games where the player controls an object (not an avatar).
+
+---
+
+## Using gameTemplateAssets.html
+
+### Step 1: Fill in GAME_CONFIG
+
+Replace the placeholder comments with actual values:
+
+```javascript
+const GAME_CONFIG = {
+ title: "Space Defender", // Replace
+
+ assets: {
+ music: {
+ mood: "Epic", // Replace
+ energy: 8,
+ enabled: true
+ },
+
+ background: {
+ theme: "space", // Replace
+ variant: "nebula", // Replace
+ animated: true,
+ effects: ["stars"]
+ },
+
+ avatarContext: {
+ mode: "HEAD_ONLY", // Replace
+ context: "spaceship-cockpit", // Replace
+ showAccessories: true
+ }
+ },
+
+ gameplay: {
+ // Add your game-specific config
+ enemySpeed: 200,
+ spawnRate: 2000,
+ bulletSpeed: 500
+ }
+};
+```
+
+### Step 2: Choose Asset Combinations
+
+#### Music Moods (8 options)
+| Mood | Best For | Energy Range |
+|------|----------|--------------|
+| Epic | Boss battles, action games | 7-10 |
+| Chill | Puzzle games, casual games | 2-4 |
+| Intense | Racing, shooters, action | 8-10 |
+| Playful | Kids games, casual games | 5-7 |
+| Mysterious | Horror, mystery, exploration | 3-6 |
+| Heroic | Adventure, platformers | 6-8 |
+| Quirky | Puzzle, comedy, unique games | 4-7 |
+| Ambient | Background, meditation, creative | 1-3 |
+
+#### Background Themes (15 themes × 8 variants each)
+| Theme | Variants | Best For |
+|-------|----------|----------|
+| space | nebula, asteroidField, deepSpace, planets, spaceStation, warpSpeed, satellites, blackHole | Space games, sci-fi |
+| nature | forest, jungle, desert, mountains, ocean, underwater, waterfall, meadow | Adventure, relaxing |
+| urban | citySkyline, street, rooftop, neonDistrict, subway, park, mall, alley | Racing, action |
+| fantasy | castle, dungeon, enchantedForest, floatingIslands, magicalLibrary, crystalCave, temple, tower | RPG, adventure |
+| abstract | geometricPatterns, gradients, particleFields, minimalist, waves, fractals, kaleidoscope, matrix | Puzzle, creative |
+| retro | pixelGrid, arcadeCabinet, crtEffects, vaporwave, scanlines, glitch, eightBit, synthwave | Arcade, retro |
+| indoor | laboratory, classroom, bedroom, kitchen, arcade, office, library, gym | Simulation, casual |
+| weather | rain, snow, storm, sunset, sunrise, fog, lightning, aurora | Atmospheric |
+| sports | stadium, raceTrack, court, field, arena, pool, gym, skatepark | Sports games |
+| seasonal | springMeadow, autumnLeaves, winterWonderland, summerBeach, harvest, blossom, snowfall, tropical | Seasonal themes |
+| underwater | coralReef, deepSea, kelpForest, submarine, shipwreck, abyss, iceCave, treasure | Ocean games |
+| sky | clouds, flyingHigh, hotAirBalloon, airplaneView, stratosphere, birdsEye, horizon, starfield | Flying games |
+| mechanical | gears, circuits, factory, robotFactory, conveyorBelts, pipes, steampunk, techLab | Tech, puzzle |
+| spooky | hauntedHouse, graveyard, darkForest, abandonedBuilding, crypt, manor, fog, shadows | Horror, Halloween |
+| colorful | rainbow, paintSplatter, candyWorld, disco, neon, tieDye, gradient, prism | Kids, casual |
+
+#### Avatar Modes (4 options)
+| Mode | Dimensions | Use When |
+|------|------------|----------|
+| HEAD_ONLY | 64×64 | Cockpit view, flying games, maze games |
+| HEAD_AND_SHOULDERS | 64×96 | Driving games, submarine, conversation |
+| UPPER_BODY | 64×128 | Racing, shooting, hoverboard |
+| FULL_BODY | 64×160 | Platformers, RPGs, sports, adventure |
+
+#### Avatar Contexts (26 options)
+
+**Vehicle Contexts:**
+- `spaceship-cockpit` (HEAD_ONLY) - Space shooters
+- `race-car` (HEAD_AND_SHOULDERS) - Racing games
+- `airplane` (HEAD_AND_SHOULDERS) - Flying games
+- `flappy-style` (HEAD_ONLY) - Flappy bird clones
+- `tank` (HEAD_ONLY) - Tank games
+- `pacman-style` (HEAD_ONLY) - Maze games
+- `hoverboard` (UPPER_BODY) - Hoverboard games
+- `jetpack` (UPPER_BODY) - Jetpack games
+- `mech-suit` (HEAD_ONLY) - Mech combat
+- `submarine` (HEAD_AND_SHOULDERS) - Underwater
+- `dragon-rider` (FULL_BODY) - Dragon games
+- `motorcycle` (FULL_BODY) - Motorcycle racing
+
+**Costume Contexts:**
+- `knight-armor` (FULL_BODY) - Medieval games
+- `space-suit` (FULL_BODY) - Space exploration
+- `ninja-outfit` (FULL_BODY) - Ninja games
+- `wizard-robes` (FULL_BODY) - Magic games
+- `superhero-cape` (FULL_BODY) - Hero games
+- `athlete-uniform` (FULL_BODY) - Sports
+- `pirate-outfit` (FULL_BODY) - Pirate games
+- `scientist-labcoat` (FULL_BODY) - Science games
+- `chef-outfit` (FULL_BODY) - Cooking games
+- `cowboy-gear` (FULL_BODY) - Western games
+
+**Pure Contexts:**
+- `platformer-standard` (FULL_BODY) - Standard platformer
+- `sports-player` (FULL_BODY) - Sports games
+- `adventure-hero` (FULL_BODY) - Adventure games
+- `runner` (FULL_BODY) - Endless runners
+
+### Step 3: Implement Game Functions
+
+The template provides these functions for you to implement:
+
+```javascript
+// Called once at start and on restart
+function initGame() {
+ // Initialize enemies, items, level data
+ state.enemies = [];
+ state.items = [];
+}
+
+// Called every frame (dt = seconds since last frame)
+function updateGame(dt) {
+ // Update positions, check collisions, spawn objects
+ state.enemies.forEach(e => {
+ e.x += e.velX * dt;
+ e.y += e.velY * dt;
+ });
+
+ // Check player collision with enemies
+ state.enemies.forEach(e => {
+ if (circleCollision(
+ { x: state.playerX, y: state.playerY, r: 30 },
+ { x: e.x, y: e.y, r: e.radius }
+ )) {
+ updateHealth(-10);
+ }
+ });
+}
+
+// Called every frame after updateGame
+function renderGame() {
+ // Draw enemies, items, effects
+ state.enemies.forEach(e => {
+ ctx.fillStyle = e.color;
+ ctx.beginPath();
+ ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
+ ctx.fill();
+ });
+}
+
+// Input handlers
+function handleKeyDown(code) {
+ if (code === 'Space') {
+ shoot(); // Your function
+ }
+}
+
+function handleTouchStart(x, y) {
+ // Handle tap
+}
+```
+
+### Step 4: Use Provided Utilities
+
+The template includes these utility functions:
+
+```javascript
+// Score & Health
+updateScore(10); // Add 10 points
+updateHealth(-5); // Lose 5 health
+updateHealth(10); // Gain 10 health (capped at 100)
+
+// Animation
+setPlayerAnimation('walk'); // idle, walk, run, jump, hurt, celebrate, etc.
+
+// Math
+randomInt(1, 10); // Random integer 1-10
+randomFloat(0, 1); // Random float 0-1
+clamp(value, 0, 100); // Clamp to range
+lerp(0, 100, 0.5); // Linear interpolation = 50
+
+// Collision
+rectCollision(r1, r2); // {x, y, width, height} objects
+circleCollision(c1, c2); // {x, y, radius} objects
+
+// Game flow
+endGame(); // Trigger game over
+```
+
+---
+
+## Using gameTemplateSimple.html
+
+For simple games without avatars:
+
+```javascript
+const GAME_CONFIG = {
+ title: "Catch the Stars",
+
+ background: {
+ type: "gradient",
+ colors: ["#0f0c29", "#302b63", "#24243e"]
+ },
+
+ player: {
+ width: 50,
+ height: 50,
+ color: "#FFD700",
+ speed: 400
+ },
+
+ gameplay: {
+ starSpawnRate: 1000,
+ starFallSpeed: 200
+ }
+};
+```
+
+Then implement:
+- `initGame()` - Setup
+- `update(dt)` - Game logic
+- `render()` - Draw objects
+- Input handlers
+
+---
+
+## Common Patterns
+
+### Platformer Movement
+```javascript
+const GRAVITY = 1500;
+const JUMP_FORCE = 600;
+
+function update(dt) {
+ // Horizontal movement
+ if (keys['ArrowLeft']) state.playerVelX = -300;
+ else if (keys['ArrowRight']) state.playerVelX = 300;
+ else state.playerVelX *= 0.8; // Friction
+
+ // Apply gravity
+ state.playerVelY += GRAVITY * dt;
+
+ // Move player
+ state.playerX += state.playerVelX * dt;
+ state.playerY += state.playerVelY * dt;
+
+ // Ground collision
+ if (state.playerY > canvas.height - 50) {
+ state.playerY = canvas.height - 50;
+ state.playerVelY = 0;
+ state.isGrounded = true;
+ }
+}
+
+function handleKeyDown(code) {
+ if (code === 'Space' && state.isGrounded) {
+ state.playerVelY = -JUMP_FORCE;
+ state.isGrounded = false;
+ setPlayerAnimation('jump');
+ }
+}
+```
+
+### Space Shooter
+```javascript
+state.bullets = [];
+state.enemies = [];
+
+function update(dt) {
+ // Update bullets
+ state.bullets = state.bullets.filter(b => {
+ b.y -= 500 * dt;
+ return b.y > 0;
+ });
+
+ // Spawn enemies
+ if (Math.random() < 0.02) {
+ state.enemies.push({
+ x: randomInt(50, canvas.width - 50),
+ y: -30,
+ radius: 20,
+ color: '#ff4444'
+ });
+ }
+
+ // Update enemies
+ state.enemies.forEach(e => e.y += 150 * dt);
+ state.enemies = state.enemies.filter(e => e.y < canvas.height + 50);
+
+ // Collision: bullets vs enemies
+ state.bullets.forEach(b => {
+ state.enemies = state.enemies.filter(e => {
+ if (circleCollision(b, e)) {
+ updateScore(10);
+ return false;
+ }
+ return true;
+ });
+ });
+}
+
+function handleKeyDown(code) {
+ if (code === 'Space') {
+ state.bullets.push({
+ x: state.playerX,
+ y: state.playerY - 30,
+ radius: 5
+ });
+ }
+}
+```
+
+### Endless Runner
+```javascript
+let scrollSpeed = 300;
+let obstacles = [];
+let lastObstacle = 0;
+
+function update(dt) {
+ scrollSpeed += dt * 2; // Speed up over time
+
+ // Jump
+ if (keys['Space'] && state.isGrounded) {
+ state.playerVelY = -500;
+ state.isGrounded = false;
+ }
+
+ // Gravity
+ state.playerVelY += 1200 * dt;
+ state.playerY += state.playerVelY * dt;
+
+ // Ground
+ const ground = canvas.height - 100;
+ if (state.playerY > ground) {
+ state.playerY = ground;
+ state.isGrounded = true;
+ }
+
+ // Spawn obstacles
+ lastObstacle += dt * 1000;
+ if (lastObstacle > 1500) {
+ obstacles.push({ x: canvas.width, y: ground, w: 30, h: 50 });
+ lastObstacle = 0;
+ }
+
+ // Move obstacles
+ obstacles = obstacles.filter(o => {
+ o.x -= scrollSpeed * dt;
+ return o.x > -50;
+ });
+
+ // Score
+ updateScore(Math.floor(dt * 10));
+}
+```
+
+---
+
+## Recommended Combinations by Game Type
+
+### Space Shooter
+```javascript
+music: { mood: "Epic", energy: 8 }
+background: { theme: "space", variant: "nebula" }
+avatarContext: { mode: "HEAD_ONLY", context: "spaceship-cockpit" }
+```
+
+### Platformer
+```javascript
+music: { mood: "Playful", energy: 6 }
+background: { theme: "nature", variant: "forest" }
+avatarContext: { mode: "FULL_BODY", context: "platformer-standard" }
+```
+
+### Racing
+```javascript
+music: { mood: "Intense", energy: 9 }
+background: { theme: "urban", variant: "street" }
+avatarContext: { mode: "HEAD_AND_SHOULDERS", context: "race-car" }
+```
+
+### RPG/Adventure
+```javascript
+music: { mood: "Heroic", energy: 6 }
+background: { theme: "fantasy", variant: "castle" }
+avatarContext: { mode: "FULL_BODY", context: "knight-armor" }
+```
+
+### Puzzle
+```javascript
+music: { mood: "Chill", energy: 3 }
+background: { theme: "abstract", variant: "minimalist" }
+// Use gameTemplateSimple.html instead
+```
+
+### Horror
+```javascript
+music: { mood: "Mysterious", energy: 4 }
+background: { theme: "spooky", variant: "hauntedHouse" }
+avatarContext: { mode: "FULL_BODY", context: "adventure-hero" }
+```
+
+---
+
+## Important Notes
+
+1. **Always use the provided state object** - Don't create global variables for game state.
+
+2. **Delta time is in seconds** - Multiply speeds by `dt` for frame-independent movement.
+
+3. **Player position is center-based** - `playerX` and `playerY` are the center of the player.
+
+4. **Earned accessories always show** - User's crowns, auras, etc. render above costumes.
+
+5. **Test on mobile** - Touch handlers are provided, but test gesture-based games.
+
+6. **Use endGame() properly** - It handles score reporting and music fadeout.
+
+7. **Don't modify the asset library scripts** - Only modify the game logic section.
diff --git a/frontend/public/templates/gameTemplateAssets.html b/frontend/public/templates/gameTemplateAssets.html
new file mode 100644
index 0000000..928b4d3
--- /dev/null
+++ b/frontend/public/templates/gameTemplateAssets.html
@@ -0,0 +1,905 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Score: 0
+
Health: 100
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/templates/gameTemplateSimple.html b/frontend/public/templates/gameTemplateSimple.html
new file mode 100644
index 0000000..b5cf326
--- /dev/null
+++ b/frontend/public/templates/gameTemplateSimple.html
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+
+
+
+
+ Score: 0
+
+
+
+
diff --git a/frontend/public/tests/asset-test-suite.html b/frontend/public/tests/asset-test-suite.html
new file mode 100644
index 0000000..d494455
--- /dev/null
+++ b/frontend/public/tests/asset-test-suite.html
@@ -0,0 +1,1982 @@
+
+
+
+
+
+ GamerComp Asset Library Test Suite
+
+
+
+
+
+
+ Run All Tests
+ Test Music
+ Test Backgrounds
+ Test Avatars
+ Test Contexts
+ Test Asset Manager
+ Test Performance
+ Test Error Handling
+ Reset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/frontend/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..1c27e81
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,275 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: #f8fafc;
+ color: #1e293b;
+ -webkit-font-smoothing: antialiased;
+}
+
+.app {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.main-content {
+ flex: 1;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.625rem 1.25rem;
+ border: none;
+ border-radius: 0.5rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-decoration: none;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
+}
+
+.btn-secondary {
+ background: #e2e8f0;
+ color: #475569;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: #cbd5e1;
+}
+
+.btn-danger {
+ background: #dc2626;
+ color: white;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: #b91c1c;
+}
+
+.btn-sm {
+ padding: 0.4rem 0.875rem;
+ font-size: 0.85rem;
+}
+
+.btn-lg {
+ padding: 0.875rem 1.75rem;
+ font-size: 1.1rem;
+}
+
+/* Form elements */
+input, textarea, select {
+ font-family: inherit;
+}
+
+/* Links */
+a {
+ color: #6366f1;
+}
+
+/* Utility */
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4rem;
+ color: #475569; /* WCAG AA: 7.0:1 contrast on #f8fafc */
+}
+
+/* ============================================
+ Accessibility - Focus Styles
+ WCAG 2.1 Level AA compliant keyboard navigation
+ ============================================ */
+
+/* Global focus-visible styles for all interactive elements */
+:focus-visible {
+ outline: 2px solid #6366f1;
+ outline-offset: 2px;
+}
+
+/* Remove default focus for mouse users, keep for keyboard */
+:focus:not(:focus-visible) {
+ outline: none;
+}
+
+/* Button focus states */
+.btn:focus-visible {
+ outline: 2px solid #6366f1;
+ outline-offset: 2px;
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
+}
+
+.btn-primary:focus-visible {
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.3);
+}
+
+.btn-secondary:focus-visible {
+ outline-color: #475569;
+ box-shadow: 0 0 0 4px rgba(71, 85, 105, 0.2);
+}
+
+.btn-danger:focus-visible {
+ outline-color: #dc2626;
+ box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.2);
+}
+
+/* Link focus states */
+a:focus-visible {
+ outline: 2px solid #6366f1;
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+/* Form element focus states */
+input:focus-visible,
+textarea:focus-visible,
+select:focus-visible {
+ outline: 2px solid #6366f1;
+ outline-offset: 0;
+ border-color: #6366f1;
+}
+
+/* Skip to content link - hidden until focused */
+.skip-to-content {
+ position: absolute;
+ top: -100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #6366f1;
+ color: white;
+ padding: 0.75rem 1.5rem;
+ border-radius: 0 0 0.5rem 0.5rem;
+ font-weight: 600;
+ z-index: 10000;
+ text-decoration: none;
+ transition: top 0.2s ease;
+}
+
+.skip-to-content:focus {
+ top: 0;
+ outline: 2px solid white;
+ outline-offset: 2px;
+}
+
+/* ============================================
+ Mobile-First Global Styles
+ ============================================ */
+
+/* Prevent horizontal overflow - only on body to preserve sticky on html */
+body {
+ overflow-x: hidden;
+ max-width: 100vw;
+}
+
+html {
+ /* Allow sticky to work properly */
+ overflow-x: clip; /* Modern alternative that doesn't break sticky */
+}
+
+/* Mobile text sizes - prevent iOS zoom on focus */
+@media (max-width: 768px) {
+ input, textarea, select {
+ font-size: 16px !important;
+ }
+
+ .btn {
+ min-height: 44px;
+ padding: 0.75rem 1rem;
+ }
+
+ .btn-sm {
+ min-height: 40px;
+ padding: 0.5rem 0.75rem;
+ }
+}
+
+/* Touch-friendly targets */
+@media (hover: none), (pointer: coarse) {
+ .btn,
+ button,
+ a,
+ input[type="checkbox"],
+ input[type="radio"] {
+ min-height: 44px;
+ min-width: 44px;
+ }
+
+ /* Ensure form inputs are easy to tap */
+ input[type="text"],
+ input[type="email"],
+ input[type="password"],
+ input[type="number"],
+ input[type="tel"],
+ input[type="url"],
+ textarea,
+ select {
+ min-height: 48px;
+ padding: 0.75rem 1rem;
+ }
+
+ /* Disable hover effects on touch */
+ .btn:hover {
+ transform: none;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .btn:active {
+ transform: scale(0.98);
+ }
+
+ .btn-primary:active {
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+ }
+
+ .btn-secondary:active {
+ background: #94a3b8;
+ }
+}
+
+/* Safe area padding for notched devices */
+@supports (padding: max(0px)) {
+ .header {
+ padding-left: max(1rem, env(safe-area-inset-left));
+ padding-right: max(1rem, env(safe-area-inset-right));
+ }
+
+ .main-content {
+ padding-bottom: max(0px, env(safe-area-inset-bottom));
+ }
+}
+
+/* Reduce motion for users who prefer it */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..a0cd9c8
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,108 @@
+import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
+import { useEffect } from 'react';
+import { AuthProvider, useAuth } from './context/AuthContext';
+import Header from './components/Header';
+
+// Scroll to top on every route change
+function ScrollToTop() {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname]);
+
+ return null;
+}
+import Footer from './components/Footer';
+import InstallPrompt from './components/InstallPrompt';
+import OfflineIndicator from './components/OfflineIndicator';
+import OnboardingTutorial from './components/OnboardingTutorial';
+import Home from './pages/Home';
+import Arcade from './pages/Arcade';
+import Play from './pages/Play';
+import Login from './pages/Login';
+import Register from './pages/Register';
+import ForgotPassword from './pages/ForgotPassword';
+import ResetPassword from './pages/ResetPassword';
+import CreateWizard from './pages/CreateWizard';
+import Edit from './pages/Edit';
+import Dashboard from './pages/Dashboard';
+import Profile from './pages/Profile';
+import Collections from './pages/Collections';
+import Admin from './pages/Admin';
+import Terms from './pages/Terms';
+import Privacy from './pages/Privacy';
+import Achievements from './pages/Achievements';
+import Classrooms from './pages/Classrooms';
+import TeacherConsole from './pages/TeacherConsole';
+import GameAnalytics from './pages/GameAnalytics';
+import Leaderboard from './pages/Leaderboard';
+import CreatorProfile from './pages/CreatorProfile';
+import GameStats from './pages/GameStats';
+import ReleaseNotes from './pages/ReleaseNotes';
+import './App.css';
+
+function AppContent() {
+ const { user, loading, isAuthenticated, completeOnboarding } = useAuth();
+
+ // Show onboarding tutorial for new authenticated users who haven't completed it
+ // Wait for loading to finish to prevent flash of onboarding
+ const showOnboarding = !loading && isAuthenticated && user && !user.onboardingComplete;
+
+ return (
+ <>
+
+
+
+ {showOnboarding && (
+
+ )}
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ >
+ );
+}
+
+function App() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644
index 0000000..de41cb7
--- /dev/null
+++ b/frontend/src/api.js
@@ -0,0 +1,785 @@
+import { getFingerprint } from './hooks/useFingerprint';
+
+const API_BASE = '/api';
+
+class ApiClient {
+ constructor() {
+ this.token = localStorage.getItem('token');
+ }
+
+ setToken(token) {
+ this.token = token;
+ if (token) {
+ localStorage.setItem('token', token);
+ } else {
+ localStorage.removeItem('token');
+ }
+ }
+
+ async request(endpoint, options = {}) {
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ };
+
+ if (this.token) {
+ headers['Authorization'] = `Bearer ${this.token}`;
+ }
+
+ // Add fingerprint header for guest authentication
+ const fingerprint = getFingerprint();
+ if (fingerprint) {
+ headers['X-Device-Fingerprint'] = fingerprint;
+ }
+
+ // Support custom timeout (default 30s, long requests can specify more)
+ const timeout = options.timeout || 30000;
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ const response = await fetch(`${API_BASE}${endpoint}`, {
+ ...options,
+ headers,
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ // Check if this is a guest upgrade prompt
+ if (data.requiresSignup) {
+ const error = new Error(data.message || 'Sign up required');
+ error.requiresSignup = true;
+ throw error;
+ }
+ throw new Error(data.error || 'Request failed');
+ }
+
+ return data;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ if (err.name === 'AbortError') {
+ throw new Error('Request timed out. Please try again.');
+ }
+ throw err;
+ }
+ }
+
+ // Auth
+ async register(username, email, password) {
+ const fingerprint = getFingerprint();
+ const data = await this.request('/auth/register', {
+ method: 'POST',
+ body: JSON.stringify({ username, email, password, fingerprint })
+ });
+ this.setToken(data.token);
+ return data;
+ }
+
+ async login(email, password) {
+ const data = await this.request('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password })
+ });
+ this.setToken(data.token);
+ return data;
+ }
+
+ async guestLogin(fingerprint) {
+ const data = await this.request('/auth/guest-login', {
+ method: 'POST',
+ body: JSON.stringify({ fingerprint })
+ });
+ this.setToken(data.token);
+ return data;
+ }
+
+ async forgotPassword(email) {
+ return this.request('/auth/forgot-password', {
+ method: 'POST',
+ body: JSON.stringify({ email })
+ });
+ }
+
+ async resetPassword(token, password) {
+ return this.request('/auth/reset-password', {
+ method: 'POST',
+ body: JSON.stringify({ token, password })
+ });
+ }
+
+ logout() {
+ this.setToken(null);
+ }
+
+ // User
+ async getMe() {
+ return this.request('/users/me');
+ }
+
+ async updateProfile(data) {
+ return this.request('/users/me', {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async updateAvatar(avatarData) {
+ return this.request('/users/me/avatar', {
+ method: 'PUT',
+ body: JSON.stringify({ avatar: avatarData })
+ });
+ }
+
+ async getPlayHistory() {
+ return this.request('/users/me/play-history');
+ }
+
+ async getMyStats() {
+ return this.request('/users/me/stats');
+ }
+
+ async getMyAchievements() {
+ return this.request('/users/me/achievements');
+ }
+
+ async getAllAchievements() {
+ return this.request('/users/me/achievements?all=true');
+ }
+
+ async getMyFavorites() {
+ return this.request('/users/me/favorites');
+ }
+
+ async getXPHistory(limit = 20) {
+ return this.request(`/users/me/xp-history?limit=${limit}`);
+ }
+
+ async getMyCollections() {
+ return this.request('/users/me/collections');
+ }
+
+ async getMyGames() {
+ return this.request('/users/me/games');
+ }
+
+ async getNotifications(unreadOnly = false) {
+ return this.request(`/users/me/notifications${unreadOnly ? '?unread=true' : ''}`);
+ }
+
+ async markNotificationRead(notificationId) {
+ return this.request(`/users/me/notifications/${notificationId}/read`, {
+ method: 'POST'
+ });
+ }
+
+ async markAllNotificationsRead() {
+ return this.request('/users/me/notifications/read-all', {
+ method: 'POST'
+ });
+ }
+
+ // Profiles
+ async getUserProfile(userId) {
+ return this.request(`/users/${userId}`);
+ }
+
+ async getCreatorByUsername(username) {
+ return this.request(`/users/by-username/${encodeURIComponent(username)}`);
+ }
+
+ async getCreatorGames(username) {
+ return this.request(`/users/by-username/${encodeURIComponent(username)}/games`);
+ }
+
+ async followUser(userId) {
+ return this.request(`/users/${userId}/follow`, {
+ method: 'POST'
+ });
+ }
+
+ async unfollowUser(userId) {
+ return this.request(`/users/${userId}/follow`, {
+ method: 'DELETE'
+ });
+ }
+
+ async getFollowers(userId) {
+ return this.request(`/users/${userId}/followers`);
+ }
+
+ async getFollowing(userId) {
+ return this.request(`/users/${userId}/following`);
+ }
+
+ // Arcade
+ async getGames(params = {}) {
+ const query = new URLSearchParams(params).toString();
+ return this.request(`/arcade/games${query ? `?${query}` : ''}`);
+ }
+
+ async getGame(gameId) {
+ return this.request(`/arcade/games/${gameId}`);
+ }
+
+ async getHighScores(gameId) {
+ return this.request(`/arcade/games/${gameId}/high-scores`);
+ }
+
+ // Play
+ async startPlay(gameId) {
+ const fingerprint = getFingerprint();
+ return this.request('/play/start', {
+ method: 'POST',
+ body: JSON.stringify({ gameId, fingerprint })
+ });
+ }
+
+ async finishPlay(sessionId, score, durationSeconds, levelsCompleted = 0) {
+ return this.request('/play/finish', {
+ method: 'POST',
+ body: JSON.stringify({ sessionId, score, durationSeconds, levelsCompleted })
+ });
+ }
+
+ async rateGame(gameId, rating) {
+ return this.request('/play/rate', {
+ method: 'POST',
+ body: JSON.stringify({ gameId, rating })
+ });
+ }
+
+ async toggleFavorite(gameId) {
+ return this.request('/play/favorite', {
+ method: 'POST',
+ body: JSON.stringify({ gameId })
+ });
+ }
+
+ // Game Creation Wizard
+ async getGameStyles() {
+ return this.request('/creation/styles');
+ }
+
+ async getStyleQuestions(styleId) {
+ return this.request(`/creation/styles/${styleId}/questions`);
+ }
+
+ async estimateComplexity(styleId, answers) {
+ return this.request('/creation/estimate-complexity', {
+ method: 'POST',
+ body: JSON.stringify({ styleId, answers })
+ });
+ }
+
+ async startCreation(styleId, title, answers) {
+ return this.request('/creation/start', {
+ method: 'POST',
+ body: JSON.stringify({ styleId, title, answers })
+ });
+ }
+
+ async generateGame(gameId, editedPrompt = null, promptWasEdited = false) {
+ // Long timeout for AI generation (180 seconds to match nginx)
+ return this.request(`/creation/${gameId}/generate`, {
+ method: 'POST',
+ timeout: 180000,
+ body: JSON.stringify({
+ editedPrompt,
+ promptWasEdited
+ })
+ });
+ }
+
+ async trackTesting(gameId, durationSeconds, levelsCompleted = 0, thumbnailData = null) {
+ return this.request(`/creation/${gameId}/test`, {
+ method: 'POST',
+ body: JSON.stringify({ durationSeconds, levelsCompleted, thumbnailData })
+ });
+ }
+
+ async saveThumbnail(gameId, thumbnailData) {
+ return this.request(`/creation/${gameId}/thumbnail`, {
+ method: 'POST',
+ body: JSON.stringify({ thumbnailData })
+ });
+ }
+
+ async refineGame(gameId, feedback) {
+ // Long timeout for AI refinement (180 seconds to match nginx)
+ return this.request(`/creation/${gameId}/refine`, {
+ method: 'POST',
+ body: JSON.stringify({ feedback }),
+ timeout: 180000
+ });
+ }
+
+ // Local AI endpoints
+ async refinePromptChat(gameIdea, conversationHistory) {
+ return this.request('/localai/refine-prompt', {
+ method: 'POST',
+ body: JSON.stringify({ gameIdea, conversationHistory }),
+ timeout: 150000
+ });
+ }
+
+ async classifyFeedback(gameId, feedback) {
+ return this.request('/localai/classify', {
+ method: 'POST',
+ body: JSON.stringify({ gameId, feedback }),
+ timeout: 30000
+ });
+ }
+
+ async previewTweak(gameId, feedback) {
+ return this.request(`/localai/tweak/${gameId}/preview`, {
+ method: 'POST',
+ body: JSON.stringify({ feedback }),
+ timeout: 30000
+ });
+ }
+
+ async applyFreeTweak(gameId, feedback) {
+ return this.request(`/localai/tweak/${gameId}`, {
+ method: 'POST',
+ body: JSON.stringify({ feedback }),
+ timeout: 150000
+ });
+ }
+
+ async reviewCodeChanges(gameId, newCode) {
+ return this.request(`/localai/review-code/${gameId}`, {
+ method: 'POST',
+ body: JSON.stringify({ newCode }),
+ timeout: 30000
+ });
+ }
+
+ async saveGameCode(gameId, newCode) {
+ return this.request(`/localai/save-code/${gameId}`, {
+ method: 'POST',
+ body: JSON.stringify({ newCode }),
+ timeout: 30000
+ });
+ }
+
+ async publishGame(gameId, metadata = {}) {
+ return this.request(`/creation/${gameId}/publish`, {
+ method: 'POST',
+ body: JSON.stringify(metadata)
+ });
+ }
+
+ async getCreationStatus(gameId) {
+ return this.request(`/creation/${gameId}/status`);
+ }
+
+ async getGameVersions(gameId) {
+ return this.request(`/creation/${gameId}/versions`);
+ }
+
+ async rollbackGame(gameId, versionNumber) {
+ return this.request(`/creation/${gameId}/rollback`, {
+ method: 'POST',
+ body: JSON.stringify({ versionNumber })
+ });
+ }
+
+ async remixGame(gameId) {
+ return this.request(`/creation/${gameId}/remix`, {
+ method: 'POST'
+ });
+ }
+
+ // Legacy game creation (old system)
+ async createGame(description, title) {
+ return this.request('/games/create', {
+ method: 'POST',
+ body: JSON.stringify({ description, title })
+ });
+ }
+
+ async getMyGame(gameId) {
+ return this.request(`/games/${gameId}`);
+ }
+
+ async updateGame(gameId, data) {
+ return this.request(`/games/${gameId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async submitGame(gameId) {
+ return this.request(`/games/${gameId}/submit`, {
+ method: 'POST'
+ });
+ }
+
+ async unpublishGame(gameId) {
+ return this.request(`/games/${gameId}/unpublish`, {
+ method: 'POST'
+ });
+ }
+
+ async deleteGame(gameId) {
+ return this.request(`/games/${gameId}`, {
+ method: 'DELETE'
+ });
+ }
+
+ // Collections
+ async createCollection(name, description = '', isPublic = true) {
+ return this.request('/collections', {
+ method: 'POST',
+ body: JSON.stringify({ name, description, isPublic })
+ });
+ }
+
+ async getCollection(collectionId) {
+ return this.request(`/collections/${collectionId}`);
+ }
+
+ async updateCollection(collectionId, data) {
+ return this.request(`/collections/${collectionId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async deleteCollection(collectionId) {
+ return this.request(`/collections/${collectionId}`, {
+ method: 'DELETE'
+ });
+ }
+
+ async addToCollection(collectionId, gameId) {
+ return this.request(`/collections/${collectionId}/games`, {
+ method: 'POST',
+ body: JSON.stringify({ gameId })
+ });
+ }
+
+ async removeFromCollection(collectionId, gameId) {
+ return this.request(`/collections/${collectionId}/games/${gameId}`, {
+ method: 'DELETE'
+ });
+ }
+
+ // Developer
+ async getDashboard() {
+ return this.request('/developer/dashboard');
+ }
+
+ async getMyGames() {
+ return this.request('/developer/games');
+ }
+
+ async getGameAnalytics(gameId) {
+ return this.request(`/developer/games/${gameId}/analytics`);
+ }
+
+ // Report game
+ async reportGame(gameId, reason, comment) {
+ return this.request('/play/report', {
+ method: 'POST',
+ body: JSON.stringify({ gameId, reason, comment })
+ });
+ }
+
+ // Admin endpoints
+ async getModerationQueue() {
+ return this.request('/admin/moderation-queue');
+ }
+
+ async reviewGame(gameId, action, reason = null) {
+ return this.request(`/admin/review/${gameId}`, {
+ method: 'POST',
+ body: JSON.stringify({ action, reason })
+ });
+ }
+
+ async getReports() {
+ return this.request('/admin/reports');
+ }
+
+ async resolveReport(reportId, action, notes = null) {
+ return this.request(`/admin/reports/${reportId}/resolve`, {
+ method: 'POST',
+ body: JSON.stringify({ action, notes })
+ });
+ }
+
+ async getAdminStats() {
+ return this.request('/admin/stats');
+ }
+
+ async searchGameDiagnostics(search = '', gameId = '') {
+ const params = new URLSearchParams();
+ if (search) params.set('search', search);
+ if (gameId) params.set('gameId', gameId);
+ return this.request(`/admin/game-diagnostics?${params.toString()}`);
+ }
+
+ async banUser(userId) {
+ return this.request(`/admin/ban-user/${userId}`, {
+ method: 'POST'
+ });
+ }
+
+ async adminUnpublishGame(gameId, reason) {
+ return this.request(`/admin/unpublish/${gameId}`, {
+ method: 'POST',
+ body: JSON.stringify({ reason })
+ });
+ }
+
+ async clearCloudflareCache() {
+ return this.request('/admin/clear-cache', {
+ method: 'POST'
+ });
+ }
+
+ async getSystemHealth() {
+ return this.request('/admin/system-health');
+ }
+
+ // Share
+ async getShareData(gameId) {
+ return this.request(`/share/data/${gameId}`);
+ }
+
+ // Themes
+ async getCurrentTheme() {
+ return this.request('/themes/current');
+ }
+
+ async getUpcomingThemes() {
+ return this.request('/themes/upcoming');
+ }
+
+ async getPastThemes() {
+ return this.request('/themes/past');
+ }
+
+ // Classrooms
+ async getMyClassrooms() {
+ return this.request('/classrooms/my');
+ }
+
+ async createClassroom(name, isPublicPublishingAllowed = false) {
+ return this.request('/classrooms', {
+ method: 'POST',
+ body: JSON.stringify({ name, isPublicPublishingAllowed })
+ });
+ }
+
+ async getClassroom(classroomId) {
+ return this.request(`/classrooms/${classroomId}`);
+ }
+
+ async updateClassroom(classroomId, data) {
+ return this.request(`/classrooms/${classroomId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async deleteClassroom(classroomId) {
+ return this.request(`/classrooms/${classroomId}`, {
+ method: 'DELETE'
+ });
+ }
+
+ async regenerateJoinCode(classroomId) {
+ return this.request(`/classrooms/${classroomId}/regenerate-code`, {
+ method: 'POST'
+ });
+ }
+
+ async joinClassroom(joinCode) {
+ return this.request('/classrooms/join', {
+ method: 'POST',
+ body: JSON.stringify({ joinCode })
+ });
+ }
+
+ async leaveClassroom(classroomId) {
+ return this.request(`/classrooms/${classroomId}/leave`, {
+ method: 'POST'
+ });
+ }
+
+ async getClassroomStudents(classroomId) {
+ return this.request(`/classrooms/${classroomId}/students`);
+ }
+
+ async removeStudentFromClassroom(classroomId, studentId) {
+ return this.request(`/classrooms/${classroomId}/students/${studentId}`, {
+ method: 'DELETE'
+ });
+ }
+
+ async getClassroomLeaderboard(classroomId) {
+ return this.request(`/classrooms/${classroomId}/leaderboard`);
+ }
+
+ async getClassroomAssignments(classroomId) {
+ return this.request(`/classrooms/${classroomId}/assignments`);
+ }
+
+ async createAssignment(classroomId, data) {
+ return this.request(`/classrooms/${classroomId}/assignments`, {
+ method: 'POST',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async deleteAssignment(classroomId, assignmentId) {
+ return this.request(`/classrooms/${classroomId}/assignments/${assignmentId}`, {
+ method: 'DELETE'
+ });
+ }
+
+ async getClassroomStudentProgress(classroomId) {
+ return this.request(`/classrooms/${classroomId}/student-progress`);
+ }
+
+ async getMyAssignments(classroomId) {
+ return this.request(`/classrooms/${classroomId}/my-assignments`);
+ }
+
+ async completeAssignment(classroomId, assignmentId, data = {}) {
+ return this.request(`/classrooms/${classroomId}/assignments/${assignmentId}/complete`, {
+ method: 'POST',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async getAssignmentCompletions(classroomId, assignmentId) {
+ return this.request(`/classrooms/${classroomId}/assignments/${assignmentId}/completions`);
+ }
+
+ async broadcastToClassroom(classroomId, message, title = '') {
+ return this.request(`/classrooms/${classroomId}/broadcast`, {
+ method: 'POST',
+ body: JSON.stringify({ message, title })
+ });
+ }
+
+ // Leaderboard
+ async getTopPlayers(page = 1, limit = 10) {
+ return this.request(`/leaderboard/players?page=${page}&limit=${limit}`);
+ }
+
+ async getTopCreators(page = 1, limit = 10, period = 'all') {
+ return this.request(`/leaderboard/creators?page=${page}&limit=${limit}&period=${period}`);
+ }
+
+ async getTopGames(page = 1, limit = 10, period = 'all') {
+ return this.request(`/leaderboard/games?page=${page}&limit=${limit}&period=${period}`);
+ }
+
+ // Onboarding
+ async getOnboardingStatus() {
+ return this.request('/users/me/onboarding');
+ }
+
+ async updateOnboardingStep(step) {
+ return this.request('/users/me/onboarding', {
+ method: 'POST',
+ body: JSON.stringify({ step })
+ });
+ }
+
+ async completeOnboarding() {
+ return this.request('/users/me/onboarding', {
+ method: 'POST',
+ body: JSON.stringify({ complete: true })
+ });
+ }
+
+ // Contact
+ async submitContact(name, email, subject, message) {
+ return this.request('/contact', {
+ method: 'POST',
+ body: JSON.stringify({ name, email, subject, message })
+ });
+ }
+
+ // SSE Streaming for generation/refinement
+ streamGeneration(gameId, onEvent) {
+ const token = this.token;
+ const url = `${API_BASE}/creation/${gameId}/stream?token=${encodeURIComponent(token)}`;
+ const eventSource = new EventSource(url);
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ onEvent(data);
+ } catch {
+ // Ignore parse errors
+ }
+ };
+
+ eventSource.onerror = () => {
+ // EventSource will auto-reconnect; caller can close if needed
+ };
+
+ return eventSource;
+ }
+
+ // Push notification methods
+ async getVapidKey() {
+ return this.request('/push/vapid-key');
+ }
+
+ async subscribePush(subscription) {
+ return this.request('/push/subscribe', {
+ method: 'POST',
+ body: JSON.stringify({ subscription })
+ });
+ }
+
+ async unsubscribePush(endpoint) {
+ return this.request('/push/unsubscribe', {
+ method: 'POST',
+ body: JSON.stringify({ endpoint })
+ });
+ }
+
+ // Bug Reports
+ async submitBugReport(title, description, pageUrl = null) {
+ return this.request('/bugs', {
+ method: 'POST',
+ body: JSON.stringify({ title, description, pageUrl })
+ });
+ }
+
+ async getMyBugReports() {
+ return this.request('/bugs/my-reports');
+ }
+
+ async getAdminBugReports(status = 'all', priority = 'all') {
+ return this.request(`/bugs/admin?status=${status}&priority=${priority}`);
+ }
+
+ async updateBugReport(id, data) {
+ return this.request(`/bugs/admin/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async deleteBugReport(id) {
+ return this.request(`/bugs/admin/${id}`, {
+ method: 'DELETE'
+ });
+ }
+}
+
+export const api = new ApiClient();
+export default api;
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/frontend/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/AvatarBuilder.css b/frontend/src/components/AvatarBuilder.css
new file mode 100644
index 0000000..48efc06
--- /dev/null
+++ b/frontend/src/components/AvatarBuilder.css
@@ -0,0 +1,214 @@
+.avatar-builder {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.avatar-builder-preview {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.avatar-builder-preview .avatar-svg {
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
+}
+
+.randomize-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.avatar-builder-controls {
+ margin-bottom: 1.5rem;
+}
+
+.category-tabs {
+ display: flex;
+ gap: 0.25rem;
+ margin-bottom: 1rem;
+ overflow-x: auto;
+ padding-bottom: 0.5rem;
+ -webkit-overflow-scrolling: touch;
+}
+
+.category-tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.5rem 0.75rem;
+ border: none;
+ background: #f1f5f9;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ min-width: 60px;
+ flex-shrink: 0;
+}
+
+.category-tab:hover {
+ background: #e2e8f0;
+}
+
+.category-tab.active {
+ background: #6366f1;
+ color: white;
+}
+
+.tab-icon {
+ font-size: 1.25rem;
+}
+
+.tab-label {
+ font-size: 0.65rem;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.options-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
+ gap: 0.5rem;
+}
+
+.option-btn {
+ padding: 0.75rem 0.5rem;
+ border: 2px solid #e2e8f0;
+ background: white;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: #475569;
+ position: relative;
+}
+
+.option-btn:hover {
+ border-color: #6366f1;
+}
+
+.option-btn.selected {
+ border-color: #6366f1;
+ background: #ede9fe;
+ color: #4f46e5;
+}
+
+.option-btn.color-option {
+ aspect-ratio: 1;
+ border-radius: 50%;
+ padding: 0;
+ min-height: 45px;
+}
+
+.option-btn.color-option.selected {
+ box-shadow: 0 0 0 3px white, 0 0 0 5px #6366f1;
+}
+
+.option-btn .check {
+ position: absolute;
+ top: 2px;
+ right: 4px;
+ font-size: 0.75rem;
+ color: #6366f1;
+}
+
+.option-btn.color-option .check {
+ top: 50%;
+ left: 50%;
+ right: auto;
+ transform: translate(-50%, -50%);
+ color: white;
+ font-size: 1rem;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
+}
+
+.avatar-builder-actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+}
+
+/* Avatar SVG styling */
+.avatar-svg {
+ display: block;
+ overflow: visible;
+}
+
+/* Mini placeholder */
+.avatar-mini-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
+ border-radius: 50%;
+}
+
+/* Modal version */
+.avatar-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.avatar-modal .avatar-builder {
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+@media (max-width: 480px) {
+ .avatar-builder {
+ padding: 1rem;
+ }
+
+ .category-tabs {
+ gap: 0.35rem;
+ }
+
+ .category-tab {
+ padding: 0.4rem 0.5rem;
+ min-width: 50px;
+ }
+
+ .tab-icon {
+ font-size: 1rem;
+ }
+
+ .tab-label {
+ font-size: 0.6rem;
+ }
+
+ .options-grid {
+ grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
+ gap: 0.35rem;
+ }
+
+ .option-btn {
+ padding: 0.5rem 0.35rem;
+ font-size: 0.75rem;
+ }
+
+ .avatar-builder-actions {
+ flex-direction: column;
+ }
+
+ .avatar-builder-actions .btn {
+ width: 100%;
+ }
+}
diff --git a/frontend/src/components/AvatarBuilder.jsx b/frontend/src/components/AvatarBuilder.jsx
new file mode 100644
index 0000000..60c0274
--- /dev/null
+++ b/frontend/src/components/AvatarBuilder.jsx
@@ -0,0 +1,412 @@
+import { useState, useEffect } from 'react';
+import './AvatarBuilder.css';
+
+// Avatar customization options
+const AVATAR_OPTIONS = {
+ skinTone: [
+ { id: 'light', color: '#FFE0BD' },
+ { id: 'fair', color: '#FFCD94' },
+ { id: 'medium', color: '#E5A073' },
+ { id: 'tan', color: '#C68642' },
+ { id: 'brown', color: '#8D5524' },
+ { id: 'dark', color: '#5C4033' }
+ ],
+ hairStyle: [
+ { id: 'short', label: 'Short' },
+ { id: 'medium', label: 'Medium' },
+ { id: 'long', label: 'Long' },
+ { id: 'spiky', label: 'Spiky' },
+ { id: 'curly', label: 'Curly' },
+ { id: 'mohawk', label: 'Mohawk' },
+ { id: 'ponytail', label: 'Ponytail' },
+ { id: 'bald', label: 'None' }
+ ],
+ hairColor: [
+ { id: 'black', color: '#1a1a1a' },
+ { id: 'brown', color: '#4a3728' },
+ { id: 'blonde', color: '#e6c86e' },
+ { id: 'red', color: '#b55239' },
+ { id: 'blue', color: '#4a90d9' },
+ { id: 'pink', color: '#e75480' },
+ { id: 'purple', color: '#9b59b6' },
+ { id: 'green', color: '#27ae60' }
+ ],
+ eyes: [
+ { id: 'normal', label: 'Normal' },
+ { id: 'happy', label: 'Happy' },
+ { id: 'cool', label: 'Cool' },
+ { id: 'surprised', label: 'Surprised' },
+ { id: 'wink', label: 'Wink' },
+ { id: 'sleepy', label: 'Sleepy' }
+ ],
+ eyeColor: [
+ { id: 'brown', color: '#5D4037' },
+ { id: 'blue', color: '#2196F3' },
+ { id: 'green', color: '#4CAF50' },
+ { id: 'gray', color: '#607D8B' },
+ { id: 'purple', color: '#9C27B0' },
+ { id: 'gold', color: '#FFC107' }
+ ],
+ mouth: [
+ { id: 'smile', label: 'Smile' },
+ { id: 'grin', label: 'Grin' },
+ { id: 'open', label: 'Open' },
+ { id: 'smirk', label: 'Smirk' },
+ { id: 'neutral', label: 'Neutral' },
+ { id: 'tongue', label: 'Tongue' }
+ ],
+ accessory: [
+ { id: 'none', label: 'None' },
+ { id: 'glasses', label: 'Glasses' },
+ { id: 'sunglasses', label: 'Sunglasses' },
+ { id: 'headphones', label: 'Headphones' },
+ { id: 'cap', label: 'Cap' },
+ { id: 'crown', label: 'Crown' },
+ { id: 'bow', label: 'Bow' },
+ { id: 'horns', label: 'Horns' }
+ ],
+ background: [
+ { id: 'blue', color: '#3498db' },
+ { id: 'green', color: '#2ecc71' },
+ { id: 'purple', color: '#9b59b6' },
+ { id: 'orange', color: '#e67e22' },
+ { id: 'pink', color: '#e91e63' },
+ { id: 'teal', color: '#1abc9c' },
+ { id: 'red', color: '#e74c3c' },
+ { id: 'gray', color: '#95a5a6' }
+ ]
+};
+
+const DEFAULT_AVATAR = {
+ skinTone: 'medium',
+ hairStyle: 'short',
+ hairColor: 'brown',
+ eyes: 'normal',
+ eyeColor: 'brown',
+ mouth: 'smile',
+ accessory: 'none',
+ background: 'blue'
+};
+
+export default function AvatarBuilder({ initialData, onSave, onCancel }) {
+ const [avatar, setAvatar] = useState(() => ({
+ ...DEFAULT_AVATAR,
+ ...initialData
+ }));
+ const [activeCategory, setActiveCategory] = useState('skinTone');
+ const [saving, setSaving] = useState(false);
+
+ const handleChange = (category, value) => {
+ setAvatar(prev => ({ ...prev, [category]: value }));
+ };
+
+ const handleRandomize = () => {
+ const randomAvatar = {};
+ Object.keys(AVATAR_OPTIONS).forEach(category => {
+ const options = AVATAR_OPTIONS[category];
+ const randomIndex = Math.floor(Math.random() * options.length);
+ randomAvatar[category] = options[randomIndex].id;
+ });
+ setAvatar(randomAvatar);
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await onSave(avatar);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const categories = [
+ { id: 'skinTone', label: 'Skin', icon: '👤' },
+ { id: 'hairStyle', label: 'Hair', icon: '💇' },
+ { id: 'hairColor', label: 'Hair Color', icon: '🎨' },
+ { id: 'eyes', label: 'Eyes', icon: '👀' },
+ { id: 'eyeColor', label: 'Eye Color', icon: '🔵' },
+ { id: 'mouth', label: 'Mouth', icon: '👄' },
+ { id: 'accessory', label: 'Accessory', icon: '🎀' },
+ { id: 'background', label: 'Background', icon: '🖼️' }
+ ];
+
+ return (
+
+
+
+
+
+ {categories.map(cat => (
+ setActiveCategory(cat.id)}
+ title={cat.label}
+ >
+ {cat.icon}
+ {cat.label}
+
+ ))}
+
+
+
+ {AVATAR_OPTIONS[activeCategory].map(option => {
+ const isSelected = avatar[activeCategory] === option.id;
+ const isColor = option.color;
+
+ return (
+ handleChange(activeCategory, option.id)}
+ style={isColor ? { backgroundColor: option.color } : {}}
+ >
+ {!isColor && {option.label} }
+ {isSelected && ✓ }
+
+ );
+ })}
+
+
+
+
+ {onCancel && (
+
+ Cancel
+
+ )}
+
+ {saving ? 'Saving...' : 'Save Avatar'}
+
+
+
+ );
+}
+
+// Avatar Display Component
+export function AvatarDisplay({ data, size = 48, className = '' }) {
+ const avatar = { ...DEFAULT_AVATAR, ...data };
+
+ const skinTone = AVATAR_OPTIONS.skinTone.find(s => s.id === avatar.skinTone)?.color || '#FFCD94';
+ const hairColor = AVATAR_OPTIONS.hairColor.find(h => h.id === avatar.hairColor)?.color || '#4a3728';
+ const eyeColor = AVATAR_OPTIONS.eyeColor.find(e => e.id === avatar.eyeColor)?.color || '#5D4037';
+ const bgColor = AVATAR_OPTIONS.background.find(b => b.id === avatar.background)?.color || '#3498db';
+
+ return (
+
+ {/* Face */}
+
+
+ {/* Hair - Back layer for some styles */}
+ {avatar.hairStyle === 'long' && (
+
+ )}
+ {avatar.hairStyle === 'ponytail' && (
+ <>
+
+
+ >
+ )}
+
+ {/* Face again (on top of back hair) */}
+ {(avatar.hairStyle === 'long' || avatar.hairStyle === 'ponytail') && (
+
+ )}
+
+ {/* Eyes */}
+
+ {avatar.eyes === 'normal' && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {avatar.eyes === 'happy' && (
+ <>
+
+
+ >
+ )}
+ {avatar.eyes === 'cool' && (
+ <>
+
+
+
+
+ >
+ )}
+ {avatar.eyes === 'surprised' && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {avatar.eyes === 'wink' && (
+ <>
+
+
+
+
+ >
+ )}
+ {avatar.eyes === 'sleepy' && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Mouth */}
+
+ {avatar.mouth === 'smile' && (
+
+ )}
+ {avatar.mouth === 'grin' && (
+ <>
+
+
+ >
+ )}
+ {avatar.mouth === 'open' && (
+
+ )}
+ {avatar.mouth === 'smirk' && (
+
+ )}
+ {avatar.mouth === 'neutral' && (
+
+ )}
+ {avatar.mouth === 'tongue' && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Hair - Front layer */}
+ {avatar.hairStyle === 'short' && (
+
+ )}
+ {avatar.hairStyle === 'medium' && (
+
+ )}
+ {avatar.hairStyle === 'long' && (
+
+ )}
+ {avatar.hairStyle === 'spiky' && (
+ <>
+
+ >
+ )}
+ {avatar.hairStyle === 'curly' && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {avatar.hairStyle === 'mohawk' && (
+
+ )}
+ {avatar.hairStyle === 'ponytail' && (
+
+ )}
+
+ {/* Accessories */}
+ {avatar.accessory === 'glasses' && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {avatar.accessory === 'sunglasses' && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {avatar.accessory === 'headphones' && (
+ <>
+
+
+
+ >
+ )}
+ {avatar.accessory === 'cap' && (
+ <>
+
+
+
+ >
+ )}
+ {avatar.accessory === 'crown' && (
+ <>
+
+
+
+
+ >
+ )}
+ {avatar.accessory === 'bow' && (
+ <>
+
+
+
+ >
+ )}
+ {avatar.accessory === 'horns' && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+// Mini avatar for lists/headers
+export function AvatarMini({ data, size = 32, className = '' }) {
+ if (!data || Object.keys(data).length === 0) {
+ return (
+
+ 👤
+
+ );
+ }
+
+ return ;
+}
+
+export { AVATAR_OPTIONS, DEFAULT_AVATAR };
diff --git a/frontend/src/components/BugReportModal.css b/frontend/src/components/BugReportModal.css
new file mode 100644
index 0000000..6acfd40
--- /dev/null
+++ b/frontend/src/components/BugReportModal.css
@@ -0,0 +1,141 @@
+/* Bug Report Modal Overlay */
+.bug-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+/* Bug Report Modal Container */
+.bug-modal.bug-report-modal {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ max-width: 500px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ position: relative;
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
+}
+
+/* Close button */
+.bug-modal-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ color: #94a3b8;
+ cursor: pointer;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: all 0.2s;
+}
+
+.bug-modal-close:hover {
+ background: #f1f5f9;
+ color: #475569;
+}
+
+.bug-report-modal h2 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.bug-report-modal .modal-subtitle {
+ color: #64748b;
+ margin: 0 0 1.5rem;
+ font-size: 0.9rem;
+}
+
+.bug-report-modal .form-group {
+ margin-bottom: 1rem;
+}
+
+.bug-report-modal label {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+ color: #374151;
+}
+
+.bug-report-modal input,
+.bug-report-modal textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ font-family: inherit;
+ transition: border-color 0.2s;
+}
+
+.bug-report-modal input:focus,
+.bug-report-modal textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.bug-report-modal textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.bug-report-modal .page-info {
+ font-size: 0.8rem;
+ color: #64748b;
+ margin: 0.5rem 0 1rem;
+}
+
+.bug-report-modal .page-info code {
+ background: #f1f5f9;
+ padding: 0.2rem 0.4rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+}
+
+.bug-report-modal .modal-actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+}
+
+.bug-report-modal .error-alert {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+}
+
+.bug-success {
+ text-align: center;
+ padding: 1rem 0;
+}
+
+.bug-success .success-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.bug-success h2 {
+ margin: 0 0 0.5rem;
+}
+
+.bug-success p {
+ color: #64748b;
+ margin: 0 0 1.5rem;
+}
diff --git a/frontend/src/components/BugReportModal.jsx b/frontend/src/components/BugReportModal.jsx
new file mode 100644
index 0000000..3ee7d5d
--- /dev/null
+++ b/frontend/src/components/BugReportModal.jsx
@@ -0,0 +1,96 @@
+import { useState } from 'react';
+import api from '../api';
+import './BugReportModal.css';
+
+export default function BugReportModal({ onClose }) {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setSubmitting(true);
+
+ try {
+ await api.submitBugReport(title, description, window.location.pathname);
+ setSubmitted(true);
+ } catch (err) {
+ setError(err.message || 'Failed to submit bug report');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (submitted) {
+ return (
+
+
e.stopPropagation()}>
+
+
✅
+
Thank You!
+
Your bug report has been submitted. We'll notify you when it's fixed.
+
Close
+
+
+
+ );
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
×
+
Report a Bug
+
Help us improve GamerComp by reporting issues you find.
+
+ {error &&
{error}
}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/DevToolbar.css b/frontend/src/components/DevToolbar.css
new file mode 100644
index 0000000..b1ff943
--- /dev/null
+++ b/frontend/src/components/DevToolbar.css
@@ -0,0 +1,659 @@
+/* Floating Action Button */
+.dev-toolbar-fab {
+ position: fixed;
+ bottom: 80px;
+ right: 16px;
+ z-index: 9999;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border: none;
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
+ font-size: 24px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.dev-toolbar-fab:hover {
+ transform: scale(1.05);
+ box-shadow: 0 6px 16px rgba(99, 102, 241, 0.5);
+}
+
+.dev-toolbar-fab.expanded {
+ background: #64748b;
+}
+
+/* Panel */
+.dev-toolbar-panel {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ max-height: 70vh;
+ z-index: 9998;
+ background: white;
+ border-radius: 1rem 1rem 0 0;
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
+ display: flex;
+ flex-direction: column;
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+/* Header */
+.dev-toolbar-header {
+ padding: 12px 16px;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-radius: 1rem 1rem 0 0;
+}
+
+.dev-toolbar-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dev-toolbar-title strong {
+ font-size: 1rem;
+}
+
+.status-pill {
+ padding: 2px 8px;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ font-size: 0.75rem;
+ text-transform: capitalize;
+}
+
+.status-pill.status-testing {
+ background: rgba(251, 191, 36, 0.3);
+}
+
+.status-pill.status-draft {
+ background: rgba(148, 163, 184, 0.3);
+}
+
+.dev-toolbar-close {
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.dev-toolbar-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Tabs */
+.dev-toolbar-tabs {
+ display: flex;
+ border-bottom: 1px solid #e2e8f0;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.dev-tab {
+ flex: 1;
+ min-width: 80px;
+ padding: 10px 12px;
+ border: none;
+ background: white;
+ color: #64748b;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all 0.2s;
+}
+
+.dev-tab:hover {
+ background: #f8fafc;
+}
+
+.dev-tab.active {
+ color: #6366f1;
+ background: #f0edff;
+ border-bottom: 2px solid #6366f1;
+}
+
+/* Content */
+.dev-toolbar-content {
+ padding: 16px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.dev-tab-content {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.dev-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #374151;
+}
+
+.dev-hint {
+ font-size: 0.8rem;
+ color: #64748b;
+ margin: 0;
+}
+
+.dev-input-row {
+ display: flex;
+ gap: 8px;
+}
+
+.dev-input {
+ flex: 1;
+ padding: 10px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 0.95rem;
+}
+
+.dev-input:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.dev-textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ font-family: inherit;
+ resize: vertical;
+ min-height: 80px;
+}
+
+.dev-textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.dev-prompt {
+ background: #f8fafc;
+ padding: 12px;
+ border-radius: 8px;
+ font-size: 0.8rem;
+ font-family: monospace;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 40vh;
+ overflow-y: auto;
+ color: #475569;
+ border: 1px solid #e2e8f0;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* Mobile: ensure prompt tab content is visible */
+@media (max-width: 768px) {
+ .dev-prompt {
+ max-height: 35vh;
+ font-size: 0.75rem;
+ }
+}
+
+.dev-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.dev-actions .btn {
+ width: 100%;
+ padding: 12px;
+ border-radius: 8px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.dev-actions .btn-primary {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border: none;
+}
+
+.dev-actions .btn-primary:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.dev-actions .btn-secondary {
+ background: #f1f5f9;
+ color: #475569;
+ border: 1px solid #e2e8f0;
+}
+
+.dev-actions .btn-secondary:hover {
+ background: #e2e8f0;
+}
+
+.dev-actions .btn-danger {
+ background: white;
+ color: #dc2626;
+ border: 1px solid #fecaca;
+}
+
+.dev-actions .btn-danger:hover {
+ background: #fef2f2;
+}
+
+.dev-actions .btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.dev-error {
+ background: #fef2f2;
+ color: #dc2626;
+ padding: 10px 12px;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ border: 1px solid #fecaca;
+}
+
+.dev-success {
+ background: #f0fdf4;
+ color: #16a34a;
+ padding: 10px 12px;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ border: 1px solid #bbf7d0;
+}
+
+/* Desktop styles */
+@media (min-width: 768px) {
+ .dev-toolbar-fab {
+ bottom: 24px;
+ right: 24px;
+ }
+
+ .dev-toolbar-panel {
+ left: auto;
+ right: 24px;
+ bottom: 90px;
+ width: 380px;
+ max-height: 500px;
+ border-radius: 1rem;
+ animation: slideIn 0.3s ease;
+ }
+
+ @keyframes slideIn {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+
+ .dev-toolbar-header {
+ border-radius: 1rem 1rem 0 0;
+ }
+
+ .dev-actions {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ .dev-actions .btn {
+ flex: 1;
+ min-width: calc(50% - 5px);
+ }
+
+ .dev-actions .btn:last-child {
+ flex: none;
+ width: 100%;
+ }
+}
+
+/* Testing Timer */
+.dev-new-game-banner {
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ color: white;
+ padding: 10px 14px;
+ border-radius: 8px;
+ font-weight: 500;
+ text-align: center;
+ margin-bottom: 12px;
+}
+
+.dev-testing-timer {
+ background: #f8fafc;
+ border-radius: 10px;
+ padding: 14px;
+ margin-bottom: 12px;
+ border: 1px solid #e2e8f0;
+}
+
+.timer-display {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.timer-icon {
+ font-size: 1.2rem;
+}
+
+.timer-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ font-family: 'Monaco', 'Menlo', monospace;
+ color: #6366f1;
+}
+
+.timer-label {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.timer-progress {
+ height: 8px;
+ background: #e2e8f0;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.timer-progress-bar {
+ height: 100%;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border-radius: 4px;
+ transition: width 1s linear;
+}
+
+.btn-disabled {
+ background: #94a3b8;
+ color: white;
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 0.85rem;
+}
+
+/* Code Editor Section */
+.dev-code-section {
+ margin-top: 0.5rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid #e2e8f0;
+}
+
+.dev-code-btn {
+ width: 100%;
+ margin-top: 0.25rem;
+}
+
+/* Code Editor Modal */
+.code-editor-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 1rem;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.code-editor-modal {
+ background: #1e293b;
+ border-radius: 12px;
+ width: 100%;
+ max-width: 900px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
+ animation: slideUp 0.25s ease;
+}
+
+@keyframes slideUp {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+.code-editor-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem 1.25rem;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border-radius: 12px 12px 0 0;
+}
+
+.code-editor-header h3 {
+ margin: 0;
+ color: white;
+ font-size: 1.1rem;
+}
+
+.code-editor-close {
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ font-size: 1.5rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
+
+.code-editor-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.code-editor-body {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+}
+
+.code-editor-textarea {
+ width: 100%;
+ flex: 1;
+ padding: 1rem;
+ background: #0f172a;
+ color: #e2e8f0;
+ border: none;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ resize: none;
+ min-height: 400px;
+}
+
+.code-editor-textarea:focus {
+ outline: none;
+}
+
+.code-editor-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ background: #1e293b;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 0 0 12px 12px;
+}
+
+.code-editor-info {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.8rem;
+ color: #64748b;
+}
+
+.code-modified {
+ color: #fbbf24;
+ font-weight: 500;
+}
+
+.code-editor-buttons {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.code-editor-buttons .btn {
+ padding: 0.5rem 1rem;
+}
+
+/* Code Confirm Modal */
+.code-confirm-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10001;
+ padding: 1rem;
+}
+
+.code-confirm-modal {
+ background: linear-gradient(145deg, #1e293b, #0f172a);
+ border: 1px solid rgba(99, 102, 241, 0.3);
+ border-radius: 16px;
+ padding: 1.5rem;
+ max-width: 400px;
+ width: 100%;
+ text-align: center;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+.code-confirm-modal h3 {
+ margin: 0 0 1rem 0;
+ color: #fff;
+}
+
+.code-confirm-summary {
+ color: #94a3b8;
+ line-height: 1.5;
+ margin: 0 0 1rem 0;
+}
+
+.code-confirm-stats {
+ display: flex;
+ justify-content: center;
+ gap: 1.5rem;
+ font-size: 0.85rem;
+ color: #64748b;
+ margin-bottom: 1.25rem;
+}
+
+.code-confirm-buttons {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.code-confirm-buttons .btn {
+ flex: 1;
+}
+
+/* Mobile adjustments for code editor */
+@media (max-width: 768px) {
+ .code-editor-modal {
+ max-height: 95vh;
+ border-radius: 8px;
+ }
+
+ .code-editor-header {
+ border-radius: 8px 8px 0 0;
+ }
+
+ .code-editor-textarea {
+ font-size: 0.8rem;
+ min-height: 300px;
+ }
+
+ .code-editor-footer {
+ flex-direction: column;
+ gap: 0.75rem;
+ border-radius: 0 0 8px 8px;
+ }
+
+ .code-editor-buttons {
+ width: 100%;
+ }
+
+ .code-editor-buttons .btn {
+ flex: 1;
+ }
+}
+
+/* Ultra-small screens */
+@media (max-width: 360px) {
+ .dev-toolbar-fab {
+ width: 48px;
+ height: 48px;
+ font-size: 20px;
+ bottom: 70px;
+ right: 10px;
+ }
+
+ .dev-toolbar-panel {
+ max-height: 75vh;
+ }
+
+ .dev-tab {
+ padding: 8px 10px;
+ font-size: 0.8rem;
+ min-width: 70px;
+ }
+
+ .dev-toolbar-content {
+ padding: 12px;
+ }
+
+ .code-confirm-buttons {
+ flex-direction: column;
+ }
+}
diff --git a/frontend/src/components/DevToolbar.jsx b/frontend/src/components/DevToolbar.jsx
new file mode 100644
index 0000000..5030d92
--- /dev/null
+++ b/frontend/src/components/DevToolbar.jsx
@@ -0,0 +1,553 @@
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import api from '../api';
+import TweakPanel from './TweakPanel';
+import './DevToolbar.css';
+
+// Floating Developer Toolbar for game creators to test/edit their games
+export default function DevToolbar({ game, user, onGameUpdate }) {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const isNewGame = searchParams.get('newGame') === 'true';
+
+ const [isExpanded, setIsExpanded] = useState(isNewGame);
+ const [activeTab, setActiveTab] = useState(isNewGame ? 'testing' : 'actions');
+ const [title, setTitle] = useState(game?.title || '');
+ const [feedback, setFeedback] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+
+ // Code editor state
+ const [showCodeEditor, setShowCodeEditor] = useState(false);
+ const [editedCode, setEditedCode] = useState('');
+ const [codeReviewing, setCodeReviewing] = useState(false);
+ const [codeSaving, setCodeSaving] = useState(false);
+ const [codeReviewResult, setCodeReviewResult] = useState(null);
+ const [showCodeConfirm, setShowCodeConfirm] = useState(false);
+
+ // Testing state
+ const [testingSeconds, setTestingSeconds] = useState(game?.creatorPlayDuration || 0);
+ const [isTestingActive, setIsTestingActive] = useState(false);
+ const testingIntervalRef = useRef(null);
+ const REQUIRED_TEST_TIME = 60; // 1 minute
+
+ // Don't render if not the creator or game is published
+ if (!user || !game) return null;
+ if (user.id !== game.creatorId) return null;
+ if (game.status === 'published' || game.status === 'pending') return null;
+
+ const canPublish = testingSeconds >= REQUIRED_TEST_TIME || game.creatorPlayed;
+
+ // Start testing timer when component mounts for new games
+ useEffect(() => {
+ if (isNewGame && !isTestingActive) {
+ startTesting();
+ }
+ return () => {
+ if (testingIntervalRef.current) {
+ clearInterval(testingIntervalRef.current);
+ }
+ };
+ }, [isNewGame]);
+
+ const startTesting = () => {
+ if (isTestingActive) return;
+ setIsTestingActive(true);
+ testingIntervalRef.current = setInterval(() => {
+ setTestingSeconds(prev => prev + 1);
+ }, 1000);
+ };
+
+ const stopTesting = () => {
+ setIsTestingActive(false);
+ if (testingIntervalRef.current) {
+ clearInterval(testingIntervalRef.current);
+ testingIntervalRef.current = null;
+ }
+ };
+
+ // Save testing progress periodically
+ useEffect(() => {
+ if (testingSeconds > 0 && testingSeconds % 10 === 0) {
+ // Save every 10 seconds
+ api.trackTesting(game.id, testingSeconds, 0).catch(() => {});
+ }
+ }, [testingSeconds, game.id]);
+
+ const handleSaveTitle = async () => {
+ if (!title.trim()) return;
+ setSaving(true);
+ setError('');
+ try {
+ await api.updateGame(game.id, { title });
+ setSuccess('Title saved!');
+ setTimeout(() => setSuccess(''), 2000);
+ if (onGameUpdate) onGameUpdate({ ...game, title });
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Code editor handlers
+ const handleOpenCodeEditor = () => {
+ setEditedCode(game?.game_code || '');
+ setShowCodeEditor(true);
+ setCodeReviewResult(null);
+ };
+
+ const handleCloseCodeEditor = () => {
+ setShowCodeEditor(false);
+ setEditedCode('');
+ setCodeReviewResult(null);
+ setShowCodeConfirm(false);
+ };
+
+ const handleReviewCode = async () => {
+ if (!editedCode.trim() || editedCode === game?.game_code) {
+ setError('No changes detected');
+ return;
+ }
+ setCodeReviewing(true);
+ setError('');
+ try {
+ const result = await api.reviewCodeChanges(game.id, editedCode);
+ setCodeReviewResult(result);
+ setShowCodeConfirm(true);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setCodeReviewing(false);
+ }
+ };
+
+ const handleSaveCode = async () => {
+ setCodeSaving(true);
+ setError('');
+ try {
+ await api.saveGameCode(game.id, editedCode);
+ setShowCodeEditor(false);
+ setShowCodeConfirm(false);
+ setSuccess('Code saved! Reloading...');
+ setTimeout(() => window.location.reload(), 1500);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setCodeSaving(false);
+ }
+ };
+
+ const handleCancelCodeConfirm = () => {
+ setShowCodeConfirm(false);
+ setCodeReviewResult(null);
+ };
+
+ const [refineProgress, setRefineProgress] = useState('');
+ const [refinePercent, setRefinePercent] = useState(0);
+
+ const handleRefine = async () => {
+ if (!feedback.trim()) {
+ setError('Please describe what you want to change');
+ return;
+ }
+ setSaving(true);
+ setError('');
+ setRefineProgress('Starting AI update...');
+ setRefinePercent(0);
+ stopTesting();
+
+ try {
+ // This now returns immediately
+ await api.refineGame(game.id, feedback);
+
+ // Open SSE for progress
+ const es = api.streamGeneration(game.id, (event) => {
+ if (event.type === 'phase') {
+ setRefineProgress(event.message);
+ setRefinePercent(event.progress || 0);
+ } else if (event.type === 'progress') {
+ setRefinePercent(event.progress || 0);
+ } else if (event.type === 'complete') {
+ setRefineProgress('');
+ setRefinePercent(0);
+ setSaving(false);
+ setSuccess(event.summary ? `Done! ${event.summary}` : 'Game updated! Reloading...');
+ setFeedback('');
+ es.close();
+ setTimeout(() => window.location.reload(), 1500);
+ } else if (event.type === 'error') {
+ setRefineProgress('');
+ setRefinePercent(0);
+ setSaving(false);
+ setError(event.error || 'Update failed. Try again.');
+ es.close();
+ }
+ });
+ } catch (err) {
+ setSaving(false);
+ setRefineProgress('');
+ setError(err.message);
+ }
+ };
+
+ const handlePublish = async () => {
+ if (!canPublish) {
+ setError(`Please test your game for at least ${REQUIRED_TEST_TIME - testingSeconds} more seconds.`);
+ return;
+ }
+ stopTesting();
+ // Save final testing time
+ await api.trackTesting(game.id, testingSeconds, 0).catch(() => {});
+ navigate(`/publish/${game.id}`);
+ };
+
+ const handleSubmit = async () => {
+ if (!canPublish) {
+ setError(`Please test your game for at least ${REQUIRED_TEST_TIME - testingSeconds} more seconds.`);
+ return;
+ }
+ if (!confirm('Submit this game for review?')) return;
+ setSaving(true);
+ stopTesting();
+ try {
+ await api.trackTesting(game.id, testingSeconds, 0);
+ await api.submitGame(game.id);
+ setSuccess('Submitted for review!');
+ setTimeout(() => navigate('/dashboard'), 1500);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!confirm('Are you sure you want to delete this game? This cannot be undone.')) return;
+ setSaving(true);
+ stopTesting();
+ try {
+ await api.deleteGame(game.id);
+ navigate('/dashboard');
+ } catch (err) {
+ setError(err.message);
+ setSaving(false);
+ }
+ };
+
+ const formatTime = (seconds) => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${String(secs).padStart(2, '0')}`;
+ };
+
+ const tabs = [
+ { id: 'testing', label: 'Testing' },
+ { id: 'edit', label: 'Edit' },
+ { id: 'tweaks', label: 'Free Tweaks' },
+ { id: 'refine', label: 'AI Overhaul' },
+ { id: 'prompt', label: 'Prompt' }
+ ];
+
+ return (
+ <>
+ {/* Floating button - always visible */}
+ setIsExpanded(!isExpanded)}
+ aria-label={isExpanded ? 'Close dev tools' : 'Open dev tools'}
+ >
+ {isExpanded ? '✕' : '🛠️'}
+
+
+ {/* Expanded panel */}
+ {isExpanded && (
+
+ {/* Header */}
+
+
+ Dev Tools
+
+ {game.status}
+
+
+
setIsExpanded(false)}
+ >
+ ✕
+
+
+
+ {/* Tabs */}
+
+ {tabs.map(tab => (
+ setActiveTab(tab.id)}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* Tab content */}
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+ {/* Testing Tab */}
+ {activeTab === 'testing' && (
+
+ {isNewGame && (
+
+ Your game is ready! Play it to make sure it works.
+
+ )}
+
+
+
+ {isTestingActive ? '⏱️' : '⏸️'}
+ {formatTime(testingSeconds)}
+ / {formatTime(REQUIRED_TEST_TIME)} required
+
+
+
+
+ {!isTestingActive && testingSeconds < REQUIRED_TEST_TIME && (
+
+ Resume Timer
+
+ )}
+
+
+
+ {canPublish
+ ? '✅ You can now publish your game!'
+ : `Play your game for ${REQUIRED_TEST_TIME - testingSeconds} more seconds to unlock publishing.`
+ }
+
+
+
+
+ {canPublish ? 'Publish Game! 🚀' : `Test ${REQUIRED_TEST_TIME - testingSeconds}s more`}
+
+ navigate('/dashboard')}
+ >
+ Save & Exit
+
+
+ Delete Game
+
+
+
+ )}
+
+ {/* Edit Tab */}
+ {activeTab === 'edit' && (
+
+
Game Title
+
+ setTitle(e.target.value)}
+ className="dev-input"
+ maxLength={100}
+ />
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+
+
+
Game Code
+
+ View & Edit Code
+
+
+ Advanced: Manually view and edit the game's HTML/JavaScript code.
+
+
+
+ )}
+
+ {/* Free Tweaks Tab */}
+ {activeTab === 'tweaks' && (
+
+ window.location.reload()}
+ onFallbackNeeded={(tweakFeedback) => {
+ setFeedback(tweakFeedback || '');
+ setActiveTab('refine');
+ }}
+ />
+
+ )}
+
+ {/* Refine Tab */}
+ {activeTab === 'refine' && (
+
+
Describe what you want to change
+
setFeedback(e.target.value)}
+ className="dev-textarea"
+ placeholder="e.g., 'Make the player faster' or 'Add more enemies' or 'Change colors to blue'"
+ rows={4}
+ disabled={saving}
+ />
+ {refineProgress && (
+
+ )}
+
+ {saving ? `Updating... ${refinePercent}%` : 'Update with AI'}
+
+
+ {saving
+ ? 'You can close this page - you\'ll get a notification when done!'
+ : 'The AI will modify your game based on your feedback.'}
+
+
+ )}
+
+ {/* Prompt Tab */}
+ {activeTab === 'prompt' && (
+
+
+ {game.promptWasEdited ? '✏️ Edited Prompt' : 'Original Prompt'}
+
+ {game.creationPrompt || game.prompt ? (
+
+ {game.creationPrompt || game.prompt}
+
+ ) : (
+
No prompt data available for this game.
+ )}
+ {game.promptWasEdited && game.originalPrompt && (
+ <>
+
+ Original Auto-Generated Prompt
+
+
+ {game.originalPrompt}
+
+ >
+ )}
+
+ )}
+
+
+ )}
+
+ {/* Code Editor Modal */}
+ {showCodeEditor && (
+
+
e.stopPropagation()}>
+
+
Edit Game Code
+
+ ×
+
+
+
+
+ setEditedCode(e.target.value)}
+ spellCheck={false}
+ placeholder="Game HTML/JavaScript code..."
+ />
+
+
+
+
+ {editedCode.length.toLocaleString()} characters
+ {editedCode !== game?.game_code && (
+ Modified
+ )}
+
+
+
+ Cancel
+
+
+ {codeReviewing ? 'Reviewing...' : 'Review & Save'}
+
+
+
+
+
+ )}
+
+ {/* Code Review Confirmation Modal */}
+ {showCodeConfirm && codeReviewResult && (
+
+
e.stopPropagation()}>
+
Review Changes
+
{codeReviewResult.summary}
+
+ Before: {codeReviewResult.oldCodeLength?.toLocaleString()} chars
+ After: {codeReviewResult.newCodeLength?.toLocaleString()} chars
+
+
+
+ Keep Editing
+
+
+ {codeSaving ? 'Saving...' : 'Save Changes'}
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/FeatureTooltip.css b/frontend/src/components/FeatureTooltip.css
new file mode 100644
index 0000000..cea2112
--- /dev/null
+++ b/frontend/src/components/FeatureTooltip.css
@@ -0,0 +1,173 @@
+.tooltip-wrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.feature-tooltip {
+ position: absolute;
+ z-index: 1000;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s, visibility 0.2s, transform 0.2s;
+ pointer-events: none;
+}
+
+.feature-tooltip.visible {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+/* Positioning */
+.feature-tooltip.bottom {
+ top: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%) translateY(-5px);
+}
+
+.feature-tooltip.bottom.visible {
+ transform: translateX(-50%) translateY(0);
+}
+
+.feature-tooltip.top {
+ bottom: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%) translateY(5px);
+}
+
+.feature-tooltip.top.visible {
+ transform: translateX(-50%) translateY(0);
+}
+
+.feature-tooltip.left {
+ right: calc(100% + 10px);
+ top: 50%;
+ transform: translateY(-50%) translateX(5px);
+}
+
+.feature-tooltip.left.visible {
+ transform: translateY(-50%) translateX(0);
+}
+
+.feature-tooltip.right {
+ left: calc(100% + 10px);
+ top: 50%;
+ transform: translateY(-50%) translateX(-5px);
+}
+
+.feature-tooltip.right.visible {
+ transform: translateY(-50%) translateX(0);
+}
+
+/* Content */
+.tooltip-content {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ padding: 0.875rem 1rem;
+ border-radius: 0.75rem;
+ box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
+ min-width: 180px;
+ max-width: 280px;
+ text-align: center;
+}
+
+.tooltip-text {
+ margin: 0 0 0.75rem;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.tooltip-dismiss {
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ padding: 0.4rem 1rem;
+ border-radius: 1rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.tooltip-dismiss:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Arrow */
+.tooltip-arrow {
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ background: #6366f1;
+ transform: rotate(45deg);
+}
+
+.feature-tooltip.bottom .tooltip-arrow {
+ top: -6px;
+ left: 50%;
+ margin-left: -6px;
+}
+
+.feature-tooltip.top .tooltip-arrow {
+ bottom: -6px;
+ left: 50%;
+ margin-left: -6px;
+}
+
+.feature-tooltip.left .tooltip-arrow {
+ right: -6px;
+ top: 50%;
+ margin-top: -6px;
+}
+
+.feature-tooltip.right .tooltip-arrow {
+ left: -6px;
+ top: 50%;
+ margin-top: -6px;
+}
+
+/* Pulsing highlight */
+.pulsing-highlight-wrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.pulsing-ring {
+ position: absolute;
+ top: -4px;
+ left: -4px;
+ right: -4px;
+ bottom: -4px;
+ border: 2px solid #6366f1;
+ border-radius: 0.75rem;
+ animation: pulse-ring 1.5s ease-out infinite;
+ pointer-events: none;
+}
+
+@keyframes pulse-ring {
+ 0% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.05);
+ opacity: 0.8;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+/* Mobile adjustments */
+@media (max-width: 480px) {
+ .tooltip-content {
+ min-width: 150px;
+ max-width: 220px;
+ padding: 0.75rem;
+ }
+
+ .tooltip-text {
+ font-size: 0.85rem;
+ }
+}
diff --git a/frontend/src/components/FeatureTooltip.jsx b/frontend/src/components/FeatureTooltip.jsx
new file mode 100644
index 0000000..ff84dcc
--- /dev/null
+++ b/frontend/src/components/FeatureTooltip.jsx
@@ -0,0 +1,165 @@
+import { useState, useEffect, useRef } from 'react';
+import './FeatureTooltip.css';
+
+const TOOLTIP_STORAGE_KEY = 'gamercomp_tooltips_seen';
+
+// Get which tooltips have been dismissed
+const getSeenTooltips = () => {
+ try {
+ return JSON.parse(localStorage.getItem(TOOLTIP_STORAGE_KEY) || '[]');
+ } catch {
+ return [];
+ }
+};
+
+// Mark a tooltip as seen
+const markTooltipSeen = (tooltipId) => {
+ const seen = getSeenTooltips();
+ if (!seen.includes(tooltipId)) {
+ seen.push(tooltipId);
+ localStorage.setItem(TOOLTIP_STORAGE_KEY, JSON.stringify(seen));
+ }
+};
+
+// Check if tooltip has been seen
+const hasSeenTooltip = (tooltipId) => {
+ return getSeenTooltips().includes(tooltipId);
+};
+
+export default function FeatureTooltip({
+ id,
+ children,
+ position = 'bottom',
+ delay = 500,
+ showOnce = true,
+ forceShow = false
+}) {
+ const [visible, setVisible] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ // Check if already seen
+ if (showOnce && hasSeenTooltip(id)) {
+ setDismissed(true);
+ return;
+ }
+
+ // Show tooltip after delay
+ if (forceShow || !dismissed) {
+ timeoutRef.current = setTimeout(() => {
+ setVisible(true);
+ }, delay);
+ }
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [id, delay, showOnce, forceShow, dismissed]);
+
+ const handleDismiss = () => {
+ setVisible(false);
+ setDismissed(true);
+ if (showOnce) {
+ markTooltipSeen(id);
+ }
+ };
+
+ if (dismissed && !forceShow) {
+ return null;
+ }
+
+ return (
+
+
+
{children}
+
+ Got it!
+
+
+
+
+ );
+}
+
+// Wrapper component that shows tooltip attached to a target
+export function TooltipWrapper({
+ tooltipId,
+ tooltip,
+ position = 'bottom',
+ delay = 500,
+ showOnce = true,
+ children
+}) {
+ const [visible, setVisible] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ if (showOnce && hasSeenTooltip(tooltipId)) {
+ setDismissed(true);
+ return;
+ }
+
+ timeoutRef.current = setTimeout(() => {
+ setVisible(true);
+ }, delay);
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [tooltipId, delay, showOnce]);
+
+ const handleDismiss = (e) => {
+ e.stopPropagation();
+ setVisible(false);
+ setDismissed(true);
+ if (showOnce) {
+ markTooltipSeen(tooltipId);
+ }
+ };
+
+ if (dismissed) {
+ return children;
+ }
+
+ return (
+
+ {children}
+ {visible && (
+
+
+
{tooltip}
+
+ Got it!
+
+
+
+
+ )}
+
+ );
+}
+
+// Simple pulsing highlight for elements
+export function PulsingHighlight({ active, children }) {
+ if (!active) {
+ return children;
+ }
+
+ return (
+
+ );
+}
+
+// Utility to reset all seen tooltips (for testing)
+export const resetSeenTooltips = () => {
+ localStorage.removeItem(TOOLTIP_STORAGE_KEY);
+};
diff --git a/frontend/src/components/Footer.css b/frontend/src/components/Footer.css
new file mode 100644
index 0000000..672d1c7
--- /dev/null
+++ b/frontend/src/components/Footer.css
@@ -0,0 +1,118 @@
+.site-footer {
+ background: #1e293b;
+ color: #94a3b8;
+ padding: 3rem 1rem 1.5rem;
+ margin-top: auto;
+}
+
+.footer-content {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.footer-brand {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.footer-logo {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: white;
+}
+
+.footer-tagline {
+ margin: 0.5rem 0 0;
+ font-size: 0.9rem;
+}
+
+.footer-links {
+ display: flex;
+ justify-content: center;
+ gap: 4rem;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+}
+
+.footer-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.footer-section h4 {
+ color: white;
+ font-size: 0.9rem;
+ margin: 0 0 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.footer-section a {
+ color: #94a3b8;
+ text-decoration: none;
+ font-size: 0.9rem;
+ transition: color 0.2s;
+}
+
+.footer-section a:hover {
+ color: white;
+}
+
+.footer-link-btn {
+ background: none;
+ border: none;
+ color: #94a3b8;
+ text-decoration: none;
+ font-size: 0.9rem;
+ font-family: inherit;
+ cursor: pointer;
+ padding: 0;
+ text-align: left;
+ transition: color 0.2s;
+}
+
+.footer-link-btn:hover {
+ color: white;
+}
+
+.footer-install-btn {
+ color: #60a5fa;
+}
+
+.footer-install-btn:hover {
+ color: #93c5fd;
+}
+
+.footer-bottom {
+ text-align: center;
+ padding-top: 1.5rem;
+ border-top: 1px solid #334155;
+}
+
+.footer-copyright {
+ margin: 0 0 0.25rem;
+ font-size: 0.85rem;
+}
+
+.footer-coppa {
+ margin: 0;
+ font-size: 0.8rem;
+ color: #64748b;
+}
+
+@media (max-width: 640px) {
+ .site-footer {
+ padding: 2rem 1rem 1rem;
+ }
+
+ .footer-links {
+ flex-direction: column;
+ gap: 1.5rem;
+ text-align: center;
+ }
+
+ .footer-section {
+ align-items: center;
+ }
+}
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx
new file mode 100644
index 0000000..628b56f
--- /dev/null
+++ b/frontend/src/components/Footer.jsx
@@ -0,0 +1,97 @@
+import { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import BugReportModal from './BugReportModal';
+import './Footer.css';
+
+export default function Footer() {
+ const { isAuthenticated, isGuest } = useAuth();
+ const [showBugModal, setShowBugModal] = useState(false);
+ const [installPrompt, setInstallPrompt] = useState(null);
+ const [showInstallOption, setShowInstallOption] = useState(false);
+
+ const canReportBugs = isAuthenticated;
+
+ // PWA install prompt handler
+ useEffect(() => {
+ const handleBeforeInstall = (e) => {
+ e.preventDefault();
+ setInstallPrompt(e);
+ if (!window.matchMedia('(display-mode: standalone)').matches) {
+ setShowInstallOption(true);
+ }
+ };
+
+ window.addEventListener('beforeinstallprompt', handleBeforeInstall);
+
+ if (window.matchMedia('(display-mode: standalone)').matches) {
+ setShowInstallOption(false);
+ }
+
+ return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
+ }, []);
+
+ const handleInstallApp = async () => {
+ if (!installPrompt) return;
+ installPrompt.prompt();
+ const { outcome } = await installPrompt.userChoice;
+ if (outcome === 'accepted') {
+ setShowInstallOption(false);
+ }
+ setInstallPrompt(null);
+ };
+
+ return (
+
+
+
+
🎮 GamerComp
+
Gamer Composer - Where everyone composes games with AI
+
+
+
+
+
Play
+ Arcade
+ Create a Game
+
+
+
+
Account
+ Login
+ Sign Up
+ Dashboard
+
+
+
+
Help
+ Terms of Service
+ Privacy Policy
+ Release Notes
+ {canReportBugs && (
+ setShowBugModal(true)}>
+ Report a Bug
+
+ )}
+ {showInstallOption && (
+
+ 📲 Install App
+
+ )}
+
+
+
+
+
+ © {new Date().getFullYear()} GamerComp. All games are open source under GPL.
+
+
+ Designed for ages 8-16. COPPA compliant.
+
+
+
+
+ {showBugModal && setShowBugModal(false)} />}
+
+ );
+}
diff --git a/frontend/src/components/GameCard.css b/frontend/src/components/GameCard.css
new file mode 100644
index 0000000..159ae89
--- /dev/null
+++ b/frontend/src/components/GameCard.css
@@ -0,0 +1,413 @@
+.game-card {
+ background: #1e1b4b;
+ border-radius: 1rem;
+ overflow: hidden;
+ text-decoration: none;
+ color: inherit;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ transition: all 0.2s ease;
+ display: block;
+ -webkit-tap-highlight-color: transparent;
+ aspect-ratio: 16 / 10;
+}
+
+.game-card-inner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* Hover styles only for devices that support hover */
+@media (hover: hover) {
+ .game-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
+ }
+
+ .game-card:hover .play-overlay {
+ opacity: 1;
+ }
+
+ .game-card:hover .play-icon {
+ transform: scale(1.1);
+ }
+}
+
+/* Touch/active states for touch devices */
+@media (hover: none) {
+ .game-card:active {
+ transform: scale(0.98);
+ }
+
+ .game-card.touch-preview {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
+ }
+
+ .game-card.touch-preview .play-overlay {
+ opacity: 1;
+ }
+
+ .game-card.touch-preview .play-icon {
+ transform: scale(1.1);
+ }
+}
+
+/* Focus states for accessibility */
+.game-card:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+ transform: translateY(-4px);
+}
+
+/* Live preview iframe - fills entire card */
+.preview-iframe {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: none;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s;
+ background: #1e1b4b;
+}
+
+.preview-iframe.loaded {
+ opacity: 1;
+}
+
+/* Fallback background when no preview */
+.card-background {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #a855f7 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.card-background::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
+ animation: shimmer 3s ease-in-out infinite;
+}
+
+@keyframes shimmer {
+ 0%, 100% { transform: translate(-30%, -30%); }
+ 50% { transform: translate(30%, 30%); }
+}
+
+.bg-icon {
+ font-size: 3rem;
+ opacity: 0.5;
+ position: relative;
+ z-index: 1;
+}
+
+/* Loading indicator */
+.preview-loading {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, #312e81 0%, #4c1d95 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+}
+
+.loading-spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid rgba(255, 255, 255, 0.2);
+ border-top-color: #a78bfa;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Gradient overlay for text readability */
+.card-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(
+ 180deg,
+ rgba(0, 0, 0, 0.3) 0%,
+ transparent 30%,
+ transparent 50%,
+ rgba(0, 0, 0, 0.6) 100%
+ );
+ pointer-events: none;
+ z-index: 2;
+}
+
+/* Content layer - overlaid on top */
+.card-content {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 0.75rem;
+ z-index: 3;
+ pointer-events: none;
+}
+
+.card-content > * {
+ pointer-events: auto;
+}
+
+/* Top section - badges */
+.card-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.featured-badge {
+ background: #f59e0b;
+ color: white;
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 0.2rem 0.5rem;
+ border-radius: 0.25rem;
+ text-transform: uppercase;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
+}
+
+.difficulty-badges {
+ display: flex;
+ gap: 0.25rem;
+ margin-left: auto;
+}
+
+.difficulty-badge {
+ font-size: 0.6rem;
+ font-weight: 600;
+ padding: 0.15rem 0.35rem;
+ border-radius: 0.2rem;
+ text-transform: capitalize;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+}
+
+.difficulty-easy {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.difficulty-medium {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.difficulty-hard {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+/* Bottom section - game info */
+.card-bottom {
+ text-shadow: 0 1px 3px rgba(0,0,0,0.5);
+}
+
+.game-title {
+ font-size: 1rem;
+ font-weight: 700;
+ margin: 0 0 0.15rem;
+ color: white;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-creator {
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.85);
+ margin: 0 0 0.35rem;
+}
+
+.game-stats {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.stat {
+ display: flex;
+ align-items: center;
+ gap: 0.2rem;
+ font-size: 0.8rem;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.stat-icon {
+ font-size: 0.75rem;
+}
+
+.rating-count {
+ font-size: 0.65rem;
+ color: rgba(255, 255, 255, 0.7);
+ margin-left: 0.1rem;
+}
+
+/* Favorite button */
+.favorite-btn {
+ background: rgba(255, 255, 255, 0.15);
+ border: none;
+ padding: 0.2rem 0.4rem;
+ cursor: pointer;
+ border-radius: 0.4rem;
+ transition: all 0.2s;
+ margin-left: auto;
+ backdrop-filter: blur(4px);
+}
+
+.favorite-btn:hover {
+ background: rgba(255, 255, 255, 0.25);
+}
+
+.favorite-btn.favorited {
+ background: rgba(239, 68, 68, 0.3);
+}
+
+.favorite-btn .stat-icon {
+ font-size: 0.9rem;
+}
+
+/* Play overlay on hover/touch */
+.play-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.2s;
+ pointer-events: none;
+}
+
+.play-overlay.visible {
+ opacity: 1;
+}
+
+.play-icon {
+ width: 50px;
+ height: 50px;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.25rem;
+ color: #6366f1;
+ padding-left: 4px;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+ transition: transform 0.2s;
+}
+
+.play-icon::before {
+ content: '\25B6';
+}
+
+.play-icon.active {
+ width: auto;
+ padding: 0.75rem 1.25rem;
+ border-radius: 2rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.play-icon.active::before {
+ content: '';
+}
+
+/* Touch hint for mobile */
+.touch-hint {
+ position: absolute;
+ bottom: 3.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ font-size: 0.65rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 0.25rem;
+ z-index: 4;
+ pointer-events: none;
+}
+
+@media (hover: hover) {
+ .touch-hint {
+ display: none;
+ }
+}
+
+/* Mobile adjustments */
+@media (max-width: 480px) {
+ .game-card {
+ aspect-ratio: 16 / 9;
+ }
+
+ .card-content {
+ padding: 0.5rem;
+ }
+
+ .game-title {
+ font-size: 0.9rem;
+ }
+
+ .game-creator {
+ font-size: 0.7rem;
+ }
+
+ .stat {
+ font-size: 0.75rem;
+ }
+
+ .play-icon {
+ width: 44px;
+ height: 44px;
+ font-size: 1rem;
+ }
+
+ .play-icon.active {
+ font-size: 0.75rem;
+ padding: 0.6rem 1rem;
+ }
+}
+
+/* Large touch targets */
+@media (pointer: coarse) {
+ .favorite-btn {
+ padding: 0.3rem 0.5rem;
+ min-height: 32px;
+ }
+}
diff --git a/frontend/src/components/GameCard.jsx b/frontend/src/components/GameCard.jsx
new file mode 100644
index 0000000..7b62c92
--- /dev/null
+++ b/frontend/src/components/GameCard.jsx
@@ -0,0 +1,295 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { Link } from 'react-router-dom';
+import './GameCard.css';
+
+export default function GameCard({ game, onToggleFavorite = null, showCreator = true, showPreview = false, gameCode = null }) {
+ const [isHovering, setIsHovering] = useState(false);
+ const [isTouchPreview, setIsTouchPreview] = useState(false);
+ const [localFavorited, setLocalFavorited] = useState(game.isFavorited);
+ const [localFavoriteCount, setLocalFavoriteCount] = useState(game.favoriteCount || 0);
+ const [previewLoaded, setPreviewLoaded] = useState(false);
+ const iframeRef = useRef(null);
+ const avgRating = game.averageRating ? game.averageRating.toFixed(1) : '-';
+
+ const handleFavoriteClick = async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!onToggleFavorite) return;
+
+ const newFavorited = !localFavorited;
+ setLocalFavorited(newFavorited);
+ setLocalFavoriteCount(prev => newFavorited ? prev + 1 : Math.max(0, prev - 1));
+
+ try {
+ await onToggleFavorite(game.id);
+ } catch (error) {
+ setLocalFavorited(!newFavorited);
+ setLocalFavoriteCount(prev => newFavorited ? prev - 1 : prev + 1);
+ }
+ };
+
+ const supportsHover = typeof window !== 'undefined' &&
+ window.matchMedia('(hover: hover)').matches;
+
+ useEffect(() => {
+ setIsTouchPreview(false);
+ setPreviewLoaded(false);
+ }, [game.id]);
+
+ // Load preview iframe with muted audio
+ useEffect(() => {
+ if (!showPreview || !gameCode || !iframeRef.current) return;
+
+ const iframe = iframeRef.current;
+
+ // Inject code that mutes audio AND auto-plays the game
+ const mutedCode = gameCode.replace(
+ '',
+ ``
+ );
+
+ const blob = new Blob([mutedCode], { type: 'text/html' });
+ const url = URL.createObjectURL(blob);
+ iframe.src = url;
+
+ const handleLoad = () => {
+ setPreviewLoaded(true);
+ URL.revokeObjectURL(url);
+ };
+
+ iframe.addEventListener('load', handleLoad);
+ return () => {
+ iframe.removeEventListener('load', handleLoad);
+ URL.revokeObjectURL(url);
+ };
+ }, [showPreview, gameCode]);
+
+ const handleTouchStart = useCallback((e) => {
+ if (supportsHover) return;
+ if (!isTouchPreview) {
+ e.preventDefault();
+ setIsTouchPreview(true);
+ }
+ }, [supportsHover, isTouchPreview]);
+
+ const handleClick = useCallback((e) => {
+ if (!supportsHover && !isTouchPreview) {
+ e.preventDefault();
+ setIsTouchPreview(true);
+ }
+ }, [supportsHover, isTouchPreview]);
+
+ useEffect(() => {
+ if (!isTouchPreview) return;
+
+ const handleOutsideClick = (e) => {
+ if (!e.target.closest('.game-card')) {
+ setIsTouchPreview(false);
+ }
+ };
+
+ document.addEventListener('touchstart', handleOutsideClick);
+ document.addEventListener('click', handleOutsideClick);
+
+ return () => {
+ document.removeEventListener('touchstart', handleOutsideClick);
+ document.removeEventListener('click', handleOutsideClick);
+ };
+ }, [isTouchPreview]);
+
+ const showOverlay = isHovering || isTouchPreview;
+ const hasPreview = showPreview && gameCode;
+
+ return (
+ supportsHover && setIsHovering(true)}
+ onMouseLeave={() => supportsHover && setIsHovering(false)}
+ onTouchStart={handleTouchStart}
+ onClick={handleClick}
+ >
+
+ {/* Live preview iframe - fills entire card */}
+ {hasPreview && (
+
+ )}
+
+ {/* Fallback/loading background when no preview */}
+ {!hasPreview && (
+
+ 🎮
+
+ )}
+
+ {/* Loading indicator */}
+ {hasPreview && !previewLoaded && (
+
+ )}
+
+ {/* Overlay gradient for text readability */}
+
+
+ {/* All info overlaid on top */}
+
+ {/* Top badges */}
+
+ {game.isFeatured &&
Featured }
+ {game.difficultyTags && game.difficultyTags.length > 0 && (
+
+ {game.difficultyTags.map(d => (
+
+ {d}
+
+ ))}
+
+ )}
+
+
+ {/* Bottom info */}
+
+
{game.title}
+ {showCreator && (
+
by {game.creatorDisplayName || game.creatorUsername}
+ )}
+
+
+ ▶
+ {game.playCount.toLocaleString()}
+
+
+ ⭐
+ {avgRating}
+ {game.totalRatings > 0 && (
+ ({game.totalRatings})
+ )}
+
+ {onToggleFavorite && (
+
+ {localFavorited ? '❤️' : '🤍'}
+ {localFavoriteCount > 0 && localFavoriteCount.toLocaleString()}
+
+ )}
+
+
+
+ {/* Play overlay on hover/touch */}
+
+
+ {isTouchPreview ? 'TAP TO PLAY' : ''}
+
+
+
+
+ {/* Touch hint for mobile */}
+ {!supportsHover && !isTouchPreview && (
+
+ Tap to preview
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/GuestBanner.css b/frontend/src/components/GuestBanner.css
new file mode 100644
index 0000000..ba42447
--- /dev/null
+++ b/frontend/src/components/GuestBanner.css
@@ -0,0 +1,104 @@
+.guest-banner {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border: 1px solid #f59e0b;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+}
+
+.guest-banner-content {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.guest-icon {
+ font-size: 1.5rem;
+}
+
+.guest-text {
+ flex: 1;
+}
+
+.guest-main {
+ font-weight: 600;
+ color: #92400e;
+ margin: 0;
+ font-size: 0.95rem;
+}
+
+.guest-sub {
+ color: #b45309;
+ margin: 0;
+ font-size: 0.85rem;
+}
+
+.guest-benefits {
+ margin: 0.75rem 0 0;
+ padding-top: 0.75rem;
+ border-top: 1px solid rgba(245, 158, 11, 0.3);
+ font-size: 0.8rem;
+ color: #92400e;
+ text-align: center;
+}
+
+.btn-signup {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 0.5rem;
+ font-weight: 600;
+ font-size: 0.85rem;
+ text-decoration: none;
+ white-space: nowrap;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.btn-signup:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
+}
+
+/* Compact version for header/toolbar */
+.guest-banner.compact {
+ background: rgba(254, 243, 199, 0.9);
+ border: none;
+ border-radius: 2rem;
+ padding: 0.35rem 0.75rem;
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+}
+
+.guest-tokens {
+ color: #92400e;
+ font-weight: 500;
+}
+
+.guest-signup-link {
+ color: #6366f1;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.guest-signup-link:hover {
+ text-decoration: underline;
+}
+
+@media (max-width: 480px) {
+ .guest-banner-content {
+ flex-wrap: wrap;
+ }
+
+ .btn-signup {
+ width: 100%;
+ text-align: center;
+ margin-top: 0.5rem;
+ }
+
+ .guest-text {
+ flex: 1 1 calc(100% - 3rem);
+ }
+}
diff --git a/frontend/src/components/GuestBanner.jsx b/frontend/src/components/GuestBanner.jsx
new file mode 100644
index 0000000..34ccf88
--- /dev/null
+++ b/frontend/src/components/GuestBanner.jsx
@@ -0,0 +1,31 @@
+import { Link } from 'react-router-dom';
+import './GuestBanner.css';
+
+export default function GuestBanner({ tokensBalance, compact = false }) {
+ if (compact) {
+ return (
+
+ {tokensBalance || 0} tokens
+ Sign up free
+
+ );
+ }
+
+ return (
+
+
+
👋
+
+
Playing as Guest
+
{tokensBalance || 0} free tokens remaining today
+
+
+ Sign Up Free
+
+
+
+ Create games, save favorites, earn XP & achievements!
+
+
+ );
+}
diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css
new file mode 100644
index 0000000..19060fc
--- /dev/null
+++ b/frontend/src/components/Header.css
@@ -0,0 +1,607 @@
+.header {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ padding: 0.5rem 1rem;
+ position: -webkit-sticky; /* Safari iOS */
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
+ /* Ensure sticky works on mobile */
+ transform: translateZ(0);
+ -webkit-transform: translateZ(0);
+}
+
+/* Static header for game play pages - no sticky */
+.header.header-static {
+ position: relative;
+}
+
+.header-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ height: 44px;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ text-decoration: none;
+ color: white;
+ font-weight: 700;
+ font-size: 1.25rem;
+ flex-shrink: 0;
+}
+
+.logo-accent {
+ color: #fbbf24;
+}
+
+.logo-icon {
+ font-size: 1.5rem;
+}
+
+/* Desktop Navigation */
+.desktop-nav {
+ display: flex;
+ gap: 0.5rem;
+ flex: 1;
+ justify-content: center;
+}
+
+.nav-link {
+ color: rgba(255, 255, 255, 0.9);
+ text-decoration: none;
+ font-weight: 500;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ transition: all 0.2s;
+ font-size: 0.875rem;
+ white-space: nowrap;
+}
+
+.nav-link:hover {
+ background: rgba(255, 255, 255, 0.15);
+ color: white;
+}
+
+.nav-create {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.nav-teacher {
+ background: rgba(16, 185, 129, 0.3);
+ color: white;
+}
+
+.nav-admin {
+ background: rgba(239, 68, 68, 0.3);
+ color: white;
+}
+
+.nav-leaderboard {
+ background: rgba(251, 191, 36, 0.3);
+ color: white;
+}
+
+/* Header Actions */
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.token-display {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ background: rgba(255, 255, 255, 0.2);
+ padding: 0.35rem 0.6rem;
+ border-radius: 2rem;
+ font-weight: 600;
+ color: white;
+ font-size: 0.875rem;
+}
+
+.token-icon {
+ font-size: 1rem;
+}
+
+.token-display-clickable {
+ border: none;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.token-display-clickable:hover {
+ background: rgba(255, 255, 255, 0.35);
+}
+
+.avatar-link {
+ display: flex;
+ align-items: center;
+}
+
+.avatar-link .avatar-svg {
+ border: 2px solid rgba(255, 255, 255, 0.5);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.avatar-link:hover .avatar-svg {
+ border-color: white;
+}
+
+/* Combined profile link with avatar + level */
+.header-profile-link {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ text-decoration: none;
+ padding: 0.2rem 0.5rem 0.2rem 0.2rem;
+ border-radius: 2rem;
+ background: rgba(255, 255, 255, 0.1);
+ transition: background 0.2s;
+}
+
+.header-profile-link:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.header-profile-link .avatar-svg {
+ border: 2px solid rgba(255, 255, 255, 0.5);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.header-profile-link:hover .avatar-svg {
+ border-color: white;
+}
+
+.header-level-badge {
+ color: #fbbf24;
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
+}
+
+/* Desktop/Mobile visibility helpers */
+.desktop-only {
+ display: flex;
+ align-items: center;
+}
+
+.mobile-only {
+ display: none;
+}
+
+/* Hamburger Button */
+.hamburger-btn {
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ width: 44px;
+ height: 44px;
+ border-radius: 8px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+}
+
+.hamburger-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.hamburger-icon {
+ font-size: 1.5rem;
+}
+
+/* Mobile Menu Overlay */
+.mobile-menu-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 200;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.mobile-menu {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: min(300px, 85vw);
+ height: 100vh;
+ background: var(--card-bg, #1a1a2e);
+ box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
+ animation: slideIn 0.3s ease;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+@keyframes slideIn {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
+
+.mobile-menu-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+}
+
+.mobile-menu-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: white;
+}
+
+.mobile-menu-close {
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ font-size: 1.25rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mobile-menu-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Mobile Menu User Info */
+.mobile-menu-user {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem;
+ background: rgba(99, 102, 241, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.mobile-user-info {
+ display: flex;
+ flex-direction: column;
+}
+
+.mobile-user-name {
+ font-weight: 600;
+ color: var(--text-primary, white);
+}
+
+.mobile-user-level {
+ font-size: 0.8rem;
+ color: #fbbf24;
+ font-weight: 500;
+}
+
+.mobile-user-tokens {
+ font-size: 0.8rem;
+ color: var(--text-secondary, #94a3b8);
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-top: 0.15rem;
+}
+
+.mobile-user-tokens .token-icon {
+ font-size: 0.9rem;
+}
+
+/* Mobile Menu Links */
+.mobile-menu-links {
+ flex: 1;
+ padding: 0.5rem 0;
+}
+
+.mobile-nav-link {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ color: var(--text-primary, white);
+ text-decoration: none;
+ font-size: 1rem;
+ font-weight: 500;
+ transition: all 0.2s;
+ border: none;
+ background: none;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+}
+
+.mobile-nav-link:hover,
+.mobile-nav-link:active {
+ background: rgba(99, 102, 241, 0.15);
+}
+
+.nav-icon {
+ font-size: 1.25rem;
+ width: 28px;
+ text-align: center;
+}
+
+.mobile-menu-divider {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.1);
+ margin: 0.5rem 0;
+}
+
+.mobile-nav-admin {
+ color: #ef4444;
+}
+
+.mobile-nav-signup {
+ background: rgba(99, 102, 241, 0.2);
+}
+
+/* Guest signup button in header */
+.guest-signup-btn {
+ background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%) !important;
+ color: #1e293b !important;
+ font-weight: 600;
+ border: none;
+ box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4);
+ animation: pulse-signup 2s ease-in-out infinite;
+}
+
+.guest-signup-btn:hover {
+ background: linear-gradient(135deg, #fcd34d 0%, #fbbf24 100%) !important;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(251, 191, 36, 0.5);
+}
+
+@keyframes pulse-signup {
+ 0%, 100% { box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4); }
+ 50% { box-shadow: 0 2px 16px rgba(251, 191, 36, 0.6); }
+}
+
+.mobile-logout {
+ color: #ef4444;
+}
+
+/* XP display in header */
+.header .xp-display-compact {
+ background: rgba(255, 255, 255, 0.15);
+ padding: 0.25rem 0.6rem;
+ border-radius: 2rem;
+}
+
+.header .xp-display-compact .level-badge {
+ background: rgba(255, 255, 255, 0.25);
+ color: white;
+}
+
+.header .xp-bar-mini {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.header .xp-bar-mini .xp-fill {
+ background: #fbbf24;
+}
+
+/* Mobile Responsive */
+@media (max-width: 900px) {
+ .desktop-nav {
+ display: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .header {
+ padding: 0.5rem 0.75rem;
+ }
+
+ .header-content {
+ height: 40px;
+ }
+
+ .desktop-only {
+ display: none !important;
+ }
+
+ .mobile-only {
+ display: flex;
+ }
+
+ .logo {
+ font-size: 1.1rem;
+ }
+
+ .logo-icon {
+ font-size: 1.25rem;
+ }
+
+ .logo-text {
+ display: none;
+ }
+
+ .token-display {
+ padding: 0.3rem 0.5rem;
+ font-size: 0.8rem;
+ }
+
+ .token-icon {
+ font-size: 0.9rem;
+ }
+
+ .hamburger-btn {
+ width: 40px;
+ height: 40px;
+ }
+
+ .btn-sm {
+ padding: 0.35rem 0.6rem;
+ font-size: 0.8rem;
+ }
+}
+
+/* Ultra-narrow screens (Galaxy Fold outer, small phones) */
+@media (max-width: 360px) {
+ .header {
+ padding: 0.4rem 0.5rem;
+ }
+
+ .header-content {
+ gap: 0.25rem;
+ height: 36px;
+ }
+
+ .logo {
+ min-height: 36px;
+ }
+
+ .logo-icon {
+ font-size: 1.1rem;
+ }
+
+ /* Hide token display on very narrow screens - access via menu */
+ .token-display {
+ display: none;
+ }
+
+ /* Hide guest signup button - can sign up from menu */
+ .guest-signup-btn {
+ display: none;
+ }
+
+ /* Compact header actions */
+ .header-actions {
+ gap: 0.25rem;
+ }
+
+ /* Make hamburger compact but tappable */
+ .hamburger-btn {
+ width: 36px;
+ height: 36px;
+ min-width: 36px;
+ min-height: 36px;
+ flex-shrink: 0;
+ }
+
+ .hamburger-icon {
+ font-size: 1.25rem;
+ }
+}
+
+/* Extra narrow (Galaxy Fold outer ~260px) */
+@media (max-width: 280px) {
+ .header {
+ padding: 0.35rem 0.4rem;
+ }
+
+ .header-content {
+ gap: 0.2rem;
+ height: 32px;
+ }
+
+ .logo-icon {
+ font-size: 1rem;
+ }
+
+ .hamburger-btn {
+ width: 32px;
+ height: 32px;
+ min-width: 32px;
+ min-height: 32px;
+ }
+
+ .hamburger-icon {
+ font-size: 1.1rem;
+ }
+}
+
+/* Touch-friendly styles */
+@media (hover: none), (pointer: coarse) {
+ .nav-link,
+ .mobile-nav-link {
+ min-height: 44px;
+ }
+
+ .token-display-clickable {
+ min-height: 40px;
+ min-width: 60px;
+ }
+
+ .hamburger-btn {
+ min-width: 44px;
+ min-height: 44px;
+ }
+
+ .logo {
+ min-height: 44px;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .nav-link:active,
+ .mobile-nav-link:active {
+ background: rgba(255, 255, 255, 0.25);
+ transform: scale(0.98);
+ }
+
+ .token-display-clickable:active {
+ background: rgba(255, 255, 255, 0.4);
+ transform: scale(0.98);
+ }
+
+ .hamburger-btn:active {
+ background: rgba(255, 255, 255, 0.4);
+ transform: scale(0.95);
+ }
+
+ /* Disable iOS tap highlight */
+ .nav-link,
+ .mobile-nav-link,
+ .token-display-clickable,
+ .hamburger-btn,
+ .logo {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Focus states for accessibility */
+.nav-link:focus-visible,
+.mobile-nav-link:focus-visible,
+.token-display-clickable:focus-visible,
+.hamburger-btn:focus-visible,
+.logo:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 2px;
+}
+
+/* Skip to content link */
+.skip-to-content {
+ position: absolute;
+ left: -9999px;
+ z-index: 999;
+ padding: 1em;
+ background-color: black;
+ color: white;
+ opacity: 0;
+}
+
+.skip-to-content:focus {
+ left: 0;
+ opacity: 1;
+}
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx
new file mode 100644
index 0000000..523841b
--- /dev/null
+++ b/frontend/src/components/Header.jsx
@@ -0,0 +1,255 @@
+import { useState, useEffect } from 'react';
+import { Link, useNavigate, useLocation } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import NotificationBell from './NotificationBell';
+import TokenHelpModal from './TokenHelpModal';
+import { AvatarMini, DEFAULT_AVATAR } from './AvatarBuilder';
+import './Header.css';
+
+export default function Header() {
+ const { user, isAuthenticated, logout } = useAuth();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [showTokenHelp, setShowTokenHelp] = useState(false);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+
+ // Close mobile menu on route change
+ useEffect(() => {
+ setMobileMenuOpen(false);
+ }, [location.pathname]);
+
+ // Prevent body scroll when menu is open
+ useEffect(() => {
+ if (mobileMenuOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = '';
+ }
+ return () => {
+ document.body.style.overflow = '';
+ };
+ }, [mobileMenuOpen]);
+
+ const handleLogout = () => {
+ logout();
+ setMobileMenuOpen(false);
+ navigate('/');
+ };
+
+ const closeMobileMenu = () => {
+ setMobileMenuOpen(false);
+ };
+
+ // Disable sticky header on game play pages
+ const isPlayPage = location.pathname.startsWith('/play/');
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/InstallPrompt.css b/frontend/src/components/InstallPrompt.css
new file mode 100644
index 0000000..fa21106
--- /dev/null
+++ b/frontend/src/components/InstallPrompt.css
@@ -0,0 +1,159 @@
+.install-prompt {
+ position: fixed;
+ bottom: 1rem;
+ left: 1rem;
+ right: 1rem;
+ max-width: 400px;
+ margin: 0 auto;
+ background: white;
+ border-radius: 1rem;
+ padding: 1rem;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.install-content {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.install-icon {
+ font-size: 2rem;
+ flex-shrink: 0;
+}
+
+.install-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.install-text strong {
+ color: #1e293b;
+ font-size: 1rem;
+}
+
+.install-text span {
+ color: #64748b;
+ font-size: 0.85rem;
+}
+
+.install-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+.install-dismiss {
+ background: transparent;
+ border: none;
+ color: #64748b;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+ cursor: pointer;
+ border-radius: 0.5rem;
+ transition: all 0.2s;
+}
+
+.install-dismiss:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+}
+
+.install-button {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border: none;
+ padding: 0.5rem 1.25rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ border-radius: 0.5rem;
+ transition: all 0.2s;
+}
+
+.install-button:hover {
+ transform: scale(1.02);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
+}
+
+/* Adjust for safe area on mobile */
+@supports (padding-bottom: env(safe-area-inset-bottom)) {
+ .install-prompt {
+ bottom: calc(1rem + env(safe-area-inset-bottom));
+ }
+}
+
+/* Touch-friendly styles */
+@media (hover: none), (pointer: coarse) {
+ .install-dismiss {
+ min-height: 44px;
+ padding: 0.625rem 1.25rem;
+ font-size: 0.95rem;
+ }
+
+ .install-button {
+ min-height: 44px;
+ padding: 0.625rem 1.5rem;
+ font-size: 0.95rem;
+ }
+
+ .install-actions {
+ gap: 0.75rem;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .install-dismiss:active {
+ background: #e2e8f0;
+ transform: scale(0.98);
+ }
+
+ .install-button:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+ }
+
+ /* Disable iOS tap highlight */
+ .install-dismiss,
+ .install-button {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Hover-only styles for desktop */
+@media (hover: hover) {
+ .install-dismiss:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+ }
+
+ .install-button:hover {
+ transform: scale(1.02);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
+ }
+}
+
+/* Focus states for accessibility */
+.install-dismiss:focus-visible,
+.install-button:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+}
diff --git a/frontend/src/components/InstallPrompt.jsx b/frontend/src/components/InstallPrompt.jsx
new file mode 100644
index 0000000..f002692
--- /dev/null
+++ b/frontend/src/components/InstallPrompt.jsx
@@ -0,0 +1,103 @@
+import { useState, useEffect } from 'react';
+import { useAuth } from '../context/AuthContext';
+import './InstallPrompt.css';
+
+export default function InstallPrompt() {
+ const { showInstallPrompt: authTrigger, dismissInstallPrompt } = useAuth();
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
+ const [showPrompt, setShowPrompt] = useState(false);
+ const [isInstalled, setIsInstalled] = useState(false);
+
+ useEffect(() => {
+ // Check if already installed
+ if (window.matchMedia('(display-mode: standalone)').matches) {
+ setIsInstalled(true);
+ return;
+ }
+
+ // Listen for the beforeinstallprompt event
+ const handleBeforeInstallPrompt = (e) => {
+ e.preventDefault();
+ setDeferredPrompt(e);
+ };
+
+ // Listen for successful install
+ const handleAppInstalled = () => {
+ setIsInstalled(true);
+ setShowPrompt(false);
+ setDeferredPrompt(null);
+ };
+
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
+ window.addEventListener('appinstalled', handleAppInstalled);
+
+ return () => {
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
+ window.removeEventListener('appinstalled', handleAppInstalled);
+ };
+ }, []);
+
+ // Show prompt when triggered by auth (login/register)
+ useEffect(() => {
+ if (authTrigger && deferredPrompt && !isInstalled) {
+ // Check if dismissed recently (don't show for 7 days after dismissal)
+ const dismissedAt = localStorage.getItem('pwa_install_dismissed');
+ if (dismissedAt) {
+ const daysSinceDismissal = (Date.now() - parseInt(dismissedAt)) / (1000 * 60 * 60 * 24);
+ if (daysSinceDismissal < 7) {
+ dismissInstallPrompt();
+ return;
+ }
+ }
+ setShowPrompt(true);
+ }
+ }, [authTrigger, deferredPrompt, isInstalled, dismissInstallPrompt]);
+
+ const handleInstall = async () => {
+ if (!deferredPrompt) return;
+
+ // Show the install prompt
+ deferredPrompt.prompt();
+
+ // Wait for the user's response
+ const { outcome } = await deferredPrompt.userChoice;
+
+ if (outcome === 'accepted') {
+ setIsInstalled(true);
+ }
+
+ setDeferredPrompt(null);
+ setShowPrompt(false);
+ dismissInstallPrompt();
+ };
+
+ const handleDismiss = () => {
+ setShowPrompt(false);
+ dismissInstallPrompt();
+ localStorage.setItem('pwa_install_dismissed', Date.now().toString());
+ };
+
+ if (!showPrompt || isInstalled || !deferredPrompt) {
+ return null;
+ }
+
+ return (
+
+
+
🎮
+
+ Install GamerComp
+ Play games offline anytime!
+
+
+
+
+ Not now
+
+
+ Install
+
+
+
+ );
+}
diff --git a/frontend/src/components/LevelUpCelebration.css b/frontend/src/components/LevelUpCelebration.css
new file mode 100644
index 0000000..317220a
--- /dev/null
+++ b/frontend/src/components/LevelUpCelebration.css
@@ -0,0 +1,118 @@
+.level-up-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.confetti-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.confetti {
+ position: absolute;
+ top: -10px;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ animation: confettiFall linear forwards;
+}
+
+@keyframes confettiFall {
+ 0% {
+ transform: translateY(0) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(100vh) rotate(720deg);
+ opacity: 0;
+ }
+}
+
+.level-up-modal {
+ background: white;
+ border-radius: 1.5rem;
+ padding: 2.5rem;
+ text-align: center;
+ max-width: 320px;
+ width: 90%;
+ animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+ position: relative;
+ z-index: 1;
+}
+
+@keyframes popIn {
+ 0% {
+ transform: scale(0.5);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+.level-up-icon {
+ font-size: 4rem;
+ margin-bottom: 0.5rem;
+ animation: bounce 0.6s ease infinite alternate;
+}
+
+@keyframes bounce {
+ from {
+ transform: translateY(0);
+ }
+ to {
+ transform: translateY(-10px);
+ }
+}
+
+.level-up-modal h2 {
+ margin: 0 0 0.5rem;
+ font-size: 2rem;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #f59e0b 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.level-up-level {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin-bottom: 0.25rem;
+}
+
+.level-up-name {
+ font-size: 1.25rem;
+ color: #6366f1;
+ font-weight: 600;
+ margin-bottom: 1rem;
+}
+
+.level-up-message {
+ color: #64748b;
+ margin-bottom: 1.5rem;
+}
+
+.level-up-modal .btn {
+ min-width: 120px;
+}
diff --git a/frontend/src/components/LevelUpCelebration.jsx b/frontend/src/components/LevelUpCelebration.jsx
new file mode 100644
index 0000000..cc695cc
--- /dev/null
+++ b/frontend/src/components/LevelUpCelebration.jsx
@@ -0,0 +1,65 @@
+import { useState, useEffect } from 'react';
+import './LevelUpCelebration.css';
+
+const LEVEL_ICONS = {
+ 1: '🌱', 2: '🎮', 3: '🎯', 4: '⭐', 5: '🔥',
+ 6: '💎', 7: '🏆', 8: '👑', 9: '🌟', 10: '🎖️'
+};
+
+export default function LevelUpCelebration({ level, levelName, onClose }) {
+ const [visible, setVisible] = useState(true);
+ const [confetti, setConfetti] = useState([]);
+
+ useEffect(() => {
+ // Generate confetti particles
+ const particles = Array.from({ length: 50 }, (_, i) => ({
+ id: i,
+ left: Math.random() * 100,
+ delay: Math.random() * 0.5,
+ duration: 1 + Math.random() * 2,
+ color: ['#6366f1', '#8b5cf6', '#f59e0b', '#10b981', '#ec4899'][Math.floor(Math.random() * 5)]
+ }));
+ setConfetti(particles);
+
+ // Auto close after 4 seconds
+ const timer = setTimeout(() => {
+ setVisible(false);
+ setTimeout(onClose, 300);
+ }, 4000);
+
+ return () => clearTimeout(timer);
+ }, [onClose]);
+
+ if (!visible) return null;
+
+ const icon = LEVEL_ICONS[level] || '⭐';
+
+ return (
+
+
+ {confetti.map(p => (
+
+ ))}
+
+
e.stopPropagation()}>
+
{icon}
+
Level Up!
+
Level {level}
+
{levelName}
+
Keep creating awesome games!
+
+ Awesome!
+
+
+
+ );
+}
diff --git a/frontend/src/components/NotificationBell.css b/frontend/src/components/NotificationBell.css
new file mode 100644
index 0000000..5b40f07
--- /dev/null
+++ b/frontend/src/components/NotificationBell.css
@@ -0,0 +1,352 @@
+.notification-bell {
+ position: relative;
+}
+
+.bell-button {
+ background: none;
+ border: none;
+ padding: 0.5rem;
+ cursor: pointer;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: background 0.2s;
+}
+
+.bell-button:hover {
+ background: rgba(0, 0, 0, 0.05);
+}
+
+.bell-icon {
+ font-size: 1.25rem;
+}
+
+.bell-button.has-unread .bell-icon {
+ animation: ring 0.5s ease-in-out;
+}
+
+@keyframes ring {
+ 0%, 100% { transform: rotate(0); }
+ 25% { transform: rotate(15deg); }
+ 50% { transform: rotate(-15deg); }
+ 75% { transform: rotate(10deg); }
+}
+
+.unread-badge {
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: #ef4444;
+ color: white;
+ font-size: 0.65rem;
+ font-weight: 600;
+ min-width: 16px;
+ height: 16px;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+}
+
+.notification-dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ width: 320px;
+ max-height: 420px;
+ background: white;
+ border-radius: 0.75rem;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
+ z-index: 1000;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.dropdown-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.dropdown-header h3 {
+ margin: 0;
+ font-size: 0.95rem;
+ color: #1e293b;
+}
+
+.mark-all-read {
+ background: none;
+ border: none;
+ color: #6366f1;
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+}
+
+.mark-all-read:hover {
+ background: #f0f0ff;
+}
+
+.mark-all-read:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.notification-list {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.empty-notifications {
+ padding: 2rem;
+ text-align: center;
+ color: #64748b;
+}
+
+.empty-icon {
+ font-size: 2.5rem;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.empty-notifications p {
+ margin: 0;
+}
+
+.notification-item {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+ cursor: pointer;
+ transition: background 0.2s;
+ position: relative;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.notification-item:hover {
+ background: #f8fafc;
+}
+
+.notification-item.unread {
+ background: #faf5ff;
+}
+
+.notification-item.unread:hover {
+ background: #f5f3ff;
+}
+
+.notification-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f1f5f9;
+ border-radius: 50%;
+}
+
+.notification-item.unread .notification-icon {
+ background: #e9d5ff;
+}
+
+.notification-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.notification-title {
+ margin: 0 0 0.25rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.notification-body {
+ margin: 0 0 0.25rem;
+ font-size: 0.8rem;
+ color: #64748b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.notification-time {
+ font-size: 0.7rem;
+ color: #94a3b8;
+}
+
+.unread-dot {
+ position: absolute;
+ top: 50%;
+ right: 12px;
+ transform: translateY(-50%);
+ width: 8px;
+ height: 8px;
+ background: #6366f1;
+ border-radius: 50%;
+}
+
+.dropdown-footer {
+ padding: 0.75rem 1rem;
+ text-align: center;
+ border-top: 1px solid #e2e8f0;
+ background: #f8fafc;
+}
+
+.dropdown-footer a {
+ color: #6366f1;
+ font-size: 0.85rem;
+ text-decoration: none;
+}
+
+.dropdown-footer a:hover {
+ text-decoration: underline;
+}
+
+/* Mobile responsive */
+@media (max-width: 640px) {
+ .notification-dropdown {
+ position: fixed;
+ top: 60px;
+ left: 0;
+ right: 0;
+ width: 100%;
+ max-height: calc(100vh - 60px);
+ border-radius: 0;
+ }
+}
+
+/* Ultra-narrow screens (Galaxy Fold outer, small phones) */
+@media (max-width: 360px) {
+ .bell-button {
+ min-width: 36px;
+ min-height: 36px;
+ width: 36px;
+ height: 36px;
+ padding: 0.4rem;
+ }
+
+ .bell-icon {
+ font-size: 1.1rem;
+ }
+
+ .unread-badge {
+ min-width: 14px;
+ height: 14px;
+ font-size: 0.6rem;
+ top: -2px;
+ right: -2px;
+ }
+
+ .notification-dropdown {
+ top: 52px;
+ max-height: calc(100vh - 52px);
+ }
+}
+
+/* Touch-friendly styles */
+@media (hover: none), (pointer: coarse) {
+ .bell-button {
+ min-width: 44px;
+ min-height: 44px;
+ padding: 0.625rem;
+ }
+
+ .bell-icon {
+ font-size: 1.5rem;
+ }
+
+ .mark-all-read {
+ min-height: 36px;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.85rem;
+ }
+
+ .notification-item {
+ min-height: 60px;
+ padding: 1rem;
+ }
+
+ .dropdown-footer a {
+ display: inline-block;
+ min-height: 44px;
+ padding: 0.75rem 1rem;
+ line-height: 1.5;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .bell-button:active {
+ background: rgba(0, 0, 0, 0.1);
+ transform: scale(0.95);
+ }
+
+ .mark-all-read:active:not(:disabled) {
+ background: #ddd6fe;
+ transform: scale(0.98);
+ }
+
+ .notification-item:active {
+ background: #f1f5f9;
+ transform: scale(0.99);
+ }
+
+ .notification-item.unread:active {
+ background: #ede9fe;
+ }
+
+ .dropdown-footer a:active {
+ color: #4338ca;
+ background: #f1f5f9;
+ }
+
+ /* Disable iOS tap highlight */
+ .bell-button,
+ .mark-all-read,
+ .notification-item,
+ .dropdown-footer a {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Hover-only styles for desktop */
+@media (hover: hover) {
+ .bell-button:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ .mark-all-read:hover {
+ background: #f0f0ff;
+ }
+
+ .notification-item:hover {
+ background: #f8fafc;
+ }
+
+ .notification-item.unread:hover {
+ background: #f5f3ff;
+ }
+
+ .dropdown-footer a:hover {
+ text-decoration: underline;
+ }
+}
+
+/* Focus states for accessibility */
+.bell-button:focus-visible,
+.mark-all-read:focus-visible,
+.notification-item:focus-visible,
+.dropdown-footer a:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+}
diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx
new file mode 100644
index 0000000..406a35f
--- /dev/null
+++ b/frontend/src/components/NotificationBell.jsx
@@ -0,0 +1,164 @@
+import { useState, useEffect, useRef } from 'react';
+import { Link } from 'react-router-dom';
+import api from '../api';
+import './NotificationBell.css';
+
+export default function NotificationBell() {
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ loadNotifications();
+ // Poll for new notifications every 30 seconds
+ const interval = setInterval(loadNotifications, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ async function loadNotifications() {
+ try {
+ const data = await api.getNotifications();
+ setNotifications(data.notifications || []);
+ setUnreadCount(data.unreadCount || 0);
+ } catch (err) {
+ console.error('Failed to load notifications:', err);
+ }
+ }
+
+ async function handleMarkRead(notificationId) {
+ try {
+ await api.markNotificationRead(notificationId);
+ setNotifications(prev =>
+ prev.map(n => n.id === notificationId ? { ...n, read_at: new Date() } : n)
+ );
+ setUnreadCount(prev => Math.max(0, prev - 1));
+ } catch (err) {
+ console.error('Failed to mark notification read:', err);
+ }
+ }
+
+ async function handleMarkAllRead() {
+ setLoading(true);
+ try {
+ await api.markAllNotificationsRead();
+ setNotifications(prev => prev.map(n => ({ ...n, read_at: new Date() })));
+ setUnreadCount(0);
+ } catch (err) {
+ console.error('Failed to mark all read:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function getNotificationIcon(type) {
+ const icons = {
+ achievement: '🏆',
+ high_score: '🎯',
+ game_favorited: '💖',
+ new_follower: '👋',
+ game_remixed: '🔄',
+ game_ready: '🎮',
+ level_up: '⭐',
+ welcome: '🎮',
+ default: '📬'
+ };
+ return icons[type] || icons.default;
+ }
+
+ function formatTime(dateStr) {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'Just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+ }
+
+ return (
+
+
0 ? 'has-unread' : ''}`}
+ onClick={() => setIsOpen(!isOpen)}
+ aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
+ >
+ 🔔
+ {unreadCount > 0 && (
+ {unreadCount > 99 ? '99+' : unreadCount}
+ )}
+
+
+ {isOpen && (
+
+
+
Notifications
+ {unreadCount > 0 && (
+
+ Mark all read
+
+ )}
+
+
+
+ {notifications.length === 0 ? (
+
+
📭
+
No notifications yet
+
+ ) : (
+ notifications.slice(0, 10).map(notification => (
+
!notification.read_at && handleMarkRead(notification.id)}
+ >
+
+ {getNotificationIcon(notification.type)}
+
+
+
{notification.title}
+
{notification.body}
+
+ {formatTime(notification.created_at)}
+
+
+ {!notification.read_at &&
}
+
+ ))
+ )}
+
+
+ {notifications.length > 10 && (
+
+ setIsOpen(false)}>
+ View all notifications
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/OfflineIndicator.css b/frontend/src/components/OfflineIndicator.css
new file mode 100644
index 0000000..fbe217f
--- /dev/null
+++ b/frontend/src/components/OfflineIndicator.css
@@ -0,0 +1,52 @@
+.offline-indicator {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ padding: 0.5rem 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ z-index: 9999;
+ font-size: 0.85rem;
+ font-weight: 500;
+ animation: slideDown 0.3s ease;
+}
+
+@keyframes slideDown {
+ from {
+ transform: translateY(-100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+.offline-indicator.offline {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.offline-indicator.online {
+ background: #d1fae5;
+ color: #065f46;
+ animation: slideDown 0.3s ease, fadeOut 0.5s ease 2.5s forwards;
+}
+
+@keyframes fadeOut {
+ to {
+ opacity: 0;
+ transform: translateY(-100%);
+ }
+}
+
+.offline-icon {
+ font-size: 1rem;
+}
+
+.offline-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/frontend/src/components/OfflineIndicator.jsx b/frontend/src/components/OfflineIndicator.jsx
new file mode 100644
index 0000000..b59464d
--- /dev/null
+++ b/frontend/src/components/OfflineIndicator.jsx
@@ -0,0 +1,41 @@
+import { useState, useEffect } from 'react';
+import './OfflineIndicator.css';
+
+export default function OfflineIndicator() {
+ const [isOnline, setIsOnline] = useState(navigator.onLine);
+ const [showReconnected, setShowReconnected] = useState(false);
+
+ useEffect(() => {
+ const handleOnline = () => {
+ setIsOnline(true);
+ setShowReconnected(true);
+ setTimeout(() => setShowReconnected(false), 3000);
+ };
+
+ const handleOffline = () => {
+ setIsOnline(false);
+ setShowReconnected(false);
+ };
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ if (isOnline && !showReconnected) {
+ return null;
+ }
+
+ return (
+
+ {isOnline ? '🟢' : '📡'}
+
+ {isOnline ? 'Back online!' : 'You\'re offline - some features may be limited'}
+
+
+ );
+}
diff --git a/frontend/src/components/OnboardingTutorial.css b/frontend/src/components/OnboardingTutorial.css
new file mode 100644
index 0000000..97b3e6e
--- /dev/null
+++ b/frontend/src/components/OnboardingTutorial.css
@@ -0,0 +1,280 @@
+.onboarding-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, rgba(99, 102, 241, 0.95) 0%, rgba(139, 92, 246, 0.95) 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10001;
+ padding: 1rem;
+ animation: fadeIn 0.3s ease;
+ overflow: hidden;
+}
+
+.onboarding-overlay.exiting {
+ animation: fadeOut 0.3s ease forwards;
+}
+
+.onboarding-modal {
+ background: white;
+ border-radius: 1.5rem;
+ padding: 2.5rem 2rem;
+ max-width: 420px;
+ width: 100%;
+ text-align: center;
+ position: relative;
+ animation: slideUp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+
+.onboarding-skip {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: none;
+ border: none;
+ color: #94a3b8;
+ font-size: 0.9rem;
+ cursor: pointer;
+ padding: 0.5rem;
+ transition: color 0.2s;
+}
+
+.onboarding-skip:hover {
+ color: #64748b;
+}
+
+.onboarding-content {
+ padding: 1rem 0 1.5rem;
+}
+
+.onboarding-emoji {
+ font-size: 4rem;
+ display: block;
+ margin-bottom: 1rem;
+ animation: bounce 0.6s ease infinite alternate;
+}
+
+.onboarding-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin: 0 0 0.75rem;
+}
+
+.onboarding-text {
+ font-size: 1.1rem;
+ color: #475569;
+ margin: 0 0 0.5rem;
+ line-height: 1.5;
+}
+
+.onboarding-subtext {
+ font-size: 0.9rem;
+ color: #94a3b8;
+ margin: 0;
+ font-style: italic;
+}
+
+.onboarding-dots {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.onboarding-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ border: none;
+ background: #e2e8f0;
+ cursor: pointer;
+ padding: 0;
+ transition: all 0.2s;
+}
+
+.onboarding-dot:hover {
+ background: #cbd5e1;
+}
+
+.onboarding-dot.active {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ transform: scale(1.2);
+}
+
+.onboarding-dot.completed {
+ background: #10b981;
+}
+
+.onboarding-actions {
+ display: flex;
+ justify-content: center;
+}
+
+.onboarding-btn {
+ min-width: 140px;
+ padding: 0.875rem 2rem;
+ font-size: 1.1rem;
+ border-radius: 2rem;
+}
+
+/* Confetti */
+.confetti-particle {
+ position: absolute;
+ top: -20px;
+ border-radius: 2px;
+ animation: confettiFall linear forwards;
+ pointer-events: none;
+}
+
+/* Animations */
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes fadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(30px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes bounce {
+ from { transform: translateY(0); }
+ to { transform: translateY(-8px); }
+}
+
+@keyframes confettiFall {
+ 0% {
+ transform: translateY(0) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(100vh) rotate(720deg);
+ opacity: 0;
+ }
+}
+
+/* Mobile adjustments */
+@media (max-width: 480px) {
+ .onboarding-modal {
+ padding: 2rem 1.5rem;
+ border-radius: 1rem;
+ margin: 0 0.5rem;
+ }
+
+ .onboarding-emoji {
+ font-size: 3rem;
+ }
+
+ .onboarding-title {
+ font-size: 1.5rem;
+ }
+
+ .onboarding-text {
+ font-size: 1rem;
+ }
+
+ .onboarding-btn {
+ width: 100%;
+ max-width: 200px;
+ }
+}
+
+/* Large screens */
+@media (min-width: 768px) {
+ .onboarding-modal {
+ padding: 3rem 2.5rem;
+ max-width: 480px;
+ }
+
+ .onboarding-emoji {
+ font-size: 5rem;
+ }
+
+ .onboarding-title {
+ font-size: 2rem;
+ }
+}
+
+/* Touch-friendly styles */
+@media (hover: none), (pointer: coarse) {
+ .onboarding-skip {
+ min-width: 44px;
+ min-height: 44px;
+ padding: 0.625rem;
+ font-size: 0.95rem;
+ }
+
+ .onboarding-dot {
+ width: 14px;
+ height: 14px;
+ /* Larger touch target with invisible padding */
+ padding: 8px;
+ box-sizing: content-box;
+ margin: -8px;
+ }
+
+ .onboarding-btn {
+ min-height: 48px;
+ padding: 1rem 2rem;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .onboarding-skip:active {
+ color: #475569;
+ background: rgba(0, 0, 0, 0.05);
+ transform: scale(0.98);
+ }
+
+ .onboarding-dot:active {
+ transform: scale(1.3);
+ }
+
+ .onboarding-btn:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+ }
+
+ /* Disable iOS tap highlight */
+ .onboarding-skip,
+ .onboarding-dot,
+ .onboarding-btn {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Hover-only styles for desktop */
+@media (hover: hover) {
+ .onboarding-skip:hover {
+ color: #64748b;
+ }
+
+ .onboarding-dot:hover {
+ background: #cbd5e1;
+ }
+}
+
+/* Focus states for accessibility */
+.onboarding-skip:focus-visible,
+.onboarding-dot:focus-visible,
+.onboarding-btn:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+}
diff --git a/frontend/src/components/OnboardingTutorial.jsx b/frontend/src/components/OnboardingTutorial.jsx
new file mode 100644
index 0000000..991d773
--- /dev/null
+++ b/frontend/src/components/OnboardingTutorial.jsx
@@ -0,0 +1,160 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import api from '../api';
+import './OnboardingTutorial.css';
+
+const STEPS = [
+ {
+ id: 1,
+ title: 'Welcome to GamerComp!',
+ emoji: '🎮',
+ content: 'Your new favorite place to play and create awesome games!',
+ subtext: 'Made by kids, for kids'
+ },
+ {
+ id: 2,
+ title: 'Play Amazing Games',
+ emoji: '🕹️',
+ content: 'Browse the arcade and play games created by other players.',
+ subtext: 'Earn XP and climb the leaderboards!'
+ },
+ {
+ id: 3,
+ title: 'Create Your Own Games',
+ emoji: '✨',
+ content: 'Use our AI wizard to create your own games in minutes!',
+ subtext: 'No coding required - just describe your idea'
+ },
+ {
+ id: 4,
+ title: 'Earn Rewards & Level Up',
+ emoji: '🏆',
+ content: 'Collect achievements, earn XP, and become a Game Master!',
+ subtext: 'The more you play and create, the more you earn'
+ }
+];
+
+export default function OnboardingTutorial({ onComplete }) {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [isExiting, setIsExiting] = useState(false);
+ const [confetti, setConfetti] = useState([]);
+ const navigate = useNavigate();
+
+ const step = STEPS[currentStep];
+ const isLastStep = currentStep === STEPS.length - 1;
+
+ useEffect(() => {
+ // Prevent body scroll while tutorial is open
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.body.style.overflow = '';
+ };
+ }, []);
+
+ const generateConfetti = () => {
+ const particles = Array.from({ length: 40 }, (_, i) => ({
+ id: i,
+ left: Math.random() * 100,
+ delay: Math.random() * 0.3,
+ duration: 1.5 + Math.random() * 1.5,
+ color: ['#6366f1', '#8b5cf6', '#f59e0b', '#10b981', '#ec4899', '#3b82f6'][
+ Math.floor(Math.random() * 6)
+ ],
+ size: 6 + Math.random() * 8
+ }));
+ setConfetti(particles);
+ };
+
+ const handleNext = () => {
+ if (isLastStep) {
+ handleComplete();
+ } else {
+ setCurrentStep(prev => prev + 1);
+ }
+ };
+
+ const handleSkip = () => {
+ handleComplete();
+ };
+
+ const handleComplete = async () => {
+ generateConfetti();
+
+ // Save onboarding status to database - this is the source of truth
+ try {
+ await api.completeOnboarding();
+ } catch (err) {
+ // Log but don't block - we'll still update local state
+ console.warn('Failed to save onboarding status:', err.message);
+ }
+
+ // Show celebration briefly then exit
+ setTimeout(() => {
+ setIsExiting(true);
+ setTimeout(() => {
+ onComplete();
+ // Don't navigate if already on /arcade
+ if (window.location.pathname !== '/arcade') {
+ navigate('/arcade');
+ }
+ }, 300);
+ }, 1500);
+ };
+
+ return (
+
+ {/* Confetti */}
+ {confetti.map(particle => (
+
+ ))}
+
+
+ {/* Skip button */}
+ {!isLastStep && (
+
+ Skip
+
+ )}
+
+ {/* Step content */}
+
+
{step.emoji}
+
{step.title}
+
{step.content}
+
{step.subtext}
+
+
+ {/* Progress dots */}
+
+ {STEPS.map((_, index) => (
+ setCurrentStep(index)}
+ />
+ ))}
+
+
+ {/* Navigation */}
+
+
+ {isLastStep ? "Let's Go!" : 'Next'}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/PromptRefiner.css b/frontend/src/components/PromptRefiner.css
new file mode 100644
index 0000000..89f9d5a
--- /dev/null
+++ b/frontend/src/components/PromptRefiner.css
@@ -0,0 +1,279 @@
+.prompt-refiner {
+ max-width: 700px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+/* Offline state */
+.refiner-offline {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 2rem;
+ text-align: center;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+}
+
+.refiner-offline .offline-icon {
+ font-size: 2rem;
+}
+
+.refiner-offline-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.refiner-fallback-banner {
+ background: rgba(234, 179, 8, 0.15);
+ border: 1px solid rgba(234, 179, 8, 0.3);
+ color: #fde047;
+ padding: 0.4rem 0.75rem;
+ border-radius: 8px;
+ font-size: 0.8rem;
+ text-align: center;
+ margin-bottom: 0.5rem;
+}
+
+/* IDEA PHASE */
+.refiner-idea-phase {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.refiner-idea-phase h2 {
+ margin: 0;
+ font-size: 1.4rem;
+}
+
+.refiner-subtitle {
+ color: #aaa;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.free-tag {
+ background: #22c55e;
+ color: #fff;
+ font-size: 0.7rem;
+ padding: 0.15rem 0.5rem;
+ border-radius: 4px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+}
+
+.refiner-idea-phase textarea {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 10px;
+ color: #eee;
+ padding: 0.8rem;
+ font-size: 0.95rem;
+ font-family: inherit;
+ resize: vertical;
+}
+
+.refiner-idea-phase textarea:focus {
+ outline: none;
+ border-color: rgba(100, 200, 255, 0.5);
+}
+
+.refiner-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.refiner-error {
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ color: #fca5a5;
+ padding: 0.5rem 0.75rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+}
+
+/* CHAT PHASE */
+.refiner-chat-phase {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.chat-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.chat-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+.question-progress {
+ color: #888;
+ font-size: 0.85rem;
+}
+
+.chat-messages {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ max-height: 400px;
+ overflow-y: auto;
+ padding: 0.5rem;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 10px;
+}
+
+.chat-bubble {
+ padding: 0.6rem 0.8rem;
+ border-radius: 12px;
+ font-size: 0.9rem;
+ line-height: 1.4;
+ max-width: 85%;
+ word-wrap: break-word;
+}
+
+.bubble-label {
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ margin-bottom: 0.2rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.user-idea {
+ background: rgba(100, 200, 255, 0.1);
+ border: 1px solid rgba(100, 200, 255, 0.2);
+ align-self: flex-end;
+}
+
+.user-idea .bubble-label {
+ color: rgba(100, 200, 255, 0.7);
+}
+
+.ai-bubble {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ align-self: flex-start;
+}
+
+.ai-bubble .bubble-label {
+ color: rgba(34, 197, 94, 0.7);
+}
+
+.user-bubble {
+ background: rgba(100, 200, 255, 0.15);
+ border: 1px solid rgba(100, 200, 255, 0.25);
+ align-self: flex-end;
+}
+
+.typing .typing-dots {
+ display: flex;
+ gap: 0.3rem;
+}
+
+.typing .typing-dots span {
+ width: 6px;
+ height: 6px;
+ background: #888;
+ border-radius: 50%;
+ animation: typingBounce 1.2s infinite;
+}
+
+.typing .typing-dots span:nth-child(2) {
+ animation-delay: 0.2s;
+}
+
+.typing .typing-dots span:nth-child(3) {
+ animation-delay: 0.4s;
+}
+
+@keyframes typingBounce {
+ 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
+ 30% { opacity: 1; transform: translateY(-4px); }
+}
+
+.chat-input-area {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.chat-input-area input {
+ flex: 1;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ color: #eee;
+ padding: 0.6rem 0.8rem;
+ font-size: 0.9rem;
+ font-family: inherit;
+}
+
+.chat-input-area input:focus {
+ outline: none;
+ border-color: rgba(100, 200, 255, 0.5);
+}
+
+.chat-footer-actions {
+ display: flex;
+ justify-content: center;
+}
+
+/* REVIEW PHASE */
+.refiner-review-phase {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.refiner-review-phase h2 {
+ margin: 0;
+ font-size: 1.3rem;
+}
+
+.spec-editor {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 10px;
+ color: #eee;
+ padding: 0.8rem;
+ font-size: 0.9rem;
+ font-family: monospace;
+ resize: vertical;
+ line-height: 1.5;
+}
+
+.spec-editor:focus {
+ outline: none;
+ border-color: rgba(100, 200, 255, 0.5);
+}
+
+/* Mobile adjustments */
+@media (max-width: 600px) {
+ .refiner-actions {
+ flex-direction: column;
+ }
+
+ .refiner-actions .btn {
+ width: 100%;
+ }
+
+ .chat-messages {
+ max-height: 300px;
+ }
+
+ .chat-bubble {
+ max-width: 90%;
+ }
+}
diff --git a/frontend/src/components/PromptRefiner.jsx b/frontend/src/components/PromptRefiner.jsx
new file mode 100644
index 0000000..06063fa
--- /dev/null
+++ b/frontend/src/components/PromptRefiner.jsx
@@ -0,0 +1,263 @@
+import { useState, useEffect, useRef } from 'react';
+import api from '../api';
+import './PromptRefiner.css';
+
+const PHASES = {
+ IDEA: 'idea',
+ CHAT: 'chat',
+ REVIEW: 'review'
+};
+
+export default function PromptRefiner({ onComplete, onSkip, styleId, styleName }) {
+ const [phase, setPhase] = useState(PHASES.IDEA);
+ const [gameIdea, setGameIdea] = useState('');
+ const [conversation, setConversation] = useState([]);
+ const [currentAnswer, setCurrentAnswer] = useState('');
+ const [refinedSpec, setRefinedSpec] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [questionCount, setQuestionCount] = useState(0);
+ const chatEndRef = useRef(null);
+
+ useEffect(() => {
+ if (chatEndRef.current) {
+ chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [conversation]);
+
+ async function handleStartChat() {
+ if (!gameIdea.trim()) {
+ setError('Please describe your game idea');
+ return;
+ }
+
+ setLoading(true);
+ setError('');
+
+ try {
+ const result = await api.refinePromptChat(gameIdea, []);
+
+ if (result.isComplete) {
+ setRefinedSpec(result.refinedSpec);
+ setPhase(PHASES.REVIEW);
+ } else {
+ setConversation([{ role: 'ai', text: result.question }]);
+ setQuestionCount(1);
+ setPhase(PHASES.CHAT);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSendAnswer() {
+ if (!currentAnswer.trim()) return;
+
+ const newConversation = [
+ ...conversation,
+ { role: 'user', text: currentAnswer.trim() }
+ ];
+ setConversation(newConversation);
+ setCurrentAnswer('');
+ setLoading(true);
+ setError('');
+
+ try {
+ const result = await api.refinePromptChat(gameIdea, newConversation);
+
+ if (result.isComplete) {
+ setRefinedSpec(result.refinedSpec);
+ setPhase(PHASES.REVIEW);
+ } else {
+ setConversation(prev => [...prev, { role: 'ai', text: result.question }]);
+ setQuestionCount(prev => prev + 1);
+ }
+ } catch (err) {
+ setError(err.message);
+ // Remove the user message we added if the API failed
+ setConversation(conversation);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function handleDoneEarly() {
+ // Build spec from conversation so far
+ let spec = `Game Style: ${styleName}\n`;
+ spec += `Idea: ${gameIdea}\n\n`;
+ spec += 'Details from conversation:\n';
+
+ for (let i = 0; i < conversation.length; i += 2) {
+ const question = conversation[i]?.text || '';
+ const answer = conversation[i + 1]?.text || '';
+ if (answer) {
+ spec += `- ${question}\n Answer: ${answer}\n`;
+ }
+ }
+
+ setRefinedSpec(spec);
+ setPhase(PHASES.REVIEW);
+ }
+
+ function handleKeyDown(e) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (phase === PHASES.IDEA) {
+ handleStartChat();
+ } else if (phase === PHASES.CHAT) {
+ handleSendAnswer();
+ }
+ }
+ }
+
+ return (
+
+ {/* IDEA PHASE */}
+ {phase === PHASES.IDEA && (
+
+
Describe your game idea
+
+ Our AI will ask you a few questions to help flesh out the details.
+ FREE
+
+
+
setGameIdea(e.target.value)}
+ placeholder={`e.g., "A space shooter where you defend earth from aliens" or "A puzzle game with falling blocks"`}
+ rows={4}
+ maxLength={1000}
+ onKeyDown={handleKeyDown}
+ disabled={loading}
+ />
+
+ {error && {error}
}
+
+
+
+ {loading ? 'Starting...' : 'Refine with AI (FREE)'}
+
+
+ Use guided questions
+
+
+
+ )}
+
+ {/* CHAT PHASE */}
+ {phase === PHASES.CHAT && (
+
+
+
AI Game Designer
+ Question {questionCount} of ~5
+
+
+
+
+ Your idea:
+ {gameIdea}
+
+
+ {conversation.map((msg, i) => (
+
+ {msg.role === 'ai' && AI }
+ {msg.text}
+
+ ))}
+
+ {loading && (
+
+
+
+
+
+ )}
+
+
+
+
+ setCurrentAnswer(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Type your answer..."
+ disabled={loading}
+ autoFocus
+ />
+
+ Answer
+
+
+
+ {error &&
{error}
}
+
+
+ {questionCount >= 2 && (
+
+ I'm done - generate spec
+
+ )}
+
+
+ )}
+
+ {/* REVIEW PHASE */}
+ {phase === PHASES.REVIEW && (
+
+
Your Refined Game Spec
+
Edit anything you'd like, then generate!
+
+
setRefinedSpec(e.target.value)}
+ rows={12}
+ />
+
+ {error && {error}
}
+
+
+ onComplete(refinedSpec)}
+ disabled={!refinedSpec.trim()}
+ >
+ Use This Spec
+
+ {
+ setPhase(PHASES.CHAT);
+ setRefinedSpec('');
+ }}
+ >
+ Ask more
+
+ {
+ setPhase(PHASES.IDEA);
+ setConversation([]);
+ setRefinedSpec('');
+ setQuestionCount(0);
+ }}
+ >
+ Start over
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ShareModal.css b/frontend/src/components/ShareModal.css
new file mode 100644
index 0000000..65f6fe8
--- /dev/null
+++ b/frontend/src/components/ShareModal.css
@@ -0,0 +1,361 @@
+.share-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 1rem;
+}
+
+.share-modal {
+ background: white;
+ border-radius: 1rem;
+ padding: 2rem;
+ max-width: 500px;
+ width: 100%;
+ position: relative;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+.share-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ color: #94a3b8;
+ cursor: pointer;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: all 0.2s;
+}
+
+.share-close:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+}
+
+.share-modal h2 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+ font-size: 1.5rem;
+}
+
+.share-game-title {
+ color: #6366f1;
+ font-weight: 500;
+ margin: 0 0 1.5rem;
+}
+
+.share-loading {
+ text-align: center;
+ padding: 2rem;
+ color: #64748b;
+}
+
+/* Tabs */
+.share-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background: #f1f5f9;
+ padding: 0.25rem;
+ border-radius: 0.5rem;
+}
+
+.share-tab {
+ flex: 1;
+ padding: 0.6rem 1rem;
+ border: none;
+ background: transparent;
+ border-radius: 0.375rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.share-tab:hover {
+ color: #1e293b;
+}
+
+.share-tab.active {
+ background: white;
+ color: #6366f1;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* Content */
+.share-content {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.share-section label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: #475569;
+ margin-bottom: 0.5rem;
+}
+
+.share-input-group {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.share-input-group input,
+.share-input-group textarea {
+ flex: 1;
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-family: monospace;
+ font-size: 0.85rem;
+ color: #475569;
+ background: #f8fafc;
+}
+
+.share-input-group textarea {
+ resize: none;
+}
+
+.copy-btn {
+ padding: 0.75rem 1rem;
+ background: #6366f1;
+ color: white;
+ border: none;
+ border-radius: 0.5rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.copy-btn:hover {
+ background: #4f46e5;
+}
+
+.copy-btn.copied {
+ background: #10b981;
+}
+
+/* Social Buttons */
+.social-buttons {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.75rem;
+}
+
+.social-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border: none;
+ border-radius: 0.5rem;
+ font-weight: 500;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ color: white;
+}
+
+.social-btn svg {
+ flex-shrink: 0;
+}
+
+.social-btn.twitter {
+ background: #000;
+}
+
+.social-btn.twitter:hover {
+ background: #333;
+}
+
+.social-btn.facebook {
+ background: #1877f2;
+}
+
+.social-btn.facebook:hover {
+ background: #0d6efd;
+}
+
+.social-btn.reddit {
+ background: #ff4500;
+}
+
+.social-btn.reddit:hover {
+ background: #e03d00;
+}
+
+.social-btn.whatsapp {
+ background: #25d366;
+}
+
+.social-btn.whatsapp:hover {
+ background: #1da851;
+}
+
+/* Embed Preview */
+.embed-preview {
+ background: #1e293b;
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.embed-preview iframe {
+ display: block;
+ border: none;
+}
+
+.embed-hint {
+ font-size: 0.8rem;
+ color: #94a3b8;
+ margin: 0.5rem 0 0;
+}
+
+/* Responsive */
+@media (max-width: 480px) {
+ .share-modal {
+ padding: 1.5rem;
+ }
+
+ .social-buttons {
+ grid-template-columns: 1fr;
+ }
+
+ .share-input-group {
+ flex-direction: column;
+ }
+
+ .copy-btn {
+ width: 100%;
+ }
+}
+
+/* Touch-friendly styles */
+@media (hover: none), (pointer: coarse) {
+ .share-close {
+ width: 44px;
+ height: 44px;
+ font-size: 1.75rem;
+ }
+
+ .share-tab {
+ min-height: 44px;
+ padding: 0.75rem 1rem;
+ }
+
+ .copy-btn {
+ min-height: 44px;
+ padding: 0.875rem 1.25rem;
+ }
+
+ .social-btn {
+ min-height: 48px;
+ padding: 0.875rem 1.25rem;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .share-close:active {
+ background: #e2e8f0;
+ transform: scale(0.95);
+ }
+
+ .share-tab:active {
+ background: #e2e8f0;
+ transform: scale(0.98);
+ }
+
+ .copy-btn:active {
+ background: #4338ca;
+ transform: scale(0.98);
+ }
+
+ .social-btn:active {
+ opacity: 0.8;
+ transform: scale(0.98);
+ }
+
+ .social-btn.twitter:active {
+ background: #1a1a1a;
+ }
+
+ .social-btn.facebook:active {
+ background: #0b5ed7;
+ }
+
+ .social-btn.reddit:active {
+ background: #cc3700;
+ }
+
+ .social-btn.whatsapp:active {
+ background: #128c48;
+ }
+
+ /* Disable iOS tap highlight */
+ .share-close,
+ .share-tab,
+ .copy-btn,
+ .social-btn {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Hover-only styles for desktop */
+@media (hover: hover) {
+ .share-close:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+ }
+
+ .share-tab:hover {
+ color: #1e293b;
+ }
+
+ .copy-btn:hover {
+ background: #4f46e5;
+ }
+
+ .social-btn.twitter:hover {
+ background: #333;
+ }
+
+ .social-btn.facebook:hover {
+ background: #0d6efd;
+ }
+
+ .social-btn.reddit:hover {
+ background: #e03d00;
+ }
+
+ .social-btn.whatsapp:hover {
+ background: #1da851;
+ }
+}
+
+/* Focus states for accessibility */
+.share-close:focus-visible,
+.share-tab:focus-visible,
+.copy-btn:focus-visible,
+.social-btn:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+}
diff --git a/frontend/src/components/ShareModal.jsx b/frontend/src/components/ShareModal.jsx
new file mode 100644
index 0000000..0d66696
--- /dev/null
+++ b/frontend/src/components/ShareModal.jsx
@@ -0,0 +1,198 @@
+import { useState, useEffect } from 'react';
+import api from '../api';
+import useFocusTrap from '../hooks/useFocusTrap';
+import './ShareModal.css';
+
+export default function ShareModal({ gameId, gameTitle, onClose }) {
+ const [shareData, setShareData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [copied, setCopied] = useState(null);
+ const [activeTab, setActiveTab] = useState('share');
+
+ // Focus trap for accessibility
+ const modalRef = useFocusTrap(true, {
+ onEscape: onClose,
+ autoFocus: true,
+ restoreFocus: true
+ });
+
+ useEffect(() => {
+ loadShareData();
+ }, [gameId]);
+
+ const loadShareData = async () => {
+ try {
+ const data = await api.getShareData(gameId);
+ setShareData(data);
+ } catch (err) {
+ console.error('Failed to load share data:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const copyToClipboard = async (text, type) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(type);
+ setTimeout(() => setCopied(null), 2000);
+ } catch (err) {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ setCopied(type);
+ setTimeout(() => setCopied(null), 2000);
+ }
+ };
+
+ const openShare = (url) => {
+ window.open(url, '_blank', 'width=600,height=400');
+ };
+
+ if (loading) {
+ return (
+
+
e.stopPropagation()}>
+
Loading...
+
+
+ );
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
×
+
+
Share Game
+
{gameTitle}
+
+
+ setActiveTab('share')}
+ >
+ Share Link
+
+ setActiveTab('embed')}
+ >
+ Embed
+
+
+
+ {activeTab === 'share' && shareData && (
+
+ {/* Copy Link */}
+
+
Game Link
+
+
+ copyToClipboard(shareData.urls.play, 'link')}
+ >
+ {copied === 'link' ? 'Copied!' : 'Copy'}
+
+
+
+
+ {/* Social Share Buttons */}
+
+
Share on Social Media
+
+
openShare(shareData.social.twitter)}
+ title="Share on X (Twitter)"
+ >
+
+
+
+ X
+
+
openShare(shareData.social.facebook)}
+ title="Share on Facebook"
+ >
+
+
+
+ Facebook
+
+
openShare(shareData.social.reddit)}
+ title="Share on Reddit"
+ >
+
+
+
+ Reddit
+
+
openShare(shareData.social.whatsapp)}
+ title="Share on WhatsApp"
+ >
+
+
+
+ WhatsApp
+
+
+
+
+ )}
+
+ {activeTab === 'embed' && shareData && (
+
+ {/* Embed Preview */}
+
+
+ {/* Embed Code */}
+
+
Embed Code
+
+
+ copyToClipboard(shareData.embedCode, 'embed')}
+ >
+ {copied === 'embed' ? 'Copied!' : 'Copy'}
+
+
+
+ Paste this code into your website's HTML to embed the game.
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ThemeBanner.css b/frontend/src/components/ThemeBanner.css
new file mode 100644
index 0000000..e737b50
--- /dev/null
+++ b/frontend/src/components/ThemeBanner.css
@@ -0,0 +1,146 @@
+.theme-banner {
+ background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
+ border-radius: 0.75rem;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ color: white;
+ box-shadow: 0 2px 8px rgba(245, 158, 11, 0.25);
+}
+
+.theme-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.theme-main {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.theme-icon {
+ font-size: 1.5rem;
+}
+
+.theme-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+}
+
+.theme-label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ opacity: 0.85;
+}
+
+.theme-banner .theme-name {
+ font-size: 1.1rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.theme-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.theme-bonus {
+ background: rgba(255, 255, 255, 0.25);
+ padding: 0.25rem 0.6rem;
+ border-radius: 1rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.theme-days {
+ background: rgba(0, 0, 0, 0.15);
+ padding: 0.25rem 0.6rem;
+ border-radius: 1rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+/* Compact version for headers/wizards */
+.theme-banner-compact {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ padding: 0.5rem 1rem;
+ border-radius: 2rem;
+ font-size: 0.85rem;
+ color: #92400e;
+ border: 1px solid #f59e0b;
+}
+
+.theme-banner-compact .theme-icon {
+ font-size: 1rem;
+}
+
+.theme-banner-compact .theme-label {
+ font-weight: 500;
+ opacity: 0.8;
+}
+
+.theme-banner-compact .theme-name {
+ font-weight: 700;
+}
+
+.theme-banner-compact .theme-bonus {
+ background: #f59e0b;
+ color: white;
+ padding: 0.125rem 0.5rem;
+ border-radius: 1rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+@media (max-width: 640px) {
+ .theme-banner {
+ padding: 0.6rem 0.75rem;
+ }
+
+ .theme-content {
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .theme-main {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .theme-icon {
+ font-size: 1.25rem;
+ }
+
+ .theme-banner .theme-name {
+ font-size: 1rem;
+ }
+
+ .theme-label {
+ font-size: 0.65rem;
+ }
+
+ .theme-meta {
+ gap: 0.35rem;
+ }
+
+ .theme-bonus,
+ .theme-days {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.5rem;
+ }
+
+ .theme-banner-compact {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+}
diff --git a/frontend/src/components/ThemeBanner.jsx b/frontend/src/components/ThemeBanner.jsx
new file mode 100644
index 0000000..9703b76
--- /dev/null
+++ b/frontend/src/components/ThemeBanner.jsx
@@ -0,0 +1,58 @@
+import { useState, useEffect } from 'react';
+import api from '../api';
+import './ThemeBanner.css';
+
+export default function ThemeBanner({ compact = false }) {
+ const [theme, setTheme] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadTheme();
+ }, []);
+
+ async function loadTheme() {
+ try {
+ const data = await api.getCurrentTheme();
+ setTheme(data.theme);
+ } catch (err) {
+ console.error('Failed to load theme:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (loading || !theme) {
+ return null; // Don't show anything if no theme or loading
+ }
+
+ if (compact) {
+ return (
+
+ 🎯
+ This Week:
+ {theme.name}
+ +{Math.round((theme.bonusXpMultiplier - 1) * 100)}% XP
+
+ );
+ }
+
+ return (
+
+
+
+
🎯
+
+ Weekly Theme
+ {theme.name}
+
+
+
+ +{Math.round((theme.bonusXpMultiplier - 1) * 100)}% XP
+
+ {theme.daysRemaining}d left
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TokenHelpModal.css b/frontend/src/components/TokenHelpModal.css
new file mode 100644
index 0000000..18be90b
--- /dev/null
+++ b/frontend/src/components/TokenHelpModal.css
@@ -0,0 +1,251 @@
+/* Token Help Modal Overlay */
+.token-help-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* Modal Container */
+.token-help-modal {
+ background: white;
+ border-radius: 1.5rem;
+ max-width: 600px;
+ min-width: min(600px, 95vw);
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ position: relative;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Close Button */
+.token-help-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: #f3f4f6;
+ border: none;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ font-size: 1.5rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #6b7280;
+ transition: all 0.2s;
+ line-height: 1;
+}
+
+.token-help-close:hover {
+ background: #e5e7eb;
+ color: #374151;
+ transform: scale(1.1);
+}
+
+/* Header */
+.token-help-header {
+ background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
+ padding: 2rem;
+ text-align: center;
+ border-radius: 1.5rem 1.5rem 0 0;
+}
+
+.token-help-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.token-help-header h2 {
+ color: white;
+ font-size: 1.75rem;
+ margin: 0;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.token-help-subtitle {
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0.5rem 0 0;
+ font-size: 1rem;
+}
+
+/* Content */
+.token-help-content {
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+/* Sections */
+.token-section {
+ display: flex;
+ gap: 1rem;
+ padding: 1rem;
+ background: #f9fafb;
+ border-radius: 1rem;
+ border-left: 4px solid #6366f1;
+}
+
+.token-section-earn {
+ border-left-color: #10b981;
+ background: #ecfdf5;
+}
+
+.token-section-spend {
+ border-left-color: #f59e0b;
+ background: #fffbeb;
+}
+
+.token-section-tips {
+ border-left-color: #8b5cf6;
+ background: #f5f3ff;
+}
+
+.token-section-icon {
+ font-size: 2rem;
+ flex-shrink: 0;
+}
+
+.token-section-content {
+ flex: 1;
+}
+
+.token-section-content h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.1rem;
+ color: #1f2937;
+}
+
+.token-section-content p {
+ margin: 0;
+ color: #4b5563;
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+/* Token List */
+.token-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.token-list li {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ color: #4b5563;
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+.token-list-icon {
+ font-size: 1.1rem;
+ flex-shrink: 0;
+}
+
+.token-list strong {
+ color: #1f2937;
+}
+
+/* Footer */
+.token-help-footer {
+ padding: 1rem 1.5rem 1.5rem;
+ display: flex;
+ justify-content: center;
+}
+
+.token-help-btn {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border: none;
+ padding: 0.875rem 2.5rem;
+ border-radius: 2rem;
+ font-size: 1.1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
+}
+
+.token-help-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
+}
+
+.token-help-btn:active {
+ transform: translateY(0);
+}
+
+/* Mobile Responsive */
+@media (max-width: 480px) {
+ .token-help-modal {
+ border-radius: 1rem;
+ }
+
+ .token-help-header {
+ padding: 1.5rem;
+ border-radius: 1rem 1rem 0 0;
+ }
+
+ .token-help-icon {
+ font-size: 2.5rem;
+ }
+
+ .token-help-header h2 {
+ font-size: 1.5rem;
+ }
+
+ .token-help-content {
+ padding: 1rem;
+ }
+
+ .token-section {
+ padding: 0.875rem;
+ }
+
+ .token-section-icon {
+ font-size: 1.5rem;
+ }
+
+ .token-section-content h3 {
+ font-size: 1rem;
+ }
+
+ .token-section-content p,
+ .token-list li {
+ font-size: 0.9rem;
+ }
+}
diff --git a/frontend/src/components/TokenHelpModal.jsx b/frontend/src/components/TokenHelpModal.jsx
new file mode 100644
index 0000000..0cb837c
--- /dev/null
+++ b/frontend/src/components/TokenHelpModal.jsx
@@ -0,0 +1,107 @@
+import useFocusTrap from '../hooks/useFocusTrap';
+import './TokenHelpModal.css';
+
+export default function TokenHelpModal({ isOpen, onClose }) {
+ // Focus trap with escape key handling and auto-focus
+ const modalRef = useFocusTrap(isOpen, {
+ onEscape: onClose,
+ autoFocus: true,
+ restoreFocus: true
+ });
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+ ×
+
+
+
+
🎁
+
How Tokens Work
+
Your guide to playing and earning!
+
+
+
+ {/* What are tokens */}
+
+ 🪙
+
+
What are Tokens?
+
+ Tokens are like arcade coins! You use them to play awesome games
+ created by other players just like you.
+
+
+
+
+ {/* How to earn */}
+
+ ⭐
+
+
How to Earn Tokens
+
+
+ 🌞
+ Daily Login: Get 50 FREE tokens every day!
+
+
+ 🎮
+ Create Games: When others play your games, YOU earn tokens!
+
+
+
+
+
+ {/* How to spend */}
+
+ ⏰
+
+
How Tokens are Spent
+
+
+ 🎉
+ First Minute FREE: Try any game free for 1 minute!
+
+
+ 🪙
+ Keep Playing: 1 token per minute after the free minute
+
+
+
+
+
+ {/* Pro tips */}
+
+ 💡
+
+
Pro Tips
+
+
+ 🚀
+ Create fun games to earn tokens while you sleep!
+
+
+ 💰
+ Log in every day to get your free tokens!
+
+
+ 🌟
+ Popular games = more players = more tokens for you!
+
+
+
+
+
+
+
+
+ Got it!
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TweakPanel.css b/frontend/src/components/TweakPanel.css
new file mode 100644
index 0000000..69c2bc7
--- /dev/null
+++ b/frontend/src/components/TweakPanel.css
@@ -0,0 +1,371 @@
+.tweak-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.tweak-offline {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 1.5rem;
+ text-align: center;
+ color: #888;
+}
+
+.tweak-offline .offline-icon {
+ font-size: 1.5rem;
+}
+
+.tweak-loading-health {
+ padding: 1rem;
+ text-align: center;
+ color: #888;
+}
+
+.tweak-fallback-banner {
+ background: rgba(234, 179, 8, 0.15);
+ border: 1px solid rgba(234, 179, 8, 0.3);
+ color: #fde047;
+ padding: 0.35rem 0.6rem;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ text-align: center;
+}
+
+.tweak-label {
+ font-size: 0.85rem;
+ color: #aaa;
+ margin-bottom: 0.25rem;
+ display: block;
+}
+
+.quick-tweak-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.quick-tweak-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.35rem 0.6rem;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ color: #ddd;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.quick-tweak-btn:hover {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(100, 200, 255, 0.4);
+}
+
+.quick-tweak-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.qt-icon {
+ font-size: 0.9rem;
+}
+
+.qt-label {
+ font-size: 0.8rem;
+}
+
+.tweak-input-area textarea {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ color: #eee;
+ padding: 0.6rem;
+ font-size: 0.85rem;
+ font-family: inherit;
+ resize: vertical;
+ min-height: 60px;
+}
+
+.tweak-input-area textarea:focus {
+ outline: none;
+ border-color: rgba(100, 200, 255, 0.5);
+}
+
+.tweak-input-area textarea:disabled {
+ opacity: 0.5;
+}
+
+.tweak-input-footer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 0.2rem;
+}
+
+.tweak-input-footer .char-count {
+ font-size: 0.75rem;
+ color: #666;
+}
+
+.tweak-apply-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ font-weight: 600;
+}
+
+.free-badge {
+ background: #22c55e;
+ color: #fff;
+ font-size: 0.7rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+}
+
+.tweak-cancel-btn {
+ width: 100%;
+ margin-top: 0.25rem;
+}
+
+.tweak-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ color: #fca5a5;
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.8rem;
+}
+
+.tweak-error .btn {
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.tweak-success {
+ background: rgba(34, 197, 94, 0.15);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ color: #86efac;
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.8rem;
+}
+
+.tweak-clarification {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: rgba(59, 130, 246, 0.15);
+ border: 1px solid rgba(59, 130, 246, 0.3);
+ color: #93c5fd;
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.8rem;
+}
+
+.tweak-savings {
+ text-align: center;
+ font-size: 0.8rem;
+ color: #86efac;
+ padding: 0.4rem;
+ background: rgba(34, 197, 94, 0.1);
+ border-radius: 6px;
+}
+
+/* Dialog overlay and modal */
+.tweak-dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.tweak-dialog {
+ background: linear-gradient(145deg, #1e293b, #0f172a);
+ border: 1px solid rgba(99, 102, 241, 0.3);
+ border-radius: 16px;
+ padding: 1.5rem;
+ max-width: 400px;
+ width: 100%;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ animation: slideUp 0.25s ease;
+}
+
+@keyframes slideUp {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+.tweak-dialog h3 {
+ margin: 0 0 0.75rem 0;
+ font-size: 1.25rem;
+ color: #fff;
+ text-align: center;
+}
+
+.tweak-dialog-subtitle {
+ text-align: center;
+ color: #94a3b8;
+ font-size: 0.9rem;
+ margin: 0 0 1rem 0;
+}
+
+.tweak-comparison {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1.25rem;
+}
+
+.tweak-original,
+.tweak-optimized {
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ padding: 0.75rem;
+}
+
+.tweak-original label,
+.tweak-optimized label {
+ display: block;
+ font-size: 0.75rem;
+ color: #64748b;
+ margin-bottom: 0.35rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.tweak-original p,
+.tweak-optimized p {
+ margin: 0;
+ color: #cbd5e1;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.tweak-optimized {
+ border: 1px solid rgba(99, 102, 241, 0.3);
+}
+
+.tweak-optimized p {
+ color: #a5b4fc;
+}
+
+.tweak-arrow {
+ text-align: center;
+ color: #6366f1;
+ font-size: 1.25rem;
+}
+
+.tweak-request-display {
+ background: rgba(99, 102, 241, 0.1);
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1.25rem;
+}
+
+.tweak-request-display p {
+ margin: 0;
+ color: #a5b4fc;
+ font-size: 0.95rem;
+ line-height: 1.5;
+ text-align: center;
+}
+
+.tweak-dialog-buttons {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.tweak-dialog-buttons .btn {
+ flex: 1;
+ padding: 0.65rem 1rem;
+}
+
+/* Success dialog */
+.tweak-success-dialog {
+ text-align: center;
+}
+
+.tweak-success-icon {
+ font-size: 3rem;
+ margin-bottom: 0.5rem;
+}
+
+.tweak-changes-description {
+ color: #94a3b8;
+ font-size: 0.95rem;
+ line-height: 1.5;
+ margin: 0.75rem 0 1.25rem 0;
+ padding: 0 0.5rem;
+}
+
+.tweak-success-dialog .btn {
+ width: 100%;
+}
+
+/* Mobile adjustments */
+@media (max-width: 480px) {
+ .tweak-dialog {
+ padding: 1.25rem;
+ margin: 0.5rem;
+ }
+
+ .tweak-dialog h3 {
+ font-size: 1.1rem;
+ }
+
+ .tweak-dialog-buttons {
+ flex-direction: column;
+ }
+
+ .tweak-comparison {
+ gap: 0.5rem;
+ }
+
+ .tweak-arrow {
+ display: none;
+ }
+}
+
+/* Ultra-small screens */
+@media (max-width: 360px) {
+ .tweak-dialog {
+ padding: 1rem;
+ }
+
+ .quick-tweak-buttons {
+ gap: 0.25rem;
+ }
+
+ .quick-tweak-btn {
+ padding: 0.3rem 0.4rem;
+ font-size: 0.75rem;
+ }
+
+ .qt-icon {
+ font-size: 0.8rem;
+ }
+}
diff --git a/frontend/src/components/TweakPanel.jsx b/frontend/src/components/TweakPanel.jsx
new file mode 100644
index 0000000..d05a6e6
--- /dev/null
+++ b/frontend/src/components/TweakPanel.jsx
@@ -0,0 +1,298 @@
+import { useState, useEffect, useRef } from 'react';
+import api from '../api';
+import './TweakPanel.css';
+
+const QUICK_TWEAKS = [
+ { label: 'Colors', icon: '🎨', template: 'Change the colors to ' },
+ { label: 'Speed', icon: '⚡', template: 'Make the player speed ' },
+ { label: 'Size', icon: '📐', template: 'Make the player size ' },
+ { label: 'Text', icon: '✏️', template: 'Change the game title text to ' },
+ { label: 'Difficulty', icon: '🎯', template: 'Make the difficulty ' },
+ { label: 'Sound', icon: '🔊', template: 'Change the sound ' }
+];
+
+export default function TweakPanel({ game, onTweakApplied, onFallbackNeeded }) {
+ const [feedback, setFeedback] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [previewing, setPreviewing] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const [clarificationQuestion, setClarificationQuestion] = useState('');
+ const [tweakCount, setTweakCount] = useState(game?.local_ai_tweaks_count || 0);
+ const [savedCents, setSavedCents] = useState(game?.local_ai_savings_cents || 0);
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const timerRef = useRef(null);
+
+ // Confirmation dialog state
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [previewData, setPreviewData] = useState(null);
+
+ // Success dialog state
+ const [showSuccessDialog, setShowSuccessDialog] = useState(false);
+ const [changesDescription, setChangesDescription] = useState('');
+
+ useEffect(() => {
+ if (loading) {
+ setElapsedSeconds(0);
+ timerRef.current = setInterval(() => {
+ setElapsedSeconds(prev => prev + 1);
+ }, 1000);
+ } else {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ }
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ };
+ }, [loading]);
+
+ function handleQuickTweak(template) {
+ setFeedback(template);
+ setError('');
+ setSuccess('');
+ setClarificationQuestion('');
+ }
+
+ // Step 1: Preview the tweak (get Haiku optimization)
+ async function handlePreviewTweak() {
+ if (!feedback.trim() || feedback.trim().length < 5) {
+ setError('Please describe the change (at least 5 characters)');
+ return;
+ }
+
+ setPreviewing(true);
+ setError('');
+ setClarificationQuestion('');
+
+ try {
+ const result = await api.previewTweak(game.id, feedback);
+
+ if (result.needsClarification) {
+ setClarificationQuestion(result.question);
+ setPreviewing(false);
+ return;
+ }
+
+ if (result.tooComplex) {
+ setError(result.reason || 'This change is too big for a free tweak.');
+ setPreviewing(false);
+ return;
+ }
+
+ if (result.canProceed) {
+ // Show confirmation dialog with the optimized request
+ setPreviewData({
+ original: result.originalRequest,
+ optimized: result.optimizedRequest,
+ wasOptimized: result.wasOptimized
+ });
+ setShowConfirmDialog(true);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setPreviewing(false);
+ }
+ }
+
+ // Step 2: Apply the tweak after user confirms
+ async function handleConfirmAndApply() {
+ setShowConfirmDialog(false);
+ setLoading(true);
+ setError('');
+
+ try {
+ // Use the optimized request from preview
+ const tweakRequest = previewData?.optimized || feedback;
+ const result = await api.applyFreeTweak(game.id, tweakRequest);
+
+ if (!result.success && result.needsClarification) {
+ setClarificationQuestion(result.question);
+ return;
+ }
+
+ if (!result.success && result.cannotTweak) {
+ setError(result.reason || 'This change is too big for a free tweak.');
+ return;
+ }
+
+ if (!result.success) {
+ setError(result.error || 'Too complex for free tweaks. Try a simpler change.');
+ return;
+ }
+
+ if (result.success) {
+ setTweakCount(result.tweakCount);
+ setSavedCents(result.savedCents);
+ setFeedback('');
+ setPreviewData(null);
+
+ // Show success dialog with changes description
+ setChangesDescription(result.changesDescription || 'Changes applied successfully!');
+ setShowSuccessDialog(true);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // Step 3: User dismisses success dialog and game reloads
+ function handleSuccessClose() {
+ setShowSuccessDialog(false);
+ if (onTweakApplied) onTweakApplied();
+ else window.location.reload();
+ }
+
+ function handleFallbackToClaude() {
+ if (onFallbackNeeded) {
+ onFallbackNeeded(feedback);
+ }
+ }
+
+ function handleCancelConfirm() {
+ setShowConfirmDialog(false);
+ setPreviewData(null);
+ }
+
+ return (
+
+
+
Quick tweaks:
+
+ {QUICK_TWEAKS.map(tweak => (
+ handleQuickTweak(tweak.template)}
+ disabled={loading || previewing}
+ >
+ {tweak.icon}
+ {tweak.label}
+
+ ))}
+
+
+
+
+
setFeedback(e.target.value)}
+ placeholder="Describe the small change you want (e.g., 'make the background dark blue' or 'change player speed to 8')"
+ rows={3}
+ maxLength={500}
+ disabled={loading || previewing}
+ />
+
+ {feedback.length}/500
+
+
+
+ {clarificationQuestion && (
+
+ {clarificationQuestion}
+
+ )}
+
+ {error && (
+
+ {error}
+ {(error.includes('complex') || error.includes('Overhaul') || error.includes('big')) && (
+
+ Use AI Overhaul (~$0.05)
+
+ )}
+
+ )}
+ {success &&
{success}
}
+
+
+ {previewing ? (
+ <>Checking...>
+ ) : loading ? (
+ <>Applying... ({elapsedSeconds}s)>
+ ) : (
+ <>Preview Tweak FREE >
+ )}
+
+
+ {loading && (
+
setLoading(false)}
+ >
+ Cancel
+
+ )}
+
+ {(tweakCount > 0 || savedCents > 0) && (
+
+ {tweakCount} free tweak{tweakCount !== 1 ? 's' : ''} applied, saved ~${(savedCents / 100).toFixed(2)}!
+
+ )}
+
+ {/* Confirmation Dialog */}
+ {showConfirmDialog && previewData && (
+
+
e.stopPropagation()}>
+
Confirm Tweak
+
+ {previewData.wasOptimized ? (
+ <>
+
AI optimized your request:
+
+
+
Your request:
+
{previewData.original}
+
+
→
+
+
Optimized:
+
{previewData.optimized}
+
+
+ >
+ ) : (
+ <>
+
Ready to apply:
+
+
{previewData.optimized}
+
+ >
+ )}
+
+
+
+ Cancel
+
+
+ Apply Tweak
+
+
+
+
+ )}
+
+ {/* Success Dialog */}
+ {showSuccessDialog && (
+
+
e.stopPropagation()}>
+
✅
+
Tweak Applied!
+
{changesDescription}
+
+ Test Your Game
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/UpgradePrompt.css b/frontend/src/components/UpgradePrompt.css
new file mode 100644
index 0000000..ae9555f
--- /dev/null
+++ b/frontend/src/components/UpgradePrompt.css
@@ -0,0 +1,372 @@
+.upgrade-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 1rem;
+ animation: fadeIn 0.2s ease-out;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.upgrade-modal {
+ background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
+ border-radius: 1.5rem;
+ padding: 2rem;
+ max-width: 480px;
+ width: 100%;
+ position: relative;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ animation: slideUp 0.3s ease-out;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.upgrade-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ color: #94a3b8;
+ cursor: pointer;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: all 0.2s;
+}
+
+.upgrade-close:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+}
+
+/* Header */
+.upgrade-header {
+ text-align: center;
+ margin-bottom: 1.5rem;
+}
+
+.upgrade-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 0.75rem;
+ animation: bounce 1s ease-in-out infinite;
+}
+
+@keyframes bounce {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+
+.upgrade-header h2 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+ font-size: 1.75rem;
+ font-weight: 700;
+}
+
+.upgrade-subtitle {
+ color: #64748b;
+ font-size: 1rem;
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* Benefits */
+.upgrade-benefits {
+ background: #f8fafc;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ margin-bottom: 1.5rem;
+}
+
+.upgrade-benefits h3 {
+ font-size: 0.9rem;
+ color: #475569;
+ margin: 0 0 1rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.benefits-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.benefit-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.5rem;
+ background: white;
+ border-radius: 0.75rem;
+ border: 1px solid #e2e8f0;
+ transition: all 0.2s;
+}
+
+.benefit-item:hover {
+ border-color: #6366f1;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
+}
+
+.benefit-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
+ border-radius: 0.5rem;
+}
+
+.benefit-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.benefit-text strong {
+ color: #1e293b;
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.benefit-text span {
+ color: #64748b;
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+
+/* Actions */
+.upgrade-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.upgrade-btn {
+ padding: 1rem 2rem;
+ font-size: 1.1rem;
+ font-weight: 600;
+ border-radius: 0.75rem;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border: none;
+ color: white;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
+}
+
+.upgrade-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
+}
+
+.upgrade-btn:active {
+ transform: translateY(0);
+}
+
+.maybe-later-btn {
+ padding: 0.75rem 1.5rem;
+ font-size: 0.95rem;
+ font-weight: 500;
+ border-radius: 0.75rem;
+ background: transparent;
+ border: 2px solid #e2e8f0;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.maybe-later-btn:hover {
+ background: #f1f5f9;
+ border-color: #cbd5e1;
+ color: #475569;
+}
+
+/* Note */
+.upgrade-note {
+ text-align: center;
+ font-size: 0.85rem;
+ color: #94a3b8;
+ margin: 0;
+ padding-top: 0.5rem;
+ border-top: 1px solid #f1f5f9;
+}
+
+/* Responsive */
+@media (max-width: 480px) {
+ .upgrade-modal {
+ padding: 1.5rem;
+ border-radius: 1rem;
+ margin: 0.5rem;
+ }
+
+ .upgrade-icon {
+ font-size: 2.5rem;
+ }
+
+ .upgrade-header h2 {
+ font-size: 1.5rem;
+ }
+
+ .upgrade-subtitle {
+ font-size: 0.9rem;
+ }
+
+ .upgrade-benefits {
+ padding: 1rem;
+ }
+
+ .benefit-item {
+ padding: 0.375rem;
+ }
+
+ .benefit-icon {
+ width: 36px;
+ height: 36px;
+ font-size: 1.25rem;
+ }
+
+ .benefit-text strong {
+ font-size: 0.9rem;
+ }
+
+ .benefit-text span {
+ font-size: 0.8rem;
+ }
+
+ .upgrade-btn {
+ padding: 0.875rem 1.5rem;
+ font-size: 1rem;
+ }
+}
+
+/* Touch-friendly styles */
+@media (hover: none), (pointer: coarse) {
+ .upgrade-close {
+ width: 44px;
+ height: 44px;
+ font-size: 1.75rem;
+ }
+
+ .benefit-item {
+ min-height: 60px;
+ padding: 0.75rem;
+ }
+
+ .upgrade-btn {
+ min-height: 52px;
+ padding: 1rem 2rem;
+ }
+
+ .maybe-later-btn {
+ min-height: 48px;
+ padding: 0.875rem 1.5rem;
+ }
+}
+
+/* Active states for touch devices */
+@media (hover: none) {
+ .upgrade-close:active {
+ background: #e2e8f0;
+ transform: scale(0.95);
+ }
+
+ .benefit-item:active {
+ background: #f1f5f9;
+ border-color: #a5b4fc;
+ transform: scale(0.99);
+ }
+
+ .upgrade-btn:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+ box-shadow: 0 2px 10px rgba(99, 102, 241, 0.3);
+ }
+
+ .maybe-later-btn:active {
+ background: #e2e8f0;
+ transform: scale(0.98);
+ }
+
+ /* Disable iOS tap highlight */
+ .upgrade-close,
+ .upgrade-btn,
+ .maybe-later-btn {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Hover-only styles for desktop */
+@media (hover: hover) {
+ .upgrade-close:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+ }
+
+ .benefit-item:hover {
+ border-color: #6366f1;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
+ }
+
+ .upgrade-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
+ }
+
+ .maybe-later-btn:hover {
+ background: #f1f5f9;
+ border-color: #cbd5e1;
+ color: #475569;
+ }
+}
+
+/* Focus states for accessibility */
+.upgrade-close:focus-visible,
+.upgrade-btn:focus-visible,
+.maybe-later-btn:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+}
diff --git a/frontend/src/components/UpgradePrompt.jsx b/frontend/src/components/UpgradePrompt.jsx
new file mode 100644
index 0000000..a9ef4f1
--- /dev/null
+++ b/frontend/src/components/UpgradePrompt.jsx
@@ -0,0 +1,70 @@
+import { useNavigate } from 'react-router-dom';
+import useFocusTrap from '../hooks/useFocusTrap';
+import './UpgradePrompt.css';
+
+export default function UpgradePrompt({ onClose, feature = 'this feature' }) {
+ const navigate = useNavigate();
+
+ // Focus trap with escape key handling and auto-focus
+ const modalRef = useFocusTrap(true, {
+ onEscape: onClose,
+ autoFocus: true,
+ restoreFocus: true
+ });
+
+ const handleCreateAccount = () => {
+ onClose();
+ navigate('/register');
+ };
+
+ const benefits = [
+ { icon: '💾', title: 'Save Your Progress', description: 'Keep track of your high scores and achievements' },
+ { icon: '🎮', title: 'Create Games', description: 'Design your own games with our easy wizard' },
+ { icon: '🏆', title: 'Earn Achievements', description: 'Unlock cool badges as you play and create' },
+ { icon: '👥', title: 'Follow Creators', description: 'Get notified when your favorite creators make new games' }
+ ];
+
+ return (
+
+
e.stopPropagation()}>
+
×
+
+
+
🌟
+
Create Your Free Account!
+
+ You need an account to {feature}. It's free and only takes a minute!
+
+
+
+
+
With a free account, you can:
+
+
+
+
+
+ Create Account
+
+
+ Maybe Later
+
+
+
+
+ You can still play all games as a guest - no worries!
+
+
+
+ );
+}
diff --git a/frontend/src/components/XPDisplay.css b/frontend/src/components/XPDisplay.css
new file mode 100644
index 0000000..3e08b7c
--- /dev/null
+++ b/frontend/src/components/XPDisplay.css
@@ -0,0 +1,98 @@
+.xp-display {
+ padding: 1rem;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border-radius: 0.75rem;
+}
+
+.level-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.level-badge {
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+ color: white;
+ padding: 0.375rem 0.75rem;
+ border-radius: 1rem;
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+
+.level-name {
+ font-weight: 600;
+ color: #92400e;
+ font-size: 1.1rem;
+}
+
+.xp-progress {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.xp-bar {
+ height: 12px;
+ background: rgba(255, 255, 255, 0.6);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.xp-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%);
+ border-radius: 6px;
+ transition: width 0.3s ease;
+}
+
+.xp-text {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.8rem;
+ color: #92400e;
+}
+
+.xp-needed {
+ opacity: 0.8;
+}
+
+/* Compact version for header */
+.xp-display-compact {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.xp-display-compact .level-badge {
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+ color: white;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.5rem;
+ font-weight: 600;
+ font-size: 0.75rem;
+}
+
+.level-name-compact {
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: #92400e;
+}
+
+.xp-amount-compact {
+ font-size: 0.65rem;
+ color: #b45309;
+ opacity: 0.8;
+}
+
+.xp-bar-mini {
+ width: 40px;
+ height: 6px;
+ background: rgba(245, 158, 11, 0.3);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.xp-bar-mini .xp-fill {
+ background: #f59e0b;
+}
diff --git a/frontend/src/components/XPDisplay.jsx b/frontend/src/components/XPDisplay.jsx
new file mode 100644
index 0000000..9de6b7e
--- /dev/null
+++ b/frontend/src/components/XPDisplay.jsx
@@ -0,0 +1,64 @@
+import './XPDisplay.css';
+
+const LEVELS = [
+ { level: 1, name: 'Newbie', xpRequired: 0 },
+ { level: 2, name: 'Beginner', xpRequired: 100 },
+ { level: 3, name: 'Player', xpRequired: 250 },
+ { level: 4, name: 'Gamer', xpRequired: 500 },
+ { level: 5, name: 'Pro', xpRequired: 1000 },
+ { level: 6, name: 'Expert', xpRequired: 2000 },
+ { level: 7, name: 'Master', xpRequired: 3500 },
+ { level: 8, name: 'Champion', xpRequired: 5500 },
+ { level: 9, name: 'Legend', xpRequired: 8000 },
+ { level: 10, name: 'Grandmaster', xpRequired: 10000 }
+];
+
+export default function XPDisplay({ xp = 0, level = 1, levelName = 'Newbie', compact = false }) {
+ const currentLevelData = LEVELS.find(l => l.level === level) || LEVELS[0];
+ const nextLevelData = LEVELS.find(l => l.level === level + 1);
+
+ const xpInCurrentLevel = xp - currentLevelData.xpRequired;
+ const xpNeededForNext = nextLevelData
+ ? nextLevelData.xpRequired - currentLevelData.xpRequired
+ : 0;
+ const progressPercent = nextLevelData
+ ? Math.min(100, (xpInCurrentLevel / xpNeededForNext) * 100)
+ : 100;
+
+ if (compact) {
+ return (
+
+
Lv.{level}
+
{levelName}
+
{xp.toLocaleString()} XP
+
+
+ );
+ }
+
+ return (
+
+
+ Level {level}
+ {levelName}
+
+
+
+
+ {nextLevelData ? (
+ <>
+ {xp.toLocaleString()} XP
+ {nextLevelData.xpRequired.toLocaleString()} XP to next level
+ >
+ ) : (
+ Max Level!
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
new file mode 100644
index 0000000..2d0406b
--- /dev/null
+++ b/frontend/src/context/AuthContext.jsx
@@ -0,0 +1,158 @@
+import { createContext, useContext, useState, useEffect } from 'react';
+import api from '../api';
+import { useFingerprint } from '../hooks/useFingerprint';
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [showInstallPrompt, setShowInstallPrompt] = useState(false);
+ const { fingerprint, loading: fpLoading } = useFingerprint();
+
+ useEffect(() => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ api.getMe()
+ .then(data => setUser(normalizeUser(data.user)))
+ .catch(() => {
+ localStorage.removeItem('token');
+ })
+ .finally(() => setLoading(false));
+ } else {
+ setLoading(false);
+ }
+ }, []);
+
+ // Normalize user data from API to consistent format
+ function normalizeUser(userData) {
+ const xpData = userData.xp || {};
+ return {
+ id: userData.id,
+ username: userData.username,
+ email: userData.email,
+ displayName: userData.displayName,
+ isGuest: userData.isGuest,
+ tokensBalance: userData.tokensBalance,
+ totalTokensEarned: userData.totalTokensEarned,
+ role: userData.role,
+ createdAt: userData.createdAt,
+ avatarData: userData.avatarData,
+ // XP data flattened for easier access
+ xp: xpData.xp || 0,
+ level: xpData.level || 1,
+ levelName: xpData.badge || 'Newbie',
+ levelIcon: xpData.icon || '🌱',
+ xpProgress: xpData.progress || 0,
+ xpNeeded: xpData.xpNeeded || 100,
+ // Onboarding state
+ onboardingComplete: userData.onboardingComplete || false,
+ onboardingStep: userData.onboardingStep || 0,
+ // Streak data
+ loginStreak: userData.loginStreak || 0,
+ creationStreak: userData.creationStreak || 0
+ };
+ }
+
+ const login = async (email, password) => {
+ const data = await api.login(email, password);
+ setUser(normalizeUser(data.user));
+ // Trigger install prompt after login
+ setTimeout(() => setShowInstallPrompt(true), 500);
+ return data;
+ };
+
+ const register = async (username, email, password) => {
+ const data = await api.register(username, email, password);
+ setUser(normalizeUser(data.user));
+ // Trigger install prompt after registration
+ setTimeout(() => setShowInstallPrompt(true), 500);
+ return data;
+ };
+
+ const guestLogin = async (fingerprint) => {
+ const data = await api.guestLogin(fingerprint);
+ setUser(normalizeUser(data.user));
+ return data;
+ };
+
+ const logout = () => {
+ api.logout();
+ setUser(null);
+ };
+
+ const updateTokens = (newBalance) => {
+ if (user) {
+ setUser({ ...user, tokensBalance: newBalance });
+ }
+ };
+
+ const refreshUser = async () => {
+ try {
+ const data = await api.getMe();
+ setUser(normalizeUser(data.user));
+ } catch {
+ // ignore
+ }
+ };
+
+ const completeOnboarding = () => {
+ if (user) {
+ setUser({ ...user, onboardingComplete: true, onboardingStep: 4 });
+ }
+ };
+
+ // Set guest user from play response (auto-created guest)
+ const setGuestUser = (guestData) => {
+ if (guestData) {
+ setUser({
+ id: guestData.id,
+ username: guestData.username,
+ displayName: guestData.username,
+ isGuest: true,
+ tokensBalance: guestData.tokensBalance || 5,
+ role: 'user',
+ xp: 0,
+ level: 1,
+ levelName: 'Guest',
+ levelIcon: '👋'
+ });
+ }
+ };
+
+ // Dismiss the install prompt
+ const dismissInstallPrompt = () => {
+ setShowInstallPrompt(false);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within AuthProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/hooks/useFingerprint.js b/frontend/src/hooks/useFingerprint.js
new file mode 100644
index 0000000..961be06
--- /dev/null
+++ b/frontend/src/hooks/useFingerprint.js
@@ -0,0 +1,77 @@
+import { useState, useEffect } from 'react';
+import FingerprintJS from '@fingerprintjs/fingerprintjs';
+
+const FINGERPRINT_KEY = 'gamercomp_device_fp';
+
+// Cache fingerprint in memory and localStorage
+let cachedFingerprint = null;
+
+export function useFingerprint() {
+ const [fingerprint, setFingerprint] = useState(() => {
+ // Try to get from memory cache first
+ if (cachedFingerprint) return cachedFingerprint;
+ // Then from localStorage
+ const stored = localStorage.getItem(FINGERPRINT_KEY);
+ if (stored) {
+ cachedFingerprint = stored;
+ return stored;
+ }
+ return null;
+ });
+ const [loading, setLoading] = useState(!fingerprint);
+
+ useEffect(() => {
+ if (fingerprint) {
+ setLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+
+ async function generateFingerprint() {
+ try {
+ const fp = await FingerprintJS.load();
+ const result = await fp.get();
+ const visitorId = result.visitorId;
+
+ if (!cancelled) {
+ cachedFingerprint = visitorId;
+ localStorage.setItem(FINGERPRINT_KEY, visitorId);
+ setFingerprint(visitorId);
+ setLoading(false);
+ }
+ } catch (err) {
+ console.error('Failed to generate fingerprint:', err);
+ if (!cancelled) {
+ // Generate a fallback random ID
+ const fallback = 'fb_' + Math.random().toString(36).substring(2) + Date.now().toString(36);
+ cachedFingerprint = fallback;
+ localStorage.setItem(FINGERPRINT_KEY, fallback);
+ setFingerprint(fallback);
+ setLoading(false);
+ }
+ }
+ }
+
+ generateFingerprint();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [fingerprint]);
+
+ return { fingerprint, loading };
+}
+
+// Sync getter for API client
+export function getFingerprint() {
+ if (cachedFingerprint) return cachedFingerprint;
+ const stored = localStorage.getItem(FINGERPRINT_KEY);
+ if (stored) {
+ cachedFingerprint = stored;
+ return stored;
+ }
+ return null;
+}
+
+export default useFingerprint;
diff --git a/frontend/src/hooks/useFocusTrap.js b/frontend/src/hooks/useFocusTrap.js
new file mode 100644
index 0000000..98b4762
--- /dev/null
+++ b/frontend/src/hooks/useFocusTrap.js
@@ -0,0 +1,125 @@
+import { useEffect, useRef, useCallback } from 'react';
+
+/**
+ * Custom hook for trapping focus within a modal/dialog
+ * Implements WCAG 2.1 Level AA focus management
+ *
+ * @param {boolean} isActive - Whether the focus trap is active
+ * @param {Object} options - Configuration options
+ * @param {Function} options.onEscape - Callback when Escape key is pressed
+ * @param {boolean} options.autoFocus - Whether to auto-focus first element (default: true)
+ * @param {boolean} options.restoreFocus - Whether to restore focus on close (default: true)
+ * @returns {Object} - ref to attach to the container element
+ */
+export default function useFocusTrap(isActive, options = {}) {
+ const {
+ onEscape,
+ autoFocus = true,
+ restoreFocus = true
+ } = options;
+
+ const containerRef = useRef(null);
+ const previousActiveElement = useRef(null);
+
+ // Get all focusable elements within container
+ const getFocusableElements = useCallback(() => {
+ if (!containerRef.current) return [];
+
+ const focusableSelectors = [
+ 'button:not([disabled])',
+ 'a[href]',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(', ');
+
+ return Array.from(containerRef.current.querySelectorAll(focusableSelectors))
+ .filter(el => el.offsetParent !== null); // Filter out hidden elements
+ }, []);
+
+ // Handle keyboard navigation
+ const handleKeyDown = useCallback((e) => {
+ if (e.key === 'Escape' && onEscape) {
+ e.preventDefault();
+ onEscape();
+ return;
+ }
+
+ if (e.key !== 'Tab') return;
+
+ const focusableElements = getFocusableElements();
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ // Shift + Tab (backwards)
+ if (e.shiftKey) {
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement.focus();
+ }
+ }
+ // Tab (forwards)
+ else {
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement.focus();
+ }
+ }
+ }, [getFocusableElements, onEscape]);
+
+ useEffect(() => {
+ if (!isActive) return;
+
+ // Store the previously focused element
+ if (restoreFocus) {
+ previousActiveElement.current = document.activeElement;
+ }
+
+ // Prevent body scroll
+ const originalOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ // Auto-focus first focusable element
+ if (autoFocus) {
+ // Use setTimeout to ensure the modal is rendered
+ const timer = setTimeout(() => {
+ const focusableElements = getFocusableElements();
+ if (focusableElements.length > 0) {
+ focusableElements[0].focus();
+ }
+ }, 10);
+
+ return () => {
+ clearTimeout(timer);
+ document.body.style.overflow = originalOverflow;
+
+ // Restore focus to previous element
+ if (restoreFocus && previousActiveElement.current) {
+ previousActiveElement.current.focus();
+ }
+ };
+ }
+
+ return () => {
+ document.body.style.overflow = originalOverflow;
+
+ // Restore focus to previous element
+ if (restoreFocus && previousActiveElement.current) {
+ previousActiveElement.current.focus();
+ }
+ };
+ }, [isActive, autoFocus, restoreFocus, getFocusableElements]);
+
+ // Add keydown listener
+ useEffect(() => {
+ if (!isActive) return;
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isActive, handleKeyDown]);
+
+ return containerRef;
+}
diff --git a/frontend/src/hooks/useOfflineGames.js b/frontend/src/hooks/useOfflineGames.js
new file mode 100644
index 0000000..c5a0c70
--- /dev/null
+++ b/frontend/src/hooks/useOfflineGames.js
@@ -0,0 +1,116 @@
+import { useState, useEffect, useCallback } from 'react';
+
+const CACHE_NAME = 'offline-games-v1';
+const STORAGE_KEY = 'offline_games_list';
+
+export function useOfflineGames() {
+ const [savedGames, setSavedGames] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Load saved games list from localStorage
+ useEffect(() => {
+ const loadSavedGames = () => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ setSavedGames(JSON.parse(saved));
+ }
+ } catch (err) {
+ console.error('Failed to load offline games list:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadSavedGames();
+ }, []);
+
+ // Save a game for offline play
+ const saveGameOffline = useCallback(async (gameId, gameCode, gameData) => {
+ try {
+ // Store game code in IndexedDB via Cache API
+ if ('caches' in window) {
+ const cache = await caches.open(CACHE_NAME);
+ const response = new Response(JSON.stringify({
+ gameCode,
+ ...gameData,
+ savedAt: Date.now()
+ }), {
+ headers: { 'Content-Type': 'application/json' }
+ });
+ await cache.put(`/offline-game/${gameId}`, response);
+ }
+
+ // Update saved games list
+ const newGame = {
+ id: gameId,
+ title: gameData.title,
+ savedAt: Date.now()
+ };
+
+ setSavedGames(prev => {
+ const filtered = prev.filter(g => g.id !== gameId);
+ const updated = [newGame, ...filtered];
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+
+ return true;
+ } catch (err) {
+ console.error('Failed to save game offline:', err);
+ return false;
+ }
+ }, []);
+
+ // Remove a game from offline storage
+ const removeGameOffline = useCallback(async (gameId) => {
+ try {
+ if ('caches' in window) {
+ const cache = await caches.open(CACHE_NAME);
+ await cache.delete(`/offline-game/${gameId}`);
+ }
+
+ setSavedGames(prev => {
+ const updated = prev.filter(g => g.id !== gameId);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+
+ return true;
+ } catch (err) {
+ console.error('Failed to remove offline game:', err);
+ return false;
+ }
+ }, []);
+
+ // Get a saved game's data
+ const getOfflineGame = useCallback(async (gameId) => {
+ try {
+ if ('caches' in window) {
+ const cache = await caches.open(CACHE_NAME);
+ const response = await cache.match(`/offline-game/${gameId}`);
+ if (response) {
+ return await response.json();
+ }
+ }
+ return null;
+ } catch (err) {
+ console.error('Failed to get offline game:', err);
+ return null;
+ }
+ }, []);
+
+ // Check if a game is saved offline
+ const isGameSaved = useCallback((gameId) => {
+ return savedGames.some(g => g.id === gameId || g.id === String(gameId));
+ }, [savedGames]);
+
+ return {
+ savedGames,
+ loading,
+ saveGameOffline,
+ removeGameOffline,
+ getOfflineGame,
+ isGameSaved
+ };
+}
diff --git a/frontend/src/hooks/useThumbnailRecorder.js b/frontend/src/hooks/useThumbnailRecorder.js
new file mode 100644
index 0000000..24a412c
--- /dev/null
+++ b/frontend/src/hooks/useThumbnailRecorder.js
@@ -0,0 +1,121 @@
+import { useRef, useState, useCallback } from 'react';
+
+// Records a short animated thumbnail from an iframe's canvas
+export default function useThumbnailRecorder() {
+ const [isRecording, setIsRecording] = useState(false);
+ const [thumbnailData, setThumbnailData] = useState(null);
+ const [error, setError] = useState(null);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+
+ const startRecording = useCallback((iframe, durationMs = 3000) => {
+ return new Promise((resolve, reject) => {
+ try {
+ setError(null);
+ setIsRecording(true);
+ chunksRef.current = [];
+
+ // Try to get the canvas from the iframe
+ const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
+ if (!iframeDoc) {
+ throw new Error('Cannot access iframe content');
+ }
+
+ const canvas = iframeDoc.querySelector('canvas');
+ if (!canvas) {
+ throw new Error('No canvas found in game');
+ }
+
+ // Capture stream from canvas
+ const stream = canvas.captureStream(15); // 15 fps for smaller file
+
+ // Create MediaRecorder
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
+ ? 'video/webm;codecs=vp9'
+ : 'video/webm';
+
+ const recorder = new MediaRecorder(stream, {
+ mimeType,
+ videoBitsPerSecond: 500000 // 500kbps for small file size
+ });
+
+ mediaRecorderRef.current = recorder;
+
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) {
+ chunksRef.current.push(e.data);
+ }
+ };
+
+ recorder.onstop = async () => {
+ setIsRecording(false);
+
+ if (chunksRef.current.length === 0) {
+ const err = new Error('No data recorded');
+ setError(err.message);
+ reject(err);
+ return;
+ }
+
+ const blob = new Blob(chunksRef.current, { type: mimeType });
+
+ // Convert to base64
+ const reader = new FileReader();
+ reader.onload = () => {
+ const base64 = reader.result;
+ setThumbnailData(base64);
+ resolve(base64);
+ };
+ reader.onerror = () => {
+ const err = new Error('Failed to convert recording');
+ setError(err.message);
+ reject(err);
+ };
+ reader.readAsDataURL(blob);
+ };
+
+ recorder.onerror = (e) => {
+ setIsRecording(false);
+ const err = new Error('Recording failed: ' + e.error?.message || 'Unknown error');
+ setError(err.message);
+ reject(err);
+ };
+
+ // Start recording
+ recorder.start();
+
+ // Stop after duration
+ setTimeout(() => {
+ if (recorder.state === 'recording') {
+ recorder.stop();
+ }
+ }, durationMs);
+
+ } catch (err) {
+ setIsRecording(false);
+ setError(err.message);
+ reject(err);
+ }
+ });
+ }, []);
+
+ const stopRecording = useCallback(() => {
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ }
+ }, []);
+
+ const clearThumbnail = useCallback(() => {
+ setThumbnailData(null);
+ setError(null);
+ }, []);
+
+ return {
+ isRecording,
+ thumbnailData,
+ error,
+ startRecording,
+ stopRecording,
+ clearThumbnail
+ };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..a875cfa
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,302 @@
+:root {
+ /* ============================================
+ PRIMARY COLORS - Purple Gradient
+ ============================================ */
+ --color-primary: #6366f1;
+ --color-primary-light: #818cf8;
+ --color-primary-dark: #4f46e5;
+ --color-primary-accent: #8b5cf6;
+ --color-primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ --color-primary-gradient-hover: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+
+ /* ============================================
+ SECONDARY COLORS - Yellow/Gold
+ ============================================ */
+ --color-secondary: #fbbf24;
+ --color-secondary-light: #fcd34d;
+ --color-secondary-dark: #f59e0b;
+ --color-secondary-muted: #fef3c7;
+
+ /* ============================================
+ NEUTRAL COLORS - Slate Palette
+ ============================================ */
+ --color-slate-50: #f8fafc;
+ --color-slate-100: #f1f5f9;
+ --color-slate-200: #e2e8f0;
+ --color-slate-300: #cbd5e1;
+ --color-slate-400: #94a3b8;
+ --color-slate-500: #64748b;
+ --color-slate-600: #475569;
+ --color-slate-700: #334155;
+ --color-slate-800: #1e293b;
+ --color-slate-900: #0f172a;
+ --color-slate-950: #020617;
+
+ /* ============================================
+ SEMANTIC COLORS
+ ============================================ */
+ /* Success - Green */
+ --color-success: #22c55e;
+ --color-success-light: #4ade80;
+ --color-success-dark: #16a34a;
+ --color-success-bg: #dcfce7;
+ --color-success-border: #86efac;
+
+ /* Danger - Red */
+ --color-danger: #ef4444;
+ --color-danger-light: #f87171;
+ --color-danger-dark: #dc2626;
+ --color-danger-bg: #fee2e2;
+ --color-danger-border: #fca5a5;
+
+ /* Warning - Amber */
+ --color-warning: #f59e0b;
+ --color-warning-light: #fbbf24;
+ --color-warning-dark: #d97706;
+ --color-warning-bg: #fef3c7;
+ --color-warning-border: #fcd34d;
+
+ /* Info - Blue */
+ --color-info: #3b82f6;
+ --color-info-light: #60a5fa;
+ --color-info-dark: #2563eb;
+ --color-info-bg: #dbeafe;
+ --color-info-border: #93c5fd;
+
+ /* ============================================
+ SURFACE COLORS (Light Mode)
+ ============================================ */
+ --color-background: #f8fafc;
+ --color-surface: #ffffff;
+ --color-surface-elevated: #ffffff;
+ --color-surface-muted: #f1f5f9;
+ --color-border: #e2e8f0;
+ --color-border-muted: #f1f5f9;
+
+ /* ============================================
+ TEXT COLORS (Light Mode)
+ ============================================ */
+ --color-text: #1e293b;
+ --color-text-secondary: #475569;
+ --color-text-muted: #64748b;
+ --color-text-disabled: #94a3b8;
+ --color-text-inverse: #ffffff;
+ --color-text-on-primary: #ffffff;
+
+ /* ============================================
+ TYPOGRAPHY
+ ============================================ */
+ /* Font Families */
+ --font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+ --font-family-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ --font-family-display: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+
+ /* Font Sizes */
+ --font-size-xs: 0.75rem; /* 12px */
+ --font-size-sm: 0.875rem; /* 14px */
+ --font-size-base: 1rem; /* 16px */
+ --font-size-lg: 1.125rem; /* 18px */
+ --font-size-xl: 1.25rem; /* 20px */
+ --font-size-2xl: 1.5rem; /* 24px */
+ --font-size-3xl: 1.875rem; /* 30px */
+ --font-size-4xl: 2.25rem; /* 36px */
+ --font-size-5xl: 3rem; /* 48px */
+
+ /* Font Weights */
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --font-weight-extrabold: 800;
+
+ /* Line Heights */
+ --line-height-none: 1;
+ --line-height-tight: 1.25;
+ --line-height-snug: 1.375;
+ --line-height-normal: 1.5;
+ --line-height-relaxed: 1.625;
+ --line-height-loose: 2;
+
+ /* Letter Spacing */
+ --letter-spacing-tighter: -0.05em;
+ --letter-spacing-tight: -0.025em;
+ --letter-spacing-normal: 0;
+ --letter-spacing-wide: 0.025em;
+ --letter-spacing-wider: 0.05em;
+ --letter-spacing-widest: 0.1em;
+
+ /* ============================================
+ SPACING
+ ============================================ */
+ --spacing-px: 1px;
+ --spacing-0: 0;
+ --spacing-0-5: 0.125rem; /* 2px */
+ --spacing-1: 0.25rem; /* 4px */
+ --spacing-1-5: 0.375rem; /* 6px */
+ --spacing-2: 0.5rem; /* 8px */
+ --spacing-2-5: 0.625rem; /* 10px */
+ --spacing-3: 0.75rem; /* 12px */
+ --spacing-3-5: 0.875rem; /* 14px */
+ --spacing-4: 1rem; /* 16px */
+ --spacing-5: 1.25rem; /* 20px */
+ --spacing-6: 1.5rem; /* 24px */
+ --spacing-7: 1.75rem; /* 28px */
+ --spacing-8: 2rem; /* 32px */
+ --spacing-9: 2.25rem; /* 36px */
+ --spacing-10: 2.5rem; /* 40px */
+ --spacing-12: 3rem; /* 48px */
+ --spacing-14: 3.5rem; /* 56px */
+ --spacing-16: 4rem; /* 64px */
+ --spacing-20: 5rem; /* 80px */
+
+ /* ============================================
+ BORDERS & RADIUS
+ ============================================ */
+ --radius-none: 0;
+ --radius-sm: 0.125rem; /* 2px */
+ --radius-default: 0.25rem; /* 4px */
+ --radius-md: 0.375rem; /* 6px */
+ --radius-lg: 0.5rem; /* 8px */
+ --radius-xl: 0.75rem; /* 12px */
+ --radius-2xl: 1rem; /* 16px */
+ --radius-3xl: 1.5rem; /* 24px */
+ --radius-full: 9999px;
+
+ --border-width: 1px;
+ --border-width-2: 2px;
+ --border-width-4: 4px;
+
+ /* ============================================
+ SHADOWS
+ ============================================ */
+ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+ --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+ --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
+ --shadow-none: 0 0 #0000;
+
+ /* Primary-colored shadows for buttons/cards */
+ --shadow-primary: 0 4px 14px 0 rgb(99 102 241 / 0.35);
+ --shadow-primary-lg: 0 10px 25px -3px rgb(99 102 241 / 0.4);
+
+ /* ============================================
+ TRANSITIONS
+ ============================================ */
+ --transition-fast: 150ms ease;
+ --transition-normal: 200ms ease;
+ --transition-slow: 300ms ease;
+ --transition-slower: 500ms ease;
+
+ /* ============================================
+ Z-INDEX SCALE
+ ============================================ */
+ --z-dropdown: 1000;
+ --z-sticky: 1020;
+ --z-fixed: 1030;
+ --z-modal-backdrop: 1040;
+ --z-modal: 1050;
+ --z-popover: 1060;
+ --z-tooltip: 1070;
+ --z-toast: 1080;
+}
+
+/* ============================================
+ DARK MODE OVERRIDES
+ ============================================ */
+@media (prefers-color-scheme: dark) {
+ :root {
+ /* Surface Colors (Dark Mode) */
+ --color-background: #0f172a;
+ --color-surface: #1e293b;
+ --color-surface-elevated: #334155;
+ --color-surface-muted: #1e293b;
+ --color-border: #334155;
+ --color-border-muted: #1e293b;
+
+ /* Text Colors (Dark Mode) */
+ --color-text: #f1f5f9;
+ --color-text-secondary: #cbd5e1;
+ --color-text-muted: #94a3b8;
+ --color-text-disabled: #64748b;
+ --color-text-inverse: #0f172a;
+
+ /* Semantic Background Colors (Dark Mode) */
+ --color-success-bg: #14532d;
+ --color-success-border: #166534;
+ --color-danger-bg: #7f1d1d;
+ --color-danger-border: #991b1b;
+ --color-warning-bg: #78350f;
+ --color-warning-border: #92400e;
+ --color-info-bg: #1e3a8a;
+ --color-info-border: #1e40af;
+
+ /* Adjusted Shadows for Dark Mode */
+ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5);
+ --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.6);
+ --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.2);
+ --shadow-primary: 0 4px 14px 0 rgb(99 102 241 / 0.25);
+ --shadow-primary-lg: 0 10px 25px -3px rgb(99 102 241 / 0.3);
+ }
+}
+
+/* ============================================
+ BASE STYLES
+ ============================================ */
+:root {
+ font-family: var(--font-family-sans);
+ line-height: var(--line-height-normal);
+ font-weight: var(--font-weight-normal);
+ color: var(--color-text);
+ background-color: var(--color-background);
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ============================================
+ RESPONSIVE BREAKPOINTS
+ ============================================ */
+/* Ultra-small screens (360px and below) */
+@media (max-width: 360px) {
+ :root {
+ /* Reduce font sizes for tiny screens */
+ --font-size-base: 0.875rem; /* 14px */
+ --font-size-lg: 1rem; /* 16px */
+ --font-size-xl: 1.125rem; /* 18px */
+ --font-size-2xl: 1.25rem; /* 20px */
+ --font-size-3xl: 1.5rem; /* 24px */
+
+ /* Reduce spacing */
+ --spacing-4: 0.875rem; /* 14px */
+ --spacing-5: 1rem; /* 16px */
+ --spacing-6: 1.25rem; /* 20px */
+ --spacing-8: 1.5rem; /* 24px */
+
+ /* Adjust radii */
+ --radius-xl: 0.5rem; /* 8px */
+ --radius-2xl: 0.75rem; /* 12px */
+ --radius-3xl: 1rem; /* 16px */
+ }
+}
+
+/* Small screens (480px and below) */
+@media (max-width: 480px) {
+ html {
+ font-size: 15px;
+ }
+}
+
+/* Medium screens */
+@media (min-width: 768px) {
+ html {
+ font-size: 16px;
+ }
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/frontend/src/music/musicEngine.js b/frontend/src/music/musicEngine.js
new file mode 100644
index 0000000..763b0e2
--- /dev/null
+++ b/frontend/src/music/musicEngine.js
@@ -0,0 +1,834 @@
+// musicEngine.js — Core procedural music engine with Tone.js
+import * as Tone from 'tone';
+import { SCALES, NOTE_NAMES, ROMAN_TO_DEGREE, SYNTH_PRESETS, DRUM_KITS, FX_PRESETS, STYLES } from './styleCategories.js';
+import { SONG_LIBRARY, SONG_METADATA } from './musicLibrary.js';
+
+// ─── Helpers ─────────────────────────────────────────────────
+
+function rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
+function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
+function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
+
+// ─── Music Theory ────────────────────────────────────────────
+
+function noteToMidi(name) {
+ const match = name.match(/^([A-G]#?)(\d)$/);
+ if (!match) return 60;
+ const idx = NOTE_NAMES.indexOf(match[1]);
+ const oct = parseInt(match[2]);
+ return idx + (oct + 1) * 12;
+}
+
+function midiToNote(midi) {
+ const idx = ((midi % 12) + 12) % 12;
+ const oct = Math.floor(midi / 12) - 1;
+ return `${NOTE_NAMES[idx]}${oct}`;
+}
+
+function getScaleNotes(key, scaleName) {
+ const rootIdx = NOTE_NAMES.indexOf(key);
+ const intervals = SCALES[scaleName] || SCALES.major;
+ return intervals.map(i => (rootIdx + i) % 12);
+}
+
+function getScaleDegreeNote(degree, key, scaleName, octave) {
+ const intervals = SCALES[scaleName] || SCALES.major;
+ const rootIdx = NOTE_NAMES.indexOf(key);
+ const wrappedDeg = ((degree % intervals.length) + intervals.length) % intervals.length;
+ const octShift = Math.floor(degree / intervals.length);
+ const semitone = intervals[wrappedDeg];
+ const midi = (octave + 1 + octShift) * 12 + rootIdx + semitone;
+ return midiToNote(clamp(midi, 24, 108));
+}
+
+function resolveChord(numeral, key, scaleName, octave) {
+ const isMinor = numeral === numeral.toLowerCase() && numeral !== numeral.toUpperCase();
+ const upper = numeral.toUpperCase();
+ const rootIdx = NOTE_NAMES.indexOf(key);
+ const scale = SCALES[scaleName] || SCALES.major;
+
+ // Flat numerals
+ let degree, chromatic = false;
+ if (upper.startsWith('B') && upper.length > 1 && 'IVXL'.includes(upper[1])) {
+ // bII, bIII, bVI, bVII
+ const romanPart = upper.slice(1);
+ const degMap = { 'I': 0, 'II': 1, 'III': 2, 'IV': 3, 'V': 4, 'VI': 5, 'VII': 6 };
+ degree = degMap[romanPart] ?? 0;
+ chromatic = true;
+ } else {
+ const degMap = { 'I': 0, 'II': 1, 'III': 2, 'IV': 3, 'V': 4, 'VI': 5, 'VII': 6 };
+ degree = degMap[upper] ?? 0;
+ }
+
+ let rootSemitone;
+ if (chromatic) {
+ rootSemitone = (scale[degree] || 0) - 1;
+ } else {
+ rootSemitone = scale[degree % scale.length] || 0;
+ }
+
+ const rootMidi = (octave + 1) * 12 + rootIdx + rootSemitone;
+
+ let third, fifth;
+ if (isMinor) {
+ third = rootMidi + 3;
+ fifth = rootMidi + 7;
+ } else {
+ third = rootMidi + 4;
+ fifth = rootMidi + 7;
+ }
+
+ return [midiToNote(rootMidi), midiToNote(third), midiToNote(fifth)];
+}
+
+function pickFromScale(key, scaleName, octave, opts = {}) {
+ const { minDeg = 0, maxDeg = 14, avoid = [] } = opts;
+ const intervals = SCALES[scaleName] || SCALES.major;
+ const rootIdx = NOTE_NAMES.indexOf(key);
+ const candidates = [];
+ for (let d = minDeg; d <= maxDeg; d++) {
+ const wrapped = ((d % intervals.length) + intervals.length) % intervals.length;
+ const octShift = Math.floor(d / intervals.length);
+ const midi = (octave + octShift) * 12 + rootIdx + intervals[wrapped] + 12;
+ if (!avoid.includes(midi)) candidates.push(midi);
+ }
+ return candidates.length ? midiToNote(pick(candidates)) : midiToNote(60);
+}
+
+function nearestScaleNote(midi, key, scaleName) {
+ const scaleNotes = getScaleNotes(key, scaleName);
+ const pc = ((midi % 12) + 12) % 12;
+ if (scaleNotes.includes(pc)) return midi;
+ for (let offset = 1; offset <= 6; offset++) {
+ if (scaleNotes.includes((pc + offset) % 12)) return midi + offset;
+ if (scaleNotes.includes((pc - offset + 12) % 12)) return midi - offset;
+ }
+ return midi;
+}
+
+// ─── Melody Algorithms ───────────────────────────────────────
+
+const MELODY_ALGOS = {
+ arp(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const mids = chordNotes.map(noteToMidi);
+ const extended = [...mids, mids[0] + 12];
+ const count = rand(4, 8);
+ const dur = barTime / count;
+ for (let i = 0; i < count; i++) {
+ const note = midiToNote(extended[i % extended.length] + (i >= extended.length ? 12 : 0));
+ events.push({ time: i * dur, note, duration: dur * 0.8, velocity: dyn * (0.6 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+
+ step(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(6, 10);
+ const dur = barTime / count;
+ let currentMidi = noteToMidi(chordNotes[0]) + (Math.random() > 0.5 ? 12 : 0);
+ for (let i = 0; i < count; i++) {
+ const step = pick([-2, -1, 0, 1, 1, 2]);
+ currentMidi = nearestScaleNote(currentMidi + step, key, scale);
+ currentMidi = clamp(currentMidi, 48, 84);
+ events.push({ time: i * dur, note: midiToNote(currentMidi), duration: dur * 0.75, velocity: dyn * (0.5 + Math.random() * 0.4) });
+ }
+ return events;
+ },
+
+ leap(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(3, 5);
+ const dur = barTime / count;
+ let currentMidi = noteToMidi(chordNotes[0]) + 12;
+ for (let i = 0; i < count; i++) {
+ const interval = pick([-7, -5, 5, 7, 12]);
+ currentMidi = nearestScaleNote(currentMidi + interval, key, scale);
+ currentMidi = clamp(currentMidi, 48, 84);
+ events.push({ time: i * dur, note: midiToNote(currentMidi), duration: dur * 0.9, velocity: dyn * (0.7 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+
+ sparse(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(1, 3);
+ for (let i = 0; i < count; i++) {
+ const t = (i / count) * barTime + Math.random() * (barTime / count) * 0.3;
+ const noteDur = barTime / count * (0.6 + Math.random() * 0.3);
+ const note = pick(chordNotes);
+ const midi = noteToMidi(note) + (Math.random() > 0.5 ? 12 : 0);
+ events.push({ time: t, note: midiToNote(clamp(midi, 48, 84)), duration: noteDur, velocity: dyn * (0.4 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+
+ busy(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(8, 16);
+ const dur = barTime / count;
+ let currentMidi = noteToMidi(chordNotes[0]) + 12;
+ for (let i = 0; i < count; i++) {
+ const step = pick([-3, -2, -1, 0, 1, 2, 3]);
+ currentMidi = nearestScaleNote(currentMidi + step, key, scale);
+ currentMidi = clamp(currentMidi, 48, 84);
+ events.push({ time: i * dur, note: midiToNote(currentMidi), duration: dur * 0.6, velocity: dyn * (0.4 + Math.random() * 0.4) });
+ }
+ return events;
+ },
+
+ penta(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(4, 8);
+ const dur = barTime / count;
+ const pentaScale = scale.includes('minor') ? 'minorPentatonic' : 'pentatonic';
+ for (let i = 0; i < count; i++) {
+ const note = pickFromScale(key, pentaScale, 4, { minDeg: 0, maxDeg: 9 });
+ events.push({ time: i * dur, note, duration: dur * 0.7, velocity: dyn * (0.5 + Math.random() * 0.4) });
+ }
+ return events;
+ },
+
+ call(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const phraseNotes = rand(3, 6);
+ const phraseDur = barTime * 0.6;
+ const noteDur = phraseDur / phraseNotes;
+ let midi = noteToMidi(chordNotes[0]) + 12;
+ for (let i = 0; i < phraseNotes; i++) {
+ const step = pick([-2, -1, 1, 2, 3]);
+ midi = nearestScaleNote(midi + step, key, scale);
+ midi = clamp(midi, 48, 84);
+ events.push({ time: i * noteDur, note: midiToNote(midi), duration: noteDur * 0.8, velocity: dyn * (0.6 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+
+ ostinato(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const patLen = rand(3, 4);
+ const pattern = [];
+ let midi = noteToMidi(chordNotes[0]) + 12;
+ for (let i = 0; i < patLen; i++) {
+ midi = nearestScaleNote(midi + pick([-2, -1, 1, 2]), key, scale);
+ midi = clamp(midi, 48, 84);
+ pattern.push(midi);
+ }
+ const count = rand(4, 8);
+ const dur = barTime / count;
+ for (let i = 0; i < count; i++) {
+ const m = pattern[i % pattern.length] + (Math.random() > 0.85 ? pick([-1, 1]) : 0);
+ events.push({ time: i * dur, note: midiToNote(clamp(m, 48, 84)), duration: dur * 0.7, velocity: dyn * (0.5 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+
+ desc(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(4, 6);
+ const dur = barTime / count;
+ let midi = noteToMidi(chordNotes[2]) + 12;
+ for (let i = 0; i < count; i++) {
+ midi = nearestScaleNote(midi - rand(1, 3), key, scale);
+ midi = clamp(midi, 48, 84);
+ events.push({ time: i * dur, note: midiToNote(midi), duration: dur * 0.8, velocity: dyn * (0.7 - i * 0.05 + Math.random() * 0.2) });
+ }
+ return events;
+ },
+
+ asc(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(4, 6);
+ const dur = barTime / count;
+ let midi = noteToMidi(chordNotes[0]);
+ for (let i = 0; i < count; i++) {
+ midi = nearestScaleNote(midi + rand(1, 3), key, scale);
+ midi = clamp(midi, 48, 84);
+ events.push({ time: i * dur, note: midiToNote(midi), duration: dur * 0.8, velocity: dyn * (0.5 + i * 0.05 + Math.random() * 0.2) });
+ }
+ return events;
+ },
+
+ wave(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(6, 8);
+ const dur = barTime / count;
+ let midi = noteToMidi(chordNotes[0]) + 12;
+ for (let i = 0; i < count; i++) {
+ const direction = i < count / 2 ? 1 : -1;
+ midi = nearestScaleNote(midi + direction * rand(1, 2), key, scale);
+ midi = clamp(midi, 48, 84);
+ events.push({ time: i * dur, note: midiToNote(midi), duration: dur * 0.75, velocity: dyn * (0.5 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+
+ fanfare(chordNotes, key, scale, barTime, dyn) {
+ const events = [];
+ const count = rand(3, 5);
+ const dur = barTime / count;
+ const mids = chordNotes.map(noteToMidi);
+ for (let i = 0; i < count; i++) {
+ const midi = pick(mids) + (Math.random() > 0.3 ? 12 : 0);
+ events.push({ time: i * dur, note: midiToNote(clamp(midi, 48, 84)), duration: dur * 0.9, velocity: dyn * (0.7 + Math.random() * 0.3) });
+ }
+ return events;
+ },
+};
+
+// ─── Bass Algorithms ─────────────────────────────────────────
+
+const BASS_ALGOS = {
+ root(chordRoot, key, scale, barTime, dyn) {
+ return [
+ { time: 0, note: chordRoot, duration: barTime * 0.9, velocity: dyn * 0.7 },
+ ];
+ },
+
+ walk(chordRoot, key, scale, barTime, dyn) {
+ const events = [];
+ const steps = 4;
+ const dur = barTime / steps;
+ let midi = noteToMidi(chordRoot);
+ for (let i = 0; i < steps; i++) {
+ events.push({ time: i * dur, note: midiToNote(midi), duration: dur * 0.8, velocity: dyn * (0.6 + Math.random() * 0.2) });
+ midi = nearestScaleNote(midi + pick([-2, -1, 1, 2]), key, scale);
+ midi = clamp(midi, 28, 55);
+ }
+ return events;
+ },
+
+ oct(chordRoot, key, scale, barTime, dyn) {
+ const midi = noteToMidi(chordRoot);
+ return [
+ { time: 0, note: midiToNote(midi), duration: barTime * 0.4, velocity: dyn * 0.7 },
+ { time: barTime * 0.5, note: midiToNote(midi + 12), duration: barTime * 0.35, velocity: dyn * 0.55 },
+ ];
+ },
+
+ arpBass(chordRoot, key, scale, barTime, dyn) {
+ const events = [];
+ const midi = noteToMidi(chordRoot);
+ const pattern = [0, 7, 12, 7]; // root, fifth, octave, fifth
+ const dur = barTime / 4;
+ pattern.forEach((offset, i) => {
+ events.push({ time: i * dur, note: midiToNote(clamp(midi + offset, 28, 60)), duration: dur * 0.75, velocity: dyn * (0.6 + Math.random() * 0.15) });
+ });
+ return events;
+ },
+
+ pulse(chordRoot, key, scale, barTime, dyn) {
+ const events = [];
+ const count = 8;
+ const dur = barTime / count;
+ for (let i = 0; i < count; i++) {
+ events.push({ time: i * dur, note: chordRoot, duration: dur * 0.5, velocity: dyn * (i % 2 === 0 ? 0.65 : 0.45) });
+ }
+ return events;
+ },
+
+ fifth(chordRoot, key, scale, barTime, dyn) {
+ const midi = noteToMidi(chordRoot);
+ return [
+ { time: 0, note: midiToNote(midi), duration: barTime * 0.45, velocity: dyn * 0.7 },
+ { time: barTime * 0.5, note: midiToNote(clamp(midi + 7, 28, 60)), duration: barTime * 0.4, velocity: dyn * 0.6 },
+ ];
+ },
+
+ sync(chordRoot, key, scale, barTime, dyn) {
+ // Syncopated bass
+ const events = [];
+ const midi = noteToMidi(chordRoot);
+ const times = [0, barTime * 0.375, barTime * 0.625];
+ times.forEach(t => {
+ events.push({ time: t, note: midiToNote(clamp(midi + pick([0, 0, 7]), 28, 60)), duration: barTime * 0.2, velocity: dyn * (0.5 + Math.random() * 0.25) });
+ });
+ return events;
+ },
+};
+
+// ─── Drum Patterns ───────────────────────────────────────────
+// 16-step grid per bar (sixteenth notes)
+
+const DRUM_PATTERNS = {
+ rock: { kick: [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], snare: [0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], hat: [1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] },
+ march: { kick: [1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0], snare: [0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], hat: [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0] },
+ four: { kick: [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], snare: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], hat: [1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] },
+ break: { kick: [1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0], snare: [0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0], hat: [1,1,1,0,1,1,1,0,1,1,1,0,1,1,1,0] },
+ minimal: { kick: [1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], snare: [0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0], hat: [0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0] },
+ electronic: { kick: [1,0,0,0,1,0,0,1,0,0,1,0,0,0,1,0], snare: [0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], hat: [1,0,1,1,0,1,1,0,1,0,1,1,0,1,1,0] },
+ shuffle: { kick: [1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,0], snare: [0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], hat: [1,0,1,0,0,1,0,1,1,0,1,0,0,1,0,1] },
+ latin: { kick: [1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0], snare: [0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0], hat: [1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] },
+ half: { kick: [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], snare: [0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], hat: [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0] },
+ tribal: { kick: [1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0], snare: [0,0,1,0,0,1,0,0,0,0,1,0,0,1,0,0], hat: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] },
+ fill: { kick: [1,0,1,0,1,0,1,0,1,0,1,0,1,1,1,1], snare: [0,0,0,0,1,0,0,1,0,0,1,0,1,0,1,1], hat: [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] },
+ soft: { kick: [1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], snare: [0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0], hat: [0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0] },
+ heavy: { kick: [1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1], snare: [0,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0], hat: [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] },
+ none: { kick: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], snare: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], hat: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] },
+};
+
+// ─── MusicEngine Class ───────────────────────────────────────
+
+class MusicEngine {
+ constructor() {
+ this.initialized = false;
+ this.playing = false;
+ this.currentSong = null;
+ this.instruments = {};
+ this.effects = {};
+ this.parts = [];
+ this.masterGain = null;
+ }
+
+ async _ensureInit() {
+ if (this.initialized) return;
+ await Tone.start();
+ this.masterGain = new Tone.Gain(0.7).toDestination();
+ this.initialized = true;
+ }
+
+ _createSynth(presetName) {
+ const preset = SYNTH_PRESETS[presetName];
+ if (!preset) return new Tone.Synth();
+
+ switch (preset.type) {
+ case 'FMSynth': return new Tone.FMSynth(preset.options);
+ case 'AMSynth': return new Tone.AMSynth(preset.options);
+ case 'MonoSynth': return new Tone.MonoSynth(preset.options);
+ case 'PolySynth': {
+ const voiceMap = { 'Synth': Tone.Synth, 'FMSynth': Tone.FMSynth, 'AMSynth': Tone.AMSynth };
+ const Voice = voiceMap[preset.voiceType] || Tone.Synth;
+ return new Tone.PolySynth(Voice, preset.options);
+ }
+ default: return new Tone.Synth(preset.options);
+ }
+ }
+
+ _createDrumKit(kitName) {
+ const kit = DRUM_KITS[kitName] || DRUM_KITS.standard;
+ return {
+ kick: new Tone.MembraneSynth(kit.kick),
+ snare: new Tone.NoiseSynth(kit.snare),
+ hat: new Tone.MetalSynth(kit.hat),
+ };
+ }
+
+ _buildEffectChain(fxName) {
+ const preset = FX_PRESETS[fxName] || FX_PRESETS.hall;
+ const chain = [];
+
+ if (preset.filter) {
+ chain.push(new Tone.Filter(preset.filter.frequency, preset.filter.type));
+ }
+ if (preset.chorus) {
+ chain.push(new Tone.Chorus(preset.chorus.frequency, preset.chorus.delayTime, preset.chorus.depth).start());
+ }
+ if (preset.delay) {
+ chain.push(new Tone.FeedbackDelay(preset.delay.delayTime, preset.delay.feedback).set({ wet: preset.delay.wet }));
+ }
+ if (preset.reverb) {
+ chain.push(new Tone.Reverb({ decay: preset.reverb.decay, wet: preset.reverb.wet }));
+ }
+
+ return chain;
+ }
+
+ _cleanup() {
+ Tone.Transport.stop();
+ Tone.Transport.cancel();
+
+ this.parts.forEach(p => { try { p.dispose(); } catch(e) {} });
+ this.parts = [];
+
+ Object.values(this.instruments).forEach(inst => {
+ if (inst && typeof inst.dispose === 'function') {
+ try { inst.dispose(); } catch(e) {}
+ }
+ // Drum kits are objects with kick/snare/hat
+ if (inst && inst.kick) {
+ try { inst.kick.dispose(); } catch(e) {}
+ try { inst.snare.dispose(); } catch(e) {}
+ try { inst.hat.dispose(); } catch(e) {}
+ }
+ });
+ this.instruments = {};
+
+ Object.values(this.effects).forEach(chain => {
+ if (Array.isArray(chain)) {
+ chain.forEach(fx => { try { fx.dispose(); } catch(e) {} });
+ }
+ });
+ this.effects = {};
+
+ this.playing = false;
+ this.currentSong = null;
+ }
+
+ _generateDrumEvents(patternName, barOffset, barTime, dyn) {
+ const pattern = DRUM_PATTERNS[patternName] || DRUM_PATTERNS.none;
+ const stepDur = barTime / 16;
+ const events = { kick: [], snare: [], hat: [] };
+
+ for (let i = 0; i < 16; i++) {
+ const t = barOffset + i * stepDur;
+ // Add slight timing variation for humanization
+ const jitter = (Math.random() - 0.5) * stepDur * 0.05;
+
+ if (pattern.kick[i]) {
+ events.kick.push({ time: t + jitter, note: 'C1', duration: '16n', velocity: dyn * (0.7 + Math.random() * 0.2) });
+ }
+ if (pattern.snare[i]) {
+ events.snare.push({ time: t + jitter, duration: '16n', velocity: dyn * (0.6 + Math.random() * 0.2) });
+ }
+ if (pattern.hat[i]) {
+ events.hat.push({ time: t + jitter, note: 'C4', duration: '32n', velocity: dyn * (0.3 + Math.random() * 0.25) });
+ }
+ }
+ return events;
+ }
+
+ async playRandomSong(styleName) {
+ await this._ensureInit();
+ this._cleanup();
+
+ const style = styleName?.toLowerCase();
+ const songs = SONG_LIBRARY[style];
+ if (!songs || songs.length === 0) {
+ throw new Error(`No songs found for style: ${styleName}`);
+ }
+
+ const song = pick(songs);
+ return this._playSong(song, style);
+ }
+
+ async _playSong(song, styleName) {
+ const { id, name, tempo, key, scale, presets, fx, sections } = song;
+
+ // Create instruments
+ const melodySynth = this._createSynth(presets.melody);
+ const padSynth = this._createSynth(presets.pad);
+ const bassSynth = this._createSynth(presets.bass);
+ const drums = this._createDrumKit(presets.drums);
+
+ // Build effect chains
+ const melodyFx = this._buildEffectChain(fx);
+ const padFx = this._buildEffectChain(fx);
+ const bassFx = [new Tone.Filter(500, 'lowpass')]; // Bass always gets low-pass
+
+ // Wire up: synth -> effects -> masterGain
+ if (melodyFx.length) {
+ melodySynth.chain(...melodyFx, this.masterGain);
+ } else {
+ melodySynth.connect(this.masterGain);
+ }
+
+ if (padFx.length) {
+ const padGain = new Tone.Gain(0.35);
+ padSynth.chain(padGain, ...padFx, this.masterGain);
+ this.effects.padGain = [padGain];
+ } else {
+ const padGain = new Tone.Gain(0.35);
+ padSynth.chain(padGain, this.masterGain);
+ this.effects.padGain = [padGain];
+ }
+
+ if (bassFx.length) {
+ bassSynth.chain(...bassFx, this.masterGain);
+ } else {
+ bassSynth.connect(this.masterGain);
+ }
+
+ const drumGain = new Tone.Gain(0.5);
+ drums.kick.connect(drumGain);
+ drums.snare.connect(drumGain);
+ drums.hat.connect(drumGain);
+ drumGain.connect(this.masterGain);
+
+ this.instruments = { melodySynth, padSynth, bassSynth, drums, drumGain };
+ this.effects.melodyFx = melodyFx;
+ this.effects.padFx = padFx;
+ this.effects.bassFx = bassFx;
+
+ // Generate all events
+ const melodyEvents = [];
+ const padEvents = [];
+ const bassEvents = [];
+ const kickEvents = [];
+ const snareEvents = [];
+ const hatEvents = [];
+
+ const barTime = (60 / tempo) * 4; // 4 beats per bar in seconds
+ let barOffset = 0;
+
+ for (const section of sections) {
+ const { bars, chords, mel, bass, drums: drumPat, dyn } = section;
+ for (let bar = 0; bar < bars; bar++) {
+ const chordIdx = bar % chords.length;
+ const chordNumeral = chords[chordIdx];
+ const chordNotes = resolveChord(chordNumeral, key, scale, 4);
+ const bassRoot = resolveChord(chordNumeral, key, scale, 2)[0];
+ const currentBarTime = barOffset + bar * barTime;
+
+ // Melody
+ const melAlgo = MELODY_ALGOS[mel] || MELODY_ALGOS.step;
+ const melNotes = melAlgo(chordNotes, key, scale, barTime, dyn);
+ melNotes.forEach(e => {
+ melodyEvents.push({ ...e, time: currentBarTime + e.time });
+ });
+
+ // Harmony (pad plays chord)
+ padEvents.push({
+ time: currentBarTime,
+ notes: chordNotes,
+ duration: barTime * 0.9,
+ velocity: dyn * 0.35,
+ });
+
+ // Bass
+ const bassAlgo = BASS_ALGOS[bass] || BASS_ALGOS.root;
+ const bassNotes = bassAlgo(bassRoot, key, scale, barTime, dyn);
+ bassNotes.forEach(e => {
+ bassEvents.push({ ...e, time: currentBarTime + e.time });
+ });
+
+ // Drums
+ const drumEvents = this._generateDrumEvents(drumPat, currentBarTime, barTime, dyn);
+ kickEvents.push(...drumEvents.kick);
+ snareEvents.push(...drumEvents.snare);
+ hatEvents.push(...drumEvents.hat);
+ }
+ barOffset += bars * barTime;
+ }
+
+ let totalTime = barOffset;
+
+ // If loop is under 60s, regenerate a second pass for variety
+ if (totalTime < 60) {
+ const secondPassOffset = totalTime;
+ let barOffset2 = 0;
+ for (const section of sections) {
+ const { bars, chords, mel, bass, drums: drumPat, dyn } = section;
+ for (let bar = 0; bar < bars; bar++) {
+ const chordIdx = bar % chords.length;
+ const chordNumeral = chords[chordIdx];
+ const chordNotes = resolveChord(chordNumeral, key, scale, 4);
+ const bassRoot = resolveChord(chordNumeral, key, scale, 2)[0];
+ const currentBarTime = secondPassOffset + barOffset2 + bar * barTime;
+ const melAlgo = MELODY_ALGOS[mel] || MELODY_ALGOS.step;
+ melAlgo(chordNotes, key, scale, barTime, dyn).forEach(e => {
+ melodyEvents.push({ ...e, time: currentBarTime + e.time });
+ });
+ padEvents.push({ time: currentBarTime, notes: chordNotes, duration: barTime * 0.9, velocity: dyn * 0.35 });
+ const bassAlgo = BASS_ALGOS[bass] || BASS_ALGOS.root;
+ bassAlgo(bassRoot, key, scale, barTime, dyn).forEach(e => {
+ bassEvents.push({ ...e, time: currentBarTime + e.time });
+ });
+ const drumEvts = this._generateDrumEvents(drumPat, currentBarTime, barTime, dyn);
+ kickEvents.push(...drumEvts.kick);
+ snareEvents.push(...drumEvts.snare);
+ hatEvents.push(...drumEvts.hat);
+ }
+ barOffset2 += bars * barTime;
+ }
+ totalTime += barOffset2;
+ }
+
+ // Create Tone.Parts
+ const melodyPart = new Tone.Part((time, ev) => {
+ try { melodySynth.triggerAttackRelease(ev.note, ev.duration, time, ev.velocity); } catch(e) {}
+ }, melodyEvents.map(e => [e.time, e]));
+
+ const padPart = new Tone.Part((time, ev) => {
+ try { padSynth.triggerAttackRelease(ev.notes, ev.duration, time, ev.velocity); } catch(e) {}
+ }, padEvents.map(e => [e.time, e]));
+
+ const bassPart = new Tone.Part((time, ev) => {
+ try { bassSynth.triggerAttackRelease(ev.note, ev.duration, time, ev.velocity); } catch(e) {}
+ }, bassEvents.map(e => [e.time, e]));
+
+ const kickPart = new Tone.Part((time, ev) => {
+ try { drums.kick.triggerAttackRelease(ev.note, ev.duration, time, ev.velocity); } catch(e) {}
+ }, kickEvents.map(e => [e.time, e]));
+
+ const snarePart = new Tone.Part((time, ev) => {
+ try { drums.snare.triggerAttackRelease(ev.duration, time, ev.velocity); } catch(e) {}
+ }, snareEvents.map(e => [e.time, e]));
+
+ const hatPart = new Tone.Part((time, ev) => {
+ try { drums.hat.triggerAttackRelease(ev.note, ev.duration, time, ev.velocity); } catch(e) {}
+ }, hatEvents.map(e => [e.time, e]));
+
+ this.parts = [melodyPart, padPart, bassPart, kickPart, snarePart, hatPart];
+
+ // Configure transport
+ Tone.Transport.bpm.value = tempo;
+ Tone.Transport.loop = true;
+ Tone.Transport.loopStart = 0;
+ Tone.Transport.loopEnd = totalTime;
+
+ // Start all parts
+ this.parts.forEach(p => {
+ p.start(0);
+ p.loop = false; // Transport handles looping
+ });
+
+ Tone.Transport.start();
+ this.playing = true;
+ this.currentSong = { id, name, style: styleName };
+
+ return { id, name };
+ }
+
+ stopMusic() {
+ this._cleanup();
+ }
+
+ pauseMusic() {
+ if (this.playing) {
+ Tone.Transport.pause();
+ }
+ }
+
+ resumeMusic() {
+ if (this.currentSong) {
+ Tone.Transport.start();
+ }
+ }
+
+ setVolume(level) {
+ const vol = clamp(level, 0, 1);
+ if (this.masterGain) {
+ this.masterGain.gain.rampTo(vol * 0.7, 0.1);
+ }
+ }
+
+ getCurrentSong() {
+ return this.currentSong ? { ...this.currentSong } : null;
+ }
+
+ getStyles() {
+ return Object.keys(STYLES);
+ }
+
+ getCatalog() {
+ const moods = new Set();
+ const songsByMood = {};
+ const catalog = [];
+
+ for (const [style, songs] of Object.entries(SONG_LIBRARY)) {
+ for (const song of songs) {
+ const meta = SONG_METADATA[song.id] || {};
+ const entry = {
+ id: song.id,
+ name: song.name,
+ style,
+ tempo: song.tempo,
+ key: song.key,
+ scale: song.scale,
+ mood: meta.mood || style,
+ energy: meta.energy || 5,
+ tags: meta.tags || [],
+ description: meta.description || '',
+ recommendedFor: this._getRecommendedUses(style, meta),
+ };
+ catalog.push(entry);
+
+ const mood = entry.mood;
+ moods.add(mood);
+ if (!songsByMood[mood]) songsByMood[mood] = [];
+ songsByMood[mood].push(entry);
+ }
+ }
+
+ return { moods: [...moods].sort(), songsByMood, songs: catalog };
+ }
+
+ _getRecommendedUses(style, meta) {
+ const uses = {
+ adventure: ['RPG exploration','quest games','open world','platformer overworld'],
+ action: ['shooters','fighting games','racing','fast-paced gameplay'],
+ puzzle: ['puzzle games','brain teasers','match games','strategy'],
+ relaxing: ['idle games','meditation','menu screens','casual games'],
+ spooky: ['horror games','halloween themes','mystery','dark adventure'],
+ retro: ['arcade games','pixel art games','classic platformers','score chase'],
+ space: ['space exploration','sci-fi games','ambient backgrounds','cosmic themes'],
+ fantasy: ['RPG battles','medieval games','magic themes','story-driven games'],
+ };
+ const base = uses[style] || [];
+ if (meta.energy >= 8) base.push('boss fights', 'intense moments');
+ if (meta.energy <= 3) base.push('title screens', 'cutscenes');
+ return [...new Set(base)];
+ }
+
+ findSongs(filters = {}) {
+ const { energy, minEnergy, maxEnergy, tempo, minTempo, maxTempo, tags, mood, style } = filters;
+ const results = [];
+
+ for (const [styleName, songs] of Object.entries(SONG_LIBRARY)) {
+ if (style && styleName !== style.toLowerCase()) continue;
+ for (const song of songs) {
+ const meta = SONG_METADATA[song.id] || {};
+ const songEnergy = meta.energy || 5;
+ const songMood = meta.mood || styleName;
+ const songTags = meta.tags || [];
+
+ if (energy !== undefined && songEnergy !== energy) continue;
+ if (minEnergy !== undefined && songEnergy < minEnergy) continue;
+ if (maxEnergy !== undefined && songEnergy > maxEnergy) continue;
+ if (minTempo !== undefined && song.tempo < minTempo) continue;
+ if (maxTempo !== undefined && song.tempo > maxTempo) continue;
+ if (mood && songMood.toLowerCase() !== mood.toLowerCase()) continue;
+ if (tags && tags.length > 0 && !tags.some(t => songTags.includes(t))) continue;
+
+ results.push({
+ id: song.id, name: song.name, style: styleName, tempo: song.tempo,
+ key: song.key, scale: song.scale, mood: songMood, energy: songEnergy,
+ tags: songTags, description: meta.description || '',
+ });
+ }
+ }
+ return results;
+ }
+
+ getMetadata(songId) {
+ for (const [style, songs] of Object.entries(SONG_LIBRARY)) {
+ const song = songs.find(s => s.id === songId);
+ if (song) {
+ const meta = SONG_METADATA[songId] || {};
+ return {
+ id: song.id, name: song.name, style, tempo: song.tempo,
+ key: song.key, scale: song.scale, mood: meta.mood || style,
+ energy: meta.energy || 5, tags: meta.tags || [],
+ description: meta.description || '',
+ recommendedFor: this._getRecommendedUses(style, meta),
+ sections: song.sections.length,
+ totalBars: song.sections.reduce((sum, s) => sum + s.bars, 0),
+ estimatedDuration: Math.round(song.sections.reduce((sum, s) => sum + s.bars, 0) * 4 * 60 / song.tempo),
+ };
+ }
+ }
+ return null;
+ }
+
+ describeCurrentSong() {
+ if (!this.currentSong) return null;
+ return this.getMetadata(this.currentSong.id);
+ }
+}
+
+// ─── Singleton & Exports ─────────────────────────────────────
+
+const engine = new MusicEngine();
+
+export default engine;
+export const playRandomSong = (style) => engine.playRandomSong(style);
+export const stopMusic = () => engine.stopMusic();
+export const pauseMusic = () => engine.pauseMusic();
+export const resumeMusic = () => engine.resumeMusic();
+export const setVolume = (level) => engine.setVolume(level);
+export const getCurrentSong = () => engine.getCurrentSong();
+export const getStyles = () => engine.getStyles();
+export const getCatalog = () => engine.getCatalog();
+export const findSongs = (filters) => engine.findSongs(filters);
+export const getMetadata = (songId) => engine.getMetadata(songId);
+export const describeCurrentSong = () => engine.describeCurrentSong();
diff --git a/frontend/src/music/musicLibrary.js b/frontend/src/music/musicLibrary.js
new file mode 100644
index 0000000..f02a1c7
--- /dev/null
+++ b/frontend/src/music/musicLibrary.js
@@ -0,0 +1,1541 @@
+// musicLibrary.js — 176 song blueprints (22 per style × 8 styles)
+
+function song(id, name, tempo, key, scale, presets, fx, sections) {
+ return { id, name, tempo, key, scale, presets, fx, sections };
+}
+function sec(bars, chords, mel, bass, drums, dyn) {
+ return { bars, chords: chords.split(' '), mel, bass, drums, dyn };
+}
+
+// ─── Adventure (major, heroic) ───────────────────────────────
+
+const adventure = [
+ song('adv01','Heroic March',118,'D','major',
+ {melody:'brass',pad:'warmPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'I IV V I','fanfare','root','march',0.5),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.7),
+ sec(4,'IV V iii vi','leap','oct','fill',0.9),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.7),
+ sec(8,'I IV ii V I IV V I','arp','fifth','march',0.6),
+ ]),
+ song('adv02','Sunrise Quest',124,'G','major',
+ {melody:'brightLead',pad:'warmPad',bass:'roundBass',drums:'standard'},'chamber',[
+ sec(4,'I vi IV V','asc','root','minimal',0.4),
+ sec(8,'I IV V I I vi IV V','step','walk','rock',0.6),
+ sec(8,'vi IV I V vi IV I V','wave','fifth','rock',0.8),
+ sec(4,'IV V iii vi','leap','oct','fill',0.9),
+ sec(8,'I vi IV V I vi IV V','arp','walk','rock',0.7),
+ ]),
+ song('adv03','Open Plains',108,'C','lydian',
+ {melody:'flute',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ sec(8,'I iii IV V I iii IV V','step','walk','rock',0.5),
+ sec(8,'vi IV I V vi IV I V','penta','fifth','rock',0.7),
+ sec(8,'I II IV I I II IV I','wave','root','minimal',0.4),
+ ]),
+ song('adv04','Dragon\'s Peak',140,'E','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i VII VI VII','fanfare','root','march',0.6),
+ sec(8,'i iv v i i iv v i','busy','walk','rock',0.8),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i VII VI VII i VII VI V','step','walk','rock',0.8),
+ sec(8,'i iv v i i iv VII i','arp','fifth','march',0.7),
+ ]),
+ song('adv05','Village Morning',100,'F','major',
+ {melody:'flute',pad:'warmPad',bass:'roundBass',drums:'soft'},'chamber',[
+ sec(8,'I IV I IV I IV I IV','sparse','root','minimal',0.3),
+ sec(8,'I vi IV V I vi IV V','step','walk','soft',0.5),
+ sec(8,'I iii IV V I iii IV V','penta','fifth','rock',0.6),
+ sec(8,'I IV I IV I IV I IV','wave','root','minimal',0.4),
+ ]),
+ song('adv06','Castle Gates',130,'A','major',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'I IV V I','fanfare','root','march',0.6),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.7),
+ sec(8,'IV V iii vi IV V iii vi','arp','oct','rock',0.8),
+ sec(4,'I IV V I','fanfare','fifth','fill',0.9),
+ sec(8,'I V vi IV I V vi IV','wave','walk','march',0.7),
+ ]),
+ song('adv07','River Crossing',112,'Bb','mixolydian',
+ {melody:'reeds',pad:'warmPad',bass:'roundBass',drums:'standard'},'warm',[
+ sec(8,'I bVII IV I I bVII IV I','call','root','shuffle',0.5),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.6),
+ sec(8,'IV bVII I V IV bVII I V','ostinato','fifth','rock',0.7),
+ sec(8,'I bVII IV I I bVII IV I','wave','root','shuffle',0.5),
+ ]),
+ song('adv08','Mountain Trail',120,'D','pentatonic',
+ {melody:'brightLead',pad:'warmPad',bass:'deepBass',drums:'standard'},'bright',[
+ sec(4,'I IV V I','asc','root','march',0.5),
+ sec(8,'I V I IV I V I IV','penta','walk','rock',0.6),
+ sec(8,'IV V I IV IV V I IV','step','fifth','rock',0.8),
+ sec(4,'I IV V I','fanfare','oct','fill',0.9),
+ sec(8,'I V I IV I V I IV','arp','walk','rock',0.6),
+ ]),
+ song('adv09','Treasure Found',126,'G','major',
+ {melody:'bellTone',pad:'shimmerPad',bass:'roundBass',drums:'standard'},'chamber',[
+ sec(4,'I iii IV V','arp','root','minimal',0.4),
+ sec(8,'I vi IV V I vi IV V','step','walk','rock',0.6),
+ sec(8,'I V vi IV I V vi IV','wave','fifth','rock',0.8),
+ sec(4,'IV V I I','fanfare','oct','fill',0.9),
+ sec(8,'I iii IV V I iii IV V','ostinato','walk','rock',0.6),
+ ]),
+ song('adv10','Forest Path',104,'A','dorian',
+ {melody:'flute',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','minimal',0.3),
+ sec(8,'i VII IV i i VII IV i','step','walk','shuffle',0.5),
+ sec(8,'i IV VII i i IV VII i','penta','fifth','rock',0.6),
+ sec(8,'i IV i IV i IV i IV','wave','root','minimal',0.4),
+ ]),
+ song('adv11','Battle Drums',138,'C','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'crisp',[
+ sec(4,'i VII VI VII','fanfare','root','tribal',0.7),
+ sec(8,'i iv v i i iv v i','busy','walk','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i VII VI V i VII VI V','step','walk','heavy',0.9),
+ sec(8,'i iv VII i i iv VII i','arp','fifth','tribal',0.8),
+ ]),
+ song('adv12','Calm Harbor',96,'F','lydian',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','step','walk','soft',0.4),
+ sec(8,'I IV V I I IV V I','wave','fifth','minimal',0.5),
+ sec(8,'I II IV I I II IV I','call','root','minimal',0.3),
+ ]),
+ song('adv13','Knight\'s Oath',132,'Eb','major',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'I IV V I','fanfare','root','march',0.6),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.7),
+ sec(4,'IV V iii vi','leap','oct','fill',0.9),
+ sec(8,'I vi IV V I vi IV V','arp','fifth','rock',0.8),
+ sec(8,'I IV ii V I IV V I','wave','walk','march',0.6),
+ ]),
+ song('adv14','Windswept Ridge',116,'B','mixolydian',
+ {melody:'reeds',pad:'warmPad',bass:'roundBass',drums:'standard'},'chamber',[
+ sec(8,'I bVII IV I I bVII IV I','call','root','shuffle',0.5),
+ sec(8,'I V vi IV I V vi IV','ostinato','walk','rock',0.6),
+ sec(8,'IV bVII I V IV bVII I V','step','fifth','rock',0.7),
+ sec(8,'I bVII IV I I bVII IV I','desc','root','shuffle',0.5),
+ ]),
+ song('adv15','Expedition',128,'Ab','major',
+ {melody:'brightLead',pad:'warmPad',bass:'deepBass',drums:'standard'},'bright',[
+ sec(4,'I IV V I','asc','root','march',0.5),
+ sec(8,'I vi IV V I vi IV V','step','walk','rock',0.7),
+ sec(8,'I V vi IV I V vi IV','busy','fifth','rock',0.8),
+ sec(4,'I IV V I','fanfare','oct','fill',0.9),
+ sec(8,'I vi IV V I vi IV V','arp','walk','rock',0.6),
+ ]),
+ song('adv16','Ancient Ruins',110,'D','dorian',
+ {melody:'bellTone',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','minimal',0.3),
+ sec(8,'i VII IV i i VII IV i','step','walk','shuffle',0.5),
+ sec(8,'i IV VII i i IV VII i','arp','fifth','rock',0.7),
+ sec(8,'i IV i IV i IV i IV','ostinato','root','minimal',0.4),
+ ]),
+ song('adv17','Victory Fanfare',136,'C','major',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'I IV V I','fanfare','root','march',0.7),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.8),
+ sec(4,'IV V iii vi','leap','oct','fill',1.0),
+ sec(8,'I V vi IV I V vi IV','busy','walk','rock',0.9),
+ sec(8,'I IV V I I IV V I','fanfare','fifth','march',0.8),
+ ]),
+ song('adv18','Meadow Stroll',98,'G','pentatonic',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','minimal',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.5),
+ sec(8,'IV V I IV IV V I IV','wave','fifth','soft',0.6),
+ sec(8,'I IV I IV I IV I IV','ostinato','root','minimal',0.4),
+ ]),
+ song('adv19','Sea Voyage',122,'F','mixolydian',
+ {melody:'reeds',pad:'warmPad',bass:'deepBass',drums:'standard'},'chamber',[
+ sec(4,'I bVII IV I','call','root','shuffle',0.5),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.6),
+ sec(8,'I bVII IV V I bVII IV V','arp','fifth','rock',0.7),
+ sec(4,'IV V I bVII','leap','oct','fill',0.8),
+ sec(8,'I bVII IV I I bVII IV I','wave','walk','shuffle',0.6),
+ ]),
+ song('adv20','Summit Ascent',134,'A','major',
+ {melody:'brightLead',pad:'organPad',bass:'deepBass',drums:'standard'},'bright',[
+ sec(4,'I IV V I','asc','root','march',0.5),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.7),
+ sec(4,'IV V iii vi','busy','oct','fill',0.9),
+ sec(8,'I vi IV V I vi IV V','arp','fifth','rock',0.8),
+ sec(8,'I IV V I I IV V I','wave','walk','march',0.6),
+ ]),
+ song('adv21','Dusk Camp',94,'E','pentatonic',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'I IV I IV I IV I IV','sparse','root','minimal',0.3),
+ sec(8,'I V I IV I V I IV','penta','walk','soft',0.4),
+ sec(8,'IV V I IV IV V I IV','call','fifth','minimal',0.5),
+ sec(8,'I IV I IV I IV I IV','wave','root','minimal',0.3),
+ ]),
+ song('adv22','Final Frontier',142,'Bb','major',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'I IV V I','fanfare','root','march',0.7),
+ sec(8,'I V vi IV I V vi IV','busy','walk','rock',0.8),
+ sec(8,'vi IV I V vi IV I V','step','fifth','rock',0.9),
+ sec(4,'IV V iii vi','leap','oct','fill',1.0),
+ sec(8,'I IV V I I IV V I','arp','walk','march',0.7),
+ ]),
+];
+
+// ─── Action (minor, intense) ────────────────────────────────
+
+const action = [
+ song('act01','Adrenaline Rush',160,'A','minor',
+ {melody:'brightLead',pad:'darkPad',bass:'wobbleBass',drums:'heavy'},'dark',[
+ sec(4,'i VII VI VII','busy','pulse','electronic',0.8),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i VII VI V i VII VI V','busy','walk','rock',0.9),
+ sec(8,'i iv VII III i iv VII i','arp','pulse','electronic',0.8),
+ ]),
+ song('act02','Shadow Strike',150,'D','phrygian',
+ {melody:'squareLead',pad:'darkPad',bass:'synthBass',drums:'electronic'},'crisp',[
+ sec(4,'i bII v i','ostinato','root','electronic',0.7),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','rock',0.8),
+ sec(8,'i bII bVII i i bII bVII i','busy','pulse','electronic',0.9),
+ sec(4,'i iv bVI V','leap','oct','fill',1.0),
+ sec(8,'i bII v i i bII v i','arp','walk','rock',0.8),
+ ]),
+ song('act03','Chase Scene',170,'E','minor',
+ {melody:'brightLead',pad:'darkPad',bass:'pulseBass',drums:'heavy'},'dark',[
+ sec(8,'i VII VI VII i VII VI VII','busy','pulse','electronic',0.9),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i VII VI V i VII VI V','arp','walk','rock',0.9),
+ sec(4,'i iv VII i','fanfare','pulse','electronic',0.8),
+ ]),
+ song('act04','Battleground',145,'C','harmonicMinor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i iv v i','fanfare','root','march',0.7),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.8),
+ sec(8,'i iv bVI V i iv bVI V','busy','fifth','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv v i i iv v i','arp','walk','march',0.8),
+ ]),
+ song('act05','Midnight Run',155,'G','minorPentatonic',
+ {melody:'squareLead',pad:'darkPad',bass:'synthBass',drums:'electronic'},'crisp',[
+ sec(8,'i VII i VII i VII i VII','penta','pulse','electronic',0.7),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.8),
+ sec(8,'i VII VI V i VII VI V','busy','walk','electronic',0.9),
+ sec(8,'i iv VII i i iv VII i','arp','pulse','rock',0.8),
+ ]),
+ song('act06','Boss Fight',165,'F','harmonicMinor',
+ {melody:'brass',pad:'darkPad',bass:'wobbleBass',drums:'heavy'},'dark',[
+ sec(4,'i iv bVI V','fanfare','root','tribal',0.8),
+ sec(8,'i VII VI VII i VII VI VII','busy','walk','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv v i i iv v i','step','pulse','rock',0.9),
+ sec(8,'i bII bVII i i bII bVII i','arp','walk','tribal',0.8),
+ ]),
+ song('act07','Stealth Mode',125,'B','minor',
+ {melody:'reeds',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'dark',[
+ sec(8,'i iv v i i iv v i','sparse','root','minimal',0.4),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','electronic',0.6),
+ sec(8,'i iv bVI V i iv bVI V','ostinato','pulse','electronic',0.7),
+ sec(8,'i iv v i i iv v i','call','root','minimal',0.5),
+ ]),
+ song('act08','Power Surge',158,'C#','minor',
+ {melody:'brightLead',pad:'darkPad',bass:'synthBass',drums:'heavy'},'crisp',[
+ sec(4,'i VII VI VII','asc','root','rock',0.7),
+ sec(8,'i iv v i i iv v i','busy','walk','rock',0.9),
+ sec(8,'i VII VI V i VII VI V','step','pulse','electronic',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv VII i i iv VII i','arp','walk','rock',0.8),
+ ]),
+ song('act09','Danger Zone',148,'F#','phrygian',
+ {melody:'squareLead',pad:'darkPad',bass:'wobbleBass',drums:'electronic'},'dark',[
+ sec(8,'i bII v i i bII v i','ostinato','root','electronic',0.7),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','rock',0.8),
+ sec(4,'i bII bVII i','leap','oct','fill',0.9),
+ sec(8,'i VII VI VII i VII VI VII','busy','pulse','electronic',0.9),
+ sec(4,'i bII v i','fanfare','root','rock',0.8),
+ ]),
+ song('act10','Escape Velocity',162,'Ab','minor',
+ {melody:'brightLead',pad:'darkPad',bass:'pulseBass',drums:'heavy'},'crisp',[
+ sec(4,'i VII VI VII','busy','pulse','electronic',0.8),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.9),
+ sec(8,'i VII VI V i VII VI V','arp','pulse','electronic',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv VII III i iv VII i','wave','walk','rock',0.8),
+ ]),
+ song('act11','Iron Will',140,'D','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i iv v i','fanfare','root','march',0.7),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.8),
+ sec(8,'i iv bVI V i iv bVI V','arp','fifth','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv v i i iv v i','ostinato','walk','march',0.7),
+ ]),
+ song('act12','Turbo Boost',168,'E','minorPentatonic',
+ {melody:'squareLead',pad:'darkPad',bass:'synthBass',drums:'electronic'},'crisp',[
+ sec(8,'i VII i VII i VII i VII','penta','pulse','electronic',0.8),
+ sec(8,'i iv v i i iv v i','busy','walk','rock',0.9),
+ sec(8,'i VII VI V i VII VI V','step','pulse','electronic',0.9),
+ sec(8,'i iv VII i i iv VII i','arp','walk','rock',0.8),
+ ]),
+ song('act13','Combat Ready',152,'G','harmonicMinor',
+ {melody:'brass',pad:'darkPad',bass:'wobbleBass',drums:'heavy'},'dark',[
+ sec(4,'i iv bVI V','fanfare','root','tribal',0.7),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.8),
+ sec(8,'i iv v i i iv v i','busy','pulse','electronic',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i bII bVII i i bII bVII i','arp','walk','tribal',0.8),
+ ]),
+ song('act14','Night Assault',142,'Bb','minor',
+ {melody:'reeds',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'dark',[
+ sec(8,'i iv v i i iv v i','ostinato','root','electronic',0.6),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.8),
+ sec(4,'i iv bVI V','leap','oct','fill',0.9),
+ sec(8,'i VII VI V i VII VI V','busy','pulse','electronic',0.9),
+ sec(4,'i iv v i','call','root','rock',0.7),
+ ]),
+ song('act15','Fury Unleashed',164,'A','harmonicMinor',
+ {melody:'brightLead',pad:'darkPad',bass:'wobbleBass',drums:'heavy'},'crisp',[
+ sec(4,'i VII VI VII','fanfare','root','rock',0.8),
+ sec(8,'i iv v i i iv v i','busy','walk','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv bVI V i iv bVI V','step','pulse','electronic',0.9),
+ sec(8,'i VII VI V i VII VI V','arp','walk','rock',0.8),
+ ]),
+ song('act16','Rapid Fire',156,'C','minorPentatonic',
+ {melody:'squareLead',pad:'darkPad',bass:'synthBass',drums:'electronic'},'crisp',[
+ sec(8,'i VII i VII i VII i VII','penta','pulse','electronic',0.8),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.8),
+ sec(8,'i VII VI V i VII VI V','busy','walk','electronic',0.9),
+ sec(8,'i iv VII i i iv VII i','arp','pulse','rock',0.8),
+ ]),
+ song('act17','Warpath',146,'Eb','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i iv v i','fanfare','root','march',0.7),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.8),
+ sec(8,'i iv bVI V i iv bVI V','arp','fifth','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv v i i iv v i','busy','walk','march',0.8),
+ ]),
+ song('act18','Quick Reflexes',135,'F#','minor',
+ {melody:'reeds',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'dark',[
+ sec(8,'i iv v i i iv v i','call','root','electronic',0.5),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.7),
+ sec(8,'i iv bVI V i iv bVI V','ostinato','pulse','electronic',0.8),
+ sec(8,'i VII VI V i VII VI V','wave','walk','rock',0.7),
+ ]),
+ song('act19','Thunder Strike',160,'B','harmonicMinor',
+ {melody:'brass',pad:'darkPad',bass:'wobbleBass',drums:'heavy'},'dark',[
+ sec(4,'i iv bVI V','fanfare','root','tribal',0.8),
+ sec(8,'i VII VI VII i VII VI VII','busy','walk','rock',0.9),
+ sec(8,'i iv v i i iv v i','step','pulse','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i bII bVII i i bII bVII i','arp','walk','tribal',0.8),
+ ]),
+ song('act20','Mach Speed',170,'D','minor',
+ {melody:'brightLead',pad:'darkPad',bass:'synthBass',drums:'electronic'},'crisp',[
+ sec(8,'i VII VI VII i VII VI VII','busy','pulse','electronic',0.9),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.9),
+ sec(8,'i VII VI V i VII VI V','arp','pulse','electronic',0.9),
+ sec(8,'i iv VII i i iv VII i','wave','walk','rock',0.8),
+ ]),
+ song('act21','Ambush',130,'G#','phrygian',
+ {melody:'reeds',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'dark',[
+ sec(8,'i bII v i i bII v i','sparse','root','minimal',0.4),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','electronic',0.7),
+ sec(8,'i bII bVII i i bII bVII i','ostinato','pulse','rock',0.8),
+ sec(8,'i bII v i i bII v i','call','root','electronic',0.6),
+ ]),
+ song('act22','Final Clash',166,'E','harmonicMinor',
+ {melody:'brass',pad:'darkPad',bass:'wobbleBass',drums:'heavy'},'hall',[
+ sec(4,'i iv bVI V','fanfare','root','march',0.8),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.9),
+ sec(4,'i VI III VII','leap','oct','fill',1.0),
+ sec(8,'i iv v i i iv v i','busy','pulse','rock',0.9),
+ sec(8,'i VII VI V i VII VI V','arp','walk','march',0.8),
+ ]),
+];
+
+// ─── Puzzle (mixed, quirky) ─────────────────────────────────
+
+const puzzle = [
+ song('puz01','Think Tank',110,'C','major',
+ {melody:'bellTone',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'chamber',[
+ sec(8,'I vi ii V I vi ii V','arp','root','minimal',0.4),
+ sec(8,'I bVII IV I I bVII IV I','step','walk','shuffle',0.5),
+ sec(8,'I ii iii IV I ii iii IV','ostinato','fifth','minimal',0.6),
+ sec(8,'ii V I vi ii V I vi','wave','root','minimal',0.4),
+ ]),
+ song('puz02','Gears Turning',120,'F','dorian',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'i IV i IV i IV i IV','ostinato','pulse','electronic',0.5),
+ sec(8,'i VII IV i i VII IV i','step','walk','shuffle',0.6),
+ sec(8,'ii V I vi ii V I vi','arp','fifth','electronic',0.7),
+ sec(8,'i IV i IV i IV i IV','call','root','electronic',0.5),
+ ]),
+ song('puz03','Brain Teaser',100,'G','mixolydian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I bVII IV I I bVII IV I','sparse','root','minimal',0.3),
+ sec(8,'I vi ii V I vi ii V','step','walk','soft',0.5),
+ sec(8,'I ii iii IV I ii iii IV','wave','fifth','minimal',0.6),
+ sec(8,'I bVII IV I I bVII IV I','penta','root','soft',0.4),
+ ]),
+ song('puz04','Clockwork',128,'D','major',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'I ii iii IV I ii iii IV','ostinato','pulse','electronic',0.5),
+ sec(8,'ii V I vi ii V I vi','step','walk','shuffle',0.6),
+ sec(8,'I bVII IV I I bVII IV I','arp','fifth','electronic',0.7),
+ sec(8,'I vi ii V I vi ii V','ostinato','pulse','electronic',0.6),
+ ]),
+ song('puz05','Eureka',116,'A','pentatonic',
+ {melody:'bellTone',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'chamber',[
+ sec(4,'I IV V I','asc','root','minimal',0.4),
+ sec(8,'I V I IV I V I IV','penta','walk','soft',0.5),
+ sec(8,'IV V I IV IV V I IV','step','fifth','minimal',0.6),
+ sec(4,'I IV V I','fanfare','root','fill',0.7),
+ sec(8,'I V I IV I V I IV','wave','walk','soft',0.5),
+ ]),
+ song('puz06','Riddle Room',108,'Eb','dorian',
+ {melody:'softLead',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'i IV i IV i IV i IV','call','root','minimal',0.3),
+ sec(8,'i VII IV i i VII IV i','step','walk','shuffle',0.5),
+ sec(8,'i IV VII i i IV VII i','ostinato','fifth','soft',0.6),
+ sec(8,'i IV i IV i IV i IV','sparse','root','minimal',0.4),
+ ]),
+ song('puz07','Logic Gate',124,'Bb','major',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'I vi ii V I vi ii V','arp','pulse','electronic',0.5),
+ sec(8,'I bVII IV I I bVII IV I','step','walk','shuffle',0.6),
+ sec(8,'I ii iii IV I ii iii IV','ostinato','fifth','electronic',0.7),
+ sec(8,'ii V I vi ii V I vi','wave','pulse','shuffle',0.5),
+ ]),
+ song('puz08','Jigsaw',96,'G','wholeTone',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ sec(8,'I II IV I I II IV I','step','walk','soft',0.4),
+ sec(8,'I II IV I I II IV I','wave','fifth','minimal',0.5),
+ sec(8,'I II IV I I II IV I','arp','root','minimal',0.4),
+ ]),
+ song('puz09','Pattern Match',118,'E','major',
+ {melody:'bellTone',pad:'warmPad',bass:'roundBass',drums:'soft'},'chamber',[
+ sec(8,'I vi ii V I vi ii V','ostinato','root','minimal',0.4),
+ sec(8,'I bVII IV I I bVII IV I','step','walk','shuffle',0.5),
+ sec(8,'I ii iii IV I ii iii IV','arp','fifth','soft',0.6),
+ sec(8,'ii V I vi ii V I vi','call','root','minimal',0.5),
+ ]),
+ song('puz10','Cascade',114,'Ab','dorian',
+ {melody:'pluck',pad:'shimmerPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'i IV i IV i IV i IV','desc','root','electronic',0.4),
+ sec(8,'i VII IV i i VII IV i','step','walk','shuffle',0.6),
+ sec(8,'i IV VII i i IV VII i','arp','fifth','electronic',0.7),
+ sec(8,'i IV i IV i IV i IV','asc','root','electronic',0.5),
+ ]),
+ song('puz11','Mind Palace',106,'D','mixolydian',
+ {melody:'softLead',pad:'warmPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'I bVII IV I I bVII IV I','sparse','root','minimal',0.3),
+ sec(8,'I vi ii V I vi ii V','step','walk','soft',0.5),
+ sec(8,'I ii iii IV I ii iii IV','wave','fifth','minimal',0.6),
+ sec(8,'I bVII IV I I bVII IV I','call','root','soft',0.4),
+ ]),
+ song('puz12','Rubik\'s Groove',130,'F#','major',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'I ii iii IV I ii iii IV','ostinato','pulse','electronic',0.6),
+ sec(8,'ii V I vi ii V I vi','step','walk','shuffle',0.6),
+ sec(8,'I bVII IV I I bVII IV I','arp','fifth','electronic',0.7),
+ sec(8,'I vi ii V I vi ii V','busy','pulse','shuffle',0.7),
+ ]),
+ song('puz13','Domino Effect',112,'C','dorian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'chamber',[
+ sec(8,'i IV i IV i IV i IV','asc','root','minimal',0.4),
+ sec(8,'i VII IV i i VII IV i','step','walk','soft',0.5),
+ sec(8,'i IV VII i i IV VII i','desc','fifth','soft',0.6),
+ sec(8,'i IV i IV i IV i IV','wave','root','minimal',0.4),
+ ]),
+ song('puz14','Maze Runner',122,'A','major',
+ {melody:'bellTone',pad:'warmPad',bass:'roundBass',drums:'minimal'},'chamber',[
+ sec(8,'I vi ii V I vi ii V','step','root','minimal',0.4),
+ sec(8,'I bVII IV I I bVII IV I','arp','walk','shuffle',0.6),
+ sec(8,'I ii iii IV I ii iii IV','ostinato','fifth','minimal',0.6),
+ sec(8,'ii V I vi ii V I vi','penta','root','shuffle',0.5),
+ ]),
+ song('puz15','Combo Chain',126,'E','mixolydian',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'I bVII IV I I bVII IV I','arp','pulse','electronic',0.5),
+ sec(8,'I vi ii V I vi ii V','step','walk','shuffle',0.6),
+ sec(8,'I ii iii IV I ii iii IV','ostinato','fifth','electronic',0.7),
+ sec(8,'I bVII IV I I bVII IV I','wave','pulse','shuffle',0.6),
+ ]),
+ song('puz16','Tile Slide',98,'Bb','pentatonic',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','minimal',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.5),
+ sec(8,'IV V I IV IV V I IV','call','fifth','minimal',0.5),
+ sec(8,'I IV I IV I IV I IV','sparse','root','soft',0.3),
+ ]),
+ song('puz17','Code Breaker',132,'G','major',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(4,'I vi ii V','arp','root','electronic',0.5),
+ sec(8,'I bVII IV I I bVII IV I','busy','walk','shuffle',0.7),
+ sec(8,'I ii iii IV I ii iii IV','step','fifth','electronic',0.7),
+ sec(4,'ii V I vi','ostinato','pulse','fill',0.8),
+ sec(8,'I vi ii V I vi ii V','wave','walk','shuffle',0.6),
+ ]),
+ song('puz18','Puzzle Box',104,'D','dorian',
+ {melody:'bellTone',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'chamber',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','minimal',0.3),
+ sec(8,'i VII IV i i VII IV i','step','walk','soft',0.5),
+ sec(8,'i IV VII i i IV VII i','arp','fifth','minimal',0.6),
+ sec(8,'i IV i IV i IV i IV','ostinato','root','soft',0.4),
+ ]),
+ song('puz19','Chain Reaction',120,'F','major',
+ {melody:'softLead',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I vi ii V I vi ii V','asc','root','minimal',0.4),
+ sec(8,'I bVII IV I I bVII IV I','step','walk','shuffle',0.5),
+ sec(8,'I ii iii IV I ii iii IV','wave','fifth','soft',0.6),
+ sec(8,'ii V I vi ii V I vi','desc','root','minimal',0.5),
+ ]),
+ song('puz20','Tetris Flow',128,'C','major',
+ {melody:'pluck',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'I vi ii V I vi ii V','ostinato','pulse','electronic',0.5),
+ sec(8,'I bVII IV I I bVII IV I','step','walk','shuffle',0.6),
+ sec(8,'I ii iii IV I ii iii IV','arp','fifth','electronic',0.7),
+ sec(8,'ii V I vi ii V I vi','busy','pulse','shuffle',0.7),
+ ]),
+ song('puz21','Light Bulb',92,'Ab','major',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'I IV I IV I IV I IV','sparse','root','minimal',0.3),
+ sec(8,'I vi IV V I vi IV V','step','walk','soft',0.4),
+ sec(8,'I iii vi IV I iii vi IV','wave','fifth','minimal',0.5),
+ sec(8,'I IV I IV I IV I IV','penta','root','soft',0.3),
+ ]),
+ song('puz22','Master Key',118,'E','dorian',
+ {melody:'bellTone',pad:'warmPad',bass:'pulseBass',drums:'electronic'},'chamber',[
+ sec(4,'i IV i IV','call','root','minimal',0.4),
+ sec(8,'i VII IV i i VII IV i','step','walk','electronic',0.6),
+ sec(8,'i IV VII i i IV VII i','arp','fifth','shuffle',0.7),
+ sec(4,'i IV i IV','ostinato','pulse','fill',0.8),
+ sec(8,'i VII IV i i VII IV i','wave','walk','electronic',0.6),
+ ]),
+];
+
+// ─── Relaxing (major, gentle) ───────────────────────────────
+
+const relaxing = [
+ song('rel01','Gentle Breeze',72,'C','major',
+ {melody:'flute',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'ambient',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','minimal',0.3),
+ sec(8,'I IV I IV I IV I IV','step','walk','soft',0.4),
+ sec(8,'I iii vi IV I iii vi IV','wave','fifth','minimal',0.4),
+ sec(8,'I IV vi V I IV vi V','penta','root','soft',0.3),
+ ]),
+ song('rel02','Morning Dew',68,'G','lydian',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.2),
+ sec(8,'I iii vi IV I iii vi IV','step','walk','soft',0.4),
+ sec(8,'I IV vi V I IV vi V','wave','fifth','minimal',0.4),
+ sec(8,'I II IV I I II IV I','call','root','minimal',0.3),
+ ]),
+ song('rel03','Still Waters',64,'F','major',
+ {melody:'ethereal',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','none',0.2),
+ sec(8,'I IV I IV I IV I IV','step','walk','minimal',0.3),
+ sec(8,'IV I V vi IV I V vi','wave','root','none',0.3),
+ sec(8,'I vi IV V I vi IV V','call','walk','minimal',0.2),
+ ]),
+ song('rel04','Cloud Float',76,'D','pentatonic',
+ {melody:'pluck',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','minimal',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.4),
+ sec(8,'IV V I IV IV V I IV','wave','fifth','soft',0.4),
+ sec(8,'I IV I IV I IV I IV','sparse','root','minimal',0.3),
+ ]),
+ song('rel05','Sunset Glow',80,'A','major',
+ {melody:'flute',pad:'warmPad',bass:'deepBass',drums:'soft'},'warm',[
+ sec(8,'I vi IV V I vi IV V','step','root','soft',0.3),
+ sec(8,'I iii vi IV I iii vi IV','wave','walk','soft',0.4),
+ sec(8,'I IV vi V I IV vi V','desc','fifth','minimal',0.4),
+ sec(8,'I vi IV V I vi IV V','sparse','root','soft',0.3),
+ ]),
+ song('rel06','Lullaby Moon',62,'Eb','major',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','none',0.2),
+ sec(8,'I IV I IV I IV I IV','step','walk','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','wave','fifth','none',0.3),
+ sec(8,'I vi IV V I vi IV V','call','root','minimal',0.2),
+ ]),
+ song('rel07','Daydream',84,'Bb','lydian',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'ambient',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','step','walk','soft',0.4),
+ sec(8,'I IV vi V I IV vi V','penta','fifth','soft',0.4),
+ sec(8,'I II IV I I II IV I','wave','root','minimal',0.3),
+ ]),
+ song('rel08','Meadow Rest',70,'G','major',
+ {melody:'flute',pad:'warmPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'I IV I IV I IV I IV','call','root','none',0.2),
+ sec(8,'I vi IV V I vi IV V','step','walk','soft',0.4),
+ sec(8,'I iii vi IV I iii vi IV','wave','fifth','minimal',0.4),
+ sec(8,'I IV I IV I IV I IV','sparse','root','none',0.2),
+ ]),
+ song('rel09','Autumn Leaves',78,'E','dorian',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','minimal',0.3),
+ sec(8,'i VII IV i i VII IV i','step','walk','soft',0.4),
+ sec(8,'i IV VII i i IV VII i','wave','fifth','soft',0.5),
+ sec(8,'i IV i IV i IV i IV','penta','root','minimal',0.3),
+ ]),
+ song('rel10','Crystal Lake',66,'Ab','major',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','none',0.2),
+ sec(8,'I IV I IV I IV I IV','step','walk','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','arp','fifth','none',0.3),
+ sec(8,'I vi IV V I vi IV V','wave','root','minimal',0.2),
+ ]),
+ song('rel11','Soft Rain',74,'D','major',
+ {melody:'ethereal',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'ambient',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','minimal',0.3),
+ sec(8,'I IV I IV I IV I IV','step','walk','soft',0.4),
+ sec(8,'I IV vi V I IV vi V','wave','fifth','minimal',0.4),
+ sec(8,'IV I V vi IV I V vi','call','root','soft',0.3),
+ ]),
+ song('rel12','Petal Dance',82,'F','pentatonic',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','soft',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.4),
+ sec(8,'IV V I IV IV V I IV','arp','fifth','soft',0.5),
+ sec(8,'I IV I IV I IV I IV','wave','root','soft',0.3),
+ ]),
+ song('rel13','Snow Globe',60,'C','major',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','none',0.2),
+ sec(8,'I iii vi IV I iii vi IV','step','walk','minimal',0.3),
+ sec(8,'I IV I IV I IV I IV','wave','fifth','none',0.3),
+ sec(8,'I vi IV V I vi IV V','arp','root','minimal',0.2),
+ ]),
+ song('rel14','Warm Hearth',88,'A','major',
+ {melody:'softLead',pad:'warmPad',bass:'deepBass',drums:'soft'},'warm',[
+ sec(8,'I vi IV V I vi IV V','step','root','soft',0.3),
+ sec(8,'I IV I IV I IV I IV','wave','walk','soft',0.4),
+ sec(8,'I iii vi IV I iii vi IV','penta','fifth','soft',0.4),
+ sec(8,'I vi IV V I vi IV V','call','root','soft',0.3),
+ ]),
+ song('rel15','Stargazer',70,'E','lydian',
+ {melody:'ethereal',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I iii vi IV I iii vi IV','step','walk','minimal',0.3),
+ sec(8,'I IV vi V I IV vi V','wave','fifth','none',0.3),
+ sec(8,'I II IV I I II IV I','call','root','minimal',0.2),
+ ]),
+ song('rel16','Butterfly Garden',86,'Bb','pentatonic',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','soft',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.4),
+ sec(8,'IV V I IV IV V I IV','wave','fifth','soft',0.4),
+ sec(8,'I IV I IV I IV I IV','arp','root','soft',0.3),
+ ]),
+ song('rel17','Ocean Calm',65,'F#','major',
+ {melody:'flute',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I vi IV V I vi IV V','sparse','root','none',0.2),
+ sec(8,'I IV I IV I IV I IV','step','walk','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','wave','fifth','none',0.3),
+ sec(8,'I vi IV V I vi IV V','call','root','minimal',0.2),
+ ]),
+ song('rel18','Zen Garden',76,'D','major',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I IV I IV I IV I IV','sparse','root','none',0.2),
+ sec(8,'I vi IV V I vi IV V','step','walk','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','arp','fifth','minimal',0.4),
+ sec(8,'I IV I IV I IV I IV','wave','root','none',0.3),
+ ]),
+ song('rel19','Candlelight',68,'G','major',
+ {melody:'softLead',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I vi IV V I vi IV V','call','root','minimal',0.3),
+ sec(8,'I iii vi IV I iii vi IV','step','walk','soft',0.4),
+ sec(8,'I IV vi V I IV vi V','wave','fifth','minimal',0.4),
+ sec(8,'I vi IV V I vi IV V','sparse','root','soft',0.3),
+ ]),
+ song('rel20','Feather Fall',72,'C','pentatonic',
+ {melody:'pluck',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I IV I IV I IV I IV','penta','root','none',0.2),
+ sec(8,'I V I IV I V I IV','step','walk','minimal',0.3),
+ sec(8,'IV V I IV IV V I IV','wave','fifth','none',0.3),
+ sec(8,'I IV I IV I IV I IV','desc','root','minimal',0.2),
+ ]),
+ song('rel21','Silk Road',90,'Eb','dorian',
+ {melody:'flute',pad:'warmPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','soft',0.3),
+ sec(8,'i VII IV i i VII IV i','step','walk','soft',0.4),
+ sec(8,'i IV VII i i IV VII i','wave','fifth','soft',0.5),
+ sec(8,'i IV i IV i IV i IV','penta','root','soft',0.3),
+ ]),
+ song('rel22','Windchimes',64,'A','lydian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I iii vi IV I iii vi IV','arp','walk','minimal',0.3),
+ sec(8,'I IV vi V I IV vi V','wave','fifth','none',0.3),
+ sec(8,'I II IV I I II IV I','call','root','minimal',0.2),
+ ]),
+];
+
+// ─── Spooky (minor, dissonant) ──────────────────────────────
+
+const spooky = [
+ song('spk01','Haunted Manor',80,'A','harmonicMinor',
+ {melody:'ethereal',pad:'darkPad',bass:'wobbleBass',drums:'minimal'},'dark',[
+ sec(8,'i bII v i i bII v i','sparse','root','minimal',0.3),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','minimal',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','ostinato','root','soft',0.6),
+ sec(8,'i v bVI iv i v bVI iv','wave','walk','minimal',0.4),
+ ]),
+ song('spk02','Midnight Fog',70,'D','phrygian',
+ {melody:'reeds',pad:'darkPad',bass:'deepBass',drums:'minimal'},'space',[
+ sec(8,'i bII v i i bII v i','sparse','root','none',0.2),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','minimal',0.4),
+ sec(8,'i bII bVII i i bII bVII i','call','root','none',0.4),
+ sec(8,'i bII v i i bII v i','wave','walk','minimal',0.3),
+ ]),
+ song('spk03','Creeping Shadows',90,'E','minor',
+ {melody:'bellTone',pad:'darkPad',bass:'wobbleBass',drums:'soft'},'dark',[
+ sec(8,'i bVI bVII i i bVI bVII i','ostinato','root','minimal',0.4),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','soft',0.5),
+ sec(8,'i v bVI iv i v bVI iv','desc','root','minimal',0.6),
+ sec(8,'i bVI bVII i i bVI bVII i','sparse','walk','soft',0.4),
+ ]),
+ song('spk04','Ghost Waltz',100,'G','harmonicMinor',
+ {melody:'softLead',pad:'darkPad',bass:'deepBass',drums:'soft'},'dark',[
+ sec(8,'i iv bVI V i iv bVI V','step','root','shuffle',0.4),
+ sec(8,'i bII v i i bII v i','wave','walk','soft',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','arp','fifth','shuffle',0.6),
+ sec(8,'i iv bVI V i iv bVI V','call','root','soft',0.4),
+ ]),
+ song('spk05','Witch\'s Brew',110,'C','phrygian',
+ {melody:'reeds',pad:'darkPad',bass:'wobbleBass',drums:'electronic'},'dark',[
+ sec(8,'i bII v i i bII v i','ostinato','pulse','electronic',0.5),
+ sec(8,'i iv bVI V i iv bVI V','busy','walk','electronic',0.7),
+ sec(8,'i bII bVII i i bII bVII i','step','pulse','electronic',0.7),
+ sec(8,'i bII v i i bII v i','arp','root','electronic',0.6),
+ ]),
+ song('spk06','Crypt Descent',66,'F','minor',
+ {melody:'ethereal',pad:'darkPad',bass:'deepBass',drums:'minimal'},'space',[
+ sec(8,'i v bVI iv i v bVI iv','sparse','root','none',0.2),
+ sec(8,'i bVI bVII i i bVI bVII i','step','walk','minimal',0.3),
+ sec(8,'i iv bVI V i iv bVI V','desc','root','none',0.4),
+ sec(8,'i v bVI iv i v bVI iv','wave','walk','minimal',0.3),
+ ]),
+ song('spk07','Eerie Whispers',76,'Bb','harmonicMinor',
+ {melody:'softLead',pad:'shimmerPad',bass:'wobbleBass',drums:'minimal'},'ethereal',[
+ sec(8,'i bII v i i bII v i','call','root','none',0.2),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','minimal',0.4),
+ sec(8,'i bVI bVII i i bVI bVII i','wave','root','none',0.4),
+ sec(8,'i bII v i i bII v i','sparse','walk','minimal',0.3),
+ ]),
+ song('spk08','Dark Ritual',106,'D','phrygian',
+ {melody:'reeds',pad:'darkPad',bass:'deepBass',drums:'heavy'},'dark',[
+ sec(8,'i bII bVII i i bII bVII i','ostinato','root','tribal',0.6),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','rock',0.7),
+ sec(8,'i bII v i i bII v i','busy','pulse','tribal',0.8),
+ sec(8,'i bII bVII i i bII bVII i','leap','root','rock',0.7),
+ ]),
+ song('spk09','Phantom\'s Lament',84,'G#','minor',
+ {melody:'ethereal',pad:'darkPad',bass:'wobbleBass',drums:'soft'},'dark',[
+ sec(8,'i bVI bVII i i bVI bVII i','sparse','root','minimal',0.3),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','soft',0.4),
+ sec(8,'i v bVI iv i v bVI iv','wave','root','soft',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','call','walk','minimal',0.3),
+ ]),
+ song('spk10','Skeleton Dance',108,'E','harmonicMinor',
+ {melody:'bellTone',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'i iv bVI V i iv bVI V','ostinato','pulse','electronic',0.5),
+ sec(8,'i bII v i i bII v i','step','walk','shuffle',0.6),
+ sec(8,'i bVI bVII i i bVI bVII i','arp','pulse','electronic',0.7),
+ sec(8,'i iv bVI V i iv bVI V','busy','walk','shuffle',0.6),
+ ]),
+ song('spk11','Foghorn Bay',72,'C','minor',
+ {melody:'softLead',pad:'darkPad',bass:'deepBass',drums:'minimal'},'space',[
+ sec(8,'i v bVI iv i v bVI iv','sparse','root','none',0.2),
+ sec(8,'i bVI bVII i i bVI bVII i','step','walk','minimal',0.4),
+ sec(8,'i iv bVI V i iv bVI V','wave','root','minimal',0.4),
+ sec(8,'i v bVI iv i v bVI iv','call','walk','none',0.3),
+ ]),
+ song('spk12','Cursed Music Box',96,'F#','harmonicMinor',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'wobbleBass',drums:'soft'},'dark',[
+ sec(8,'i bII v i i bII v i','arp','root','minimal',0.4),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','soft',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','ostinato','root','soft',0.6),
+ sec(8,'i bII v i i bII v i','wave','walk','minimal',0.4),
+ ]),
+ song('spk13','Tomb Raider',104,'A','phrygian',
+ {melody:'reeds',pad:'darkPad',bass:'deepBass',drums:'heavy'},'dark',[
+ sec(8,'i bII bVII i i bII bVII i','step','root','rock',0.6),
+ sec(8,'i iv bVI V i iv bVI V','ostinato','walk','tribal',0.7),
+ sec(8,'i bII v i i bII v i','busy','pulse','rock',0.8),
+ sec(8,'i bII bVII i i bII bVII i','leap','root','tribal',0.7),
+ ]),
+ song('spk14','Banshee Wail',78,'Eb','minor',
+ {melody:'ethereal',pad:'darkPad',bass:'wobbleBass',drums:'minimal'},'ethereal',[
+ sec(8,'i v bVI iv i v bVI iv','sparse','root','none',0.2),
+ sec(8,'i bVI bVII i i bVI bVII i','asc','walk','minimal',0.4),
+ sec(8,'i iv bVI V i iv bVI V','desc','root','none',0.4),
+ sec(8,'i v bVI iv i v bVI iv','wave','walk','minimal',0.3),
+ ]),
+ song('spk15','Cobweb Corridor',88,'B','harmonicMinor',
+ {melody:'bellTone',pad:'darkPad',bass:'deepBass',drums:'soft'},'dark',[
+ sec(8,'i bII v i i bII v i','sparse','root','minimal',0.3),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','soft',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','arp','root','soft',0.6),
+ sec(8,'i bII v i i bII v i','call','walk','minimal',0.4),
+ ]),
+ song('spk16','Poltergeist Party',112,'G','phrygian',
+ {melody:'squareLead',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'i bII v i i bII v i','busy','pulse','electronic',0.6),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','shuffle',0.7),
+ sec(8,'i bII bVII i i bII bVII i','arp','pulse','electronic',0.7),
+ sec(8,'i bII v i i bII v i','ostinato','walk','shuffle',0.6),
+ ]),
+ song('spk17','Graveyard Shift',82,'C#','minor',
+ {melody:'softLead',pad:'darkPad',bass:'wobbleBass',drums:'minimal'},'dark',[
+ sec(8,'i bVI bVII i i bVI bVII i','sparse','root','minimal',0.3),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','soft',0.4),
+ sec(8,'i v bVI iv i v bVI iv','wave','root','minimal',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','call','walk','soft',0.4),
+ ]),
+ song('spk18','Voodoo Drums',102,'F','phrygian',
+ {melody:'reeds',pad:'darkPad',bass:'deepBass',drums:'heavy'},'dark',[
+ sec(8,'i bII v i i bII v i','ostinato','root','tribal',0.6),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','tribal',0.7),
+ sec(8,'i bII bVII i i bII bVII i','leap','root','rock',0.8),
+ sec(8,'i bII v i i bII v i','busy','walk','tribal',0.7),
+ ]),
+ song('spk19','Moonlit Mausoleum',68,'Ab','harmonicMinor',
+ {melody:'ethereal',pad:'shimmerPad',bass:'wobbleBass',drums:'minimal'},'space',[
+ sec(8,'i bII v i i bII v i','sparse','root','none',0.2),
+ sec(8,'i iv bVI V i iv bVI V','step','walk','minimal',0.3),
+ sec(8,'i bVI bVII i i bVI bVII i','wave','root','none',0.4),
+ sec(8,'i bII v i i bII v i','call','walk','minimal',0.3),
+ ]),
+ song('spk20','Nightmare Circus',98,'D','harmonicMinor',
+ {melody:'bellTone',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'retro',[
+ sec(8,'i iv bVI V i iv bVI V','arp','pulse','electronic',0.5),
+ sec(8,'i bII v i i bII v i','step','walk','shuffle',0.6),
+ sec(8,'i bVI bVII i i bVI bVII i','ostinato','pulse','electronic',0.7),
+ sec(8,'i iv bVI V i iv bVI V','busy','walk','shuffle',0.6),
+ ]),
+ song('spk21','Vampire Waltz',92,'Bb','harmonicMinor',
+ {melody:'softLead',pad:'darkPad',bass:'deepBass',drums:'soft'},'dark',[
+ sec(8,'i iv bVI V i iv bVI V','step','root','shuffle',0.4),
+ sec(8,'i bII v i i bII v i','wave','walk','soft',0.5),
+ sec(8,'i bVI bVII i i bVI bVII i','arp','fifth','shuffle',0.5),
+ sec(8,'i iv bVI V i iv bVI V','desc','root','soft',0.4),
+ ]),
+ song('spk22','The Abyss',62,'E','minor',
+ {melody:'ethereal',pad:'darkPad',bass:'wobbleBass',drums:'minimal'},'space',[
+ sec(8,'i v bVI iv i v bVI iv','sparse','root','none',0.2),
+ sec(8,'i bVI bVII i i bVI bVII i','step','walk','minimal',0.3),
+ sec(8,'i iv bVI V i iv bVI V','wave','root','none',0.3),
+ sec(8,'i v bVI iv i v bVI iv','call','walk','minimal',0.2),
+ ]),
+];
+
+// ─── Retro (catchy, 8-bit) ──────────────────────────────────
+
+const retro = [
+ song('ret01','Pixel Hero',140,'C','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(4,'I V vi IV','arp','pulse','electronic',0.6),
+ sec(8,'I IV V V I IV V V','step','walk','rock',0.7),
+ sec(8,'vi IV I V vi IV I V','busy','pulse','electronic',0.8),
+ sec(4,'I V vi IV','fanfare','root','fill',0.9),
+ sec(8,'I IV V V I IV V V','arp','walk','rock',0.7),
+ ]),
+ song('ret02','Coin Collector',150,'G','major',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'crisp',[
+ sec(8,'I V vi IV I V vi IV','arp','pulse','electronic',0.6),
+ sec(8,'vi IV I V vi IV I V','step','walk','rock',0.7),
+ sec(8,'I vi ii V I vi ii V','busy','pulse','electronic',0.8),
+ sec(8,'I bVII IV I I bVII IV I','ostinato','walk','rock',0.7),
+ ]),
+ song('ret03','Power Up',156,'D','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(4,'I IV V V','asc','root','electronic',0.6),
+ sec(8,'I V vi IV I V vi IV','step','walk','rock',0.7),
+ sec(8,'vi IV I V vi IV I V','arp','pulse','electronic',0.8),
+ sec(4,'I IV V V','fanfare','root','fill',0.9),
+ sec(8,'I bVII IV I I bVII IV I','wave','walk','rock',0.7),
+ ]),
+ song('ret04','Boss Battle 8-Bit',160,'A','minor',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'crisp',[
+ sec(4,'i VII VI VII','arp','root','electronic',0.7),
+ sec(8,'i iv v i i iv v i','busy','walk','rock',0.8),
+ sec(8,'i VI III VII i VI III VII','step','pulse','electronic',0.9),
+ sec(4,'i VII VI V','leap','root','fill',1.0),
+ sec(8,'i iv v i i iv v i','arp','walk','rock',0.8),
+ ]),
+ song('ret05','Chiptune Dreams',130,'F','pentatonic',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(8,'I IV I IV I IV I IV','penta','pulse','electronic',0.5),
+ sec(8,'I V I IV I V I IV','step','walk','rock',0.6),
+ sec(8,'IV V I IV IV V I IV','arp','pulse','electronic',0.7),
+ sec(8,'I IV I IV I IV I IV','wave','walk','rock',0.5),
+ ]),
+ song('ret06','Turbo Tunnel',158,'E','minor',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'crisp',[
+ sec(8,'i VII VI VII i VII VI VII','busy','pulse','electronic',0.8),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.8),
+ sec(8,'i VI III VII i VI III VII','arp','pulse','electronic',0.9),
+ sec(8,'i VII VI V i VII VI V','ostinato','walk','rock',0.8),
+ ]),
+ song('ret07','High Score',144,'Bb','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(4,'I V vi IV','fanfare','root','electronic',0.6),
+ sec(8,'I IV V V I IV V V','step','walk','rock',0.7),
+ sec(8,'vi IV I V vi IV I V','arp','pulse','electronic',0.8),
+ sec(4,'I bVII IV I','leap','root','fill',0.9),
+ sec(8,'I V vi IV I V vi IV','wave','walk','rock',0.7),
+ ]),
+ song('ret08','Warp Zone',148,'C','blues',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'retro',[
+ sec(8,'I IV I IV I IV I IV','penta','pulse','electronic',0.5),
+ sec(8,'I V I IV I V I IV','step','walk','shuffle',0.6),
+ sec(8,'IV V I IV IV V I IV','busy','pulse','electronic',0.7),
+ sec(8,'I IV I IV I IV I IV','arp','walk','shuffle',0.6),
+ ]),
+ song('ret09','Star Run',152,'G','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'crisp',[
+ sec(4,'I V vi IV','asc','root','electronic',0.6),
+ sec(8,'I vi ii V I vi ii V','step','walk','rock',0.7),
+ sec(8,'I bVII IV I I bVII IV I','arp','pulse','electronic',0.8),
+ sec(4,'I V vi IV','fanfare','root','fill',0.9),
+ sec(8,'vi IV I V vi IV I V','busy','walk','rock',0.8),
+ ]),
+ song('ret10','Dungeon Crawl',136,'D','minor',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'dark',[
+ sec(8,'i iv v i i iv v i','ostinato','root','electronic',0.5),
+ sec(8,'i VII VI VII i VII VI VII','step','walk','rock',0.6),
+ sec(8,'i iv v i i iv v i','arp','pulse','electronic',0.7),
+ sec(8,'i VII VI V i VII VI V','busy','walk','rock',0.7),
+ ]),
+ song('ret11','Arcade Fever',146,'A','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(8,'I V vi IV I V vi IV','arp','pulse','electronic',0.6),
+ sec(8,'I IV V V I IV V V','step','walk','rock',0.7),
+ sec(8,'vi IV I V vi IV I V','busy','pulse','electronic',0.8),
+ sec(8,'I bVII IV I I bVII IV I','wave','walk','rock',0.7),
+ ]),
+ song('ret12','Crystal Caves',132,'F#','minorPentatonic',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'retro',[
+ sec(8,'i VII i VII i VII i VII','penta','root','electronic',0.5),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.6),
+ sec(8,'i VII VI V i VII VI V','arp','pulse','electronic',0.7),
+ sec(8,'i iv VII i i iv VII i','ostinato','walk','rock',0.6),
+ ]),
+ song('ret13','Rainbow Road',154,'Eb','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'bright',[
+ sec(4,'I V vi IV','asc','root','electronic',0.6),
+ sec(8,'I IV V V I IV V V','step','walk','rock',0.7),
+ sec(8,'vi IV I V vi IV I V','arp','pulse','electronic',0.8),
+ sec(4,'I bVII IV I','fanfare','root','fill',0.9),
+ sec(8,'I V vi IV I V vi IV','busy','walk','rock',0.8),
+ ]),
+ song('ret14','Byte Blaster',162,'B','minor',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'crisp',[
+ sec(8,'i VII VI VII i VII VI VII','busy','pulse','electronic',0.7),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.8),
+ sec(8,'i VI III VII i VI III VII','arp','pulse','electronic',0.9),
+ sec(8,'i VII VI V i VII VI V','ostinato','walk','rock',0.8),
+ ]),
+ song('ret15','Space Invader',142,'C','minor',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(8,'i VII VI VII i VII VI VII','arp','pulse','electronic',0.6),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.7),
+ sec(8,'i VI III VII i VI III VII','busy','pulse','electronic',0.8),
+ sec(8,'i VII VI V i VII VI V','wave','walk','rock',0.7),
+ ]),
+ song('ret16','Pixel Sunset',118,'G','pentatonic',
+ {melody:'chipSaw',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','electronic',0.4),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.5),
+ sec(8,'IV V I IV IV V I IV','wave','pulse','electronic',0.5),
+ sec(8,'I IV I IV I IV I IV','arp','root','soft',0.4),
+ ]),
+ song('ret17','Mega Drive',156,'F','major',
+ {melody:'chipSquare',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'retro',[
+ sec(4,'I V vi IV','fanfare','root','electronic',0.6),
+ sec(8,'I IV V V I IV V V','busy','walk','rock',0.7),
+ sec(8,'vi IV I V vi IV I V','step','pulse','electronic',0.8),
+ sec(4,'I bVII IV I','leap','root','fill',0.9),
+ sec(8,'I V vi IV I V vi IV','arp','walk','rock',0.7),
+ ]),
+ song('ret18','Game Over',112,'D','minorPentatonic',
+ {melody:'chipSaw',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'dark',[
+ sec(8,'i VII i VII i VII i VII','desc','root','electronic',0.4),
+ sec(8,'i iv v i i iv v i','step','walk','rock',0.5),
+ sec(8,'i VII VI V i VII VI V','sparse','pulse','minimal',0.4),
+ sec(8,'i iv VII i i iv VII i','call','root','electronic',0.4),
+ ]),
+ song('ret19','Continue?',138,'Ab','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(8,'I V vi IV I V vi IV','step','pulse','electronic',0.5),
+ sec(8,'I IV V V I IV V V','arp','walk','rock',0.6),
+ sec(8,'vi IV I V vi IV I V','ostinato','pulse','electronic',0.7),
+ sec(8,'I bVII IV I I bVII IV I','wave','walk','rock',0.6),
+ ]),
+ song('ret20','Pixel Paradise',128,'E','major',
+ {melody:'chipSaw',pad:'organPad',bass:'synthBass',drums:'retro8bit'},'bright',[
+ sec(8,'I V vi IV I V vi IV','penta','pulse','electronic',0.5),
+ sec(8,'I vi ii V I vi ii V','step','walk','rock',0.6),
+ sec(8,'I IV V V I IV V V','arp','pulse','electronic',0.7),
+ sec(8,'I bVII IV I I bVII IV I','wave','walk','rock',0.6),
+ ]),
+ song('ret21','Bonus Stage',160,'Bb','major',
+ {melody:'chipSquare',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'retro',[
+ sec(4,'I V vi IV','fanfare','root','electronic',0.7),
+ sec(8,'I IV V V I IV V V','busy','walk','rock',0.8),
+ sec(8,'vi IV I V vi IV I V','arp','pulse','electronic',0.9),
+ sec(4,'I V vi IV','leap','root','fill',1.0),
+ sec(8,'I bVII IV I I bVII IV I','step','walk','rock',0.8),
+ ]),
+ song('ret22','8-Bit Sunrise',120,'C','pentatonic',
+ {melody:'chipSaw',pad:'organPad',bass:'pulseBass',drums:'retro8bit'},'warm',[
+ sec(8,'I IV I IV I IV I IV','penta','root','electronic',0.4),
+ sec(8,'I V I IV I V I IV','asc','walk','soft',0.5),
+ sec(8,'IV V I IV IV V I IV','step','pulse','electronic',0.5),
+ sec(8,'I IV I IV I IV I IV','wave','root','soft',0.4),
+ ]),
+];
+
+// ─── Space (modal, ethereal) ────────────────────────────────
+
+const space = [
+ song('spc01','Nebula Drift',90,'C','lydian',
+ {melody:'ethereal',pad:'shimmerPad',bass:'deepBass',drums:'electronic'},'space',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ sec(8,'I bVII I bVII I bVII I bVII','step','walk','electronic',0.4),
+ sec(8,'I v IV I I v IV I','wave','root','minimal',0.5),
+ sec(8,'bVII I bVII I bVII I bVII I','call','walk','electronic',0.4),
+ ]),
+ song('spc02','Wormhole',110,'D','wholeTone',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'wobbleBass',drums:'electronic'},'space',[
+ sec(8,'I II IV I I II IV I','arp','root','electronic',0.4),
+ sec(8,'I II IV I I II IV I','step','walk','electronic',0.5),
+ sec(8,'I II IV I I II IV I','ostinato','pulse','electronic',0.6),
+ sec(8,'I II IV I I II IV I','wave','root','electronic',0.5),
+ ]),
+ song('spc03','Starfield',80,'G','lydian',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I bVII I bVII I bVII I bVII','step','walk','minimal',0.4),
+ sec(8,'I v IV I I v IV I','wave','root','none',0.4),
+ sec(8,'I II IV I I II IV I','call','walk','minimal',0.3),
+ ]),
+ song('spc04','Gravity Well',100,'A','dorian',
+ {melody:'ethereal',pad:'darkPad',bass:'deepBass',drums:'electronic'},'space',[
+ sec(8,'i IV i IV i IV i IV','ostinato','root','electronic',0.4),
+ sec(8,'bVII I bVII I bVII I bVII I','step','walk','electronic',0.5),
+ sec(8,'i IV i IV i IV i IV','wave','root','electronic',0.5),
+ sec(8,'bVII I bVII I bVII I bVII I','arp','walk','electronic',0.4),
+ ]),
+ song('spc05','Solar Winds',88,'F','mixolydian',
+ {melody:'flute',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I bVII I bVII I bVII I bVII','sparse','root','minimal',0.3),
+ sec(8,'I v IV I I v IV I','step','walk','soft',0.4),
+ sec(8,'I bVII I bVII I bVII I bVII','wave','root','minimal',0.4),
+ sec(8,'I v IV I I v IV I','penta','walk','soft',0.3),
+ ]),
+ song('spc06','Deep Space',72,'Eb','lydian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'deepBass',drums:'minimal'},'space',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I bVII I bVII I bVII I bVII','step','walk','minimal',0.3),
+ sec(8,'I v IV I I v IV I','wave','root','none',0.3),
+ sec(8,'I II IV I I II IV I','arp','walk','minimal',0.3),
+ ]),
+ song('spc07','Meteor Shower',114,'B','dorian',
+ {melody:'softLead',pad:'shimmerPad',bass:'wobbleBass',drums:'electronic'},'ethereal',[
+ sec(8,'i IV i IV i IV i IV','busy','root','electronic',0.5),
+ sec(8,'bVII I bVII I bVII I bVII I','step','walk','electronic',0.6),
+ sec(8,'i IV i IV i IV i IV','arp','pulse','electronic',0.7),
+ sec(8,'bVII I bVII I bVII I bVII I','wave','root','electronic',0.5),
+ ]),
+ song('spc08','Cosmic Silence',70,'Ab','pentatonic',
+ {melody:'ethereal',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I IV I IV I IV I IV','sparse','root','none',0.2),
+ sec(8,'I V I IV I V I IV','step','walk','minimal',0.3),
+ sec(8,'IV V I IV IV V I IV','penta','root','none',0.3),
+ sec(8,'I IV I IV I IV I IV','call','walk','minimal',0.2),
+ ]),
+ song('spc09','Pulsar Beat',108,'E','dorian',
+ {melody:'crystalBell',pad:'darkPad',bass:'pulseBass',drums:'electronic'},'space',[
+ sec(8,'i IV i IV i IV i IV','ostinato','pulse','electronic',0.5),
+ sec(8,'bVII I bVII I bVII I bVII I','step','walk','electronic',0.6),
+ sec(8,'i IV i IV i IV i IV','arp','pulse','electronic',0.6),
+ sec(8,'bVII I bVII I bVII I bVII I','busy','walk','electronic',0.6),
+ ]),
+ song('spc10','Orbital Station',96,'C','mixolydian',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'electronic'},'ambient',[
+ sec(8,'I bVII I bVII I bVII I bVII','sparse','root','minimal',0.3),
+ sec(8,'I v IV I I v IV I','step','walk','electronic',0.4),
+ sec(8,'I bVII I bVII I bVII I bVII','wave','root','electronic',0.5),
+ sec(8,'I v IV I I v IV I','arp','walk','minimal',0.4),
+ ]),
+ song('spc11','Zero Gravity',84,'F#','lydian',
+ {melody:'flute',pad:'shimmerPad',bass:'deepBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I bVII I bVII I bVII I bVII','step','walk','minimal',0.3),
+ sec(8,'I v IV I I v IV I','wave','root','none',0.4),
+ sec(8,'I II IV I I II IV I','call','walk','minimal',0.3),
+ ]),
+ song('spc12','Asteroid Field',118,'G','dorian',
+ {melody:'crystalBell',pad:'darkPad',bass:'wobbleBass',drums:'electronic'},'space',[
+ sec(8,'i IV i IV i IV i IV','busy','root','electronic',0.5),
+ sec(8,'bVII I bVII I bVII I bVII I','step','walk','rock',0.6),
+ sec(8,'i IV i IV i IV i IV','arp','pulse','electronic',0.7),
+ sec(8,'bVII I bVII I bVII I bVII I','ostinato','walk','rock',0.6),
+ ]),
+ song('spc13','Event Horizon',76,'D','lydian',
+ {melody:'ethereal',pad:'shimmerPad',bass:'deepBass',drums:'minimal'},'space',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I v IV I I v IV I','step','walk','minimal',0.3),
+ sec(8,'bVII I bVII I bVII I bVII I','wave','root','none',0.4),
+ sec(8,'I II IV I I II IV I','desc','walk','minimal',0.3),
+ ]),
+ song('spc14','Light Speed',120,'A','mixolydian',
+ {melody:'brightLead',pad:'shimmerPad',bass:'synthBass',drums:'electronic'},'bright',[
+ sec(4,'I bVII I bVII','asc','root','electronic',0.5),
+ sec(8,'I v IV I I v IV I','step','walk','rock',0.6),
+ sec(8,'bVII I bVII I bVII I bVII I','busy','pulse','electronic',0.7),
+ sec(4,'I bVII I bVII','leap','root','fill',0.8),
+ sec(8,'I v IV I I v IV I','arp','walk','rock',0.6),
+ ]),
+ song('spc15','Saturn Rings',92,'Bb','pentatonic',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I IV I IV I IV I IV','penta','root','minimal',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.4),
+ sec(8,'IV V I IV IV V I IV','wave','root','soft',0.4),
+ sec(8,'I IV I IV I IV I IV','call','walk','minimal',0.3),
+ ]),
+ song('spc16','Quantum Flux',104,'E','wholeTone',
+ {melody:'crystalBell',pad:'darkPad',bass:'wobbleBass',drums:'electronic'},'space',[
+ sec(8,'I II IV I I II IV I','ostinato','root','electronic',0.4),
+ sec(8,'I II IV I I II IV I','step','walk','electronic',0.5),
+ sec(8,'I II IV I I II IV I','arp','pulse','electronic',0.6),
+ sec(8,'I II IV I I II IV I','wave','root','electronic',0.5),
+ ]),
+ song('spc17','Moon Colony',98,'F','dorian',
+ {melody:'flute',pad:'shimmerPad',bass:'roundBass',drums:'electronic'},'warm',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','electronic',0.3),
+ sec(8,'bVII I bVII I bVII I bVII I','step','walk','electronic',0.5),
+ sec(8,'i IV i IV i IV i IV','wave','root','soft',0.5),
+ sec(8,'bVII I bVII I bVII I bVII I','penta','walk','electronic',0.4),
+ ]),
+ song('spc18','Cosmic Dust',78,'C#','lydian',
+ {melody:'ethereal',pad:'shimmerPad',bass:'deepBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I bVII I bVII I bVII I bVII','step','walk','minimal',0.3),
+ sec(8,'I v IV I I v IV I','wave','root','none',0.3),
+ sec(8,'I II IV I I II IV I','call','walk','minimal',0.2),
+ ]),
+ song('spc19','Supernova',116,'G','mixolydian',
+ {melody:'brightLead',pad:'shimmerPad',bass:'wobbleBass',drums:'electronic'},'bright',[
+ sec(4,'I bVII I bVII','fanfare','root','electronic',0.6),
+ sec(8,'I v IV I I v IV I','step','walk','rock',0.7),
+ sec(8,'bVII I bVII I bVII I bVII I','arp','pulse','electronic',0.8),
+ sec(4,'I bVII I bVII','leap','root','fill',0.9),
+ sec(8,'I v IV I I v IV I','busy','walk','rock',0.7),
+ ]),
+ song('spc20','Voyager',86,'D','pentatonic',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ambient',[
+ sec(8,'I IV I IV I IV I IV','penta','root','minimal',0.3),
+ sec(8,'I V I IV I V I IV','step','walk','soft',0.4),
+ sec(8,'IV V I IV IV V I IV','wave','root','soft',0.4),
+ sec(8,'I IV I IV I IV I IV','sparse','walk','minimal',0.3),
+ ]),
+ song('spc21','Black Hole Sun',74,'Ab','dorian',
+ {melody:'ethereal',pad:'darkPad',bass:'deepBass',drums:'minimal'},'space',[
+ sec(8,'i IV i IV i IV i IV','sparse','root','none',0.2),
+ sec(8,'bVII I bVII I bVII I bVII I','step','walk','minimal',0.4),
+ sec(8,'i IV i IV i IV i IV','wave','root','none',0.4),
+ sec(8,'bVII I bVII I bVII I bVII I','desc','walk','minimal',0.3),
+ ]),
+ song('spc22','First Contact',106,'B','lydian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'electronic'},'ethereal',[
+ sec(8,'I II IV I I II IV I','arp','root','electronic',0.4),
+ sec(8,'I bVII I bVII I bVII I bVII','step','walk','electronic',0.5),
+ sec(8,'I v IV I I v IV I','ostinato','pulse','electronic',0.5),
+ sec(8,'I II IV I I II IV I','wave','root','electronic',0.4),
+ ]),
+];
+
+// ─── Fantasy (orchestral, modal) ────────────────────────────
+
+const fantasy = [
+ song('fan01','Enchanted Forest',110,'D','dorian',
+ {melody:'flute',pad:'warmPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'I ii IV V','sparse','root','minimal',0.4),
+ sec(8,'I ii IV V I ii IV V','step','walk','rock',0.6),
+ sec(8,'vi III VII IV vi III VII IV','wave','fifth','rock',0.7),
+ sec(4,'I bVII IV I','leap','root','fill',0.8),
+ sec(8,'I ii IV V I ii IV V','arp','walk','rock',0.6),
+ ]),
+ song('fan02','Dragon\'s Lair',130,'E','harmonicMinor',
+ {melody:'brass',pad:'warmPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i III VII IV','fanfare','root','march',0.7),
+ sec(8,'i III VII IV i III VII IV','step','walk','rock',0.8),
+ sec(8,'vi III VII IV vi III VII IV','busy','fifth','rock',0.9),
+ sec(4,'I bVII IV I','leap','oct','fill',1.0),
+ sec(8,'i III VII IV i III VII IV','arp','walk','march',0.8),
+ ]),
+ song('fan03','Mystic Lake',90,'G','mixolydian',
+ {melody:'bellTone',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'warm',[
+ sec(8,'I bVII IV I I bVII IV I','sparse','root','minimal',0.3),
+ sec(8,'I ii IV V I ii IV V','step','walk','soft',0.5),
+ sec(8,'I III vi IV I III vi IV','wave','fifth','soft',0.5),
+ sec(8,'I bVII IV I I bVII IV I','call','root','minimal',0.4),
+ ]),
+ song('fan04','Throne Room',120,'C','major',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'I ii IV V','fanfare','root','march',0.6),
+ sec(8,'I III vi IV I III vi IV','step','walk','rock',0.7),
+ sec(8,'vi III VII IV vi III VII IV','arp','fifth','rock',0.8),
+ sec(4,'I bVII IV I','leap','oct','fill',0.9),
+ sec(8,'I ii IV V I ii IV V','wave','walk','march',0.7),
+ ]),
+ song('fan05','Fairy Glen',96,'F','lydian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ sec(8,'I ii IV V I ii IV V','step','walk','soft',0.4),
+ sec(8,'I III vi IV I III vi IV','penta','fifth','soft',0.5),
+ sec(8,'I II IV I I II IV I','wave','root','minimal',0.3),
+ ]),
+ song('fan06','Battle Standard',128,'A','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i III VII IV','fanfare','root','march',0.7),
+ sec(8,'i III VII IV i III VII IV','step','walk','rock',0.8),
+ sec(4,'I bVII IV I','leap','oct','fill',1.0),
+ sec(8,'vi III VII IV vi III VII IV','busy','fifth','rock',0.9),
+ sec(8,'i III VII IV i III VII IV','arp','walk','march',0.8),
+ ]),
+ song('fan07','Ancient Library',88,'Bb','dorian',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'minimal'},'warm',[
+ sec(8,'i IV i IV i IV i IV','call','root','minimal',0.3),
+ sec(8,'I ii IV V I ii IV V','step','walk','soft',0.5),
+ sec(8,'I bVII IV I I bVII IV I','ostinato','fifth','minimal',0.5),
+ sec(8,'i IV i IV i IV i IV','wave','root','soft',0.4),
+ ]),
+ song('fan08','Phoenix Rising',126,'D','harmonicMinor',
+ {melody:'reeds',pad:'warmPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'i III VII IV','asc','root','rock',0.6),
+ sec(8,'i III VII IV i III VII IV','step','walk','rock',0.7),
+ sec(8,'vi III VII IV vi III VII IV','arp','fifth','rock',0.8),
+ sec(4,'I bVII IV I','fanfare','oct','fill',0.9),
+ sec(8,'i III VII IV i III VII IV','wave','walk','rock',0.7),
+ ]),
+ song('fan09','Elven Melody',100,'G','lydian',
+ {melody:'flute',pad:'shimmerPad',bass:'roundBass',drums:'soft'},'ethereal',[
+ sec(8,'I II IV I I II IV I','step','root','minimal',0.3),
+ sec(8,'I ii IV V I ii IV V','wave','walk','soft',0.5),
+ sec(8,'I III vi IV I III vi IV','penta','fifth','soft',0.5),
+ sec(8,'I II IV I I II IV I','sparse','root','minimal',0.3),
+ ]),
+ song('fan10','Siege Warfare',132,'F','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'dark',[
+ sec(4,'i III VII IV','fanfare','root','tribal',0.7),
+ sec(8,'i III VII IV i III VII IV','busy','walk','rock',0.9),
+ sec(4,'I bVII IV I','leap','oct','fill',1.0),
+ sec(8,'vi III VII IV vi III VII IV','step','fifth','rock',0.9),
+ sec(8,'i III VII IV i III VII IV','arp','walk','tribal',0.8),
+ ]),
+ song('fan11','Wanderer\'s Song',106,'C','dorian',
+ {melody:'reeds',pad:'warmPad',bass:'roundBass',drums:'standard'},'chamber',[
+ sec(8,'i IV i IV i IV i IV','call','root','shuffle',0.4),
+ sec(8,'I ii IV V I ii IV V','step','walk','rock',0.6),
+ sec(8,'I bVII IV I I bVII IV I','arp','fifth','rock',0.7),
+ sec(8,'i IV i IV i IV i IV','wave','root','shuffle',0.5),
+ ]),
+ song('fan12','Crystal Caverns',94,'Eb','lydian',
+ {melody:'crystalBell',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','arp','root','minimal',0.3),
+ sec(8,'I ii IV V I ii IV V','step','walk','soft',0.4),
+ sec(8,'I III vi IV I III vi IV','wave','fifth','minimal',0.5),
+ sec(8,'I II IV I I II IV I','sparse','root','soft',0.3),
+ ]),
+ song('fan13','War Drums',124,'G','minor',
+ {melody:'brass',pad:'darkPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i III VII IV','fanfare','root','tribal',0.7),
+ sec(8,'i III VII IV i III VII IV','step','walk','rock',0.8),
+ sec(8,'vi III VII IV vi III VII IV','busy','fifth','tribal',0.9),
+ sec(4,'I bVII IV I','leap','oct','fill',1.0),
+ sec(8,'i III VII IV i III VII IV','arp','walk','rock',0.8),
+ ]),
+ song('fan14','Tavern Night',116,'A','mixolydian',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'standard'},'warm',[
+ sec(8,'I bVII IV I I bVII IV I','step','root','shuffle',0.5),
+ sec(8,'I ii IV V I ii IV V','arp','walk','rock',0.6),
+ sec(8,'I III vi IV I III vi IV','ostinato','fifth','shuffle',0.7),
+ sec(8,'I bVII IV I I bVII IV I','call','root','rock',0.5),
+ ]),
+ song('fan15','Sorcerer\'s Tower',108,'B','harmonicMinor',
+ {melody:'bellTone',pad:'darkPad',bass:'deepBass',drums:'soft'},'dark',[
+ sec(8,'i III VII IV i III VII IV','sparse','root','minimal',0.3),
+ sec(8,'vi III VII IV vi III VII IV','step','walk','soft',0.5),
+ sec(8,'I bVII IV I I bVII IV I','arp','root','soft',0.6),
+ sec(8,'i III VII IV i III VII IV','wave','walk','minimal',0.4),
+ ]),
+ song('fan16','Champion\'s March',130,'D','major',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'I ii IV V','fanfare','root','march',0.7),
+ sec(8,'I III vi IV I III vi IV','step','walk','rock',0.8),
+ sec(4,'vi III VII IV','leap','oct','fill',0.9),
+ sec(8,'I ii IV V I ii IV V','arp','fifth','rock',0.8),
+ sec(8,'I bVII IV I I bVII IV I','wave','walk','march',0.7),
+ ]),
+ song('fan17','Shadow Keep',102,'F#','minor',
+ {melody:'reeds',pad:'darkPad',bass:'wobbleBass',drums:'soft'},'dark',[
+ sec(8,'i III VII IV i III VII IV','sparse','root','minimal',0.3),
+ sec(8,'vi III VII IV vi III VII IV','step','walk','soft',0.5),
+ sec(8,'I bVII IV I I bVII IV I','ostinato','root','soft',0.6),
+ sec(8,'i III VII IV i III VII IV','desc','walk','minimal',0.4),
+ ]),
+ song('fan18','Harvest Festival',114,'C','major',
+ {melody:'pluck',pad:'warmPad',bass:'roundBass',drums:'standard'},'chamber',[
+ sec(8,'I ii IV V I ii IV V','step','root','rock',0.5),
+ sec(8,'I III vi IV I III vi IV','arp','walk','shuffle',0.6),
+ sec(8,'I bVII IV I I bVII IV I','penta','fifth','rock',0.6),
+ sec(8,'I ii IV V I ii IV V','wave','root','shuffle',0.5),
+ ]),
+ song('fan19','Quest Begins',118,'Eb','dorian',
+ {melody:'flute',pad:'warmPad',bass:'deepBass',drums:'standard'},'hall',[
+ sec(4,'i IV i IV','asc','root','minimal',0.4),
+ sec(8,'I ii IV V I ii IV V','step','walk','rock',0.6),
+ sec(8,'vi III VII IV vi III VII IV','wave','fifth','rock',0.7),
+ sec(4,'I bVII IV I','fanfare','oct','fill',0.8),
+ sec(8,'I ii IV V I ii IV V','arp','walk','rock',0.6),
+ ]),
+ song('fan20','Dragon Flight',134,'A','harmonicMinor',
+ {melody:'brass',pad:'warmPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i III VII IV','fanfare','root','march',0.8),
+ sec(8,'i III VII IV i III VII IV','busy','walk','rock',0.9),
+ sec(8,'vi III VII IV vi III VII IV','step','fifth','rock',0.9),
+ sec(4,'I bVII IV I','leap','oct','fill',1.0),
+ sec(8,'i III VII IV i III VII IV','arp','walk','march',0.8),
+ ]),
+ song('fan21','Moonlit Path',86,'G','lydian',
+ {melody:'softLead',pad:'shimmerPad',bass:'roundBass',drums:'minimal'},'ethereal',[
+ sec(8,'I II IV I I II IV I','sparse','root','none',0.2),
+ sec(8,'I ii IV V I ii IV V','step','walk','soft',0.4),
+ sec(8,'I III vi IV I III vi IV','wave','fifth','minimal',0.5),
+ sec(8,'I II IV I I II IV I','call','root','soft',0.3),
+ ]),
+ song('fan22','Epic Finale',136,'D','minor',
+ {melody:'brass',pad:'organPad',bass:'deepBass',drums:'heavy'},'hall',[
+ sec(4,'i III VII IV','fanfare','root','march',0.8),
+ sec(8,'vi III VII IV vi III VII IV','step','walk','rock',0.8),
+ sec(4,'I bVII IV I','leap','oct','fill',1.0),
+ sec(8,'i III VII IV i III VII IV','busy','fifth','rock',0.9),
+ sec(8,'vi III VII IV I bVII IV I','arp','walk','march',0.8),
+ ]),
+];
+
+// ─── Export ──────────────────────────────────────────────────
+
+export const SONG_LIBRARY = {
+ adventure,
+ action,
+ puzzle,
+ relaxing,
+ spooky,
+ retro,
+ space,
+ fantasy,
+};
+
+// ─── Song Metadata (mood, energy, tags, descriptions) ───────
+
+export const SONG_METADATA = {
+ // Adventure
+ adv01:{mood:'Heroic',energy:7,tags:['epic','march','triumphant','brass'],description:'Bold brass fanfares with a driving march rhythm'},
+ adv02:{mood:'Adventurous',energy:6,tags:['journey','quest','bright','uplifting'],description:'Bright lead melody over steady rock beat, building anticipation'},
+ adv03:{mood:'Peaceful',energy:4,tags:['calm','exploration','open','serene'],description:'Gentle flute over shimmering pads with open lydian harmony'},
+ adv04:{mood:'Epic',energy:9,tags:['intense','dragon','battle','dramatic'],description:'Dark minor brass with heavy drums and urgent tempo'},
+ adv05:{mood:'Calm',energy:3,tags:['peaceful','village','gentle','morning'],description:'Soft flute melody drifting over warm pads and light percussion'},
+ adv06:{mood:'Heroic',energy:7,tags:['castle','royal','grand','march'],description:'Grand organ and brass march through heroic chord progressions'},
+ adv07:{mood:'Adventurous',energy:5,tags:['journey','river','flowing','exploration'],description:'Reed melody with mixolydian shuffle feel and warm harmony'},
+ adv08:{mood:'Adventurous',energy:6,tags:['hiking','trail','upbeat','outdoor'],description:'Bright pentatonic lead with steady march building to fanfare'},
+ adv09:{mood:'Triumphant',energy:6,tags:['discovery','reward','bright','celebration'],description:'Bell tones shimmer over arpeggiated chords with celebratory energy'},
+ adv10:{mood:'Mysterious',energy:4,tags:['forest','nature','winding','calm'],description:'Quiet flute weaving through dorian harmonies with soft shuffle'},
+ adv11:{mood:'Intense',energy:9,tags:['combat','war','drums','powerful'],description:'Heavy tribal drums driving brass and dark pads into battle'},
+ adv12:{mood:'Peaceful',energy:3,tags:['harbor','rest','gentle','water'],description:'Soft lead floating over shimmer pads with minimal percussion'},
+ adv13:{mood:'Heroic',energy:7,tags:['knight','oath','noble','brass'],description:'Noble brass fanfares with organ pad support and march drums'},
+ adv14:{mood:'Adventurous',energy:5,tags:['wind','ridge','journey','exploration'],description:'Reed call-and-response melody with warm mixolydian harmony'},
+ adv15:{mood:'Energetic',energy:7,tags:['expedition','discovery','bold','driving'],description:'Bright lead driving through major progressions with busy energy'},
+ adv16:{mood:'Mysterious',energy:4,tags:['ruins','ancient','discovery','atmospheric'],description:'Bell tones echoing through dorian spaces with soft shimmer'},
+ adv17:{mood:'Triumphant',energy:8,tags:['victory','celebration','fanfare','climax'],description:'Full brass victory fanfare with heavy drums and busy fills'},
+ adv18:{mood:'Calm',energy:3,tags:['meadow','gentle','walking','pastoral'],description:'Plucked pentatonic melody strolling over warm pads softly'},
+ adv19:{mood:'Adventurous',energy:6,tags:['ocean','sailing','voyage','nautical'],description:'Reed melody with mixolydian sea-shanty feel and flowing rhythm'},
+ adv20:{mood:'Epic',energy:7,tags:['climbing','summit','achievement','dramatic'],description:'Bright ascending lead building to dramatic peak and fanfare'},
+ adv21:{mood:'Peaceful',energy:3,tags:['evening','rest','campfire','gentle'],description:'Soft sparse lead over shimmer pads fading into quiet night'},
+ adv22:{mood:'Epic',energy:8,tags:['climax','frontier','bold','powerful'],description:'Powerful brass and organ driving through heroic final push'},
+
+ // Action
+ act01:{mood:'Intense',energy:9,tags:['fast','adrenaline','combat','driving'],description:'Blazing lead over heavy drums and wobble bass at 160bpm'},
+ act02:{mood:'Dark',energy:8,tags:['stealth','dark','strike','aggressive'],description:'Square wave ostinato in phrygian with electronic beat tension'},
+ act03:{mood:'Intense',energy:10,tags:['chase','fast','urgent','frantic'],description:'Maximum speed chase with bright lead and heavy drums at 170bpm'},
+ act04:{mood:'Epic',energy:8,tags:['battle','war','orchestral','powerful'],description:'Harmonic minor brass with heavy march drums and fanfare breaks'},
+ act05:{mood:'Energetic',energy:7,tags:['night','running','urban','electronic'],description:'Pentatonic square lead pulsing over electronic beat in darkness'},
+ act06:{mood:'Epic',energy:10,tags:['boss','intense','climax','powerful'],description:'Harmonic minor boss battle with tribal drums and maximum intensity'},
+ act07:{mood:'Tense',energy:4,tags:['stealth','sneaking','quiet','suspense'],description:'Sparse reeds over dark pads with quiet electronic pulse'},
+ act08:{mood:'Energetic',energy:9,tags:['power','energy','fast','electronic'],description:'Bright lead surging through minor progressions with driving bass'},
+ act09:{mood:'Dark',energy:8,tags:['danger','tension','aggressive','dark'],description:'Phrygian ostinato with wobble bass and electronic aggression'},
+ act10:{mood:'Intense',energy:9,tags:['escape','fast','urgent','driving'],description:'Urgent lead driving escape over heavy drums and pulse bass'},
+ act11:{mood:'Powerful',energy:8,tags:['determination','strong','march','heavy'],description:'Iron-willed brass march through minor with heavy percussion'},
+ act12:{mood:'Energetic',energy:9,tags:['speed','turbo','fast','electronic'],description:'Square wave pentatonic blazing at 168bpm with electronic drive'},
+ act13:{mood:'Intense',energy:8,tags:['combat','preparation','aggressive','heavy'],description:'Brass and tribal drums in harmonic minor battle preparation'},
+ act14:{mood:'Dark',energy:7,tags:['night','assault','tactical','electronic'],description:'Reed ostinato building tension through dark electronic layers'},
+ act15:{mood:'Aggressive',energy:9,tags:['fury','rage','intense','fast'],description:'Unleashed bright lead fury over harmonic minor at 164bpm'},
+ act16:{mood:'Energetic',energy:8,tags:['rapid','shooting','fast','electronic'],description:'Rapid pentatonic pulses firing over electronic beats'},
+ act17:{mood:'Powerful',energy:8,tags:['war','march','heavy','determined'],description:'Brass warpath march through minor with heavy drum determination'},
+ act18:{mood:'Energetic',energy:6,tags:['reflexes','agile','moderate','electronic'],description:'Moderate reed calls building through dark electronic layers'},
+ act19:{mood:'Intense',energy:9,tags:['thunder','powerful','dramatic','heavy'],description:'Heavy brass thunder strikes with tribal drums and wobble bass'},
+ act20:{mood:'Intense',energy:10,tags:['speed','extreme','fast','driving'],description:'Maximum velocity at 170bpm with bright lead and electronic fury'},
+ act21:{mood:'Tense',energy:5,tags:['ambush','waiting','suspense','dark'],description:'Sparse phrygian reeds lurking in dark electronic ambush'},
+ act22:{mood:'Epic',energy:9,tags:['final','climax','dramatic','powerful'],description:'Final harmonic minor clash with brass fanfare and heavy march'},
+
+ // Puzzle
+ puz01:{mood:'Thoughtful',energy:4,tags:['thinking','cerebral','bells','calm'],description:'Bell tones arpeggiate gently over shimmering thoughtful pads'},
+ puz02:{mood:'Quirky',energy:5,tags:['gears','mechanical','pluck','electronic'],description:'Plucked ostinato patterns clicking like gears with electronic pulse'},
+ puz03:{mood:'Calm',energy:3,tags:['thinking','gentle','crystal','atmospheric'],description:'Crystal bells floating through mixolydian space with soft warmth'},
+ puz04:{mood:'Quirky',energy:5,tags:['clockwork','mechanical','pluck','tick'],description:'Clockwork pluck patterns ticking steadily in major with precision'},
+ puz05:{mood:'Bright',energy:5,tags:['discovery','eureka','bells','uplifting'],description:'Bell tones ascending through pentatonic discovery moments'},
+ puz06:{mood:'Thoughtful',energy:3,tags:['riddle','mystery','soft','dorian'],description:'Soft lead calling through dorian riddle spaces quietly'},
+ puz07:{mood:'Playful',energy:5,tags:['logic','bouncy','pluck','electronic'],description:'Plucked arpeggios bouncing through logical electronic patterns'},
+ puz08:{mood:'Mysterious',energy:3,tags:['jigsaw','atmospheric','crystal','whole-tone'],description:'Crystal bells drifting through whole-tone puzzle atmosphere'},
+ puz09:{mood:'Thoughtful',energy:4,tags:['pattern','matching','bells','cerebral'],description:'Bell ostinato patterns matching chord changes thoughtfully'},
+ puz10:{mood:'Playful',energy:4,tags:['cascade','falling','pluck','dorian'],description:'Descending pluck cascades rising again through dorian shifts'},
+ puz11:{mood:'Calm',energy:3,tags:['palace','quiet','soft','mixolydian'],description:'Soft lead wandering through quiet mixolydian mind palace'},
+ puz12:{mood:'Energetic',energy:6,tags:['groove','busy','pluck','funky'],description:'Groovy pluck patterns turning busily with electronic shuffle'},
+ puz13:{mood:'Calm',energy:4,tags:['domino','chain','crystal','dorian'],description:'Crystal bells creating ascending then descending domino chains'},
+ puz14:{mood:'Thoughtful',energy:4,tags:['maze','winding','bells','cerebral'],description:'Bell tones stepping through maze-like chord progressions'},
+ puz15:{mood:'Playful',energy:5,tags:['combo','chain','pluck','electronic'],description:'Plucked arpeggio combos chaining through mixolydian patterns'},
+ puz16:{mood:'Calm',energy:3,tags:['tile','gentle','crystal','pentatonic'],description:'Crystal pentatonic calls floating over gentle shimmer pads'},
+ puz17:{mood:'Energetic',energy:6,tags:['code','hacking','pluck','busy'],description:'Busy pluck patterns cracking codes with electronic energy'},
+ puz18:{mood:'Thoughtful',energy:3,tags:['box','contained','bells','dorian'],description:'Bells exploring contained dorian puzzle box spaces'},
+ puz19:{mood:'Calm',energy:4,tags:['reaction','flowing','soft','ascending'],description:'Soft ascending lead flowing through chain reaction chords'},
+ puz20:{mood:'Playful',energy:5,tags:['tetris','falling','pluck','electronic'],description:'Plucked ostinato patterns fitting together like falling blocks'},
+ puz21:{mood:'Calm',energy:3,tags:['lightbulb','gentle','crystal','atmospheric'],description:'Crystal bells glowing gently in atmospheric light-bulb moments'},
+ puz22:{mood:'Energetic',energy:5,tags:['key','unlock','bells','electronic'],description:'Bell calls unlocking through dorian with building electronic energy'},
+
+ // Relaxing
+ rel01:{mood:'Peaceful',energy:2,tags:['breeze','gentle','flute','ambient'],description:'Gentle flute melody floating on ambient shimmer pads'},
+ rel02:{mood:'Serene',energy:2,tags:['morning','dew','soft','ethereal'],description:'Soft lead glistening like morning dew on ethereal lydian pads'},
+ rel03:{mood:'Calm',energy:1,tags:['still','water','ethereal','ambient'],description:'Ethereal tones barely moving over still ambient water textures'},
+ rel04:{mood:'Dreamy',energy:3,tags:['cloud','floating','pluck','warm'],description:'Plucked pentatonic notes floating through warm cloud-like pads'},
+ rel05:{mood:'Warm',energy:3,tags:['sunset','golden','flute','gentle'],description:'Warm flute melodies glowing over sunset-golden pad harmonies'},
+ rel06:{mood:'Serene',energy:1,tags:['lullaby','night','crystal','ethereal'],description:'Crystal bell lullaby whispering over ethereal night pads'},
+ rel07:{mood:'Dreamy',energy:3,tags:['daydream','floating','soft','ambient'],description:'Soft lead drifting through lydian daydream with ambient shimmer'},
+ rel08:{mood:'Peaceful',energy:2,tags:['meadow','rest','flute','warm'],description:'Flute calling softly across warm meadow pad textures'},
+ rel09:{mood:'Warm',energy:3,tags:['autumn','leaves','pluck','dorian'],description:'Warm plucked dorian melody with the gentle feel of autumn'},
+ rel10:{mood:'Serene',energy:1,tags:['crystal','lake','bells','ethereal'],description:'Crystal bells reflecting on still lake-like ethereal pads'},
+ rel11:{mood:'Peaceful',energy:2,tags:['rain','soft','ethereal','ambient'],description:'Ethereal tones gently falling like soft rain on ambient pads'},
+ rel12:{mood:'Warm',energy:3,tags:['petals','dancing','pluck','gentle'],description:'Plucked pentatonic petals dancing gently over warm harmonies'},
+ rel13:{mood:'Serene',energy:1,tags:['snow','quiet','crystal','ethereal'],description:'Crystal bells falling like snowflakes on silent ethereal pads'},
+ rel14:{mood:'Warm',energy:3,tags:['hearth','cozy','soft','gentle'],description:'Soft warm lead sitting by the hearth with gentle pad glow'},
+ rel15:{mood:'Dreamy',energy:2,tags:['stars','gazing','ethereal','lydian'],description:'Ethereal tones gazing at lydian stars with quiet wonder'},
+ rel16:{mood:'Warm',energy:3,tags:['butterfly','garden','pluck','gentle'],description:'Plucked pentatonic butterflies floating through warm garden pads'},
+ rel17:{mood:'Calm',energy:1,tags:['ocean','calm','flute','ambient'],description:'Flute drifting over ocean-calm ambient shimmer pads'},
+ rel18:{mood:'Serene',energy:2,tags:['zen','meditation','crystal','ethereal'],description:'Crystal zen bells resonating in ethereal meditative space'},
+ rel19:{mood:'Warm',energy:3,tags:['candle','intimate','soft','gentle'],description:'Soft warm candlelight lead over gentle pad harmonies'},
+ rel20:{mood:'Peaceful',energy:2,tags:['feather','falling','pluck','ambient'],description:'Plucked pentatonic notes falling like feathers through ambient air'},
+ rel21:{mood:'Warm',energy:3,tags:['silk','flowing','flute','dorian'],description:'Flute melody flowing like silk through warm dorian textures'},
+ rel22:{mood:'Serene',energy:1,tags:['wind','chimes','crystal','lydian'],description:'Crystal windchime arpeggios catching ethereal lydian breezes'},
+
+ // Spooky
+ spk01:{mood:'Eerie',energy:4,tags:['haunted','manor','dark','atmospheric'],description:'Ethereal tones drifting through haunted harmonic minor manor'},
+ spk02:{mood:'Dark',energy:3,tags:['midnight','fog','phrygian','ambient'],description:'Dark reeds lost in midnight phrygian fog with spacey delay'},
+ spk03:{mood:'Creepy',energy:5,tags:['shadows','creeping','bells','dark'],description:'Bell ostinato creeping through dark shadows with wobble bass'},
+ spk04:{mood:'Eerie',energy:4,tags:['ghost','waltz','soft','harmonic-minor'],description:'Ghostly soft lead waltzing through harmonic minor with shuffling rhythm'},
+ spk05:{mood:'Dark',energy:6,tags:['witch','ritual','reeds','electronic'],description:'Busy reed patterns brewing dark phrygian electronic potions'},
+ spk06:{mood:'Mysterious',energy:2,tags:['crypt','descent','ethereal','deep'],description:'Ethereal descent into deep dark crypt with spacey ambience'},
+ spk07:{mood:'Eerie',energy:2,tags:['whispers','eerie','soft','shimmer'],description:'Soft whisper-like lead through eerie harmonic minor shimmer'},
+ spk08:{mood:'Dark',energy:7,tags:['ritual','drums','reeds','tribal'],description:'Dark ritual reeds with heavy tribal drums in phrygian mode'},
+ spk09:{mood:'Eerie',energy:3,tags:['phantom','lament','ethereal','sad'],description:'Ethereal phantom lament drifting over dark wobble bass'},
+ spk10:{mood:'Quirky',energy:5,tags:['skeleton','dance','bells','retro'],description:'Bell tones dancing a quirky skeleton jig with electronic pulse'},
+ spk11:{mood:'Dark',energy:2,tags:['foghorn','bay','soft','spacey'],description:'Soft lead moaning through dark foggy bay with spacey reverb'},
+ spk12:{mood:'Eerie',energy:4,tags:['cursed','music-box','crystal','harmonic-minor'],description:'Cursed crystal music box arpeggios in harmonic minor darkness'},
+ spk13:{mood:'Dark',energy:7,tags:['tomb','raider','reeds','heavy'],description:'Heavy tomb-raiding reeds with tribal drums in phrygian depths'},
+ spk14:{mood:'Eerie',energy:3,tags:['banshee','wail','ethereal','ascending'],description:'Ethereal ascending banshee wails with minimal dark atmosphere'},
+ spk15:{mood:'Eerie',energy:4,tags:['cobweb','corridor','bells','soft'],description:'Bells echoing through cobwebbed harmonic minor corridors'},
+ spk16:{mood:'Quirky',energy:6,tags:['poltergeist','party','square','electronic'],description:'Square lead poltergeist party with electronic phrygian shuffle'},
+ spk17:{mood:'Dark',energy:3,tags:['graveyard','shift','soft','minimal'],description:'Soft lead on graveyard shift duty with minimal dark atmosphere'},
+ spk18:{mood:'Dark',energy:7,tags:['voodoo','drums','reeds','tribal'],description:'Voodoo reed ostinato with heavy tribal phrygian drums'},
+ spk19:{mood:'Mysterious',energy:2,tags:['moonlit','mausoleum','ethereal','spacey'],description:'Ethereal moonlight filtering through spacey mausoleum reverb'},
+ spk20:{mood:'Quirky',energy:5,tags:['nightmare','circus','bells','retro'],description:'Nightmare bell arpeggios with electronic circus pulse'},
+ spk21:{mood:'Eerie',energy:4,tags:['vampire','waltz','soft','harmonic-minor'],description:'Soft vampire waltz stepping through harmonic minor with dark elegance'},
+ spk22:{mood:'Dark',energy:2,tags:['abyss','deep','ethereal','spacey'],description:'Ethereal tones sinking into the spacey abyss of minor darkness'},
+
+ // Retro
+ ret01:{mood:'Upbeat',energy:7,tags:['pixel','hero','chiptune','classic'],description:'Classic chiptune hero theme with square wave lead and 8-bit drums'},
+ ret02:{mood:'Upbeat',energy:7,tags:['coins','collecting','saw','catchy'],description:'Catchy sawtooth coin-collecting melody with electronic bounce'},
+ ret03:{mood:'Energetic',energy:7,tags:['power-up','ascending','square','bright'],description:'Ascending square wave power-up theme building to fanfare'},
+ ret04:{mood:'Intense',energy:8,tags:['boss','battle','8bit','aggressive'],description:'8-bit boss battle sawtooth fury with retro electronic aggression'},
+ ret05:{mood:'Nostalgic',energy:5,tags:['dream','chiptune','pentatonic','calm'],description:'Dreamy chiptune pentatonic melody floating with electronic warmth'},
+ ret06:{mood:'Intense',energy:8,tags:['turbo','tunnel','saw','fast'],description:'Turbo sawtooth blazing through dark 8-bit tunnel at speed'},
+ ret07:{mood:'Upbeat',energy:7,tags:['high-score','celebration','square','bright'],description:'Bright square wave high-score celebration with fanfare energy'},
+ ret08:{mood:'Quirky',energy:5,tags:['warp','blues','saw','shuffle'],description:'Sawtooth blues shuffle warping through pentatonic retro grooves'},
+ ret09:{mood:'Energetic',energy:7,tags:['star','running','square','catchy'],description:'Catchy square wave star-run with ascending energy and fills'},
+ ret10:{mood:'Dark',energy:5,tags:['dungeon','crawl','saw','minor'],description:'Minor sawtooth dungeon crawl with electronic ostinato tension'},
+ ret11:{mood:'Upbeat',energy:7,tags:['arcade','fever','square','catchy'],description:'Arcade fever square wave arpeggios with catchy electronic bounce'},
+ ret12:{mood:'Mysterious',energy:5,tags:['crystal','caves','saw','pentatonic'],description:'Sawtooth pentatonic echoes exploring crystalline retro caverns'},
+ ret13:{mood:'Energetic',energy:8,tags:['rainbow','bright','square','fast'],description:'Bright square wave rainbow road racing with ascending energy'},
+ ret14:{mood:'Intense',energy:8,tags:['blast','fast','saw','aggressive'],description:'Fast sawtooth byte blasting through minor with electronic fury'},
+ ret15:{mood:'Energetic',energy:7,tags:['space','invader','square','arcade'],description:'Square wave space invader arpeggios with retro electronic drive'},
+ ret16:{mood:'Calm',energy:4,tags:['sunset','pixel','saw','warm'],description:'Warm sawtooth pentatonic pixel sunset with gentle electronic pulse'},
+ ret17:{mood:'Energetic',energy:7,tags:['mega','drive','square','fast'],description:'Square wave mega drive fanfare with busy electronic fills'},
+ ret18:{mood:'Melancholy',energy:4,tags:['game-over','sad','saw','minor'],description:'Descending minor sawtooth game-over lament with sparse beats'},
+ ret19:{mood:'Nostalgic',energy:5,tags:['continue','hopeful','square','moderate'],description:'Moderate square wave continue screen with hopeful retro energy'},
+ ret20:{mood:'Bright',energy:6,tags:['paradise','pixel','saw','uplifting'],description:'Bright sawtooth pentatonic pixel paradise with uplifting flow'},
+ ret21:{mood:'Intense',energy:9,tags:['bonus','stage','square','fast'],description:'Maximum energy square wave bonus stage at 160bpm with fills'},
+ ret22:{mood:'Calm',energy:4,tags:['sunrise','8bit','saw','warm'],description:'Warm 8-bit sawtooth sunrise ascending gently with soft beats'},
+
+ // Space
+ spc01:{mood:'Cosmic',energy:3,tags:['nebula','drifting','ethereal','ambient'],description:'Ethereal tones drifting through lydian nebula with spacey delay'},
+ spc02:{mood:'Mysterious',energy:4,tags:['wormhole','whole-tone','crystal','swirling'],description:'Crystal bells swirling through whole-tone wormhole distortions'},
+ spc03:{mood:'Serene',energy:2,tags:['starfield','quiet','soft','ethereal'],description:'Soft lead tracing quiet lydian starfield constellations'},
+ spc04:{mood:'Cosmic',energy:4,tags:['gravity','pull','ethereal','dorian'],description:'Ethereal dorian ostinato caught in gravity well with electronic pulse'},
+ spc05:{mood:'Serene',energy:3,tags:['solar','wind','flute','ambient'],description:'Flute riding solar winds through mixolydian ambient space'},
+ spc06:{mood:'Cosmic',energy:2,tags:['deep-space','vast','crystal','spacey'],description:'Crystal bells barely moving in vast deep space silence'},
+ spc07:{mood:'Energetic',energy:5,tags:['meteor','shower','soft','electronic'],description:'Busy soft lead showering through dorian with electronic sparkle'},
+ spc08:{mood:'Peaceful',energy:2,tags:['cosmic','silence','ethereal','ambient'],description:'Ethereal pentatonic whispers in vast cosmic ambient silence'},
+ spc09:{mood:'Energetic',energy:5,tags:['pulsar','beat','crystal','electronic'],description:'Crystal bell ostinato pulsing like a pulsar with electronic drive'},
+ spc10:{mood:'Calm',energy:3,tags:['orbital','station','soft','ambient'],description:'Soft mixolydian lead orbiting through ambient electronic station'},
+ spc11:{mood:'Serene',energy:2,tags:['zero-gravity','floating','flute','ethereal'],description:'Flute floating in zero-gravity lydian ethereal space'},
+ spc12:{mood:'Energetic',energy:6,tags:['asteroid','field','crystal','electronic'],description:'Crystal bells dodging busy dorian asteroid field with electronic rock'},
+ spc13:{mood:'Cosmic',energy:2,tags:['event-horizon','deep','ethereal','dark'],description:'Ethereal descent toward event horizon with deep spacey reverb'},
+ spc14:{mood:'Energetic',energy:6,tags:['light-speed','fast','bright','electronic'],description:'Bright lead jumping to light speed through mixolydian electronic drive'},
+ spc15:{mood:'Peaceful',energy:3,tags:['saturn','rings','soft','ambient'],description:'Soft pentatonic lead tracing Saturn ring patterns gently'},
+ spc16:{mood:'Mysterious',energy:4,tags:['quantum','flux','crystal','whole-tone'],description:'Crystal ostinato flickering through quantum whole-tone fluctuations'},
+ spc17:{mood:'Calm',energy:3,tags:['moon','colony','flute','warm'],description:'Warm flute melody in dorian moon colony with electronic pulse'},
+ spc18:{mood:'Cosmic',energy:2,tags:['dust','floating','ethereal','lydian'],description:'Ethereal cosmic dust floating through lydian shimmer silence'},
+ spc19:{mood:'Intense',energy:7,tags:['supernova','explosion','bright','electronic'],description:'Bright supernova fanfare exploding through mixolydian electronic power'},
+ spc20:{mood:'Peaceful',energy:3,tags:['voyager','journey','soft','ambient'],description:'Soft pentatonic voyager melody drifting through ambient space'},
+ spc21:{mood:'Dark',energy:2,tags:['black-hole','deep','ethereal','dark'],description:'Ethereal dorian descent into dark black hole with deep reverb'},
+ spc22:{mood:'Mysterious',energy:4,tags:['first-contact','alien','crystal','ethereal'],description:'Crystal arpeggios making first contact through lydian ethereal shimmer'},
+
+ // Fantasy
+ fan01:{mood:'Enchanted',energy:5,tags:['forest','enchanted','flute','medieval'],description:'Enchanted flute wandering through dorian forest with warm pads'},
+ fan02:{mood:'Epic',energy:8,tags:['dragon','lair','brass','intense'],description:'Epic brass descent into dragon lair with heavy march drums'},
+ fan03:{mood:'Mystical',energy:3,tags:['lake','mystic','bells','atmospheric'],description:'Mystical bell tones reflecting on mixolydian lake surface'},
+ fan04:{mood:'Grand',energy:7,tags:['throne','royal','brass','orchestral'],description:'Grand brass and organ throne room march with royal authority'},
+ fan05:{mood:'Enchanted',energy:3,tags:['fairy','magical','crystal','lydian'],description:'Crystal fairy bells twinkling through enchanted lydian glen'},
+ fan06:{mood:'Epic',energy:9,tags:['battle','standard','brass','heavy'],description:'Epic battle standard brass with heavy drums and powerful climax'},
+ fan07:{mood:'Mystical',energy:3,tags:['library','ancient','pluck','atmospheric'],description:'Plucked melodies browsing ancient dorian library shelves quietly'},
+ fan08:{mood:'Epic',energy:7,tags:['phoenix','rising','reeds','dramatic'],description:'Reed melody rising like a phoenix through harmonic minor drama'},
+ fan09:{mood:'Enchanted',energy:3,tags:['elven','melody','flute','ethereal'],description:'Ethereal elven flute singing through lydian enchanted harmony'},
+ fan10:{mood:'Intense',energy:9,tags:['siege','warfare','brass','heavy'],description:'Intense brass siege warfare with heavy tribal drums and power'},
+ fan11:{mood:'Adventurous',energy:5,tags:['wanderer','journey','reeds','folk'],description:'Reed wanderer song with folk shuffle through dorian landscapes'},
+ fan12:{mood:'Mystical',energy:3,tags:['crystal','caverns','bells','ethereal'],description:'Crystal bell arpeggios echoing through ethereal lydian caverns'},
+ fan13:{mood:'Intense',energy:8,tags:['war','drums','brass','tribal'],description:'War drum brass marching through minor with tribal intensity'},
+ fan14:{mood:'Warm',energy:5,tags:['tavern','night','pluck','folk'],description:'Warm plucked tavern melody with folk shuffle and mixolydian cheer'},
+ fan15:{mood:'Mystical',energy:4,tags:['sorcerer','tower','bells','dark'],description:'Dark bell tones spiraling up sorcerer tower in harmonic minor'},
+ fan16:{mood:'Grand',energy:7,tags:['champion','march','brass','heroic'],description:'Grand champion brass march through major with heroic organ support'},
+ fan17:{mood:'Dark',energy:4,tags:['shadow','keep','reeds','atmospheric'],description:'Dark reed atmosphere haunting shadow keep with wobble bass depth'},
+ fan18:{mood:'Warm',energy:5,tags:['harvest','festival','pluck','folk'],description:'Warm plucked harvest festival melody with cheerful folk shuffle'},
+ fan19:{mood:'Adventurous',energy:5,tags:['quest','begins','flute','building'],description:'Flute quest beginning ascending through dorian with building energy'},
+ fan20:{mood:'Epic',energy:9,tags:['dragon','flight','brass','intense'],description:'Epic brass dragon flight with heavy drums and maximum harmonic minor intensity'},
+ fan21:{mood:'Enchanted',energy:2,tags:['moonlit','path','soft','ethereal'],description:'Soft ethereal moonlit path through quiet lydian enchantment'},
+ fan22:{mood:'Epic',energy:9,tags:['finale','climax','brass','powerful'],description:'Epic brass and organ finale with heavy drums and powerful climax'},
+};
diff --git a/frontend/src/music/styleCategories.js b/frontend/src/music/styleCategories.js
new file mode 100644
index 0000000..ee1d012
--- /dev/null
+++ b/frontend/src/music/styleCategories.js
@@ -0,0 +1,345 @@
+// styleCategories.js — Scales, synth presets, drum kits, effects, and style definitions
+
+// ─── Music Theory ────────────────────────────────────────────
+
+export const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
+
+export const SCALES = {
+ major: [0, 2, 4, 5, 7, 9, 11],
+ minor: [0, 2, 3, 5, 7, 8, 10],
+ dorian: [0, 2, 3, 5, 7, 9, 10],
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
+ lydian: [0, 2, 4, 6, 7, 9, 11],
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
+ pentatonic: [0, 2, 4, 7, 9],
+ minorPentatonic:[0, 3, 5, 7, 10],
+ harmonicMinor: [0, 2, 3, 5, 7, 8, 11],
+ wholeTone: [0, 2, 4, 6, 8, 10],
+ blues: [0, 3, 5, 6, 7, 10],
+ chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+};
+
+export const ROMAN_TO_DEGREE = {
+ 'I': 0, 'II': 1, 'III': 2, 'IV': 3, 'V': 4, 'VI': 5, 'VII': 6,
+ 'i': 0, 'ii': 1, 'iii': 2, 'iv': 3, 'v': 4, 'vi': 5, 'vii': 6,
+ 'bII': -1, 'bIII': -2, 'bVI': -5, 'bVII': -6,
+};
+
+// ─── Synth Presets ───────────────────────────────────────────
+
+export const SYNTH_PRESETS = {
+ // Melody synths
+ brass: {
+ type: 'FMSynth',
+ options: {
+ harmonicity: 3.01, modulationIndex: 14,
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.05, decay: 0.3, sustain: 0.6, release: 0.8 },
+ modulation: { type: 'square' },
+ modulationEnvelope: { attack: 0.5, decay: 0, sustain: 1, release: 0.5 },
+ }
+ },
+ brightLead: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 0.4 },
+ }
+ },
+ softLead: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.1, decay: 0.3, sustain: 0.7, release: 1.0 },
+ }
+ },
+ squareLead: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.01, decay: 0.1, sustain: 0.4, release: 0.3 },
+ }
+ },
+ bellTone: {
+ type: 'FMSynth',
+ options: {
+ harmonicity: 8, modulationIndex: 2,
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.001, decay: 1.4, sustain: 0, release: 0.6 },
+ modulation: { type: 'square' },
+ modulationEnvelope: { attack: 0.01, decay: 0.5, sustain: 0, release: 0.5 },
+ }
+ },
+ flute: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.15, decay: 0.1, sustain: 0.8, release: 0.5 },
+ }
+ },
+ pluck: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.001, decay: 0.5, sustain: 0, release: 0.3 },
+ }
+ },
+ reeds: {
+ type: 'AMSynth',
+ options: {
+ harmonicity: 2,
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.08, decay: 0.3, sustain: 0.6, release: 0.5 },
+ modulation: { type: 'sine' },
+ modulationEnvelope: { attack: 0.3, decay: 0, sustain: 1, release: 0.5 },
+ }
+ },
+ ethereal: {
+ type: 'FMSynth',
+ options: {
+ harmonicity: 5, modulationIndex: 1,
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.5, decay: 0.8, sustain: 0.3, release: 2.0 },
+ modulation: { type: 'sine' },
+ modulationEnvelope: { attack: 0.5, decay: 0.5, sustain: 0.5, release: 1.0 },
+ }
+ },
+ chipSquare: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.001, decay: 0.05, sustain: 0.3, release: 0.1 },
+ }
+ },
+ chipSaw: {
+ type: 'Synth',
+ options: {
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.001, decay: 0.08, sustain: 0.3, release: 0.1 },
+ }
+ },
+ crystalBell: {
+ type: 'FMSynth',
+ options: {
+ harmonicity: 12, modulationIndex: 1.5,
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.001, decay: 2.0, sustain: 0, release: 1.0 },
+ modulation: { type: 'sine' },
+ modulationEnvelope: { attack: 0.01, decay: 1.0, sustain: 0, release: 0.5 },
+ }
+ },
+
+ // Pad / Harmony (PolySynth wrappers)
+ warmPad: {
+ type: 'PolySynth',
+ voiceType: 'Synth',
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.4, decay: 0.5, sustain: 0.8, release: 1.5 },
+ }
+ },
+ darkPad: {
+ type: 'PolySynth',
+ voiceType: 'Synth',
+ options: {
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.6, decay: 0.8, sustain: 0.5, release: 2.0 },
+ }
+ },
+ shimmerPad: {
+ type: 'PolySynth',
+ voiceType: 'FMSynth',
+ options: {
+ harmonicity: 3, modulationIndex: 1,
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.8, decay: 1.0, sustain: 0.6, release: 2.5 },
+ modulation: { type: 'sine' },
+ modulationEnvelope: { attack: 0.5, decay: 0.5, sustain: 0.5, release: 1.0 },
+ }
+ },
+ organPad: {
+ type: 'PolySynth',
+ voiceType: 'Synth',
+ options: {
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.05, decay: 0.1, sustain: 0.9, release: 0.3 },
+ }
+ },
+
+ // Bass (MonoSynth)
+ deepBass: {
+ type: 'MonoSynth',
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.02, decay: 0.3, sustain: 0.6, release: 0.4 },
+ filterEnvelope: { attack: 0.06, decay: 0.2, sustain: 0.5, release: 0.4, baseFrequency: 200, octaves: 2 },
+ }
+ },
+ pulseBass: {
+ type: 'MonoSynth',
+ options: {
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.01, decay: 0.1, sustain: 0.4, release: 0.2 },
+ filterEnvelope: { attack: 0.02, decay: 0.1, sustain: 0.3, release: 0.2, baseFrequency: 300, octaves: 2 },
+ }
+ },
+ wobbleBass: {
+ type: 'MonoSynth',
+ options: {
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 0.3 },
+ filterEnvelope: { attack: 0.01, decay: 0.4, sustain: 0.2, release: 0.3, baseFrequency: 150, octaves: 4 },
+ }
+ },
+ roundBass: {
+ type: 'MonoSynth',
+ options: {
+ oscillator: { type: 'sine' },
+ envelope: { attack: 0.05, decay: 0.4, sustain: 0.7, release: 0.5 },
+ filterEnvelope: { attack: 0.1, decay: 0.3, sustain: 0.6, release: 0.5, baseFrequency: 250, octaves: 1.5 },
+ }
+ },
+ synthBass: {
+ type: 'MonoSynth',
+ options: {
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.01, decay: 0.15, sustain: 0.5, release: 0.3 },
+ filterEnvelope: { attack: 0.02, decay: 0.15, sustain: 0.4, release: 0.3, baseFrequency: 200, octaves: 3 },
+ }
+ },
+};
+
+// ─── Drum Kit Configs ────────────────────────────────────────
+
+export const DRUM_KITS = {
+ standard: {
+ kick: { pitchDecay: 0.05, octaves: 6, oscillator: { type: 'sine' }, envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.3 } },
+ snare: { noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.15, sustain: 0 } },
+ hat: { frequency: 400, envelope: { attack: 0.001, decay: 0.08, release: 0.01 }, harmonicity: 5.1, modulationIndex: 32, resonance: 4000, octaves: 1.5 },
+ },
+ electronic: {
+ kick: { pitchDecay: 0.08, octaves: 8, oscillator: { type: 'sine' }, envelope: { attack: 0.001, decay: 0.4, sustain: 0, release: 0.4 } },
+ snare: { noise: { type: 'pink' }, envelope: { attack: 0.001, decay: 0.2, sustain: 0 } },
+ hat: { frequency: 600, envelope: { attack: 0.001, decay: 0.05, release: 0.01 }, harmonicity: 6, modulationIndex: 40, resonance: 5000, octaves: 1 },
+ },
+ soft: {
+ kick: { pitchDecay: 0.03, octaves: 4, oscillator: { type: 'sine' }, envelope: { attack: 0.005, decay: 0.2, sustain: 0, release: 0.2 } },
+ snare: { noise: { type: 'brown' }, envelope: { attack: 0.005, decay: 0.1, sustain: 0 } },
+ hat: { frequency: 350, envelope: { attack: 0.002, decay: 0.06, release: 0.02 }, harmonicity: 4, modulationIndex: 20, resonance: 3000, octaves: 1 },
+ },
+ heavy: {
+ kick: { pitchDecay: 0.1, octaves: 10, oscillator: { type: 'sine' }, envelope: { attack: 0.001, decay: 0.5, sustain: 0, release: 0.5 } },
+ snare: { noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.25, sustain: 0 } },
+ hat: { frequency: 500, envelope: { attack: 0.001, decay: 0.1, release: 0.01 }, harmonicity: 5.5, modulationIndex: 36, resonance: 4500, octaves: 2 },
+ },
+ retro8bit: {
+ kick: { pitchDecay: 0.15, octaves: 6, oscillator: { type: 'square' }, envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.15 } },
+ snare: { noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.08, sustain: 0 } },
+ hat: { frequency: 800, envelope: { attack: 0.001, decay: 0.03, release: 0.01 }, harmonicity: 8, modulationIndex: 50, resonance: 6000, octaves: 0.5 },
+ },
+ minimal: {
+ kick: { pitchDecay: 0.04, octaves: 5, oscillator: { type: 'sine' }, envelope: { attack: 0.002, decay: 0.25, sustain: 0, release: 0.25 } },
+ snare: { noise: { type: 'pink' }, envelope: { attack: 0.002, decay: 0.12, sustain: 0 } },
+ hat: { frequency: 380, envelope: { attack: 0.002, decay: 0.05, release: 0.02 }, harmonicity: 4.5, modulationIndex: 25, resonance: 3500, octaves: 1 },
+ },
+};
+
+// ─── Effects Presets ─────────────────────────────────────────
+
+export const FX_PRESETS = {
+ hall: { reverb: { decay: 4.0, wet: 0.35 }, delay: { delayTime: '8n', feedback: 0.15, wet: 0.1 }, filter: null, chorus: null },
+ chamber: { reverb: { decay: 2.0, wet: 0.25 }, delay: { delayTime: '16n', feedback: 0.1, wet: 0.08 }, filter: null, chorus: null },
+ ambient: { reverb: { decay: 6.0, wet: 0.5 }, delay: { delayTime: '4n', feedback: 0.3, wet: 0.2 }, filter: { frequency: 3000, type: 'lowpass' }, chorus: null },
+ dark: { reverb: { decay: 3.5, wet: 0.3 }, delay: { delayTime: '8n.', feedback: 0.2, wet: 0.15 }, filter: { frequency: 1500, type: 'lowpass' }, chorus: null },
+ bright: { reverb: { decay: 1.5, wet: 0.2 }, delay: { delayTime: '16n', feedback: 0.08, wet: 0.05 }, filter: { frequency: 6000, type: 'highpass' }, chorus: null },
+ retro: { reverb: { decay: 1.0, wet: 0.15 }, delay: { delayTime: '8n', feedback: 0.2, wet: 0.15 }, filter: { frequency: 4000, type: 'lowpass' }, chorus: { frequency: 1.5, delayTime: 3.5, depth: 0.7 } },
+ space: { reverb: { decay: 8.0, wet: 0.6 }, delay: { delayTime: '4n.', feedback: 0.4, wet: 0.3 }, filter: { frequency: 2500, type: 'lowpass' }, chorus: { frequency: 0.5, delayTime: 5, depth: 0.5 } },
+ crisp: { reverb: { decay: 1.0, wet: 0.1 }, delay: null, filter: null, chorus: null },
+ warm: { reverb: { decay: 2.5, wet: 0.25 }, delay: { delayTime: '8n', feedback: 0.1, wet: 0.08 }, filter: { frequency: 2000, type: 'lowpass' }, chorus: { frequency: 1.0, delayTime: 4, depth: 0.4 } },
+ ethereal:{ reverb: { decay: 7.0, wet: 0.55 }, delay: { delayTime: '4n', feedback: 0.35, wet: 0.25 }, filter: { frequency: 3500, type: 'lowpass' }, chorus: { frequency: 0.3, delayTime: 6, depth: 0.6 } },
+};
+
+// ─── Style Definitions ───────────────────────────────────────
+
+export const STYLES = {
+ adventure: {
+ name: 'Adventure',
+ tempoRange: [100, 140],
+ scales: ['major', 'mixolydian', 'lydian', 'pentatonic'],
+ melodyPresets: ['brass', 'brightLead', 'flute', 'reeds'],
+ padPresets: ['warmPad', 'organPad'],
+ bassPresets: ['deepBass', 'roundBass'],
+ drumKits: ['standard', 'heavy'],
+ fxPresets: ['hall', 'chamber', 'bright'],
+ },
+ action: {
+ name: 'Action',
+ tempoRange: [120, 170],
+ scales: ['minor', 'phrygian', 'harmonicMinor', 'minorPentatonic'],
+ melodyPresets: ['brightLead', 'squareLead', 'brass', 'reeds'],
+ padPresets: ['darkPad', 'warmPad'],
+ bassPresets: ['wobbleBass', 'synthBass', 'pulseBass'],
+ drumKits: ['heavy', 'electronic', 'standard'],
+ fxPresets: ['dark', 'crisp', 'hall'],
+ },
+ puzzle: {
+ name: 'Puzzle',
+ tempoRange: [90, 130],
+ scales: ['major', 'dorian', 'mixolydian', 'pentatonic', 'wholeTone'],
+ melodyPresets: ['bellTone', 'pluck', 'softLead', 'crystalBell'],
+ padPresets: ['shimmerPad', 'warmPad'],
+ bassPresets: ['roundBass', 'pulseBass'],
+ drumKits: ['minimal', 'soft', 'electronic'],
+ fxPresets: ['chamber', 'warm', 'retro'],
+ },
+ relaxing: {
+ name: 'Relaxing',
+ tempoRange: [60, 100],
+ scales: ['major', 'lydian', 'pentatonic', 'dorian'],
+ melodyPresets: ['flute', 'softLead', 'ethereal', 'pluck', 'crystalBell'],
+ padPresets: ['shimmerPad', 'warmPad'],
+ bassPresets: ['roundBass', 'deepBass'],
+ drumKits: ['soft', 'minimal'],
+ fxPresets: ['ambient', 'warm', 'ethereal'],
+ },
+ spooky: {
+ name: 'Spooky',
+ tempoRange: [60, 110],
+ scales: ['minor', 'phrygian', 'harmonicMinor', 'chromatic', 'wholeTone'],
+ melodyPresets: ['ethereal', 'reeds', 'bellTone', 'softLead'],
+ padPresets: ['darkPad', 'shimmerPad'],
+ bassPresets: ['wobbleBass', 'deepBass'],
+ drumKits: ['minimal', 'soft', 'heavy'],
+ fxPresets: ['dark', 'space', 'ethereal'],
+ },
+ retro: {
+ name: 'Retro',
+ tempoRange: [110, 160],
+ scales: ['major', 'minor', 'pentatonic', 'minorPentatonic', 'blues'],
+ melodyPresets: ['chipSquare', 'chipSaw', 'squareLead', 'pluck'],
+ padPresets: ['organPad', 'warmPad'],
+ bassPresets: ['pulseBass', 'synthBass'],
+ drumKits: ['retro8bit', 'electronic'],
+ fxPresets: ['retro', 'crisp', 'bright'],
+ },
+ space: {
+ name: 'Space',
+ tempoRange: [70, 120],
+ scales: ['lydian', 'dorian', 'mixolydian', 'wholeTone', 'pentatonic'],
+ melodyPresets: ['ethereal', 'crystalBell', 'softLead', 'flute'],
+ padPresets: ['shimmerPad', 'darkPad'],
+ bassPresets: ['deepBass', 'roundBass', 'wobbleBass'],
+ drumKits: ['electronic', 'minimal'],
+ fxPresets: ['space', 'ethereal', 'ambient'],
+ },
+ fantasy: {
+ name: 'Fantasy',
+ tempoRange: [80, 130],
+ scales: ['dorian', 'mixolydian', 'minor', 'harmonicMinor', 'lydian'],
+ melodyPresets: ['flute', 'reeds', 'bellTone', 'brass', 'pluck'],
+ padPresets: ['warmPad', 'shimmerPad', 'organPad'],
+ bassPresets: ['deepBass', 'roundBass'],
+ drumKits: ['standard', 'soft', 'minimal'],
+ fxPresets: ['hall', 'warm', 'chamber', 'ethereal'],
+ },
+};
diff --git a/frontend/src/pages/Achievements.css b/frontend/src/pages/Achievements.css
new file mode 100644
index 0000000..e869149
--- /dev/null
+++ b/frontend/src/pages/Achievements.css
@@ -0,0 +1,284 @@
+.achievements-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.achievements-header {
+ text-align: center;
+ margin-bottom: 2rem;
+ background: white;
+ border-radius: 1rem;
+ padding: 2rem;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+}
+
+.achievements-header h1 {
+ margin: 0;
+ font-size: 2rem;
+ color: #1e293b;
+}
+
+.achievements-subtitle {
+ color: #64748b;
+ margin: 0.5rem 0 1.5rem;
+}
+
+.achievements-stats {
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+ margin-bottom: 1.5rem;
+}
+
+.stat-box {
+ text-align: center;
+}
+
+.stat-box .stat-value {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.stat-box .stat-label {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.stat-box.highlight .stat-value {
+ color: #6366f1;
+}
+
+.progress-bar-container {
+ background: #e2e8f0;
+ border-radius: 1rem;
+ height: 12px;
+ overflow: hidden;
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
+ border-radius: 1rem;
+ transition: width 0.5s ease;
+}
+
+/* Category Filters */
+.category-filters {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+}
+
+.filter-btn {
+ background: white;
+ border: 2px solid #e2e8f0;
+ padding: 0.5rem 1rem;
+ border-radius: 2rem;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.filter-btn:hover {
+ border-color: #6366f1;
+}
+
+.filter-btn.active {
+ background: #6366f1;
+ color: white;
+ border-color: #6366f1;
+}
+
+/* Achievements Grid */
+.achievements-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+}
+
+.achievement-card {
+ display: flex;
+ gap: 1rem;
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+ transition: all 0.2s;
+}
+
+.achievement-card.earned {
+ border-left: 4px solid #10b981;
+}
+
+.achievement-card.locked {
+ opacity: 0.6;
+ filter: grayscale(30%);
+}
+
+.achievement-card.locked:hover {
+ opacity: 0.8;
+}
+
+.achievement-icon-wrap {
+ position: relative;
+ flex-shrink: 0;
+ width: 60px;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f8fafc;
+ border-radius: 50%;
+}
+
+.achievement-card.earned .achievement-icon-wrap {
+ background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
+}
+
+.achievement-card.locked .achievement-icon-wrap {
+ background: #f1f5f9;
+}
+
+.achievement-icon {
+ font-size: 1.75rem;
+}
+
+.earned-check {
+ position: absolute;
+ bottom: -2px;
+ right: -2px;
+ background: #10b981;
+ color: white;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ font-size: 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid white;
+}
+
+.achievement-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.achievement-name {
+ margin: 0 0 0.25rem;
+ font-size: 1rem;
+ color: #1e293b;
+}
+
+.achievement-description {
+ margin: 0 0 0.5rem;
+ font-size: 0.85rem;
+ color: #64748b;
+ line-height: 1.4;
+}
+
+.achievement-meta {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.xp-reward {
+ background: #f0f9ff;
+ color: #0369a1;
+ padding: 0.2rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.earned-date {
+ font-size: 0.75rem;
+ color: #94a3b8;
+}
+
+/* Progress for unearned achievements */
+.achievement-progress {
+ margin-top: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.mini-progress-bar {
+ flex: 1;
+ height: 6px;
+ background: #e2e8f0;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.mini-progress-fill {
+ height: 100%;
+ background: #6366f1;
+ border-radius: 3px;
+ transition: width 0.3s;
+}
+
+.progress-text {
+ font-size: 0.75rem;
+ color: #64748b;
+ white-space: nowrap;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem;
+ color: #64748b;
+}
+
+.loading {
+ text-align: center;
+ padding: 4rem;
+ color: #64748b;
+}
+
+@media (max-width: 640px) {
+ .achievements-page {
+ padding: 1rem;
+ }
+
+ .achievements-header {
+ padding: 1.5rem;
+ }
+
+ .achievements-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .achievements-stats {
+ gap: 1rem;
+ }
+
+ .stat-box .stat-value {
+ font-size: 1.25rem;
+ }
+
+ .category-filters {
+ justify-content: flex-start;
+ overflow-x: auto;
+ padding-bottom: 0.5rem;
+ }
+
+ .filter-btn {
+ white-space: nowrap;
+ }
+
+ .achievements-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/pages/Achievements.jsx b/frontend/src/pages/Achievements.jsx
new file mode 100644
index 0000000..54faaf7
--- /dev/null
+++ b/frontend/src/pages/Achievements.jsx
@@ -0,0 +1,143 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './Achievements.css';
+
+export default function Achievements() {
+ const navigate = useNavigate();
+ const { user, isAuthenticated } = useAuth();
+ const [achievements, setAchievements] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('all');
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ navigate('/login');
+ return;
+ }
+ loadAchievements();
+ }, [isAuthenticated]);
+
+ const loadAchievements = async () => {
+ try {
+ const data = await api.getAllAchievements();
+ setAchievements(data.achievements || []);
+ } catch (error) {
+ console.error('Failed to load achievements:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const categories = [
+ { id: 'all', label: 'All' },
+ { id: 'creation', label: 'Creation' },
+ { id: 'play', label: 'Playing' },
+ { id: 'social', label: 'Social' },
+ { id: 'special', label: 'Special' },
+ { id: 'onboarding', label: 'Getting Started' }
+ ];
+
+ const filteredAchievements = filter === 'all'
+ ? achievements
+ : achievements.filter(a => a.category === filter);
+
+ const earnedCount = achievements.filter(a => a.earnedAt).length;
+ const totalCount = achievements.length;
+ const totalXP = achievements.filter(a => a.earnedAt).reduce((sum, a) => sum + (a.xpReward || 0), 0);
+
+ if (loading) {
+ return (
+
+
Loading achievements...
+
+ );
+ }
+
+ return (
+
+
+
Achievements
+
Complete challenges and earn XP!
+
+
+
+
{earnedCount}/{totalCount}
+
Unlocked
+
+
+
{Math.round((earnedCount / totalCount) * 100)}%
+
Complete
+
+
+
+{totalXP}
+
XP Earned
+
+
+
+
+
+
+
+ {categories.map(cat => (
+ setFilter(cat.id)}
+ >
+ {cat.label}
+
+ ))}
+
+
+
+ {filteredAchievements.map(achievement => (
+
+
+ {achievement.icon}
+ {achievement.earnedAt && ✓ }
+
+
+
{achievement.name}
+
{achievement.description}
+
+ +{achievement.xpReward} XP
+ {achievement.earnedAt && (
+
+ Earned {new Date(achievement.earnedAt).toLocaleDateString()}
+
+ )}
+
+ {achievement.progress !== undefined && !achievement.earnedAt && (
+
+
+
{achievement.progress}/{achievement.threshold}
+
+ )}
+
+
+ ))}
+
+
+ {filteredAchievements.length === 0 && (
+
+
No achievements in this category yet!
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Admin.css b/frontend/src/pages/Admin.css
new file mode 100644
index 0000000..2a4e986
--- /dev/null
+++ b/frontend/src/pages/Admin.css
@@ -0,0 +1,1199 @@
+.admin-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+.admin-header {
+ margin-bottom: 2rem;
+}
+
+.admin-header h1 {
+ margin: 0 0 1.5rem;
+ color: #1e293b;
+}
+
+.admin-stats {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.stat-pill {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem 1.5rem;
+ border-radius: 1rem;
+ min-width: 100px;
+}
+
+.stat-pill.pending {
+ background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
+ color: white;
+}
+
+.stat-pill.reports {
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+ color: white;
+}
+
+.stat-pill.users {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+}
+
+.stat-pill.games {
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ color: white;
+}
+
+.stat-pill.plays {
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
+ color: white;
+}
+
+.stat-pill.spending {
+ background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
+ color: white;
+}
+
+.stat-pill.generations {
+ background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
+ color: white;
+}
+
+.stat-count {
+ font-size: 1.75rem;
+ font-weight: 700;
+}
+
+.stat-pill .stat-label {
+ font-size: 0.75rem;
+ opacity: 0.9;
+}
+
+/* Tabs */
+.admin-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background: white;
+ padding: 0.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.admin-tabs .tab {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: none;
+ background: transparent;
+ border-radius: 0.75rem;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.admin-tabs .tab:hover {
+ background: #f1f5f9;
+}
+
+.admin-tabs .tab.active {
+ background: #6366f1;
+ color: white;
+}
+
+/* Section */
+.admin-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem;
+ color: #64748b;
+}
+
+.empty-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+/* Queue List */
+.queue-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.queue-item {
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ border: 1px solid #e2e8f0;
+}
+
+.queue-item-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.75rem;
+}
+
+.queue-item-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ color: #1e293b;
+}
+
+.queue-item .creator {
+ color: #6366f1;
+ font-size: 0.9rem;
+}
+
+.queue-item .date {
+ color: #94a3b8;
+ font-size: 0.85rem;
+ margin-left: auto;
+}
+
+.queue-item .description {
+ color: #475569;
+ font-size: 0.9rem;
+ margin: 0 0 1rem;
+}
+
+.queue-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* Preview Section */
+.game-preview-section {
+ margin-top: 1.5rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid #e2e8f0;
+ display: grid;
+ gap: 1.5rem;
+}
+
+@media (min-width: 768px) {
+ .game-preview-section {
+ grid-template-columns: 1fr 300px;
+ }
+}
+
+.preview-container {
+ background: #1e293b;
+ border-radius: 0.75rem;
+ overflow: hidden;
+ aspect-ratio: 16/9;
+}
+
+.preview-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+.reject-form {
+ background: #fff;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ border: 1px solid #fecaca;
+}
+
+.reject-form h4 {
+ margin: 0 0 1rem;
+ color: #dc2626;
+}
+
+.reject-form textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-family: inherit;
+ resize: vertical;
+ margin-bottom: 1rem;
+}
+
+.reject-form textarea:focus {
+ outline: none;
+ border-color: #dc2626;
+}
+
+.reject-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+/* Reports List */
+.reports-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.report-item {
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ border: 1px solid #e2e8f0;
+}
+
+.report-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+}
+
+.report-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.report-info h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ color: #1e293b;
+}
+
+.reason-badge {
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.reason-inappropriate,
+.reason-offensive {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.reason-wont_load,
+.reason-broken {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.reason-copyright {
+ background: #ede9fe;
+ color: #6d28d9;
+}
+
+.reason-other {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.report-meta {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.85rem;
+ color: #64748b;
+ flex-wrap: wrap;
+}
+
+.report-description {
+ color: #475569;
+ font-size: 0.9rem;
+ margin: 0 0 1rem;
+ padding: 0.75rem;
+ background: white;
+ border-radius: 0.5rem;
+ border: 1px solid #e2e8f0;
+}
+
+/* Resolve Form */
+.resolve-form {
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ border: 1px solid #e2e8f0;
+}
+
+.resolve-options {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+}
+
+.resolve-options label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-size: 0.9rem;
+ color: #475569;
+}
+
+.resolve-options input[type="radio"] {
+ accent-color: #6366f1;
+}
+
+.resolve-notes {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-family: inherit;
+ margin-bottom: 1rem;
+}
+
+.resolve-notes:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.resolve-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+/* Buttons */
+.btn-preview {
+ background: #f1f5f9;
+ color: #475569;
+}
+
+.btn-preview:hover {
+ background: #e2e8f0;
+}
+
+.btn-success {
+ background: #10b981;
+ color: white;
+}
+
+.btn-success:hover {
+ background: #059669;
+}
+
+.btn-danger {
+ background: #dc2626;
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #b91c1c;
+}
+
+.btn-sm {
+ padding: 0.5rem 1rem;
+ font-size: 0.85rem;
+}
+
+/* Utilities */
+.loading {
+ text-align: center;
+ padding: 4rem;
+ color: #64748b;
+}
+
+.access-denied {
+ text-align: center;
+ padding: 4rem;
+ color: #dc2626;
+ font-size: 1.25rem;
+}
+
+/* Responsive */
+@media (max-width: 640px) {
+ .admin-page {
+ padding: 1rem;
+ }
+
+ .admin-stats {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .stat-pill {
+ min-width: auto;
+ }
+
+ .queue-item-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .queue-item .date {
+ margin-left: 0;
+ }
+
+ .report-header {
+ flex-direction: column;
+ }
+
+ .resolve-options {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+}
+
+/* Bug Reports */
+.stat-pill.bugs {
+ background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
+ color: white;
+}
+
+.bug-filters {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.filter-btn {
+ padding: 0.5rem 1rem;
+ border: 1px solid #e2e8f0;
+ background: white;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.filter-btn:hover {
+ border-color: #6366f1;
+ color: #6366f1;
+}
+
+.filter-btn.active {
+ background: #6366f1;
+ border-color: #6366f1;
+ color: white;
+}
+
+.bugs-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.bug-item {
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ border: 1px solid #e2e8f0;
+ border-left: 4px solid #94a3b8;
+}
+
+.bug-item.priority-critical {
+ border-left-color: #dc2626;
+}
+
+.bug-item.priority-high {
+ border-left-color: #f97316;
+}
+
+.bug-item.priority-normal {
+ border-left-color: #3b82f6;
+}
+
+.bug-item.priority-low {
+ border-left-color: #94a3b8;
+}
+
+.bug-item.status-resolved,
+.bug-item.status-closed {
+ opacity: 0.7;
+}
+
+.bug-header {
+ margin-bottom: 0.75rem;
+}
+
+.bug-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.5rem;
+}
+
+.bug-title-row h3 {
+ margin: 0;
+ font-size: 1rem;
+ color: #1e293b;
+}
+
+.priority-badge,
+.status-badge {
+ padding: 0.2rem 0.6rem;
+ border-radius: 1rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.priority-badge.priority-critical {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.priority-badge.priority-high {
+ background: #ffedd5;
+ color: #c2410c;
+}
+
+.priority-badge.priority-normal {
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+.priority-badge.priority-low {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.status-badge.status-open {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.status-badge.status-in_progress {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.status-badge.status-resolved {
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+.status-badge.status-closed {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.status-badge.status-wont_fix {
+ background: #fecaca;
+ color: #991b1b;
+}
+
+.bug-meta {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.8rem;
+ color: #64748b;
+ flex-wrap: wrap;
+}
+
+.bug-email {
+ color: #94a3b8;
+}
+
+.bug-page {
+ color: #6366f1;
+}
+
+.bug-description {
+ color: #475569;
+ font-size: 0.9rem;
+ margin: 0 0 0.75rem;
+ line-height: 1.5;
+}
+
+.bug-notes {
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ color: #92400e;
+ margin-bottom: 0.75rem;
+}
+
+.bug-resolved {
+ font-size: 0.8rem;
+ color: #64748b;
+ margin-bottom: 0.75rem;
+}
+
+.bug-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.bug-edit-form {
+ background: white;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-top: 0.75rem;
+}
+
+.bug-edit-form .edit-row {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-bottom: 0.75rem;
+}
+
+.bug-edit-form label {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.bug-edit-form select {
+ padding: 0.5rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.375rem;
+ font-size: 0.9rem;
+}
+
+.bug-edit-form textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-family: inherit;
+ font-size: 0.9rem;
+ resize: vertical;
+ margin-bottom: 0.75rem;
+}
+
+.bug-edit-form textarea:focus,
+.bug-edit-form select:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.edit-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+/* Game Diagnostics */
+.diag-search-form {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.diag-search-input {
+ flex: 1;
+ min-width: 200px;
+ padding: 0.75rem 1rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 0.95rem;
+ font-family: inherit;
+}
+
+.diag-search-input:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.diag-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.diag-card {
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ border: 1px solid #e2e8f0;
+ border-left: 4px solid #94a3b8;
+}
+
+.diag-card.status-border-published {
+ border-left-color: #10b981;
+}
+
+.diag-card.status-border-testing {
+ border-left-color: #3b82f6;
+}
+
+.diag-card.status-border-draft {
+ border-left-color: #f59e0b;
+}
+
+.diag-card.status-border-generating {
+ border-left-color: #8b5cf6;
+}
+
+.diag-card.status-border-rejected {
+ border-left-color: #dc2626;
+}
+
+.diag-card.status-border-refining {
+ border-left-color: #06b6d4;
+}
+
+.diag-header {
+ margin-bottom: 0.75rem;
+}
+
+.diag-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.5rem;
+}
+
+.diag-title-row h3 {
+ margin: 0;
+ font-size: 1rem;
+ color: #1e293b;
+}
+
+.diag-style {
+ padding: 0.15rem 0.5rem;
+ background: #ede9fe;
+ color: #6d28d9;
+ border-radius: 0.75rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+}
+
+.diag-meta {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.8rem;
+ color: #64748b;
+ flex-wrap: wrap;
+}
+
+.diag-error-box {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ margin-bottom: 0.75rem;
+}
+
+.diag-rejection-box {
+ background: #fff7ed;
+ border: 1px solid #fed7aa;
+ color: #9a3412;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ margin-bottom: 0.75rem;
+}
+
+.diag-details {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid #e2e8f0;
+}
+
+.diag-section {
+ margin-bottom: 1rem;
+}
+
+.diag-section h4 {
+ margin: 0 0 0.5rem;
+ font-size: 0.85rem;
+ color: #475569;
+}
+
+.diag-section p {
+ margin: 0;
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.diag-code {
+ background: #1e293b;
+ color: #e2e8f0;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+/* System Tab */
+.system-section h3 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.section-description {
+ color: #64748b;
+ font-size: 0.9rem;
+ margin: 0 0 1.5rem;
+}
+
+.cache-message {
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1.5rem;
+ font-size: 0.9rem;
+}
+
+.cache-message.cache-success {
+ background: #dcfce7;
+ color: #166534;
+ border: 1px solid #86efac;
+}
+
+.cache-message.cache-error {
+ background: #fee2e2;
+ color: #991b1b;
+ border: 1px solid #fecaca;
+}
+
+.cache-message.cache-info {
+ background: #dbeafe;
+ color: #1e40af;
+ border: 1px solid #93c5fd;
+}
+
+.cache-actions {
+ display: grid;
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+@media (min-width: 768px) {
+ .cache-actions {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.cache-card {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+}
+
+.cache-card-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.cache-icon {
+ font-size: 1.5rem;
+}
+
+.cache-card h4 {
+ margin: 0;
+ font-size: 1rem;
+ color: #1e293b;
+}
+
+.cache-card p {
+ color: #64748b;
+ font-size: 0.85rem;
+ margin: 0 0 1rem;
+ flex: 1;
+}
+
+.cache-card .btn {
+ width: 100%;
+}
+
+.cache-info {
+ background: #f1f5f9;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+}
+
+.cache-info h4 {
+ margin: 0 0 1rem;
+ color: #1e293b;
+}
+
+.cache-info ol {
+ margin: 0 0 1rem;
+ padding-left: 1.25rem;
+ color: #475569;
+ font-size: 0.9rem;
+}
+
+.cache-info li {
+ margin-bottom: 0.5rem;
+}
+
+.cache-note {
+ margin: 0;
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.cache-note code {
+ background: #e2e8f0;
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ font-size: 0.8rem;
+}
+
+/* Health Check Section */
+.health-section {
+ margin-top: 2rem;
+ padding-top: 2rem;
+ border-top: 1px solid #e2e8f0;
+}
+
+.health-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.health-header h3 {
+ margin: 0 0 0.25rem;
+}
+
+.health-error {
+ background: #fee2e2;
+ color: #991b1b;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.health-results {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.health-status-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-radius: 0.75rem;
+ font-weight: 500;
+}
+
+.health-status-banner.status-healthy {
+ background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
+ color: #166534;
+}
+
+.health-status-banner.status-warning {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ color: #92400e;
+}
+
+.health-status-banner.status-critical,
+.health-status-banner.status-error {
+ background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
+ color: #991b1b;
+}
+
+.health-status-banner .status-icon {
+ font-size: 1.5rem;
+}
+
+.health-status-banner .status-text {
+ font-size: 1.1rem;
+}
+
+.health-status-banner .status-meta {
+ margin-left: auto;
+ font-size: 0.85rem;
+ opacity: 0.8;
+}
+
+.health-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.health-card {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ border-left: 4px solid #94a3b8;
+}
+
+.health-card.status-healthy {
+ border-left-color: #10b981;
+}
+
+.health-card.status-warning {
+ border-left-color: #f59e0b;
+}
+
+.health-card.status-critical,
+.health-card.status-error {
+ border-left-color: #dc2626;
+}
+
+.health-card-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.health-icon {
+ font-size: 1.25rem;
+}
+
+.health-card-header h4 {
+ margin: 0;
+ flex: 1;
+ font-size: 0.95rem;
+ color: #1e293b;
+}
+
+.health-badge {
+ padding: 0.15rem 0.5rem;
+ border-radius: 1rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.health-badge.badge-healthy {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.health-badge.badge-warning {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.health-badge.badge-critical,
+.health-badge.badge-error {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.health-badge.badge-unknown {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.health-stats {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.stat-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.85rem;
+ padding: 0.25rem 0;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.stat-row:last-child {
+ border-bottom: none;
+}
+
+.stat-row span {
+ color: #64748b;
+}
+
+.stat-row strong {
+ color: #1e293b;
+}
+
+.stat-row.highlight {
+ background: #fffbeb;
+ margin: 0 -0.5rem;
+ padding: 0.35rem 0.5rem;
+ border-radius: 0.25rem;
+}
+
+.stat-row .text-success {
+ color: #10b981;
+}
+
+.stat-row .text-error {
+ color: #dc2626;
+}
+
+.stat-warning {
+ background: #fef3c7;
+ color: #92400e;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.8rem;
+ margin-top: 0.5rem;
+}
+
+.health-cost-summary {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ background: #f1f5f9;
+ border-radius: 0.5rem;
+}
+
+.cost-label {
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.cost-value {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #6366f1;
+}
+
+@media (max-width: 640px) {
+ .health-header {
+ flex-direction: column;
+ }
+
+ .health-header .btn {
+ width: 100%;
+ }
+
+ .health-status-banner {
+ flex-wrap: wrap;
+ }
+
+ .health-status-banner .status-meta {
+ width: 100%;
+ margin-left: 0;
+ margin-top: 0.5rem;
+ }
+}
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
new file mode 100644
index 0000000..ae53882
--- /dev/null
+++ b/frontend/src/pages/Admin.jsx
@@ -0,0 +1,1103 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './Admin.css';
+
+// Component for previewing game in admin
+function GamePreviewIframe({ gameCode, title }) {
+ if (!gameCode) return null;
+
+ return (
+
+ );
+}
+
+const REPORT_REASONS = {
+ wont_load: "Won't Load",
+ inappropriate: "Inappropriate Content",
+ broken: "Broken/Unplayable",
+ offensive: "Offensive Content",
+ copyright: "Copyright Violation",
+ other: "Other Issue"
+};
+
+export default function Admin() {
+ const { user, isAuthenticated } = useAuth();
+ const navigate = useNavigate();
+
+ const [activeTab, setActiveTab] = useState('queue');
+ const [stats, setStats] = useState(null);
+ const [queue, setQueue] = useState([]);
+ const [reports, setReports] = useState([]);
+ const [bugReports, setBugReports] = useState([]);
+ const [bugCounts, setBugCounts] = useState({});
+ const [bugFilter, setBugFilter] = useState('open');
+ const [loading, setLoading] = useState(true);
+ const [selectedGame, setSelectedGame] = useState(null);
+ const [rejectReason, setRejectReason] = useState('');
+ const [resolveAction, setResolveAction] = useState('dismiss');
+ const [resolveNotes, setResolveNotes] = useState('');
+ const [processing, setProcessing] = useState(false);
+ const [editingBug, setEditingBug] = useState(null);
+ const [bugUpdate, setBugUpdate] = useState({ status: '', priority: '', adminNotes: '' });
+ const [diagSearch, setDiagSearch] = useState('');
+ const [diagGames, setDiagGames] = useState([]);
+ const [diagLoading, setDiagLoading] = useState(false);
+ const [expandedDiag, setExpandedDiag] = useState(null);
+ const [cacheClearing, setCacheClearing] = useState({});
+ const [cacheMessage, setCacheMessage] = useState(null);
+ const [healthData, setHealthData] = useState(null);
+ const [healthLoading, setHealthLoading] = useState(false);
+ const [healthError, setHealthError] = useState(null);
+
+ useEffect(() => {
+ if (!isAuthenticated || user?.role !== 'admin') {
+ navigate('/');
+ return;
+ }
+ loadData();
+ }, [isAuthenticated, user]);
+
+ const loadData = async () => {
+ try {
+ const [statsData, queueData, reportsData, bugsData] = await Promise.all([
+ api.getAdminStats(),
+ api.getModerationQueue(),
+ api.getReports(),
+ api.getAdminBugReports('all')
+ ]);
+ setStats(statsData.stats);
+ setQueue(queueData.games || []);
+ setReports(reportsData.reports || []);
+ setBugReports(bugsData.reports || []);
+ setBugCounts(bugsData.counts || {});
+ } catch (error) {
+ console.error('Failed to load admin data:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadBugReports = async (status = bugFilter) => {
+ try {
+ const data = await api.getAdminBugReports(status);
+ setBugReports(data.reports || []);
+ setBugCounts(data.counts || {});
+ } catch (error) {
+ console.error('Failed to load bug reports:', error);
+ }
+ };
+
+ const handleBugFilterChange = (status) => {
+ setBugFilter(status);
+ loadBugReports(status);
+ };
+
+ const handleUpdateBug = async (bugId) => {
+ setProcessing(true);
+ try {
+ await api.updateBugReport(bugId, bugUpdate);
+ await loadBugReports();
+ setEditingBug(null);
+ setBugUpdate({ status: '', priority: '', adminNotes: '' });
+ } catch (error) {
+ alert('Failed to update bug: ' + error.message);
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const searchDiagnostics = async (e) => {
+ if (e) e.preventDefault();
+ setDiagLoading(true);
+ try {
+ const isGameId = /^\d+$/.test(diagSearch.trim());
+ const data = await api.searchGameDiagnostics(
+ isGameId ? '' : diagSearch.trim(),
+ isGameId ? diagSearch.trim() : ''
+ );
+ setDiagGames(data.games || []);
+ } catch (error) {
+ console.error('Failed to search diagnostics:', error);
+ } finally {
+ setDiagLoading(false);
+ }
+ };
+
+ const loadDefaultDiagnostics = async () => {
+ setDiagLoading(true);
+ try {
+ const data = await api.searchGameDiagnostics();
+ setDiagGames(data.games || []);
+ } catch (error) {
+ console.error('Failed to load diagnostics:', error);
+ } finally {
+ setDiagLoading(false);
+ }
+ };
+
+ const startEditingBug = (bug) => {
+ setEditingBug(bug.id);
+ setBugUpdate({
+ status: bug.status,
+ priority: bug.priority,
+ adminNotes: bug.admin_notes || ''
+ });
+ };
+
+ const clearCloudflareCache = async () => {
+ setCacheClearing(prev => ({ ...prev, cloudflare: true }));
+ setCacheMessage(null);
+ try {
+ const result = await api.clearCloudflareCache();
+ setCacheMessage({ type: 'success', text: result.message });
+ } catch (error) {
+ setCacheMessage({ type: 'error', text: error.message });
+ } finally {
+ setCacheClearing(prev => ({ ...prev, cloudflare: false }));
+ }
+ };
+
+ const clearServiceWorkerCache = async () => {
+ setCacheClearing(prev => ({ ...prev, sw: true }));
+ setCacheMessage(null);
+ try {
+ if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
+ // Get all caches and delete them
+ const cacheNames = await caches.keys();
+ await Promise.all(cacheNames.map(name => caches.delete(name)));
+
+ // Unregister service worker
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ await Promise.all(registrations.map(reg => reg.unregister()));
+
+ setCacheMessage({ type: 'success', text: `Cleared ${cacheNames.length} cache(s) and unregistered service worker. Refresh page to reinstall.` });
+ } else {
+ setCacheMessage({ type: 'info', text: 'No active service worker found.' });
+ }
+ } catch (error) {
+ setCacheMessage({ type: 'error', text: error.message });
+ } finally {
+ setCacheClearing(prev => ({ ...prev, sw: false }));
+ }
+ };
+
+ const clearBrowserCache = () => {
+ setCacheMessage(null);
+ window.open('/clear-cache.html', '_blank');
+ setCacheMessage({ type: 'info', text: 'Opened cache clearing page in new tab.' });
+ };
+
+ const runHealthCheck = async () => {
+ setHealthLoading(true);
+ setHealthError(null);
+ try {
+ const data = await api.getSystemHealth();
+ setHealthData(data);
+ } catch (error) {
+ setHealthError(error.message);
+ } finally {
+ setHealthLoading(false);
+ }
+ };
+
+ const handleApprove = async (gameId) => {
+ setProcessing(true);
+ try {
+ await api.reviewGame(gameId, 'approve');
+ setQueue(queue.filter(g => g.id !== gameId));
+ setSelectedGame(null);
+ setStats(prev => ({
+ ...prev,
+ pendingReview: prev.pendingReview - 1,
+ publishedGames: prev.publishedGames + 1
+ }));
+ } catch (error) {
+ alert('Failed to approve: ' + error.message);
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const handleReject = async (gameId) => {
+ if (!rejectReason.trim()) {
+ alert('Please provide a rejection reason');
+ return;
+ }
+ setProcessing(true);
+ try {
+ await api.reviewGame(gameId, 'reject', rejectReason);
+ setQueue(queue.filter(g => g.id !== gameId));
+ setSelectedGame(null);
+ setRejectReason('');
+ setStats(prev => ({
+ ...prev,
+ pendingReview: prev.pendingReview - 1
+ }));
+ } catch (error) {
+ alert('Failed to reject: ' + error.message);
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const handleResolveReport = async (reportId) => {
+ setProcessing(true);
+ try {
+ await api.resolveReport(reportId, resolveAction, resolveNotes || null);
+ setReports(reports.filter(r => r.id !== reportId));
+ setResolveAction('dismiss');
+ setResolveNotes('');
+ setStats(prev => ({
+ ...prev,
+ pendingReports: prev.pendingReports - 1
+ }));
+ } catch (error) {
+ alert('Failed to resolve: ' + error.message);
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ if (!isAuthenticated || user?.role !== 'admin') {
+ return ;
+ }
+
+ return (
+
+
+
Admin Panel
+
+
+ {stats?.pendingReview || 0}
+ Pending Review
+
+
+ {stats?.pendingReports || 0}
+ Reports
+
+
+ {bugCounts.open || 0}
+ Open Bugs
+
+
+ {stats?.totalUsers || 0}
+ Users
+
+
+ {stats?.publishedGames || 0}
+ Games
+
+
+ {stats?.todayPlays || 0}
+ Today's Plays
+
+
+ ${((stats?.totalApiCostCents || 0) / 100).toFixed(2)}
+ Total AI Cost
+
+
+ {stats?.totalGenerations || 0}
+ Generations
+
+
+
+
+
+ setActiveTab('queue')}
+ >
+ Moderation Queue ({queue.length})
+
+ setActiveTab('reports')}
+ >
+ Reports ({reports.length})
+
+ setActiveTab('bugs')}
+ >
+ Bug Reports ({bugCounts.open || 0})
+
+ { setActiveTab('diagnostics'); if (diagGames.length === 0) loadDefaultDiagnostics(); }}
+ >
+ Game Diagnostics
+
+ setActiveTab('system')}
+ >
+ System
+
+
+
+ {activeTab === 'queue' && (
+
+ {queue.length === 0 ? (
+
+
🎉
+
No games pending review!
+
+ ) : (
+
+ {queue.map(game => (
+
+
+
{game.title}
+ by @{game.creatorUsername}
+ {new Date(game.createdAt).toLocaleDateString()}
+
+
+ {game.description && (
+
{game.description}
+ )}
+
+
+ setSelectedGame(selectedGame?.id === game.id ? null : game)}
+ >
+ {selectedGame?.id === game.id ? 'Hide Preview' : 'Preview Game'}
+
+ handleApprove(game.id)}
+ disabled={processing}
+ >
+ Approve
+
+ setSelectedGame(game)}
+ disabled={processing}
+ >
+ Reject
+
+
+
+ {selectedGame?.id === game.id && (
+
+
+
+
+
+
Reject this game?
+
setRejectReason(e.target.value)}
+ rows={3}
+ />
+
+ { setSelectedGame(null); setRejectReason(''); }}
+ >
+ Cancel
+
+ handleReject(game.id)}
+ disabled={processing || !rejectReason.trim()}
+ >
+ {processing ? 'Rejecting...' : 'Confirm Reject'}
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'reports' && (
+
+ {reports.length === 0 ? (
+
+
🛡️
+
No pending reports!
+
+ ) : (
+
+ {reports.map(report => (
+
+
+
+
{report.gameTitle}
+
+ {REPORT_REASONS[report.reason] || report.reason}
+
+
+
+ Reported by @{report.reporterUsername}
+ Created by @{report.creatorUsername}
+ {new Date(report.createdAt).toLocaleDateString()}
+
+
+
+ {report.description && (
+
{report.description}
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'bugs' && (
+
+
+ handleBugFilterChange('all')}
+ >
+ All ({Object.values(bugCounts).reduce((a, b) => a + b, 0)})
+
+ handleBugFilterChange('open')}
+ >
+ Open ({bugCounts.open || 0})
+
+ handleBugFilterChange('in_progress')}
+ >
+ In Progress ({bugCounts.in_progress || 0})
+
+ handleBugFilterChange('resolved')}
+ >
+ Resolved ({bugCounts.resolved || 0})
+
+ handleBugFilterChange('closed')}
+ >
+ Closed ({bugCounts.closed || 0})
+
+
+
+ {bugReports.length === 0 ? (
+
+
🐛
+
No bug reports {bugFilter !== 'all' ? `with status "${bugFilter}"` : ''}!
+
+ ) : (
+
+ {bugReports.map(bug => (
+
+
+
+
{bug.title}
+ {bug.priority}
+ {bug.status.replace('_', ' ')}
+
+
+ Reported by @{bug.reporter_username || 'deleted user'}
+ {bug.reporter_email && ({bug.reporter_email}) }
+ {new Date(bug.created_at).toLocaleDateString()}
+ {bug.page_url && on {bug.page_url} }
+
+
+
+
{bug.description}
+
+ {bug.admin_notes && (
+
+ Admin Notes: {bug.admin_notes}
+
+ )}
+
+ {bug.resolved_at && (
+
+ Resolved {new Date(bug.resolved_at).toLocaleDateString()}
+ {bug.resolver_username && ` by @${bug.resolver_username}`}
+
+ )}
+
+ {editingBug === bug.id ? (
+
+
+ Status:
+ setBugUpdate({ ...bugUpdate, status: e.target.value })}
+ >
+ Open
+ In Progress
+ Resolved
+ Closed
+ Won't Fix
+
+
+ Priority:
+ setBugUpdate({ ...bugUpdate, priority: e.target.value })}
+ >
+ Low
+ Normal
+ High
+ Critical
+
+
+
+
setBugUpdate({ ...bugUpdate, adminNotes: e.target.value })}
+ rows={2}
+ />
+
+
+ setEditingBug(null)}
+ >
+ Cancel
+
+ handleUpdateBug(bug.id)}
+ disabled={processing}
+ >
+ {processing ? 'Saving...' : 'Save Changes'}
+
+
+
+ ) : (
+
+ startEditingBug(bug)}
+ >
+ Edit
+
+ {bug.status === 'open' && (
+ {
+ setBugUpdate({ status: 'in_progress', priority: bug.priority, adminNotes: '' });
+ api.updateBugReport(bug.id, { status: 'in_progress' }).then(() => loadBugReports());
+ }}
+ >
+ Start Working
+
+ )}
+ {bug.status === 'in_progress' && (
+ {
+ api.updateBugReport(bug.id, { status: 'resolved' }).then(() => loadBugReports());
+ }}
+ >
+ Mark Resolved
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+ {activeTab === 'diagnostics' && (
+
+
+ setDiagSearch(e.target.value)}
+ className="diag-search-input"
+ />
+
+ {diagLoading ? 'Searching...' : 'Search'}
+
+
+ Show Problems
+
+
+
+ {diagGames.length === 0 && !diagLoading ? (
+
+
🔍
+
No games found. Try searching or click "Show Problems".
+
+ ) : (
+
+ {diagGames.map(g => (
+
+
+
+
#{g.id} {g.title}
+ {g.status}
+ {g.gameStyle && {g.gameStyle} }
+
+
+ by @{g.creatorUsername || 'unknown'}
+ {new Date(g.createdAt).toLocaleDateString()}
+ {g.hasCode ? `${(g.codeLength / 1024).toFixed(1)}KB code` : 'No code'}
+ {g.generationsCount > 0 && {g.generationsCount} generations }
+ {g.totalCostCents > 0 && ${(g.totalCostCents / 100).toFixed(2)} }
+
+
+
+ {g.error && (
+
+ Error: {g.error}
+
+ )}
+
+ {g.rejectionReason && (
+
+ Rejected: {g.rejectionReason}
+
+ )}
+
+
setExpandedDiag(expandedDiag === g.id ? null : g.id)}
+ >
+ {expandedDiag === g.id ? 'Hide Details' : 'Show Details'}
+
+
+ {expandedDiag === g.id && (
+
+ {g.prompt && (
+
+
Creation Prompt
+
{g.prompt}
+
+ )}
+ {g.codePreview && (
+
+
Code Preview
+
{g.codePreview}
+
+ )}
+
+
Token Usage
+
Input: {g.inputTokens.toLocaleString()} | Output: {g.outputTokens.toLocaleString()} | API Cost: ${(g.apiCostCents / 100).toFixed(3)}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'system' && (
+
+
+
Cache Management
+
+ Clear various caches to ensure users see the latest content.
+ Order matters: Cloudflare first, then local caches.
+
+
+ {cacheMessage && (
+
+ {cacheMessage.text}
+
+ )}
+
+
+
+
+ ☁️
+
Cloudflare CDN
+
+
Purges all cached content from Cloudflare edge servers worldwide.
+
+ {cacheClearing.cloudflare ? 'Purging...' : 'Purge Cloudflare Cache'}
+
+
+
+
+
+ ⚙️
+
Service Worker Cache
+
+
Clears PWA cache on this browser and unregisters service worker.
+
+ {cacheClearing.sw ? 'Clearing...' : 'Clear Service Worker'}
+
+
+
+
+
+ 🌐
+
Browser Cache Page
+
+
Opens /clear-cache.html for users to clear their browser cache.
+
+ Open Cache Clear Page
+
+
+
+
+
+
Cache Clearing Order
+
+ Cloudflare CDN - Clear edge servers first so they don't serve stale content
+ Deploy new code - Build frontend with new hashed filenames
+ Cloudflare CDN again - Ensures CDN fetches new files
+ User browser cache - Via /clear-cache.html or hard refresh
+
+
+ The deploy script (./deploy.sh --clear-cloudflare) handles steps 1-3 automatically.
+
+
+
+
+
+
+
+
System Health Check
+
+ Run comprehensive diagnostics on all system components.
+
+
+
+ {healthLoading ? 'Checking...' : 'Run Health Check'}
+
+
+
+ {healthError && (
+
+ Failed to run health check: {healthError}
+
+ )}
+
+ {healthData && (
+
+
+
+ {healthData.status === 'healthy' ? '✓' : healthData.status === 'warning' ? '⚠' : '✗'}
+
+
+ System {healthData.status === 'healthy' ? 'Healthy' : healthData.status === 'warning' ? 'Warning' : 'Critical'}
+
+
+ Checked {new Date(healthData.timestamp).toLocaleTimeString()} ({healthData.responseTimeMs}ms)
+
+
+
+
+ {/* Database */}
+
+
+ 🗄️
+
Database
+
+ {healthData.checks.database?.status}
+
+
+ {healthData.checks.database?.stats && (
+
+
+ Users
+ {healthData.checks.database.stats.total_users}
+
+
+ Published Games
+ {healthData.checks.database.stats.published_games}
+
+
+ Total Plays
+ {healthData.checks.database.stats.total_plays}
+
+
+ Today's Plays
+ {healthData.checks.database.stats.today_plays}
+
+
+ Pending Review
+ {healthData.checks.database.stats.pending_review}
+
+
+ Open Bugs
+ {healthData.checks.database.stats.open_bugs}
+
+
+ Pending Reports
+ {healthData.checks.database.stats.pending_reports}
+
+
+ )}
+
+
+ {/* Memory */}
+
+
+ 💾
+
Memory
+
+ {healthData.checks.memory?.status}
+
+
+ {healthData.checks.memory && (
+
+
+ Heap Used
+ {healthData.checks.memory.heapUsed} MB
+
+
+ Heap Total
+ {healthData.checks.memory.heapTotal} MB
+
+
+ RSS
+ {healthData.checks.memory.rss} MB
+
+
+ )}
+
+
+ {/* Disk */}
+
+
+ 💿
+
Disk Space
+
+ {healthData.checks.disk?.status}
+
+
+ {healthData.checks.disk && (
+
+
+ Used
+ {healthData.checks.disk.used} / {healthData.checks.disk.total}
+
+
+ Available
+ {healthData.checks.disk.available}
+
+
+ Usage
+ {healthData.checks.disk.usagePercent}%
+
+ {healthData.checks.disk.message && (
+
{healthData.checks.disk.message}
+ )}
+
+ )}
+
+
+ {/* Environment */}
+
+
+ ⚙️
+
Environment
+
+ {healthData.checks.environment?.status}
+
+
+ {healthData.checks.environment && (
+
+
+ Node.js
+ {healthData.checks.environment.nodeVersion}
+
+
+ Uptime
+ {healthData.checks.environment.uptimeFormatted}
+
+
+ Claude API
+ {healthData.checks.environment.claudeApiKey ? '✓' : '✗'}
+
+
+ Cloudflare
+ {healthData.checks.environment.cloudflareConfigured ? '✓' : '✗'}
+
+
+ Push Notifications
+ {healthData.checks.environment.vapidConfigured ? '✓' : '✗'}
+
+
+ Email (SMTP)
+ {healthData.checks.environment.smtpConfigured ? '✓' : '✗'}
+
+
+ )}
+
+
+ {/* API Endpoints */}
+
+
+ 🔌
+
API Endpoints
+
+ {healthData.checks.endpoints?.status}
+
+
+ {healthData.checks.endpoints?.results && (
+
+ {healthData.checks.endpoints.results.map((ep, i) => (
+
+ {ep.name}
+
+ {ep.status === 'ok' ? `✓ ${ep.code}` : `✗ ${ep.error || ep.code}`}
+
+
+ ))}
+
+ )}
+
+
+ {/* Activity */}
+
+
+ 📊
+
Recent Activity
+
+ {healthData.checks.activity?.status}
+
+
+ {healthData.checks.activity && (
+
+
+ New Users (24h)
+ {healthData.checks.activity.new_users_24h}
+
+
+ New Games (24h)
+ {healthData.checks.activity.new_games_24h}
+
+
+ Plays (last hour)
+ {healthData.checks.activity.plays_last_hour}
+
+
+ )}
+
+
+
+ {/* Cost Summary */}
+ {healthData.checks.database?.stats && (
+
+ Total AI Cost:
+
+ ${(healthData.checks.database.stats.total_api_cost_cents / 100).toFixed(2)}
+
+
+ )}
+
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Arcade.css b/frontend/src/pages/Arcade.css
new file mode 100644
index 0000000..4fb9b51
--- /dev/null
+++ b/frontend/src/pages/Arcade.css
@@ -0,0 +1,263 @@
+.arcade {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+.arcade-header {
+ margin-bottom: 2rem;
+}
+
+.arcade-header h1 {
+ font-size: 2rem;
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.arcade-subtitle {
+ color: #475569; /* WCAG AA: 7.0:1 contrast on #f8fafc */
+ margin: 0 0 1.5rem;
+ font-size: 0.95rem;
+}
+
+.arcade-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.search-form {
+ display: flex;
+ gap: 0.5rem;
+ flex: 1;
+ max-width: 400px;
+}
+
+.search-input {
+ flex: 1;
+ padding: 0.6rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.search-input:focus {
+ border-color: #6366f1;
+}
+
+.sort-tabs {
+ display: flex;
+ gap: 0.5rem;
+ background: #f1f5f9;
+ padding: 0.25rem;
+ border-radius: 0.5rem;
+}
+
+.tab {
+ padding: 0.5rem 1rem;
+ border: none;
+ background: transparent;
+ border-radius: 0.375rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ color: #475569; /* WCAG AA: 5.9:1 contrast on #f1f5f9 */
+ transition: all 0.2s;
+}
+
+.tab:hover {
+ color: #1e293b;
+}
+
+.tab.active {
+ background: white;
+ color: #6366f1;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* Difficulty filters */
+.difficulty-filters {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.filter-label {
+ font-size: 0.85rem;
+ color: #64748b;
+ font-weight: 500;
+}
+
+.difficulty-tab {
+ padding: 0.4rem 0.75rem;
+ border: 2px solid #e2e8f0;
+ background: white;
+ border-radius: 2rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ color: #64748b;
+ transition: all 0.2s;
+}
+
+.difficulty-tab:hover {
+ border-color: #cbd5e1;
+ color: #1e293b;
+}
+
+.difficulty-tab.active {
+ border-color: #6366f1;
+ color: #6366f1;
+ background: #eef2ff;
+}
+
+.difficulty-tab.difficulty-easy.active {
+ border-color: #10b981;
+ color: #065f46;
+ background: #d1fae5;
+}
+
+.difficulty-tab.difficulty-medium.active {
+ border-color: #f59e0b;
+ color: #92400e;
+ background: #fef3c7;
+}
+
+.difficulty-tab.difficulty-hard.active {
+ border-color: #ef4444;
+ color: #991b1b;
+ background: #fee2e2;
+}
+
+.games-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 1.5rem;
+}
+
+.loading,
+.empty {
+ text-align: center;
+ padding: 4rem 1rem;
+ color: #475569; /* WCAG AA: 7.0:1 contrast on #f8fafc */
+}
+
+.load-more {
+ text-align: center;
+ margin-top: 2rem;
+}
+
+.token-info {
+ margin-top: 3rem;
+ padding: 1.5rem;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border-radius: 1rem;
+}
+
+.token-info h3 {
+ margin: 0 0 1rem;
+ color: #92400e;
+}
+
+.token-info ul {
+ margin: 0;
+ padding-left: 1.5rem;
+ color: #78350f;
+}
+
+.token-info li {
+ margin-bottom: 0.5rem;
+}
+
+/* Mobile Responsive */
+@media (max-width: 768px) {
+ .arcade {
+ padding: 1rem;
+ }
+
+ .arcade-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .arcade-subtitle {
+ font-size: 0.875rem;
+ }
+
+ .arcade-controls {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.75rem;
+ }
+
+ .search-form {
+ max-width: none;
+ }
+
+ .search-input {
+ padding: 0.75rem 1rem;
+ font-size: 16px; /* Prevent iOS zoom */
+ }
+
+ .sort-tabs {
+ justify-content: center;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ .sort-tabs::-webkit-scrollbar {
+ display: none;
+ }
+
+ .tab {
+ min-height: 44px;
+ padding: 0.625rem 1rem;
+ white-space: nowrap;
+ }
+
+ .difficulty-filters {
+ justify-content: flex-start;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ padding-bottom: 0.5rem;
+ }
+
+ .difficulty-tab {
+ min-height: 40px;
+ padding: 0.5rem 1rem;
+ white-space: nowrap;
+ }
+
+ .games-grid {
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ }
+}
+
+/* Small mobile screens */
+@media (max-width: 480px) {
+ .arcade {
+ padding: 0.75rem;
+ }
+
+ .games-grid {
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+ }
+}
+
+/* Touch-friendly targets */
+@media (hover: none), (pointer: coarse) {
+ .tab,
+ .difficulty-tab,
+ .search-input,
+ .btn {
+ min-height: 44px;
+ }
+}
diff --git a/frontend/src/pages/Arcade.jsx b/frontend/src/pages/Arcade.jsx
new file mode 100644
index 0000000..cfc2ed4
--- /dev/null
+++ b/frontend/src/pages/Arcade.jsx
@@ -0,0 +1,186 @@
+import { useState, useEffect } from 'react';
+import GameCard from '../components/GameCard';
+import OnboardingTutorial from '../components/OnboardingTutorial';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './Arcade.css';
+
+export default function Arcade() {
+ const { isAuthenticated, isGuest } = useAuth();
+ const [showGuestOnboarding, setShowGuestOnboarding] = useState(false);
+ const [games, setGames] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [sort, setSort] = useState('trending');
+ const [search, setSearch] = useState('');
+ const [difficulty, setDifficulty] = useState('');
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(false);
+
+ useEffect(() => {
+ if (!isAuthenticated || isGuest) {
+ if (!localStorage.getItem('gamercomp_guest_onboarding')) {
+ setShowGuestOnboarding(true);
+ }
+ }
+ }, [isAuthenticated, isGuest]);
+
+ useEffect(() => {
+ loadGames();
+ }, [sort, difficulty]);
+
+ const loadGames = async (reset = true) => {
+ setLoading(true);
+ try {
+ const newPage = reset ? 1 : page;
+ const data = await api.getGames({ sort, search, difficulty, page: newPage, limit: 20 });
+
+ if (reset) {
+ setGames(data.games);
+ setPage(1);
+ } else {
+ setGames(prev => [...prev, ...data.games]);
+ }
+ setHasMore(data.hasMore);
+ } catch (error) {
+ console.error('Failed to load games:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSearch = (e) => {
+ e.preventDefault();
+ loadGames(true);
+ };
+
+ const loadMore = () => {
+ setPage(p => p + 1);
+ loadGames(false);
+ };
+
+ const handleToggleFavorite = async (gameId) => {
+ if (!isAuthenticated) return;
+ await api.toggleFavorite(gameId);
+ };
+
+ return (
+
+ {showGuestOnboarding && (
+
{
+ setShowGuestOnboarding(false);
+ localStorage.setItem('gamercomp_guest_onboarding', 'true');
+ }} />
+ )}
+
+
Game Arcade
+
Live game previews - click to play!
+
+
+
+ setSearch(e.target.value)}
+ className="search-input"
+ />
+ Search
+
+
+
+ setSort('trending')}
+ >
+ 🔥 Trending
+
+ setSort('new')}
+ >
+ ✨ New
+
+ setSort('top-rated')}
+ >
+ ⭐ Top Rated
+
+
+
+
+ Difficulty:
+ setDifficulty('')}
+ >
+ All
+
+ setDifficulty('easy')}
+ >
+ Easy
+
+ setDifficulty('medium')}
+ >
+ Medium
+
+ setDifficulty('hard')}
+ >
+ Hard
+
+
+
+
+
+ {loading && games.length === 0 ? (
+ Loading games...
+ ) : games.length === 0 ? (
+
+
No games found. Be the first to create one!
+
+ ) : (
+ <>
+
+ {games.map(game => (
+
+ ))}
+
+
+ {hasMore && (
+
+
+ {loading ? 'Loading...' : 'Load More'}
+
+
+ )}
+ >
+ )}
+
+
+
🪙 Token System
+
+ Get 50 free tokens every day
+ Tokens are only charged after 1 minute of play
+ 1 token per minute of gameplay
+ Game creators earn tokens when you play their games!
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Auth.css b/frontend/src/pages/Auth.css
new file mode 100644
index 0000000..3c8186a
--- /dev/null
+++ b/frontend/src/pages/Auth.css
@@ -0,0 +1,330 @@
+.auth-page {
+ min-height: calc(100vh - 60px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1rem;
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+}
+
+.auth-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 2.5rem 2rem;
+ width: 100%;
+ max-width: 400px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+}
+
+.auth-card h1 {
+ font-size: 1.75rem;
+ margin: 0 0 0.5rem;
+ text-align: center;
+ color: #1e293b;
+}
+
+.auth-subtitle {
+ text-align: center;
+ color: #64748b;
+ margin: 0 0 2rem;
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.form-group label {
+ font-weight: 500;
+ color: #374151;
+ font-size: 0.95rem;
+}
+
+.form-group input[type="text"],
+.form-group input[type="email"],
+.form-group input[type="password"],
+.form-group textarea {
+ padding: 0.75rem 1rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group textarea:focus {
+ border-color: #6366f1;
+}
+
+.checkbox-group {
+ flex-direction: row;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ font-weight: normal;
+ cursor: pointer;
+}
+
+.checkbox-label input {
+ margin-top: 0.25rem;
+ width: 1rem;
+ height: 1rem;
+}
+
+.checkbox-label span {
+ font-size: 0.9rem;
+ color: #475569;
+ line-height: 1.4;
+}
+
+.checkbox-label span a {
+ color: #6366f1;
+ text-decoration: none;
+}
+
+.checkbox-label span a:hover {
+ text-decoration: underline;
+}
+
+.error-alert {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.btn-block {
+ width: 100%;
+ padding: 0.875rem;
+ font-size: 1rem;
+}
+
+.auth-footer {
+ text-align: center;
+ margin: 1.5rem 0 0;
+ color: #64748b;
+ font-size: 0.95rem;
+}
+
+.auth-footer a {
+ color: #6366f1;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.auth-footer a:hover {
+ text-decoration: underline;
+}
+
+/* Username suggestions */
+.username-notice {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: #fef3c7;
+ border-radius: 0.375rem;
+ font-size: 0.85rem;
+ color: #92400e;
+}
+
+.notice-icon {
+ font-size: 1rem;
+}
+
+.field-error {
+ color: #dc2626;
+ font-size: 0.85rem;
+ margin-top: 0.25rem;
+}
+
+.field-hint {
+ color: #64748b;
+ font-size: 0.8rem;
+ margin-top: 0.25rem;
+}
+
+.username-suggestions {
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+ background: #f0f9ff;
+ border-radius: 0.5rem;
+}
+
+.suggestions-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ font-size: 0.85rem;
+ color: #0369a1;
+}
+
+.refresh-btn {
+ background: none;
+ border: none;
+ color: #0369a1;
+ cursor: pointer;
+ font-size: 0.85rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+}
+
+.refresh-btn:hover {
+ background: rgba(3, 105, 161, 0.1);
+}
+
+.suggestions-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.suggestion-btn {
+ padding: 0.375rem 0.75rem;
+ background: white;
+ border: 1px solid #bae6fd;
+ border-radius: 1rem;
+ font-size: 0.85rem;
+ color: #0369a1;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.suggestion-btn:hover {
+ background: #0369a1;
+ color: white;
+ border-color: #0369a1;
+}
+
+/* Forgot password link */
+.forgot-password-link {
+ text-align: right;
+ margin-top: -0.5rem;
+}
+
+.forgot-password-link a {
+ color: #6366f1;
+ font-size: 0.875rem;
+ text-decoration: none;
+}
+
+.forgot-password-link a:hover {
+ text-decoration: underline;
+}
+
+/* Auth container (for password reset pages) */
+.auth-container {
+ background: white;
+ border-radius: 1rem;
+ padding: 2.5rem 2rem;
+ width: 100%;
+ max-width: 400px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+ text-align: center;
+}
+
+.auth-header {
+ margin-bottom: 1.5rem;
+}
+
+.auth-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.auth-header h1 {
+ font-size: 1.5rem;
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.auth-header p {
+ color: #64748b;
+ margin: 0;
+}
+
+.auth-container .auth-form {
+ text-align: left;
+}
+
+.auth-error {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+.auth-error-box {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 1.5rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.auth-success {
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ color: #166534;
+ padding: 1.5rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.auth-success p {
+ margin: 0 0 0.5rem;
+}
+
+.auth-success p:last-child {
+ margin-bottom: 0;
+}
+
+.auth-hint {
+ font-size: 0.875rem;
+ color: #64748b;
+}
+
+.auth-links {
+ margin-top: 1.5rem;
+}
+
+.auth-links p {
+ margin: 0.5rem 0;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.auth-links a {
+ color: #6366f1;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.auth-links a:hover {
+ text-decoration: underline;
+}
+
+.btn-full {
+ width: 100%;
+ padding: 0.875rem;
+}
diff --git a/frontend/src/pages/Classrooms.css b/frontend/src/pages/Classrooms.css
new file mode 100644
index 0000000..49b2a3c
--- /dev/null
+++ b/frontend/src/pages/Classrooms.css
@@ -0,0 +1,505 @@
+.classrooms-page {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+.classrooms-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.classrooms-header h1 {
+ margin: 0;
+ color: #1e293b;
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.error-alert {
+ background: #fef2f2;
+ color: #dc2626;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid #fecaca;
+}
+
+/* Join/Create Forms */
+.join-form,
+.create-form {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ border: 1px solid #e2e8f0;
+}
+
+.join-form input,
+.create-form input {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+}
+
+.join-form input {
+ text-transform: uppercase;
+ font-family: monospace;
+ font-size: 1.1rem;
+ letter-spacing: 2px;
+}
+
+/* Classrooms Sections */
+.classrooms-section {
+ margin-bottom: 2rem;
+}
+
+.classrooms-section h2 {
+ font-size: 1.1rem;
+ color: #64748b;
+ margin: 0 0 1rem;
+}
+
+.classrooms-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+}
+
+/* Classroom Cards */
+.classroom-card {
+ display: flex;
+ gap: 1rem;
+ padding: 1.25rem;
+ background: white;
+ border-radius: 1rem;
+ text-decoration: none;
+ color: inherit;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+ border: 2px solid transparent;
+ transition: all 0.2s;
+}
+
+.classroom-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
+}
+
+.classroom-card.teacher-card {
+ border-color: #6366f1;
+}
+
+.classroom-card.student-card {
+ border-color: #10b981;
+}
+
+.classroom-icon {
+ font-size: 2rem;
+}
+
+.classroom-info {
+ flex: 1;
+}
+
+.classroom-info h3 {
+ margin: 0 0 0.25rem;
+ color: #1e293b;
+ font-size: 1.1rem;
+}
+
+.classroom-meta {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.classroom-meta .dot {
+ margin: 0 0.35rem;
+}
+
+.join-code-preview {
+ margin-top: 0.5rem;
+ font-size: 0.85rem;
+ color: #6366f1;
+ font-family: monospace;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+}
+
+.empty-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.empty-subtitle {
+ font-size: 0.9rem;
+ color: #94a3b8;
+}
+
+/* Classroom Detail */
+.classroom-detail-header {
+ margin-bottom: 1.5rem;
+}
+
+.back-link {
+ color: #6366f1;
+ text-decoration: none;
+ font-size: 0.9rem;
+ display: inline-block;
+ margin-bottom: 1rem;
+}
+
+.back-link:hover {
+ text-decoration: underline;
+}
+
+.classroom-title-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.classroom-title-row h1 {
+ margin: 0;
+ color: #1e293b;
+}
+
+.classroom-subtitle {
+ margin: 0.25rem 0 0;
+ color: #64748b;
+}
+
+.classroom-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+/* Join Code Box */
+.join-code-box {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 1rem;
+ padding: 0.75rem 1rem;
+ background: #f0fdf4;
+ border-radius: 0.5rem;
+ border: 1px solid #86efac;
+ flex-wrap: wrap;
+}
+
+.join-code-label {
+ font-size: 0.9rem;
+ color: #166534;
+}
+
+.join-code-value {
+ font-family: monospace;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #15803d;
+ letter-spacing: 2px;
+}
+
+/* Tabs */
+.classroom-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background: white;
+ padding: 0.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.tab-btn {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: none;
+ background: transparent;
+ border-radius: 0.75rem;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.tab-btn:hover {
+ background: #f1f5f9;
+}
+
+.tab-btn.active {
+ background: #6366f1;
+ color: white;
+}
+
+/* Classroom Section */
+.classroom-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+/* Students List */
+.students-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.student-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ background: #f8fafc;
+ border-radius: 0.5rem;
+}
+
+.student-info {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.student-name {
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.student-badge {
+ font-size: 0.75rem;
+ background: #e0e7ff;
+ color: #4f46e5;
+ padding: 0.125rem 0.5rem;
+ border-radius: 1rem;
+}
+
+.student-level {
+ font-size: 0.8rem;
+ color: #64748b;
+}
+
+.student-stats {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+/* Leaderboard */
+.leaderboard-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.leaderboard-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ background: #f8fafc;
+ border-radius: 0.5rem;
+}
+
+.leaderboard-row.top-three {
+ background: #fef3c7;
+}
+
+.leaderboard-row .rank {
+ font-weight: 700;
+ color: #6366f1;
+ width: 2.5rem;
+}
+
+.leaderboard-row .name {
+ flex: 1;
+ font-weight: 500;
+}
+
+.leaderboard-row .badge {
+ font-size: 0.75rem;
+ background: #e0e7ff;
+ color: #4f46e5;
+ padding: 0.125rem 0.5rem;
+ border-radius: 1rem;
+}
+
+.leaderboard-row .xp {
+ font-weight: 600;
+ color: #10b981;
+}
+
+.leaderboard-row .games {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+/* Assignments */
+.assignment-actions {
+ margin-bottom: 1rem;
+}
+
+.assignment-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+}
+
+.assignment-form input,
+.assignment-form textarea {
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ font-family: inherit;
+}
+
+.assignment-form .form-row {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.assignment-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.9rem;
+ color: #64748b;
+}
+
+.assignments-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.assignment-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1rem 1.25rem;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ border-left: 4px solid #6366f1;
+}
+
+.assignment-card.overdue {
+ border-left-color: #dc2626;
+ background: #fef2f2;
+}
+
+.assignment-info h3 {
+ margin: 0 0 0.25rem;
+ color: #1e293b;
+ font-size: 1rem;
+}
+
+.assignment-info p {
+ margin: 0 0 0.5rem;
+ font-size: 0.9rem;
+ color: #64748b;
+}
+
+.due-date {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.overdue-badge {
+ margin-left: 0.5rem;
+ background: #dc2626;
+ color: white;
+ font-size: 0.7rem;
+ padding: 0.125rem 0.4rem;
+ border-radius: 0.25rem;
+}
+
+/* Loading/Error States */
+.loading,
+.error-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+}
+
+.error-state h2 {
+ color: #dc2626;
+ margin-bottom: 0.5rem;
+}
+
+/* Mobile */
+@media (max-width: 640px) {
+ .classrooms-page {
+ padding: 1rem;
+ }
+
+ .classrooms-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .header-actions {
+ flex-direction: column;
+ }
+
+ .join-form,
+ .create-form {
+ flex-direction: column;
+ }
+
+ .classroom-tabs {
+ flex-wrap: wrap;
+ }
+
+ .tab-btn {
+ flex: 1 1 45%;
+ }
+
+ .student-row {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .student-stats {
+ font-size: 0.8rem;
+ }
+
+ .leaderboard-row {
+ flex-wrap: wrap;
+ }
+
+ .join-code-box {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/frontend/src/pages/Classrooms.jsx b/frontend/src/pages/Classrooms.jsx
new file mode 100644
index 0000000..2cba516
--- /dev/null
+++ b/frontend/src/pages/Classrooms.jsx
@@ -0,0 +1,546 @@
+import { useState, useEffect } from 'react';
+import { Link, useParams, useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './Classrooms.css';
+
+export default function Classrooms() {
+ const { classroomId } = useParams();
+
+ if (classroomId) {
+ return ;
+ }
+
+ return ;
+}
+
+function ClassroomsList() {
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const [classrooms, setClassrooms] = useState({ asTeacher: [], asStudent: [] });
+ const [loading, setLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+ const [showJoin, setShowJoin] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [joinCode, setJoinCode] = useState('');
+ const [creating, setCreating] = useState(false);
+ const [joining, setJoining] = useState(false);
+ const [error, setError] = useState('');
+
+ const isTeacher = user?.role === 'teacher' || user?.role === 'admin';
+
+ useEffect(() => {
+ loadClassrooms();
+ }, []);
+
+ async function loadClassrooms() {
+ try {
+ const data = await api.getMyClassrooms();
+ setClassrooms(data);
+ } catch (err) {
+ console.error('Failed to load classrooms:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleCreate(e) {
+ e.preventDefault();
+ if (!newName.trim()) return;
+
+ setCreating(true);
+ setError('');
+ try {
+ const data = await api.createClassroom(newName);
+ setClassrooms(prev => ({
+ ...prev,
+ asTeacher: [...prev.asTeacher, data.classroom]
+ }));
+ setNewName('');
+ setShowCreate(false);
+ navigate(`/classrooms/${data.classroom.id}`);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleJoin(e) {
+ e.preventDefault();
+ if (!joinCode.trim()) return;
+
+ setJoining(true);
+ setError('');
+ try {
+ const data = await api.joinClassroom(joinCode);
+ setClassrooms(prev => ({
+ ...prev,
+ asStudent: [...prev.asStudent, data.classroom]
+ }));
+ setJoinCode('');
+ setShowJoin(false);
+ navigate(`/classrooms/${data.classroom.id}`);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setJoining(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
Loading classrooms...
+
+ );
+ }
+
+ return (
+
+
+
My Classrooms
+
+ { setShowJoin(!showJoin); setShowCreate(false); }}
+ >
+ {showJoin ? 'Cancel' : 'Join Classroom'}
+
+ { setShowCreate(!showCreate); setShowJoin(false); }}
+ >
+ {showCreate ? 'Cancel' : '+ Create Classroom'}
+
+
+
+
+ {error &&
{error}
}
+
+ {showJoin && (
+
+ setJoinCode(e.target.value.toUpperCase())}
+ maxLength={10}
+ required
+ />
+
+ {joining ? 'Joining...' : 'Join'}
+
+
+ )}
+
+ {showCreate && (
+
+ setNewName(e.target.value)}
+ maxLength={100}
+ required
+ />
+
+ {creating ? 'Creating...' : 'Create'}
+
+
+ )}
+
+ {/* Created by me */}
+ {classrooms.asTeacher.length > 0 && (
+
+ Classrooms I Created
+
+ {classrooms.asTeacher.map(c => (
+
+
🏫
+
+
{c.name}
+
+ {c.studentCount} students
+ ·
+ {c.assignmentCount} assignments
+
+
+ Code: {c.joinCode}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Enrolled */}
+ {classrooms.asStudent.length > 0 && (
+
+ Classrooms I'm In
+
+ {classrooms.asStudent.map(c => (
+
+
📚
+
+
{c.name}
+
+ Teacher: {c.teacherDisplayName || c.teacherUsername}
+ ·
+ {c.studentCount} students
+
+
+
+ ))}
+
+
+ )}
+
+ {classrooms.asTeacher.length === 0 && classrooms.asStudent.length === 0 && (
+
+
🏫
+
You're not in any classrooms yet
+
+ Create a classroom to get started, or join one with a code!
+
+
+ )}
+
+ );
+}
+
+function ClassroomDetail({ classroomId }) {
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const [classroom, setClassroom] = useState(null);
+ const [students, setStudents] = useState([]);
+ const [assignments, setAssignments] = useState([]);
+ const [leaderboard, setLeaderboard] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState('students');
+ const [error, setError] = useState('');
+
+ // Assignment form
+ const [showAssignmentForm, setShowAssignmentForm] = useState(false);
+ const [newAssignment, setNewAssignment] = useState({ title: '', description: '', dueDate: '' });
+ const [creatingAssignment, setCreatingAssignment] = useState(false);
+
+ useEffect(() => {
+ loadClassroom();
+ }, [classroomId]);
+
+ async function loadClassroom() {
+ try {
+ const [classroomData, studentsData, assignmentsData, leaderboardData] = await Promise.all([
+ api.getClassroom(classroomId),
+ api.getClassroomStudents(classroomId),
+ api.getClassroomAssignments(classroomId),
+ api.getClassroomLeaderboard(classroomId)
+ ]);
+
+ setClassroom(classroomData.classroom);
+ setStudents(studentsData.students);
+ setAssignments(assignmentsData.assignments);
+ setLeaderboard(leaderboardData.leaderboard);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleRemoveStudent(studentId) {
+ if (!confirm('Remove this student from the classroom?')) return;
+
+ try {
+ await api.removeStudentFromClassroom(classroomId, studentId);
+ setStudents(prev => prev.filter(s => s.id !== studentId));
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function handleCreateAssignment(e) {
+ e.preventDefault();
+ if (!newAssignment.title.trim()) return;
+
+ setCreatingAssignment(true);
+ try {
+ const data = await api.createAssignment(classroomId, newAssignment);
+ setAssignments(prev => [...prev, data.assignment]);
+ setNewAssignment({ title: '', description: '', dueDate: '' });
+ setShowAssignmentForm(false);
+ } catch (err) {
+ alert(err.message);
+ } finally {
+ setCreatingAssignment(false);
+ }
+ }
+
+ async function handleDeleteAssignment(assignmentId) {
+ if (!confirm('Delete this assignment?')) return;
+
+ try {
+ await api.deleteAssignment(classroomId, assignmentId);
+ setAssignments(prev => prev.filter(a => a.id !== assignmentId));
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function handleLeaveClassroom() {
+ if (!confirm('Are you sure you want to leave this classroom?')) return;
+
+ try {
+ await api.leaveClassroom(classroomId);
+ navigate('/classrooms');
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function handleRegenerateCode() {
+ if (!confirm('Regenerate the join code? The old code will stop working.')) return;
+
+ try {
+ const data = await api.regenerateJoinCode(classroomId);
+ setClassroom(prev => ({ ...prev, joinCode: data.joinCode }));
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function handleDeleteClassroom() {
+ if (!confirm('Delete this classroom? This cannot be undone.')) return;
+
+ try {
+ await api.deleteClassroom(classroomId);
+ navigate('/classrooms');
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !classroom) {
+ return (
+
+
+
Classroom not found
+
{error}
+
Back to Classrooms
+
+
+ );
+ }
+
+ return (
+
+
+
← Back to Classrooms
+
+
+
+
{classroom.isTeacher ? '🏫' : '📚'} {classroom.name}
+
+ {classroom.isTeacher
+ ? `${classroom.studentCount} students enrolled`
+ : `Teacher: ${classroom.teacherDisplayName || classroom.teacherUsername}`}
+
+
+
+ {classroom.isTeacher ? (
+ <>
+
+ Delete
+
+ >
+ ) : (
+
+ Leave Classroom
+
+ )}
+
+
+
+ {classroom.isTeacher && (
+
+ Join Code:
+ {classroom.joinCode}
+ navigator.clipboard.writeText(classroom.joinCode)}>
+ Copy
+
+
+ New Code
+
+
+ )}
+
+
+
+ setActiveTab('students')}
+ >
+ Students ({students.length})
+
+ setActiveTab('leaderboard')}
+ >
+ Leaderboard
+
+ setActiveTab('assignments')}
+ >
+ Assignments ({assignments.length})
+
+
+
+ {activeTab === 'students' && (
+
+ {students.length === 0 ? (
+
+
No students yet
+ {classroom.isTeacher && (
+
Share the join code {classroom.joinCode} with your students!
+ )}
+
+ ) : (
+
+ {students.map(s => (
+
+
+ {s.displayName || s.username}
+ {s.badge}
+ Lv.{s.level}
+
+
+ {s.gamesCreated} games
+ {s.xpTotal} XP
+
+ {classroom.isTeacher && (
+
handleRemoveStudent(s.id)}
+ >
+ Remove
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'leaderboard' && (
+
+ {leaderboard.length === 0 ? (
+
+ ) : (
+
+ {leaderboard.map((s, i) => (
+
+ #{s.rank}
+ {s.displayName || s.username}
+ {s.badge}
+ {s.xpTotal} XP
+ {s.gamesCreated} games
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'assignments' && (
+
+ {classroom.isTeacher && (
+
+ setShowAssignmentForm(!showAssignmentForm)}
+ >
+ {showAssignmentForm ? 'Cancel' : '+ New Assignment'}
+
+
+ )}
+
+ {showAssignmentForm && (
+
+ setNewAssignment(prev => ({ ...prev, title: e.target.value }))}
+ required
+ />
+ setNewAssignment(prev => ({ ...prev, description: e.target.value }))}
+ rows={3}
+ />
+
+
+ Due Date (optional):
+ setNewAssignment(prev => ({ ...prev, dueDate: e.target.value }))}
+ />
+
+
+ {creatingAssignment ? 'Creating...' : 'Create Assignment'}
+
+
+
+ )}
+
+ {assignments.length === 0 ? (
+
+ ) : (
+
+ {assignments.map(a => (
+
+
+
{a.title}
+ {a.description &&
{a.description}
}
+ {a.dueDate && (
+
+ Due: {new Date(a.dueDate).toLocaleDateString()}
+ {a.isOverdue && Overdue }
+
+ )}
+
+ {classroom.isTeacher && (
+
handleDeleteAssignment(a.id)}
+ >
+ Delete
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Collections.css b/frontend/src/pages/Collections.css
new file mode 100644
index 0000000..cbfda3b
--- /dev/null
+++ b/frontend/src/pages/Collections.css
@@ -0,0 +1,338 @@
+.collections-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: #f8fafc;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.collections-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+}
+
+.collections-header h1 {
+ margin: 0;
+ color: #1e293b;
+}
+
+/* Create form */
+.create-collection-form {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ margin-bottom: 1.5rem;
+}
+
+.form-row {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.form-row:last-child {
+ margin-bottom: 0;
+}
+
+.form-row input[type="text"] {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+}
+
+.form-row input[type="text"]:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+}
+
+.error-alert {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+/* Collections grid */
+.collections-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1.5rem;
+}
+
+.collection-card {
+ display: flex;
+ gap: 1rem;
+ background: white;
+ padding: 1.25rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ text-decoration: none;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.collection-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
+}
+
+.collection-icon {
+ font-size: 2.5rem;
+ flex-shrink: 0;
+}
+
+.collection-info h3 {
+ margin: 0 0 0.25rem;
+ color: #1e293b;
+ font-size: 1.1rem;
+}
+
+.collection-description {
+ margin: 0 0 0.5rem;
+ color: #64748b;
+ font-size: 0.9rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.collection-meta {
+ font-size: 0.8rem;
+ color: #94a3b8;
+}
+
+.collection-meta .dot {
+ margin: 0 0.25rem;
+}
+
+/* Collection detail */
+.collection-header {
+ margin-bottom: 2rem;
+}
+
+.back-link {
+ display: inline-block;
+ margin-bottom: 1rem;
+ color: #6366f1;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.back-link:hover {
+ text-decoration: underline;
+}
+
+.collection-title-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.collection-title-row h1 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.edit-form {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.edit-form input {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ margin-bottom: 1rem;
+}
+
+.edit-form input:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.edit-actions {
+ display: flex;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+/* Search in collection */
+.collection-search {
+ position: relative;
+ margin-bottom: 1.5rem;
+ max-width: 400px;
+}
+
+.collection-search .search-input {
+ width: 100%;
+ padding: 0.875rem 2.5rem 0.875rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.75rem;
+ font-size: 1rem;
+ background: white;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.collection-search .search-input:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
+}
+
+.collection-search .search-input::placeholder {
+ color: #94a3b8;
+}
+
+.clear-search-btn {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 24px;
+ height: 24px;
+ background: #e2e8f0;
+ border: none;
+ border-radius: 50%;
+ color: #64748b;
+ font-size: 0.875rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s;
+}
+
+.clear-search-btn:hover {
+ background: #cbd5e1;
+ color: #475569;
+}
+
+/* Games in collection */
+.collection-games {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 1.5rem;
+}
+
+.collection-game-item {
+ position: relative;
+}
+
+.remove-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 28px;
+ height: 28px;
+ background: rgba(239, 68, 68, 0.9);
+ border: none;
+ border-radius: 50%;
+ color: white;
+ font-size: 1.25rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.2s;
+ z-index: 10;
+}
+
+.collection-game-item:hover .remove-btn {
+ opacity: 1;
+}
+
+.remove-btn:hover {
+ background: #dc2626;
+}
+
+/* Empty state */
+.empty-state {
+ text-align: center;
+ padding: 3rem;
+ background: white;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.empty-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.empty-state p {
+ color: #64748b;
+ margin: 0 0 0.5rem;
+}
+
+.empty-subtitle {
+ font-size: 0.9rem;
+ color: #94a3b8;
+}
+
+.error-state {
+ text-align: center;
+ padding: 3rem;
+}
+
+.error-state h2 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.error-state p {
+ color: #64748b;
+ margin: 0 0 1rem;
+}
+
+/* Responsive */
+@media (max-width: 640px) {
+ .collections-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 1rem;
+ }
+
+ .form-row {
+ flex-direction: column;
+ }
+
+ .collections-grid,
+ .collection-games {
+ grid-template-columns: 1fr;
+ }
+
+ .collection-title-row {
+ flex-direction: column;
+ }
+
+ .edit-actions {
+ flex-direction: column;
+ }
+}
diff --git a/frontend/src/pages/Collections.jsx b/frontend/src/pages/Collections.jsx
new file mode 100644
index 0000000..0d367ec
--- /dev/null
+++ b/frontend/src/pages/Collections.jsx
@@ -0,0 +1,354 @@
+import { useState, useEffect } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import GameCard from '../components/GameCard';
+import './Collections.css';
+
+export default function Collections() {
+ const { collectionId } = useParams();
+ const { user } = useAuth();
+
+ if (collectionId) {
+ return ;
+ }
+
+ return ;
+}
+
+function CollectionsList() {
+ const [collections, setCollections] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+ const [newName, setNewName] = useState('');
+ const [newDescription, setNewDescription] = useState('');
+ const [isPublic, setIsPublic] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ loadCollections();
+ }, []);
+
+ async function loadCollections() {
+ try {
+ const data = await api.getMyCollections();
+ setCollections(data.collections || []);
+ } catch (err) {
+ console.error('Failed to load collections:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleCreate(e) {
+ e.preventDefault();
+ if (!newName.trim()) return;
+
+ setCreating(true);
+ setError('');
+ try {
+ const data = await api.createCollection(newName, newDescription, isPublic);
+ setCollections(prev => [...prev, data.collection]);
+ setNewName('');
+ setNewDescription('');
+ setShowCreate(false);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
Loading collections...
+
+ );
+ }
+
+ return (
+
+
+
My Collections
+ setShowCreate(!showCreate)}
+ >
+ {showCreate ? 'Cancel' : '+ New Collection'}
+
+
+
+ {showCreate && (
+
+ {error && {error}
}
+
+ setNewName(e.target.value)}
+ maxLength={50}
+ required
+ />
+ setNewDescription(e.target.value)}
+ maxLength={200}
+ />
+
+
+
+ setIsPublic(e.target.checked)}
+ />
+ Make this collection public
+
+
+ {creating ? 'Creating...' : 'Create Collection'}
+
+
+
+ )}
+
+ {collections.length === 0 ? (
+
+
📁
+
You don't have any collections yet
+
Create a collection to organize your favorite games!
+
+ ) : (
+
+ {collections.map(collection => (
+
+
+ {collection.is_public ? '📁' : '🔒'}
+
+
+
{collection.name}
+ {collection.description && (
+
{collection.description}
+ )}
+
+ {collection.game_count || 0} games
+ ·
+ {collection.is_public ? 'Public' : 'Private'}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function CollectionDetail({ collectionId }) {
+ const { user } = useAuth();
+ const [collection, setCollection] = useState(null);
+ const [games, setGames] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(false);
+ const [editName, setEditName] = useState('');
+ const [editDescription, setEditDescription] = useState('');
+ const [editPublic, setEditPublic] = useState(true);
+ const [error, setError] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+
+ useEffect(() => {
+ loadCollection();
+ }, [collectionId]);
+
+ async function loadCollection() {
+ try {
+ const data = await api.getCollection(collectionId);
+ setCollection(data.collection);
+ setGames(data.games || []);
+ setEditName(data.collection.name);
+ setEditDescription(data.collection.description || '');
+ setEditPublic(data.collection.is_public);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleUpdate() {
+ try {
+ await api.updateCollection(collectionId, {
+ name: editName,
+ description: editDescription,
+ isPublic: editPublic
+ });
+ setCollection(prev => ({
+ ...prev,
+ name: editName,
+ description: editDescription,
+ is_public: editPublic
+ }));
+ setEditing(false);
+ } catch (err) {
+ setError(err.message);
+ }
+ }
+
+ async function handleRemoveGame(gameId) {
+ try {
+ await api.removeFromCollection(collectionId, gameId);
+ setGames(prev => prev.filter(g => g.id !== gameId));
+ } catch (err) {
+ console.error('Failed to remove game:', err);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
Loading collection...
+
+ );
+ }
+
+ if (error || !collection) {
+ return (
+
+
+
Collection not found
+
{error}
+
Back to Collections
+
+
+ );
+ }
+
+ const isOwner = user && collection.user_id === user.id;
+
+ // Filter games by search term (client-side)
+ const filteredGames = searchTerm.trim()
+ ? games.filter(game =>
+ game.title.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ : games;
+
+ return (
+
+
+
← Back to Collections
+
+ {editing ? (
+
+ ) : (
+
+
+
+ {collection.is_public ? '📁' : '🔒'} {collection.name}
+
+ {collection.description && (
+
{collection.description}
+ )}
+
+ {isOwner && (
+
setEditing(true)}>
+ Edit
+
+ )}
+
+ )}
+
+
+ {games.length > 0 && (
+
+ setSearchTerm(e.target.value)}
+ className="search-input"
+ />
+ {searchTerm && (
+ setSearchTerm('')}
+ title="Clear search"
+ >
+ x
+
+ )}
+
+ )}
+
+ {games.length === 0 ? (
+
+
🎮
+
This collection is empty
+ {isOwner && (
+
Browse Games to Add
+ )}
+
+ ) : filteredGames.length === 0 ? (
+
+
🔍
+
No games found matching "{searchTerm}"
+
setSearchTerm('')}
+ >
+ Clear Search
+
+
+ ) : (
+
+ {filteredGames.map(game => (
+
+
+ {isOwner && (
+ handleRemoveGame(game.id)}
+ title="Remove from collection"
+ >
+ ×
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Create.css b/frontend/src/pages/Create.css
new file mode 100644
index 0000000..88e3269
--- /dev/null
+++ b/frontend/src/pages/Create.css
@@ -0,0 +1,530 @@
+.create-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: #f8fafc;
+}
+
+.create-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 2.5rem 2rem;
+ max-width: 600px;
+ margin: 0 auto;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+}
+
+.create-card h1 {
+ font-size: 1.75rem;
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.create-subtitle {
+ color: #64748b;
+ margin: 0 0 2rem;
+}
+
+.create-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.char-count {
+ font-size: 0.8rem;
+ color: #94a3b8;
+ text-align: right;
+ margin-top: 0.25rem;
+}
+
+.tips {
+ background: #f0f9ff;
+ border-radius: 0.5rem;
+ padding: 1rem 1.25rem;
+}
+
+.tips h4 {
+ margin: 0 0 0.5rem;
+ color: #0369a1;
+ font-size: 0.9rem;
+}
+
+.tips ul {
+ margin: 0;
+ padding-left: 1.25rem;
+ color: #475569;
+ font-size: 0.9rem;
+}
+
+.tips li {
+ margin-bottom: 0.25rem;
+}
+
+/* Preview section */
+.preview-section {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.preview-header h1 {
+ margin: 0;
+ color: #1e293b;
+}
+
+.preview-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.preview-container {
+ background: white;
+ border-radius: 1rem;
+ overflow: hidden;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ margin-bottom: 1.5rem;
+}
+
+.preview-iframe {
+ width: 100%;
+ height: 60vh;
+ max-height: 500px;
+ border: none;
+ display: block;
+}
+
+.feedback-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.feedback-section h3 {
+ margin: 0 0 1rem;
+ color: #1e293b;
+}
+
+.feedback-section textarea {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ resize: vertical;
+ margin-bottom: 1rem;
+}
+
+.feedback-section textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+/* Success */
+.success-card {
+ text-align: center;
+}
+
+.success-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+}
+
+.success-card .note {
+ color: #64748b;
+ font-size: 0.95rem;
+}
+
+.success-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ margin-top: 1.5rem;
+ flex-wrap: wrap;
+}
+
+/* Edit page additions */
+.edit-title-section {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.edit-form-row {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-end;
+ margin-bottom: 1rem;
+ background: white;
+ padding: 1rem;
+ border-radius: 0.75rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.edit-form-row .form-group {
+ flex: 1;
+ margin: 0;
+}
+
+.rejection-notice {
+ background: #fef2f2;
+ color: #991b1b;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ border: 1px solid #fecaca;
+}
+
+.success-alert {
+ background: #d1fae5;
+ color: #065f46;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.feedback-hint {
+ color: #64748b;
+ font-size: 0.9rem;
+ margin: 0 0 1rem;
+}
+
+/* Game preview placeholder when no code exists */
+.preview-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+ padding: 3rem;
+ text-align: center;
+ background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
+ color: white;
+}
+
+.preview-placeholder .placeholder-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.preview-placeholder h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+}
+
+.preview-placeholder p {
+ margin: 0;
+ color: #94a3b8;
+ max-width: 400px;
+}
+
+.preview-placeholder .btn {
+ margin-top: 1.5rem;
+}
+
+.preview-placeholder.generating {
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
+}
+
+.preview-placeholder .loading-spinner {
+ width: 60px;
+ height: 60px;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 1.5rem;
+}
+
+.btn-large {
+ padding: 1rem 2rem;
+ font-size: 1.1rem;
+}
+
+/* Original Prompt Section */
+.original-prompt-section {
+ background: white;
+ border-radius: 0.75rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+ overflow: hidden;
+}
+
+.prompt-toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ background: none;
+ border: none;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #1e293b;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.2s;
+}
+
+.prompt-toggle:hover {
+ background: #f8fafc;
+}
+
+.toggle-icon {
+ color: #6366f1;
+ font-size: 0.8rem;
+}
+
+.style-badge {
+ margin-left: auto;
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
+ color: white;
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.prompt-content {
+ padding: 0 1.25rem 1.25rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.prompt-answers h4,
+.full-prompt h4 {
+ font-size: 0.9rem;
+ color: #64748b;
+ margin: 1rem 0 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.answers-list {
+ margin: 0;
+}
+
+.answer-item {
+ display: flex;
+ gap: 1rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.answer-item:last-child {
+ border-bottom: none;
+}
+
+.answer-item dt {
+ flex: 0 0 150px;
+ font-weight: 500;
+ color: #475569;
+}
+
+.answer-item dd {
+ flex: 1;
+ margin: 0;
+ color: #1e293b;
+}
+
+.prompt-text {
+ background: #1e293b;
+ color: #e2e8f0;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 300px;
+ overflow-y: auto;
+ margin: 0;
+}
+
+@media (max-width: 640px) {
+ .answer-item {
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .answer-item dt {
+ flex: none;
+ }
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.status-draft {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.status-pending_review {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.status-rejected {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.loading {
+ padding: 4rem;
+ text-align: center;
+ color: #64748b;
+}
+
+/* Loading animation card */
+.loading-card {
+ text-align: center;
+ padding: 3rem 2rem;
+}
+
+.loading-animation {
+ position: relative;
+ width: 120px;
+ height: 120px;
+ margin: 0 auto 2rem;
+}
+
+.loading-emoji {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 3rem;
+ animation: pulse 1s ease-in-out infinite;
+}
+
+.loading-spinner {
+ width: 100%;
+ height: 100%;
+ border: 4px solid #e2e8f0;
+ border-top-color: #6366f1;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes pulse {
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); }
+ 50% { transform: translate(-50%, -50%) scale(1.1); }
+}
+
+.loading-title {
+ font-size: 1.75rem;
+ color: #6366f1;
+ margin: 0 0 1rem;
+ animation: fadeIn 0.5s ease;
+}
+
+.loading-message {
+ font-size: 1.25rem;
+ color: #1e293b;
+ margin: 0 0 1.5rem;
+ min-height: 2rem;
+ animation: fadeIn 0.3s ease;
+}
+
+.loading-progress {
+ margin-bottom: 1.5rem;
+}
+
+.progress-dots {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #e2e8f0;
+ transition: all 0.3s ease;
+}
+
+.dot.active {
+ background: #6366f1;
+ transform: scale(1.2);
+}
+
+.loading-subtitle {
+ color: #64748b;
+ font-size: 0.95rem;
+ margin: 0;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Tokens used display */
+.tokens-info {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: #f0f9ff;
+ padding: 0.5rem 1rem;
+ border-radius: 2rem;
+ color: #0369a1;
+ font-size: 0.9rem;
+ margin-top: 1rem;
+}
+
+.tokens-info strong {
+ font-weight: 600;
+}
+
+@media (max-width: 640px) {
+ .create-page {
+ padding: 1rem;
+ }
+
+ .create-card {
+ padding: 1.5rem;
+ }
+
+ .preview-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .preview-actions {
+ flex-wrap: wrap;
+ }
+
+ .preview-actions .btn {
+ flex: 1;
+ text-align: center;
+ }
+
+ .edit-form-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .preview-iframe {
+ height: 50vh;
+ }
+}
diff --git a/frontend/src/pages/Create.jsx b/frontend/src/pages/Create.jsx
new file mode 100644
index 0000000..d1c4a54
--- /dev/null
+++ b/frontend/src/pages/Create.jsx
@@ -0,0 +1,321 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './Create.css';
+
+
+
+const LOADING_MESSAGES = [
+ { text: "Initializing AI game engine...", emoji: "🚀" },
+ { text: "Analyzing your awesome idea...", emoji: "💡" },
+ { text: "This is going to be SO COOL!", emoji: "🔥" },
+ { text: "Designing game mechanics...", emoji: "⚙️" },
+ { text: "Your friends will love this!", emoji: "🎮" },
+ { text: "Adding colorful graphics...", emoji: "🎨" },
+ { text: "Making it super fun to play...", emoji: "✨" },
+ { text: "Composing epic music...", emoji: "🎵" },
+ { text: "Almost there, this is exciting!", emoji: "🎉" },
+ { text: "Adding the finishing touches...", emoji: "💫" },
+ { text: "Your game is going to be AMAZING!", emoji: "🌟" },
+ { text: "Just a few more seconds...", emoji: "⏳" },
+];
+
+export default function Create() {
+ const navigate = useNavigate();
+ const { user, isAuthenticated } = useAuth();
+
+ const [step, setStep] = useState(1);
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [gameCode, setGameCode] = useState('');
+ const [gameId, setGameId] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [feedback, setFeedback] = useState('');
+ const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES[0]);
+ const [loadingIndex, setLoadingIndex] = useState(0);
+ const [tokensUsed, setTokensUsed] = useState(0);
+ const loadingIntervalRef = useRef(null);
+
+
+ // Cycle through loading messages
+ useEffect(() => {
+ if (loading) {
+ setLoadingIndex(0);
+ setLoadingMessage(LOADING_MESSAGES[0]);
+ loadingIntervalRef.current = setInterval(() => {
+ setLoadingIndex(prev => {
+ const next = (prev + 1) % LOADING_MESSAGES.length;
+ setLoadingMessage(LOADING_MESSAGES[next]);
+ return next;
+ });
+ }, 2500);
+ } else {
+ if (loadingIntervalRef.current) {
+ clearInterval(loadingIntervalRef.current);
+ }
+ }
+ return () => {
+ if (loadingIntervalRef.current) {
+ clearInterval(loadingIntervalRef.current);
+ }
+ };
+ }, [loading]);
+
+ if (!isAuthenticated || user?.isGuest) {
+ return (
+
+
+
Create a Game
+
You need an account to create games.
+
Create Account
+
+
+ );
+ }
+
+ const handleGenerate = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ setTokensUsed(0);
+
+ try {
+ const data = await api.createGame(description, title);
+ setGameCode(data.previewCode);
+ setGameId(data.game.id);
+ setTokensUsed(data.tokensUsed || 0);
+ setStep(2);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRegenerate = async () => {
+ if (!feedback.trim()) {
+ setError('Please describe what you want to change');
+ return;
+ }
+
+ if (!gameId) {
+ setError('Game ID not found. Please try creating a new game.');
+ return;
+ }
+
+ setError('');
+ setLoading(true);
+
+ try {
+ const data = await api.updateGame(gameId, { feedback });
+ if (data.game?.gameCode) {
+ setGameCode(data.game.gameCode);
+ setFeedback('');
+ if (data.tokensUsed) {
+ setTokensUsed(prev => prev + data.tokensUsed);
+ }
+ } else {
+ setError('Failed to get updated game code');
+ }
+ } catch (err) {
+ // Handle HTML error responses
+ if (err.message?.includes('Unexpected token')) {
+ setError('Server error. Please try again or create a new game.');
+ } else {
+ setError(err.message || 'Failed to update game');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSubmit = async () => {
+ setLoading(true);
+ try {
+ await api.submitGame(gameId);
+ setStep(3);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {step === 1 && !loading && (
+
+
Create a Game with AI
+
+ Describe your game idea and our AI will create it for you!
+
+
+
+ {error && {error}
}
+
+
+ Game Title
+ setTitle(e.target.value)}
+ placeholder="My Awesome Game"
+ maxLength={100}
+ />
+
+
+
+ Describe Your Game Idea
+ setDescription(e.target.value)}
+ placeholder="A fun game where you catch falling stars while avoiding meteors. The player moves left and right with arrow keys. Each star is worth 10 points..."
+ rows={8}
+ required
+ minLength={10}
+ maxLength={1000}
+ />
+ {description.length}/1000
+
+
+
+
Tips for great games:
+
+ Be specific about game mechanics
+ Describe the controls (keyboard, mouse, touch)
+ Mention scoring system
+ Keep it simple and fun!
+
+
+
+
+ ✨ Generate Game
+
+
+
+ )}
+
+ {step === 1 && loading && (
+
+
+
{loadingMessage.emoji}
+
+
+
Creating Your Game!
+
{loadingMessage.text}
+
+
+ {LOADING_MESSAGES.slice(0, 6).map((_, i) => (
+
+ ))}
+
+
+
Our AI is building something special just for you...
+
+ )}
+
+ {step === 2 && !loading && (
+
+
+
+
{title || 'Your Game'}
+ {tokensUsed > 0 && (
+
+ 🤖 {tokensUsed.toLocaleString()} AI tokens used to create this game!
+
+ )}
+
+
+ setStep(1)}
+ className="btn btn-secondary"
+ >
+ Start Over
+
+
+ Submit for Review
+
+
+
+
+
+ {gameCode && (
+
+ )}
+
+
+
+
Want changes?
+ {error &&
{error}
}
+
setFeedback(e.target.value)}
+ placeholder="Describe what you'd like to change... (e.g., 'make the player faster' or 'add more enemies')"
+ rows={3}
+ />
+
+ Update Game
+
+
+
+ )}
+
+ {step === 2 && loading && (
+
+
+
{loadingMessage.emoji}
+
+
+
Updating Your Game!
+
{loadingMessage.text}
+
+
+ {LOADING_MESSAGES.slice(0, 6).map((_, i) => (
+
+ ))}
+
+
+
Making your game even better...
+
+ )}
+
+ {step === 3 && (
+
+
🎉
+
Game Submitted!
+
Your game has been submitted for review. We'll check it soon!
+
Once approved, it will appear in the arcade and you'll earn tokens when others play.
+
+ navigate('/dashboard')} className="btn btn-primary">
+ Go to Dashboard
+
+ { setStep(1); setGameCode(''); setGameId(null); }} className="btn btn-secondary">
+ Create Another Game
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/CreateWizard.css b/frontend/src/pages/CreateWizard.css
new file mode 100644
index 0000000..a467d82
--- /dev/null
+++ b/frontend/src/pages/CreateWizard.css
@@ -0,0 +1,1366 @@
+.wizard-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: linear-gradient(135deg, #f8fafc 0%, #e0e7ff 100%);
+}
+
+/* Progress indicator */
+.wizard-progress {
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ margin-bottom: 2rem;
+ padding: 0 1rem;
+ flex-wrap: wrap;
+}
+
+.progress-step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ opacity: 0.5;
+ transition: all 0.3s;
+}
+
+.progress-step.active,
+.progress-step.completed {
+ opacity: 1;
+}
+
+.step-number {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: #e2e8f0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 0.9rem;
+ transition: all 0.3s;
+}
+
+.progress-step.active .step-number {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+}
+
+.progress-step.completed .step-number {
+ background: #10b981;
+ color: white;
+}
+
+.step-label {
+ font-size: 0.8rem;
+ color: #64748b;
+}
+
+/* Wizard sections */
+.wizard-section {
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.wizard-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.back-btn {
+ background: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 0.5rem;
+ color: #6366f1;
+ cursor: pointer;
+ font-weight: 500;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+ transition: all 0.2s;
+}
+
+.back-btn:hover {
+ background: #f1f5f9;
+}
+
+.wizard-section h1 {
+ margin: 0;
+ color: #1e293b;
+ font-size: 1.75rem;
+}
+
+.wizard-subtitle {
+ color: #64748b;
+ margin: 0.5rem 0 0;
+}
+
+/* Style selection */
+.style-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+.style-card {
+ background: white;
+ border: 2px solid transparent;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.style-card:hover {
+ transform: translateY(-4px);
+ border-color: #6366f1;
+ box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15);
+}
+
+.style-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.style-card h3 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.style-card p {
+ margin: 0 0 0.75rem;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.style-examples {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: center;
+}
+
+.example-tag {
+ background: #f1f5f9;
+ padding: 0.25rem 0.5rem;
+ border-radius: 1rem;
+ font-size: 0.75rem;
+ color: #475569;
+}
+
+/* Questions */
+.questions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+/* Prefilled banner */
+.prefilled-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
+ border: 2px solid #6366f1;
+ border-radius: 12px;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+ color: #4338ca;
+ flex-wrap: wrap;
+}
+
+.prefilled-icon {
+ font-size: 1.25rem;
+}
+
+.randomize-btn {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border: none;
+ padding: 0.4rem 0.75rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ margin-left: auto;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.randomize-btn:hover {
+ transform: scale(1.05);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
+}
+
+.randomize-btn:active {
+ transform: scale(0.98);
+}
+
+/* AI Refine Banner */
+.ai-refine-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
+ border: 2px solid #10b981;
+ border-radius: 12px;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+ color: #065f46;
+ flex-wrap: wrap;
+}
+
+.ai-refine-icon {
+ font-size: 1.25rem;
+}
+
+.ai-refine-btn {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.free-badge-sm {
+ background: #22c55e;
+ color: #fff;
+ font-size: 0.65rem;
+ padding: 0.1rem 0.35rem;
+ border-radius: 3px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+}
+
+/* Title card */
+.title-card {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border: 2px solid #f59e0b;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ margin-bottom: 1rem;
+}
+
+.title-card label {
+ display: block;
+ font-weight: 600;
+ color: #92400e;
+ margin-bottom: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.title-input-large {
+ width: 100%;
+ padding: 1rem;
+ border: 2px solid #f59e0b;
+ border-radius: 10px;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #1e293b;
+ background: white;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.title-input-large:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
+}
+
+.title-hint {
+ display: block;
+ margin-top: 0.5rem;
+ font-size: 0.8rem;
+ color: #78350f;
+}
+
+.question-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.question-card h3 {
+ margin: 0 0 1rem;
+ color: #1e293b;
+ font-size: 1.1rem;
+}
+
+.question-input input {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ transition: border-color 0.2s;
+}
+
+.question-input input:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.question-textarea textarea {
+ width: 100%;
+ padding: 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ font-family: inherit;
+ resize: vertical;
+ min-height: 200px;
+ transition: border-color 0.2s;
+ line-height: 1.5;
+}
+
+.question-textarea textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.question-textarea textarea::placeholder {
+ color: #94a3b8;
+ white-space: pre-wrap;
+}
+
+.char-count {
+ text-align: right;
+ font-size: 0.85rem;
+ color: #64748b;
+ margin-top: 0.5rem;
+}
+
+.suggestions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.suggestion-chip {
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ padding: 0.375rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.85rem;
+ color: #0369a1;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.suggestion-chip:hover {
+ background: #e0f2fe;
+ border-color: #7dd3fc;
+}
+
+.question-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.option-btn {
+ background: #f8fafc;
+ border: 2px solid #e2e8f0;
+ padding: 0.75rem 1.25rem;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ font-size: 0.95rem;
+ transition: all 0.2s;
+}
+
+.option-btn:hover {
+ border-color: #6366f1;
+ background: #faf5ff;
+}
+
+.option-btn.selected {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border-color: transparent;
+}
+
+.multiselect .option-btn.selected {
+ background: #10b981;
+}
+
+/* Options with icons */
+.option-btn.has-icon {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.option-icon {
+ font-size: 1.25rem;
+}
+
+.option-label {
+ flex: 1;
+}
+
+.title-input {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+}
+
+.title-input:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+/* Wizard actions */
+.wizard-actions {
+ margin-top: 2rem;
+ display: flex;
+ justify-content: center;
+}
+
+/* Review */
+.review-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 2rem;
+ text-align: center;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+}
+
+.review-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+}
+
+.review-card h2 {
+ margin: 0 0 1rem;
+ color: #1e293b;
+}
+
+.review-summary {
+ color: #475569;
+ font-size: 1.1rem;
+ margin: 0 0 1.5rem;
+ line-height: 1.6;
+}
+
+.review-details {
+ background: #f0fdf4;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ text-align: left;
+}
+
+.review-details h4 {
+ margin: 0 0 0.5rem;
+ color: #166534;
+}
+
+.prompt-learning {
+ color: #15803d;
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+.generate-btn {
+ font-size: 1.25rem;
+ padding: 1rem 2rem;
+}
+
+/* Loading card */
+.wizard-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 2.5rem 2rem;
+ max-width: 500px;
+ margin: 0 auto;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+}
+
+.wizard-card.centered {
+ text-align: center;
+}
+
+.loading-card {
+ text-align: center;
+}
+
+.loading-animation {
+ position: relative;
+ width: 120px;
+ height: 120px;
+ margin: 0 auto 2rem;
+}
+
+.loading-emoji {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 3rem;
+ animation: pulse 1s ease-in-out infinite;
+}
+
+.loading-spinner {
+ width: 100%;
+ height: 100%;
+ border: 4px solid #e2e8f0;
+ border-top-color: #6366f1;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes pulse {
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); }
+ 50% { transform: translate(-50%, -50%) scale(1.1); }
+}
+
+.loading-title {
+ font-size: 1.75rem;
+ color: #6366f1;
+ margin: 0 0 1rem;
+}
+
+.loading-message {
+ font-size: 1.25rem;
+ color: #1e293b;
+ margin: 0 0 1.5rem;
+ min-height: 2rem;
+}
+
+.loading-progress {
+ margin-bottom: 1.5rem;
+}
+
+.progress-dots {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #e2e8f0;
+ transition: all 0.3s ease;
+}
+
+.dot.active {
+ background: #6366f1;
+ transform: scale(1.2);
+}
+
+.loading-subtitle {
+ color: #64748b;
+ font-size: 0.95rem;
+ margin: 0;
+}
+
+/* Testing */
+.testing-section {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.testing-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ background: white;
+ padding: 1.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.testing-info h1 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+}
+
+.testing-info p {
+ margin: 0;
+ color: #64748b;
+}
+
+.testing-stats {
+ display: flex;
+ gap: 1.5rem;
+ margin-top: 1rem;
+ flex-wrap: wrap;
+}
+
+.test-timer {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: #fef3c7;
+ padding: 0.5rem 1rem;
+ border-radius: 2rem;
+}
+
+.timer-icon {
+ font-size: 1.25rem;
+}
+
+.timer-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #92400e;
+}
+
+.timer-label {
+ color: #b45309;
+ font-size: 0.85rem;
+}
+
+.tokens-used {
+ background: #f0f9ff;
+ padding: 0.5rem 1rem;
+ border-radius: 2rem;
+ color: #0369a1;
+ font-size: 0.9rem;
+}
+
+.testing-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.game-preview {
+ background: #1e293b;
+ border-radius: 1rem;
+ overflow: hidden;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ min-height: 400px;
+}
+
+.preview-iframe {
+ width: 100%;
+ height: 65vh;
+ min-height: 400px;
+ max-height: 600px;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ display: block;
+ background: white;
+}
+
+.game-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 400px;
+ color: white;
+}
+
+.game-loading .loading-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+ animation: pulse 1s ease-in-out infinite;
+}
+
+.game-loading p {
+ font-size: 1.1rem;
+ color: #94a3b8;
+}
+
+/* Refine */
+.refine-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.refine-card textarea {
+ width: 100%;
+ padding: 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ resize: vertical;
+ margin-bottom: 1rem;
+}
+
+.refine-card textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.refine-tips {
+ background: #f0f9ff;
+ padding: 1rem;
+ border-radius: 0.5rem;
+}
+
+.refine-tips h4 {
+ margin: 0 0 0.25rem;
+ color: #0369a1;
+}
+
+.refine-tips p {
+ margin: 0;
+ color: #0284c7;
+ font-size: 0.9rem;
+}
+
+/* Publish */
+.publish-form {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.form-group input,
+.form-group textarea {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+}
+
+.form-group input:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+}
+
+.form-hint {
+ font-size: 0.85rem;
+ color: #64748b;
+ margin: 0 0 0.75rem;
+}
+
+/* Difficulty selector */
+.difficulty-selector {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.difficulty-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.25rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 0.75rem;
+ background: white;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 0.95rem;
+}
+
+.difficulty-option:hover {
+ border-color: #cbd5e1;
+}
+
+.difficulty-option.selected {
+ border-color: #6366f1;
+ background: #eef2ff;
+}
+
+.difficulty-option.difficulty-easy.selected {
+ border-color: #10b981;
+ background: #d1fae5;
+}
+
+.difficulty-option.difficulty-medium.selected {
+ border-color: #f59e0b;
+ background: #fef3c7;
+}
+
+.difficulty-option.difficulty-hard.selected {
+ border-color: #ef4444;
+ background: #fee2e2;
+}
+
+.difficulty-icon {
+ font-size: 1.25rem;
+}
+
+.difficulty-label {
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.publish-preview {
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1rem;
+}
+
+.publish-preview h4 {
+ margin: 0 0 0.75rem;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.preview-card-mini {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+}
+
+.preview-icon {
+ font-size: 2.5rem;
+}
+
+.preview-card-mini strong {
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.preview-card-mini p {
+ margin: 0;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.publish-btn {
+ font-size: 1.1rem;
+}
+
+/* Success */
+.success-card {
+ text-align: center;
+ max-width: 550px;
+}
+
+.success-confetti {
+ font-size: 5rem;
+ margin-bottom: 1rem;
+ animation: bounce 0.6s ease infinite;
+}
+
+@keyframes bounce {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-10px); }
+}
+
+.success-card h1 {
+ margin: 0 0 0.5rem;
+ color: #10b981;
+}
+
+.success-message {
+ font-size: 1.1rem;
+ color: #475569;
+ margin: 0 0 1.5rem;
+}
+
+.xp-reward {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ padding: 0.75rem 1.5rem;
+ border-radius: 2rem;
+ margin-bottom: 1.5rem;
+ font-weight: 600;
+ color: #92400e;
+}
+
+.xp-icon {
+ font-size: 1.25rem;
+}
+
+.level-up {
+ background: #10b981;
+ color: white;
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.85rem;
+ margin-left: 0.5rem;
+}
+
+.achievements-earned {
+ margin-bottom: 1.5rem;
+}
+
+.achievements-earned h3 {
+ margin: 0 0 0.75rem;
+ color: #1e293b;
+}
+
+.achievement-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ justify-content: center;
+}
+
+.achievement-badge {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: #faf5ff;
+ border: 1px solid #e9d5ff;
+ padding: 0.5rem 1rem;
+ border-radius: 2rem;
+}
+
+.achievement-icon {
+ font-size: 1.25rem;
+}
+
+.achievement-name {
+ font-weight: 500;
+ color: #7c3aed;
+}
+
+.success-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+/* Saved indicator */
+.saved-indicator {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ background: #d1fae5;
+ border: 1px solid #6ee7b7;
+ color: #065f46;
+ padding: 0.875rem 1.25rem;
+ border-radius: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.saved-icon {
+ background: #10b981;
+ color: white;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ flex-shrink: 0;
+}
+
+/* Retry notice */
+.retry-notice {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ background: #fef3c7;
+ border: 1px solid #fcd34d;
+ color: #92400e;
+ padding: 1rem 1.25rem;
+ border-radius: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.retry-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.retry-notice strong {
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.retry-notice p {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+/* Auto-save note */
+.auto-save-note {
+ margin-top: 1rem;
+ padding: 0.75rem;
+ background: #f0fdf4;
+ border-radius: 0.5rem;
+ color: #166534;
+ font-size: 0.85rem;
+ text-align: center;
+}
+
+/* Error */
+.error-alert {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ text-align: center;
+ max-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* Mobile responsive */
+@media (max-width: 640px) {
+ .wizard-page {
+ padding: 1rem;
+ }
+
+ .wizard-progress {
+ gap: 0.5rem;
+ }
+
+ .step-label {
+ display: none;
+ }
+
+ .wizard-header {
+ flex-direction: column;
+ }
+
+ .style-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .testing-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .testing-actions {
+ flex-direction: column;
+ }
+
+ .preview-iframe {
+ height: 50vh;
+ }
+
+ .success-actions {
+ flex-direction: column;
+ }
+
+ .success-actions .btn {
+ width: 100%;
+ }
+}
+
+/* ==========================================
+ PROMPT REVIEW SECTION
+ ========================================== */
+
+.prompt-review-section {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+/* View Mode Toggle */
+.prompt-view-toggle {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+
+.toggle-btn {
+ padding: 0.6rem 1rem;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ background: white;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ min-height: 44px;
+}
+
+.toggle-btn:hover {
+ border-color: #6366f1;
+}
+
+.toggle-btn.active {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ border-color: transparent;
+}
+
+.tips-toggle {
+ margin-left: auto;
+}
+
+/* Tips Panel */
+.prompt-tips-panel {
+ background: linear-gradient(135deg, #f0edff 0%, #e8f4fd 100%);
+ border-radius: 12px;
+ padding: 1.25rem;
+ margin-bottom: 1rem;
+ border: 1px solid #c7d2fe;
+}
+
+.prompt-tips-panel h3 {
+ margin: 0 0 1rem;
+ font-size: 1rem;
+ color: #4338ca;
+}
+
+.tips-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.tip {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.tip-icon {
+ flex-shrink: 0;
+}
+
+.tip-do {
+ color: #166534;
+}
+
+.tip-dont {
+ color: #991b1b;
+}
+
+/* Prompt Editor Container */
+.prompt-editor-container {
+ margin-bottom: 0.75rem;
+}
+
+/* Highlighted Prompt View */
+.prompt-highlighted {
+ background: #1a1a2e;
+ border-radius: 12px;
+ padding: 1.25rem;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 0.85rem;
+ line-height: 1.6;
+ max-height: 50vh;
+ overflow-y: auto;
+ border: 2px solid #312e81;
+}
+
+.prompt-line {
+ color: #94a3b8;
+ padding: 2px 0;
+}
+
+.prompt-line.user-content {
+ color: #a5b4fc;
+ background: rgba(99, 102, 241, 0.15);
+ padding: 2px 6px;
+ margin: 0 -6px;
+ border-radius: 4px;
+}
+
+.prompt-line.header-line {
+ color: #f0abfc;
+ font-weight: 600;
+ margin-top: 0.5rem;
+}
+
+.prompt-line.bullet-line {
+ color: #cbd5e1;
+ padding-left: 1rem;
+}
+
+.prompt-line.important-line {
+ color: #fbbf24;
+ font-weight: 600;
+}
+
+/* Edit Mode Textarea */
+.prompt-textarea {
+ width: 100%;
+ min-height: 350px;
+ padding: 1.25rem;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 0.85rem;
+ line-height: 1.6;
+ border: 2px solid #6366f1;
+ border-radius: 12px;
+ background: #fafaff;
+ resize: vertical;
+ color: #1e1b4b;
+}
+
+.prompt-textarea:focus {
+ outline: none;
+ border-color: #8b5cf6;
+ box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.15);
+}
+
+/* Prompt Stats */
+.prompt-stats {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.85rem;
+ color: #64748b;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.edited-badge {
+ color: #6366f1;
+ font-weight: 500;
+}
+
+/* Action Buttons */
+.prompt-actions {
+ display: flex;
+ gap: 0.75rem;
+ position: sticky;
+ bottom: 0;
+ background: linear-gradient(to top, #f8fafc 60%, transparent);
+ padding: 1rem 0;
+ margin: 0 -1rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.prompt-actions .btn {
+ min-height: 48px;
+}
+
+.prompt-actions .btn-secondary {
+ flex: 0 0 auto;
+}
+
+.prompt-actions .generate-btn {
+ flex: 1;
+ font-size: 1.1rem;
+}
+
+/* Complexity Warning */
+.complexity-warning {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border: 2px solid #f59e0b;
+ border-radius: 12px;
+ padding: 1rem 1.25rem;
+ margin-bottom: 1rem;
+}
+
+.complexity-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.complexity-icon {
+ font-size: 1.2rem;
+}
+
+.complexity-header strong {
+ color: #92400e;
+ font-size: 1rem;
+}
+
+.complexity-score {
+ background: #f59e0b;
+ color: white;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-left: auto;
+}
+
+.complexity-warning p {
+ color: #78350f;
+ font-size: 0.9rem;
+ margin: 0.5rem 0;
+}
+
+.complexity-warning ul {
+ margin: 0.5rem 0;
+ padding-left: 1.25rem;
+}
+
+.complexity-warning li {
+ color: #92400e;
+ font-size: 0.85rem;
+ margin-bottom: 0.25rem;
+}
+
+.complexity-tip {
+ background: rgba(255, 255, 255, 0.5);
+ padding: 0.5rem 0.75rem;
+ border-radius: 8px;
+ margin-top: 0.75rem !important;
+}
+
+.complexity-tip strong {
+ color: #78350f;
+}
+
+/* Mobile adjustments for prompt review */
+@media (max-width: 640px) {
+ .prompt-view-toggle {
+ justify-content: center;
+ }
+
+ .tips-toggle {
+ margin-left: 0;
+ width: 100%;
+ order: -1;
+ }
+
+ .prompt-highlighted {
+ font-size: 0.8rem;
+ padding: 1rem;
+ max-height: 40vh;
+ }
+
+ .prompt-textarea {
+ min-height: 250px;
+ font-size: 0.8rem;
+ }
+
+ .prompt-actions {
+ flex-wrap: wrap;
+ }
+
+ .prompt-actions .btn-secondary {
+ flex: 1;
+ min-width: calc(50% - 0.5rem);
+ }
+
+ .prompt-actions .generate-btn {
+ width: 100%;
+ order: -1;
+ margin-bottom: 0.5rem;
+ }
+}
diff --git a/frontend/src/pages/CreateWizard.jsx b/frontend/src/pages/CreateWizard.jsx
new file mode 100644
index 0000000..71c64fe
--- /dev/null
+++ b/frontend/src/pages/CreateWizard.jsx
@@ -0,0 +1,1320 @@
+import { useState, useEffect, useRef } from 'react';
+
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import useThumbnailRecorder from '../hooks/useThumbnailRecorder';
+import ThemeBanner from '../components/ThemeBanner';
+import PromptRefiner from '../components/PromptRefiner';
+import './CreateWizard.css';
+
+
+const LOADING_MESSAGES = [
+ { text: "Initializing AI game engine...", emoji: "🚀" },
+ { text: "Analyzing your awesome idea...", emoji: "💡" },
+ { text: "This is going to be SO COOL!", emoji: "🔥" },
+ { text: "Designing game mechanics...", emoji: "⚙️" },
+ { text: "Your friends will love this!", emoji: "🎮" },
+ { text: "Adding colorful graphics...", emoji: "🎨" },
+ { text: "Making it super fun to play...", emoji: "✨" },
+ { text: "Composing epic music...", emoji: "🎵" },
+ { text: "Almost there, this is exciting!", emoji: "🎉" },
+ { text: "Adding the finishing touches...", emoji: "💫" },
+ { text: "Your game is going to be AMAZING!", emoji: "🌟" },
+ { text: "Just a few more seconds...", emoji: "⏳" },
+];
+
+const WIZARD_STEPS = {
+ STYLE: 'style',
+ AI_REFINE: 'ai_refine',
+ TEXT_INPUTS: 'text_inputs',
+ FINAL_TOUCHES: 'final_touches',
+ REVIEW: 'review',
+ GENERATING: 'generating',
+ TESTING: 'testing',
+ REFINE: 'refine',
+ PUBLISH: 'publish',
+ SUCCESS: 'success'
+};
+
+export default function CreateWizard() {
+ const navigate = useNavigate();
+ const { user, isAuthenticated, refreshUser } = useAuth();
+
+ // Wizard state
+ const [step, setStep] = useState(WIZARD_STEPS.STYLE);
+ const [styles, setStyles] = useState([]);
+ const [selectedStyle, setSelectedStyle] = useState(null);
+ const [questions, setQuestions] = useState(null);
+ const [answers, setAnswers] = useState({});
+ const [title, setTitle] = useState('');
+
+ // Game state
+ const [gameId, setGameId] = useState(null);
+ const [gameCode, setGameCode] = useState('');
+ const [summary, setSummary] = useState('');
+ const [tokensUsed, setTokensUsed] = useState(0);
+ const [totalCost, setTotalCost] = useState(0); // Cost in cents
+ const [draftSaved, setDraftSaved] = useState(false);
+ const [generationFailed, setGenerationFailed] = useState(false);
+
+ // Prompt review state
+ const [originalPrompt, setOriginalPrompt] = useState('');
+ const [editablePrompt, setEditablePrompt] = useState('');
+ const [promptViewMode, setPromptViewMode] = useState('highlighted'); // 'highlighted' or 'edit'
+ const [showPromptTips, setShowPromptTips] = useState(false);
+ const [promptAnswersForHighlight, setPromptAnswersForHighlight] = useState({});
+
+ // Complexity state
+ const [complexity, setComplexity] = useState(1);
+ const [complexityWarnings, setComplexityWarnings] = useState([]);
+ const [isHighComplexity, setIsHighComplexity] = useState(false);
+
+ // Testing state
+ const [testingStartTime, setTestingStartTime] = useState(null);
+ const [totalTestTime, setTotalTestTime] = useState(0);
+ const [canPublish, setCanPublish] = useState(false);
+
+ // Refine state
+ const [feedback, setFeedback] = useState('');
+
+ // Publish state
+ const [publishData, setPublishData] = useState({
+ title: '',
+ description: '',
+ difficultyTags: [],
+ themeTags: [],
+ accessibilityFeatures: []
+ });
+ const [publishResult, setPublishResult] = useState(null);
+
+ // SSE streaming state
+ const [streamPhase, setStreamPhase] = useState('');
+ const [streamMessage, setStreamMessage] = useState('');
+ const [streamProgress, setStreamProgress] = useState(0);
+ const [streamSummary, setStreamSummary] = useState('');
+ const eventSourceRef = useRef(null);
+
+ // UI state
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES[0]);
+ const loadingIntervalRef = useRef(null);
+ const testingIntervalRef = useRef(null);
+
+ // Thumbnail recording
+ const iframeRef = useRef(null);
+ const { isRecording, thumbnailData, startRecording } = useThumbnailRecorder();
+ const [thumbnailCaptured, setThumbnailCaptured] = useState(false);
+
+
+ // Load styles on mount
+ useEffect(() => {
+ loadStyles();
+ }, []);
+
+ // Loading message cycle
+ useEffect(() => {
+ if (loading && step === WIZARD_STEPS.GENERATING) {
+ let index = 0;
+ loadingIntervalRef.current = setInterval(() => {
+ index = (index + 1) % LOADING_MESSAGES.length;
+ setLoadingMessage(LOADING_MESSAGES[index]);
+ }, 2500);
+ } else {
+ if (loadingIntervalRef.current) {
+ clearInterval(loadingIntervalRef.current);
+ }
+ }
+ return () => {
+ if (loadingIntervalRef.current) {
+ clearInterval(loadingIntervalRef.current);
+ }
+ };
+ }, [loading, step]);
+
+ // Testing timer
+ useEffect(() => {
+ if (step === WIZARD_STEPS.TESTING && testingStartTime) {
+ testingIntervalRef.current = setInterval(() => {
+ const elapsed = Math.floor((Date.now() - testingStartTime) / 1000);
+ setTotalTestTime(prev => Math.max(prev, elapsed));
+ }, 1000);
+ }
+ return () => {
+ if (testingIntervalRef.current) {
+ clearInterval(testingIntervalRef.current);
+ }
+ };
+ }, [step, testingStartTime]);
+
+ // Thumbnail recording - capture after game loads
+ useEffect(() => {
+ if (step === WIZARD_STEPS.TESTING && gameCode && iframeRef.current && !thumbnailCaptured && !isRecording) {
+ // Wait for game to load and start playing, then record
+ const captureTimer = setTimeout(() => {
+ if (iframeRef.current) {
+ startRecording(iframeRef.current, 3000)
+ .then(() => {
+ setThumbnailCaptured(true);
+ })
+ .catch(() => {
+ // Silently fail - thumbnail is optional
+ });
+ }
+ }, 2000); // Wait 2s for game to load and start
+
+ return () => clearTimeout(captureTimer);
+ }
+ }, [step, gameCode, thumbnailCaptured, isRecording, startRecording]);
+
+ // Randomization helpers
+ function shuffleArray(array) {
+ const shuffled = [...array];
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ return shuffled;
+ }
+
+ function pickRandom(array) {
+ return array[Math.floor(Math.random() * array.length)];
+ }
+
+ // Generate a creative title from answers
+ function generateRandomTitle(styleName, randomAnswers) {
+ const adjectives = ['Epic', 'Super', 'Mega', 'Ultra', 'Cosmic', 'Turbo', 'Hyper', 'Rad', 'Awesome', 'Wild'];
+ const adj = pickRandom(adjectives);
+
+ // Try to extract a theme from answers
+ const theme = randomAnswers.theme || randomAnswers.player || '';
+ const themeWord = theme.split(' ').slice(0, 2).join(' ');
+
+ if (themeWord) {
+ return `${adj} ${themeWord.charAt(0).toUpperCase() + themeWord.slice(1)} ${styleName}`;
+ }
+ return `${adj} ${styleName} Adventure`;
+ }
+
+ // Pre-populate answers with random selections
+ function generateRandomAnswers(questionsData) {
+ const randomAnswers = {};
+
+ // Process common questions
+ if (questionsData.common) {
+ for (const q of questionsData.common) {
+ if (q.suggestions && q.suggestions.length > 0) {
+ randomAnswers[q.id] = pickRandom(q.suggestions);
+ }
+ }
+ }
+
+ // Process style-specific questions
+ if (questionsData.styleSpecific) {
+ for (const q of questionsData.styleSpecific) {
+ if (q.type === 'select' && q.options) {
+ // Pick first option (usually the simplest/default)
+ randomAnswers[q.id] = q.options[0].value;
+ } else if (q.suggestions && q.suggestions.length > 0) {
+ randomAnswers[q.id] = pickRandom(q.suggestions);
+ }
+ }
+ }
+
+ // Process final questions - randomize music, pick easy difficulty
+ if (questionsData.final) {
+ for (const q of questionsData.final) {
+ if (q.id === 'difficulty') {
+ randomAnswers[q.id] = 'easy-medium'; // Default to easy-medium
+ } else if (q.id === 'avatar') {
+ randomAnswers[q.id] = 'yes'; // Default to avatar customization
+ } else if (q.id === 'musicStyle' && q.options) {
+ // Random music (exclude 'none')
+ const musicOptions = q.options.filter(o => o.value !== 'none');
+ randomAnswers[q.id] = pickRandom(musicOptions).value;
+ }
+ // Skip multiselect features by default
+ }
+ }
+
+ return randomAnswers;
+ }
+
+ // Randomize the order of suggestions in questions
+ function randomizeSuggestions(questionsData) {
+ const randomized = { ...questionsData };
+
+ if (randomized.common) {
+ randomized.common = randomized.common.map(q => ({
+ ...q,
+ suggestions: q.suggestions ? shuffleArray(q.suggestions) : undefined
+ }));
+ }
+
+ if (randomized.styleSpecific) {
+ randomized.styleSpecific = randomized.styleSpecific.map(q => ({
+ ...q,
+ suggestions: q.suggestions ? shuffleArray(q.suggestions) : undefined
+ }));
+ }
+
+ return randomized;
+ }
+
+ async function loadStyles() {
+ try {
+ const data = await api.getGameStyles();
+ setStyles(data.styles);
+ } catch (err) {
+ setError('Failed to load game styles');
+ }
+ }
+
+ async function handleStyleSelect(style) {
+ setSelectedStyle(style);
+ setError('');
+ try {
+ const data = await api.getStyleQuestions(style.id);
+
+ // Randomize suggestion order
+ const randomizedQuestions = randomizeSuggestions(data);
+ setQuestions(randomizedQuestions);
+
+ // Pre-populate with random answers (unless freeform)
+ if (!data.skipCommonQuestions) {
+ const randomAnswers = generateRandomAnswers(randomizedQuestions);
+ setAnswers(randomAnswers);
+
+ // Generate a suggested title
+ const suggestedTitle = generateRandomTitle(style.name, randomAnswers);
+ setTitle(suggestedTitle);
+ }
+
+ // Always go to TEXT_INPUTS (including freeform)
+ setStep(WIZARD_STEPS.TEXT_INPUTS);
+ } catch (err) {
+ setError('Failed to load questions');
+ }
+ }
+
+ function handleAiRefineComplete(refinedSpec) {
+ // Use the AI-refined spec as a freeform description
+ setAnswers(prev => ({ ...prev, fullDescription: refinedSpec }));
+ // Create game with the refined spec
+ handleStartCreationWithSpec(refinedSpec);
+ }
+
+ async function handleStartCreationWithSpec(spec) {
+ setLoading(true);
+ setError('');
+ try {
+ const creationAnswers = { ...answers, fullDescription: spec };
+ const data = await api.startCreation(selectedStyle.id, title || `${selectedStyle.name} Game`, creationAnswers);
+ setGameId(data.game.id);
+ setSummary(data.summary);
+ setDraftSaved(true);
+ setOriginalPrompt(data.prompt);
+ setEditablePrompt(data.prompt);
+ setPromptAnswersForHighlight(data.promptAnswers || creationAnswers);
+ setComplexity(data.complexity || 1);
+ setComplexityWarnings(data.complexityWarnings || []);
+ setIsHighComplexity(data.isHighComplexity || false);
+
+ // Update prompt_refinement_source
+ await api.updateGame(data.game.id, { prompt_refinement_source: 'ai_chat' }).catch(() => {});
+
+ setStep(WIZARD_STEPS.REVIEW);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function handleAnswerChange(questionId, value) {
+ setAnswers(prev => ({ ...prev, [questionId]: value }));
+ }
+
+ function handleMultiSelectToggle(questionId, value) {
+ setAnswers(prev => {
+ const current = prev[questionId] || [];
+ if (current.includes(value)) {
+ return { ...prev, [questionId]: current.filter(v => v !== value) };
+ } else {
+ return { ...prev, [questionId]: [...current, value] };
+ }
+ });
+ }
+
+ // Render prompt with user answers highlighted
+ function renderHighlightedPrompt(prompt, userAnswers) {
+ if (!prompt) return null;
+
+ // Get all user-provided values to highlight
+ const valuesToHighlight = [];
+ Object.values(userAnswers || {}).forEach(val => {
+ if (Array.isArray(val)) {
+ val.forEach(v => v && valuesToHighlight.push(String(v)));
+ } else if (val && typeof val === 'string' && val.length > 2) {
+ valuesToHighlight.push(val);
+ }
+ });
+
+ // Sort by length (longest first) to avoid partial matches
+ valuesToHighlight.sort((a, b) => b.length - a.length);
+
+ // Split prompt into lines for display
+ const lines = prompt.split('\n');
+
+ return lines.map((line, lineIndex) => {
+ // Check if this line contains user values
+ let highlightedLine = line;
+ let hasHighlight = false;
+
+ valuesToHighlight.forEach(value => {
+ if (line.toLowerCase().includes(value.toLowerCase())) {
+ hasHighlight = true;
+ }
+ });
+
+ // Determine line type for styling
+ const isHeader = line.includes(':') && line.indexOf(':') < 30 && !line.startsWith('-');
+ const isBullet = line.trim().startsWith('-');
+ const isImportant = line.includes('IMPORTANT') || line.includes('REQUIRED');
+
+ return (
+
+ {line || '\u00A0'}
+
+ );
+ });
+ }
+
+ function renderQuestion(question) {
+ if (question.type === 'select') {
+ return (
+
+ {question.options.map(opt => (
+ handleAnswerChange(question.id, opt.value)}
+ >
+ {opt.icon && {opt.icon} }
+ {opt.label}
+
+ ))}
+
+ );
+ }
+
+ if (question.type === 'multiselect') {
+ const selected = answers[question.id] || [];
+ return (
+
+ {question.options.map(opt => (
+ handleMultiSelectToggle(question.id, opt.value)}
+ >
+ {opt.label}
+
+ ))}
+
+ );
+ }
+
+ // Textarea for freeform descriptions
+ if (question.type === 'textarea') {
+ return (
+
+
handleAnswerChange(question.id, e.target.value)}
+ placeholder={question.placeholder}
+ maxLength={question.maxLength || 2000}
+ rows={10}
+ />
+
+ {(answers[question.id] || '').length} / {question.maxLength || 2000}
+
+
+ );
+ }
+
+ // Text input with suggestions
+ return (
+
+
handleAnswerChange(question.id, e.target.value)}
+ placeholder={question.placeholder}
+ />
+ {question.suggestions && (
+
+ {question.suggestions.map(sug => (
+ handleAnswerChange(question.id, sug)}
+ >
+ {sug}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ async function handleStartCreation() {
+ setLoading(true);
+ setError('');
+ try {
+ const data = await api.startCreation(selectedStyle.id, title, answers);
+ setGameId(data.game.id);
+ setSummary(data.summary);
+ setDraftSaved(true);
+ // Store the prompt for review/editing
+ setOriginalPrompt(data.prompt);
+ setEditablePrompt(data.prompt);
+ setPromptAnswersForHighlight(data.promptAnswers || answers);
+ // Store complexity info for warnings
+ setComplexity(data.complexity || 1);
+ setComplexityWarnings(data.complexityWarnings || []);
+ setIsHighComplexity(data.isHighComplexity || false);
+ setStep(WIZARD_STEPS.REVIEW);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleGenerate() {
+ setStep(WIZARD_STEPS.GENERATING);
+ setLoading(true);
+ setError('');
+ setGenerationFailed(false);
+ setStreamPhase('');
+ setStreamMessage('AI is getting ready...');
+ setStreamProgress(0);
+ setStreamSummary('');
+
+ const promptWasEdited = editablePrompt !== originalPrompt;
+
+ try {
+ // This now returns immediately - generation runs in background
+ await api.generateGame(gameId, editablePrompt, promptWasEdited);
+
+ // Open SSE connection for real-time progress
+ const es = api.streamGeneration(gameId, (event) => {
+ if (event.type === 'phase') {
+ setStreamPhase(event.phase);
+ setStreamMessage(event.message);
+ setStreamProgress(event.progress || 0);
+ } else if (event.type === 'progress') {
+ setStreamProgress(event.progress || 0);
+ } else if (event.type === 'complete') {
+ setStreamProgress(100);
+ setStreamMessage('Your game is ready!');
+ setStreamSummary(event.summary || '');
+ setLoading(false);
+
+ // Close SSE
+ es.close();
+ eventSourceRef.current = null;
+
+ // Brief pause to show summary, then navigate
+ setTimeout(() => {
+ navigate(`/play/${gameId}?newGame=true`);
+ }, 2000);
+ } else if (event.type === 'error') {
+ setLoading(false);
+ setGenerationFailed(true);
+ setError(event.error || 'Generation failed. Your prompt is saved - click "Try Again".');
+ setStep(WIZARD_STEPS.REVIEW);
+ es.close();
+ eventSourceRef.current = null;
+ }
+ });
+
+ eventSourceRef.current = es;
+ } catch (err) {
+ setLoading(false);
+ setGenerationFailed(true);
+ setError(`Generation failed: ${err.message}. Your prompt is saved - click "Try Again".`);
+ setStep(WIZARD_STEPS.REVIEW);
+ }
+ }
+
+ async function handleTrackTesting() {
+ try {
+ // Include thumbnail data if we captured one
+ const data = await api.trackTesting(gameId, totalTestTime, 0, thumbnailData);
+ setCanPublish(data.canPublish);
+ if (data.canPublish) {
+ setPublishData(prev => ({
+ ...prev,
+ title: title || `${selectedStyle.name} Game`,
+ description: summary
+ }));
+ }
+ } catch (err) {
+ console.error('Failed to track testing:', err);
+ }
+ }
+
+ async function handleRefine() {
+ if (!feedback.trim()) {
+ setError('Please describe what you want to change');
+ return;
+ }
+ setStep(WIZARD_STEPS.GENERATING);
+ setLoading(true);
+ setError('');
+ setGenerationFailed(false);
+ setStreamPhase('');
+ setStreamMessage('AI is preparing your update...');
+ setStreamProgress(0);
+ setStreamSummary('');
+
+ try {
+ // This now returns immediately
+ await api.refineGame(gameId, feedback);
+
+ // Open SSE for progress
+ const es = api.streamGeneration(gameId, (event) => {
+ if (event.type === 'phase') {
+ setStreamPhase(event.phase);
+ setStreamMessage(event.message);
+ setStreamProgress(event.progress || 0);
+ } else if (event.type === 'progress') {
+ setStreamProgress(event.progress || 0);
+ } else if (event.type === 'complete') {
+ setStreamProgress(100);
+ setStreamMessage('Update complete!');
+ setStreamSummary(event.summary || '');
+ setLoading(false);
+ es.close();
+ eventSourceRef.current = null;
+
+ setGameCode(event.gameCode);
+ setTokensUsed(prev => prev + (event.tokensUsed || 0));
+ setTotalCost(prev => prev + (event.costCents || 0));
+ setFeedback('');
+ setTotalTestTime(0);
+ setCanPublish(false);
+ setThumbnailCaptured(false);
+
+ setTimeout(() => {
+ navigate(`/play/${gameId}?newGame=true`);
+ }, 2000);
+ } else if (event.type === 'error') {
+ setLoading(false);
+ setGenerationFailed(true);
+ setError(event.error || 'Update failed. Your feedback is saved - click "Try Again".');
+ setStep(WIZARD_STEPS.REFINE);
+ es.close();
+ eventSourceRef.current = null;
+ }
+ });
+
+ eventSourceRef.current = es;
+ } catch (err) {
+ setLoading(false);
+ setGenerationFailed(true);
+ setError(`Update failed: ${err.message}. Your feedback is saved - click "Try Again".`);
+ setStep(WIZARD_STEPS.REFINE);
+ }
+ }
+
+ async function handlePublish() {
+ setLoading(true);
+ setError('');
+ try {
+ const data = await api.publishGame(gameId, publishData);
+ setPublishResult(data);
+ setStep(WIZARD_STEPS.SUCCESS);
+ refreshUser();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // Auth check
+ if (!isAuthenticated || user?.isGuest) {
+ return (
+
+
+
Create a Game
+
You need an account to create games.
+
Create Account
+
+
+ );
+ }
+
+ return (
+
+ {/* Progress indicator */}
+ {step !== WIZARD_STEPS.SUCCESS && (
+
+
+ 1
+ Style
+
+
+ 2
+ Ideas
+
+
+ 3
+ Touches
+
+
+ 4
+ Create
+
+
+ )}
+
+ {error &&
{error}
}
+
+ {/* Step 1: Style Selection */}
+ {step === WIZARD_STEPS.STYLE && (
+
+
+
What kind of game do you want to create?
+
Pick a style and we'll guide you through the rest!
+
+ {styles.map(style => (
+
handleStyleSelect(style)}
+ >
+ {style.icon}
+ {style.name}
+ {style.description}
+
+ {style.examples.slice(0, 2).map(ex => (
+ {ex}
+ ))}
+
+
+ ))}
+
+
+ )}
+
+ {/* Step 1b: AI Prompt Refinement (alternate path) */}
+ {step === WIZARD_STEPS.AI_REFINE && selectedStyle && (
+
+
+
setStep(WIZARD_STEPS.TEXT_INPUTS)}>
+ ← Back
+
+
+
{selectedStyle.icon} {selectedStyle.name}
+
Chat with AI to refine your game idea
+
+
+
setStep(WIZARD_STEPS.TEXT_INPUTS)}
+ styleId={selectedStyle.id}
+ styleName={selectedStyle.name}
+ />
+
+ )}
+
+ {/* Step 2: Text Inputs (title + common text questions + style-specific text questions) */}
+ {step === WIZARD_STEPS.TEXT_INPUTS && questions && (
+
+
+
setStep(WIZARD_STEPS.STYLE)}>
+ ← Back
+
+
+
{selectedStyle.icon} {selectedStyle.name} {questions.skipCommonQuestions ? '' : 'Game'}
+
{questions.skipCommonQuestions ? 'Describe exactly what you want!' : 'Pre-filled with random ideas - change anything you like!'}
+
+
+
+ {!questions.skipCommonQuestions && (
+
+ 🎲
+ Random ideas selected! Edit any field or
+ {
+ const newAnswers = generateRandomAnswers(questions);
+ setAnswers(newAnswers);
+ setTitle(generateRandomTitle(selectedStyle.name, newAnswers));
+ }}
+ >
+ 🔀 Randomize Again
+
+
+ )}
+
+ {/* AI Refine shortcut */}
+
+ 🤖
+ Have a specific idea? Let AI help you describe it!
+ setStep(WIZARD_STEPS.AI_REFINE)}
+ >
+ Refine with AI FREE
+
+
+
+ {/* Game title - prominently displayed */}
+ {!questions.skipCommonQuestions && (
+
+ Your Game Title
+ setTitle(e.target.value)}
+ placeholder={`My ${selectedStyle.name} Game`}
+ maxLength={100}
+ />
+ ✨ Auto-generated from your choices - edit if you like!
+
+ )}
+
+
+ {/* Common text questions (skipped for freeform) */}
+ {!questions.skipCommonQuestions && questions.common.map(q => (
+
+
{q.question}
+ {renderQuestion(q)}
+
+ ))}
+
+ {/* Style-specific text questions (non-select types) */}
+ {questions.styleSpecific
+ .filter(q => !q.type || q.type === 'textarea')
+ .map(q => (
+
+
{q.question}
+ {renderQuestion(q)}
+
+ ))}
+
+
+ setStep(WIZARD_STEPS.FINAL_TOUCHES)}>
+ Continue →
+
+
+
+ )}
+
+ {/* Step 3: Final Touches (style-specific selects + final questions + avatar) */}
+ {step === WIZARD_STEPS.FINAL_TOUCHES && questions && (
+
+
+
setStep(WIZARD_STEPS.TEXT_INPUTS)}>
+ ← Back
+
+
+
Final Touches
+
Almost ready - just a few more options!
+
+
+
+
+ {/* Style-specific select questions (e.g., puzzle→levels, story→endings, rpg→combat) */}
+ {questions.styleSpecific
+ .filter(q => q.type === 'select')
+ .map(q => (
+
+
{q.question}
+ {renderQuestion(q)}
+
+ ))}
+
+ {/* Final questions (avatar, difficulty, music, features, accessibility) */}
+ {questions.final.map(q => (
+
+
{q.question}
+ {renderQuestion(q)}
+
+ ))}
+
+
+
+ {loading ? 'Saving...' : 'Review & Create →'}
+
+
+
+ )}
+
+ {/* Step 3: Review & Edit Prompt */}
+ {step === WIZARD_STEPS.REVIEW && (
+
+
+
setStep(WIZARD_STEPS.FINAL_TOUCHES)}>
+ ← Back
+
+
+
Review Your Prompt
+
This is exactly what the AI will read. Edit anything you want!
+
+
+
+ {draftSaved && (
+
+ ✓
+ Your idea is saved! You can safely close this page and come back later.
+
+ )}
+
+ {generationFailed && (
+
+
⚠️
+
+
Generation failed, but don't worry!
+
Your prompt is saved. Edit it if you want, then try again.
+
+
+ )}
+
+ {/* Complexity Warning */}
+ {isHighComplexity && (
+
+
+ ⚠️
+ High Complexity Game
+ Complexity: {complexity}/5
+
+
Your game has many features which may cause issues. Consider simplifying:
+
+ {complexityWarnings.map((warning, i) => (
+ {warning}
+ ))}
+
+
+ Tip: Start simple! You can always add more features later using AI Refine.
+
+
+ )}
+
+ {/* View Mode Toggle */}
+
+ setPromptViewMode('highlighted')}
+ >
+ 🎨 Highlighted View
+
+ setPromptViewMode('edit')}
+ >
+ ✏️ Edit View
+
+ setShowPromptTips(!showPromptTips)}
+ >
+ 💡 Tips
+
+
+
+ {/* Tips Panel - Collapsible */}
+ {showPromptTips && (
+
+
Tips for a Better Prompt
+
+
+ ✅
+ Be specific about colors, sizes, and speeds
+
+
+ ✅
+ Describe exactly how the player controls things
+
+
+ ✅
+ Say what happens when the player wins or loses
+
+
+ ✅
+ Include details about difficulty progression
+
+
+ ✅
+ Mention sound effects or music style
+
+
+ ❌
+ Don't be too vague ("make it fun" → say HOW)
+
+
+ ❌
+ Don't contradict yourself
+
+
+
+ )}
+
+ {/* Prompt Display/Editor */}
+
+ {promptViewMode === 'highlighted' ? (
+
+ {renderHighlightedPrompt(editablePrompt, promptAnswersForHighlight)}
+
+ ) : (
+
setEditablePrompt(e.target.value)}
+ placeholder="Your game prompt..."
+ />
+ )}
+
+
+ {/* Prompt Stats */}
+
+ {editablePrompt.length} characters
+ {editablePrompt !== originalPrompt && (
+ ✏️ You've edited this prompt
+ )}
+
+
+ {/* Action Buttons - Sticky on mobile */}
+
+ setStep(WIZARD_STEPS.FINAL_TOUCHES)}
+ >
+ ← Back
+
+ {editablePrompt !== originalPrompt && (
+ setEditablePrompt(originalPrompt)}
+ >
+ Reset
+
+ )}
+
+ {generationFailed ? '🔄 Try Again' : 'Generate Game! 🎮'}
+
+
+
+ )}
+
+ {/* Step 4: Generating */}
+ {step === WIZARD_STEPS.GENERATING && (
+
+
+
{streamProgress >= 100 ? '🎉' : streamPhase === 'audio' ? '🎵' : streamPhase === 'styling' ? '🎨' : streamPhase === 'controls' ? '🎮' : '⚙️'}
+ {streamProgress < 100 &&
}
+
+
{streamProgress >= 100 ? 'Your Game is Ready!' : 'Creating Your Game!'}
+
{streamMessage || 'AI is getting ready...'}
+
+ {streamSummary && (
+
{streamSummary}
+ )}
+ {streamProgress < 100 && (
+
+ You can safely close this page - we'll notify you when your game is ready!
+
+ )}
+
+ )}
+
+ {/* Step 5: Testing */}
+ {step === WIZARD_STEPS.TESTING && (
+
+
+
+
Test Your Game!
+
Play for at least 2 minutes before publishing. {gameCode ? `(${Math.round(gameCode.length / 1024)}KB loaded)` : '(Loading...)'}
+
+
+ ⏱️
+ {Math.floor(totalTestTime / 60)}:{String(totalTestTime % 60).padStart(2, '0')}
+ / 2:00 required
+
+ {tokensUsed > 0 && (
+
+ 🤖 {tokensUsed.toLocaleString()} tokens
+ {totalCost > 0 && ` • $${(totalCost / 100).toFixed(2)} cost`}
+
+ )}
+
+
+
+ setStep(WIZARD_STEPS.REFINE)}
+ >
+ Need Changes?
+
+ {
+ handleTrackTesting();
+ if (totalTestTime >= 120 || canPublish) {
+ setStep(WIZARD_STEPS.PUBLISH);
+ }
+ }}
+ disabled={totalTestTime < 120 && !canPublish}
+ >
+ {totalTestTime >= 120 || canPublish ? 'Ready to Publish!' : `Test ${120 - totalTestTime}s more`}
+
+
+
+
+ {gameCode ? (
+
+ ) : (
+
+
🎮
+
Loading your game...
+
+ )}
+
+
+ )}
+
+ {/* Step 5b: Refine */}
+ {step === WIZARD_STEPS.REFINE && (
+
+
+
setStep(WIZARD_STEPS.TESTING)}>
+ ← Back to Testing
+
+
+
What would you like to change?
+
Tell us what to improve!
+
+
+
+ {generationFailed && (
+
+
⚠️
+
+
Update failed, but your feedback is saved!
+
Click the button below to try again.
+
+
+ )}
+
+
+
setFeedback(e.target.value)}
+ placeholder="Describe the changes you want... (e.g., 'make the player faster', 'add more enemies', 'change the colors to blue')"
+ rows={4}
+ />
+
+
Tip: Be specific!
+
The more details you give, the better the AI will understand your vision.
+
+
+ 💾 Your feedback is saved automatically before updating
+
+
+
+
+ {loading ? 'Updating...' : generationFailed ? '🔄 Try Again' : '✨ Update Game'}
+
+
+
+ )}
+
+ {/* Step 6: Publish */}
+ {step === WIZARD_STEPS.PUBLISH && (
+
+
+
setStep(WIZARD_STEPS.TESTING)}>
+ ← Back to Testing
+
+
+
Publish Your Game!
+
Add final details before going live.
+
+
+
+
+ Game Title
+ setPublishData(prev => ({ ...prev, title: e.target.value }))}
+ placeholder="My Awesome Game"
+ maxLength={100}
+ />
+
+
+ Description
+ setPublishData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="A fun game where..."
+ rows={3}
+ maxLength={500}
+ />
+
+
+
Difficulty Levels
+
Select all difficulty levels your game supports:
+
+ {['easy', 'medium', 'hard'].map(level => (
+ {
+ setPublishData(prev => ({
+ ...prev,
+ difficultyTags: prev.difficultyTags.includes(level)
+ ? prev.difficultyTags.filter(d => d !== level)
+ : [...prev.difficultyTags, level]
+ }));
+ }}
+ >
+
+ {level === 'easy' ? '🟢' : level === 'medium' ? '🟡' : '🔴'}
+
+ {level}
+
+ ))}
+
+
+
+
Preview
+
+
{selectedStyle.icon}
+
+
{publishData.title || 'Untitled Game'}
+
{publishData.description || 'No description'}
+
+
+
+
+
+
+ {loading ? 'Publishing...' : '🚀 Publish to Arcade!'}
+
+
+
+ )}
+
+ {/* Step 7: Success */}
+ {step === WIZARD_STEPS.SUCCESS && publishResult && (
+
+
🎉
+
Your Game is Live!
+
"{publishResult.game.title}" is now in the arcade!
+
+ {publishResult.xp && (
+
+ ⭐
+ +{publishResult.xp.awarded} XP
+ {publishResult.xp.leveledUp && (
+ Level Up! → {publishResult.xp.newLevel.name}
+ )}
+
+ )}
+
+ {publishResult.achievements && publishResult.achievements.length > 0 && (
+
+
Achievements Unlocked!
+
+ {publishResult.achievements.map(a => (
+
+ {a.icon}
+ {a.name}
+
+ ))}
+
+
+ )}
+
+
+ navigate(publishResult.game.url)}
+ className="btn btn-primary btn-lg"
+ >
+ Play My Game
+
+ {
+ setStep(WIZARD_STEPS.STYLE);
+ setSelectedStyle(null);
+ setQuestions(null);
+ setAnswers({});
+ setTitle('');
+ setGameId(null);
+ setGameCode('');
+ setSummary('');
+ setTokensUsed(0);
+ setTotalCost(0);
+ setTotalTestTime(0);
+ setCanPublish(false);
+ setPublishResult(null);
+ }}
+ className="btn btn-secondary btn-lg"
+ >
+ Create Another Game
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/CreatorProfile.css b/frontend/src/pages/CreatorProfile.css
new file mode 100644
index 0000000..0e88499
--- /dev/null
+++ b/frontend/src/pages/CreatorProfile.css
@@ -0,0 +1,270 @@
+.creator-profile-page {
+ min-height: calc(100vh - 60px);
+ background: var(--bg-color);
+}
+
+/* Loading & Error States */
+.creator-profile-page .loading-state,
+.creator-profile-page .error-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ text-align: center;
+ padding: 2rem;
+}
+
+.creator-profile-page .loading-spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid rgba(255, 255, 255, 0.1);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.error-state .error-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+}
+
+.error-state h2 {
+ margin-bottom: 0.5rem;
+}
+
+.error-state p {
+ color: var(--text-secondary);
+ margin-bottom: 1.5rem;
+}
+
+/* Creator Banner */
+.creator-banner {
+ background: linear-gradient(135deg, var(--card-bg) 0%, #1a1a2e 100%);
+ padding: 2rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.banner-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ display: flex;
+ align-items: flex-start;
+ gap: 2rem;
+}
+
+.creator-avatar-section {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.level-badge {
+ position: absolute;
+ bottom: -8px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--card-bg);
+ padding: 4px 12px;
+ border-radius: 20px;
+ border: 2px solid var(--primary);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+
+.level-icon {
+ font-size: 1rem;
+}
+
+.level-name {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--primary);
+}
+
+.creator-info {
+ flex: 1;
+}
+
+.creator-name {
+ font-size: 2rem;
+ margin: 0 0 0.25rem 0;
+ color: var(--text-primary);
+}
+
+.creator-username {
+ color: var(--text-secondary);
+ margin: 0 0 0.75rem 0;
+ font-size: 1rem;
+}
+
+.creator-bio {
+ color: var(--text-primary);
+ margin: 0 0 1rem 0;
+ max-width: 600px;
+ line-height: 1.5;
+}
+
+.creator-stats {
+ display: flex;
+ gap: 2rem;
+ margin-bottom: 1.5rem;
+}
+
+.stat-item {
+ display: flex;
+ flex-direction: column;
+}
+
+.stat-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.stat-label {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+}
+
+.follow-btn {
+ background: var(--primary);
+ color: white;
+ padding: 0.5rem 1.5rem;
+ border-radius: 20px;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+
+.follow-btn:hover {
+ transform: scale(1.05);
+}
+
+.follow-btn.following {
+ background: transparent;
+ border: 2px solid var(--primary);
+ color: var(--primary);
+}
+
+/* Creator Arcade */
+.creator-arcade {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+.arcade-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.arcade-header h2 {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+ font-size: 1.5rem;
+}
+
+.arcade-icon {
+ font-size: 1.5rem;
+}
+
+.sort-controls {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.sort-btn {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ padding: 0.5rem 1rem;
+ border-radius: 8px;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.sort-btn:hover {
+ border-color: var(--primary);
+ color: var(--text-primary);
+}
+
+.sort-btn.active {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: white;
+}
+
+/* Empty State */
+.empty-arcade {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: var(--card-bg);
+ border-radius: 16px;
+}
+
+.empty-arcade .empty-icon {
+ font-size: 4rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.empty-arcade h3 {
+ margin: 0 0 0.5rem 0;
+}
+
+.empty-arcade p {
+ color: var(--text-secondary);
+ margin: 0 0 1.5rem 0;
+}
+
+/* Games Grid */
+.games-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1.5rem;
+}
+
+/* Mobile Responsive */
+@media (max-width: 768px) {
+ .banner-content {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ .creator-stats {
+ justify-content: center;
+ }
+
+ .creator-info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .creator-bio {
+ text-align: center;
+ }
+
+ .arcade-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .sort-controls {
+ justify-content: center;
+ }
+
+ .games-grid {
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ }
+}
diff --git a/frontend/src/pages/CreatorProfile.jsx b/frontend/src/pages/CreatorProfile.jsx
new file mode 100644
index 0000000..c4d16a9
--- /dev/null
+++ b/frontend/src/pages/CreatorProfile.jsx
@@ -0,0 +1,225 @@
+import { useState, useEffect } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import GameCard from '../components/GameCard';
+import { AvatarDisplay, DEFAULT_AVATAR } from '../components/AvatarBuilder';
+import './CreatorProfile.css';
+
+const LEVEL_ICONS = {
+ 1: '🌱', 2: '🎮', 3: '🎯', 4: '⭐', 5: '🔥',
+ 6: '💎', 7: '🏆', 8: '👑', 9: '🌟', 10: '🎖️'
+};
+
+const SORT_OPTIONS = [
+ { id: 'popular', label: 'Most Played' },
+ { id: 'recent', label: 'Newest First' },
+ { id: 'rating', label: 'Highest Rated' }
+];
+
+export default function CreatorProfile() {
+ const { username } = useParams();
+ const { user: currentUser, isAuthenticated } = useAuth();
+
+ const [creator, setCreator] = useState(null);
+ const [games, setGames] = useState([]);
+ const [isFollowing, setIsFollowing] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [sortBy, setSortBy] = useState('popular');
+ const [followLoading, setFollowLoading] = useState(false);
+
+ const isOwnProfile = currentUser && creator && currentUser.id === creator.id;
+
+ useEffect(() => {
+ loadCreator();
+ }, [username]);
+
+ async function loadCreator() {
+ setLoading(true);
+ setError('');
+ try {
+ const [profileData, gamesData] = await Promise.all([
+ api.getCreatorByUsername(username),
+ api.getCreatorGames(username)
+ ]);
+
+ setCreator(profileData.user);
+ setGames(gamesData.games || []);
+ setIsFollowing(profileData.isFollowing || false);
+ } catch (err) {
+ setError(err.message || 'Creator not found');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleFollowToggle() {
+ if (!isAuthenticated || !creator) return;
+ setFollowLoading(true);
+ try {
+ if (isFollowing) {
+ await api.unfollowUser(creator.id);
+ setIsFollowing(false);
+ setCreator(prev => ({
+ ...prev,
+ followersCount: prev.followersCount - 1
+ }));
+ } else {
+ await api.followUser(creator.id);
+ setIsFollowing(true);
+ setCreator(prev => ({
+ ...prev,
+ followersCount: prev.followersCount + 1
+ }));
+ }
+ } catch (err) {
+ console.error('Follow error:', err);
+ } finally {
+ setFollowLoading(false);
+ }
+ }
+
+ const sortedGames = [...games].sort((a, b) => {
+ switch (sortBy) {
+ case 'popular':
+ return b.playCount - a.playCount;
+ case 'recent':
+ return new Date(b.publishedAt) - new Date(a.publishedAt);
+ case 'rating':
+ return (parseFloat(b.averageRating) || 0) - (parseFloat(a.averageRating) || 0);
+ default:
+ return 0;
+ }
+ });
+
+ if (loading) {
+ return (
+
+
+
+
Loading creator profile...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
😕
+
Creator Not Found
+
{error}
+
Browse Arcade
+
+
+ );
+ }
+
+ return (
+
+ {/* Header Banner */}
+
+
+
+
+
+ {LEVEL_ICONS[creator.level] || '🎮'}
+ {creator.badge}
+
+
+
+
+
+ {creator.displayName || creator.username}
+
+
@{creator.username}
+ {creator.bio &&
{creator.bio}
}
+
+
+
+ {creator.gamesCount}
+ Games
+
+
+ {creator.totalPlays?.toLocaleString() || 0}
+ Total Plays
+
+
+ {creator.followersCount}
+ Followers
+
+
+ {creator.xp?.toLocaleString() || 0}
+ XP
+
+
+
+ {!isOwnProfile && isAuthenticated && (
+
+ {followLoading ? '...' : isFollowing ? 'Following' : 'Follow'}
+
+ )}
+
+ {isOwnProfile && (
+
+ Edit Profile
+
+ )}
+
+
+
+
+ {/* Games Section */}
+
+
+
+ 🎮
+ {isOwnProfile ? 'My Arcade' : `${creator.displayName || creator.username}'s Arcade`}
+
+
+
+ {SORT_OPTIONS.map(opt => (
+ setSortBy(opt.id)}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+ {sortedGames.length === 0 ? (
+
+
🎮
+
No games yet
+
+ {isOwnProfile
+ ? "You haven't published any games yet. Create your first game!"
+ : "This creator hasn't published any games yet."}
+
+ {isOwnProfile && (
+
Create a Game
+ )}
+
+ ) : (
+
+ {sortedGames.map(game => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css
new file mode 100644
index 0000000..6355222
--- /dev/null
+++ b/frontend/src/pages/Dashboard.css
@@ -0,0 +1,688 @@
+.dashboard {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+.dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.dashboard-header h1 {
+ margin: 0;
+ color: #1e293b;
+}
+
+/* XP Section */
+.xp-section {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border-radius: 1rem;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
+}
+
+.xp-section .xp-display {
+ background: transparent;
+ color: white;
+}
+
+.xp-section .level-badge {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+}
+
+.xp-section .level-name {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.xp-section .xp-bar {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.xp-section .xp-fill {
+ background: white;
+}
+
+.xp-section .xp-text {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.xp-section .xp-needed {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+/* Dashboard Grid */
+.dashboard-grid {
+ display: grid;
+ gap: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+@media (min-width: 768px) {
+ .dashboard-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+/* Stats Section */
+.stats-section {
+ grid-column: 1 / -1;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 1rem;
+}
+
+.stat-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ text-align: center;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.stat-card.highlight {
+ background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
+ color: white;
+}
+
+.stat-card.highlight .stat-value,
+.stat-card.highlight .stat-label {
+ color: white;
+}
+
+.stat-card.spending {
+ background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
+ color: white;
+}
+
+.stat-card.spending .stat-value,
+.stat-card.spending .stat-label {
+ color: white;
+}
+
+.stat-sublabel {
+ font-size: 0.7rem;
+ opacity: 0.7;
+ margin-top: 0.25rem;
+}
+
+.stat-value {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin-bottom: 0.25rem;
+}
+
+.stat-label {
+ font-size: 0.85rem;
+ opacity: 0.8;
+}
+
+/* Streaks Section */
+.streaks-section {
+ grid-column: 1 / -1;
+}
+
+.streaks-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+.streak-card {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+ border: 2px solid #e2e8f0;
+ transition: all 0.3s ease;
+}
+
+.streak-card.on-fire {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border-color: #f59e0b;
+ box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
+}
+
+.streak-card.on-fire .streak-icon {
+ animation: pulse-fire 1s ease-in-out infinite;
+}
+
+@keyframes pulse-fire {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.1); }
+}
+
+.streak-icon {
+ font-size: 2.5rem;
+ line-height: 1;
+}
+
+.streak-info {
+ flex: 1;
+}
+
+.streak-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #1e293b;
+ line-height: 1;
+}
+
+.streak-label {
+ font-size: 0.85rem;
+ color: #64748b;
+ margin-top: 0.25rem;
+}
+
+.streak-milestone {
+ display: inline-block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #f59e0b;
+ background: rgba(245, 158, 11, 0.15);
+ padding: 0.2rem 0.5rem;
+ border-radius: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.streak-card.on-fire .streak-milestone {
+ background: rgba(0, 0, 0, 0.1);
+ color: #92400e;
+}
+
+/* Section Headers */
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.section-header h3 {
+ margin: 0;
+ font-size: 1rem;
+ color: #1e293b;
+}
+
+.view-all {
+ font-size: 0.85rem;
+ color: #6366f1;
+ text-decoration: none;
+}
+
+.view-all:hover {
+ text-decoration: underline;
+}
+
+/* Achievements Section */
+.achievements-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.achievements-row {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.achievement-badge {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 0.75rem 1rem;
+ min-width: 80px;
+ border: 1px solid #e2e8f0;
+ cursor: default;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.achievement-badge:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.achievement-icon {
+ font-size: 1.5rem;
+}
+
+.achievement-name {
+ font-size: 0.7rem;
+ color: #64748b;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 80px;
+}
+
+/* XP Activity Section */
+.xp-activity-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.xp-activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.xp-activity-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.xp-activity-item:last-child {
+ border-bottom: none;
+}
+
+.xp-activity-label {
+ flex: 1;
+ font-size: 0.85rem;
+ color: #475569;
+}
+
+.xp-activity-amount {
+ font-weight: 600;
+ color: #10b981;
+ font-size: 0.85rem;
+}
+
+.xp-activity-time {
+ font-size: 0.75rem;
+ color: #94a3b8;
+ min-width: 60px;
+ text-align: right;
+}
+
+/* Collections Section */
+.collections-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.empty-collections {
+ text-align: center;
+ padding: 1.5rem 1rem;
+ color: #64748b;
+}
+
+.empty-collections .empty-icon {
+ font-size: 2.5rem;
+ display: block;
+ margin-bottom: 0.75rem;
+}
+
+.empty-collections p {
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+}
+
+.collections-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.75rem;
+}
+
+.collection-card-mini {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid #e2e8f0;
+ text-decoration: none;
+ transition: all 0.2s;
+}
+
+.collection-card-mini:hover {
+ border-color: #6366f1;
+ background: #f1f5f9;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
+}
+
+.collection-card-mini .collection-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.collection-card-mini .collection-details {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.collection-card-mini .collection-name {
+ font-weight: 600;
+ color: #1e293b;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.collection-card-mini .collection-count {
+ font-size: 0.75rem;
+ color: #64748b;
+}
+
+@media (max-width: 640px) {
+ .collections-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Dashboard tabs */
+.dashboard-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background: white;
+ padding: 0.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.tab-btn {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: none;
+ background: transparent;
+ border-radius: 0.75rem;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.tab-btn:hover {
+ background: #f1f5f9;
+}
+
+.tab-btn.active {
+ background: #6366f1;
+ color: white;
+}
+
+.games-section {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+/* Play history */
+.play-history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.play-history-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ border: 1px solid #e2e8f0;
+ gap: 1rem;
+}
+
+.play-history-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.play-history-title {
+ display: block;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #1e293b;
+ text-decoration: none;
+ margin-bottom: 0.35rem;
+}
+
+.play-history-title:hover {
+ color: #6366f1;
+}
+
+.play-history-stats {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.85rem;
+ color: #64748b;
+ flex-wrap: wrap;
+}
+
+.play-history-stats strong {
+ color: #16a34a;
+}
+
+.play-history-date {
+ color: #94a3b8;
+}
+
+.games-section h2 {
+ margin: 0 0 1.5rem;
+ color: #1e293b;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+}
+
+.empty-state p {
+ margin-bottom: 1rem;
+}
+
+.games-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.game-row {
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ border: 1px solid #e2e8f0;
+}
+
+.game-main-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.5rem;
+}
+
+.game-row .game-title {
+ margin: 0;
+ font-size: 1.1rem;
+ color: #1e293b;
+}
+
+.game-stats-row {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.85rem;
+ color: #64748b;
+ margin-bottom: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.game-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 0.2rem 0.6rem;
+ border-radius: 1rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.status-draft {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.status-pending_review {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.status-published {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.status-rejected {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.status-testing {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.status-generating {
+ background: #ede9fe;
+ color: #6d28d9;
+}
+
+.rejection-reason {
+ font-size: 0.8rem;
+ color: #dc2626;
+ background: #fef2f2;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.btn-sm {
+ padding: 0.4rem 0.8rem;
+ font-size: 0.85rem;
+}
+
+.btn-danger {
+ background: #dc2626;
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #b91c1c;
+}
+
+.btn-danger:disabled {
+ background: #f87171;
+ cursor: not-allowed;
+}
+
+.btn-analytics {
+ background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
+ color: white;
+}
+
+.btn-analytics:hover {
+ background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
+}
+
+.loading {
+ text-align: center;
+ padding: 4rem;
+ color: #64748b;
+}
+
+@media (max-width: 640px) {
+ .dashboard {
+ padding: 1rem;
+ }
+
+ .dashboard-header {
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .stat-value {
+ font-size: 1.5rem;
+ }
+
+ .game-actions {
+ margin-top: 0.5rem;
+ }
+
+ .game-actions .btn-sm {
+ flex: 1;
+ text-align: center;
+ }
+
+ .xp-section {
+ padding: 1rem;
+ }
+
+ .achievements-row {
+ justify-content: center;
+ }
+
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .streaks-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .streak-card {
+ padding: 1rem 1.25rem;
+ }
+
+ .streak-icon {
+ font-size: 2rem;
+ }
+
+ .streak-value {
+ font-size: 1.5rem;
+ }
+}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..46780f8
--- /dev/null
+++ b/frontend/src/pages/Dashboard.jsx
@@ -0,0 +1,207 @@
+import { useState, useEffect } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './Dashboard.css';
+
+export default function Dashboard() {
+ const { user, isAuthenticated } = useAuth();
+ const navigate = useNavigate();
+
+ const [games, setGames] = useState([]);
+ const [collections, setCollections] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [deleting, setDeleting] = useState(null);
+
+ useEffect(() => {
+ if (!isAuthenticated || user?.isGuest) {
+ navigate('/login');
+ return;
+ }
+ loadDashboard();
+ }, [isAuthenticated, user]);
+
+ const loadDashboard = async () => {
+ try {
+ const [gamesData, collectionsData] = await Promise.all([
+ api.getMyGames(),
+ api.getMyCollections()
+ ]);
+ setGames(gamesData.games);
+ setCollections(collectionsData.collections || []);
+ } catch (error) {
+ console.error('Failed to load dashboard:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async (gameId, gameTitle) => {
+ if (!confirm(`Are you sure you want to delete "${gameTitle}"? This cannot be undone.`)) {
+ return;
+ }
+
+ setDeleting(gameId);
+ try {
+ await api.deleteGame(gameId);
+ setGames(games.filter(g => g.id !== gameId));
+ } catch (error) {
+ alert('Failed to delete game: ' + error.message);
+ } finally {
+ setDeleting(null);
+ }
+ };
+
+ const handleSubmit = async (gameId) => {
+ try {
+ await api.submitGame(gameId);
+ loadDashboard();
+ } catch (error) {
+ alert('Failed to submit game: ' + error.message);
+ }
+ };
+
+ const handleUnpublish = async (gameId, gameTitle) => {
+ if (!confirm(`Are you sure you want to unpublish "${gameTitle}"? It will be moved back to testing status.`)) {
+ return;
+ }
+ try {
+ await api.unpublishGame(gameId);
+ loadDashboard();
+ } catch (error) {
+ alert('Failed to unpublish game: ' + error.message);
+ }
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
My Games
+ + Create New Game
+
+
+ {/* My Collections */}
+
+
+
My Collections
+ Manage All
+
+ {collections.length === 0 ? (
+
+
📁
+
Organize your favorite games into collections!
+
Create Collection
+
+ ) : (
+
+ {collections.slice(0, 4).map(c => (
+
+
📁
+
+ {c.name}
+ {c.gameCount} games
+
+
+ ))}
+
+ )}
+
+
+ {/* Games I've Created */}
+
+
Your Games
+
+ {games.length === 0 ? (
+
+
You haven't created any games yet.
+
Create Your First Game
+
+ ) : (
+
+ {games.map(game => (
+
+
+
{game.title}
+
+ {game.status.replace('_', ' ')}
+
+
+
+
+ ▶ {game.playCount} plays
+ ⭐ {game.averageRating ? game.averageRating.toFixed(1) : '—'}
+ 🪙 {game.tokensEarned} earned
+
+
+ {game.status === 'rejected' && game.rejectionReason && (
+
+ Rejected: {game.rejectionReason}
+
+ )}
+
+
+ {game.status === 'published' && (
+ <>
+
+ View
+
+
+ Edit
+
+ handleUnpublish(game.id, game.title)}
+ className="btn btn-sm btn-warning"
+ >
+ Unpublish
+
+ >
+ )}
+
+ {game.status === 'draft' && (
+ <>
+
+ Edit
+
+ handleSubmit(game.id)}
+ className="btn btn-sm btn-primary"
+ >
+ Submit for Review
+
+ >
+ )}
+
+ {game.status === 'rejected' && (
+
+ Edit & Resubmit
+
+ )}
+
+ {game.status === 'testing' && (
+
+ Test & Edit
+
+ )}
+
+ {game.status !== 'published' && (
+ handleDelete(game.id, game.title)}
+ className="btn btn-sm btn-danger"
+ disabled={deleting === game.id}
+ >
+ {deleting === game.id ? 'Deleting...' : 'Delete'}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Edit.jsx b/frontend/src/pages/Edit.jsx
new file mode 100644
index 0000000..6ee825d
--- /dev/null
+++ b/frontend/src/pages/Edit.jsx
@@ -0,0 +1,31 @@
+import { useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+
+// Edit page now redirects to Play page where DevToolbar handles editing
+export default function Edit() {
+ const { gameId } = useParams();
+ const navigate = useNavigate();
+ const { user, isAuthenticated } = useAuth();
+
+ useEffect(() => {
+ if (!isAuthenticated || user?.isGuest) {
+ navigate('/login');
+ return;
+ }
+ // Redirect to play page - DevToolbar will show for the creator
+ navigate(`/play/${gameId}`, { replace: true });
+ }, [gameId, isAuthenticated, user, navigate]);
+
+ return (
+
+ Redirecting to game...
+
+ );
+}
diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx
new file mode 100644
index 0000000..4a5468e
--- /dev/null
+++ b/frontend/src/pages/ForgotPassword.jsx
@@ -0,0 +1,84 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import api from '../api';
+import './Auth.css';
+
+export default function ForgotPassword() {
+ const [email, setEmail] = useState('');
+ const [submitted, setSubmitted] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await api.forgotPassword(email);
+ setSubmitted(true);
+ } catch (err) {
+ setError(err.message || 'Failed to send reset email');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (submitted) {
+ return (
+
+
+
+ 📧
+
Check Your Email
+
+
+
If an account exists with {email} , we've sent a password reset link.
+
The link expires in 1 hour. Check your spam folder if you don't see it.
+
+
+ Back to Login
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
🔐
+
Forgot Password
+
Enter your email and we'll send you a reset link
+
+
+ {error &&
{error}
}
+
+
+
+ Email
+ setEmail(e.target.value)}
+ placeholder="your@email.com"
+ required
+ autoFocus
+ />
+
+
+
+ {loading ? 'Sending...' : 'Send Reset Link'}
+
+
+
+
+
Remember your password? Login
+
Don't have an account? Sign Up Free
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GameAnalytics.css b/frontend/src/pages/GameAnalytics.css
new file mode 100644
index 0000000..05bdec4
--- /dev/null
+++ b/frontend/src/pages/GameAnalytics.css
@@ -0,0 +1,511 @@
+.analytics-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+/* Header */
+.analytics-header {
+ margin-bottom: 2rem;
+}
+
+.back-link {
+ display: inline-block;
+ color: #6366f1;
+ text-decoration: none;
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+}
+
+.back-link:hover {
+ text-decoration: underline;
+}
+
+.analytics-header h1 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+ font-size: 1.75rem;
+}
+
+.game-meta {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.publish-date {
+ font-size: 0.9rem;
+ color: #64748b;
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 0.2rem 0.6rem;
+ border-radius: 1rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.status-published {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.status-draft {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.status-pending_review {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+/* Summary Cards */
+.summary-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.summary-card {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+ border-left: 4px solid;
+}
+
+.summary-card.plays {
+ border-color: #6366f1;
+}
+
+.summary-card.players {
+ border-color: #10b981;
+}
+
+.summary-card.session {
+ border-color: #f59e0b;
+}
+
+.summary-card.favorites {
+ border-color: #ef4444;
+}
+
+.card-icon {
+ font-size: 2rem;
+ width: 50px;
+ height: 50px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.75rem;
+ background: #f1f5f9;
+}
+
+.summary-card.plays .card-icon {
+ background: #eef2ff;
+ color: #6366f1;
+}
+
+.summary-card.players .card-icon {
+ background: #ecfdf5;
+ color: #10b981;
+}
+
+.summary-card.session .card-icon {
+ background: #fffbeb;
+ color: #f59e0b;
+}
+
+.summary-card.favorites .card-icon {
+ background: #fef2f2;
+ color: #ef4444;
+}
+
+.card-content {
+ flex: 1;
+}
+
+.card-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #1e293b;
+ line-height: 1.2;
+}
+
+.card-label {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+/* Charts Grid */
+.charts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.chart-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.chart-card h3 {
+ margin: 0 0 1.25rem;
+ font-size: 1rem;
+ color: #1e293b;
+}
+
+/* Daily Plays Bar Chart */
+.daily-plays-card {
+ grid-column: 1 / -1;
+}
+
+.bar-chart {
+ display: flex;
+ align-items: flex-end;
+ height: 200px;
+ gap: 4px;
+ padding-bottom: 24px;
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+.bar-chart.horizontal-scroll {
+ scroll-behavior: smooth;
+ -webkit-overflow-scrolling: touch;
+}
+
+.bar-container {
+ flex: 1;
+ min-width: 24px;
+ max-width: 40px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ position: relative;
+}
+
+.bar {
+ width: 100%;
+ min-height: 4px;
+ border-radius: 4px 4px 0 0;
+ transition: height 0.3s ease;
+ position: relative;
+ cursor: pointer;
+}
+
+.bar:hover {
+ opacity: 0.85;
+ transform: scaleY(1.02);
+}
+
+.bar-value {
+ position: absolute;
+ top: -20px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #475569;
+ white-space: nowrap;
+}
+
+.bar-label {
+ position: absolute;
+ bottom: -20px;
+ font-size: 0.65rem;
+ color: #94a3b8;
+ white-space: nowrap;
+}
+
+/* Rating Distribution */
+.rating-chart {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.rating-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.rating-label {
+ width: 60px;
+ font-size: 0.8rem;
+ color: #64748b;
+}
+
+.rating-bar-container {
+ flex: 1;
+ height: 20px;
+ background: #f1f5f9;
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.rating-bar {
+ height: 100%;
+ border-radius: 10px;
+ transition: width 0.5s ease;
+ min-width: 4px;
+}
+
+.rating-count {
+ width: 30px;
+ text-align: right;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #475569;
+}
+
+.avg-rating {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid #e2e8f0;
+ text-align: center;
+ font-size: 0.9rem;
+ color: #64748b;
+}
+
+.avg-rating strong {
+ color: #1e293b;
+ font-size: 1.25rem;
+}
+
+/* Peak Hours Chart */
+.hours-chart {
+ display: flex;
+ align-items: flex-end;
+ height: 150px;
+ gap: 2px;
+ padding-bottom: 24px;
+}
+
+.hour-bar-container {
+ flex: 1;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ position: relative;
+}
+
+.hour-bar {
+ width: 100%;
+ min-height: 2px;
+ border-radius: 3px 3px 0 0;
+ transition: height 0.3s ease, background-color 0.3s ease;
+ cursor: pointer;
+}
+
+.hour-bar:hover {
+ opacity: 0.85;
+}
+
+.hour-label {
+ position: absolute;
+ bottom: -20px;
+ font-size: 0.55rem;
+ color: #94a3b8;
+}
+
+/* Score Distribution */
+.score-distribution {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.score-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.score-range {
+ width: 80px;
+ font-size: 0.75rem;
+ color: #64748b;
+ white-space: nowrap;
+}
+
+.score-bar-container {
+ flex: 1;
+ height: 16px;
+ background: #f1f5f9;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.score-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #6366f1, #8b5cf6);
+ border-radius: 8px;
+ transition: width 0.5s ease;
+ min-width: 4px;
+}
+
+.score-count {
+ width: 30px;
+ text-align: right;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: #475569;
+}
+
+/* No Data State */
+.no-data {
+ text-align: center;
+ padding: 2rem;
+ color: #94a3b8;
+ font-size: 0.9rem;
+}
+
+/* Additional Stats */
+.additional-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 1rem;
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.stat-item {
+ text-align: center;
+ padding: 0.75rem;
+}
+
+.stat-item .stat-label {
+ display: block;
+ font-size: 0.8rem;
+ color: #64748b;
+ margin-bottom: 0.25rem;
+}
+
+.stat-item .stat-value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+/* Error State */
+.error-state {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: white;
+ border-radius: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.error-state h2 {
+ color: #dc2626;
+ margin-bottom: 1rem;
+}
+
+.error-state p {
+ color: #64748b;
+ margin-bottom: 1.5rem;
+}
+
+/* Loading State */
+.loading {
+ text-align: center;
+ padding: 4rem;
+ color: #64748b;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .analytics-page {
+ padding: 1rem;
+ }
+
+ .analytics-header h1 {
+ font-size: 1.4rem;
+ }
+
+ .summary-cards {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .summary-card {
+ padding: 1rem;
+ }
+
+ .card-icon {
+ width: 40px;
+ height: 40px;
+ font-size: 1.5rem;
+ }
+
+ .card-value {
+ font-size: 1.25rem;
+ }
+
+ .charts-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .bar-chart {
+ height: 160px;
+ }
+
+ .hours-chart {
+ height: 120px;
+ }
+
+ .hour-label {
+ display: none;
+ }
+
+ .hour-bar-container:nth-child(6n+1) .hour-label {
+ display: block;
+ }
+
+ .bar-container {
+ min-width: 16px;
+ }
+
+ .bar-label {
+ display: none;
+ }
+
+ .bar-container:nth-child(5n+1) .bar-label {
+ display: block;
+ }
+
+ .additional-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 480px) {
+ .summary-cards {
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+ }
+
+ .summary-card {
+ flex-direction: column;
+ text-align: center;
+ gap: 0.5rem;
+ }
+}
diff --git a/frontend/src/pages/GameAnalytics.jsx b/frontend/src/pages/GameAnalytics.jsx
new file mode 100644
index 0000000..219c323
--- /dev/null
+++ b/frontend/src/pages/GameAnalytics.jsx
@@ -0,0 +1,246 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './GameAnalytics.css';
+
+export default function GameAnalytics() {
+ const { gameId } = useParams();
+ const { user, isAuthenticated } = useAuth();
+ const navigate = useNavigate();
+
+ const [analytics, setAnalytics] = useState(null);
+ const [game, setGame] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!isAuthenticated || user?.isGuest) {
+ navigate('/login');
+ return;
+ }
+ loadAnalytics();
+ }, [isAuthenticated, user, gameId]);
+
+ const loadAnalytics = async () => {
+ try {
+ const [analyticsData, gameData] = await Promise.all([
+ api.getGameAnalytics(gameId),
+ api.getMyGame(gameId)
+ ]);
+ setAnalytics(analyticsData);
+ setGame(gameData.game);
+ } catch (err) {
+ console.error('Failed to load analytics:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatTime = (seconds) => {
+ if (!seconds) return '0m';
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ if (hours > 0) return `${hours}h ${mins}m`;
+ return `${mins}m`;
+ };
+
+ const getMaxValue = (arr) => Math.max(...arr, 1);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
+
Error Loading Analytics
+
{error}
+
Back to Dashboard
+
+
+ );
+ }
+
+ const { dailyPlays, ratingDistribution, peakHours, scoreDistribution, summary } = analytics;
+
+ // Calculate max values for scaling
+ const maxDailyPlays = getMaxValue(dailyPlays.map(d => d.plays));
+ const maxRating = getMaxValue(Object.values(ratingDistribution));
+ const maxHour = getMaxValue(Object.values(peakHours));
+ const maxScore = getMaxValue(Object.values(scoreDistribution));
+
+ return (
+
+ {/* Header */}
+
+
+
Back to Dashboard
+
{game?.title || 'Game'} Analytics
+
+
+ {game?.status?.replace('_', ' ')}
+
+
+ Published: {game?.publishedAt ? new Date(game.publishedAt).toLocaleDateString() : 'Not published'}
+
+
+
+
+
+ {/* Summary Cards */}
+
+
+
▶
+
+
{summary.totalPlays.toLocaleString()}
+
Total Plays
+
+
+
+
👤
+
+
{summary.uniquePlayers.toLocaleString()}
+
Unique Players
+
+
+
+
⏱
+
+
{formatTime(summary.avgSessionDuration)}
+
Avg Session
+
+
+
+
♥
+
+
{summary.favoriteCount.toLocaleString()}
+
Favorites
+
+
+
+
+ {/* Charts Grid */}
+
+ {/* Daily Plays Chart */}
+
+
Daily Plays (Last 30 Days)
+
+ {dailyPlays.map((day, i) => (
+
+
+ {day.plays > 0 && {day.plays} }
+
+
{day.label}
+
+ ))}
+
+ {dailyPlays.every(d => d.plays === 0) && (
+
No plays recorded yet
+ )}
+
+
+ {/* Rating Distribution */}
+
+
Rating Distribution
+
+ {[5, 4, 3, 2, 1].map(star => (
+
+
{star} star{star !== 1 ? 's' : ''}
+
+
0 ? (ratingDistribution[star] / maxRating) * 100 : 0}%`,
+ backgroundColor: star >= 4 ? '#10b981' : star === 3 ? '#f59e0b' : '#ef4444'
+ }}
+ />
+
+
{ratingDistribution[star]}
+
+ ))}
+
+
+ Average: {summary.avgRating ? summary.avgRating.toFixed(1) : 'N/A'} / 5
+
+
+
+ {/* Peak Hours Chart */}
+
+
Peak Play Hours (24h)
+
+ {Object.entries(peakHours).map(([hour, count]) => (
+
+
0 ? (count / maxHour) * 100 : 0}%`,
+ backgroundColor: count === maxHour && count > 0 ? '#6366f1' : '#94a3b8'
+ }}
+ title={`${hour}:00 - ${count} plays`}
+ />
+ {hour}
+
+ ))}
+
+ {Object.values(peakHours).every(v => v === 0) && (
+
No play time data yet
+ )}
+
+
+ {/* High Score Distribution */}
+
+
High Score Distribution
+
+ {Object.entries(scoreDistribution).map(([range, count]) => (
+
+
{range}
+
+
0 ? (count / maxScore) * 100 : 0}%`
+ }}
+ />
+
+
{count}
+
+ ))}
+
+ {Object.values(scoreDistribution).every(v => v === 0) && (
+
No high scores recorded yet
+ )}
+
+
+
+ {/* Additional Stats */}
+
+
+ Highest Score
+ {summary.highestScore?.toLocaleString() || 'N/A'}
+
+
+ Total Ratings
+ {summary.totalRatings}
+
+
+ Remix Count
+ {summary.remixCount}
+
+
+ Tokens Earned
+ {summary.tokensEarned}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GameStats.css b/frontend/src/pages/GameStats.css
new file mode 100644
index 0000000..2ac32f9
--- /dev/null
+++ b/frontend/src/pages/GameStats.css
@@ -0,0 +1,488 @@
+.game-stats-page {
+ min-height: calc(100vh - 60px);
+ background: var(--bg-color);
+ padding-bottom: 2rem;
+}
+
+/* Loading & Error States */
+.game-stats-page .loading-state,
+.game-stats-page .error-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ text-align: center;
+ padding: 2rem;
+}
+
+.game-stats-page .loading-spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid rgba(255, 255, 255, 0.1);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.error-state .error-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+}
+
+/* Stats Header */
+.stats-header {
+ background: linear-gradient(135deg, var(--card-bg) 0%, #1a1a2e 100%);
+ padding: 2rem;
+ display: flex;
+ gap: 2rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.game-preview {
+ flex-shrink: 0;
+}
+
+.game-thumbnail {
+ width: 200px;
+ height: 150px;
+ object-fit: cover;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+}
+
+.game-placeholder {
+ width: 200px;
+ height: 150px;
+ background: linear-gradient(135deg, #2a2a4a, #1a1a2e);
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 4rem;
+}
+
+.game-info {
+ flex: 1;
+}
+
+.game-info h1 {
+ font-size: 2rem;
+ margin: 0 0 0.5rem 0;
+}
+
+.creator-link {
+ color: var(--primary);
+ text-decoration: none;
+ font-size: 1rem;
+ display: inline-block;
+ margin-bottom: 0.75rem;
+}
+
+.creator-link:hover {
+ text-decoration: underline;
+}
+
+.game-description {
+ color: var(--text-secondary);
+ margin: 0 0 1rem 0;
+ max-width: 600px;
+ line-height: 1.5;
+}
+
+.quick-stats {
+ display: flex;
+ gap: 2rem;
+ margin-bottom: 1.5rem;
+}
+
+.quick-stat {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.quick-stat .stat-icon {
+ font-size: 1.25rem;
+}
+
+.quick-stat .stat-value {
+ font-size: 1.25rem;
+ font-weight: 700;
+}
+
+.quick-stat .stat-label {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.header-actions {
+ display: flex;
+ gap: 1rem;
+}
+
+/* Tabs */
+.stats-tabs {
+ display: flex;
+ gap: 0.5rem;
+ padding: 1rem 2rem;
+ background: var(--card-bg);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.tab-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 1rem;
+ transition: all 0.2s;
+}
+
+.tab-btn:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text-primary);
+}
+
+.tab-btn.active {
+ background: var(--primary);
+ color: white;
+}
+
+.tab-icon {
+ font-size: 1.25rem;
+}
+
+/* Tab Content */
+.tab-content {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+/* Overview Tab */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--card-bg);
+ border-radius: 12px;
+ padding: 1.5rem;
+ text-align: center;
+ border: 1px solid var(--border-color);
+}
+
+.stat-card h3 {
+ margin: 0 0 0.5rem 0;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.big-number {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+}
+
+.big-number .star {
+ font-size: 2rem;
+}
+
+.stat-subtitle {
+ margin: 0.5rem 0 0 0;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.style-badge {
+ display: inline-block;
+ background: var(--primary);
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-weight: 600;
+ text-transform: capitalize;
+}
+
+/* Preview Section */
+.preview-section {
+ background: var(--card-bg);
+ border-radius: 12px;
+ padding: 1.5rem;
+ border: 1px solid var(--border-color);
+}
+
+.preview-section h3 {
+ margin: 0 0 1rem 0;
+}
+
+.top-players-preview {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.top-player {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 8px;
+}
+
+.top-player.rank-1 {
+ background: rgba(255, 215, 0, 0.1);
+}
+
+.top-player.rank-2 {
+ background: rgba(192, 192, 192, 0.1);
+}
+
+.top-player.rank-3 {
+ background: rgba(205, 127, 50, 0.1);
+}
+
+.rank-badge {
+ font-size: 1.5rem;
+}
+
+.player-name {
+ flex: 1;
+ font-weight: 500;
+}
+
+.player-score {
+ font-weight: 700;
+ color: var(--primary);
+}
+
+/* Leaderboard Tab */
+.leaderboard-tab h3 {
+ margin: 0 0 1.5rem 0;
+}
+
+.leaderboard-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.leaderboard-item {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ background: var(--card-bg);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ transition: all 0.2s;
+}
+
+.leaderboard-item:hover {
+ border-color: var(--primary);
+}
+
+.leaderboard-item.top-three {
+ border-width: 2px;
+}
+
+.leaderboard-item.top-three:nth-child(1) {
+ border-color: gold;
+ background: rgba(255, 215, 0, 0.05);
+}
+
+.leaderboard-item.top-three:nth-child(2) {
+ border-color: silver;
+ background: rgba(192, 192, 192, 0.05);
+}
+
+.leaderboard-item.top-three:nth-child(3) {
+ border-color: #cd7f32;
+ background: rgba(205, 127, 50, 0.05);
+}
+
+.leaderboard-item .rank {
+ width: 40px;
+ text-align: center;
+ font-size: 1.25rem;
+ font-weight: 700;
+}
+
+.leaderboard-item .player-info {
+ flex: 1;
+}
+
+.leaderboard-item .player-name {
+ font-weight: 600;
+}
+
+.leaderboard-item .score-info {
+ text-align: right;
+}
+
+.leaderboard-item .score {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--primary);
+ display: block;
+}
+
+.leaderboard-item .date {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+/* Ratings Tab */
+.rating-summary {
+ background: var(--card-bg);
+ border-radius: 12px;
+ padding: 2rem;
+ border: 1px solid var(--border-color);
+ margin-bottom: 2rem;
+}
+
+.avg-rating {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+}
+
+.big-star {
+ font-size: 3rem;
+}
+
+.big-rating {
+ font-size: 4rem;
+ font-weight: 700;
+}
+
+.rating-count {
+ color: var(--text-secondary);
+ margin-left: 0.5rem;
+}
+
+.rating-bars {
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.rating-bar-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 0.75rem;
+}
+
+.star-label {
+ width: 50px;
+ text-align: right;
+}
+
+.bar-container {
+ flex: 1;
+ height: 8px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--primary), #fbbf24);
+ border-radius: 4px;
+ transition: width 0.3s;
+}
+
+.percentage {
+ width: 40px;
+ text-align: right;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.rate-cta {
+ text-align: center;
+ padding: 2rem;
+ background: var(--card-bg);
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+}
+
+.rate-cta p {
+ margin: 0 0 1rem 0;
+ color: var(--text-secondary);
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ background: var(--card-bg);
+ border-radius: 12px;
+}
+
+.empty-state .empty-icon {
+ font-size: 4rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+/* Mobile Responsive */
+@media (max-width: 768px) {
+ .stats-header {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ .game-info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .quick-stats {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .stats-tabs {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .tab-btn {
+ white-space: nowrap;
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .big-number {
+ font-size: 2rem;
+ }
+
+ .big-rating {
+ font-size: 3rem;
+ }
+}
diff --git a/frontend/src/pages/GameStats.jsx b/frontend/src/pages/GameStats.jsx
new file mode 100644
index 0000000..a38c630
--- /dev/null
+++ b/frontend/src/pages/GameStats.jsx
@@ -0,0 +1,256 @@
+import { useState, useEffect } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import api from '../api';
+import { AvatarMini, DEFAULT_AVATAR } from '../components/AvatarBuilder';
+import './GameStats.css';
+
+const TABS = [
+ { id: 'overview', label: 'Overview', icon: '📊' },
+ { id: 'leaderboard', label: 'Leaderboard', icon: '🏆' },
+ { id: 'ratings', label: 'Ratings', icon: '⭐' }
+];
+
+export default function GameStats() {
+ const { gameId } = useParams();
+ const [game, setGame] = useState(null);
+ const [highScores, setHighScores] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [activeTab, setActiveTab] = useState('overview');
+
+ useEffect(() => {
+ loadGameStats();
+ }, [gameId]);
+
+ async function loadGameStats() {
+ setLoading(true);
+ try {
+ const [gameData, scoresData] = await Promise.all([
+ api.getGame(gameId),
+ api.getHighScores(gameId)
+ ]);
+
+ setGame(gameData.game);
+ setHighScores(scoresData.highScores || []);
+ } catch (err) {
+ setError(err.message || 'Failed to load game stats');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+
Loading game stats...
+
+
+ );
+ }
+
+ if (error || !game) {
+ return (
+
+
+
😕
+
Game Not Found
+
{error}
+
Browse Arcade
+
+
+ );
+ }
+
+ const avgRating = game.totalRatings > 0
+ ? (game.averageRating || 0).toFixed(1)
+ : '0.0';
+
+ const ratingDistribution = [5, 4, 3, 2, 1].map(stars => {
+ // Simulated distribution (in a real app, this would come from API)
+ const total = game.totalRatings || 1;
+ const percentage = game.totalRatings > 0
+ ? Math.max(0, Math.floor((stars === 5 ? 0.6 : stars === 4 ? 0.25 : stars === 3 ? 0.1 : 0.05) * 100))
+ : 0;
+ return { stars, percentage };
+ });
+
+ return (
+
+ {/* Game Header */}
+
+
+ {game.thumbnailData ? (
+
+ ) : (
+
🎮
+ )}
+
+
+
+
{game.title}
+
+ by {game.creatorDisplayName || game.creatorUsername}
+
+
{game.description}
+
+
+
+ ▶️
+ {game.playCount?.toLocaleString() || 0}
+ Plays
+
+
+ ⭐
+ {avgRating}
+ Rating
+
+
+ ❤️
+ {game.favoriteCount?.toLocaleString() || 0}
+ Favorites
+
+
+
+
+ Play Game
+
+
+
+
+ {/* Tabs */}
+
+ {TABS.map(tab => (
+ setActiveTab(tab.id)}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ {/* Tab Content */}
+
+ {activeTab === 'overview' && (
+
+
+
+
Total Plays
+
{game.playCount?.toLocaleString() || 0}
+
All-time plays
+
+
+
+
Average Rating
+
+ ⭐
+ {avgRating}
+
+
{game.totalRatings || 0} ratings
+
+
+
+
Favorites
+
{game.favoriteCount?.toLocaleString() || 0}
+
Players love this game
+
+
+
+
Game Style
+
{game.gameStyle || 'Classic'}
+
Published {new Date(game.publishedAt).toLocaleDateString()}
+
+
+
+ {/* Top Players Preview */}
+ {highScores.length > 0 && (
+
+
Top Players
+
+ {highScores.slice(0, 3).map((score, i) => (
+
+
+ {i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'}
+
+ {score.displayName || score.username}
+ {score.score.toLocaleString()}
+
+ ))}
+
+
setActiveTab('leaderboard')}
+ >
+ View Full Leaderboard
+
+
+ )}
+
+ )}
+
+ {activeTab === 'leaderboard' && (
+
+
High Scores
+ {highScores.length === 0 ? (
+
+
🏆
+
No high scores yet. Be the first!
+
Play Now
+
+ ) : (
+
+ {highScores.map((score, i) => (
+
+
+ {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i + 1}`}
+
+
+ {score.displayName || score.username}
+
+
+ {score.score.toLocaleString()}
+ {new Date(score.achievedAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'ratings' && (
+
+
+
+ ⭐
+ {avgRating}
+ ({game.totalRatings || 0} ratings)
+
+
+
+ {ratingDistribution.map(({ stars, percentage }) => (
+
+
{stars} ⭐
+
+
{percentage}%
+
+ ))}
+
+
+
+
+
Have you played this game?
+
Play & Rate
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css
new file mode 100644
index 0000000..bd94db9
--- /dev/null
+++ b/frontend/src/pages/Home.css
@@ -0,0 +1,494 @@
+.home {
+ min-height: calc(100vh - 60px);
+}
+
+.hero {
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+ padding: 3rem 1.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2rem;
+ justify-content: center;
+ align-items: center;
+}
+
+.hero-content {
+ max-width: 550px;
+}
+
+.hero-tagline {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #6366f1;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin: 0 0 0.75rem;
+}
+
+.hero-title {
+ font-size: 2.25rem;
+ line-height: 1.2;
+ margin: 0 0 1rem;
+ color: #1e293b;
+}
+
+.highlight {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.hero-subtitle {
+ font-size: 1.05rem;
+ color: #475569;
+ margin: 0 0 1.5rem;
+ line-height: 1.6;
+}
+
+.hero-actions {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.25rem;
+}
+
+.hero-badges {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.hero-badges .badge {
+ background: rgba(99, 102, 241, 0.1);
+ color: #4f46e5;
+ padding: 0.35rem 0.75rem;
+ border-radius: 2rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.hero-badges .badge.badge-rating {
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ color: white;
+ font-weight: 600;
+}
+
+.hero-visual {
+ width: 250px;
+ height: 250px;
+ position: relative;
+}
+
+.floating-games {
+ width: 100%;
+ height: 100%;
+ position: relative;
+}
+
+.float-item {
+ position: absolute;
+ font-size: 2.5rem;
+ animation: float 3s ease-in-out infinite;
+}
+
+.float-item:nth-child(1) { top: 10%; left: 20%; animation-delay: 0s; }
+.float-item:nth-child(2) { top: 20%; right: 15%; animation-delay: 0.5s; }
+.float-item:nth-child(3) { bottom: 25%; left: 10%; animation-delay: 1s; }
+.float-item:nth-child(4) { bottom: 15%; right: 25%; animation-delay: 1.5s; }
+
+@keyframes float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-15px); }
+}
+
+/* Theme section */
+.theme-section {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+}
+
+/* Mission section */
+.mission {
+ background: #f8fafc;
+ padding: 2.5rem 1.5rem;
+ text-align: center;
+}
+
+.mission-content {
+ max-width: 700px;
+ margin: 0 auto;
+}
+
+.mission .section-title {
+ margin-bottom: 1rem;
+}
+
+.mission-text {
+ font-size: 1.1rem;
+ color: #475569;
+ line-height: 1.7;
+ margin: 0;
+}
+
+/* Features section */
+.features {
+ padding: 3rem 1.5rem;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.section-title {
+ text-align: center;
+ font-size: 1.75rem;
+ margin: 0 0 2rem;
+ color: #1e293b;
+}
+
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.25rem;
+}
+
+.feature-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ text-align: center;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+ border: 1px solid #e2e8f0;
+}
+
+.feature-icon {
+ font-size: 2rem;
+ margin-bottom: 0.5rem;
+}
+
+.feature-card h3 {
+ font-size: 1rem;
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.feature-card p {
+ font-size: 0.9rem;
+ color: #64748b;
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* How it works section */
+.how-it-works {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ padding: 3rem 1.5rem;
+ color: white;
+}
+
+.how-it-works .section-title {
+ color: white;
+}
+
+.steps-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.5rem;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.step-card {
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 1rem;
+ padding: 1.5rem;
+ text-align: center;
+}
+
+.step-number {
+ width: 40px;
+ height: 40px;
+ background: white;
+ color: #6366f1;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 1.25rem;
+ margin: 0 auto 0.75rem;
+}
+
+.step-card h3 {
+ font-size: 1.1rem;
+ margin: 0 0 0.5rem;
+}
+
+.step-card p {
+ font-size: 0.9rem;
+ opacity: 0.9;
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* For educators section */
+.for-educators {
+ padding: 3rem 1.5rem;
+ background: #f8fafc;
+}
+
+.educators-content {
+ max-width: 1000px;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2rem;
+ align-items: center;
+}
+
+.educators-text h2 {
+ font-size: 1.5rem;
+ color: #1e293b;
+ margin: 0 0 1.25rem;
+}
+
+.benefits-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1.5rem;
+}
+
+.benefits-list li {
+ padding: 0.5rem 0;
+ padding-left: 1.5rem;
+ position: relative;
+ color: #475569;
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+.benefits-list li::before {
+ content: "✓";
+ position: absolute;
+ left: 0;
+ color: #10b981;
+ font-weight: 700;
+}
+
+.benefits-list strong {
+ color: #1e293b;
+}
+
+.educators-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.btn-outline {
+ background: transparent;
+ color: #6366f1;
+ border: 2px solid #6366f1;
+ padding: 0.6rem 1.25rem;
+ border-radius: 0.5rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition: all 0.2s;
+}
+
+.btn-outline:hover {
+ background: #6366f1;
+ color: white;
+}
+
+.testimonial {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
+ border-left: 4px solid #6366f1;
+}
+
+.testimonial p {
+ font-size: 1.05rem;
+ color: #475569;
+ font-style: italic;
+ line-height: 1.6;
+ margin: 0 0 0.75rem;
+}
+
+.testimonial-author {
+ font-size: 0.9rem;
+ color: #94a3b8;
+ font-weight: 500;
+}
+
+/* Safety section */
+.safety {
+ padding: 3rem 1.5rem;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.safety-rating-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.rating-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ color: white;
+ font-size: 1.5rem;
+ font-weight: 800;
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
+}
+
+.rating-text {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #10b981;
+}
+
+.safety-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.25rem;
+}
+
+.safety-item {
+ text-align: center;
+ padding: 1rem;
+}
+
+.safety-icon {
+ font-size: 2rem;
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.safety-item h4 {
+ font-size: 1rem;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+}
+
+.safety-item p {
+ font-size: 0.9rem;
+ color: #64748b;
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* CTA section */
+.cta-section {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ padding: 3rem 1.5rem;
+ text-align: center;
+ color: white;
+}
+
+.cta-section h2 {
+ font-size: 1.75rem;
+ margin: 0 0 0.5rem;
+}
+
+.cta-section p {
+ font-size: 1rem;
+ margin: 0 0 1.25rem;
+ opacity: 0.9;
+}
+
+.cta-buttons {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.cta-section .btn-primary {
+ background: white;
+ color: #6366f1;
+}
+
+.cta-section .btn-primary:hover {
+ background: #f8fafc;
+}
+
+.cta-section .btn-secondary {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: 2px solid white;
+}
+
+.cta-section .btn-secondary:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+@media (max-width: 768px) {
+ .hero {
+ padding: 2rem 1rem;
+ }
+
+ .hero-title {
+ font-size: 1.75rem;
+ }
+
+ .hero-visual {
+ width: 180px;
+ height: 180px;
+ }
+
+ .float-item {
+ font-size: 2rem;
+ }
+
+ .educators-content {
+ grid-template-columns: 1fr;
+ }
+
+ .educators-visual {
+ order: -1;
+ }
+
+ .section-title {
+ font-size: 1.5rem;
+ }
+
+ .mission,
+ .features,
+ .how-it-works,
+ .for-educators,
+ .safety,
+ .cta-section {
+ padding: 2rem 1rem;
+ }
+
+ /* Safety rating badge mobile */
+ .safety-rating-badge {
+ gap: 0.5rem;
+ }
+
+ .rating-icon {
+ width: 40px;
+ height: 40px;
+ font-size: 1.25rem;
+ }
+
+ .rating-text {
+ font-size: 1.1rem;
+ }
+
+ /* Hero badges wrap gracefully */
+ .hero-badges .badge {
+ flex-shrink: 0;
+ }
+}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000..3a029b1
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,205 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import FingerprintJS from '@fingerprintjs/fingerprintjs';
+import { useAuth } from '../context/AuthContext';
+import './Home.css';
+
+export default function Home() {
+ const { isAuthenticated, guestLogin } = useAuth();
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(false);
+
+ const handlePlayAsGuest = async () => {
+ if (isAuthenticated) {
+ navigate('/arcade');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const fp = await FingerprintJS.load();
+ const result = await fp.get();
+ await guestLogin(result.visitorId);
+ navigate('/arcade');
+ } catch (error) {
+ console.error('Guest login failed:', error);
+ alert('Failed to start guest session. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
Teaching AI Literacy Through Play
+
+ Where Everyone Learns to
+
+ Compose Games with AI
+
+
+ GamerComp teaches effective AI communication through game creation.
+ Learn prompt engineering by building your own video games - a skill that
+ prepares you for the AI-powered future.
+
+
+
+
+ Browse Games
+
+
+
+
+ Rated E for Everyone
+ No Chat - No Strangers
+ All Games Moderated
+
+
+
+
+
+
+
+
+
Preparing for an AI-Powered World
+
+ AI isn't going away. By learning to communicate clearly with AI systems now,
+ you develop critical thinking skills you'll use your entire life.
+ GamerComp makes this learning fun through game creation.
+
+
+
+
+
+ What You'll Learn
+
+
+
💬
+
Clear Communication
+
Practice expressing ideas precisely. Vague prompts create vague games - learn why specificity matters.
+
+
+
🔄
+
Iterative Thinking
+
Games rarely come out perfect the first time. Learn to give feedback and refine your creations.
+
+
+
🧩
+
Problem Decomposition
+
Breaking down a game idea into theme, characters, goals, and mechanics teaches structured thinking.
+
+
+
🎯
+
Goal Setting
+
Defining what makes a game fun helps you understand how to set and communicate clear objectives.
+
+
+
+
+
+ How GamerComp Works
+
+
+
1
+
Answer Questions
+
Our guided wizard asks about theme, characters, goals, and challenges. No blank page intimidation.
+
+
+
2
+
AI Creates the Game
+
Their answers become a detailed prompt. The AI generates a fully playable game in seconds.
+
+
+
3
+
Test & Refine
+
Play the game, then give feedback. Learn how specific feedback creates better results.
+
+
+
4
+
Share & Earn
+
Publish games for others to play. Earn XP and level up as a Game Composer.
+
+
+
+
+
+
+
+
For Teachers & Parents
+
+ Classroom-ready: Teacher accounts can create student groups and track progress
+ Safe environment: All content is moderated. No personal info collection.
+ Curriculum aligned: Supports computational thinking standards (ISTE, CS)
+ Progress tracking: See what students create and how their skills develop
+ No cost: Free for educational use with generous daily limits
+
+
+ Create Teacher Account
+ Preview Games
+
+
+
+
+
"My students are finally excited about learning to communicate clearly. They don't even realize they're learning!"
+
- Middle School Teacher
+
+
+
+
+
+
+
+ E
+ Rated E for Everyone
+
+ Built for Child Safety
+
+
+
🚫
+
No Chat or Messaging
+
Players cannot communicate with each other. No strangers, no predators, no bullying.
+
+
+
🛡️
+
All Games Moderated
+
Every game is reviewed before publishing. AI filters block inappropriate content automatically.
+
+
+
🔒
+
Privacy First
+
No real names required. Minimal data collection. COPPA-aware design.
+
+
+
👨👩👧👦
+
Family Friendly
+
Designed for learners of all ages. Clean, friendly content throughout.
+
+
+
+
+
+ Ready to Try It?
+ No signup required to play. Create an account to save progress and publish games.
+
+
+ {loading ? 'Starting...' : 'Start Playing Free'}
+
+ {!isAuthenticated && (
+
+ Create Free Account
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Leaderboard.css b/frontend/src/pages/Leaderboard.css
new file mode 100644
index 0000000..31e4fbc
--- /dev/null
+++ b/frontend/src/pages/Leaderboard.css
@@ -0,0 +1,806 @@
+.leaderboard-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: linear-gradient(180deg, #fef3c7 0%, #fde68a 30%, #fbbf24 100%);
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+/* Header */
+.leaderboard-header {
+ text-align: center;
+ margin-bottom: 2rem;
+ position: relative;
+}
+
+.header-decoration {
+ position: absolute;
+ top: -10px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 200px;
+ height: 60px;
+ pointer-events: none;
+}
+
+.decoration-star {
+ position: absolute;
+ font-size: 1.5rem;
+ animation: twinkle 2s ease-in-out infinite;
+}
+
+.star-1 {
+ left: 10%;
+ animation-delay: 0s;
+}
+
+.star-2 {
+ left: 50%;
+ transform: translateX(-50%);
+ animation-delay: 0.5s;
+}
+
+.star-3 {
+ right: 10%;
+ animation-delay: 1s;
+}
+
+@keyframes twinkle {
+ 0%, 100% {
+ opacity: 0.5;
+ transform: scale(0.8);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.1);
+ }
+}
+
+.leaderboard-title {
+ font-size: 2.5rem;
+ color: #78350f;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ text-shadow: 2px 2px 0 rgba(255, 255, 255, 0.5);
+}
+
+.title-trophy {
+ animation: bounce 2s ease-in-out infinite;
+}
+
+.title-trophy:last-child {
+ animation-delay: 0.5s;
+}
+
+@keyframes bounce {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+
+.leaderboard-subtitle {
+ color: #92400e;
+ margin: 0.5rem 0 0;
+ font-size: 1.1rem;
+}
+
+/* Tabs */
+.tabs-container {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.tab-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: white;
+ border: 3px solid #f59e0b;
+ padding: 0.75rem 1.25rem;
+ border-radius: 2rem;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #78350f;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 0 #d97706;
+}
+
+.tab-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 0 #d97706;
+}
+
+.tab-btn.active {
+ background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
+ color: white;
+ transform: translateY(2px);
+ box-shadow: 0 2px 0 #d97706;
+}
+
+.tab-icon {
+ font-size: 1.25rem;
+}
+
+/* Period Filters */
+.period-filters {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.period-btn {
+ background: rgba(255, 255, 255, 0.7);
+ border: 2px solid rgba(120, 53, 15, 0.2);
+ padding: 0.5rem 1rem;
+ border-radius: 1rem;
+ font-size: 0.9rem;
+ color: #78350f;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.period-btn:hover {
+ background: white;
+ border-color: #f59e0b;
+}
+
+.period-btn.active {
+ background: #78350f;
+ color: white;
+ border-color: #78350f;
+}
+
+/* Podium */
+.podium-section {
+ margin-bottom: 2rem;
+}
+
+.podium {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ gap: 0.5rem;
+ padding: 1rem;
+}
+
+.podium-spot {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ animation: podiumEntrance 0.6s ease-out backwards;
+}
+
+.podium-1 {
+ animation-delay: 0.3s;
+}
+
+.podium-2 {
+ animation-delay: 0.1s;
+}
+
+.podium-3 {
+ animation-delay: 0.2s;
+}
+
+@keyframes podiumEntrance {
+ from {
+ opacity: 0;
+ transform: translateY(50px) scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.podium-medal {
+ font-size: 2rem;
+ position: absolute;
+ top: -20px;
+ animation: medalPulse 2s ease-in-out infinite;
+}
+
+.podium-1 .podium-medal {
+ font-size: 2.5rem;
+ top: -30px;
+}
+
+@keyframes medalPulse {
+ 0%, 100% {
+ transform: scale(1) rotate(-5deg);
+ }
+ 50% {
+ transform: scale(1.1) rotate(5deg);
+ }
+}
+
+.podium-avatar-link {
+ display: block;
+ margin-bottom: 0.5rem;
+ transition: transform 0.3s;
+}
+
+.podium-avatar-link:hover {
+ transform: scale(1.1);
+}
+
+.podium-game-thumb {
+ width: 60px;
+ height: 60px;
+ border-radius: 12px;
+ background: white;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 3px solid white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.podium-1 .podium-game-thumb {
+ width: 80px;
+ height: 80px;
+}
+
+.podium-game-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.podium-game-thumb .game-icon {
+ font-size: 2rem;
+}
+
+.podium-info {
+ text-align: center;
+ margin-bottom: 0.5rem;
+}
+
+.podium-name {
+ display: block;
+ font-weight: 700;
+ color: #78350f;
+ text-decoration: none;
+ font-size: 0.9rem;
+ max-width: 100px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.podium-name:hover {
+ text-decoration: underline;
+}
+
+.podium-stat {
+ font-size: 0.75rem;
+ color: #92400e;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ flex-wrap: wrap;
+}
+
+.podium-stand {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 800;
+ color: white;
+ border-radius: 8px 8px 0 0;
+ position: relative;
+ overflow: hidden;
+}
+
+.podium-stand::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 10px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, transparent 100%);
+}
+
+.podium-stand-1 {
+ width: 120px;
+ height: 100px;
+ background: linear-gradient(135deg, #ffd700 0%, #ffb700 50%, #cc9500 100%);
+ font-size: 2rem;
+ box-shadow: 0 8px 20px rgba(255, 183, 0, 0.4);
+}
+
+.podium-stand-2 {
+ width: 100px;
+ height: 70px;
+ background: linear-gradient(135deg, #e8e8e8 0%, #c0c0c0 50%, #a0a0a0 100%);
+ font-size: 1.5rem;
+ box-shadow: 0 8px 20px rgba(192, 192, 192, 0.4);
+}
+
+.podium-stand-3 {
+ width: 100px;
+ height: 50px;
+ background: linear-gradient(135deg, #cd7f32 0%, #b8702c 50%, #8b5a2b 100%);
+ font-size: 1.25rem;
+ box-shadow: 0 8px 20px rgba(205, 127, 50, 0.4);
+}
+
+.podium-rank-num {
+ text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.2);
+}
+
+/* Leaderboard List */
+.leaderboard-list {
+ background: white;
+ border-radius: 1.5rem;
+ padding: 1.5rem;
+ box-shadow: 0 8px 30px rgba(120, 53, 15, 0.15);
+}
+
+.list-header {
+ text-align: center;
+ color: #78350f;
+ margin: 0 0 1rem;
+ font-size: 1.25rem;
+}
+
+.leaderboard-item {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 1rem;
+ margin-bottom: 0.5rem;
+ background: #fefce8;
+ transition: all 0.3s ease;
+ position: relative;
+ overflow: hidden;
+ animation: slideIn 0.4s ease-out backwards;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.leaderboard-item:hover {
+ transform: translateX(5px);
+ box-shadow: 0 4px 12px rgba(120, 53, 15, 0.1);
+}
+
+.leaderboard-item.gold {
+ background: linear-gradient(90deg, #fef9c3 0%, #fef3c7 100%);
+ border: 2px solid #fcd34d;
+}
+
+.leaderboard-item.silver {
+ background: linear-gradient(90deg, #f5f5f5 0%, #e5e5e5 100%);
+ border: 2px solid #d4d4d4;
+}
+
+.leaderboard-item.bronze {
+ background: linear-gradient(90deg, #fef3e6 0%, #fed7aa 100%);
+ border: 2px solid #fdba74;
+}
+
+.item-shine {
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ animation: shine 3s ease-in-out infinite;
+}
+
+@keyframes shine {
+ 0% {
+ left: -100%;
+ }
+ 50%, 100% {
+ left: 150%;
+ }
+}
+
+/* Rank Badge */
+.rank-badge {
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.rank-badge.top-three {
+ background: none;
+}
+
+.rank-medal {
+ font-size: 2rem;
+}
+
+.rank-number {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #92400e;
+ background: white;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid #fcd34d;
+}
+
+/* Item Avatar */
+.item-avatar-link {
+ display: block;
+ flex-shrink: 0;
+ transition: transform 0.2s;
+}
+
+.item-avatar-link:hover {
+ transform: scale(1.1);
+}
+
+.item-game-thumb {
+ width: 48px;
+ height: 48px;
+ border-radius: 10px;
+ background: white;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid #fcd34d;
+}
+
+.item-game-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.item-game-thumb .game-icon {
+ font-size: 1.5rem;
+}
+
+/* Item Info */
+.item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.item-name {
+ display: block;
+ font-weight: 700;
+ color: #78350f;
+ text-decoration: none;
+ font-size: 1rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.item-name:hover {
+ text-decoration: underline;
+}
+
+.item-creator,
+.item-level {
+ display: block;
+ font-size: 0.8rem;
+ color: #92400e;
+ margin-top: 0.125rem;
+}
+
+/* Item Stats */
+.item-stats {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ font-size: 0.9rem;
+ color: #78350f;
+}
+
+.stat-icon {
+ font-size: 1rem;
+}
+
+.stat-num {
+ font-weight: 700;
+ font-size: 1.1rem;
+}
+
+.stat-label {
+ color: #92400e;
+ font-size: 0.85rem;
+}
+
+.stat-secondary {
+ color: #a16207;
+ font-size: 0.8rem;
+ margin-left: 0.5rem;
+ padding-left: 0.5rem;
+ border-left: 1px solid #fcd34d;
+}
+
+/* Loading State */
+.loading-state {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: white;
+ border-radius: 1.5rem;
+ box-shadow: 0 8px 30px rgba(120, 53, 15, 0.15);
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 4px solid #fef3c7;
+ border-top-color: #f59e0b;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 1rem;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.loading-state p {
+ color: #92400e;
+ font-size: 1.1rem;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: white;
+ border-radius: 1.5rem;
+ box-shadow: 0 8px 30px rgba(120, 53, 15, 0.15);
+}
+
+.empty-icon {
+ font-size: 4rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.empty-state h3 {
+ color: #78350f;
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+}
+
+.empty-state p {
+ color: #92400e;
+ margin: 0;
+}
+
+/* Load More */
+.load-more-section {
+ text-align: center;
+ margin-top: 1.5rem;
+}
+
+.load-more-btn {
+ background: white;
+ border: 3px solid #f59e0b;
+ padding: 0.75rem 2rem;
+ border-radius: 2rem;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #78350f;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ box-shadow: 0 4px 0 #d97706;
+}
+
+.load-more-btn:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 0 #d97706;
+}
+
+.load-more-btn:active:not(:disabled) {
+ transform: translateY(2px);
+ box-shadow: 0 2px 0 #d97706;
+}
+
+.load-more-btn:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.btn-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid #fef3c7;
+ border-top-color: #f59e0b;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.btn-arrow {
+ font-size: 1.2rem;
+ animation: bounceArrow 1s ease-in-out infinite;
+}
+
+@keyframes bounceArrow {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(3px);
+ }
+}
+
+/* Mobile Responsive */
+@media (max-width: 640px) {
+ .leaderboard-page {
+ padding: 1rem;
+ }
+
+ .leaderboard-title {
+ font-size: 1.75rem;
+ }
+
+ .tabs-container {
+ gap: 0.25rem;
+ }
+
+ .tab-btn {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.85rem;
+ }
+
+ .tab-label {
+ display: none;
+ }
+
+ .tab-icon {
+ font-size: 1.5rem;
+ }
+
+ .period-filters {
+ gap: 0.25rem;
+ }
+
+ .period-btn {
+ padding: 0.4rem 0.75rem;
+ font-size: 0.8rem;
+ }
+
+ .podium {
+ gap: 0.25rem;
+ padding: 0.5rem;
+ }
+
+ .podium-stand-1 {
+ width: 90px;
+ height: 80px;
+ font-size: 1.5rem;
+ }
+
+ .podium-stand-2 {
+ width: 75px;
+ height: 55px;
+ font-size: 1.25rem;
+ }
+
+ .podium-stand-3 {
+ width: 75px;
+ height: 40px;
+ font-size: 1rem;
+ }
+
+ .podium-name {
+ font-size: 0.75rem;
+ max-width: 70px;
+ }
+
+ .podium-medal {
+ font-size: 1.5rem;
+ }
+
+ .podium-1 .podium-medal {
+ font-size: 2rem;
+ top: -25px;
+ }
+
+ .leaderboard-list {
+ padding: 1rem;
+ }
+
+ .leaderboard-item {
+ padding: 0.75rem;
+ gap: 0.75rem;
+ }
+
+ .rank-badge {
+ width: 36px;
+ height: 36px;
+ }
+
+ .rank-medal {
+ font-size: 1.5rem;
+ }
+
+ .rank-number {
+ font-size: 0.9rem;
+ width: 32px;
+ height: 32px;
+ }
+
+ .item-name {
+ font-size: 0.9rem;
+ }
+
+ .item-stats {
+ font-size: 0.8rem;
+ }
+
+ .stat-num {
+ font-size: 0.95rem;
+ }
+
+ .stat-secondary {
+ display: none;
+ }
+}
+
+/* Fun hover effects for kids */
+.leaderboard-item:nth-child(even):hover {
+ background: #fef9c3;
+}
+
+.podium-spot:hover .podium-medal {
+ animation: wobble 0.5s ease-in-out;
+}
+
+@keyframes wobble {
+ 0%, 100% {
+ transform: rotate(-5deg);
+ }
+ 25% {
+ transform: rotate(10deg);
+ }
+ 50% {
+ transform: rotate(-10deg);
+ }
+ 75% {
+ transform: rotate(5deg);
+ }
+}
diff --git a/frontend/src/pages/Leaderboard.jsx b/frontend/src/pages/Leaderboard.jsx
new file mode 100644
index 0000000..eea5438
--- /dev/null
+++ b/frontend/src/pages/Leaderboard.jsx
@@ -0,0 +1,347 @@
+import { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import api from '../api';
+import { AvatarMini, DEFAULT_AVATAR } from '../components/AvatarBuilder';
+import './Leaderboard.css';
+
+const TABS = [
+ { id: 'players', label: 'Top Players', icon: '🎮' },
+ { id: 'creators', label: 'Top Creators', icon: '🛠️' },
+ { id: 'games', label: 'Top Games', icon: '🏆' }
+];
+
+const PERIODS = [
+ { id: 'week', label: 'This Week' },
+ { id: 'month', label: 'This Month' },
+ { id: 'all', label: 'All Time' }
+];
+
+const RANK_STYLES = {
+ 1: { icon: '🥇', class: 'gold', label: '1st' },
+ 2: { icon: '🥈', class: 'silver', label: '2nd' },
+ 3: { icon: '🥉', class: 'bronze', label: '3rd' }
+};
+
+export default function Leaderboard() {
+ const [activeTab, setActiveTab] = useState('players');
+ const [period, setPeriod] = useState('all');
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+
+ const limit = 10;
+
+ useEffect(() => {
+ setPage(1);
+ setData([]);
+ loadData(1, true);
+ }, [activeTab, period]);
+
+ const loadData = async (pageNum, reset = false) => {
+ if (reset) {
+ setLoading(true);
+ } else {
+ setLoadingMore(true);
+ }
+
+ try {
+ let result;
+ switch (activeTab) {
+ case 'players':
+ result = await api.getTopPlayers(pageNum, limit);
+ break;
+ case 'creators':
+ result = await api.getTopCreators(pageNum, limit, period);
+ break;
+ case 'games':
+ result = await api.getTopGames(pageNum, limit, period);
+ break;
+ default:
+ result = { leaderboard: [] };
+ }
+
+ // Normalize the response - API returns 'leaderboard' array
+ const items = (result.leaderboard || result.items || []).map(item => ({
+ ...item,
+ // Map API field names to what the component expects
+ xpTotal: item.xp || item.xpTotal || 0,
+ levelName: item.badge || item.levelName,
+ gamesCreated: item.gamesCount || item.gamesCreated || 0,
+ totalPlays: item.totalPlays || item.periodPlays || 0,
+ playCount: item.periodPlays || item.allTimePlays || item.playCount || 0,
+ rating: item.avgRating ? parseFloat(item.avgRating) : (item.rating || 0),
+ thumbnail: item.thumbnailData || item.thumbnailUrl || item.thumbnail,
+ creatorName: item.creator?.displayName || item.creator?.username || item.creatorName
+ }));
+
+ setData(prev => reset ? items : [...prev, ...items]);
+ setHasMore(result.pagination?.hasMore ?? items.length === limit);
+ setPage(pageNum);
+ } catch (error) {
+ console.error('Failed to load leaderboard:', error);
+ } finally {
+ setLoading(false);
+ setLoadingMore(false);
+ }
+ };
+
+ const handleLoadMore = () => {
+ if (!loadingMore && hasMore) {
+ loadData(page + 1);
+ }
+ };
+
+ const renderPodium = () => {
+ if (data.length < 3) return null;
+
+ const top3 = data.slice(0, 3);
+ const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd for visual
+
+ return (
+
+
+ {podiumOrder.map((item, idx) => {
+ const actualRank = idx === 0 ? 2 : idx === 1 ? 1 : 3;
+ const rankStyle = RANK_STYLES[actualRank];
+
+ return (
+
+
{rankStyle.icon}
+
+ {activeTab === 'games' ? (
+
+ {item.thumbnail ? (
+
+ ) : (
+
🎮
+ )}
+
+ ) : (
+
+ )}
+
+
+
+ {activeTab === 'games' ? item.title : (item.displayName || item.username)}
+
+
+ {renderStatValue(item)}
+
+
+
+ {actualRank}
+
+
+ );
+ })}
+
+
+ );
+ };
+
+ const renderStatValue = (item) => {
+ switch (activeTab) {
+ case 'players':
+ return (
+ <>
+
⭐
+
{item.xpTotal?.toLocaleString() || 0}
+
XP
+ >
+ );
+ case 'creators':
+ return (
+ <>
+
🎮
+
{item.gamesCreated || 0}
+
games
+
+ {(item.totalPlays || 0).toLocaleString()} plays
+
+ >
+ );
+ case 'games':
+ return (
+ <>
+
▶️
+
{(item.playCount || 0).toLocaleString()}
+
plays
+ {item.rating && (
+
+ ⭐ {item.rating.toFixed(1)}
+
+ )}
+ >
+ );
+ default:
+ return null;
+ }
+ };
+
+ const renderListItem = (item, index) => {
+ const rank = index + 1;
+ const rankStyle = RANK_STYLES[rank];
+ const isTopThree = rank <= 3;
+
+ return (
+
+
+ {isTopThree ? (
+ {rankStyle.icon}
+ ) : (
+ #{rank}
+ )}
+
+
+
+ {activeTab === 'games' ? (
+
+ {item.thumbnail ? (
+
+ ) : (
+
🎮
+ )}
+
+ ) : (
+
+ )}
+
+
+
+
+ {activeTab === 'games' ? item.title : (item.displayName || item.username)}
+
+ {activeTab === 'games' && item.creatorName && (
+ by {item.creatorName}
+ )}
+ {activeTab !== 'games' && item.levelName && (
+ {item.levelName}
+ )}
+
+
+
+ {renderStatValue(item)}
+
+
+ {isTopThree &&
}
+
+ );
+ };
+
+ return (
+
+
+
+ ⭐
+ 🌟
+ ✨
+
+
+ 🏆
+ Leaderboard
+ 🏆
+
+
See who's at the top!
+
+
+
+ {TABS.map(tab => (
+ setActiveTab(tab.id)}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ {activeTab !== 'players' && (
+
+ {PERIODS.map(p => (
+ setPeriod(p.id)}
+ >
+ {p.label}
+
+ ))}
+
+ )}
+
+ {loading ? (
+
+
+
Loading rankings...
+
+ ) : data.length === 0 ? (
+
+
📊
+
No data yet!
+
Be the first to make it to the leaderboard!
+
+ ) : (
+ <>
+ {renderPodium()}
+
+
+
+ {activeTab === 'players' && 'All Players'}
+ {activeTab === 'creators' && 'All Creators'}
+ {activeTab === 'games' && 'All Games'}
+
+ {data.map((item, index) => renderListItem(item, index))}
+
+
+ {hasMore && (
+
+
+ {loadingMore ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ <>
+ Load More
+ ↓
+ >
+ )}
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Legal.css b/frontend/src/pages/Legal.css
new file mode 100644
index 0000000..8372dfc
--- /dev/null
+++ b/frontend/src/pages/Legal.css
@@ -0,0 +1,196 @@
+.legal-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: #f8fafc;
+}
+
+.legal-content {
+ max-width: 800px;
+ margin: 0 auto;
+ background: white;
+ border-radius: 1rem;
+ padding: 2.5rem;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+}
+
+.legal-content h1 {
+ font-size: 2.25rem;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+ text-align: center;
+}
+
+.legal-subtitle {
+ text-align: center;
+ color: #64748b;
+ font-size: 1.1rem;
+ margin: 0 0 2rem;
+}
+
+/* Kid-Friendly Section */
+.kid-friendly {
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+ border-radius: 1rem;
+ padding: 2rem;
+ margin-bottom: 2.5rem;
+}
+
+.friendly-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.friendly-icon {
+ font-size: 2rem;
+}
+
+.friendly-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #0369a1;
+}
+
+.rule-card {
+ display: flex;
+ gap: 1rem;
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.rule-card:last-child {
+ margin-bottom: 0;
+}
+
+.rule-card.highlight {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border: 2px solid #f59e0b;
+}
+
+.rule-emoji {
+ font-size: 2rem;
+ flex-shrink: 0;
+}
+
+.rule-content h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.1rem;
+ color: #1e293b;
+}
+
+.rule-content p {
+ margin: 0;
+ color: #475569;
+ line-height: 1.6;
+}
+
+/* Legal Section */
+.legal-section {
+ border-top: 2px solid #e5e7eb;
+ padding-top: 2rem;
+ margin-top: 2rem;
+}
+
+.legal-section h2 {
+ font-size: 1.5rem;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+}
+
+.legal-updated {
+ color: #64748b;
+ font-size: 0.9rem;
+ margin: 0 0 2rem;
+}
+
+.legal-section h3 {
+ font-size: 1.1rem;
+ color: #1e293b;
+ margin: 2rem 0 0.75rem;
+}
+
+.legal-section h4 {
+ font-size: 1rem;
+ color: #475569;
+ margin: 1.5rem 0 0.5rem;
+}
+
+.legal-section p {
+ color: #475569;
+ line-height: 1.7;
+ margin: 0 0 1rem;
+}
+
+.legal-section ul {
+ color: #475569;
+ line-height: 1.7;
+ margin: 0 0 1rem;
+ padding-left: 1.5rem;
+}
+
+.legal-section li {
+ margin-bottom: 0.5rem;
+}
+
+.legal-section strong {
+ color: #1e293b;
+}
+
+/* Footer Links */
+.legal-footer {
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+ margin-top: 2.5rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.legal-link {
+ color: #6366f1;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.legal-link:hover {
+ text-decoration: underline;
+}
+
+/* Mobile Responsive */
+@media (max-width: 640px) {
+ .legal-page {
+ padding: 1rem;
+ }
+
+ .legal-content {
+ padding: 1.5rem;
+ border-radius: 0.75rem;
+ }
+
+ .legal-content h1 {
+ font-size: 1.75rem;
+ }
+
+ .kid-friendly {
+ padding: 1.25rem;
+ }
+
+ .rule-card {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .rule-emoji {
+ font-size: 2.5rem;
+ }
+
+ .legal-footer {
+ flex-direction: column;
+ gap: 1rem;
+ text-align: center;
+ }
+}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
new file mode 100644
index 0000000..926967f
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -0,0 +1,78 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import './Auth.css';
+
+export default function Login() {
+ const navigate = useNavigate();
+ const { login } = useAuth();
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await login(email, password);
+ navigate('/arcade');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Welcome Back!
+
Login to continue playing and creating
+
+
+ {error && {error}
}
+
+
+ Email
+ setEmail(e.target.value)}
+ required
+ autoComplete="email"
+ />
+
+
+
+ Password
+ setPassword(e.target.value)}
+ required
+ autoComplete="current-password"
+ />
+
+
+
+ Forgot your password?
+
+
+
+ {loading ? 'Logging in...' : 'Login'}
+
+
+
+
+ Don't have an account? Sign up
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Play.css b/frontend/src/pages/Play.css
new file mode 100644
index 0000000..98028b9
--- /dev/null
+++ b/frontend/src/pages/Play.css
@@ -0,0 +1,2308 @@
+/* Play page base */
+.play-page {
+ min-height: 100vh;
+ min-height: 100dvh;
+ background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
+ display: flex;
+ flex-direction: column;
+}
+
+.play-page.playing {
+ height: 100vh;
+ height: 100dvh;
+ max-height: 100vh;
+ max-height: 100dvh;
+ overflow: hidden;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ z-index: 200;
+}
+
+/* Loading and messages */
+.loading-game {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 1.2rem;
+}
+
+.play-message {
+ margin: auto;
+ padding: 2rem;
+ background: #fff;
+ border-radius: 1rem;
+ text-align: center;
+ max-width: 350px;
+ box-shadow: 0 10px 40px rgba(0,0,0,0.3);
+}
+
+.play-message h3 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.play-message p {
+ margin: 0 0 1.5rem;
+ color: #475569; /* WCAG AA: 7.0:1 contrast on white */
+}
+
+.current-rating {
+ font-size: 0.85rem;
+ color: #6366f1;
+ margin-bottom: 1rem !important;
+}
+
+/* Instructions page - wider layout */
+.play-page.instructions-page {
+ padding: 1rem;
+ overflow-y: auto;
+}
+
+/* Instructions screen */
+.instructions-screen {
+ margin: 0 auto;
+ padding: 1.5rem;
+ background: #fff;
+ border-radius: 1.5rem;
+ max-width: 500px;
+ width: 100%;
+ max-height: none;
+ overflow-y: visible;
+ box-shadow: 0 10px 40px rgba(0,0,0,0.3);
+}
+
+.instructions-header {
+ text-align: center;
+ margin-bottom: 1.5rem;
+}
+
+.instructions-screen h1 {
+ margin: 0 0 0.25rem;
+ color: #1e293b;
+ font-size: 1.5rem;
+}
+
+.creator-line {
+ color: #475569; /* WCAG AA: 7.0:1 contrast on white */
+ font-size: 0.85rem;
+ margin: 0;
+}
+
+.instructions-content {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.instructions-left {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ text-align: center;
+}
+
+.instructions-right {
+ display: none;
+}
+
+/* Mobile prompt display - visible only on mobile */
+.prompt-display-mobile {
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+ border: 1px solid #e2e8f0;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ text-align: left;
+ max-height: 40vh;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.prompt-display-mobile h4 {
+ font-size: 0.9rem;
+ color: #334155;
+ margin: 0 0 0.5rem 0;
+}
+
+.prompt-display-mobile .prompt-intro {
+ font-size: 0.75rem;
+ color: #64748b;
+ margin: 0 0 0.75rem 0;
+}
+
+.prompt-display-mobile .prompt-code {
+ background: #0f172a;
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ color: #e2e8f0;
+ font-size: 0.7rem;
+ line-height: 1.4;
+ max-height: 25vh;
+ overflow-y: auto;
+}
+
+/* Hide mobile prompt on desktop */
+@media (min-width: 768px) {
+ .prompt-display-mobile {
+ display: none;
+ }
+}
+
+.instructions-buttons-row {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.btn-toggle {
+ flex: 1;
+ padding: 0.65rem 1rem;
+ background: #e2e8f0;
+ border: none;
+ border-radius: 0.5rem;
+ color: #475569;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.btn-toggle:hover {
+ background: #cbd5e1;
+}
+
+.btn-toggle:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* High scores on instructions page */
+.high-scores-section {
+ background: #fef3c7;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ text-align: left;
+}
+
+.high-scores-section h4 {
+ margin: 0 0 0.75rem;
+ color: #92400e;
+ text-align: center;
+ font-size: 1rem;
+}
+
+.scores-list-instructions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.score-row-instr {
+ display: grid;
+ grid-template-columns: 2rem 1fr auto auto;
+ gap: 0.5rem;
+ align-items: center;
+ padding: 0.4rem 0.5rem;
+ background: rgba(255,255,255,0.6);
+ border-radius: 0.4rem;
+ font-size: 0.85rem;
+}
+
+.score-row-instr.top-three {
+ background: rgba(251, 191, 36, 0.3);
+ font-weight: 600;
+}
+
+/* Large prompt display for desktop */
+.prompt-display-large {
+ background: #1e293b;
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+ text-align: left;
+ height: 100%;
+ min-height: 300px;
+ overflow-y: auto;
+}
+
+.prompt-display-large h4 {
+ margin: 0 0 1rem;
+ color: #cbd5e1; /* WCAG AA: 7.5:1 contrast on #1e293b */
+ font-size: 0.95rem;
+}
+
+.prompt-display-large pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #e2e8f0;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ font-family: 'Monaco', 'Consolas', monospace;
+}
+
+.prompt-intro {
+ color: #94a3b8;
+ font-size: 0.85rem;
+ margin: 0 0 1rem;
+}
+
+.prompt-intro-mini {
+ color: #94a3b8;
+ font-size: 0.75rem;
+ margin: 0 0 0.5rem;
+}
+
+.user-text-sample {
+ background: rgba(59, 130, 246, 0.3);
+ color: #93c5fd;
+ padding: 0.1rem 0.3rem;
+ border-radius: 0.25rem;
+}
+
+.user-text {
+ background: rgba(59, 130, 246, 0.3);
+ color: #93c5fd;
+ padding: 0.1rem 0.25rem;
+ border-radius: 0.2rem;
+}
+
+.prompt-code {
+ font-family: 'Monaco', 'Consolas', monospace;
+}
+
+/* Desktop: wider two-column layout */
+@media (min-width: 768px) {
+ .play-page.instructions-page {
+ padding: 2rem;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ }
+
+ .instructions-screen {
+ max-width: 90%;
+ width: 90%;
+ padding: 2rem;
+ }
+
+ .instructions-screen h1 {
+ font-size: 2rem;
+ }
+
+ .creator-line {
+ font-size: 1rem;
+ }
+
+ .instructions-content {
+ flex-direction: row;
+ align-items: flex-start;
+ }
+
+ .instructions-left {
+ flex: 0 0 400px;
+ text-align: center;
+ }
+
+ .instructions-right {
+ display: block;
+ flex: 1;
+ min-width: 0;
+ }
+
+ .how-to-play {
+ padding: 1rem 1.5rem;
+ }
+
+ .how-to-play h3 {
+ font-size: 1.1rem;
+ }
+
+ .how-to-play li {
+ font-size: 1rem;
+ }
+
+ .btn-play {
+ padding: 1rem;
+ font-size: 1.2rem;
+ }
+
+ .prompt-display-large {
+ min-height: 400px;
+ }
+
+ .prompt-display-large pre {
+ font-size: 0.95rem;
+ }
+}
+
+.how-to-play {
+ background: #f1f5f9;
+ border-radius: 0.75rem;
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ text-align: left;
+}
+
+.how-to-play h3 {
+ margin: 0 0 0.5rem;
+ color: #475569;
+ font-size: 0.9rem;
+}
+
+.how-to-play ul {
+ margin: 0;
+ padding-left: 1.25rem;
+ color: #334155;
+}
+
+.how-to-play li {
+ margin-bottom: 0.35rem;
+ font-size: 0.9rem;
+}
+
+.rating-display {
+ display: block;
+ width: 100%;
+ padding: 0.6rem;
+ margin-bottom: 0.75rem;
+ background: #fef3c7;
+ border: 2px solid #f59e0b;
+ border-radius: 0.5rem;
+ color: #92400e;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+.btn-play {
+ width: 100%;
+ padding: 0.875rem;
+ font-size: 1.1rem;
+ margin-bottom: 0.75rem;
+}
+
+.btn-prompt-toggle {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ background: #e2e8f0;
+ border: none;
+ border-radius: 0.5rem;
+ color: #475569;
+ font-size: 0.85rem;
+ cursor: pointer;
+ margin-bottom: 0.75rem;
+}
+
+.btn-prompt-toggle:hover {
+ background: #cbd5e1;
+}
+
+.prompt-display {
+ background: #1e293b;
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ margin-bottom: 0.75rem;
+ text-align: left;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.prompt-display h4 {
+ margin: 0 0 0.5rem;
+ color: #cbd5e1; /* WCAG AA: 7.5:1 contrast on #1e293b */
+ font-size: 0.8rem;
+}
+
+.prompt-display pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #e2e8f0;
+ font-size: 0.75rem;
+ line-height: 1.4;
+ font-family: 'Monaco', 'Consolas', monospace;
+}
+
+.btn-back {
+ background: none;
+ border: none;
+ color: #e2e8f0; /* WCAG AA: 8.5:1 contrast on dark gradient backgrounds */
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+.btn-back:hover {
+ color: #ffffff;
+}
+
+/* Game playing layout */
+.game-top-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.4rem 0.75rem;
+ background: rgba(0,0,0,0.95);
+ flex-shrink: 0;
+ height: 44px;
+ box-sizing: border-box;
+ position: relative;
+ z-index: 10;
+}
+
+.game-top-bar .game-center-info {
+ flex: 1;
+}
+
+.game-center-info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.1rem;
+}
+
+.game-title-main {
+ color: #fff;
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+.game-plays {
+ color: #d1d5db; /* WCAG AA: 9.3:1 contrast on dark background */
+ font-size: 0.7rem;
+}
+
+.top-bar-right {
+ display: flex;
+ align-items: center;
+}
+
+.timer {
+ font-family: monospace;
+ font-size: 0.85rem;
+ color: #fff;
+ background: rgba(255,255,255,0.1);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.timer.charged {
+ color: #f59e0b;
+ background: rgba(245, 158, 11, 0.2);
+}
+
+.timer small {
+ font-size: 0.55rem;
+ color: #22c55e;
+ font-family: sans-serif;
+ font-weight: 600;
+}
+
+
+/* Game container - takes all available space */
+.game-container {
+ flex: 1;
+ display: flex;
+ background: #000;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+ z-index: 1;
+}
+
+.game-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ display: block;
+ position: relative;
+ z-index: 1;
+}
+
+/* Unified bottom bar */
+.game-bottom-bar.unified {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ padding: 0.35rem 0.5rem;
+ background: rgba(0,0,0,0.95);
+ flex-shrink: 0;
+ height: 44px;
+ box-sizing: border-box;
+ position: relative;
+ z-index: 10;
+}
+
+.bar-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: none;
+ background: rgba(255,255,255,0.1);
+ color: #fff;
+ font-size: 0.95rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s;
+ flex-shrink: 0;
+}
+
+.bar-btn:hover {
+ background: rgba(255,255,255,0.2);
+}
+
+.bar-btn:active {
+ background: rgba(255,255,255,0.3);
+}
+
+.bar-btn.pause-btn {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border: 1px solid #818cf8;
+ width: 36px;
+ height: 36px;
+ font-size: 1rem;
+}
+
+.bar-btn.pause-btn:hover {
+ background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
+}
+
+.bar-btn.audio-on {
+ background: rgba(34, 197, 94, 0.3);
+ color: #4ade80;
+}
+
+.bar-btn.audio-off {
+ background: rgba(255, 255, 255, 0.1);
+ opacity: 0.5;
+}
+
+.bar-btn.favorited {
+ background: rgba(239, 68, 68, 0.2);
+ color: #f87171;
+}
+
+.bar-divider {
+ width: 1px;
+ height: 20px;
+ background: rgba(255,255,255,0.2);
+ margin: 0 0.15rem;
+ flex-shrink: 0;
+}
+
+.bar-rating {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ flex-shrink: 0;
+}
+
+.star-btn-sm {
+ background: none;
+ border: none;
+ font-size: 0.85rem;
+ cursor: pointer;
+ padding: 0 1px;
+ transition: transform 0.1s;
+ filter: grayscale(1);
+ opacity: 0.5;
+ line-height: 1;
+}
+
+.star-btn-sm:hover {
+ transform: scale(1.3);
+}
+
+.star-btn-sm.filled {
+ filter: none;
+ opacity: 1;
+}
+
+.end-btn {
+ background: #dc2626;
+ color: white;
+ border: none;
+ padding: 0.3rem 0.6rem;
+ border-radius: 0.4rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ flex-shrink: 0;
+ margin-left: 0.15rem;
+}
+
+.end-btn:hover {
+ background: #b91c1c;
+}
+
+/* Help/Pause overlay */
+.help-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 23, 42, 0.92);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+ /* iOS keyboard handling */
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* On mobile, align to top so keyboard doesn't push content */
+@media (max-width: 768px) {
+ .help-overlay {
+ align-items: flex-start;
+ padding-top: 2rem;
+ }
+}
+
+.help-popup {
+ background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
+ border-radius: 1.5rem;
+ padding: 1.5rem;
+ width: 90%;
+ max-height: 95vh;
+ max-height: 95dvh; /* Use dynamic viewport height for mobile keyboard */
+ display: flex;
+ flex-direction: column;
+ text-align: center;
+ border: 2px solid rgba(99, 102, 241, 0.3);
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 30px rgba(99, 102, 241, 0.15);
+}
+
+.help-popup-scrollable {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ -webkit-overflow-scrolling: touch;
+ /* Prevent scroll from closing keyboard */
+ overscroll-behavior: contain;
+ /* Ensure scrollable area can scroll to show textarea above keyboard */
+ padding-bottom: 1rem;
+}
+
+/* When keyboard is visible on mobile, add extra scroll space */
+@media (max-width: 768px) {
+ .help-popup-scrollable {
+ padding-bottom: 2rem;
+ }
+}
+
+@media (min-width: 768px) {
+ .help-popup {
+ padding: 2rem;
+ }
+}
+
+.help-popup h2 {
+ margin: 0 0 0.25rem;
+ color: #f59e0b;
+ font-size: 1.75rem;
+ font-weight: 800;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ text-shadow: 0 2px 10px rgba(245, 158, 11, 0.3);
+}
+
+.help-popup h3 {
+ margin: 0 0 1.5rem;
+ color: #e2e8f0;
+ font-size: 1.25rem;
+ font-weight: 500;
+}
+
+@media (min-width: 768px) {
+ .help-popup h2 {
+ font-size: 2rem;
+ }
+ .help-popup h3 {
+ font-size: 1.4rem;
+ }
+}
+
+.help-instructions {
+ background: rgba(30, 41, 59, 0.8);
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ margin-bottom: 1.25rem;
+ text-align: left;
+}
+
+.help-instructions h4 {
+ margin: 0 0 0.75rem;
+ color: #a5b4fc;
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.help-instructions ul {
+ margin: 0;
+ padding-left: 1.25rem;
+ color: #cbd5e1;
+}
+
+.help-instructions li {
+ margin-bottom: 0.4rem;
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+@media (min-width: 768px) {
+ .help-instructions {
+ padding: 1.25rem 1.5rem;
+ }
+ .help-instructions h4 {
+ font-size: 1rem;
+ }
+ .help-instructions li {
+ font-size: 1.05rem;
+ margin-bottom: 0.5rem;
+ }
+}
+
+.popup-buttons-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.btn-popup-toggle {
+ padding: 0.65rem 0.75rem;
+ background: rgba(51, 65, 85, 0.8);
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ border-radius: 0.5rem;
+ color: #e2e8f0;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-popup-toggle:hover {
+ background: rgba(71, 85, 105, 0.9);
+ border-color: rgba(148, 163, 184, 0.5);
+}
+
+.btn-popup-toggle:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+@media (min-width: 768px) {
+ .popup-buttons-grid {
+ gap: 0.75rem;
+ }
+ .btn-popup-toggle {
+ padding: 0.75rem 1rem;
+ font-size: 0.9rem;
+ }
+}
+
+/* High Scores in Pause Popup */
+.high-scores-popup {
+ background: rgba(30, 41, 59, 0.8);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+ border-radius: 0.75rem;
+ padding: 1rem 1.25rem;
+ margin-bottom: 1rem;
+ text-align: left;
+}
+
+.high-scores-popup h4 {
+ margin: 0 0 0.75rem;
+ color: #fbbf24;
+ font-size: 1rem;
+ text-align: center;
+}
+
+.no-scores {
+ text-align: center;
+ color: #94a3b8;
+ font-style: italic;
+ margin: 0.5rem 0;
+}
+
+.scores-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.score-row {
+ display: grid;
+ grid-template-columns: 2.25rem 1fr auto auto;
+ gap: 0.5rem;
+ align-items: center;
+ padding: 0.5rem;
+ background: rgba(51, 65, 85, 0.6);
+ border-radius: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.score-row.top-three {
+ background: rgba(251, 191, 36, 0.15);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+}
+
+.score-rank {
+ color: #fbbf24;
+ font-weight: bold;
+}
+
+.score-name {
+ color: #e2e8f0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.score-points {
+ color: #86efac;
+ font-weight: 600;
+ font-family: inherit;
+}
+
+.score-date {
+ color: #cbd5e1;
+ font-size: 0.8rem;
+}
+
+/* Instructions page score overrides (light background) */
+.scores-list-instructions .score-rank {
+ color: #b45309;
+}
+
+.scores-list-instructions .score-name {
+ color: #1e293b;
+}
+
+.scores-list-instructions .score-points {
+ color: #047857;
+}
+
+.scores-list-instructions .score-date {
+ color: #475569;
+}
+
+@media (min-width: 768px) {
+ .high-scores-popup {
+ padding: 1.25rem 1.5rem;
+ }
+ .high-scores-popup h4 {
+ font-size: 1.1rem;
+ }
+ .score-row {
+ font-size: 0.9rem;
+ padding: 0.6rem 0.75rem;
+ }
+}
+
+.btn-prompt-toggle-popup {
+ display: block;
+ width: 100%;
+ padding: 0.5rem;
+ background: #e2e8f0;
+ border: none;
+ border-radius: 0.5rem;
+ color: #475569;
+ font-size: 0.85rem;
+ cursor: pointer;
+ margin-bottom: 0.75rem;
+}
+
+.btn-prompt-toggle-popup:hover {
+ background: #cbd5e1;
+}
+
+.prompt-display-popup {
+ background: rgba(15, 23, 42, 0.9);
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ border-radius: 0.75rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ text-align: left;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.prompt-display-popup pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #cbd5e1;
+ font-size: 0.8rem;
+ line-height: 1.5;
+ font-family: 'Monaco', 'Consolas', monospace;
+}
+
+@media (min-width: 768px) {
+ .prompt-display-popup {
+ max-height: 250px;
+ padding: 1.25rem;
+ }
+ .prompt-display-popup pre {
+ font-size: 0.85rem;
+ line-height: 1.6;
+ }
+}
+
+.resume-btn {
+ width: 100%;
+ padding: 1rem;
+ font-size: 1.2rem;
+ margin-bottom: 0.75rem;
+ background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
+ border: none;
+ border-radius: 0.75rem;
+ color: white;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ box-shadow: 0 4px 0 #15803d, 0 0 20px rgba(34, 197, 94, 0.3);
+ transition: all 0.15s;
+}
+
+.resume-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 0 #15803d, 0 0 25px rgba(34, 197, 94, 0.4);
+}
+
+.resume-btn:active {
+ transform: translateY(2px);
+ box-shadow: 0 2px 0 #15803d, 0 0 15px rgba(34, 197, 94, 0.2);
+}
+
+.help-hint {
+ margin: 0;
+ color: #64748b;
+ font-size: 0.75rem;
+}
+
+/* Rating stars */
+.stars {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.star {
+ font-size: 2.25rem;
+ background: none;
+ border: none;
+ opacity: 0.3;
+ cursor: pointer;
+ padding: 0.25rem;
+ transition: all 0.15s;
+}
+
+.star.active,
+.star:hover {
+ opacity: 1;
+ transform: scale(1.1);
+}
+
+/* Mobile adjustments */
+@media (max-width: 480px) {
+ .instructions-screen {
+ padding: 1.25rem;
+ }
+
+ .instructions-screen h1 {
+ font-size: 1.3rem;
+ }
+
+ .game-top-bar {
+ padding: 0.35rem;
+ height: 40px;
+ }
+
+ .game-bottom-bar.unified {
+ padding: 0.25rem 0.35rem;
+ height: 40px;
+ gap: 0.15rem;
+ }
+
+ .bar-btn {
+ width: 28px;
+ height: 28px;
+ font-size: 0.85rem;
+ }
+
+ .bar-btn.pause-btn {
+ width: 32px;
+ height: 32px;
+ }
+
+ .star-btn-sm {
+ font-size: 0.7rem;
+ }
+
+ .game-title-main {
+ font-size: 0.85rem;
+ }
+
+ .timer {
+ font-size: 0.8rem;
+ padding: 0.2rem 0.4rem;
+ }
+
+ .end-btn {
+ padding: 0.35rem 0.75rem;
+ font-size: 0.8rem;
+ }
+}
+
+.btn-report {
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.75); /* WCAG AA: ~4.5:1 contrast on dark purple */
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.5rem;
+ transition: color 0.2s;
+}
+
+.btn-report:hover {
+ color: rgba(255, 255, 255, 1);
+}
+
+.btn-report-small {
+ background: transparent;
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ color: #94a3b8;
+ font-size: 0.75rem;
+ cursor: pointer;
+ padding: 0.4rem 0.75rem;
+ border-radius: 0.5rem;
+ margin-top: 0.75rem;
+ transition: all 0.2s;
+}
+
+.btn-report-small:hover {
+ border-color: rgba(148, 163, 184, 0.5);
+ color: #e2e8f0;
+}
+
+/* Pause popup footer */
+.pause-footer {
+ margin-top: 0.5rem;
+}
+
+.pause-footer-buttons {
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ margin-top: 0.5rem;
+}
+
+.btn-quit-small {
+ background: transparent;
+ border: 1px solid rgba(239, 68, 68, 0.4);
+ color: #f87171;
+ font-size: 0.75rem;
+ cursor: pointer;
+ padding: 0.4rem 0.75rem;
+ border-radius: 0.5rem;
+ transition: all 0.2s;
+}
+
+.btn-quit-small:hover {
+ border-color: rgba(239, 68, 68, 0.6);
+ background: rgba(239, 68, 68, 0.1);
+ color: #fca5a5;
+}
+
+/* Dev Tools in Pause Overlay */
+.help-popup.dev-mode {
+ width: 90%;
+ padding: 1rem 1.25rem;
+}
+
+/* Mobile dev mode - adjust for keyboard */
+@media (max-width: 768px) {
+ .help-popup.dev-mode {
+ max-height: 80vh;
+ max-height: 80dvh;
+ margin-top: 0;
+ align-self: flex-start;
+ }
+}
+
+.help-popup.dev-mode h2 {
+ font-size: 1.25rem;
+ margin-bottom: 0.25rem;
+}
+
+.help-popup.dev-mode h3 {
+ font-size: 1rem;
+ margin-bottom: 0.75rem;
+}
+
+.dev-tools-section {
+ background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%);
+ border: 1px solid rgba(99, 102, 241, 0.3);
+ border-radius: 0.75rem;
+ padding: 0.75rem;
+ margin-bottom: 0.75rem;
+ text-align: left;
+}
+
+
+.dev-error {
+ background: rgba(239, 68, 68, 0.2);
+ border: 1px solid rgba(239, 68, 68, 0.4);
+ color: #fca5a5;
+ padding: 0.4rem 0.6rem;
+ border-radius: 0.4rem;
+ font-size: 0.8rem;
+ margin-top: 0.5rem;
+}
+
+.dev-success {
+ background: rgba(34, 197, 94, 0.2);
+ border: 1px solid rgba(34, 197, 94, 0.4);
+ color: #86efac;
+ padding: 0.4rem 0.6rem;
+ border-radius: 0.4rem;
+ font-size: 0.8rem;
+ margin-top: 0.5rem;
+}
+
+/* AI Refine Loading Animation */
+.dev-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem;
+ margin-top: 0.5rem;
+}
+
+.dev-loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid rgba(99, 102, 241, 0.2);
+ border-top-color: #6366f1;
+ border-radius: 50%;
+ animation: dev-spin 1s linear infinite;
+}
+
+@keyframes dev-spin {
+ to { transform: rotate(360deg); }
+}
+
+.dev-loading-text {
+ color: #a5b4fc;
+ font-size: 0.85rem;
+ margin-top: 0.5rem;
+ animation: dev-pulse 1.5s ease-in-out infinite;
+}
+
+.dev-loading-hint {
+ color: #64748b;
+ font-size: 0.75rem;
+ margin-top: 0.25rem;
+}
+
+@keyframes dev-pulse {
+ 0%, 100% { opacity: 0.6; }
+ 50% { opacity: 1; }
+}
+
+.dev-refine-row {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.dev-refine-row textarea {
+ flex: 1;
+ padding: 0.75rem;
+ border: 2px solid rgba(99, 102, 241, 0.3);
+ border-radius: 0.5rem;
+ background: rgba(15, 23, 42, 0.8);
+ color: #e2e8f0;
+ font-size: 16px; /* Prevents iOS zoom */
+ font-family: inherit;
+ resize: none;
+ min-height: 60px;
+ /* iOS keyboard fixes */
+ -webkit-appearance: none;
+ -webkit-user-select: text;
+ user-select: text;
+ /* Prevent transform/transition that can cause keyboard issues */
+ transform: none !important;
+ transition: border-color 0.2s;
+}
+
+.dev-refine-row textarea:focus {
+ outline: none;
+ border-color: #6366f1;
+ background: rgba(15, 23, 42, 0.95);
+}
+
+/* Mobile-specific textarea handling */
+@media (max-width: 768px) {
+ .dev-refine-row textarea {
+ /* Larger tap target */
+ min-height: 80px;
+ padding: 1rem;
+ /* Ensure text is visible above keyboard */
+ scroll-margin-bottom: 200px;
+ }
+
+ .dev-refine-row textarea:focus {
+ /* Prevent any movement that might dismiss keyboard */
+ position: static;
+ }
+}
+
+.dev-actions-row {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.dev-refine-row textarea::placeholder {
+ color: #64748b;
+}
+
+.btn-dev-refine {
+ padding: 0.5rem 1rem;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border: none;
+ border-radius: 0.4rem;
+ color: white;
+ font-weight: 600;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ white-space: nowrap;
+}
+
+.btn-dev-refine:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.btn-dev-refine:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-dev-publish {
+ flex: 1;
+ padding: 0.5rem 0.75rem;
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ border: none;
+ border-radius: 0.4rem;
+ color: white;
+ font-weight: 600;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.btn-dev-publish:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.btn-dev-publish.disabled {
+ background: #475569;
+ cursor: not-allowed;
+}
+
+.btn-dev-secondary {
+ padding: 0.5rem;
+ background: rgba(71, 85, 105, 0.5);
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ border-radius: 0.4rem;
+ color: #cbd5e1;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-dev-secondary:hover {
+ background: rgba(71, 85, 105, 0.7);
+}
+
+.btn-dev-delete {
+ padding: 0.5rem;
+ background: transparent;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 0.4rem;
+ color: #f87171;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-dev-delete:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.1);
+}
+
+/* Mobile: stack vertically */
+@media (max-width: 500px) {
+ .dev-refine-row {
+ flex-direction: column;
+ }
+ .dev-actions-row {
+ flex-wrap: wrap;
+ }
+ .btn-dev-publish {
+ width: 100%;
+ }
+}
+
+/* Developer hint during testing - now clickable */
+.dev-hint {
+ position: fixed;
+ top: 12%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(99, 102, 241, 0.95);
+ color: white;
+ padding: 0.75rem 1.25rem;
+ border-radius: 2rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ z-index: 100;
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
+ animation: dev-hint-pulse 2s ease-in-out infinite;
+ cursor: pointer;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform 0.15s, box-shadow 0.15s;
+}
+
+.dev-hint:hover {
+ transform: translateX(-50%) scale(1.05);
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
+}
+
+.dev-hint:active {
+ transform: translateX(-50%) scale(0.98);
+ box-shadow: 0 2px 10px rgba(99, 102, 241, 0.3);
+}
+
+@keyframes dev-hint-pulse {
+ 0%, 100% { opacity: 0.8; }
+ 50% { opacity: 1; }
+}
+
+/* Version History */
+.version-history {
+ margin-top: 0.75rem;
+ background: rgba(15, 23, 42, 0.4);
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+}
+
+.version-history h4 {
+ margin: 0 0 0.5rem;
+ color: #a5b4fc;
+ font-size: 0.85rem;
+}
+
+.no-versions {
+ color: #64748b;
+ font-size: 0.8rem;
+ font-style: italic;
+ margin: 0;
+}
+
+.version-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.version-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.5rem;
+ background: rgba(51, 65, 85, 0.5);
+ border-radius: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.version-info {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.version-number {
+ color: #a5b4fc;
+ font-weight: 600;
+ font-size: 0.8rem;
+}
+
+.version-type {
+ color: #94a3b8;
+ font-size: 0.7rem;
+ padding: 0.1rem 0.4rem;
+ background: rgba(148, 163, 184, 0.15);
+ border-radius: 0.25rem;
+}
+
+.version-size {
+ color: #64748b;
+ font-size: 0.7rem;
+}
+
+.version-date {
+ color: #64748b;
+ font-size: 0.7rem;
+}
+
+.version-desc {
+ color: #94a3b8;
+ font-size: 0.75rem;
+ margin: 0;
+ flex-basis: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.version-restore {
+ font-size: 0.7rem !important;
+ padding: 0.2rem 0.5rem !important;
+ white-space: nowrap;
+}
+
+/* Dev Explainer Overlay */
+.dev-explainer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 23, 42, 0.92);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.dev-explainer {
+ background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
+ border-radius: 1.5rem;
+ padding: 2rem;
+ max-width: 440px;
+ width: 95%;
+ text-align: left;
+ border: 2px solid rgba(99, 102, 241, 0.3);
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+}
+
+.dev-explainer h2 {
+ margin: 0 0 0.75rem;
+ color: #a5b4fc;
+ font-size: 1.5rem;
+ text-align: center;
+}
+
+.dev-explainer p {
+ color: #cbd5e1;
+ font-size: 0.95rem;
+ margin: 0 0 1rem;
+}
+
+.dev-explainer ul {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1.5rem;
+}
+
+.dev-explainer li {
+ color: #e2e8f0;
+ font-size: 0.9rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
+}
+
+.dev-explainer li:last-child {
+ border-bottom: none;
+}
+
+.dev-explainer li strong {
+ color: #a5b4fc;
+}
+
+.dev-explainer .btn {
+ width: 100%;
+ text-align: center;
+}
+
+/* Report Modal */
+.report-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.report-modal {
+ background: white;
+ border-radius: 1rem;
+ padding: 2rem;
+ max-width: 400px;
+ width: 100%;
+ text-align: center;
+}
+
+.report-modal h3 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.report-game-title {
+ color: #6366f1;
+ font-weight: 500;
+ margin: 0 0 1.5rem;
+}
+
+.report-success-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.report-options {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ text-align: left;
+}
+
+.report-option {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.report-option:hover {
+ background: #f1f5f9;
+}
+
+.report-option.selected {
+ background: #ede9fe;
+ border-color: #8b5cf6;
+}
+
+.report-option input {
+ accent-color: #6366f1;
+}
+
+.report-option span {
+ color: #475569;
+ font-size: 0.95rem;
+}
+
+.report-comment {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 0.9rem;
+ font-family: inherit;
+ resize: vertical;
+ margin-bottom: 1rem;
+ box-sizing: border-box;
+ color: #1e293b;
+}
+
+.report-comment:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
+}
+
+.report-comment::placeholder {
+ color: #94a3b8;
+}
+
+.report-ai-feedback {
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ margin: 0.5rem 0;
+ text-align: left;
+}
+
+.ai-feedback-text {
+ color: #166534;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ margin: 0;
+}
+
+.report-actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: center;
+}
+
+.report-actions .btn {
+ min-width: 100px;
+}
+
+/* Share button */
+.btn-share,
+.btn-popup-toggle.btn-share {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
+ color: white !important;
+ border: none !important;
+}
+
+.btn-share:hover,
+.btn-popup-toggle.btn-share:hover {
+ opacity: 0.9;
+}
+
+/* Download button */
+.btn-download,
+.btn-popup-toggle.btn-download {
+ background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important;
+ color: white !important;
+ border: none !important;
+}
+
+.btn-download:hover,
+.btn-popup-toggle.btn-download:hover {
+ opacity: 0.9;
+}
+
+/* Collection button */
+.btn-collection,
+.btn-popup-toggle.btn-collection {
+ background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%) !important;
+ color: white !important;
+ border: none !important;
+}
+
+.btn-collection:hover,
+.btn-popup-toggle.btn-collection:hover {
+ opacity: 0.9;
+}
+
+.btn-remix,
+.btn-popup-toggle.btn-remix {
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important;
+ color: white !important;
+ border: none !important;
+}
+
+.btn-remix:hover,
+.btn-popup-toggle.btn-remix:hover {
+ opacity: 0.9;
+}
+
+.btn-remix:disabled,
+.btn-popup-toggle.btn-remix:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* Report toggle in pause grid */
+.btn-popup-toggle.btn-report-toggle {
+ background: rgba(245, 158, 11, 0.2) !important;
+ border-color: rgba(245, 158, 11, 0.4) !important;
+ color: #fbbf24 !important;
+}
+
+.btn-popup-toggle.btn-report-toggle:hover {
+ background: rgba(245, 158, 11, 0.3) !important;
+}
+
+/* Quit toggle in pause grid */
+.btn-popup-toggle.btn-quit-toggle {
+ background: rgba(239, 68, 68, 0.2) !important;
+ border-color: rgba(239, 68, 68, 0.4) !important;
+ color: #f87171 !important;
+}
+
+.btn-popup-toggle.btn-quit-toggle:hover {
+ background: rgba(239, 68, 68, 0.3) !important;
+}
+
+/* Instructions footer */
+.instructions-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 1.5rem;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.footer-left {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.footer-right {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+}
+
+/* Install App button matching Browse Games / Back button style */
+.btn-install-match {
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
+ color: white !important;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
+}
+
+.btn-install-match:hover {
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important;
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+ transform: translateY(-1px);
+}
+
+.btn-install-match:active {
+ transform: translateY(0);
+}
+
+/* Offline button */
+.btn-offline {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ transition: all 0.2s;
+}
+
+.btn-offline:hover {
+ border-color: rgba(255, 255, 255, 0.6);
+ color: white;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.btn-offline:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-offline.saved {
+ border-color: #10b981;
+ color: #10b981;
+ background: rgba(16, 185, 129, 0.1);
+}
+
+.btn-offline.saved:hover {
+ background: rgba(16, 185, 129, 0.2);
+}
+
+/* Install App button */
+.btn-install {
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
+ border: none;
+ color: white;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ padding: 0.5rem 1rem;
+ border-radius: 0.5rem;
+ transition: all 0.2s;
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
+}
+
+.btn-install:hover {
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+ transform: translateY(-1px);
+}
+
+.btn-install:active {
+ transform: translateY(0);
+ box-shadow: 0 2px 6px rgba(59, 130, 246, 0.3);
+}
+
+/* Install button in popup grid */
+.btn-popup-toggle.btn-install-popup {
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
+ color: white !important;
+ border: none !important;
+}
+
+.btn-popup-toggle.btn-install-popup:hover {
+ opacity: 0.9;
+}
+
+/* Modal Overlay - Full screen centered modal container */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+/* Collection Modal */
+.collection-modal {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ max-width: 400px;
+ width: 100%;
+ position: relative;
+}
+
+.collection-modal h2 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+ font-size: 1.25rem;
+}
+
+.collection-modal .modal-subtitle {
+ margin: 0 0 1.5rem;
+ color: #475569; /* WCAG AA: 7.0:1 contrast on white */
+ font-size: 0.9rem;
+}
+
+.collection-modal .modal-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: #f1f5f9;
+ border-radius: 50%;
+ font-size: 1.25rem;
+ color: #475569; /* WCAG AA: 5.9:1 contrast on #f1f5f9 */
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.collection-modal .modal-close:hover {
+ background: #e2e8f0;
+ color: #1e293b;
+}
+
+.collection-modal .modal-loading,
+.collection-modal .modal-empty {
+ text-align: center;
+ padding: 2rem 1rem;
+ color: #475569; /* WCAG AA: 7.0:1 contrast on white */
+}
+
+.collection-modal .modal-empty .btn {
+ margin-top: 1rem;
+}
+
+.collection-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.collection-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ width: 100%;
+ text-align: left;
+}
+
+.collection-item:hover:not(:disabled) {
+ background: #f1f5f9;
+ border-color: #cbd5e1;
+}
+
+.collection-item:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.collection-item.success {
+ background: #d1fae5;
+ border-color: #10b981;
+}
+
+.collection-item .collection-icon {
+ font-size: 1.25rem;
+}
+
+.collection-item .collection-name {
+ flex: 1;
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.collection-item .collection-count {
+ font-size: 0.8rem;
+ color: #475569; /* WCAG AA: 7.0:1 contrast on light backgrounds */
+}
+
+.collection-item .adding,
+.collection-item .added {
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.collection-item .adding {
+ color: #6366f1;
+}
+
+.collection-item .added {
+ color: #10b981;
+}
+
+/* ===========================================
+ Touch-Friendly Styles for Mobile Devices
+ =========================================== */
+
+/* Ensure minimum touch target sizes (44x44px) */
+@media (hover: none), (pointer: coarse) {
+ /* Unified bottom bar touch targets */
+ .bar-btn {
+ width: 36px;
+ height: 36px;
+ min-width: 36px;
+ min-height: 36px;
+ font-size: 1rem;
+ }
+
+ .bar-btn.pause-btn {
+ width: 40px;
+ height: 40px;
+ min-width: 40px;
+ min-height: 40px;
+ }
+
+ /* End game button */
+ .end-btn {
+ min-height: 36px;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.8rem;
+ }
+
+ /* Bottom bar spacing */
+ .game-bottom-bar.unified {
+ height: 48px;
+ padding: 0.35rem 0.5rem;
+ gap: 0.3rem;
+ }
+
+ .star-btn-sm {
+ font-size: 0.9rem;
+ padding: 0 2px;
+ min-height: 36px;
+ }
+
+ /* Toggle buttons on instructions page */
+ .btn-toggle {
+ min-height: 44px;
+ padding: 0.75rem 1rem;
+ font-size: 0.95rem;
+ }
+
+ /* Play button */
+ .btn-play {
+ min-height: 48px;
+ padding: 1rem;
+ }
+
+ /* Rating stars - larger touch targets */
+ .star {
+ font-size: 2.5rem;
+ padding: 0.5rem;
+ min-width: 44px;
+ min-height: 44px;
+ }
+
+ /* Report options */
+ .report-option {
+ min-height: 48px;
+ padding: 1rem 1.25rem;
+ }
+
+ /* Collection items */
+ .collection-item {
+ min-height: 52px;
+ padding: 1rem 1.25rem;
+ }
+
+ /* Back button */
+ .btn-back {
+ min-height: 44px;
+ padding: 0.5rem 1rem;
+ }
+
+ /* Popup toggle buttons */
+ .btn-popup-toggle {
+ min-height: 44px;
+ padding: 0.875rem 1rem;
+ }
+
+ /* Resume button */
+ .resume-btn {
+ min-height: 48px;
+ padding: 1rem;
+ }
+
+ /* Offline button */
+ .btn-offline {
+ min-height: 40px;
+ padding: 0.625rem 1rem;
+ }
+
+ /* Report buttons */
+ .btn-report,
+ .btn-report-small {
+ min-height: 40px;
+ padding: 0.625rem 1rem;
+ }
+
+ /* Install button touch target */
+ .btn-install {
+ min-height: 44px;
+ padding: 0.75rem 1.25rem;
+ }
+
+ /* Dev hint touch target */
+ .dev-hint {
+ min-height: 44px;
+ padding: 0.875rem 1.5rem;
+ font-size: 1rem;
+ }
+}
+
+/* Active/pressed states for touch feedback */
+@media (hover: none) {
+ .bar-btn:active {
+ background: rgba(255, 255, 255, 0.3);
+ transform: scale(0.95);
+ }
+
+ .bar-btn.pause-btn:active {
+ background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
+ transform: scale(0.95);
+ }
+
+ .end-btn:active {
+ background: #b91c1c;
+ transform: scale(0.98);
+ }
+
+ .btn-toggle:active {
+ background: #94a3b8;
+ transform: scale(0.98);
+ }
+
+ .btn-play:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+ }
+
+ .star:active {
+ transform: scale(1.2);
+ }
+
+ .btn-popup-toggle:active {
+ background: #94a3b8;
+ transform: scale(0.98);
+ }
+
+ .btn-share:active,
+ .btn-download:active,
+ .btn-collection:active,
+ .btn-remix:active {
+ opacity: 0.8;
+ transform: scale(0.98);
+ }
+
+ .resume-btn:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+ }
+
+ .collection-item:active:not(:disabled) {
+ background: #e2e8f0;
+ transform: scale(0.99);
+ }
+
+ .report-option:active {
+ background: #e2e8f0;
+ }
+
+ .btn-back:active {
+ color: #1e293b;
+ transform: scale(0.98);
+ }
+
+ .btn-offline:active {
+ background: rgba(255, 255, 255, 0.2);
+ transform: scale(0.98);
+ }
+
+ .btn-report:active,
+ .btn-report-small:active {
+ color: rgba(255, 255, 255, 1);
+ transform: scale(0.98);
+ }
+
+ .rating-display:active {
+ background: #fef08a;
+ transform: scale(0.99);
+ }
+
+ .btn-install:active {
+ transform: scale(0.98);
+ opacity: 0.9;
+ }
+
+ .dev-hint:active {
+ transform: translateX(-50%) scale(0.95);
+ }
+
+ /* Disable iOS tap highlight */
+ button,
+ a,
+ .bar-btn,
+ .btn-toggle,
+ .btn-play,
+ .collection-item,
+ .report-option,
+ .dev-hint,
+ .btn-install {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+/* Hover-only styles for desktop */
+@media (hover: hover) {
+ .end-btn:hover {
+ background: #b91c1c;
+ }
+}
+
+/* Game iframe touch handling */
+.game-iframe {
+ touch-action: auto; /* Allow touch events to pass through to the game */
+}
+
+/* Ensure game container allows touch events */
+.game-container {
+ touch-action: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* Focus states for keyboard navigation (all devices) */
+.bar-btn:focus-visible,
+.end-btn:focus-visible,
+.btn-toggle:focus-visible,
+.btn-play:focus-visible,
+.btn-popup-toggle:focus-visible,
+.resume-btn:focus-visible,
+.collection-item:focus-visible,
+.report-option:focus-visible {
+ outline: 3px solid #6366f1;
+ outline-offset: 2px;
+}
+
+/* Instructions page touch optimizations */
+@media (hover: none) {
+ .instructions-buttons-row {
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .instructions-buttons-row .btn-toggle {
+ flex: 1 1 calc(50% - 0.25rem);
+ min-width: 120px;
+ }
+
+ .instructions-footer {
+ flex-direction: column;
+ gap: 1rem;
+ align-items: stretch;
+ }
+
+ .footer-right {
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+}
+
+/* High scores touch targets */
+@media (hover: none) {
+ .score-row,
+ .score-row-instr {
+ min-height: 40px;
+ padding: 0.625rem 0.75rem;
+ }
+}
diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx
new file mode 100644
index 0000000..f92053a
--- /dev/null
+++ b/frontend/src/pages/Play.jsx
@@ -0,0 +1,1643 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import ShareModal from '../components/ShareModal';
+import UpgradePrompt from '../components/UpgradePrompt';
+import GuestBanner from '../components/GuestBanner';
+import { useOfflineGames } from '../hooks/useOfflineGames';
+import './Play.css';
+
+
+// Helper to highlight user answers in the prompt
+function highlightUserAnswers(prompt, answers) {
+ if (!prompt || !answers) return prompt;
+
+ // Get all user-typed values from answers object
+ const userValues = Object.values(answers || {})
+ .filter(v => typeof v === 'string' && v.length > 3) // Only strings > 3 chars
+ .map(v => v.trim())
+ .filter(v => v.length > 0);
+
+ if (userValues.length === 0) return prompt;
+
+ // Create regex pattern for all user values (case-insensitive)
+ const parts = [];
+ let lastIndex = 0;
+
+ // Sort by length (longest first) to match longer phrases first
+ const sortedValues = [...userValues].sort((a, b) => b.length - a.length);
+
+ // Find all occurrences of user values
+ for (const value of sortedValues) {
+ const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(escapedValue, 'gi');
+ let match;
+
+ while ((match = regex.exec(prompt)) !== null) {
+ if (match.index >= lastIndex) {
+ if (match.index > lastIndex) {
+ parts.push({ text: prompt.slice(lastIndex, match.index), highlight: false });
+ }
+ parts.push({ text: match[0], highlight: true });
+ lastIndex = match.index + match[0].length;
+ }
+ }
+ }
+
+ if (lastIndex < prompt.length) {
+ parts.push({ text: prompt.slice(lastIndex), highlight: false });
+ }
+
+ // If no parts found, just return the prompt
+ if (parts.length === 0) return prompt;
+
+ return parts.map((part, i) =>
+ part.highlight
+ ?
{part.text}
+ : part.text
+ );
+}
+
+// Global audio preferences stored in localStorage
+const getAudioPrefs = () => ({
+ music: localStorage.getItem('gamercomp_music') !== 'off',
+ sfx: localStorage.getItem('gamercomp_sfx') !== 'off'
+});
+
+const setAudioPref = (type, enabled) => {
+ localStorage.setItem(`gamercomp_${type}`, enabled ? 'on' : 'off');
+};
+
+const INSTRUCTIONS = {
+ 'Snake': {
+ mobile: ['Touch: Swipe or use D-pad to change direction', 'Desktop: Arrow keys to move', 'Eat apples to grow', 'Don\'t hit yourself!', 'Tap ? or press H/P to pause'],
+ desktop: ['Desktop: Arrow keys to move', 'Touch: Swipe or use D-pad', 'Eat apples to grow', 'Don\'t hit yourself!', 'Press H, P, or ? to pause']
+ },
+ 'Space Invaders': {
+ mobile: ['Touch: Slide left/right to move, tap to shoot', 'Desktop: Arrow keys to move, Space to shoot', 'Destroy all aliens!', 'Tap ? or press H/P to pause'],
+ desktop: ['Desktop: Arrow keys to move, Space to shoot', 'Touch: Slide left/right to move, tap to shoot', 'Destroy all aliens!', 'Press H, P, or ? to pause']
+ },
+ 'Flappy Bird': {
+ mobile: ['Touch: Tap anywhere to flap', 'Desktop: Click or Space to flap', 'Fly through the gaps', 'Don\'t hit pipes or ground!', 'Tap ? or press H/P to pause'],
+ desktop: ['Desktop: Click or Space to flap', 'Touch: Tap anywhere to flap', 'Fly through the gaps', 'Don\'t hit pipes or ground!', 'Press H, P, or ? to pause']
+ },
+ 'Minesweeper': {
+ mobile: ['Touch: Tap to reveal, long-press to flag', 'Desktop: Left-click to reveal, right-click to flag', 'Numbers show nearby bombs', 'Tap ? or press H/P to pause'],
+ desktop: ['Desktop: Left-click to reveal, right-click to flag', 'Touch: Tap to reveal, long-press to flag', 'Numbers show nearby bombs', 'Press H, P, or ? to pause']
+ }
+};
+
+export default function Play() {
+ const { gameId } = useParams();
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { user, isAuthenticated, isGuest, fingerprintReady, updateTokens, setGuestUser } = useAuth();
+ const iframeRef = useRef(null);
+
+ const [game, setGame] = useState(null);
+ const [gameCode, setGameCode] = useState(null);
+ const [codeVersion, setCodeVersion] = useState(1);
+ const [iframeLoaded, setIframeLoaded] = useState(false);
+ const [sessionId, setSessionId] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [startTime, setStartTime] = useState(null);
+ const [playTime, setPlayTime] = useState(0);
+ const [userRating, setUserRating] = useState(0);
+ const [userPlayCount, setUserPlayCount] = useState(0);
+ const [showInstructions, setShowInstructions] = useState(true);
+ const [showRatingPopup, setShowRatingPopup] = useState(false);
+ const [gameStarted, setGameStarted] = useState(false);
+ const [musicEnabled, setMusicEnabled] = useState(() => getAudioPrefs().music);
+ const [sfxEnabled, setSfxEnabled] = useState(() => getAudioPrefs().sfx);
+ const [showHelpPopup, setShowHelpPopup] = useState(false);
+ // Show prompt by default on desktop, hidden on mobile
+ const [showPrompt, setShowPrompt] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 768);
+ const [showHighScores, setShowHighScores] = useState(false);
+ const [highScores, setHighScores] = useState([]);
+ const [loadingScores, setLoadingScores] = useState(false);
+ const [showReportModal, setShowReportModal] = useState(false);
+ const [reportReason, setReportReason] = useState('');
+ const [reportComment, setReportComment] = useState('');
+ const [reportSubmitting, setReportSubmitting] = useState(false);
+ const [reportSubmitted, setReportSubmitted] = useState(false);
+ const [reportAiFeedback, setReportAiFeedback] = useState('');
+ const [showShareModal, setShowShareModal] = useState(false);
+ const [savingOffline, setSavingOffline] = useState(false);
+ const [showUpgradePrompt, setShowUpgradePrompt] = useState(false);
+ const [upgradeFeature, setUpgradeFeature] = useState('');
+ const [showDevExplainer, setShowDevExplainer] = useState(false);
+ const [versions, setVersions] = useState([]);
+ const [showVersions, setShowVersions] = useState(false);
+ const [loadingVersions, setLoadingVersions] = useState(false);
+ const { isGameSaved, saveGameOffline, removeGameOffline } = useOfflineGames();
+
+ // Collection state
+ const [showCollectionModal, setShowCollectionModal] = useState(false);
+ const [collections, setCollections] = useState([]);
+ const [loadingCollections, setLoadingCollections] = useState(false);
+ const [addingToCollection, setAddingToCollection] = useState(null);
+ const [collectionSuccess, setCollectionSuccess] = useState(null);
+ const [remixing, setRemixing] = useState(false);
+
+ // PWA install prompt
+ const [installPrompt, setInstallPrompt] = useState(null);
+ const [showInstallOption, setShowInstallOption] = useState(false);
+
+ // Game score received via postMessage
+ const [reportedScore, setReportedScore] = useState(0);
+
+ // Favorite state
+ const [isFavorited, setIsFavorited] = useState(false);
+
+ // Dev Tools state (for creator testing their own games)
+ const [devFeedback, setDevFeedback] = useState('');
+ const [devSaving, setDevSaving] = useState(false);
+ const [devError, setDevError] = useState('');
+ const [devSuccess, setDevSuccess] = useState('');
+ const [testingSeconds, setTestingSeconds] = useState(0);
+ const testingIntervalRef = useRef(null);
+ const REQUIRED_TEST_TIME = 60;
+
+ // Check if this is creator testing their own game
+ const isCreatorTesting = game?.isOwner && game?.status !== 'published' && game?.status !== 'pending';
+ const canPublish = testingSeconds >= REQUIRED_TEST_TIME || game?.creatorPlayed;
+
+ const REPORT_REASONS = [
+ { value: 'wont_load', label: "Won't Load" },
+ { value: 'crashes', label: 'Game Crashes' },
+ { value: 'broken', label: 'Broken/Unplayable' },
+ { value: 'inappropriate', label: 'Inappropriate Content' },
+ { value: 'stolen', label: 'Stolen/Copied' },
+ { value: 'other', label: 'Other Issue' }
+ ];
+
+ const handleReport = async () => {
+ if (!reportReason) return;
+ setReportSubmitting(true);
+ setReportAiFeedback('');
+ try {
+ const result = await api.reportGame(parseInt(gameId), reportReason, reportComment || undefined);
+ setReportSubmitted(true);
+ if (result.aiFeedback) {
+ setReportAiFeedback(result.aiFeedback);
+ }
+ setTimeout(() => {
+ setShowReportModal(false);
+ setReportReason('');
+ setReportComment('');
+ setReportSubmitted(false);
+ setReportAiFeedback('');
+ }, 5000);
+ } catch (error) {
+ alert(error.message || 'Failed to submit report');
+ } finally {
+ setReportSubmitting(false);
+ }
+ };
+
+ const handleSaveOffline = async () => {
+ if (!game) return;
+ setSavingOffline(true);
+ try {
+ const gameData = await api.getGame(gameId);
+ const success = await saveGameOffline(gameId, gameData.game.gameCode, {
+ title: game.title,
+ creatorUsername: game.creatorUsername,
+ playCount: game.playCount
+ });
+ if (success) {
+ alert('Game saved for offline play!');
+ }
+ } catch (error) {
+ alert('Failed to save game offline');
+ } finally {
+ setSavingOffline(false);
+ }
+ };
+
+ const handleRemoveOffline = async () => {
+ await removeGameOffline(gameId);
+ };
+
+ const handleRemix = async () => {
+ if (!isAuthenticated) {
+ alert('Please log in to remix games');
+ return;
+ }
+ if (user?.isGuest) {
+ setUpgradeFeature('remix games');
+ setShowUpgradePrompt(true);
+ return;
+ }
+ if (remixing) return;
+
+ setRemixing(true);
+ try {
+ const result = await api.remixGame(parseInt(gameId));
+ if (result.game?.id) {
+ navigate(`/play/${result.game.id}?newGame=true`);
+ }
+ } catch (err) {
+ alert(err.message || 'Failed to remix game');
+ } finally {
+ setRemixing(false);
+ }
+ };
+
+ // Collection functions
+ const loadCollections = async () => {
+ if (!isAuthenticated || user?.isGuest) return;
+ setLoadingCollections(true);
+ try {
+ const data = await api.getMyCollections();
+ setCollections(data.collections || []);
+ } catch (err) {
+ console.error('Failed to load collections:', err);
+ } finally {
+ setLoadingCollections(false);
+ }
+ };
+
+ const openCollectionModal = () => {
+ if (!isAuthenticated) {
+ alert('Please log in to save games to collections');
+ return;
+ }
+ if (user?.isGuest) {
+ setUpgradeFeature('save games to collections');
+ setShowUpgradePrompt(true);
+ return;
+ }
+ setShowCollectionModal(true);
+ loadCollections();
+ };
+
+ const handleAddToCollection = async (collectionId) => {
+ setAddingToCollection(collectionId);
+ try {
+ await api.addToCollection(collectionId, parseInt(gameId));
+ setCollectionSuccess(collectionId);
+ setTimeout(() => setCollectionSuccess(null), 2000);
+ } catch (err) {
+ alert(err.message || 'Failed to add to collection');
+ } finally {
+ setAddingToCollection(null);
+ }
+ };
+
+ const handleDownload = () => {
+ if (!gameCode || !game) return;
+
+ // Create filename from game title
+ const safeTitle = game.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ const safeCreator = (game.creatorUsername || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-');
+ const filename = `gamercomp-${safeTitle}-by-${safeCreator}.html`;
+
+ // Add GPL license and credits to the HTML
+ const gplHeader = `
+`;
+
+ // Insert the header at the beginning of the HTML
+ let downloadCode = gameCode;
+ if (downloadCode.startsWith('\n' + downloadCode;
+ }
+
+ // Create and trigger download
+ const blob = new Blob([downloadCode], { type: 'text/html' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ };
+
+ useEffect(() => {
+ loadGame();
+ }, [gameId]);
+
+ const loadHighScores = async () => {
+ if (highScores.length > 0) {
+ const next = !showHighScores;
+ setShowHighScores(next);
+ if (next) setShowPrompt(false);
+ return;
+ }
+ setLoadingScores(true);
+ try {
+ const data = await api.getHighScores(gameId);
+ setHighScores(data.highScores || []);
+ setShowHighScores(true);
+ setShowPrompt(false);
+ } catch (err) {
+ console.error('Failed to load high scores:', err);
+ } finally {
+ setLoadingScores(false);
+ }
+ };
+
+ useEffect(() => {
+ let interval;
+ if (gameStarted && startTime && !showHelpPopup) {
+ interval = setInterval(() => {
+ setPlayTime(Math.floor((Date.now() - startTime) / 1000));
+ }, 1000);
+ }
+ return () => clearInterval(interval);
+ }, [gameStarted, startTime, showHelpPopup]);
+
+ // Testing timer for creator testing their own games
+ useEffect(() => {
+ if (isCreatorTesting && gameStarted && !showHelpPopup) {
+ testingIntervalRef.current = setInterval(() => {
+ setTestingSeconds(prev => prev + 1);
+ }, 1000);
+ } else if (testingIntervalRef.current) {
+ clearInterval(testingIntervalRef.current);
+ }
+ return () => {
+ if (testingIntervalRef.current) clearInterval(testingIntervalRef.current);
+ };
+ }, [isCreatorTesting, gameStarted, showHelpPopup]);
+
+ // Save testing progress periodically
+ useEffect(() => {
+ if (isCreatorTesting && testingSeconds > 0 && testingSeconds % 10 === 0) {
+ api.trackTesting(game.id, testingSeconds, 0).catch(() => {});
+ }
+ }, [isCreatorTesting, testingSeconds, game?.id]);
+
+ // Initialize testing seconds from game data
+ useEffect(() => {
+ if (game?.creatorPlayDuration) {
+ setTestingSeconds(game.creatorPlayDuration);
+ }
+ }, [game?.creatorPlayDuration]);
+
+ // Show dev explainer for first-time creators
+ useEffect(() => {
+ if (isCreatorTesting && gameStarted && !localStorage.getItem('gamercomp_dev_explained')) {
+ const timer = setTimeout(() => {
+ setShowDevExplainer(true);
+ }, 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [isCreatorTesting, gameStarted]);
+
+ // PWA install prompt handler
+ useEffect(() => {
+ const handleBeforeInstall = (e) => {
+ e.preventDefault();
+ setInstallPrompt(e);
+ // Only show if not already installed
+ if (!window.matchMedia('(display-mode: standalone)').matches) {
+ setShowInstallOption(true);
+ }
+ };
+
+ window.addEventListener('beforeinstallprompt', handleBeforeInstall);
+
+ // Check if already in standalone mode
+ if (window.matchMedia('(display-mode: standalone)').matches) {
+ setShowInstallOption(false);
+ }
+
+ return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
+ }, []);
+
+ const handleInstallApp = async () => {
+ if (!installPrompt) return;
+ installPrompt.prompt();
+ const { outcome } = await installPrompt.userChoice;
+ if (outcome === 'accepted') {
+ setShowInstallOption(false);
+ }
+ setInstallPrompt(null);
+ };
+
+ // Dev tools functions
+ const formatTestTime = (seconds) => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${String(secs).padStart(2, '0')}`;
+ };
+
+ const loadVersions = async () => {
+ if (versions.length > 0) {
+ setShowVersions(!showVersions);
+ return;
+ }
+ setLoadingVersions(true);
+ try {
+ const data = await api.getGameVersions(game.id);
+ setVersions(data.versions || []);
+ setShowVersions(true);
+ } catch (err) {
+ console.error('Failed to load versions:', err);
+ } finally {
+ setLoadingVersions(false);
+ }
+ };
+
+ const handleRollback = async (versionNumber) => {
+ if (!confirm(`Restore version ${versionNumber}? Current code will be saved as a snapshot.`)) return;
+ setDevSaving(true);
+ setDevError('');
+ try {
+ await api.rollbackGame(game.id, versionNumber);
+ setDevSuccess(`Rolled back to version ${versionNumber}! Reloading...`);
+ setTimeout(() => window.location.reload(), 1000);
+ } catch (err) {
+ setDevError(err.message || 'Failed to rollback');
+ setDevSaving(false);
+ }
+ };
+
+ const handleDevRefine = async () => {
+ if (!devFeedback.trim()) {
+ setDevError('Please describe what you want to change');
+ return;
+ }
+ setDevSaving(true);
+ setDevError('');
+ try {
+ await api.refineGame(game.id, devFeedback);
+ setDevSuccess('Game updated! Reloading...');
+ setDevFeedback('');
+ setTimeout(() => window.location.reload(), 1000);
+ } catch (err) {
+ // Check if game was actually updated despite error
+ try {
+ const status = await api.getCreationStatus(game.id);
+ if (status.game.status === 'testing' && status.game.gameCode) {
+ setDevSuccess('Game updated! Reloading...');
+ setTimeout(() => window.location.reload(), 1000);
+ return;
+ }
+ } catch {}
+ setDevError(err.message.includes('timed out')
+ ? 'Update timed out. The AI may still be working - try again in a moment.'
+ : err.message);
+ } finally {
+ setDevSaving(false);
+ }
+ };
+
+ const handleDevPublish = async () => {
+ if (!canPublish) {
+ setDevError(`Play for ${REQUIRED_TEST_TIME - testingSeconds} more seconds to unlock publishing.`);
+ return;
+ }
+ setDevSaving(true);
+ setDevError('');
+ try {
+ await api.trackTesting(game.id, testingSeconds, 0).catch(() => {});
+ await api.publishGame(game.id);
+ setDevSuccess('Game published! Redirecting to arcade...');
+ // Navigate to arcade with the game title as search filter
+ setTimeout(() => {
+ navigate(`/arcade?search=${encodeURIComponent(game.title)}`);
+ }, 1500);
+ } catch (err) {
+ setDevError(err.message || 'Failed to publish game');
+ setDevSaving(false);
+ }
+ };
+
+ const handleDevDelete = async () => {
+ if (!confirm('Delete this game? This cannot be undone.')) return;
+ setDevSaving(true);
+ try {
+ await api.deleteGame(game.id);
+ navigate('/dashboard');
+ } catch (err) {
+ setDevError(err.message);
+ setDevSaving(false);
+ }
+ };
+
+ const pauseGame = useCallback(() => {
+ try {
+ const win = iframeRef.current?.contentWindow;
+ if (!win) return;
+ // Try direct function call first (same-origin), then postMessage fallback
+ if (typeof win.pauseGame === 'function') win.pauseGame();
+ else win.postMessage({ type: 'pauseGame' }, '*');
+ if (typeof win.pauseMusic === 'function') win.pauseMusic();
+ else win.postMessage({ type: 'pauseMusic' }, '*');
+ } catch {}
+ }, []);
+
+ const resumeGame = useCallback(() => {
+ try {
+ const win = iframeRef.current?.contentWindow;
+ if (!win) return;
+ if (typeof win.resumeGame === 'function') win.resumeGame();
+ else win.postMessage({ type: 'resumeGame' }, '*');
+ if (typeof win.resumeMusic === 'function') win.resumeMusic();
+ else win.postMessage({ type: 'resumeMusic' }, '*');
+ } catch {}
+ }, []);
+
+ // Focus iframe when game starts or resumes
+ const focusGame = useCallback(() => {
+ if (iframeRef.current) {
+ iframeRef.current.focus();
+ try {
+ iframeRef.current.contentWindow?.focus();
+ iframeRef.current.contentDocument?.body?.focus();
+ } catch {}
+ }
+ }, []);
+
+ const toggleHelpPopup = useCallback(() => {
+ setShowHelpPopup(prev => {
+ if (prev) {
+ resumeGame();
+ setTimeout(focusGame, 50);
+ return false;
+ } else {
+ pauseGame();
+ return true;
+ }
+ });
+ }, [pauseGame, resumeGame, focusGame]);
+
+ // Keyboard shortcuts for help/pause (pause-only, no keyboard unpause)
+ // Listens on both parent window AND iframe contentDocument for reliable P key detection
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (!gameStarted || showInstructions) return;
+
+ // Don't trigger shortcuts when typing in inputs/textareas
+ const tag = (e.target?.tagName || '').toLowerCase();
+ if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) {
+ return;
+ }
+
+ const key = e.key.toLowerCase();
+ // Toggle pause popup with P/H/?
+ if (key === 'h' || key === 'p' || key === '?') {
+ e.preventDefault();
+ e.stopPropagation();
+ if (showHelpPopup) {
+ setShowHelpPopup(false);
+ resumeGame();
+ } else {
+ pauseGame();
+ setShowHelpPopup(true);
+ }
+ return; // Prevent further handling
+ }
+ // Close pause popup with Escape only
+ if (showHelpPopup && key === 'escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowHelpPopup(false);
+ resumeGame();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ // Also listen inside iframe (same-origin allows direct access)
+ let iframeDoc = null;
+ try {
+ iframeDoc = iframeRef.current?.contentDocument;
+ if (iframeDoc) {
+ iframeDoc.addEventListener('keydown', handleKeyDown, true);
+ }
+ } catch {}
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ try {
+ if (iframeDoc) iframeDoc.removeEventListener('keydown', handleKeyDown, true);
+ } catch {}
+ };
+ }, [gameStarted, showInstructions, showHelpPopup, pauseGame, iframeLoaded]);
+
+ const loadGame = async () => {
+ try {
+ // First try the arcade route (for published games)
+ const data = await api.getGame(gameId);
+ setGame(data.game);
+ setUserRating(data.userRating || 0);
+ setUserPlayCount(data.userPlayCount || 0);
+ setIsFavorited(data.isFavorited || false);
+ setLoading(false);
+ } catch (err) {
+ // If not found in arcade, try the games route (for creator's own testing/draft games)
+ if (err.message.includes('not found') || err.message.includes('404')) {
+ try {
+ const data = await api.getMyGame(gameId);
+ setGame(data.game);
+ // For own games during testing, auto-load the game code
+ if (data.game.gameCode && data.game.isOwner) {
+ setGameCode(data.game.gameCode);
+ setCodeVersion(data.game.codeVersion || 1);
+ // Auto-start if arriving from remix or newGame flow
+ if (searchParams.get('newGame') === 'true') {
+ setStartTime(Date.now());
+ setShowInstructions(false);
+ setGameStarted(true);
+ setSearchParams({}, { replace: true });
+ setTimeout(focusGame, 100);
+ }
+ }
+ setLoading(false);
+ } catch (err2) {
+ setError(err2.message);
+ setLoading(false);
+ }
+ } else {
+ setError(err.message);
+ setLoading(false);
+ }
+ }
+ };
+
+ const startGame = async () => {
+ // If creator is testing their own game, skip tokens and just play
+ const isCreatorTesting = game?.isOwner && game?.status !== 'published';
+ if (isCreatorTesting && game?.gameCode) {
+ setGameCode(game.gameCode);
+ setCodeVersion(game.codeVersion || 1);
+ setStartTime(Date.now());
+ setShowInstructions(false);
+ setGameStarted(true);
+ // Focus game after a brief delay for iframe to load
+ setTimeout(focusGame, 100);
+ return;
+ }
+
+ // For guests, we need fingerprint ready
+ if (!isAuthenticated && !fingerprintReady) {
+ setError('Please wait while we set up your session...');
+ return;
+ }
+
+ // Check token balance (for authenticated users) - skip for creator testing
+ if (!isCreatorTesting && isAuthenticated && user && user.tokensBalance < 1) {
+ setError('Not enough tokens! Come back tomorrow for free tokens.');
+ return;
+ }
+
+ try {
+ const playData = await api.startPlay(parseInt(gameId));
+ setSessionId(playData.sessionId);
+ setGameCode(playData.gameCode);
+ setCodeVersion(playData.codeVersion || 1);
+ setStartTime(Date.now());
+ setShowInstructions(false);
+ setGameStarted(true);
+ // Focus game after a brief delay for iframe to load
+ setTimeout(focusGame, 100);
+
+ // If this was a guest play, set the guest user in context
+ if (playData.isGuest && playData.guestUser) {
+ setGuestUser(playData.guestUser);
+ updateTokens(playData.guestUser.tokensBalance);
+ } else if (playData.tokensBalance !== undefined) {
+ updateTokens(playData.tokensBalance);
+ }
+ } catch (err) {
+ if (err.requiresSignup) {
+ setUpgradeFeature('play games');
+ setShowUpgradePrompt(true);
+ } else {
+ setError(err.message);
+ }
+ }
+ };
+
+ const endPlay = async () => {
+ if (!sessionId) {
+ navigate(isCreatorTesting ? '/dashboard' : '/arcade');
+ return;
+ }
+
+ const duration = Math.floor((Date.now() - startTime) / 1000);
+ // Use score from postMessage (reportedScore) since we use blob URLs now
+ const finalScore = reportedScore;
+
+ try {
+ const result = await api.finishPlay(sessionId, finalScore, duration);
+ if (result.tokensBalance !== undefined) {
+ updateTokens(result.tokensBalance);
+ }
+
+ const newPlayCount = userPlayCount + 1;
+ setUserPlayCount(newPlayCount);
+
+ // Show rating popup only if 3+ plays AND user hasn't rated yet
+ if (!isCreatorTesting && newPlayCount >= 3 && userRating === 0) {
+ setShowRatingPopup(true);
+ setGameStarted(false);
+ } else {
+ navigate(isCreatorTesting ? '/dashboard' : '/arcade');
+ }
+ } catch (err) {
+ navigate(isCreatorTesting ? '/dashboard' : '/arcade');
+ }
+ };
+
+ const handleRate = async (rating) => {
+ try {
+ await api.rateGame(parseInt(gameId), rating);
+ setUserRating(rating);
+ } catch {}
+ setShowRatingPopup(false);
+ navigate('/arcade');
+ };
+
+ const handleToggleFavorite = async () => {
+ if (!isAuthenticated) return;
+ try {
+ const result = await api.toggleFavorite(parseInt(gameId));
+ setIsFavorited(result.isFavorited);
+ } catch {}
+ };
+
+ const formatTime = (seconds) => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const toggleMusic = () => {
+ const newVal = !musicEnabled;
+ setMusicEnabled(newVal);
+ setAudioPref('music', newVal);
+ try {
+ iframeRef.current?.contentWindow?.postMessage({ type: 'setMusic', enabled: newVal }, '*');
+ } catch {}
+ };
+
+ const toggleSfx = () => {
+ const newVal = !sfxEnabled;
+ setSfxEnabled(newVal);
+ setAudioPref('sfx', newVal);
+ try {
+ iframeRef.current?.contentWindow?.postMessage({ type: 'setSfx', enabled: newVal }, '*');
+ } catch {}
+ };
+
+ // Send audio prefs to iframe when game loads
+ useEffect(() => {
+ if (gameCode && iframeRef.current) {
+ const sendPrefs = () => {
+ try {
+ iframeRef.current?.contentWindow?.postMessage({ type: 'setMusic', enabled: musicEnabled }, '*');
+ iframeRef.current?.contentWindow?.postMessage({ type: 'setSfx', enabled: sfxEnabled }, '*');
+ } catch {}
+ };
+ // Try immediately and after a short delay for iframe to initialize
+ sendPrefs();
+ const timer = setTimeout(sendPrefs, 500);
+ return () => clearTimeout(timer);
+ }
+ }, [gameCode, musicEnabled, sfxEnabled]);
+
+ // Inject keyboard listener, score reporting, and audio control into game code
+ const getInjectedGameCode = useCallback((code, version) => {
+ if (!code) return code;
+ // Add version comment for cache busting
+ const versionComment = `\n`;
+ const injectedScript = `
+`;
+ // Inject version comment and script before or
+
+
+ or at end
+ if (code.includes('')) {
+ return versionComment + code.replace('', injectedScript + '');
+ } else if (code.includes('')) {
+ return versionComment + code.replace('', injectedScript + '');
+ }
+ return versionComment + code + injectedScript;
+ }, []);
+
+ // Listen for messages from iframe (pause-only from iframe, no close)
+ useEffect(() => {
+ const handleMessage = (e) => {
+ if (e.data?.type === 'togglePause') {
+ if (!showHelpPopup) {
+ pauseGame();
+ setShowHelpPopup(true);
+ }
+ } else if (e.data?.type === 'gameScore') {
+ const score = parseInt(e.data.score) || 0;
+ setReportedScore(score);
+ }
+ };
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, [showHelpPopup, pauseGame]);
+
+ // Get injected game code for iframe
+ const injectedGameCode = getInjectedGameCode(gameCode, codeVersion);
+
+
+ const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
+ const instructions = game ? (INSTRUCTIONS[game.title] || { mobile: ['Tap to play!'], desktop: ['Click to play!'] }) : null;
+
+ if (loading) {
+ return
+ );
+ }
+
+ // Guest users and unauthenticated users can now play - just need fingerprint
+ // This block only shows if fingerprint isn't ready yet
+ if (!isAuthenticated && !fingerprintReady) {
+ return (
+
+ );
+ }
+
+ // Rating popup
+ if (showRatingPopup) {
+ return (
+
+ );
+ }
+
+ // Instructions screen (before game starts)
+ if (showInstructions && !gameStarted) {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/Privacy.jsx b/frontend/src/pages/Privacy.jsx
new file mode 100644
index 0000000..3d3ac3a
--- /dev/null
+++ b/frontend/src/pages/Privacy.jsx
@@ -0,0 +1,250 @@
+import { Link } from 'react-router-dom';
+import './Legal.css';
+
+export default function Privacy() {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css
new file mode 100644
index 0000000..2db6204
--- /dev/null
+++ b/frontend/src/pages/Profile.css
@@ -0,0 +1,704 @@
+.profile-page {
+ min-height: calc(100vh - 60px);
+ padding: 2rem 1rem;
+ background: #f8fafc;
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.profile-header {
+ display: flex;
+ gap: 2rem;
+ background: white;
+ padding: 2rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ margin-bottom: 1.5rem;
+}
+
+.profile-avatar {
+ flex-shrink: 0;
+}
+
+.profile-avatar img,
+.avatar-placeholder {
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.avatar-placeholder {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 3rem;
+ font-weight: 700;
+}
+
+.profile-avatar-wrapper {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.profile-avatar-wrapper .avatar-svg {
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
+}
+
+.edit-avatar-btn {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: white;
+ border: 2px solid #6366f1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.edit-avatar-btn:hover {
+ transform: scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.profile-info {
+ flex: 1;
+}
+
+.profile-info h1 {
+ margin: 0 0 0.25rem;
+ color: #1e293b;
+ font-size: 1.5rem;
+}
+
+.username {
+ color: #64748b;
+ margin: 0 0 0.5rem;
+ font-size: 0.95rem;
+}
+
+.bio {
+ color: #475569;
+ margin: 0 0 1rem;
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+.profile-stats-row {
+ display: flex;
+ gap: 1.5rem;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+
+.stat {
+ text-align: center;
+}
+
+.stat-value {
+ display: block;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.stat-label {
+ font-size: 0.75rem;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+}
+
+.profile-xp {
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ padding: 1.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
+}
+
+.profile-xp .xp-display {
+ flex: 1;
+ background: transparent;
+ color: white;
+}
+
+.profile-xp .level-badge {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+}
+
+.profile-xp .level-name {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.profile-xp .xp-bar {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.profile-xp .xp-fill {
+ background: white;
+}
+
+.profile-xp .xp-text {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.profile-xp .xp-needed {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.level-icon-display {
+ flex-shrink: 0;
+}
+
+.level-icon-large {
+ font-size: 3.5rem;
+ display: block;
+ filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
+}
+
+/* Profile Stats Section */
+.profile-stats-section {
+ margin-bottom: 1.5rem;
+}
+
+/* Quick Stats Grid */
+.quick-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.quick-stat-card {
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ text-align: center;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.quick-stat-card.highlight {
+ background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
+ color: white;
+}
+
+.quick-stat-card.highlight .quick-stat-value,
+.quick-stat-card.highlight .quick-stat-label {
+ color: white;
+}
+
+.quick-stat-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #1e293b;
+ margin-bottom: 0.25rem;
+}
+
+.quick-stat-label {
+ font-size: 0.75rem;
+ color: #64748b;
+}
+
+/* Streaks Row */
+.streaks-row {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.streak-badge {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+ border: 2px solid #e2e8f0;
+ transition: all 0.3s ease;
+}
+
+.streak-badge.on-fire {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ border-color: #f59e0b;
+ box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
+}
+
+.streak-badge.on-fire .streak-icon {
+ animation: pulse-fire 1s ease-in-out infinite;
+}
+
+@keyframes pulse-fire {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.1); }
+}
+
+.streak-badge .streak-icon {
+ font-size: 1.75rem;
+}
+
+.streak-badge .streak-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.streak-badge .streak-label {
+ font-size: 0.75rem;
+ color: #64748b;
+}
+
+.streak-badge .streak-milestone {
+ font-size: 0.65rem;
+ font-weight: 600;
+ color: #f59e0b;
+ background: rgba(245, 158, 11, 0.15);
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+.streak-badge.on-fire .streak-milestone {
+ background: rgba(0, 0, 0, 0.1);
+ color: #92400e;
+}
+
+/* XP Activity Card */
+.xp-activity-card {
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.xp-activity-card h3 {
+ margin: 0 0 0.75rem;
+ font-size: 0.9rem;
+ color: #1e293b;
+}
+
+.xp-activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.xp-activity-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.xp-activity-item:last-child {
+ border-bottom: none;
+}
+
+.xp-activity-label {
+ flex: 1;
+ font-size: 0.85rem;
+ color: #475569;
+}
+
+.xp-activity-amount {
+ font-weight: 600;
+ color: #10b981;
+ font-size: 0.85rem;
+}
+
+.xp-activity-time {
+ font-size: 0.75rem;
+ color: #94a3b8;
+ min-width: 60px;
+ text-align: right;
+}
+
+/* Achievements */
+.profile-section {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ margin-bottom: 1.5rem;
+}
+
+.profile-section h2 {
+ margin: 0;
+ font-size: 1.25rem;
+ color: #1e293b;
+}
+
+/* Achievement Showcase */
+.achievements-showcase .section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.toggle-btn {
+ background: transparent;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.8rem;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.toggle-btn:hover {
+ background: #f1f5f9;
+ color: #1e293b;
+}
+
+.achievements-summary {
+ display: flex;
+ gap: 2rem;
+ margin-bottom: 1rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.earned-count {
+ font-weight: 600;
+ color: #7c3aed;
+}
+
+.xp-from-achievements {
+ color: #10b981;
+ font-weight: 500;
+}
+
+.achievements-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.75rem;
+}
+
+.achievement-card {
+ background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%);
+ border: 1px solid #e9d5ff;
+ border-radius: 0.75rem;
+ padding: 0.875rem 0.75rem;
+ text-align: center;
+ cursor: help;
+ transition: transform 0.2s, box-shadow 0.2s;
+ min-height: 100px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.achievement-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
+}
+
+.achievement-icon {
+ font-size: 1.75rem;
+ display: block;
+ margin-bottom: 0.375rem;
+ line-height: 1;
+}
+
+.achievement-name {
+ font-weight: 600;
+ color: #6d28d9;
+ font-size: 0.8rem;
+ display: block;
+ line-height: 1.2;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ max-width: 100%;
+}
+
+.achievement-date {
+ font-size: 0.7rem;
+ color: #a78bfa;
+ display: block;
+ margin-top: 0.25rem;
+}
+
+.achievement-card.locked {
+ background: #f8fafc;
+ border-color: #e2e8f0;
+ opacity: 0.6;
+}
+
+.achievement-card.locked .achievement-icon {
+ filter: grayscale(1);
+}
+
+.achievement-card.locked .achievement-name {
+ color: #94a3b8;
+}
+
+.achievement-reward {
+ font-size: 0.7rem;
+ color: #10b981;
+ display: block;
+ margin-top: 0.25rem;
+ font-weight: 600;
+}
+
+/* Tabs */
+.profile-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background: white;
+ padding: 0.5rem;
+ border-radius: 0.75rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.tab {
+ flex: 1;
+ background: transparent;
+ border: none;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ font-weight: 500;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.tab:hover {
+ background: #f1f5f9;
+}
+
+.tab.active {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+}
+
+/* Content */
+.profile-content {
+ min-height: 200px;
+}
+
+.games-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 1.5rem;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem;
+ background: white;
+ border-radius: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+}
+
+.empty-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.empty-state p {
+ color: #64748b;
+ margin: 0 0 1rem;
+}
+
+/* Play History */
+.play-history {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.history-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: white;
+ padding: 1rem 1.25rem;
+ border-radius: 0.75rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+ text-decoration: none;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.history-item:hover {
+ transform: translateX(4px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.history-game strong {
+ display: block;
+ color: #1e293b;
+ margin-bottom: 0.25rem;
+}
+
+.history-date {
+ font-size: 0.8rem;
+ color: #94a3b8;
+}
+
+.history-stats {
+ text-align: right;
+}
+
+.history-score {
+ display: block;
+ font-weight: 600;
+ color: #10b981;
+}
+
+.history-duration {
+ font-size: 0.8rem;
+ color: #64748b;
+}
+
+/* Error */
+.profile-error {
+ text-align: center;
+ padding: 3rem;
+}
+
+.profile-error h2 {
+ margin: 0 0 1rem;
+ color: #64748b;
+}
+
+/* Responsive */
+@media (max-width: 640px) {
+ .profile-page {
+ padding: 1rem;
+ }
+
+ .profile-header {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 1.5rem;
+ gap: 1rem;
+ }
+
+ .profile-info h1 {
+ font-size: 1.35rem;
+ }
+
+ .profile-stats-row {
+ justify-content: center;
+ gap: 1rem;
+ }
+
+ .stat-value {
+ font-size: 1.1rem;
+ }
+
+ .stat-label {
+ font-size: 0.7rem;
+ }
+
+ .profile-xp {
+ padding: 1rem;
+ gap: 1rem;
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .level-icon-large {
+ font-size: 2.5rem;
+ }
+
+ /* Quick Stats - 2 columns on mobile */
+ .quick-stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.5rem;
+ }
+
+ .quick-stat-card {
+ padding: 0.75rem;
+ }
+
+ .quick-stat-value {
+ font-size: 1.25rem;
+ }
+
+ .quick-stat-label {
+ font-size: 0.7rem;
+ }
+
+ /* Streaks */
+ .streaks-row {
+ gap: 0.5rem;
+ }
+
+ .streak-badge {
+ padding: 0.75rem;
+ }
+
+ .streak-badge .streak-icon {
+ font-size: 1.5rem;
+ }
+
+ .streak-badge .streak-value {
+ font-size: 1.25rem;
+ }
+
+ .streak-badge .streak-label {
+ font-size: 0.7rem;
+ }
+
+ .profile-tabs {
+ flex-wrap: wrap;
+ }
+
+ .tab {
+ flex-basis: calc(50% - 0.25rem);
+ padding: 0.6rem 0.75rem;
+ font-size: 0.9rem;
+ }
+
+ .games-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .achievements-grid {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5rem;
+ }
+
+ .achievement-card {
+ padding: 0.625rem 0.5rem;
+ min-height: 85px;
+ }
+
+ .achievement-icon {
+ font-size: 1.5rem;
+ margin-bottom: 0.25rem;
+ }
+
+ .achievement-name {
+ font-size: 0.7rem;
+ }
+
+ .achievement-date,
+ .achievement-reward {
+ font-size: 0.6rem;
+ }
+}
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx
new file mode 100644
index 0000000..4088b43
--- /dev/null
+++ b/frontend/src/pages/Profile.jsx
@@ -0,0 +1,461 @@
+import { useState, useEffect } from 'react';
+import { useParams, Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import XPDisplay from '../components/XPDisplay';
+import GameCard from '../components/GameCard';
+import AvatarBuilder, { AvatarDisplay, DEFAULT_AVATAR } from '../components/AvatarBuilder';
+import './Profile.css';
+
+const LEVEL_ICONS = {
+ 1: '🌱', 2: '🎮', 3: '🎯', 4: '⭐', 5: '🔥',
+ 6: '💎', 7: '🏆', 8: '👑', 9: '🌟', 10: '🎖️'
+};
+
+export default function Profile() {
+ const { userId } = useParams();
+ const { user: currentUser } = useAuth();
+
+ const [profile, setProfile] = useState(null);
+ const [stats, setStats] = useState(null);
+ const [games, setGames] = useState([]);
+ const [achievements, setAchievements] = useState([]);
+ const [favorites, setFavorites] = useState([]);
+ const [playStats, setPlayStats] = useState(null);
+ const [xpHistory, setXpHistory] = useState([]);
+ const [isFollowing, setIsFollowing] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState('games');
+ const [showAllAchievements, setShowAllAchievements] = useState(false);
+ const [showAvatarBuilder, setShowAvatarBuilder] = useState(false);
+ const [savingAvatar, setSavingAvatar] = useState(false);
+
+ const isOwnProfile = !userId || userId === String(currentUser?.id);
+ const profileId = isOwnProfile ? currentUser?.id : userId;
+
+ useEffect(() => {
+ if (profileId) {
+ loadProfile();
+ }
+ }, [profileId]);
+
+ async function loadProfile() {
+ setLoading(true);
+ try {
+ if (isOwnProfile) {
+ const [meData, statsData, achievementsData, favoritesData, gamesData, historyData, xpHistoryData] = await Promise.all([
+ api.getMe(),
+ api.getMyStats(),
+ api.getMyAchievements(),
+ api.getMyFavorites(),
+ api.getMyGames(),
+ api.getPlayHistory(),
+ api.getXPHistory(10)
+ ]);
+ setProfile(meData.user);
+ setStats(statsData.stats);
+ setAchievements(achievementsData.achievements || []);
+ setFavorites(favoritesData.favorites || []);
+ setGames((gamesData.games || []).filter(g => g.status === 'published'));
+ setPlayStats(historyData.stats || {});
+ setXpHistory(xpHistoryData.transactions || []);
+ } else {
+ const [profileData, gamesData] = await Promise.all([
+ api.getUserProfile(profileId),
+ api.request(`/users/${profileId}/games`)
+ ]);
+ setProfile(profileData.user);
+ setStats({
+ gamesPlayed: profileData.user?.totalPlays || 0,
+ gamesCreated: profileData.user?.gamesCount || 0
+ });
+ setAchievements([]);
+ setIsFollowing(profileData.isFollowing || false);
+ setGames(gamesData.games || []);
+ }
+ } catch (err) {
+ console.error('Failed to load profile:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleFollow() {
+ try {
+ if (isFollowing) {
+ await api.unfollowUser(profileId);
+ setIsFollowing(false);
+ setProfile(prev => ({ ...prev, followerCount: (prev.followerCount || 1) - 1 }));
+ } else {
+ await api.followUser(profileId);
+ setIsFollowing(true);
+ setProfile(prev => ({ ...prev, followerCount: (prev.followerCount || 0) + 1 }));
+ }
+ } catch (err) {
+ console.error('Failed to follow/unfollow:', err);
+ }
+ }
+
+ async function handleSaveAvatar(newAvatarData) {
+ setSavingAvatar(true);
+ try {
+ await api.updateAvatar(newAvatarData);
+ setProfile(prev => ({ ...prev, avatarData: newAvatarData }));
+ setShowAvatarBuilder(false);
+ } catch (err) {
+ console.error('Failed to save avatar:', err);
+ } finally {
+ setSavingAvatar(false);
+ }
+ }
+
+ const formatTime = (seconds) => {
+ if (!seconds) return '0m';
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ if (hours > 0) return `${hours}h ${mins}m`;
+ return `${mins}m`;
+ };
+
+ const formatTimeAgo = (dateStr) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+ };
+
+ if (loading) {
+ return (
+
+ );
+}
+
+function PlayHistory() {
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadHistory();
+ }, []);
+
+ async function loadHistory() {
+ try {
+ const data = await api.getPlayHistory();
+ setHistory(data.history || []);
+ } catch (err) {
+ console.error('Failed to load history:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (loading) {
+ return
Loading history...
+ );
+}
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx
new file mode 100644
index 0000000..c206dcc
--- /dev/null
+++ b/frontend/src/pages/Register.jsx
@@ -0,0 +1,207 @@
+import { useState, useEffect } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import './Auth.css';
+
+export default function Register() {
+ const navigate = useNavigate();
+ const { register } = useAuth();
+
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [ageConfirm, setAgeConfirm] = useState(false);
+ const [termsAccepted, setTermsAccepted] = useState(false);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [suggestions, setSuggestions] = useState([]);
+ const [usernameError, setUsernameError] = useState('');
+
+ // Load initial username suggestions
+ useEffect(() => {
+ fetch('/api/auth/username-suggestions')
+ .then(res => res.json())
+ .then(data => setSuggestions(data.suggestions))
+ .catch(() => {});
+ }, []);
+
+ const handleUsernameChange = (e) => {
+ const value = e.target.value;
+ setUsername(value);
+ setUsernameError('');
+ };
+
+ const selectSuggestion = (name) => {
+ setUsername(name);
+ setUsernameError('');
+ setSuggestions([]);
+ };
+
+ const refreshSuggestions = async () => {
+ try {
+ const res = await fetch('/api/auth/username-suggestions');
+ const data = await res.json();
+ setSuggestions(data.suggestions);
+ } catch {
+ // ignore
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setUsernameError('');
+
+ if (!ageConfirm) {
+ setError('Please confirm your age to continue');
+ return;
+ }
+
+ if (!termsAccepted) {
+ setError('Please accept the Terms of Service and Privacy Policy to continue');
+ return;
+ }
+
+ if (password.length < 6) {
+ setError('Password must be at least 6 characters');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ await register(username, email, password);
+ navigate('/arcade');
+ } catch (err) {
+ // Check if response has suggestions
+ const errorData = err.response?.data || {};
+ if (errorData.suggestions) {
+ setSuggestions(errorData.suggestions);
+ setUsernameError(errorData.message || errorData.error);
+ } else {
+ setError(err.message);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/ReleaseNotes.css b/frontend/src/pages/ReleaseNotes.css
new file mode 100644
index 0000000..c05d2ce
--- /dev/null
+++ b/frontend/src/pages/ReleaseNotes.css
@@ -0,0 +1,226 @@
+.release-notes {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+.release-notes-header {
+ text-align: center;
+ margin-bottom: 2.5rem;
+}
+
+.release-notes-header h1 {
+ font-size: 2rem;
+ color: #1e293b;
+ margin: 0 0 0.5rem;
+}
+
+.release-notes-subtitle {
+ color: #64748b;
+ font-size: 1.1rem;
+ margin: 0;
+}
+
+/* Release cards */
+.releases-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.release-card {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+ border: 1px solid #e2e8f0;
+}
+
+.release-card.latest {
+ border-color: #6366f1;
+ box-shadow: 0 4px 20px rgba(99, 102, 241, 0.15);
+}
+
+.release-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.release-version-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.release-version {
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: #6366f1;
+ background: rgba(99, 102, 241, 0.1);
+ padding: 0.25rem 0.75rem;
+ border-radius: 2rem;
+}
+
+.latest-badge {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: white;
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ padding: 0.2rem 0.5rem;
+ border-radius: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.release-date {
+ font-size: 0.85rem;
+ color: #94a3b8;
+}
+
+.release-title {
+ font-size: 1.25rem;
+ color: #1e293b;
+ margin: 0 0 1rem;
+ font-weight: 600;
+}
+
+/* Changes list */
+.release-changes {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.change-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ background: #f8fafc;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.change-icon {
+ flex-shrink: 0;
+ font-size: 1rem;
+}
+
+.change-type {
+ flex-shrink: 0;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ letter-spacing: 0.3px;
+}
+
+.change-text {
+ color: #475569;
+}
+
+/* Change type colors */
+.change-feature .change-type {
+ background: rgba(99, 102, 241, 0.15);
+ color: #4f46e5;
+}
+
+.change-improvement .change-type {
+ background: rgba(59, 130, 246, 0.15);
+ color: #2563eb;
+}
+
+.change-fix .change-type {
+ background: rgba(251, 191, 36, 0.2);
+ color: #b45309;
+}
+
+.change-security .change-type {
+ background: rgba(16, 185, 129, 0.15);
+ color: #059669;
+}
+
+/* Footer */
+.release-notes-footer {
+ margin-top: 3rem;
+ text-align: center;
+ padding: 1.5rem;
+ background: #f8fafc;
+ border-radius: 1rem;
+}
+
+.release-notes-footer p {
+ margin: 0;
+ color: #64748b;
+}
+
+.release-notes-footer a {
+ color: #6366f1;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.release-notes-footer a:hover {
+ text-decoration: underline;
+}
+
+/* Mobile responsive */
+@media (max-width: 768px) {
+ .release-notes {
+ padding: 1.5rem 1rem;
+ }
+
+ .release-notes-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .release-notes-subtitle {
+ font-size: 1rem;
+ }
+
+ .release-card {
+ padding: 1.25rem;
+ }
+
+ .release-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .release-title {
+ font-size: 1.1rem;
+ }
+
+ .change-item {
+ flex-wrap: wrap;
+ padding: 0.5rem;
+ }
+
+ .change-type {
+ order: -1;
+ margin-left: 1.5rem;
+ }
+
+ .change-text {
+ width: 100%;
+ padding-left: 1.5rem;
+ }
+}
+
+/* Touch-friendly */
+@media (hover: none), (pointer: coarse) {
+ .release-notes-footer a {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ min-height: 44px;
+ line-height: 1.5;
+ }
+}
diff --git a/frontend/src/pages/ReleaseNotes.jsx b/frontend/src/pages/ReleaseNotes.jsx
new file mode 100644
index 0000000..539a8c7
--- /dev/null
+++ b/frontend/src/pages/ReleaseNotes.jsx
@@ -0,0 +1,155 @@
+import './ReleaseNotes.css';
+
+const releases = [
+ {
+ version: '1.4.0',
+ date: '2026-02-07',
+ title: 'Safety Messaging & Mobile Fixes',
+ changes: [
+ { type: 'feature', text: 'Added "Rated E for Everyone" safety messaging to home page' },
+ { type: 'feature', text: 'Added safety badges: No Chat, No Strangers, All Games Moderated' },
+ { type: 'feature', text: 'Added safety footer to all email communications' },
+ { type: 'fix', text: 'Fixed header layout on ultra-narrow screens (Galaxy Fold outer screen)' },
+ { type: 'fix', text: 'Fixed CSS media query ordering for proper mobile cascade' },
+ { type: 'fix', text: 'Token display now shown in mobile menu on narrow screens' },
+ { type: 'security', text: 'Added HTML escaping to prevent XSS in forwarded emails' },
+ ]
+ },
+ {
+ version: '1.3.0',
+ date: '2026-02-05',
+ title: 'Streaming Generation & Push Notifications',
+ changes: [
+ { type: 'feature', text: 'Real-time progress streaming for game generation via SSE' },
+ { type: 'feature', text: 'Background generation - safe to close browser while generating' },
+ { type: 'feature', text: 'Push notifications when your game is ready' },
+ { type: 'feature', text: 'In-app notifications for game completion' },
+ { type: 'feature', text: 'Email notifications when generation finishes' },
+ { type: 'improvement', text: 'Replaced Ollama with Claude Haiku for faster AI responses' },
+ { type: 'improvement', text: 'All lightweight AI tasks now use Haiku (tweaks, refinement, summaries)' },
+ { type: 'improvement', text: 'DevToolbar shows real-time progress during refinement' },
+ ]
+ },
+ {
+ version: '1.2.0',
+ date: '2026-02-04',
+ title: 'Streamlined Creation & Profile Improvements',
+ changes: [
+ { type: 'feature', text: 'Simplified game creation wizard from 5 to 3 steps' },
+ { type: 'feature', text: 'Added avatar customization during game creation' },
+ { type: 'feature', text: 'New Profile page with stats, streaks, and XP activity' },
+ { type: 'feature', text: 'Report Issue now includes AI-powered analysis' },
+ { type: 'improvement', text: 'Combined Avatar + Level badge in header (links to profile)' },
+ { type: 'improvement', text: 'Merged game toolbar into single unified row' },
+ { type: 'improvement', text: 'Dashboard simplified to focus on collections and games' },
+ { type: 'fix', text: 'Fixed iOS keyboard issues with game edit textarea' },
+ { type: 'fix', text: 'Fixed sticky header on mobile Safari' },
+ ]
+ },
+ {
+ version: '1.1.0',
+ date: '2026-02-01',
+ title: 'Classrooms & Education Features',
+ changes: [
+ { type: 'feature', text: 'Teacher Console for managing student groups' },
+ { type: 'feature', text: 'Classroom assignments and progress tracking' },
+ { type: 'feature', text: 'Student progress reports for educators' },
+ { type: 'feature', text: 'Leaderboard with players, creators, and games rankings' },
+ { type: 'improvement', text: 'COPPA-compliant design for ages 8-16' },
+ { type: 'improvement', text: 'Enhanced content moderation system' },
+ ]
+ },
+ {
+ version: '1.0.0',
+ date: '2026-01-15',
+ title: 'Public Launch',
+ changes: [
+ { type: 'feature', text: 'AI-powered game creation wizard' },
+ { type: 'feature', text: 'Play games in browser with touch/keyboard support' },
+ { type: 'feature', text: 'Game collections for organizing favorites' },
+ { type: 'feature', text: 'XP and achievement system' },
+ { type: 'feature', text: 'Creator profiles and game statistics' },
+ { type: 'feature', text: 'Guest play without account required' },
+ { type: 'feature', text: 'PWA support - install as app' },
+ { type: 'feature', text: 'High score tracking and leaderboards' },
+ ]
+ },
+ {
+ version: '0.9.0',
+ date: '2026-01-01',
+ title: 'Beta Release',
+ changes: [
+ { type: 'feature', text: 'Initial game arcade with AI generation' },
+ { type: 'feature', text: 'User accounts and authentication' },
+ { type: 'feature', text: 'Game publishing workflow' },
+ { type: 'feature', text: 'DevToolbar for game tweaking (P key)' },
+ { type: 'feature', text: 'Mobile-responsive design' },
+ ]
+ }
+];
+
+function getTypeIcon(type) {
+ switch (type) {
+ case 'feature': return '✨';
+ case 'improvement': return '🔧';
+ case 'fix': return '🐛';
+ case 'security': return '🔒';
+ default: return '📝';
+ }
+}
+
+function getTypeLabel(type) {
+ switch (type) {
+ case 'feature': return 'New';
+ case 'improvement': return 'Improved';
+ case 'fix': return 'Fixed';
+ case 'security': return 'Security';
+ default: return 'Changed';
+ }
+}
+
+export default function ReleaseNotes() {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/ResetPassword.jsx b/frontend/src/pages/ResetPassword.jsx
new file mode 100644
index 0000000..07b7f4d
--- /dev/null
+++ b/frontend/src/pages/ResetPassword.jsx
@@ -0,0 +1,134 @@
+import { useState } from 'react';
+import { Link, useSearchParams, useNavigate } from 'react-router-dom';
+import api from '../api';
+import './Auth.css';
+
+export default function ResetPassword() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const token = searchParams.get('token');
+
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+
+ if (password !== confirmPassword) {
+ setError('Passwords do not match');
+ return;
+ }
+
+ if (password.length < 6) {
+ setError('Password must be at least 6 characters');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ await api.resetPassword(token, password);
+ setSuccess(true);
+ // Redirect to login after 3 seconds
+ setTimeout(() => navigate('/login'), 3000);
+ } catch (err) {
+ setError(err.message || 'Failed to reset password');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!token) {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/TeacherConsole.css b/frontend/src/pages/TeacherConsole.css
new file mode 100644
index 0000000..22b69de
--- /dev/null
+++ b/frontend/src/pages/TeacherConsole.css
@@ -0,0 +1,835 @@
+.teacher-console {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+/* Header */
+.console-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.header-title h1 {
+ margin: 0;
+ color: #1e293b;
+ font-size: 2rem;
+}
+
+.header-subtitle {
+ margin: 0.5rem 0 0;
+ color: #64748b;
+ font-size: 1rem;
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+/* Empty Console State */
+.empty-console {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: white;
+ border-radius: 1.5rem;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
+}
+
+.empty-console .empty-icon {
+ font-size: 4rem;
+ display: block;
+ margin-bottom: 1.5rem;
+}
+
+.empty-console h2 {
+ margin: 0 0 0.5rem;
+ color: #1e293b;
+}
+
+.empty-console p {
+ margin: 0 0 1.5rem;
+ color: #64748b;
+}
+
+.btn-lg {
+ padding: 1rem 2rem;
+ font-size: 1.1rem;
+}
+
+/* Classroom Selector */
+.classroom-selector {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ margin-bottom: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.classroom-selector label {
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #64748b;
+ margin-bottom: 0.75rem;
+}
+
+.classroom-pills {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.classroom-pill {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.25rem;
+ background: #f8fafc;
+ border: 2px solid #e2e8f0;
+ border-radius: 2rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.classroom-pill:hover {
+ border-color: #6366f1;
+ background: #f0f0ff;
+}
+
+.classroom-pill.active {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border-color: transparent;
+ color: white;
+}
+
+.pill-icon {
+ font-size: 1.25rem;
+}
+
+.pill-name {
+ font-weight: 600;
+}
+
+.pill-count {
+ font-size: 0.8rem;
+ opacity: 0.8;
+}
+
+/* Quick Actions */
+.quick-actions {
+ margin-bottom: 1.5rem;
+}
+
+.quick-actions h3 {
+ margin: 0 0 1rem;
+ font-size: 1rem;
+ color: #64748b;
+}
+
+.actions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 1rem;
+}
+
+.action-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1.5rem 1rem;
+ background: white;
+ border: 2px solid #e2e8f0;
+ border-radius: 1rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-decoration: none;
+ color: inherit;
+}
+
+.action-card:hover {
+ border-color: #6366f1;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.15);
+}
+
+.action-icon {
+ font-size: 2rem;
+}
+
+.action-label {
+ font-weight: 500;
+ color: #1e293b;
+ text-align: center;
+}
+
+/* Class Overview */
+.class-overview {
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ border-radius: 1.5rem;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+ color: white;
+ box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
+}
+
+.class-overview h3 {
+ margin: 0 0 1.25rem;
+ font-size: 1.1rem;
+ font-weight: 500;
+ opacity: 0.9;
+}
+
+.overview-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 1rem;
+}
+
+.overview-stats .stat-card {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 1rem;
+ padding: 1rem;
+ backdrop-filter: blur(10px);
+}
+
+.overview-stats .stat-icon {
+ font-size: 2rem;
+}
+
+.overview-stats .stat-info {
+ flex: 1;
+}
+
+.overview-stats .stat-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.overview-stats .stat-label {
+ font-size: 0.8rem;
+ opacity: 0.85;
+}
+
+/* View Tabs */
+.view-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ background: white;
+ padding: 0.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.tab-btn {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: none;
+ background: transparent;
+ border-radius: 0.75rem;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.tab-btn:hover {
+ background: #f1f5f9;
+}
+
+.tab-btn.active {
+ background: #6366f1;
+ color: white;
+}
+
+/* Console Content */
+.console-content {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+ min-height: 300px;
+}
+
+/* Progress Table */
+.progress-table-container {
+ overflow-x: auto;
+}
+
+.progress-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.progress-table th,
+.progress-table td {
+ padding: 1rem 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.progress-table th {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.progress-table tbody tr:hover {
+ background: #f8fafc;
+}
+
+.student-cell {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.student-name {
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.student-badge {
+ font-size: 0.7rem;
+ background: #e0e7ff;
+ color: #4f46e5;
+ padding: 0.125rem 0.5rem;
+ border-radius: 1rem;
+}
+
+.level-badge {
+ display: inline-block;
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
+ color: white;
+ padding: 0.25rem 0.6rem;
+ border-radius: 1rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.xp-value {
+ font-weight: 600;
+ color: #10b981;
+}
+
+.stat-value {
+ color: #64748b;
+}
+
+.achievement-count {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: #f59e0b;
+ font-weight: 500;
+}
+
+.achievement-count::before {
+ content: '🏆';
+ font-size: 0.9rem;
+}
+
+/* Mini Progress Bar */
+.mini-progress {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 100px;
+}
+
+.mini-progress-bg {
+ flex: 1;
+ height: 8px;
+ background: #e2e8f0;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.mini-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #6366f1, #8b5cf6);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+.mini-progress-text {
+ font-size: 0.75rem;
+ color: #64748b;
+ min-width: 35px;
+}
+
+/* Leaderboard Container */
+.leaderboard-container {
+ padding: 1rem 0;
+}
+
+/* Podium */
+.podium {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ gap: 1rem;
+ margin-bottom: 2rem;
+ padding: 1rem;
+}
+
+.podium-spot {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ position: relative;
+}
+
+.podium-crown {
+ font-size: 1.5rem;
+ margin-bottom: 0.25rem;
+ animation: bounce 2s ease-in-out infinite;
+}
+
+@keyframes bounce {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-5px); }
+}
+
+.podium-avatar {
+ font-size: 2.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.podium-name {
+ font-weight: 600;
+ color: #1e293b;
+ font-size: 0.95rem;
+ max-width: 100px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.podium-xp {
+ font-size: 0.8rem;
+ color: #10b981;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+}
+
+.podium-bar {
+ width: 80px;
+ border-radius: 8px 8px 0 0;
+}
+
+.podium-spot.first .podium-bar {
+ height: 100px;
+ background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
+}
+
+.podium-spot.second .podium-bar {
+ height: 70px;
+ background: linear-gradient(180deg, #94a3b8 0%, #64748b 100%);
+}
+
+.podium-spot.third .podium-bar {
+ height: 50px;
+ background: linear-gradient(180deg, #d97706 0%, #b45309 100%);
+}
+
+/* Leaderboard List */
+.leaderboard-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.leaderboard-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ background: #f8fafc;
+ border-radius: 0.75rem;
+ transition: all 0.2s;
+}
+
+.leaderboard-row:hover {
+ background: #f1f5f9;
+}
+
+.leaderboard-row.top-three {
+ background: linear-gradient(90deg, #fef3c7 0%, #fff 100%);
+}
+
+.leaderboard-row .rank {
+ font-weight: 700;
+ color: #6366f1;
+ width: 3rem;
+ font-size: 1.1rem;
+}
+
+.leaderboard-row .name {
+ flex: 1;
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.leaderboard-row .badge {
+ font-size: 0.75rem;
+ background: #e0e7ff;
+ color: #4f46e5;
+ padding: 0.125rem 0.5rem;
+ border-radius: 1rem;
+}
+
+.leaderboard-row .level {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.leaderboard-row .xp {
+ font-weight: 600;
+ color: #10b981;
+ min-width: 80px;
+}
+
+.leaderboard-row .games {
+ font-size: 0.85rem;
+ color: #64748b;
+ min-width: 70px;
+}
+
+/* Assignment Status */
+.assignments-status {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.assignment-status-card {
+ background: #f8fafc;
+ border-radius: 1rem;
+ padding: 1.25rem;
+ border-left: 4px solid #6366f1;
+}
+
+.assignment-status-card.overdue {
+ border-left-color: #dc2626;
+ background: #fef2f2;
+}
+
+.assignment-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.assignment-header h4 {
+ margin: 0;
+ color: #1e293b;
+ font-size: 1.1rem;
+}
+
+.due-badge {
+ font-size: 0.8rem;
+ padding: 0.25rem 0.75rem;
+ background: #dbeafe;
+ color: #1e40af;
+ border-radius: 1rem;
+}
+
+.due-badge.overdue {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.assignment-description {
+ margin: 0 0 1rem;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.assignment-progress {
+ background: white;
+ border-radius: 0.75rem;
+ padding: 1rem;
+}
+
+.progress-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.progress-label {
+ font-weight: 500;
+ color: #1e293b;
+}
+
+.progress-count {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.progress-bar-container {
+ margin-bottom: 0.5rem;
+}
+
+.progress-bar-bg {
+ height: 12px;
+ background: #e2e8f0;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #10b981, #059669);
+ border-radius: 6px;
+ transition: width 0.5s ease;
+}
+
+.progress-note {
+ margin: 0;
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+}
+
+.empty-state .empty-icon {
+ font-size: 3rem;
+ display: block;
+ margin-bottom: 1rem;
+}
+
+.empty-subtitle {
+ font-size: 0.9rem;
+ color: #94a3b8;
+ margin-top: 0.5rem;
+}
+
+/* Modals */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.modal-content {
+ background: white;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ width: 100%;
+ max-width: 500px;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
+}
+
+.modal-content h2 {
+ margin: 0 0 1.5rem;
+ color: #1e293b;
+}
+
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+.form-group label {
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: #475569;
+ margin-bottom: 0.5rem;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ font-family: inherit;
+ transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.modal-actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+ margin-top: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid #f1f5f9;
+}
+
+.message-info {
+ text-align: center;
+ padding: 1rem;
+}
+
+.message-info p {
+ margin: 0.5rem 0;
+ color: #64748b;
+}
+
+.message-info .coming-soon {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #6366f1;
+ margin: 1rem 0;
+}
+
+/* Loading and Error States */
+.loading,
+.error-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+}
+
+.error-state h2 {
+ color: #dc2626;
+ margin-bottom: 0.5rem;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .teacher-console {
+ padding: 1rem;
+ }
+
+ .console-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .header-title {
+ text-align: center;
+ }
+
+ .header-actions {
+ justify-content: center;
+ }
+
+ .classroom-pills {
+ flex-direction: column;
+ }
+
+ .classroom-pill {
+ justify-content: center;
+ }
+
+ .actions-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .overview-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .view-tabs {
+ flex-wrap: wrap;
+ }
+
+ .tab-btn {
+ flex: 1 1 45%;
+ }
+
+ .progress-table {
+ font-size: 0.85rem;
+ }
+
+ .progress-table th,
+ .progress-table td {
+ padding: 0.75rem 0.5rem;
+ }
+
+ .podium {
+ flex-direction: column;
+ align-items: center;
+ gap: 1.5rem;
+ }
+
+ .podium-spot {
+ width: 100%;
+ flex-direction: row;
+ justify-content: center;
+ gap: 1rem;
+ }
+
+ .podium-bar {
+ display: none;
+ }
+
+ .leaderboard-row {
+ flex-wrap: wrap;
+ }
+
+ .leaderboard-row .name {
+ flex-basis: 100%;
+ order: -1;
+ }
+
+ .modal-content {
+ margin: 0.5rem;
+ padding: 1rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .actions-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-stats {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-stats .stat-card {
+ justify-content: center;
+ }
+
+ .assignment-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/frontend/src/pages/TeacherConsole.jsx b/frontend/src/pages/TeacherConsole.jsx
new file mode 100644
index 0000000..0cdaba0
--- /dev/null
+++ b/frontend/src/pages/TeacherConsole.jsx
@@ -0,0 +1,691 @@
+import { useState, useEffect } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import api from '../api';
+import './TeacherConsole.css';
+
+export default function TeacherConsole() {
+ const { user, isAuthenticated } = useAuth();
+ const navigate = useNavigate();
+
+ const [classrooms, setClassrooms] = useState([]);
+ const [selectedClassroom, setSelectedClassroom] = useState(null);
+ const [studentProgress, setStudentProgress] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [progressLoading, setProgressLoading] = useState(false);
+ const [activeView, setActiveView] = useState('overview');
+ const [error, setError] = useState('');
+
+ // Quick action states
+ const [showAssignmentModal, setShowAssignmentModal] = useState(false);
+ const [showMessageModal, setShowMessageModal] = useState(false);
+ const [newAssignment, setNewAssignment] = useState({ title: '', description: '', dueDate: '', classroomId: '' });
+ const [creatingAssignment, setCreatingAssignment] = useState(false);
+ const [broadcastMessage, setBroadcastMessage] = useState({ title: '', message: '' });
+ const [sendingBroadcast, setSendingBroadcast] = useState(false);
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ navigate('/login');
+ return;
+ }
+
+ if (user?.role !== 'teacher' && user?.role !== 'admin' && !user?.isTeacher) {
+ navigate('/dashboard');
+ return;
+ }
+
+ loadTeacherData();
+ }, [isAuthenticated, user]);
+
+ async function loadTeacherData() {
+ try {
+ const data = await api.getMyClassrooms();
+ setClassrooms(data.asTeacher || []);
+
+ // Auto-select first classroom if exists
+ if (data.asTeacher?.length > 0) {
+ setSelectedClassroom(data.asTeacher[0]);
+ loadStudentProgress(data.asTeacher[0].id);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function loadStudentProgress(classroomId) {
+ setProgressLoading(true);
+ try {
+ const data = await api.getClassroomStudentProgress(classroomId);
+ setStudentProgress(data.students || []);
+ } catch (err) {
+ console.error('Failed to load student progress:', err);
+ // Fallback to basic student data
+ try {
+ const studentsData = await api.getClassroomStudents(classroomId);
+ setStudentProgress(studentsData.students || []);
+ } catch (e) {
+ console.error('Fallback failed:', e);
+ }
+ } finally {
+ setProgressLoading(false);
+ }
+ }
+
+ function handleClassroomChange(classroom) {
+ setSelectedClassroom(classroom);
+ loadStudentProgress(classroom.id);
+ }
+
+ async function handleCreateAssignment(e) {
+ e.preventDefault();
+ if (!newAssignment.title.trim() || !newAssignment.classroomId) return;
+
+ setCreatingAssignment(true);
+ try {
+ await api.createAssignment(newAssignment.classroomId, {
+ title: newAssignment.title,
+ description: newAssignment.description,
+ dueDate: newAssignment.dueDate || null
+ });
+ setShowAssignmentModal(false);
+ setNewAssignment({ title: '', description: '', dueDate: '', classroomId: '' });
+ // Refresh data
+ loadTeacherData();
+ } catch (err) {
+ alert('Failed to create assignment: ' + err.message);
+ } finally {
+ setCreatingAssignment(false);
+ }
+ }
+
+ function generateReport() {
+ if (!selectedClassroom || studentProgress.length === 0) {
+ alert('No data available to generate report');
+ return;
+ }
+
+ // Generate CSV report
+ const headers = ['Student', 'Level', 'XP Total', 'Games Created', 'Games Played', 'Achievements'];
+ const rows = studentProgress.map(s => [
+ s.displayName || s.username,
+ s.level || 1,
+ s.xpTotal || 0,
+ s.gamesCreated || 0,
+ s.gamesPlayed || 0,
+ s.achievementCount || 0
+ ]);
+
+ const csvContent = [
+ headers.join(','),
+ ...rows.map(row => row.join(','))
+ ].join('\n');
+
+ const blob = new Blob([csvContent], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${selectedClassroom.name}-progress-report.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ // Calculate class stats
+ const classStats = {
+ totalStudents: studentProgress.length,
+ totalGamesCreated: studentProgress.reduce((sum, s) => sum + (s.gamesCreated || 0), 0),
+ totalXPEarned: studentProgress.reduce((sum, s) => sum + (s.xpTotal || 0), 0),
+ averageLevel: studentProgress.length > 0
+ ? (studentProgress.reduce((sum, s) => sum + (s.level || 1), 0) / studentProgress.length).toFixed(1)
+ : 0,
+ totalAchievements: studentProgress.reduce((sum, s) => sum + (s.achievementCount || 0), 0)
+ };
+
+ if (loading) {
+ return (
+
+ );
+}
+
+// Student Progress View Component
+function StudentProgressView({ students, classroomId }) {
+ if (students.length === 0) {
+ return (
+
+ );
+}
+
+// Leaderboard View Component
+function LeaderboardView({ students, classroomId }) {
+ const sortedStudents = [...students].sort((a, b) => (b.xpTotal || 0) - (a.xpTotal || 0));
+
+ if (students.length === 0) {
+ return (
+
+ );
+}
+
+// Assignment Status View Component
+function AssignmentStatusView({ classroomId, students }) {
+ const [assignments, setAssignments] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (classroomId) {
+ loadAssignments();
+ }
+ }, [classroomId]);
+
+ async function loadAssignments() {
+ try {
+ const data = await api.getClassroomAssignments(classroomId);
+ setAssignments(data.assignments || []);
+ } catch (err) {
+ console.error('Failed to load assignments:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (loading) {
+ return
Loading assignments...
+ );
+}
+
+// Progress Bar Component
+function ProgressBar({ value, max }) {
+ const percentage = max > 0 ? Math.min(100, (value / max) * 100) : 0;
+
+ return (
+
+ );
+}
+
+// Helper function to get XP required for next level
+function getXPForLevel(level) {
+ const levelXP = {
+ 1: 0,
+ 2: 100,
+ 3: 300,
+ 4: 600,
+ 5: 1000,
+ 6: 1500,
+ 7: 2500,
+ 8: 4000,
+ 9: 6000,
+ 10: 10000,
+ 11: 15000
+ };
+ return levelXP[level] || level * 2000;
+}
diff --git a/frontend/src/pages/Terms.jsx b/frontend/src/pages/Terms.jsx
new file mode 100644
index 0000000..c415e8b
--- /dev/null
+++ b/frontend/src/pages/Terms.jsx
@@ -0,0 +1,195 @@
+import { Link } from 'react-router-dom';
+import './Legal.css';
+
+export default function Terms() {
+ return (
+
+ );
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..2adad20
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,146 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { VitePWA } from 'vite-plugin-pwa'
+
+// https://vite.dev/config/
+export default defineConfig({
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ // Core React dependencies
+ 'vendor-react': ['react', 'react-dom', 'react-router-dom'],
+ // UI/utility libraries (if any large ones are added)
+ // Split pages into separate chunks for lazy loading
+ }
+ }
+ },
+ // Target modern browsers for smaller bundle
+ target: 'es2020',
+ // Increase chunk size warning limit
+ chunkSizeWarningLimit: 600
+ },
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ // Force service worker to update immediately
+ devOptions: {
+ enabled: false
+ },
+ includeAssets: ['favicon.ico', 'robots.txt'],
+ manifest: {
+ name: 'GamerComp',
+ short_name: 'GamerComp',
+ description: 'Where kids become Game Composers! Learn AI communication by creating your own games.',
+ theme_color: '#6366f1',
+ background_color: '#1e1b4b',
+ display: 'standalone',
+ orientation: 'any',
+ scope: '/',
+ start_url: '/',
+ icons: [
+ {
+ src: '/icon.svg',
+ sizes: 'any',
+ type: 'image/svg+xml',
+ purpose: 'any'
+ },
+ {
+ src: '/icon.svg',
+ sizes: '512x512',
+ type: 'image/svg+xml',
+ purpose: 'maskable'
+ }
+ ],
+ categories: ['games', 'entertainment', 'education'],
+ screenshots: [
+ {
+ src: '/screenshot-wide.png',
+ sizes: '1280x720',
+ type: 'image/png',
+ form_factor: 'wide'
+ },
+ {
+ src: '/screenshot-narrow.png',
+ sizes: '640x1136',
+ type: 'image/png',
+ form_factor: 'narrow'
+ }
+ ]
+ },
+ workbox: {
+ // Force update without waiting
+ skipWaiting: true,
+ clientsClaim: true,
+ // Import push notification handler
+ importScripts: ['/push-handler.js'],
+ // Use network-first for navigation to always get fresh HTML
+ navigateFallback: 'index.html',
+ navigateFallbackDenylist: [/^\/api/],
+ // Cache strategies
+ runtimeCaching: [
+ {
+ // Cache API responses (except play/start which needs to be fresh)
+ urlPattern: /^https:\/\/gamercomp\.com\/api\/(?!play\/start|auth).*/i,
+ handler: 'NetworkFirst',
+ options: {
+ cacheName: 'api-cache',
+ expiration: {
+ maxEntries: 100,
+ maxAgeSeconds: 60 * 60 // 1 hour
+ },
+ cacheableResponse: {
+ statuses: [0, 200]
+ }
+ }
+ },
+ {
+ // Cache game code for offline play
+ urlPattern: /^https:\/\/gamercomp\.com\/api\/arcade\/games\/\d+$/i,
+ handler: 'CacheFirst',
+ options: {
+ cacheName: 'game-cache',
+ expiration: {
+ maxEntries: 50,
+ maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
+ },
+ cacheableResponse: {
+ statuses: [0, 200]
+ }
+ }
+ },
+ {
+ // Cache fonts
+ urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
+ handler: 'CacheFirst',
+ options: {
+ cacheName: 'google-fonts-cache',
+ expiration: {
+ maxEntries: 10,
+ maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
+ },
+ cacheableResponse: {
+ statuses: [0, 200]
+ }
+ }
+ },
+ {
+ // Cache images
+ urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
+ handler: 'CacheFirst',
+ options: {
+ cacheName: 'image-cache',
+ expiration: {
+ maxEntries: 50,
+ maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
+ }
+ }
+ }
+ ],
+ // Pre-cache essential files
+ globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
+ }
+ })
+ ],
+})
diff --git a/games/samples/asteroids.html b/games/samples/asteroids.html
new file mode 100644
index 0000000..0f8d516
--- /dev/null
+++ b/games/samples/asteroids.html
@@ -0,0 +1,542 @@
+
+
+