{"asset_map": {"punch_impact_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/punch_impact_sfx_99e24271-eff9-44a7-b507-6cfa117e3ffa.mp3", "type": "audio", "prompt": "A satisfying, cartoony punch impact sound effect. Medium-pitched 'thwack' with a slight rubbery bounce tail. Stylized and non-violent, resembling 3D brawler physics like Fall Guys. Clean, punchy attack, no background noise.", "description": "A cartoony, medium-pitched 'thwack' for punch impacts with a comedic bounce.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0.3, "loop": false, "animation": null}, "kick_impact_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/kick_impact_sfx_0fb84110-cb86-475e-897b-6710fa1f4156.mp3", "type": "audio", "prompt": "Heavy, deep impact sound for a character kick. Low-pitched 'thud' with a resonant bass body. Cartoony, weighty, and exaggerated physics-based impact. Clean hit for a 3D ragdoll game.", "description": "A heavy, low-pitched 'thud' for kick impacts with a cartoony weight.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.22, "loop": false, "animation": null}, "enemy_defeat_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/enemy_defeat_sfx_8a1f308f-3bbc-4ad1-8408-08b67b900b06.mp3", "type": "audio", "prompt": "Comedic 'oof' grunt sound effect. Exaggerated, high-pitched, silly cartoony voice. Expressing surprise and a harmless fall. Short, clean, and humorous, suitable for a physics brawler.", "description": "Comedic exaggerated 'oof' or grunt when an enemy is knocked down.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 2, "loop": false, "animation": null}, "player_hurt_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/player_hurt_sfx_348a6e17-fa1e-4568-aa1f-dd3de5d3a617.mp3", "type": "audio", "prompt": "Short cartoony 'ow!' pain sound. Upbeat, surprised rather than hurt. High-pitched, silly voice effect for a friendly 3D character. Instant attack, quick decay.", "description": "A short, surprised 'ow!' sound when the player character takes damage.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.67, "loop": false, "animation": null}, "wave_complete_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/wave_complete_sfx_e9101e92-1264-4417-b514-5e24eab8e2ee.mp3", "type": "audio", "prompt": "Cheerful, upbeat 'success' chime. Bright, positive tone, game show success style. Rising melodic motif, clean bells and digital synths, sense of accomplishment.", "description": "A cheerful success chime for completing a wave of enemies.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.77, "loop": false, "animation": null}, "victory_music": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/music/victory_music_7a0c156d-b8d8-462d-a9ca-784c0d5903a8.mp3", "type": "audio", "prompt": "Upbeat, triumphant victory fanfare, 130 BPM, C Major. Cartoony and celebratory. Featuring bright brass hits, glockenspiel, and energetic synth leads. Short, high-energy resolution chord at the end.", "description": "Triumphant and energetic cartoony fanfare for game victory.", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 8.071836734693878, "loop": false, "animation": null}, "defeat_music": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/music/defeat_music_fdf064c8-85fd-4933-a2c4-98b611223abf.mp3", "type": "audio", "prompt": "Comedic 'sad trombone' defeat music. Descending 'wah-wah-wah-wah' notes, disappointed but lighthearted and silly. Low brass textures, cartoony anti-climax vibe.", "description": "Comedic 'sad trombone' style music for the defeat screen.", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 5.0416326530612245, "loop": false, "animation": null}, "background_music": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/music/background_music_ddb96b6b-abdc-427c-8eed-9ed145069e51.mp3", "type": "audio", "prompt": "Upbeat, energetic electronic track for a cartoony brawler. 135 BPM. Playful and action-oriented. Funky synth bass, punchy electronic drums, bright lead melodies, and bleepy textures. Consistent energy, looping seamlessly, similar to Gang Beasts or Fall Guys soundtrack.", "description": "Playful and energetic combat music for the main gameplay arena.", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 44.98285714285714, "loop": true, "animation": null}, "limb_whoosh_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/limb_whoosh_sfx_fc443ccf-3f8a-460d-ab53-42111d8b1914.mp3", "type": "audio", "prompt": "Quick 'whoosh' or 'swish' sound effect. Airy, fast movement, light and cartoony. High-frequency zip sound, very short duration, suitable for frequent triggering during ragdoll swings.", "description": "A quick, airy swish sound for fast limb movement.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.26, "loop": false, "animation": null}, "goal_kaching_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/goal_kaching_sfx_70239854-ee10-45b6-becb-59ef6ea3a99f.mp3", "type": "audio", "prompt": "A premium, high-quality 'ka-ching' cash register sound effect. Bright, clear metallic chime with a warm shimmer and a smooth resonant tail. It should sound polished and celebratory, featuring a sophisticated 'ding-ching' sequence. Clean audio, no background noise, non-cartoony, with a graceful fade-out. Suitable for a rewarding UI feedback moment.", "description": "A polished cash register chime used for rewarding players upon goal completion or score increases.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.38, "loop": false, "animation": null}, "ball_rolling_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/ball_rolling_sfx_bb26894a-7c03-416b-beae-443b56d50bd9.mp3", "type": "audio", "prompt": "A continuous, smooth sound effect of a soccer ball rolling on grass. Features a low-pitched organic rumble with subtle rustling textures of blades of grass. The sound should be consistent in volume and texture to allow for seamless looping. Pure foley, no background noise, no music, high-quality recording of leather or synthetic material on turf.", "description": "Seamlessly looping sound of a ball rolling across a grassy surface, providing constant movement feedback.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 3, "loop": true, "animation": null}, "ball_kick_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/ball_kick_sfx_1de18dbb-29b0-44db-b4c7-08567552cd94.mp3", "type": "audio", "prompt": "A single, clean, and immediate 'thwack' or 'thud' sound of a soccer ball being kicked. Sharp organic impact with a brief leather-like resonance. High-quality foley, professional recording, absolutely no human grunts, no voices, and no vocalizations. Pure silence immediately following the impact tail. Punchy and crisp, suitable for a physics-based sports game.", "description": "A clean, isolated impact sound of a foot kicking a soccer ball, without any vocalizations.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1, "loop": false, "animation": null}, "soccer_bg_music": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/music/soccer_bg_music_395d0421-3f16-4359-a987-cdb48e3bcd79.mp3", "type": "audio", "prompt": "Energetic and fun arcade sports music, 120 BPM, upbeat cartoony vibe. Features lively percussion, punchy brass stabs, bouncy synth bass, and a catchy, whistling-style melody. High-energy but lighthearted, similar to classic Nintendo-style sports titles. Composition should have a clear rhythm and loop seamlessly without vocals. Instrumental only.", "description": "Upbeat and cartoony arcade soccer background music for the main match gameplay.", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 60.02938775510204, "loop": true, "animation": null}, "oob_defeat_sfx": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/oob_defeat_sfx_69a982cb-36f3-42c1-ba89-06174865788d.mp3", "type": "audio", "prompt": "A classic comedic 'sad trombone' failure sound effect. A solo brass instrument playing four descending notes in a 'wah-wah-wah-wahhh' style. The pitch should start medium-high and step/slide downwards, ending on a long, low, dejected final note with a slight vibrato and fade out. Pure brass tone, no background noise, no voices, no whistles. High-quality isolated sound effect for a game over or failure state.", "description": "A comedic 'sad trombone' failure fanfare with descending brass notes to signal an out-of-bounds defeat.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 2.51, "loop": false, "animation": null}, "ui_course_completed": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/ui_course_completed_0f1adcd0-6242-495e-a54a-6ad067204a04.webp", "type": "image", "prompt": "Generate a single rounded square UI element, clean flat cartoony style. The color is bright lime green with a soft 3D raised effect, featuring a lighter green highlight on the top-left and a darker green shadow on the bottom-right for depth. Large corner radius (squircle). No text, no icons, no outlines. High-quality game asset, professional UI design, mobile game aesthetic, transparent background.", "description": "A bright lime green 3D-styled rounded square UI element for progress bars.", "subtype": "ui", "aspect_ratio": [827, 797], "compression_suggestion": "medium", "duration_secs": null, "loop": null, "animation": null}, "ui_course_current": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/ui_course_current_3788f86b-a724-448e-bc72-b1df94502244.webp", "type": "image", "prompt": "Generate a single rounded square UI element, medium gray color (#B0B0B0) with a soft 3D raised look. Feature a slightly lighter gray on the top-left and darker on the bottom-right for depth. Center a smaller, darker gray (#888888) rounded square inset within it, creating a recessed effect. Use a clean, flat cartoony style, matching the shape and 3D depth of ui_course_completed. Transparent background, square aspect ratio, professional game UI quality.", "description": "A gray 3D-styled UI progress bar element with a recessed center square", "subtype": "ui", "aspect_ratio": [765, 761], "compression_suggestion": "high", "duration_secs": null, "loop": null, "animation": null}, "ui_course_upcoming": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/ui_course_upcoming_874aee5c-bb22-44aa-971f-2cd3212b34f1.webp", "type": "image", "prompt": "Generate a single rounded square UI element for a mobile game progress bar. Light gray/off-white color (#D8D8D8) with a very subtle 3D raised look, featuring slightly lighter top-left edges and slightly darker bottom-right edges for depth. Large rounded corners (squircle shape). Clean, empty interior with no icons or inner shapes. Cartoony flat style, high resolution, professional quality. Transparent background, front view. Match the 3D depth and stylistic treatment of image 1 and image 2.", "description": "A light gray rounded square UI element representing an upcoming course in the progress bar.", "subtype": "ui", "aspect_ratio": [1024, 1018], "compression_suggestion": "high", "duration_secs": null, "loop": null, "animation": null}, "ui_level_box": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/ui_level_box_198bc83b-f723-4dcf-ad9d-e06c678fb69a.webp", "type": "image", "prompt": "Generate a single, clean rounded square UI element for a mobile game level selector. Light gray color (#CCCCCC) with a soft 3D raised effect featuring light highlights on the top-left and subtle shadows on the bottom-right for depth. Large rounded corners (squircle shape). The center is empty and clean to allow for text overlay. Polished cartoony flat style, high resolution, transparent background, square aspect ratio. Match the visual language and depth of ui_course_completed and ui_course_upcoming.", "description": "A light gray 3D-style rounded square UI container for level numbers.", "subtype": "ui", "aspect_ratio": [1024, 1017], "compression_suggestion": "high", "duration_secs": null, "loop": null, "animation": null}, "loading_football": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/loading_football_3241a374-875b-405f-929b-273f96018651.webp", "type": "image", "prompt": "Generate a high-quality, centered illustration of a traditional black-and-white soccer ball. The style is bright, fun, and 3D cartoon-like with a shiny, inflated texture, bold shading, and crisp highlights. The ball features classic black pentagon patches on a clean white surface. Include a subtle soft drop shadow directly beneath the ball. High resolution, professional game asset, clean edges, centered composition, transparent background.", "description": "A high-quality 3D cartoon-style soccer ball with a shiny finish and drop shadow for the loading screen.", "subtype": "ui", "aspect_ratio": [837, 832], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}, "menu_btn_play": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/menu_btn_play_1b0791be-d8a5-4250-9a34-2f53f93dc5c7.webp", "type": "image", "prompt": "Generate a large, chunky 3D-style 'PLAY' button for a cartoony soccer game main menu. The button is a wide pill/stadium shape with a bright lime-green to yellow-green gradient. It features a thick embossed border with a shiny top highlight stripe and a satisfying 3D pressed look. In the center, bold white 3D-extruded text reads 'PLAY' with a dark green drop shadow. A high-quality 3D white soccer ball icon, matching the style of image 1, sits to the left of the text. The overall style is vibrant, glossy, and cartoony, appearing as a professional console game UI element. High resolution, detailed, transparent background, no external drop shadows.", "description": "A vibrant 3D-styled 'PLAY' button with a soccer ball icon for the main menu.", "subtype": "ui", "aspect_ratio": [1109, 411], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}, "menu_btn_settings": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/menu_btn_settings_d420e7f0-6d5f-4794-9517-01a49d53f048.webp", "type": "image", "prompt": "Generate a chunky 3D-style square 'SETTINGS' button for a cartoony soccer game menu. The button is a rounded square shape with a cool steel-blue to slate gradient and a thick embossed border with a shiny top highlight. In the center, a large white 3D gear/cog icon sits above bold white extruded text reading 'SETTINGS'. Match the vibrant, glossy, and cartoony 3D style of image 1. High resolution, professional game UI, transparent background, no external drop shadow.", "description": "A chunky 3D-style settings button with a gear icon and text, matching the menu's cartoony soccer theme.", "subtype": "ui", "aspect_ratio": [1202, 551], "compression_suggestion": "medium", "duration_secs": null, "loop": null, "animation": null}, "menu_btn_customize": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/menu_btn_customize_b429ca7c-25ad-4a41-b7a5-6010b9cbd9eb.webp", "type": "image", "prompt": "Generate a chunky 3D-style square 'CUSTOMIZE' button for a cartoony soccer game UI. The button is a rounded square shape with a vibrant purple to magenta gradient and a thick embossed border with a shiny top highlight. In the center, a large white 3D paint brush and star icon sits above bold white extruded text reading 'CUSTOMIZE'. High-quality, glossy, vibrant cartoon style, matching the aesthetic of image 1 and image 2. Transparent background, professional game asset.", "description": "A vibrant purple 3D-style 'CUSTOMIZE' menu button with a paint brush and star icon.", "subtype": "ui", "aspect_ratio": [1248, 571], "compression_suggestion": "medium", "duration_secs": null, "loop": null, "animation": null}, "menu_btn_leaderboard": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/menu_btn_leaderboard_87ef1d22-b0c5-416c-9e0b-6e7e78d5584b.webp", "type": "image", "prompt": "Generate a chunky 3D-style square 'LEADERBOARD' button for a cartoony soccer game. The button is a rounded square with a warm gold to orange gradient. It features a thick embossed border with a shiny top highlight for a glossy 3D pressed look. In the center, place a large white 3D trophy icon above bold white text reading 'SCORES'. The style should be vibrant, glossy, and cartoony, matching the aesthetic of menu_btn_play and menu_btn_settings. High resolution, professional game UI, transparent background, no drop shadow outside the button.", "description": "A chunky 3D-style 'SCORES' button with a gold gradient and trophy icon for the main menu.", "subtype": "ui", "aspect_ratio": [952, 670], "compression_suggestion": "medium", "duration_secs": null, "loop": null, "animation": null}, "menu_title_logo": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/menu_title_logo_0db642c2-581c-428b-8d73-f7453661267c.webp", "type": "image", "prompt": "Transform the text in the logo from 'RAGDOLL RUMBLE' to 'CRAZY SHOT'. Maintain the exact chunky 3D typography, vibrant color palette, soccer ball motif, and playful sports aesthetic from image 1. Ensure the layout and high-quality 3D rendered style remain identical, only modifying the words to say 'CRAZY SHOT'.", "description": "Main menu logo with the title 'CRAZY SHOT', maintaining the 3D soccer theme.", "subtype": "ui", "aspect_ratio": [1080, 764], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}}, "game_code": "<!DOCTYPE html>\n<html>\n<head>\n    <title>Crazy Shot</title>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n    <link href=\"https://fonts.googleapis.com/css2?family=Fredoka+One&display=swap\" rel=\"stylesheet\">\n    <style>\n        :root {\n            --transition-fast: 150ms;\n            --transition-normal: 300ms;\n            --space-xs: 8px;\n            --space-sm: 16px;\n            --space-md: 24px;\n            --space-lg: 32px;\n            --ease-out: cubic-bezier(0.0, 0, 0.2, 1);\n        }\n\n        body, html {\n            margin: 0;\n            padding: 0;\n            width: 100%;\n            height: 100%;\n            overflow: hidden;\n            touch-action: none;\n            -ms-touch-action: none;\n            font-family: 'Fredoka One', cursive;\n            background: #1a1a2e;\n        }\n\n        #game-world {\n            position: relative;\n            width: 100%;\n            height: 100%;\n        }\n\n        canvas {\n            display: block;\n            width: 100%;\n            height: 100%;\n        }\n\n        .ui-overlay {\n            position: absolute;\n            top: 0;\n            left: 0;\n            width: 100%;\n            height: 100%;\n            pointer-events: none;\n            z-index: 10;\n        }\n\n        .ui-overlay > * {\n            pointer-events: auto;\n        }\n\n        .hud {\n            position: absolute;\n            top: 16px;\n            left: 16px;\n            right: 16px;\n            display: flex;\n            justify-content: space-between;\n            align-items: flex-start;\n        }\n\n        .health-bar-container {\n            background: rgba(0,0,0,0.6);\n            border-radius: 12px;\n            padding: 8px 16px;\n            backdrop-filter: blur(4px);\n        }\n\n        .health-bar-label {\n            color: #fff;\n            font-size: 18px;\n            font-family: 'Fredoka One', cursive;\n            font-weight: 600;\n            margin-bottom: 4px;\n            text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;\n        }\n\n        .health-bar {\n            width: 200px;\n            height: 24px;\n            background: #333;\n            border-radius: 12px;\n            overflow: hidden;\n            border: 2px solid rgba(255,255,255,0.3);\n        }\n\n        .health-bar-fill {\n            height: 100%;\n            background: linear-gradient(90deg, #ff4444, #ff6b6b);\n            border-radius: 12px;\n            transition: width var(--transition-normal) var(--ease-out);\n        }\n\n        .wave-info {\n            background: rgba(0,0,0,0.6);\n            border-radius: 12px;\n            padding: 8px 16px;\n            color: #fff;\n            font-size: 24px;\n            font-family: 'Fredoka One', cursive;\n            font-weight: 700;\n            text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;\n            backdrop-filter: blur(4px);\n        }\n\n        .hint-text {\n            position: absolute;\n            bottom: 120px;\n            left: 50%;\n            transform: translateX(-50%);\n            background: rgba(0,0,0,0.7);\n            color: #fff;\n            padding: 12px 24px;\n            border-radius: 12px;\n            font-size: 22px;\n            font-family: 'Fredoka One', cursive;\n            text-align: center;\n            white-space: nowrap;\n            backdrop-filter: blur(4px);\n            transition: opacity var(--transition-normal);\n        }\n\n        .screen-overlay {\n            position: absolute;\n            top: 0;\n            left: 0;\n            width: 100%;\n            height: 100%;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            background: rgba(0,0,0,0);\n            backdrop-filter: none;\n            z-index: 20;\n        }\n\n        .screen-overlay h1 {\n            font-size: 56px;\n            font-family: 'Fredoka One', cursive;\n            color: #fff;\n            margin: 0 0 16px 0;\n            text-shadow: -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000, 2px 2px 0 #000;\n        }\n\n        .screen-overlay p {\n            font-size: 28px;\n            font-family: 'Fredoka One', cursive;\n            color: #fff;\n            margin: 0 0 32px 0;\n            text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;\n        }\n\n        .btn {\n            padding: 16px 48px;\n            font-size: 28px;\n            font-family: 'Fredoka One', cursive;\n            font-weight: 700;\n            border: none;\n            border-radius: 16px;\n            cursor: pointer;\n            transition: transform var(--transition-fast), box-shadow var(--transition-fast);\n            min-width: 96px;\n            min-height: 96px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n        }\n\n        .btn:hover {\n            transform: scale(1.05);\n            box-shadow: 0 8px 24px rgba(0,0,0,0.3);\n        }\n\n        .btn:active {\n            transform: scale(0.95);\n        }\n\n        .btn-primary {\n            background: linear-gradient(135deg, #4ecdc4, #44a08d);\n            color: #fff;\n        }\n\n        .btn-restart {\n            background: linear-gradient(135deg, #f093fb, #f5576c);\n            color: #fff;\n        }\n\n        .victory-title { color: #4ecdc4 !important; }\n        .defeat-title { color: #f5576c !important; }\n\n        .edit-info {\n            position: absolute;\n            bottom: 16px;\n            left: 50%;\n            transform: translateX(-50%);\n            background: rgba(0,0,0,0.7);\n            color: #fff;\n            padding: 12px 24px;\n            border-radius: 12px;\n            font-size: 20px;\n            font-family: 'Fredoka One', cursive;\n            text-align: center;\n        }\n\n        .view-model-btn {\n            position: absolute;\n            top: 80px;\n            right: 16px;\n            background: rgba(0,0,0,0.6);\n            color: #fff;\n            border: 2px solid rgba(255,255,255,0.3);\n            border-radius: 12px;\n            padding: 10px 18px;\n            font-family: 'Fredoka One', cursive;\n            font-size: 16px;\n            font-weight: 600;\n            cursor: pointer;\n            backdrop-filter: blur(4px);\n            transition: transform 150ms, background 150ms;\n            min-width: 96px;\n            min-height: 48px;\n            z-index: 15;\n        }\n\n        .view-model-btn:hover {\n            background: rgba(60,60,80,0.8);\n            transform: scale(1.05);\n        }\n\n        .view-model-btn:active {\n            transform: scale(0.95);\n        }\n\n        .model-viewer-ui {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            pointer-events: none;\n            z-index: 25;\n        }\n\n        .model-viewer-ui > * {\n            pointer-events: auto;\n        }\n\n        .model-viewer-header {\n            position: absolute;\n            top: 16px;\n            left: 50%;\n            transform: translateX(-50%);\n            background: rgba(0,0,0,0.7);\n            color: #fff;\n            padding: 12px 24px;\n            border-radius: 12px;\n            font-size: 22px;\n            font-family: 'Fredoka One', cursive;\n            font-weight: 700;\n            text-align: center;\n            backdrop-filter: blur(4px);\n        }\n\n        .model-viewer-back {\n            position: absolute;\n            top: 16px;\n            left: 16px;\n            background: linear-gradient(135deg, #f093fb, #f5576c);\n            color: #fff;\n            border: none;\n            border-radius: 12px;\n            padding: 12px 24px;\n            font-family: 'Fredoka One', cursive;\n            font-size: 18px;\n            font-weight: 700;\n            cursor: pointer;\n            min-width: 96px;\n            min-height: 48px;\n            transition: transform 150ms;\n        }\n\n        .model-viewer-back:hover { transform: scale(1.05); }\n        .model-viewer-back:active { transform: scale(0.95); }\n\n        @keyframes squarePop {\n            0%   { transform: scale(1); }\n            20%  { transform: scale(1.45); }\n            50%  { transform: scale(0.88); }\n            70%  { transform: scale(1.18); }\n            85%  { transform: scale(0.97); }\n            100% { transform: scale(1); }\n        }\n\n        @keyframes squareGlow {\n            0%   { filter: drop-shadow(0 0 0px #7ED321); }\n            30%  { filter: drop-shadow(0 0 14px #bfff55); }\n            100% { filter: drop-shadow(0 0 0px #7ED321); }\n        }\n\n        .model-viewer-hint {\n            position: absolute;\n            bottom: 80px;\n            left: 50%;\n            transform: translateX(-50%);\n            background: rgba(0,0,0,0.6);\n            color: #ccc;\n            padding: 10px 20px;\n            border-radius: 10px;\n            font-size: 16px;\n            font-family: 'Fredoka One', cursive;\n            text-align: center;\n            backdrop-filter: blur(4px);\n        }\n    </style>\n</head>\n<body>\n    <div id=\"game-world\">\n        <canvas id=\"game-canvas\"></canvas>\n        <div class=\"ui-overlay\" id=\"ui-overlay\"></div>\n    </div>\n\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/utils/BufferGeometryUtils.js\"></script>\n\n    <script>\n    /*\n     * CRAZY SHOT\n     * A 3D third-person physics brawler where players drag character limbs\n     * to attack enemies. Built with Three.js using geometric primitives\n     * for a cartoony-realistic hybrid aesthetic.\n     */\n\n    async function run(mode) {\n        const gameWorld = document.getElementById('game-world');\n        const canvas = document.getElementById('game-canvas');\n        const uiOverlay = document.getElementById('ui-overlay');\n\n        const config = window.gameConfig;\n\n        // === LOADING SCREEN ===\n        const loadingScreen = document.createElement('div');\n        loadingScreen.id = 'loading-screen';\n        loadingScreen.style.cssText = `\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            background: linear-gradient(160deg, #FFD200 0%, #FF8C00 45%, #FF4500 100%);\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            z-index: 9999;\n            font-family: 'Fredoka One', cursive;\n        `;\n\n        // Football image\n        const loadingBallImg = document.createElement('img');\n        const _loadingBallAsset = lib.getAsset('loading_football');\n        if (_loadingBallAsset) {\n            loadingBallImg.src = _loadingBallAsset.url;\n        }\n        loadingBallImg.style.cssText = `\n            width: 200px;\n            height: 200px;\n            object-fit: contain;\n            margin-bottom: 36px;\n            filter: drop-shadow(0 12px 28px rgba(0,0,0,0.45));\n            animation: loadBallBounce 1.1s ease-in-out infinite alternate;\n        `;\n\n        // Title\n        const loadingTitle = document.createElement('div');\n        loadingTitle.textContent = 'Crazy Shot';\n        loadingTitle.style.cssText = `\n            font-size: 46px;\n            color: #fff;\n            text-shadow: -3px -3px 0 #b84400, 3px -3px 0 #b84400, -3px 3px 0 #b84400, 3px 3px 0 #b84400, 0 6px 20px rgba(0,0,0,0.4);\n            margin-bottom: 40px;\n            letter-spacing: 1px;\n        `;\n\n        // Loading bar container\n        const loadBarOuter = document.createElement('div');\n        loadBarOuter.style.cssText = `\n            width: 380px;\n            max-width: 80%;\n            height: 34px;\n            background: rgba(0,0,0,0.25);\n            border-radius: 17px;\n            border: 3px solid rgba(255,255,255,0.55);\n            overflow: hidden;\n            box-shadow: 0 4px 18px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.2);\n            position: relative;\n        `;\n\n        const loadBarFill = document.createElement('div');\n        loadBarFill.style.cssText = `\n            height: 100%;\n            width: 0%;\n            background: linear-gradient(90deg, #fff700, #fffbe0 40%, #fff700 80%, #ffd000);\n            border-radius: 14px;\n            box-shadow: 0 0 18px rgba(255,240,80,0.85), inset 0 2px 6px rgba(255,255,255,0.6);\n            transition: width 0.18s ease-out;\n            position: relative;\n            overflow: hidden;\n        `;\n\n        // Shimmer stripe on bar\n        const shimmer = document.createElement('div');\n        shimmer.style.cssText = `\n            position: absolute;\n            top: 0; left: -60%;\n            width: 55%;\n            height: 100%;\n            background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent);\n            animation: loadShimmer 1.2s linear infinite;\n        `;\n        loadBarFill.appendChild(shimmer);\n\n        // Loading label\n        const loadingLabel = document.createElement('div');\n        loadingLabel.textContent = 'Loading...';\n        loadingLabel.style.cssText = `\n            margin-top: 16px;\n            font-size: 22px;\n            color: rgba(255,255,255,0.92);\n            text-shadow: 0 2px 8px rgba(0,0,0,0.3);\n            letter-spacing: 1px;\n        `;\n\n        // Keyframe styles\n        const loadingStyle = document.createElement('style');\n        loadingStyle.textContent = `\n            @keyframes loadBallBounce {\n                0%   { transform: translateY(0px) scale(1.00); filter: drop-shadow(0 12px 28px rgba(0,0,0,0.45)); }\n                100% { transform: translateY(-22px) scale(1.04); filter: drop-shadow(0 28px 18px rgba(0,0,0,0.22)); }\n            }\n            @keyframes loadShimmer {\n                0%   { left: -60%; }\n                100% { left: 120%; }\n            }\n        `;\n        document.head.appendChild(loadingStyle);\n\n        loadBarOuter.appendChild(loadBarFill);\n        loadingScreen.appendChild(loadingBallImg);\n        loadingScreen.appendChild(loadingTitle);\n        loadingScreen.appendChild(loadBarOuter);\n        loadingScreen.appendChild(loadingLabel);\n        gameWorld.appendChild(loadingScreen);\n\n        function setLoadProgress(pct) {\n            loadBarFill.style.width = Math.round(pct) + '%';\n            loadingLabel.textContent = pct >= 100 ? 'Ready!' : `Loading... ${Math.round(pct)}%`;\n        }\n\n        // Audio setup\n        let audioCtx = null;\n        const audioBuffers = {};\n\n        function getAudioContext() {\n            if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();\n            return audioCtx;\n        }\n\n        async function loadAudio(id) {\n            try {\n                const asset = lib.getAsset(id);\n                if (!asset || asset.type !== 'audio') return;\n                const response = await fetch(asset.url);\n                const arrayBuffer = await response.arrayBuffer();\n                const ctx = getAudioContext();\n                audioBuffers[id] = await ctx.decodeAudioData(arrayBuffer);\n            } catch(e) { lib.log('Audio load error: ' + id); }\n        }\n\n        function playSound(id, volume = 0.5, loop = false) {\n            try {\n                const ctx = getAudioContext();\n                if (ctx.state === 'suspended') ctx.resume();\n                const buffer = audioBuffers[id];\n                if (!buffer) return null;\n                const source = ctx.createBufferSource();\n                const gain = ctx.createGain();\n                source.buffer = buffer;\n                source.loop = loop;\n                gain.gain.value = volume;\n                source.connect(gain);\n                gain.connect(ctx.destination);\n                source.start(0);\n                return { source, gain };\n            } catch(e) { return null; }\n        }\n\n        // === PRELOAD ALL ASSETS (audio + images) with unified progress ===\n        const audioIds = ['goal_kaching_sfx', 'ball_rolling_sfx', 'ball_kick_sfx', 'soccer_bg_music', 'oob_defeat_sfx', 'punch_impact_sfx'];\n        const imageIds = [\n            'menu_btn_play', 'menu_btn_settings', 'menu_btn_customize',\n            'menu_btn_leaderboard', 'menu_title_logo',\n            'ui_course_completed', 'ui_course_current', 'ui_course_upcoming', 'ui_level_box'\n        ];\n        const totalAssets = audioIds.length + imageIds.length;\n        let loadedCount = 0;\n        setLoadProgress(5);\n\n        // Pre-populate caches so assets are ready immediately after loading\n        const menuAssets = {};\n        const _preloadedImages = {}; // keyed by asset id \u2192 HTMLImageElement\n\n        function loadImage(id) {\n            return new Promise(async resolve => {\n                const a = lib.getAsset(id);\n                if (!a) { resolve(null); return; }\n                const img = new Image();\n                img.crossOrigin = 'anonymous';\n                img.src = a.url;\n                try {\n                    await img.decode();\n                    resolve(img);\n                } catch(e) {\n                    // decode() failed \u2014 fall back to waiting for load event\n                    if (img.complete && img.naturalWidth > 0) { resolve(img); return; }\n                    img.onload  = () => resolve(img);\n                    img.onerror = () => resolve(null);\n                }\n            });\n        }\n\n        // Load audio and images in parallel, updating progress for each\n        await Promise.all([\n            ...audioIds.map(async id => {\n                await loadAudio(id);\n                loadedCount++;\n                setLoadProgress(5 + (loadedCount / totalAssets) * 88);\n            }),\n            ...imageIds.map(async id => {\n                const img = await loadImage(id);\n                if (img) {\n                    _preloadedImages[id] = img;\n                    // Populate menuAssets for menu button images\n                    if (id.startsWith('menu_')) menuAssets[id] = img;\n                }\n                loadedCount++;\n                setLoadProgress(5 + (loadedCount / totalAssets) * 88);\n            })\n        ]);\n\n        setLoadProgress(100);\n        // Brief pause so \"Ready!\" is visible before fading out\n        await new Promise(resolve => setTimeout(resolve, 420));\n        loadingScreen.style.transition = 'opacity 0.55s ease';\n        loadingScreen.style.opacity = '0';\n        await new Promise(resolve => setTimeout(resolve, 580));\n        loadingScreen.remove();\n\n        // Show game parameters\n        lib.showGameParameters({\n            name: 'Crazy Shot',\n            params: {\n                'Player Health': { key: 'playerHealth', type: 'slider', min: 50, max: 200, step: 10 },\n                'Enemy Health': { key: 'enemyHealth', type: 'slider', min: 20, max: 100, step: 5 },\n                'Enemy Speed': { key: 'enemySpeed', type: 'slider', min: 0.5, max: 2.0, step: 0.1 },\n                'Number of Waves': { key: 'numWaves', type: 'slider', min: 1, max: 5, step: 1 },\n                'Enemies Per First Wave': { key: 'enemiesPerWave', type: 'slider', min: 2, max: 10, step: 1 }\n            }\n        });\n\n        // Three.js setup\n        const scene = new THREE.Scene();\n        scene.background = new THREE.Color(0x5DADE2);\n        scene.fog = new THREE.Fog(0x5DADE2, 40, 90);\n\n        const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 100);\n        camera.position.set(0, 6, 12);\n        camera.lookAt(0, 1.5, 0);\n\n        const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });\n        renderer.setSize(canvas.clientWidth, canvas.clientHeight);\n        renderer.shadowMap.enabled = true;\n        renderer.shadowMap.type = THREE.PCFSoftShadowMap;\n\n        function onResize() {\n            const w = gameWorld.clientWidth;\n            const h = gameWorld.clientHeight;\n            camera.aspect = w / h;\n            camera.updateProjectionMatrix();\n            renderer.setSize(w, h);\n        }\n        window.addEventListener('resize', onResize);\n        onResize();\n\n        // Lighting\n        const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);\n        scene.add(ambientLight);\n\n        const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);\n        dirLight.position.set(5, 15, 10);\n        dirLight.castShadow = true;\n        dirLight.shadow.mapSize.set(1024, 1024);\n        dirLight.shadow.camera.near = 0.5;\n        dirLight.shadow.camera.far = 50;\n        dirLight.shadow.camera.left = -15;\n        dirLight.shadow.camera.right = 15;\n        dirLight.shadow.camera.top = 15;\n        dirLight.shadow.camera.bottom = -15;\n        scene.add(dirLight);\n\n        const fillLight = new THREE.DirectionalLight(0x8888ff, 0.3);\n        fillLight.position.set(-5, 5, -5);\n        scene.add(fillLight);\n\n        // === GRASS FIELD ===\n        const fieldW = 34, fieldD = 28;\n\n        // === GRASS TEXTURE \u2014 procedural canvas matching the uploaded image ===\n        // Base color: #B5E06B (soft light yellow-green), subtle mottled variation\n        const grassTexCanvas = document.createElement('canvas');\n        grassTexCanvas.width = 512; grassTexCanvas.height = 512;\n        const gCtx = grassTexCanvas.getContext('2d');\n\n        // Fill base color\n        gCtx.fillStyle = '#78B84A';\n        gCtx.fillRect(0, 0, 512, 512);\n\n        // Subtle mottled variation \u2014 small blobs slightly lighter/darker\n        const rng = () => Math.random();\n        for (let b = 0; b < 1800; b++) {\n            const bx = rng() * 512;\n            const by = rng() * 512;\n            const br = 4 + rng() * 14;\n            const lum = (rng() - 0.5) * 18;\n            const base = 120 + lum;\n            const g = Math.round(Math.min(255, Math.max(0, 184 + lum)));\n            const bl = Math.round(Math.min(255, Math.max(0, 74 + lum * 0.4)));\n            gCtx.globalAlpha = 0.10 + rng() * 0.10;\n            gCtx.fillStyle = `rgb(${Math.round(base)},${g},${bl})`;\n            gCtx.beginPath();\n            gCtx.ellipse(bx, by, br, br * (0.5 + rng() * 0.7), rng() * Math.PI, 0, Math.PI * 2);\n            gCtx.fill();\n        }\n        gCtx.globalAlpha = 1.0;\n\n        const grassTex = new THREE.CanvasTexture(grassTexCanvas);\n        grassTex.wrapS = THREE.RepeatWrapping;\n        grassTex.wrapT = THREE.RepeatWrapping;\n        grassTex.repeat.set(6, 5);\n\n        const surroundTex = new THREE.CanvasTexture(grassTexCanvas);\n        surroundTex.wrapS = THREE.RepeatWrapping;\n        surroundTex.wrapT = THREE.RepeatWrapping;\n        surroundTex.repeat.set(24, 24);\n\n        // Main grass slab\n        const groundGeo = new THREE.BoxGeometry(fieldW, 0.4, fieldD);\n        const groundMat = new THREE.MeshLambertMaterial({ map: grassTex });\n        const ground = new THREE.Mesh(groundGeo, groundMat);\n        ground.position.y = -0.2;\n        ground.receiveShadow = true;\n        scene.add(ground);\n\n        // Surrounding terrain \u2014 slightly lower so the main grass patch stands out\n        const surroundGeo = new THREE.BoxGeometry(80, 0.35, 80);\n        const surroundMat = new THREE.MeshLambertMaterial({ map: surroundTex, color: 0x6BA33A });\n        const surround = new THREE.Mesh(surroundGeo, surroundMat);\n        surround.position.y = -0.26;\n        surround.receiveShadow = true;\n        scene.add(surround);\n\n        const hd = fieldD / 2 - 0.5;\n\n        // === TRAFFIC BARRIERS around field perimeter ===\n        // 2-part jersey barrier: curved tapered body + carved dent strip.\n        // Alternating white / orange repeating pattern, with gaps between each barrier.\n        const BARRIER_L    = 3.20;   // length along wall\n        const BARRIER_W    = 0.64;   // base width (into field)\n        const BARRIER_H    = 1.45;   // total height\n        const BARRIER_GAP  = 0.08;   // gap between adjacent barriers\n        const BARRIER_STEP = BARRIER_L + BARRIER_GAP;\n\n        const barrierWhiteMat  = new THREE.MeshPhongMaterial({ color: 0xF2EDDF, shininess: 35, specular: 0x888877 });\n        const barrierOrangeMat = new THREE.MeshPhongMaterial({ color: 0xFF8C00, shininess: 50, specular: 0x884400 });\n        const barrierDarkMat   = new THREE.MeshPhongMaterial({ color: 0xB8B0A0, shininess: 10, specular: 0x222211 });\n\n        // Collision boxes for barriers \u2014 populated by placeBarriers()\n        const barrierBoxes = []; // { cx, cz, hwX, hwZ, maxY }\n\n\n        function buildBarrierMesh(orange) {\n            const mat = orange ? barrierOrangeMat : barrierWhiteMat;\n            const group = new THREE.Group();\n\n            // Overall silhouette: jersey-style profile with tapered top, stepped lower base,\n            // soft rounded top edges, and chunky proportions.\n            const MAIN_H = BARRIER_H * 0.76;\n            const BASE_H = BARRIER_H * 0.24;\n            const TOP_W  = BARRIER_W * 0.78;\n\n            // Main body (tapered sides)\n            const bodyGeo = new THREE.BoxGeometry(BARRIER_L, MAIN_H, TOP_W);\n            const bodyMesh = new THREE.Mesh(bodyGeo, mat);\n            bodyMesh.position.y = BASE_H + MAIN_H * 0.5;\n            bodyMesh.castShadow = true;\n            bodyMesh.receiveShadow = true;\n            group.add(bodyMesh);\n\n            // Lower base lip (sticks out slightly farther than upper body)\n            const baseGeo = new THREE.BoxGeometry(BARRIER_L, BASE_H, BARRIER_W);\n            const baseMesh = new THREE.Mesh(baseGeo, mat);\n            baseMesh.position.y = BASE_H * 0.5;\n            baseMesh.castShadow = true;\n            baseMesh.receiveShadow = true;\n            group.add(baseMesh);\n\n            // Rounded top front/back edges for a softer, molded look\n            const filletR = 0.045;\n            const filletLen = BARRIER_L;\n            const filletGeo = new THREE.CylinderGeometry(filletR, filletR, filletLen, 14);\n\n            const topFrontFillet = new THREE.Mesh(filletGeo, mat);\n            topFrontFillet.rotation.z = Math.PI * 0.5;\n            topFrontFillet.position.set(0, BASE_H + MAIN_H - filletR, TOP_W * 0.5 - filletR * 0.8);\n            topFrontFillet.castShadow = true;\n            group.add(topFrontFillet);\n\n            const topBackFillet = new THREE.Mesh(filletGeo.clone(), mat);\n            topBackFillet.rotation.z = Math.PI * 0.5;\n            topBackFillet.position.set(0, BASE_H + MAIN_H - filletR, -(TOP_W * 0.5 - filletR * 0.8));\n            topBackFillet.castShadow = true;\n            group.add(topBackFillet);\n\n            // Recessed front dents: deep rectangular cut-ins with slanted bottoms.\n            const NUM_SLOTS = 3;\n            const SLOT_W = BARRIER_L * 0.16;\n            const SLOT_H = BARRIER_H * 0.36;\n            const SLOT_D = BARRIER_W * 0.28;\n            const slotSpacing = BARRIER_L / (NUM_SLOTS + 1);\n            const slotY = BASE_H + MAIN_H * 0.42;\n\n            for (let s = 0; s < NUM_SLOTS; s++) {\n                const slotX = -BARRIER_L / 2 + slotSpacing * (s + 1);\n\n                // Vertical rear wall of recess\n                const recessWall = new THREE.Mesh(new THREE.BoxGeometry(SLOT_W, SLOT_H, SLOT_D), barrierDarkMat);\n                recessWall.position.set(slotX, slotY, TOP_W * 0.5 - SLOT_D * 0.5 + 0.002);\n                group.add(recessWall);\n\n                // Slanted recess floor toward barrier base\n                const wedgeGeo = new THREE.BoxGeometry(SLOT_W * 0.96, SLOT_H * 0.40, SLOT_D * 0.74);\n                const wedge = new THREE.Mesh(wedgeGeo, barrierDarkMat);\n                wedge.position.set(slotX, BASE_H + SLOT_H * 0.14, TOP_W * 0.5 - SLOT_D * 0.17);\n                wedge.rotation.x = -0.55;\n                group.add(wedge);\n            }\n\n            // Side connector tabs so barriers visually interlock.\n            const TAB_W = 0.095;\n            const TAB_H = BARRIER_H * 0.13;\n            const TAB_D = BARRIER_W * 0.34;\n            const tabGeo = new THREE.BoxGeometry(TAB_W, TAB_H, TAB_D);\n            const tabY = BASE_H + TAB_H * 0.62;\n\n            const leftTab = new THREE.Mesh(tabGeo, mat);\n            leftTab.position.set(-BARRIER_L / 2 - TAB_W / 2, tabY, 0);\n            leftTab.castShadow = true;\n            group.add(leftTab);\n\n            const rightTab = new THREE.Mesh(tabGeo.clone(), mat);\n            rightTab.position.set(BARRIER_L / 2 + TAB_W / 2, tabY, 0);\n            rightTab.castShadow = true;\n            group.add(rightTab);\n\n            return group;\n        }\n\n        // Compute count + per-barrier length scale so N barriers fill span exactly\n        function fitBarriers(span) {\n            const count = Math.max(1, Math.round(span / BARRIER_STEP));\n            const L_wall = (span - (count - 1) * BARRIER_GAP) / count;\n            const step = L_wall + BARRIER_GAP; // = (span + BARRIER_GAP) / count\n            const lengthScale = L_wall / BARRIER_L;\n            return { count, step, lengthScale };\n        }\n\n        function placeBarriers(count, startX, startZ, stepX, stepZ, rotY, lengthScale = 1) {\n            for (let i = 0; i < count; i++) {\n                const cx = startX + stepX * i + stepX * 0.5;\n                const cz = startZ + stepZ * i + stepZ * 0.5;\n                const isOrange = (i % 2 === 1);\n                const b = buildBarrierMesh(isOrange);\n                b.rotation.y = rotY;\n                b.scale.x = lengthScale; // stretch/shrink along barrier's local length axis\n                b.position.set(cx, 0, cz);\n                b.castShadow = true;\n                scene.add(b);\n\n                // AABB collision box in world space \u2014 account for length scale\n                const isAlongZ = Math.abs(Math.sin(rotY)) > 0.5;\n                const hwX = isAlongZ ? BARRIER_W * 0.5 : BARRIER_L * 0.5 * lengthScale;\n                const hwZ = isAlongZ ? BARRIER_L * 0.5 * lengthScale : BARRIER_W * 0.5;\n                barrierBoxes.push({ cx, cz, hwX, hwZ, maxY: BARRIER_H });\n            }\n        }\n\n        // Field corners: X \u2208 [-17, 17], Z \u2208 [-14, 14]\n        const FHW = fieldW / 2; // 17\n        const FHD = fieldD / 2; // 14\n\n        // Corner strategy: Z-axis side walls own the corners; X-axis walls\n        // start/end BARRIER_W inward so they never overlap with the side walls.\n        const GOAL_HALF_W = 6.5;\n        const GOAL_BUFFER = 1.2;\n        const goalGapEdge = GOAL_HALF_W + GOAL_BUFFER; // 7.7\n\n        // Far side (Z = -FHD): left + right sections, each inset by BARRIER_W from field corner\n        // so the corner belongs entirely to the left/right side walls.\n        const farSectionSpan = FHW - BARRIER_W - goalGapEdge; // 17 - 0.64 - 7.7 = 8.66\n        const farFit = fitBarriers(farSectionSpan);\n        placeBarriers(farFit.count, -FHW + BARRIER_W, -FHD - BARRIER_W * 0.5, farFit.step, 0, 0, farFit.lengthScale);\n        placeBarriers(farFit.count,  goalGapEdge,      -FHD - BARRIER_W * 0.5, farFit.step, 0, 0, farFit.lengthScale);\n\n        // Near side (Z = +FHD): full row, inset BARRIER_W on each side so corners are owned by side walls\n        const innerW = fieldW - 2 * BARRIER_W; // 34 - 1.28 = 32.72\n        const nearFit = fitBarriers(innerW);\n        placeBarriers(nearFit.count, -FHW + BARRIER_W, FHD - BARRIER_W * 0.5, nearFit.step, 0, 0, nearFit.lengthScale);\n\n        // Left side (X = -FHW): full fieldD span, corners owned by this wall\n        const sideFit = fitBarriers(fieldD); // 28\n        const sideStartZ = -FHD;\n        placeBarriers(sideFit.count, -FHW - BARRIER_W * 0.5, sideStartZ, 0, sideFit.step, Math.PI / 2, sideFit.lengthScale);\n\n        // Right side (X = +FHW)\n        placeBarriers(sideFit.count,  FHW - BARRIER_W * 0.5, sideStartZ, 0, sideFit.step, Math.PI / 2, sideFit.lengthScale);\n\n        // === LEVEL OBSTACLE SYSTEM ===\n        const perimeterBarrierCount = barrierBoxes.length;\n        let levelObstacleMeshes = [];\n        let currentBigLevel = 0; // 0-indexed big level\n        let currentCourse = 0;    // 0-indexed course within big level (0-2)\n        let checkpointBigLevel = 0; // last big level reached \u2014 used as respawn checkpoint\n        const COURSES_PER_LEVEL = 3;\n        let levelTransitionTimer = 0;\n\n        function clearLevelObstacles() {\n            levelObstacleMeshes.forEach(m => scene.remove(m));\n            levelObstacleMeshes = [];\n            barrierBoxes.length = perimeterBarrierCount;\n        }\n\n        function spawnLevelObstacles(bigLevel, course) {\n            clearLevelObstacles();\n            const levels = config.levels;\n            if (!levels || !levels[bigLevel]) return;\n            const courses = levels[bigLevel].courses;\n            if (!courses || !courses[course]) return;\n            const levelData = courses[course];\n\n            levelData.obstacles.forEach(obs => {\n                const count = obs.count || 1;\n                const rotY = obs.rot || 0;\n                const isAlongZ = Math.abs(Math.sin(rotY)) > 0.5;\n\n                for (let i = 0; i < count; i++) {\n                    const offset = (i - (count - 1) / 2) * BARRIER_STEP;\n                    const cx = obs.x + (isAlongZ ? 0 : offset);\n                    const cz = obs.z + (isAlongZ ? offset : 0);\n\n                    const isOrange = (i % 2 === 1);\n                    const b = buildBarrierMesh(isOrange);\n                    b.rotation.y = rotY;\n                    b.position.set(cx, 0, cz);\n                    b.castShadow = true;\n                    scene.add(b);\n                    levelObstacleMeshes.push(b);\n\n                    const hwX = isAlongZ ? BARRIER_W * 0.5 : BARRIER_L * 0.5;\n                    const hwZ = isAlongZ ? BARRIER_L * 0.5 : BARRIER_W * 0.5;\n                    barrierBoxes.push({ cx, cz, hwX, hwZ, maxY: BARRIER_H });\n                }\n            });\n        }\n\n        // === GOAL NET ===\n\n        // Build a diamond-pattern net texture matching the reference image:\n        // large white diamonds on transparent background, thick strands\n        function createGoal(zPos) {\n            const goalGroup = new THREE.Group();\n\n            // Purple posts \u2014 thick, matching reference image\n            const postMat = new THREE.MeshPhongMaterial({ color: 0xffffff, shininess: 90, specular: 0x9955cc });\n            const POST_R = 0.22;\n            const GOAL_W       = 13.0;  // total inner width\n            const GOAL_H       = 5.2;   // front post height\n            const BACK_POST_H  = 3.7;   // back post height (shorter than front)\n            const DEPTH        = 3.2;   // how far back the net goes\n\n            // Front frame \u2014 single seamless TubeGeometry built from a CurvePath.\n            // Straight posts (LineCurve3) + CubicBezier corners for a true inward\n            // 90\u00b0 rounded corner. CatmullRom is NOT used \u2014 straight sections stay\n            // perfectly straight with no bowing near the corners.\n            const cornerR = POST_R * 1.2; // visible corner radius\n            const k = 0.5523; // cubic-bezier magic number for 90\u00b0 arc approximation\n\n            const frontPath = new THREE.CurvePath();\n\n            // Left post \u2014 perfectly straight\n            frontPath.add(new THREE.LineCurve3(\n                new THREE.Vector3(-GOAL_W / 2, 0, 0),\n                new THREE.Vector3(-GOAL_W / 2, GOAL_H - cornerR, 0)\n            ));\n\n            // Top-left corner \u2014 cubic bezier, curves INWARD (correct goalpost bend)\n            // Arc center: (-GOAL_W/2 + cornerR, GOAL_H - cornerR)\n            // Start tangent: (0, +1)  \u2192  CP1 offset along +Y\n            // End tangent:   (-1, 0)  \u2192  CP2 offset along -X\n            frontPath.add(new THREE.CubicBezierCurve3(\n                new THREE.Vector3(-GOAL_W / 2,              GOAL_H - cornerR,             0),\n                new THREE.Vector3(-GOAL_W / 2,              GOAL_H - cornerR + k * cornerR, 0),\n                new THREE.Vector3(-GOAL_W / 2 + cornerR - k * cornerR, GOAL_H,           0),\n                new THREE.Vector3(-GOAL_W / 2 + cornerR,   GOAL_H,                        0)\n            ));\n\n            // Crossbar \u2014 perfectly straight\n            frontPath.add(new THREE.LineCurve3(\n                new THREE.Vector3(-GOAL_W / 2 + cornerR, GOAL_H, 0),\n                new THREE.Vector3( GOAL_W / 2 - cornerR, GOAL_H, 0)\n            ));\n\n            // Top-right corner \u2014 cubic bezier, curves INWARD\n            // Start tangent: (+1, 0)  \u2192  CP1 offset along +X\n            // End tangent:   (0, +1)  \u2192  CP2 offset along +Y\n            frontPath.add(new THREE.CubicBezierCurve3(\n                new THREE.Vector3( GOAL_W / 2 - cornerR,              GOAL_H,                        0),\n                new THREE.Vector3( GOAL_W / 2 - cornerR + k * cornerR, GOAL_H,                      0),\n                new THREE.Vector3( GOAL_W / 2,              GOAL_H - cornerR + k * cornerR,          0),\n                new THREE.Vector3( GOAL_W / 2,              GOAL_H - cornerR,                        0)\n            ));\n\n            // Right post \u2014 perfectly straight\n            frontPath.add(new THREE.LineCurve3(\n                new THREE.Vector3(GOAL_W / 2, GOAL_H - cornerR, 0),\n                new THREE.Vector3(GOAL_W / 2, 0, 0)\n            ));\n\n            const frontFrameGeo = new THREE.TubeGeometry(frontPath, 120, POST_R, 20, false);\n            const frontFrameMesh  = new THREE.Mesh(frontFrameGeo, postMat);\n\n            frontFrameMesh.castShadow = true;\n            goalGroup.add(frontFrameMesh);\n\n            // Side connecting bars \u2014 run from top of each front post to top of each back post\n            const leftSideBarPath = new THREE.LineCurve3(\n                new THREE.Vector3(-GOAL_W / 2, GOAL_H, 0),\n                new THREE.Vector3(-GOAL_W / 2, BACK_POST_H, -DEPTH)\n            );\n            const leftSideBarGeo = new THREE.TubeGeometry(leftSideBarPath, 4, POST_R * 0.82, 16, false);\n            const leftSideBar = new THREE.Mesh(leftSideBarGeo, postMat);\n            leftSideBar.castShadow = true;\n            goalGroup.add(leftSideBar);\n\n            const rightSideBarPath = new THREE.LineCurve3(\n                new THREE.Vector3(GOAL_W / 2, GOAL_H, 0),\n                new THREE.Vector3(GOAL_W / 2, BACK_POST_H, -DEPTH)\n            );\n            const rightSideBarGeo = new THREE.TubeGeometry(rightSideBarPath, 4, POST_R * 0.82, 16, false);\n            const rightSideBar = new THREE.Mesh(rightSideBarGeo, postMat);\n            rightSideBar.castShadow = true;\n            goalGroup.add(rightSideBar);\n\n            // Back vertical posts (corners where side-net meets back net)\n            const backPostGeo = new THREE.CylinderGeometry(POST_R * 0.72, POST_R * 0.72, BACK_POST_H, 10);\n            const leftBackPost = new THREE.Mesh(backPostGeo, postMat);\n            leftBackPost.position.set(-GOAL_W / 2, BACK_POST_H / 2, -DEPTH);\n            goalGroup.add(leftBackPost);\n            const rightBackPost = new THREE.Mesh(backPostGeo.clone(), postMat);\n            rightBackPost.position.set(GOAL_W / 2, BACK_POST_H / 2, -DEPTH);\n            goalGroup.add(rightBackPost);\n\n\n            // === 3D NET \u2014 diamond grid of merged TubeGeometry strands ===\n            const NET_SPACING = 0.55;   // distance between parallel strands\n            const NET_TUBE_R  = 0.018;  // strand radius\n            const netMat = new THREE.MeshPhongMaterial({\n                color: 0xffffff, shininess: 60, specular: 0x555555\n            });\n\n            // Build a flat net panel (XY plane) of given width \u00d7 height,\n            // with two sets of diagonal strands forming a diamond pattern.\n            function build3DNetPanel(panelW, panelH) {\n                const geos = [];\n                const hw = panelW / 2, hh = panelH / 2;\n\n                function addStrand(x0, y0, x1, y1) {\n                    if (Math.hypot(x1 - x0, y1 - y0) < 0.05) return;\n                    const path = new THREE.LineCurve3(\n                        new THREE.Vector3(x0 - hw, y0 - hh, 0),\n                        new THREE.Vector3(x1 - hw, y1 - hh, 0)\n                    );\n                    geos.push(new THREE.TubeGeometry(path, 1, NET_TUBE_R, 4, false));\n                }\n\n                // Diagonal set 1 (\u2197): y = x + c\n                for (let c = -(panelW - NET_SPACING); c <= panelH + NET_SPACING; c += NET_SPACING) {\n                    const x0 = Math.max(0, -c);\n                    const x1 = Math.min(panelW, panelH - c);\n                    if (x1 > x0 + 0.01) addStrand(x0, x0 + c, x1, x1 + c);\n                }\n                // Diagonal set 2 (\u2198): y = c - x\n                for (let c = 0; c <= panelW + panelH; c += NET_SPACING) {\n                    const x0 = Math.max(0, c - panelH);\n                    const x1 = Math.min(panelW, c);\n                    if (x1 > x0 + 0.01) addStrand(x0, c - x0, x1, c - x1);\n                }\n\n                if (geos.length === 0) return null;\n                const merged = THREE.BufferGeometryUtils.mergeBufferGeometries(geos);\n                return new THREE.Mesh(merged, netMat);\n            }\n\n            // Back net (XY panel, sits at z = -DEPTH)\n            const backNet = build3DNetPanel(GOAL_W, GOAL_H);\n            if (backNet) {\n                backNet.position.set(0, GOAL_H / 2, -DEPTH);\n                goalGroup.add(backNet);\n            }\n\n            // Top net (XZ panel, rotated to lie flat at y = GOAL_H)\n            const topNet = build3DNetPanel(GOAL_W, DEPTH);\n            if (topNet) {\n                topNet.rotation.x = -Math.PI / 2;\n                topNet.position.set(0, GOAL_H, -DEPTH / 2);\n                goalGroup.add(topNet);\n            }\n\n            // Left side net (ZY panel, at x = -GOAL_W/2)\n            const leftNet = build3DNetPanel(DEPTH, GOAL_H);\n            if (leftNet) {\n                leftNet.rotation.y = Math.PI / 2;\n                leftNet.position.set(-GOAL_W / 2, GOAL_H / 2, -DEPTH / 2);\n                goalGroup.add(leftNet);\n            }\n\n            // Right side net (ZY panel, at x = +GOAL_W/2)\n            const rightNet = build3DNetPanel(DEPTH, GOAL_H);\n            if (rightNet) {\n                rightNet.rotation.y = -Math.PI / 2;\n                rightNet.position.set(GOAL_W / 2, GOAL_H / 2, -DEPTH / 2);\n                goalGroup.add(rightNet);\n            }\n\n            // === GOAL INDICATOR RECTANGLE ===\n            // Semi-transparent white rectangle on the front opening face.\n            // Always slightly visible as a targeting guide; brightens when ball is inside.\n            const rectGeo = new THREE.PlaneGeometry(GOAL_W, GOAL_H);\n            const rectMat = new THREE.MeshBasicMaterial({\n                color: 0xffffff,\n                transparent: true,\n                opacity: 0.35,\n                side: THREE.DoubleSide,\n                depthWrite: false,\n            });\n            const goalIndicatorTri = new THREE.Mesh(rectGeo, rectMat);\n            // Center the rect: PlaneGeometry is centered at origin, so shift up by half height\n            goalIndicatorTri.position.set(0, GOAL_H / 2, 0.04);\n            goalGroup.add(goalIndicatorTri);\n\n            goalGroup.position.set(0, 0, zPos);\n            scene.add(goalGroup);\n            return goalIndicatorTri;\n        }\n\n        const goalTriIndicator = createGoal(-hd - 0.5);\n\n        // === FOOTBALL \u2014 proper soccer ball with true Voronoi hex/pent patches ===\n\n        // A truncated icosahedron has 12 pentagonal faces (black) and 20 hexagonal faces (white).\n        // Pentagon centers = the 12 normalized icosahedron vertices.\n        // Hexagon centers  = the 20 normalized icosahedron face centers.\n        // Voronoi classification against all 32 centers gives correct patches.\n\n        const _phi = (1 + Math.sqrt(5)) / 2;\n        const _icoVerts = [\n            [0,1,_phi],[0,-1,_phi],[0,1,-_phi],[0,-1,-_phi],\n            [1,_phi,0],[-1,_phi,0],[1,-_phi,0],[-1,-_phi,0],\n            [_phi,0,1],[-_phi,0,1],[_phi,0,-1],[-_phi,0,-1]\n        ].map(v => { const l=Math.sqrt(v[0]**2+v[1]**2+v[2]**2); return [v[0]/l,v[1]/l,v[2]/l]; });\n\n        // 20 triangular faces of the icosahedron (standard winding)\n        const _icoFaces = [\n            [0,1,8],[0,8,4],[0,4,5],[0,5,9],[0,9,1],\n            [1,9,7],[1,7,6],[1,6,8],[8,6,10],[8,10,4],\n            [4,10,2],[4,2,5],[5,2,11],[5,11,9],[9,11,7],\n            [7,11,3],[7,3,6],[6,3,10],[10,3,2],[2,3,11]\n        ];\n        const _hexCenters = _icoFaces.map(f => {\n            const cx=(_icoVerts[f[0]][0]+_icoVerts[f[1]][0]+_icoVerts[f[2]][0])/3;\n            const cy=(_icoVerts[f[0]][1]+_icoVerts[f[1]][1]+_icoVerts[f[2]][1])/3;\n            const cz=(_icoVerts[f[0]][2]+_icoVerts[f[1]][2]+_icoVerts[f[2]][2])/3;\n            const l=Math.sqrt(cx*cx+cy*cy+cz*cz);\n            return [cx/l,cy/l,cz/l];\n        });\n\n        // 0-11 = pentagon centers (black), 12-31 = hexagon centers (white)\n        const _allBallCenters = [..._icoVerts, ..._hexCenters];\n\n        // Voronoi classify: returns nearest index + top-2 dot products\n        function _ballClassify(nx, ny, nz) {\n            let d1=-2, d2=-2, idx1=0;\n            for (let ci=0; ci<_allBallCenters.length; ci++) {\n                const c=_allBallCenters[ci];\n                const dot=nx*c[0]+ny*c[1]+nz*c[2];\n                if (dot>d1) { d2=d1; d1=dot; idx1=ci; }\n                else if (dot>d2) { d2=dot; }\n            }\n            return { idx:idx1, d1, d2 };\n        }\n\n        // --- Procedural texture: proper black pentagons + white hexagons ---\n        const ballCanvas = document.createElement('canvas');\n        const BW = 1024, BH = 512;\n        ballCanvas.width = BW; ballCanvas.height = BH;\n        const bCtx = ballCanvas.getContext('2d');\n        const imgData = bCtx.createImageData(BW, BH);\n\n        const SEAM_W = 0.022; // Voronoi boundary half-width (d1\u2212d2 threshold)\n\n        for (let py = 0; py < BH; py++) {\n            for (let px = 0; px < BW; px++) {\n                const u = (px + 0.5) / BW;\n                const v = (py + 0.5) / BH;\n                const lon = u * Math.PI * 2;\n                const lat = (1.0 - v) * Math.PI - Math.PI / 2;\n                const nx = Math.cos(lat) * Math.cos(lon);\n                const ny = Math.sin(lat);\n                const nz = Math.cos(lat) * Math.sin(lon);\n\n                const { idx, d1, d2 } = _ballClassify(nx, ny, nz);\n                const seamness = d1 - d2; // 0 at boundary, positive inside patch\n\n                let cr, cg, cb;\n                // Leather base color: warm off-white/cream\n                const BASE_R = 242, BASE_G = 238, BASE_B = 228;\n                if (seamness < SEAM_W) {\n                    // Seam groove \u2014 dark brown groove, smooth gradient\n                    const t = seamness / SEAM_W;\n                    const groove = t * t; // non-linear: sharpest at centre\n                    cr = Math.round(BASE_R * groove + 28 * (1 - groove));\n                    cg = Math.round(BASE_G * groove + 18 * (1 - groove));\n                    cb = Math.round(BASE_B * groove + 10 * (1 - groove));\n                } else if (idx < 12) {\n                    // Pentagon patch \u2014 classic black\n                    cr = 18; cg = 16; cb = 14;\n                } else {\n                    // Hexagon patch \u2014 warm off-white leather\n                    cr = BASE_R; cg = BASE_G; cb = BASE_B;\n                }\n\n                const i4 = (py * BW + px) * 4;\n                imgData.data[i4]   = cr;\n                imgData.data[i4+1] = cg;\n                imgData.data[i4+2] = cb;\n                imgData.data[i4+3] = 255;\n            }\n        }\n        bCtx.putImageData(imgData, 0, 0);\n        const ballTex = new THREE.CanvasTexture(ballCanvas);\n\n        // --- Geometry: high-poly sphere with vertex-displaced seam dents ---\n        const ballGeo = new THREE.SphereGeometry(0.44, 80, 80);\n        {\n            const posAttr = ballGeo.attributes.position;\n            const BALL_R = 0.44;\n            const DENT_DEPTH = 0.016;\n            const DENT_SHARP = 22.0;\n            for (let vi = 0; vi < posAttr.count; vi++) {\n                const x = posAttr.getX(vi), y = posAttr.getY(vi), z = posAttr.getZ(vi);\n                const len = Math.sqrt(x*x + y*y + z*z);\n                if (len < 0.001) continue;\n                const nx = x/len, ny = y/len, nz = z/len;\n                const { d1, d2 } = _ballClassify(nx, ny, nz);\n                const seamness = d1 - d2;\n                const dent = DENT_DEPTH * Math.exp(-seamness * seamness * DENT_SHARP * DENT_SHARP);\n                const r = BALL_R - dent;\n                posAttr.setXYZ(vi, nx * r, ny * r, nz * r);\n            }\n            ballGeo.computeVertexNormals();\n        }\n        // Matte leather material \u2014 low shininess, warm tint, minimal specular\n        const ballMat = new THREE.MeshPhongMaterial({\n            map: ballTex,\n            color: 0xf5f0e8,\n            shininess: 8,\n            specular: 0x111008,\n        });\n        const footballMesh = new THREE.Mesh(ballGeo, ballMat);\n        // Spawn ball on the near side of the field (player kicks toward goal at far end)\n        const ballSpawnX = (Math.random() - 0.5) * 10;\n        const ballSpawnZ = 8;\n        footballMesh.position.set(ballSpawnX, 0.44, ballSpawnZ);\n        footballMesh.castShadow = true;\n        footballMesh.receiveShadow = true;\n        scene.add(footballMesh);\n\n        // Helper: apply position, rotation, scale to a cloned geometry\n        function transformGeometry(geo, pos, rot, scl) {\n            const g = geo.clone();\n            const mat4 = new THREE.Matrix4();\n            const q = new THREE.Quaternion();\n            if (rot) q.setFromEuler(new THREE.Euler(rot.x || 0, rot.y || 0, rot.z || 0));\n            const s = scl || { x: 1, y: 1, z: 1 };\n            const p = pos || { x: 0, y: 0, z: 0 };\n            mat4.compose(new THREE.Vector3(p.x, p.y, p.z), q, new THREE.Vector3(s.x, s.y, s.z));\n            g.applyMatrix4(mat4);\n            return g;\n        }\n\n        // Merge an array of {geo, pos, rot, scl} into one BufferGeometry\n        function mergeGeos(parts) {\n            const geos = parts.map(p => transformGeometry(p.geo, p.pos, p.rot, p.scl));\n            return THREE.BufferGeometryUtils.mergeBufferGeometries(geos, false);\n        }\n\n        // Character creation - fully remade 3D model with sculpted proportions\n        function createCharacter(color, isPlayer = false) {\n            const group = new THREE.Group();\n\n            const headColor = 0x2277ff;\n            const bodyMat = new THREE.MeshPhongMaterial({ \n                color: headColor, \n                shininess: 60,\n                specular: 0x4499ff,\n                side: THREE.DoubleSide\n            });\n            const skinMat = new THREE.MeshPhongMaterial({\n                color: 0xffcc99,\n                shininess: 30,\n                specular: 0x222222\n            });\n\n            // --- HEAD + NECK: single merged mesh with shader-carved eye holes ---\n            // Head sphere center sits at y=2.65 in local group space.\n            // Neck blends seamlessly below it. All parts are merged into one geometry\n            // so head and neck are a single continuous 3D part.\n            // Head sphere constants \u2014 defined here, used by shader and geometry below\n            const HEAD_SPHERE_CY = 2.58;\n            const HEAD_SPHERE_R  = 0.38;\n            const HEAD_CENTER_Y  = HEAD_SPHERE_CY;\n            const headHoleMat = new THREE.MeshPhongMaterial({\n                color: headColor,\n                shininess: 60,\n                specular: 0x4499ff,\n                side: THREE.FrontSide,\n            });\n\n            // The shader subtracts headCenter so eye-hole math works on the\n            // sphere portion even though vertices span the whole merged shape.\n            headHoleMat.onBeforeCompile = (shader) => {\n                shader.uniforms.headCenter = { value: new THREE.Vector3(0, HEAD_CENTER_Y, 0) };\n                shader.vertexShader = 'varying vec3 vSphPos;\\n' +\n                    shader.vertexShader.replace(\n                        'void main() {',\n                        'void main() {\\nvSphPos = position;\\n'\n                    );\n                shader.fragmentShader =\n                    'varying vec3 vSphPos;\\nuniform vec3 headCenter;\\n' +\n                    shader.fragmentShader.replace(\n                        'void main() {',\n                        `void main() {\n            vec3 sn = normalize(vSphPos - headCenter);\n            // Left eye elliptical hole \u2014 slightly taller (y) than wide (x)\n            vec3 lEye = normalize(vec3(-0.371, 0.086, 0.925));\n            vec3 lUp = normalize(vec3(0.0, 1.0, 0.0) - dot(vec3(0.0, 1.0, 0.0), lEye) * lEye);\n            vec3 lRight = cross(lEye, lUp);\n            vec3 lPerp = sn - dot(sn, lEye) * lEye;\n            float lPx = dot(lPerp, lRight);\n            float lPy = dot(lPerp, lUp);\n            float lDist = (lPx * lPx) / (0.22 * 0.22) + (lPy * lPy) / (0.30 * 0.30);\n            // Right eye elliptical hole\n            vec3 rEye = normalize(vec3( 0.371, 0.086, 0.925));\n            vec3 rUp = normalize(vec3(0.0, 1.0, 0.0) - dot(vec3(0.0, 1.0, 0.0), rEye) * rEye);\n            vec3 rRight = cross(rEye, rUp);\n            vec3 rPerp = sn - dot(sn, rEye) * rEye;\n            float rPx = dot(rPerp, rRight);\n            float rPy = dot(rPerp, rUp);\n            float rDist = (rPx * rPx) / (0.22 * 0.22) + (rPy * rPy) / (0.30 * 0.30);\n            bool lFront = dot(sn, lEye) > 0.5;\n            bool rFront = dot(sn, rEye) > 0.5;\n            if ((lDist < 1.0 && lFront) || (rDist < 1.0 && rFront)) discard;\n        `\n                    );\n            };\n\n            // Head + neck as a SINGLE LatheGeometry \u2014 one continuous shape, zero merging.\n            // Key: neck top radius = 0.14. Sphere arc starts at the angle where r=0.14\n            // so the neck flares directly into the sphere with zero gap or pinch point.\n            const NECK_TOP_R = 0.075;\n            // angle where sphere radius == NECK_TOP_R: cos(a) = NECK_TOP_R / HEAD_SPHERE_R\n            const sphereStartAngle = -Math.acos(NECK_TOP_R / HEAD_SPHERE_R);\n            const sphereStartY = HEAD_SPHERE_CY + HEAD_SPHERE_R * Math.sin(sphereStartAngle);\n\n            // Neck points \u2014 slimmer neck\n            const neckPoints = [\n                new THREE.Vector2(0.10, 2.10),        // neck base\n                new THREE.Vector2(0.085, 2.13),       // narrow lower neck\n                new THREE.Vector2(0.075, 2.20),       // slim upper neck\n                new THREE.Vector2(NECK_TOP_R, sphereStartY), // neck top \u2014 perfectly matches sphere start\n            ];\n\n            // Sphere arc: starts at sphereStartAngle (r=NECK_TOP_R) \u2192 +\u03c0/2 (crown, r=0)\n            const headSpherePoints = [];\n            const headSteps = 32;\n            for (let si = 0; si <= headSteps; si++) {\n                const angle = sphereStartAngle + (Math.PI / 2 - sphereStartAngle) * (si / headSteps);\n                const r = HEAD_SPHERE_R * Math.cos(angle);\n                const y = HEAD_SPHERE_CY + HEAD_SPHERE_R * Math.sin(angle);\n                headSpherePoints.push(new THREE.Vector2(Math.max(0, r), y));\n            }\n            // Drop the first sphere point \u2014 it's identical to neckPoints' last point\n            const headNeckPoints = [...neckPoints, ...headSpherePoints.slice(1)];\n            const headNeckGeo = new THREE.LatheGeometry(headNeckPoints, 64);\n            const bodyMesh = new THREE.Mesh(headNeckGeo, headHoleMat);\n            bodyMesh.castShadow = true;\n            group.add(bodyMesh);\n\n            // Dark backing spheres \u2014 visible through the discarded eye-hole pixels\n            const socketMat = new THREE.MeshBasicMaterial({ color: 0x060606 });\n            const socketGeo = new THREE.SphereGeometry(0.11, 16, 16);\n            const leftSocket = new THREE.Mesh(socketGeo, socketMat);\n            leftSocket.position.set(-0.13, 2.61, 0.22);\n            leftSocket.scale.set(0.85, 1.15, 1.0);\n            group.add(leftSocket);\n            const rightSocket = new THREE.Mesh(socketGeo.clone(), socketMat);\n            rightSocket.position.set(0.13, 2.61, 0.22);\n            rightSocket.scale.set(0.85, 1.15, 1.0);\n            group.add(rightSocket);\n\n            // White oval eyeballs \u2014 3D ellipsoids that sit flush with the head surface\n            // and protrude slightly outward. Sized and positioned to match the shader holes.\n            // Eye normal directions: normalize(\u00b10.371, 0.086, 0.925) \u2014 mostly forward, slight inward/up tilt.\n            // Physical hole half-widths at r=0.35: x\u22480.077, y\u22480.105.\n            // Place eyeball center at radius + tiny protrusion along eye normal.\n            const eyeMat = new THREE.MeshPhongMaterial({\n                color: 0xffffff,\n                shininess: 80,\n                specular: 0xaaaaaa\n            });\n            const eyeGeo = new THREE.SphereGeometry(0.1, 20, 20);\n\n            // Left eye: normal = normalize(-0.371, 0.086, 0.925), placed at r=0.37 (slight protrusion)\n            const leftEye = new THREE.Mesh(eyeGeo, eyeMat);\n            leftEye.position.set(-0.371 * 0.36, 2.58 + 0.086 * 0.36, 0.925 * 0.36);\n            // Scale to match hole ellipse: width 0.77, height 1.05 of base 0.1 sphere \u2192 0.077 / 0.105 half-sizes\n            // Depth (z in local) kept at 0.5 for a subtle dome that doesn't clip through back of head\n            leftEye.scale.set(0.77, 1.05, 0.50);\n            // Rotate to face outward along eye normal (tilt slightly matches the normal's small y component)\n            leftEye.rotation.y = Math.atan2(-0.371, 0.925);  // yaw toward left\n            leftEye.rotation.x = -Math.atan2(0.086, Math.sqrt(0.371*0.371 + 0.925*0.925)); // slight upward pitch\n            leftEye.castShadow = false;\n            group.add(leftEye);\n\n            const rightEye = new THREE.Mesh(eyeGeo.clone(), eyeMat);\n            rightEye.position.set(0.371 * 0.36, 2.58 + 0.086 * 0.36, 0.925 * 0.36);\n            rightEye.scale.set(0.77, 1.05, 0.50);\n            rightEye.rotation.y = Math.atan2(0.371, 0.925);  // yaw toward right\n            rightEye.rotation.x = -Math.atan2(0.086, Math.sqrt(0.371*0.371 + 0.925*0.925));\n            rightEye.castShadow = false;\n            group.add(rightEye);\n\n\n            // --- TORSO: single LatheGeometry \u2014 shoulders \u2192 chest \u2192 waist \u2192 hips ---\n            // Profile spans y=1.00 (pelvis base) \u2192 y=2.10 (neck base, r=0.17 matches neck bottom).\n            // Using smooth cubic-like control points for natural anatomical curves.\n            const torsoPoints = [\n                new THREE.Vector2(0.00,  0.96), // center bottom cap \u2014 pelvis bottom\n                new THREE.Vector2(0.09,  0.96), // inner pelvis\n                new THREE.Vector2(0.18,  0.99), // pelvis curve\n                new THREE.Vector2(0.24,  1.04), // pelvis outer\n                new THREE.Vector2(0.27,  1.10), // hip flare \u2014 surrounds leg tops\n                new THREE.Vector2(0.29,  1.18), // upper hip \u2014 wider to embrace leg tops\n                new THREE.Vector2(0.28,  1.32), // lower torso\n                new THREE.Vector2(0.28,  1.48), // mid torso\n                new THREE.Vector2(0.28,  1.62), // upper-mid torso\n                new THREE.Vector2(0.30,  1.75), // slight chest swell\n                new THREE.Vector2(0.31,  1.85), // chest\n                new THREE.Vector2(0.30,  1.93), // upper chest\n                new THREE.Vector2(0.24,  2.02), // trapezius slope\n                new THREE.Vector2(0.17,  2.06), // trap narrows\n                new THREE.Vector2(0.14,  2.08), // gentle concave curve into neck\n                new THREE.Vector2(0.10,  2.10), // neck base \u2014 exact match to neck profile bottom\n                new THREE.Vector2(0.00,  2.10), // top center cap \u2014 closes the opening so top is solid\n            ];\n            const torsoGeo = new THREE.LatheGeometry(torsoPoints, 64);\n\n            // Deform torso vertices: front bows outward, back curves inward\n            // This modifies the geometry directly so front/back are asymmetric\n            // while the torso top stays perfectly aligned with the neck base at y=2.10\n            const posAttr = torsoGeo.attributes.position;\n            const tYMin = 0.88, tYMax = 2.10;\n            for (let vi = 0; vi < posAttr.count; vi++) {\n                const y = posAttr.getY(vi);\n                const z = posAttr.getZ(vi);\n                const t = Math.max(0, Math.min(1, (y - tYMin) / (tYMax - tYMin)));\n                const bow = 0.22 * Math.sin(t * Math.PI); // peak bow in mid-torso, zero at top/bottom\n                if (z > 0.001) {\n                    posAttr.setZ(vi, z * (1 + bow)); // front bows outward\n                } else if (z < -0.001) {\n                    posAttr.setZ(vi, z * (1 - bow * 0.55)); // back curves inward (flatter)\n                }\n            }\n            torsoGeo.computeVertexNormals();\n\n            // Shoulder blend deformation \u2014 push torso side vertices outward at shoulder\n            // height so arms grow organically from the torso with zero visible seam.\n            // Uses a wide vertical bell + broad angular spread so the shoulder cap\n            // envelops the arm pivot completely.\n            {\n                const tPos2 = torsoGeo.attributes.position;\n                for (let vi = 0; vi < tPos2.count; vi++) {\n                    const x = tPos2.getX(vi);\n                    const y = tPos2.getY(vi);\n                    const z = tPos2.getZ(vi);\n                    const r = Math.sqrt(x * x + z * z);\n                    if (r < 0.001) continue;\n\n                    // Vertical envelope: wide shoulder zone y \u2208 [1.82, 2.26], peak at y = 2.04\n                    const tEnv = 1.0 - Math.abs(y - 2.04) / 0.22;\n                    if (tEnv <= 0) continue;\n                    const vEnv = Math.sin(tEnv * Math.PI * 0.5); // smooth 0\u21921 ramp\n\n                    // Angular spread: strongest at pure \u00b1X side (az=\u00b190\u00b0).\n                    // Use a softer power (sin^1.4 instead of sin^2) so the push\n                    // bleeds forward and backward \u2014 arm socket is fully wrapped.\n                    const az = Math.atan2(x, z);\n                    const sinAz = Math.sin(az);\n                    const sideFactor = Math.pow(Math.abs(sinAz), 1.4) * Math.sign(sinAz) * Math.sign(sinAz);\n\n                    // Larger push (0.085 \u2192 0.18) so shoulder visually envelops arm top\n                    const push = 0.18 * sideFactor * vEnv;\n                    if (Math.abs(push) < 0.001) continue;\n                    const newR = r + push;\n                    tPos2.setX(vi, x * (newR / r));\n                    // Also nudge Z slightly so shoulder wraps forward around arm\n                    const zWrap = 0.04 * sideFactor * vEnv;\n                    tPos2.setZ(vi, z + zWrap * Math.sign(z === 0 ? 1 : z));\n                }\n                tPos2.needsUpdate = true;\n                torsoGeo.computeVertexNormals();\n            }\n\n            const torsoMesh = new THREE.Mesh(torsoGeo, bodyMat);\n            torsoMesh.scale.z = 0.58; // flatten front-to-back while keeping left-right width\n            torsoMesh.position.y = 0.00; // sit torso right at ground of pelvis so hip caps overlap cleanly\n            torsoMesh.castShadow = true;\n            group.add(torsoMesh);\n\n            // --- ARM: smooth LatheGeometry shaft + merged fist ---\n            // Profile runs from shoulder (y=0) down to wrist (y=-0.90).\n            // Key landmarks: deltoid flare, bicep peak, elbow narrowing, forearm swell, wrist taper.\n            const armProfilePoints = [\n                new THREE.Vector2(0.00,   0.00),  // top cap center\n                new THREE.Vector2(0.100,  0.00),  // shoulder rim\n                new THREE.Vector2(0.118, -0.03),  // deltoid flare\n                new THREE.Vector2(0.108, -0.14),  // upper arm\n                new THREE.Vector2(0.098, -0.25),  // bicep peak\n                new THREE.Vector2(0.090, -0.36),  // bicep lower\n                new THREE.Vector2(0.082, -0.47),  // elbow\n                new THREE.Vector2(0.078, -0.52),  // post-elbow\n                new THREE.Vector2(0.086, -0.61),  // forearm swell\n                new THREE.Vector2(0.076, -0.73),  // forearm mid\n                new THREE.Vector2(0.060, -0.84),  // forearm taper\n                new THREE.Vector2(0.053, -0.91),  // wrist\n                new THREE.Vector2(0.00,  -0.91),  // bottom cap\n            ];\n            const armShaftGeo = new THREE.LatheGeometry(armProfilePoints, 36);\n\n            // Fist \u2014 squashed sphere + knuckle ridge, merged\n            const fistParts = [\n                { geo: new THREE.SphereGeometry(0.092, 16, 16), pos: {x:0, y:-1.01, z:0.02}, scl: {x:0.88, y:0.68, z:1.05} },\n                { geo: new THREE.SphereGeometry(0.060, 12, 12), pos: {x:0, y:-1.00, z:0.10}, scl: {x:0.92, y:0.38, z:0.65} },\n            ];\n            const fistGeo = mergeGeos(fistParts);\n            const armGeo = THREE.BufferGeometryUtils.mergeBufferGeometries([armShaftGeo, fistGeo]);\n\n            const armMat = new THREE.MeshPhongMaterial({ color: headColor, shininess: 80, specular: 0x88bbff, side: THREE.DoubleSide });\n\n            // Left Arm \u2014 pivot placed at shoulder blend point\n            const leftArm = new THREE.Group();\n            const leftArmMesh = new THREE.Mesh(armGeo.clone(), armMat);\n            leftArmMesh.castShadow = true;\n            leftArm.add(leftArmMesh);\n            leftArm.position.set(-0.27, 1.98, 0);\n            group.add(leftArm);\n\n            // Right Arm \u2014 pivot placed at centre of shoulder cap\n            const rightArm = new THREE.Group();\n            const rightArmMesh = new THREE.Mesh(armGeo.clone(), armMat);\n            rightArmMesh.castShadow = true;\n            rightArm.add(rightArmMesh);\n            rightArm.position.set(0.27, 1.98, 0);\n            group.add(rightArm);\n\n            // --- UPPER LEG geometry: smooth LatheGeometry profile ---\n            // Profile runs from hip (y=0) down to knee top (y=-0.48).\n            // Captures hip socket flare, thigh mass, quad taper, and knee narrowing\n            // as one seamless lathed surface \u2014 no merged primitive seams.\n            const upperLegProfilePoints = [\n                new THREE.Vector2(0.00,   0.00),  // top center cap\n                new THREE.Vector2(0.115,  0.00),  // hip socket rim\n                new THREE.Vector2(0.142, -0.04),  // hip flare\n                new THREE.Vector2(0.145, -0.10),  // upper thigh peak\n                new THREE.Vector2(0.138, -0.18),  // thigh mid\n                new THREE.Vector2(0.130, -0.27),  // lower thigh\n                new THREE.Vector2(0.118, -0.35),  // thigh taper\n                new THREE.Vector2(0.105, -0.42),  // pre-knee\n                new THREE.Vector2(0.098, -0.48),  // knee top\n                new THREE.Vector2(0.00,  -0.48),  // bottom center cap\n            ];\n            const upperLegGeo = new THREE.LatheGeometry(upperLegProfilePoints, 40);\n\n            // --- LOWER LEG geometry (pivot at knee joint, y=0 at knee, extends downward) ---\n            // Single LatheGeometry \u2014 top radius 0.098 exactly matches upper leg bottom radius\n            // so the knee area blends seamlessly with no visible seam or separate sphere.\n            const lowerLegProfilePoints = [\n                new THREE.Vector2(0.00,   0.00),  // top center cap\n                new THREE.Vector2(0.098,  0.00),  // knee top \u2014 exact match to upper leg bottom\n                new THREE.Vector2(0.112, -0.03),  // knee widening\n                new THREE.Vector2(0.118, -0.07),  // knee mid \u2014 broadest point\n                new THREE.Vector2(0.108, -0.13),  // below knee, starts narrowing\n                new THREE.Vector2(0.110, -0.20),  // upper shin / calf swell (radial average; z-bow adds asymmetry)\n                new THREE.Vector2(0.102, -0.29),  // mid shin\n                new THREE.Vector2(0.090, -0.38),  // lower shin\n                new THREE.Vector2(0.078, -0.47),  // shin taper\n                new THREE.Vector2(0.072, -0.55),  // ankle\n                new THREE.Vector2(0.00,  -0.55),  // bottom center cap\n            ];\n            const lowerLegGeo = new THREE.LatheGeometry(lowerLegProfilePoints, 40);\n\n            // Asymmetric deformation: kneecap bump (front, top) + calf bulge (back, lower)\n            {\n                const llPos = lowerLegGeo.attributes.position;\n                const llYMin = -0.55, llYMax = 0.00;\n                for (let vi = 0; vi < llPos.count; vi++) {\n                    const y = llPos.getY(vi);\n                    const z = llPos.getZ(vi);\n                    const t = Math.max(0, Math.min(1, (y - llYMin) / (llYMax - llYMin)));\n\n                    // Calf bulge: back side, peaks around t=0.70 (mid-lower leg)\n                    const calfBow = 0.22 * Math.sin(Math.pow(t, 0.7) * Math.PI);\n\n                    // Kneecap bump: front side only, strongest at very top (t\u22481.0),\n                    // fades out smoothly by t=0.72 so it blends into the shin with no hard edge.\n                    // envelope: sin curve so it's 0 at t=1 (cap center), peaks ~t=0.88, gone by t=0.72\n                    const kneeBumpT = Math.max(0, (t - 0.72) / (1.0 - 0.72)); // 0\u21921 in top 28% of leg\n                    const kneeBow = 0.34 * Math.sin(kneeBumpT * Math.PI); // smooth hill, zero at both ends\n\n                    if (z < -0.001) {\n                        // Back = calf\n                        llPos.setZ(vi, z * (1 + calfBow));\n                    } else if (z > 0.001) {\n                        // Front = kneecap bump + slight tibial ridge\n                        llPos.setZ(vi, z * (1 + kneeBow + calfBow * 0.18));\n                    }\n                }\n                llPos.needsUpdate = true;\n                lowerLegGeo.computeVertexNormals();\n            }\n\n            // --- FOOT geometry (pivot at ankle joint, y=0 at ankle) ---\n            const footParts = [\n                // Main foot / shoe body (elongated forward)\n                { geo: new THREE.SphereGeometry(0.130, 16, 16), pos: {x:0, y:-0.04, z:0.09}, scl: {x:0.80, y:0.48, z:1.55} },\n                // Heel\n                { geo: new THREE.SphereGeometry(0.090, 12, 12), pos: {x:0, y:-0.04, z:-0.045}, scl: {x:0.82, y:0.48, z:0.80} },\n                // Toe cap (front of shoe)\n                { geo: new THREE.SphereGeometry(0.078, 12, 12), pos: {x:0, y:-0.03, z:0.17}, scl: {x:0.88, y:0.62, z:0.70} },\n            ];\n            const footGeo = mergeGeos(footParts);\n\n            // Flatten the sole \u2014 clamp every vertex below the cut plane to that plane\n            {\n                const fp = footGeo.attributes.position;\n                const footFloorY = -0.062; // flat sole cut plane (in foot-group local space)\n                for (let vi = 0; vi < fp.count; vi++) {\n                    if (fp.getY(vi) < footFloorY) fp.setY(vi, footFloorY);\n                }\n                fp.needsUpdate = true;\n                footGeo.computeVertexNormals();\n            }\n\n            // Hip blend spheres \u2014 small flattened ellipsoids placed exactly at each\n            // hip socket to organically cover the torso-to-leg seam.\n            const hipBlendGeo = new THREE.SphereGeometry(0.145, 24, 16);\n            const leftHipBlend = new THREE.Mesh(hipBlendGeo, bodyMat);\n            leftHipBlend.scale.set(1.05, 0.52, 0.80);\n            leftHipBlend.position.set(-0.13, 1.16, 0);\n            leftHipBlend.castShadow = true;\n            group.add(leftHipBlend);\n\n            const rightHipBlend = new THREE.Mesh(hipBlendGeo.clone(), bodyMat);\n            rightHipBlend.scale.set(1.05, 0.52, 0.80);\n            rightHipBlend.position.set(0.13, 1.16, 0);\n            rightHipBlend.castShadow = true;\n            group.add(rightHipBlend);\n\n            // ---- Build left/right leg groups ----\n            // Hip pivot y=1.02, Knee pivot y=0.54, Ankle pivot y=0.14\n\n            // Leg pivot positions \u2014 sized so total leg height matches torso (1.10 units).\n            // Hip y=1.02, knee y=0.40, ankle y=-0.08. Player group raised +0.12 so feet sit at world y\u22480.04.\n            const HIP_Y    =  1.16;\n            const KNEE_Y   =  0.38;\n            const ANKLE_Y  = -0.10;\n\n            // Left Upper Leg\n            const leftUpperLeg = new THREE.Group();\n            const leftUpperLegMesh = new THREE.Mesh(upperLegGeo.clone(), bodyMat);\n            leftUpperLegMesh.scale.set(1, 1.65, 1); // stretch thigh to fill hip\u2192knee gap\n            leftUpperLegMesh.castShadow = true;\n            leftUpperLeg.add(leftUpperLegMesh);\n            leftUpperLeg.position.set(-0.13, HIP_Y, 0);\n            group.add(leftUpperLeg);\n\n            // Left Lower Leg\n            const leftLowerLeg = new THREE.Group();\n            const leftLowerLegMesh = new THREE.Mesh(lowerLegGeo.clone(), bodyMat);\n            leftLowerLegMesh.scale.set(1, 1.42, 1); // stretch shin to fill knee\u2192ankle gap\n            leftLowerLegMesh.castShadow = true;\n            leftLowerLeg.add(leftLowerLegMesh);\n            leftLowerLeg.position.set(-0.13, KNEE_Y, 0);\n            group.add(leftLowerLeg);\n\n            // Left Foot\n            const leftFoot = new THREE.Group();\n            const leftFootMesh = new THREE.Mesh(footGeo.clone(), bodyMat);\n            leftFootMesh.castShadow = true;\n            leftFoot.add(leftFootMesh);\n            leftFoot.position.set(-0.13, ANKLE_Y, 0);\n            group.add(leftFoot);\n\n            // Right Upper Leg\n            const rightUpperLeg = new THREE.Group();\n            const rightUpperLegMesh = new THREE.Mesh(upperLegGeo.clone(), bodyMat);\n            rightUpperLegMesh.scale.set(1, 1.65, 1);\n            rightUpperLegMesh.castShadow = true;\n            rightUpperLeg.add(rightUpperLegMesh);\n            rightUpperLeg.position.set(0.13, HIP_Y, 0);\n            group.add(rightUpperLeg);\n\n            // Right Lower Leg\n            const rightLowerLeg = new THREE.Group();\n            const rightLowerLegMesh = new THREE.Mesh(lowerLegGeo.clone(), bodyMat);\n            rightLowerLegMesh.scale.set(1, 1.42, 1);\n            rightLowerLegMesh.castShadow = true;\n            rightLowerLeg.add(rightLowerLegMesh);\n            rightLowerLeg.position.set(0.13, KNEE_Y, 0);\n            group.add(rightLowerLeg);\n\n            // Right Foot\n            const rightFoot = new THREE.Group();\n            const rightFootMesh = new THREE.Mesh(footGeo.clone(), bodyMat);\n            rightFootMesh.castShadow = true;\n            rightFoot.add(rightFootMesh);\n            rightFoot.position.set(0.13, ANKLE_Y, 0);\n            group.add(rightFoot);\n\n            return {\n                group,\n                bodyMesh: bodyMesh,\n                limbs: {\n                    leftArm: { obj: leftArm, restPos: new THREE.Vector3(-0.27, 1.98, 0), restRot: new THREE.Euler(0,0,0), type: 'arm' },\n                    rightArm: { obj: rightArm, restPos: new THREE.Vector3(0.27, 1.98, 0), restRot: new THREE.Euler(0,0,0), type: 'arm' },\n                    leftUpperLeg:  { obj: leftUpperLeg,  restPos: new THREE.Vector3(-0.13, 1.16,    0), restRot: new THREE.Euler(0,0,0), type: 'leg' },\n                    leftLowerLeg:  { obj: leftLowerLeg,  restPos: new THREE.Vector3(-0.13, KNEE_Y,  0), restRot: new THREE.Euler(0,0,0), type: 'leg' },\n                    leftFoot:      { obj: leftFoot,      restPos: new THREE.Vector3(-0.13, ANKLE_Y, 0), restRot: new THREE.Euler(0,0,0), type: 'leg' },\n                    rightUpperLeg: { obj: rightUpperLeg, restPos: new THREE.Vector3( 0.13, 1.16,    0), restRot: new THREE.Euler(0,0,0), type: 'leg' },\n                    rightLowerLeg: { obj: rightLowerLeg, restPos: new THREE.Vector3( 0.13, KNEE_Y,  0), restRot: new THREE.Euler(0,0,0), type: 'leg' },\n                    rightFoot:     { obj: rightFoot,     restPos: new THREE.Vector3( 0.13, ANKLE_Y, 0), restRot: new THREE.Euler(0,0,0), type: 'leg' }\n                }\n            };\n        }\n\n        // Player \u2014 raised 0.12 so feet clear the ground with extended leg length\n        const player = createCharacter(0x4488ff, true);\n        player.group.position.set(0, 0.14, 0);\n        scene.add(player.group);\n\n        // Highlight material for selected limb\n        const highlightMat = new THREE.MeshPhongMaterial({ color: 0xffff44, emissive: 0x444400, shininess: 60 });\n\n        let modelViewerActive = false;\n\n        // Tutorial state (interactive + no text)\n        let tutorialStep = 0; // 0 = drag, 1 = flick\n        const TUTORIAL_STEPS = [\n            { icon: '\u261d\ufe0f', anim: 'drag' },\n            { icon: '\u26a1', anim: 'flick' }\n        ];\n        let tutorialDragProgress = 0;\n        let tutorialJustCompletedAt = 0;\n\n        // Game state\n        let gameState = 'mainMenu';\n        let playerHealth = config.playerHealth;\n        let maxPlayerHealth = config.playerHealth;\n        let currentWave = 1;\n        let totalWaves = config.numWaves;\n        let enemies = [];\n        let particles = [];\n\n        let bgMusic = null;\n        let prevRenderedState = null;\n\n        // === COMBO SYSTEM ===\n        let currentCombo = 0;        // consecutive first-shot goals across courses\n        let shotsTakenThisCourse = 0; // how many flick shots fired this course\n        let comboToastTimer = 0;      // seconds remaining for combo toast display\n        let comboToastCombo = 0;      // combo value displayed in toast\n        let hintVisible = false;\n        let hintTimer = 0;\n        let playerInvulnerable = 0;\n        let waveTransition = 0;\n        let enemiesDefeatedThisWave = 0;\n        let totalEnemiesThisWave = 0;\n\n        // Raycaster for limb picking\n        const raycaster = new THREE.Raycaster();\n        const mouse = new THREE.Vector2();\n\n\n        function spawnEnemy(x, z) {\n            const enemy = createCharacter(0x664444, false);\n            enemy.group.position.set(x, 0, z);\n            scene.add(enemy.group);\n            enemies.push({\n                ...enemy,\n                health: config.enemyHealth,\n                maxHealth: config.enemyHealth,\n                knockback: new THREE.Vector3(),\n                stunTimer: 0,\n                attackCooldown: 0\n            });\n        }\n\n        function spawnWave(waveNum) {\n            const count = 1;\n            totalEnemiesThisWave = count;\n            enemiesDefeatedThisWave = 0;\n            for (let i = 0; i < count; i++) {\n                const angle = (i / count) * Math.PI * 2;\n                const dist = 9 + Math.random() * 2;\n                spawnEnemy(Math.cos(angle) * dist, Math.sin(angle) * dist);\n            }\n        }\n\n        function createParticle(pos, color) {\n            const geo = new THREE.SphereGeometry(0.1, 6, 6);\n            const mat = new THREE.MeshBasicMaterial({ color });\n            const mesh = new THREE.Mesh(geo, mat);\n            mesh.position.copy(pos);\n            scene.add(mesh);\n            particles.push({\n                mesh,\n                vel: new THREE.Vector3((Math.random()-0.5)*4, Math.random()*3+2, (Math.random()-0.5)*4),\n                life: 1.0\n            });\n        }\n\n        function createImpactEffect(pos, color, count = 8) {\n            for (let i = 0; i < count; i++) createParticle(pos, color);\n        }\n\n        // Forward Kinematics leg updater \u2014 repositions lower leg and foot\n        // each frame so they stay connected to the upper leg pivot.\n        const UPPER_LEG_LEN = 0.48 * 1.65; // 0.792 \u2014 upper leg scaled height\n        const LOWER_LEG_LEN = 0.55 * 1.42; // 0.781 \u2014 lower leg scaled height\n        const HIP_Y_ANIM    = 1.16;         // hip pivot Y in character group space\n\n        function updateLegFK(limbs, side, upperAngle, lowerBend) {\n            const sign = side === 'left' ? -1 : 1;\n            const hipX  = sign * 0.13;\n\n            // Upper leg \u2014 pivot stays at hip\n            limbs[side + 'UpperLeg'].obj.position.set(hipX, HIP_Y_ANIM, 0);\n            limbs[side + 'UpperLeg'].obj.rotation.x = upperAngle;\n\n            // Knee = hip + upper-leg vector rotated by upperAngle around X\n            const kneeY = HIP_Y_ANIM - UPPER_LEG_LEN * Math.cos(upperAngle);\n            const kneeZ =              UPPER_LEG_LEN * Math.sin(upperAngle);\n            limbs[side + 'LowerLeg'].obj.position.set(hipX, kneeY, kneeZ);\n            limbs[side + 'LowerLeg'].obj.rotation.x = lowerBend;\n\n            // Ankle = knee + lower-leg vector rotated by lowerBend around X\n            const ankleY = kneeY - LOWER_LEG_LEN * Math.cos(lowerBend);\n            const ankleZ = kneeZ + LOWER_LEG_LEN * Math.sin(lowerBend);\n            limbs[side + 'Foot'].obj.position.set(hipX, ankleY, ankleZ);\n            // Foot counter-rotates so the sole stays roughly flat to the ground\n            limbs[side + 'Foot'].obj.rotation.x = -(upperAngle + lowerBend) * 0.45;\n        }\n\n        // UI\n        // Return preloaded image URL from cache (populated during loading screen)\n        function _getPBAsset(id) {\n            if (_preloadedImages[id]) return _preloadedImages[id].src;\n            // Fallback: try lib.getAsset if somehow not preloaded\n            const a = lib.getAsset(id);\n            return a ? a.url : null;\n        }\n\n        // === COMBO TOAST ===\n        let _comboToastEl = null;\n\n        function _ensureComboToast() {\n            if (!_comboToastEl) {\n                _comboToastEl = document.createElement('div');\n                _comboToastEl.id = 'combo-toast';\n                _comboToastEl.style.cssText = `\n                    position:absolute;\n                    top:50%;left:50%;\n                    transform:translate(-50%,-50%) scale(0.5);\n                    pointer-events:none;\n                    z-index:60;\n                    text-align:center;\n                    opacity:0;\n                    transition:opacity 0.18s ease, transform 0.22s cubic-bezier(0.34,1.56,0.64,1);\n                    font-family:'Fredoka One',cursive;\n                    line-height:1.1;\n                `;\n                uiOverlay.appendChild(_comboToastEl);\n            }\n            return _comboToastEl;\n        }\n\n        function _updateComboToast() {\n            const el = _ensureComboToast();\n            const c = comboToastCombo;\n            const maxTime = c >= 2 ? 3.2 : 2.2;\n            const fadeOutAt = 0.7;\n            const fadeIn = comboToastTimer > maxTime - 0.3;\n            const fadeOut = comboToastTimer < fadeOutAt;\n            const alpha = fadeOut ? (comboToastTimer / fadeOutAt) : 1.0;\n\n            // Colors escalate with combo\n            const colors = ['#fff','#5bdf3b','#ffdd00','#ff8c00','#ff4444','#cc44ff'];\n            const glows  = ['none','#5bdf3b','#ffd000','#ff6600','#ff2222','#aa00ff'];\n            const ci = Math.min(c, colors.length - 1);\n\n            // Label\n            const label = c >= 5 ? '\ud83d\udd25 INSANE!!' : c >= 4 ? '\ud83d\udd25 UNSTOPPABLE!' : c >= 3 ? '\u26a1 ON FIRE!' : c >= 2 ? '\ud83c\udfaf COMBO!' : '\u2728 FIRST SHOT!';\n            const sub   = c >= 2 ? `${c}x STREAK` : 'NICE ONE!';\n            const sz    = c >= 3 ? 64 : c >= 2 ? 58 : 50;\n\n            el.innerHTML = `\n                <div style=\"font-size:${sz}px;color:${colors[ci]};\n                    text-shadow:-3px -3px 0 #000,3px -3px 0 #000,-3px 3px 0 #000,3px 3px 0 #000;\n                    filter:drop-shadow(0 0 ${c >= 3 ? 22 : 10}px ${colors[ci]});\n                    letter-spacing:1px;\">${label}</div>\n                <div style=\"font-size:${sz * 0.55}px;color:#fff;\n                    text-shadow:-2px -2px 0 #000,2px -2px 0 #000,-2px 2px 0 #000,2px 2px 0 #000;\n                    margin-top:4px;\">${sub}</div>\n            `;\n            el.style.opacity = alpha;\n            el.style.transform = `translate(-50%,-50%) scale(${fadeIn ? 1.18 : (fadeOut ? 0.85 + alpha*0.15 : 1.0)})`;\n        }\n\n        function _hideComboToast() {\n            if (_comboToastEl) {\n                _comboToastEl.style.opacity = '0';\n                _comboToastEl.style.transform = 'translate(-50%,-50%) scale(0.7)';\n            }\n        }\n\n        function renderUI() {\n            prevRenderedState = gameState;\n            let html = '';\n\n            // Shared progress bar helpers \u2014 available to all game states\n            const urlCompleted = _getPBAsset('ui_course_completed');\n            const urlCurrent   = _getPBAsset('ui_course_current');\n            const urlUpcoming  = _getPBAsset('ui_course_upcoming');\n            const urlLevelBox  = _getPBAsset('ui_level_box');\n            const fbCompleted = '#7ED321';\n            const fbCurrent   = '#C0C0C0';\n            const fbUpcoming  = '#D8D8D8';\n\n            function levelBox(num, faded) {\n                const opacity = faded ? 'opacity:0.45;' : '';\n                if (urlLevelBox) {\n                    return `<div style=\"position:relative;width:54px;height:54px;flex-shrink:0;${opacity}\">\n                        <img src=\"${urlLevelBox}\" style=\"width:100%;height:100%;display:block;\" />\n                        <span style=\"position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:27px;font-weight:700;color:#333;font-family:'Fredoka One',cursive;text-shadow:-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000,1px 1px 0 #000;\">${num}</span>\n                    </div>`;\n                }\n                return `<div style=\"width:54px;height:54px;border-radius:14px;background:#D0D0D0;border:3px solid #A8A8A8;display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:700;color:#333;font-family:'Fredoka One',cursive;flex-shrink:0;${opacity}\">${num}</div>`;\n            }\n\n            function courseSquare(state, animated) {\n                const SZ = '42px';\n                const animStyle = animated\n                    ? 'animation:squarePop 0.55s cubic-bezier(0.36,0.07,0.19,0.97) both,squareGlow 0.7s ease both;'\n                    : '';\n                if (state === 'completed' && urlCompleted) {\n                    return `<img src=\"${urlCompleted}\" style=\"width:${SZ};height:${SZ};flex-shrink:0;display:block;${animStyle}\" />`;\n                } else if (state === 'current' && urlCurrent) {\n                    return `<img src=\"${urlCurrent}\" style=\"width:${SZ};height:${SZ};flex-shrink:0;display:block;\" />`;\n                } else if (state === 'upcoming' && urlUpcoming) {\n                    return `<img src=\"${urlUpcoming}\" style=\"width:${SZ};height:${SZ};flex-shrink:0;display:block;\" />`;\n                }\n                const bg = state === 'completed' ? fbCompleted : state === 'current' ? fbCurrent : fbUpcoming;\n                const inner = state === 'current' ? `<div style=\"width:14px;height:14px;background:#888;border-radius:4px;\"></div>` : '';\n                return `<div style=\"width:${SZ};height:${SZ};border-radius:10px;background:${bg};border:3px solid rgba(0,0,0,0.15);display:flex;align-items:center;justify-content:center;flex-shrink:0;${animStyle}\">${inner}</div>`;\n            }\n\n            const connector = `<div style=\"width:30px;height:5px;background:#A0A0A0;border-radius:3px;flex-shrink:0;margin:0 2px;\"></div>`;\n\n            function buildProgressBar(completedUpTo, newlyCompleted = -1) {\n                // completedUpTo: courses with index <= completedUpTo are shown as completed\n                const totalBigLevelsUI = config.levels ? config.levels.length : 1;\n                const currentLevelNum = currentBigLevel + 1;\n                const nextLevelNum = currentBigLevel + 2;\n                const hasNextLevel = currentBigLevel + 1 < totalBigLevelsUI;\n                let squares = '';\n                for (let ci = 0; ci < COURSES_PER_LEVEL; ci++) {\n                    if (ci > 0) squares += connector;\n                    const state = ci <= completedUpTo ? 'completed' : 'upcoming';\n                    const animated = (state === 'completed' && ci === newlyCompleted);\n                    squares += courseSquare(state, animated);\n                }\n                return `<div style=\"display:flex;align-items:center;gap:0;padding:0;filter:drop-shadow(0 3px 8px rgba(0,0,0,0.28));\">\n                    ${levelBox(currentLevelNum, false)}\n                    ${connector}\n                    ${squares}\n                    ${connector}\n                    ${levelBox(hasNextLevel ? nextLevelNum : '\ud83c\udfc6', !hasNextLevel)}\n                </div>`;\n            }\n\n            // Progress bar always visible at top (not on main menu)\n            if (gameState !== 'mainMenu') {\n            const pbCompletedUpTo = (gameState === 'levelComplete')\n                ? COURSES_PER_LEVEL - 1\n                : (gameState === 'courseComplete' ? currentCourse : currentCourse - 1);\n            const pbNewly = (gameState === 'courseComplete') ? currentCourse\n                : (gameState === 'levelComplete') ? COURSES_PER_LEVEL - 1 : -1;\n            html += `<div style=\"position:absolute;top:16px;left:0;right:0;display:flex;justify-content:center;pointer-events:none;z-index:30;\">\n                ${buildProgressBar(pbCompletedUpTo, pbNewly)}\n            </div>`;\n            }\n\n            if (gameState === 'mainMenu') {\n                // Main menu overlay \u2014 live 3D renders behind it\n                const logoUrl  = menuAssets['menu_title_logo']  ? menuAssets['menu_title_logo'].src  : null;\n                const playUrl  = menuAssets['menu_btn_play']    ? menuAssets['menu_btn_play'].src    : null;\n                const setUrl   = menuAssets['menu_btn_settings']? menuAssets['menu_btn_settings'].src: null;\n                const custUrl  = menuAssets['menu_btn_customize']?menuAssets['menu_btn_customize'].src:null;\n                const lbUrl    = menuAssets['menu_btn_leaderboard']?menuAssets['menu_btn_leaderboard'].src:null;\n\n                const logoHtml = logoUrl\n                    ? `<img src=\"${logoUrl}\" style=\"width:520px;max-width:88%;filter:drop-shadow(0 6px 28px rgba(0,0,0,0.7));margin-bottom:12px;\" />`\n                    : `<div style=\"font-size:52px;font-family:'Fredoka One',cursive;color:#fff;text-shadow:-3px -3px 0 #000,3px -3px 0 #000,-3px 3px 0 #000,3px 3px 0 #000;margin-bottom:16px;\">\u26bd Crazy Shot</div>`;\n\n                function menuBtn(id, url, label, emoji, w, h, bg) {\n                    if (url) return `<img id=\"${id}\" src=\"${url}\" style=\"width:${w}px;max-width:${w}px;height:${h}px;object-fit:contain;cursor:pointer;transition:transform 120ms,filter 120ms;filter:drop-shadow(0 5px 14px rgba(0,0,0,0.55));\" onmouseenter=\"this.style.transform='scale(1.07)'\" onmouseleave=\"this.style.transform='scale(1)'\" ontouchstart=\"this.style.transform='scale(0.95)'\" ontouchend=\"this.style.transform='scale(1)'\" />`;\n                    return `<div id=\"${id}\" style=\"background:${bg};border-radius:18px;padding:18px 32px;font-size:22px;font-family:'Fredoka One',cursive;color:#fff;cursor:pointer;min-width:${w}px;text-align:center;box-shadow:0 6px 18px rgba(0,0,0,0.45);transition:transform 120ms;\" onmouseenter=\"this.style.transform='scale(1.07)'\" onmouseleave=\"this.style.transform='scale(1)'\">${emoji} ${label}</div>`;\n                }\n\n                html += `\n                <div id=\"main-menu-overlay\" style=\"\n                    position:absolute;top:0;left:0;width:100%;height:100%;\n                    background:linear-gradient(180deg,rgba(0,0,0,0.55) 0%,rgba(0,0,0,0.25) 50%,rgba(0,0,0,0.62) 100%);\n                    display:flex;flex-direction:column;align-items:center;justify-content:flex-end;\n                    padding-bottom:80px;box-sizing:border-box;\n                    z-index:25;cursor:pointer;\n                \" id=\"menu-bg-click\">\n                    <div style=\"position:absolute;top:80px;left:0;right:0;display:flex;justify-content:center;pointer-events:none;\">\n                        ${logoHtml}\n                    </div>\n                    <div style=\"display:flex;flex-direction:column;align-items:center;gap:18px;pointer-events:auto;\" onclick=\"event.stopPropagation()\">\n                        ${menuBtn('menu-play-btn', playUrl, 'PLAY', '\u26bd', 380, 100, 'linear-gradient(135deg,#5bdf3b,#3aaf1a)')}\n                        <div style=\"display:flex;gap:20px;justify-content:center;\">\n                            ${menuBtn('menu-settings-btn', setUrl, 'SETTINGS', '\u2699\ufe0f', 170, 130, 'linear-gradient(135deg,#4a80c4,#2a5090)')}\n                            ${menuBtn('menu-customize-btn', custUrl, 'CUSTOMIZE', '\ud83c\udfa8', 170, 130, 'linear-gradient(135deg,#b04ed6,#7a1fa0)')}\n                            ${menuBtn('menu-lb-btn', lbUrl, 'SCORES', '\ud83c\udfc6', 170, 130, 'linear-gradient(135deg,#e8a020,#b06000)')}\n                        </div>\n                        <div style=\"margin-top:6px;font-size:18px;font-family:'Fredoka One',cursive;color:rgba(255,255,255,0.7);text-shadow:0 1px 4px rgba(0,0,0,0.6);\">Tap anywhere to play!</div>\n                    </div>\n                </div>`;\n\n                // Settings sub-panel\n                html += `\n                <div id=\"menu-settings-panel\" style=\"display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.75);z-index:35;flex-direction:column;align-items:center;justify-content:center;\">\n                    <div style=\"background:linear-gradient(160deg,#1a2a4a,#0d1a30);border-radius:24px;padding:36px 44px;width:420px;max-width:88%;box-shadow:0 12px 40px rgba(0,0,0,0.7);border:2px solid rgba(255,255,255,0.12);\">\n                        <div style=\"font-size:36px;font-family:'Fredoka One',cursive;color:#fff;text-align:center;margin-bottom:24px;\">\u2699\ufe0f Settings</div>\n                        <div style=\"display:flex;flex-direction:column;gap:18px;\">\n                            <label style=\"color:#ccc;font-family:'Fredoka One',cursive;font-size:20px;\">\ud83d\udd0a Music Volume\n                                <input type=\"range\" id=\"s-music-vol\" min=\"0\" max=\"100\" value=\"45\" style=\"width:100%;margin-top:6px;\">\n                            </label>\n                            <label style=\"color:#ccc;font-family:'Fredoka One',cursive;font-size:20px;\">\ud83c\udfb5 SFX Volume\n                                <input type=\"range\" id=\"s-sfx-vol\" min=\"0\" max=\"100\" value=\"80\" style=\"width:100%;margin-top:6px;\">\n                            </label>\n                            <label style=\"color:#ccc;font-family:'Fredoka One',cursive;font-size:20px;display:flex;align-items:center;justify-content:space-between;\">\ud83d\udcf3 Screen Shake\n                                <input type=\"checkbox\" id=\"s-shake\" checked style=\"width:28px;height:28px;cursor:pointer;\">\n                            </label>\n                        </div>\n                        <button onclick=\"document.getElementById('menu-settings-panel').style.display='none'\" style=\"margin-top:28px;width:100%;padding:14px;background:linear-gradient(135deg,#4a80c4,#2a5090);border:none;border-radius:14px;font-size:22px;font-family:'Fredoka One',cursive;color:#fff;cursor:pointer;\">\u2705 Done</button>\n                    </div>\n                </div>`;\n\n                // Customize sub-panel\n                html += `\n                <div id=\"menu-customize-panel\" style=\"display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.75);z-index:35;flex-direction:column;align-items:center;justify-content:center;\">\n                    <div style=\"background:linear-gradient(160deg,#2a1a4a,#1a0d30);border-radius:24px;padding:36px 44px;width:440px;max-width:88%;box-shadow:0 12px 40px rgba(0,0,0,0.7);border:2px solid rgba(255,255,255,0.12);\">\n                        <div style=\"font-size:36px;font-family:'Fredoka One',cursive;color:#fff;text-align:center;margin-bottom:20px;\">\ud83c\udfa8 Customize</div>\n                        <div style=\"color:#ccc;font-family:'Fredoka One',cursive;font-size:19px;margin-bottom:16px;\">Player Color</div>\n                        <div style=\"display:flex;flex-wrap:wrap;gap:12px;justify-content:center;margin-bottom:22px;\">\n                            ${['#2277ff','#ff4444','#44cc44','#ff8800','#cc44cc','#ffffff','#ffdd00','#00cccc'].map((c,i)=>\n                                `<div class=\"color-swatch\" data-color=\"${c}\" style=\"width:52px;height:52px;border-radius:50%;background:${c};border:3px solid rgba(255,255,255,${i===0?'0.9':'0.3'});cursor:pointer;transition:transform 120ms,border 120ms;\" onmouseenter=\"this.style.transform='scale(1.15)'\" onmouseleave=\"this.style.transform='scale(1)'\"></div>`\n                            ).join('')}\n                        </div>\n                        <div style=\"color:#ccc;font-family:'Fredoka One',cursive;font-size:19px;margin-bottom:12px;\">Field Color</div>\n                        <div style=\"display:flex;flex-wrap:wrap;gap:12px;justify-content:center;\">\n                            ${['#78B84A','#4a9e6e','#5b7fa3','#a38b3a','#8b4a7a','#3a7a8b'].map((c,i)=>\n                                `<div class=\"field-swatch\" data-color=\"${c}\" style=\"width:52px;height:52px;border-radius:12px;background:${c};border:3px solid rgba(255,255,255,${i===0?'0.9':'0.3'});cursor:pointer;transition:transform 120ms;\" onmouseenter=\"this.style.transform='scale(1.15)'\" onmouseleave=\"this.style.transform='scale(1)'\"></div>`\n                            ).join('')}\n                        </div>\n                        <button onclick=\"document.getElementById('menu-customize-panel').style.display='none'\" style=\"margin-top:28px;width:100%;padding:14px;background:linear-gradient(135deg,#b04ed6,#7a1fa0);border:none;border-radius:14px;font-size:22px;font-family:'Fredoka One',cursive;color:#fff;cursor:pointer;\">\u2705 Done</button>\n                    </div>\n                </div>`;\n\n            } else if (gameState === 'tutorial') {\n                const isDrag = tutorialStep === 0;\n                const progPct = Math.min(100, Math.round(tutorialDragProgress * 100));\n                const RING_R = 52, RING_C = 64;\n                const circumference = 2 * Math.PI * RING_R;\n                const dashOffset = circumference * (1 - tutorialDragProgress);\n                html += `\n                <style>\n                    @keyframes tut-finger-drag {\n                        0%   { transform: translate(0px, 0px) scale(1.0); }\n                        15%  { transform: translate(0px, 0px) scale(0.92); }\n                        50%  { transform: translate(55px, -40px) scale(1.0); }\n                        85%  { transform: translate(90px, 5px) scale(1.0); }\n                        100% { transform: translate(0px, 0px) scale(1.0); }\n                    }\n                    @keyframes tut-ball-drag {\n                        0%   { transform: translate(0px, 0px); }\n                        15%  { transform: translate(0px, 0px); }\n                        50%  { transform: translate(55px, -40px); }\n                        85%  { transform: translate(90px, 5px); }\n                        100% { transform: translate(0px, 0px); }\n                    }\n                    @keyframes tut-finger-flick {\n                        0%   { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                        20%  { transform: translate(0px, 0px) scale(0.90); opacity:1; }\n                        55%  { transform: translate(-6px, -100px) scale(1.1); opacity:1; }\n                        65%  { transform: translate(-6px, -120px) scale(0.7); opacity:0; }\n                        66%  { transform: translate(0px, 0px) scale(1.0); opacity:0; }\n                        80%  { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                        100% { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                    }\n                    @keyframes tut-ball-flick {\n                        0%   { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                        25%  { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                        60%  { transform: translate(-5px, -80px) scale(0.80); opacity:0.9; }\n                        72%  { transform: translate(-8px, -145px) scale(0.45); opacity:0; }\n                        73%  { transform: translate(0px, 0px) scale(1.0); opacity:0; }\n                        86%  { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                        100% { transform: translate(0px, 0px) scale(1.0); opacity:1; }\n                    }\n                    @keyframes tut-streak {\n                        0%,35% { transform: scaleY(0) translateY(0); opacity:0; }\n                        55%    { transform: scaleY(1) translateY(-50px); opacity:0.9; }\n                        70%    { transform: scaleY(0.4) translateY(-130px); opacity:0; }\n                        100%   { transform: scaleY(0) translateY(0); opacity:0; }\n                    }\n                    @keyframes tut-goal-pulse {\n                        0%,100% { opacity:0.25; transform:scaleX(1); }\n                        50%     { opacity:0.7;  transform:scaleX(1.04); }\n                    }\n                    @keyframes tut-hand-pulse {\n                        0%,100% { transform: scale(1.0); }\n                        50%     { transform: scale(1.12); }\n                    }\n                </style>\n                <div style=\"position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:50;\">\n                    ${isDrag ? `\n                    <div style=\"position:absolute;bottom:220px;left:50%;transform:translateX(-50%);\n                        width:200px;height:120px;pointer-events:none;\">\n                        <div style=\"position:absolute;left:30px;top:28px;width:52px;height:52px;\n                            background:radial-gradient(circle at 35% 32%, #fff 0%, #e8e0d0 38%, #1a1a1a 100%);\n                            border-radius:50%;box-shadow:0 4px 12px rgba(0,0,0,0.55);\n                            animation:tut-ball-drag 2.4s ease-in-out infinite;\"></div>\n                        <div style=\"position:absolute;left:50px;top:48px;font-size:40px;\n                            animation:tut-finger-drag 2.4s ease-in-out infinite;\n                            filter:drop-shadow(0 2px 6px rgba(0,0,0,0.5));\">\u261d\ufe0f</div>\n                        <svg style=\"position:absolute;inset:0;width:100%;height:100%;opacity:0.22;\" viewBox=\"0 0 200 120\">\n                            <path d=\"M55,75 Q95,30 145,55\" stroke=\"white\" stroke-width=\"3.5\" fill=\"none\"\n                                stroke-dasharray=\"7 6\" stroke-linecap=\"round\"/>\n                        </svg>\n                    </div>\n                    <div style=\"position:absolute;bottom:80px;left:50%;transform:translateX(-50%);\n                        width:128px;height:128px;pointer-events:none;\">\n                        <svg width=\"128\" height=\"128\" viewBox=\"0 0 128 128\">\n                            <circle cx=\"${RING_C}\" cy=\"${RING_C}\" r=\"${RING_R}\" fill=\"none\"\n                                stroke=\"rgba(255,255,255,0.15)\" stroke-width=\"9\"/>\n                            <circle cx=\"${RING_C}\" cy=\"${RING_C}\" r=\"${RING_R}\" fill=\"none\"\n                                stroke=\"${progPct >= 100 ? '#5bdf3b' : '#fff'}\" stroke-width=\"9\"\n                                stroke-linecap=\"round\"\n                                stroke-dasharray=\"${circumference}\"\n                                stroke-dashoffset=\"${dashOffset}\"\n                                transform=\"rotate(-90 ${RING_C} ${RING_C})\"\n                                style=\"transition:stroke-dashoffset 0.15s ease,stroke 0.3s ease;\"/>\n                            <text x=\"${RING_C}\" y=\"${RING_C+6}\" text-anchor=\"middle\"\n                                font-family=\"Fredoka One,cursive\" font-size=\"26\"\n                                fill=\"${progPct >= 100 ? '#5bdf3b' : 'white'}\">${progPct >= 100 ? '\u2713' : '\u270b'}</text>\n                        </svg>\n                    </div>\n                    ` : `\n                    <div style=\"position:absolute;top:110px;left:50%;transform:translateX(-50%);\n                        width:180px;height:50px;pointer-events:none;\n                        animation:tut-goal-pulse 1.6s ease-in-out infinite;\">\n                        <svg width=\"180\" height=\"50\" viewBox=\"0 0 180 50\">\n                            <polygon points=\"90,4 170,46 10,46\" fill=\"rgba(255,220,0,0.75)\"\n                                stroke=\"rgba(255,255,255,0.5)\" stroke-width=\"2\"/>\n                        </svg>\n                    </div>\n                    <div style=\"position:absolute;bottom:200px;left:50%;transform:translateX(-50%);\n                        width:120px;height:180px;pointer-events:none;\">\n                        <div style=\"position:absolute;left:28px;top:90px;width:56px;height:56px;\n                            background:radial-gradient(circle at 35% 32%, #fff 0%, #e8e0d0 38%, #1a1a1a 100%);\n                            border-radius:50%;box-shadow:0 4px 14px rgba(0,0,0,0.55);\n                            animation:tut-ball-flick 2.2s ease-in-out infinite;\"></div>\n                        <div style=\"position:absolute;left:48px;top:20px;width:16px;height:70px;\n                            background:linear-gradient(to top, rgba(255,230,0,0.9), rgba(255,230,0,0));\n                            border-radius:8px;transform-origin:bottom center;\n                            animation:tut-streak 2.2s ease-in-out infinite;\"></div>\n                        <div style=\"position:absolute;left:34px;top:108px;font-size:42px;\n                            animation:tut-finger-flick 2.2s ease-in-out infinite;\n                            filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5));\">\u261d\ufe0f</div>\n                    </div>\n                    <div style=\"position:absolute;bottom:100px;left:50%;transform:translateX(-50%);\n                        font-size:64px;filter:drop-shadow(0 0 18px #ffdd00) drop-shadow(0 0 6px #ff8800);\n                        animation:tut-hand-pulse 1.1s ease-in-out infinite;pointer-events:none;\">\u26a1</div>\n                    `}\n                    <div style=\"position:absolute;bottom:38px;left:50%;transform:translateX(-50%);\n                        display:flex;gap:12px;pointer-events:none;\">\n                        ${TUTORIAL_STEPS.map((_, i) => `\n                            <div style=\"width:13px;height:13px;border-radius:50%;\n                                background:${i===tutorialStep?'#fff':'rgba(255,255,255,0.3)'};\n                                box-shadow:${i===tutorialStep?'0 0 8px rgba(255,255,255,0.8)':'none'};\n                                transition:background 0.3s;\"></div>\n                        `).join('')}\n                    </div>\n                    <div id=\"tut-skip-btn\" style=\"\n                        position:absolute;top:24px;right:24px;\n                        width:72px;height:72px;border-radius:50%;\n                        background:rgba(0,0,0,0.5);border:2px solid rgba(255,255,255,0.25);\n                        display:flex;align-items:center;justify-content:center;\n                        font-size:28px;cursor:pointer;pointer-events:auto;\n                        transition:background 150ms;z-index:60;\n                    \" onmouseenter=\"this.style.background='rgba(80,80,80,0.7)'\" onmouseleave=\"this.style.background='rgba(0,0,0,0.5)'\">\u2715</div>\n                </div>`;\n            } else if (gameState === 'playing') {\n                // nothing extra\n            } else if (gameState === 'courseComplete') {\n                // overlay without bar\n            } else if (gameState === 'levelComplete') {\n                // overlay without bar\n            } else if (gameState === 'defeat') {\n                const defeatMsg = window._defeatReason === 'tackle'\n                    ? { title: 'TACKLED! \ud83d\ude24', sub: 'The enemy stole the ball!' }\n                    : { title: 'OUT OF BOUNDS! \u274c', sub: 'The ball left the field!' };\n                html += `\n                <div class=\"screen-overlay\">\n                    <h1 class=\"defeat-title\">${defeatMsg.title}</h1>\n                    <p>${defeatMsg.sub}</p>\n                    <button class=\"btn btn-restart\" onclick=\"document.getElementById('restart-trigger').click()\">Try Again</button>\n                </div>`;\n            } else if (gameState === 'victory') {\n                html += `\n                <div class=\"screen-overlay\">\n                    <h1 class=\"victory-title\">YOU WIN! \ud83c\udfc6</h1>\n                    <p>All levels completed!</p>\n                    <button class=\"btn btn-primary\" onclick=\"document.getElementById('restart-trigger').click()\">Play Again</button>\n                </div>`;\n            }\n\n            html += `<button id=\"restart-trigger\" style=\"display:none\"></button>`;\n            uiOverlay.innerHTML = html;\n            // Re-attach combo toast (innerHTML wipe removes it)\n            _comboToastEl = null;\n\n            const restartBtn = document.getElementById('restart-trigger');\n            if (restartBtn) {\n                restartBtn.onclick = () => { restartGame(gameState === 'victory'); };\n            }\n\n            // Tutorial interactions\n            if (gameState === 'tutorial') {\n                const skipBtn = document.getElementById('tut-skip-btn');\n                if (skipBtn) skipBtn.addEventListener('click', completeTutorial);\n                if (skipBtn) skipBtn.addEventListener('touchend', (e) => { e.preventDefault(); completeTutorial(); });\n            }\n\n            // Main menu interactions\n            if (gameState === 'mainMenu') {\n                const bgClick = document.getElementById('menu-bg-click');\n                const playBtn = document.getElementById('menu-play-btn');\n                const settingsBtn = document.getElementById('menu-settings-btn');\n                const customizeBtn = document.getElementById('menu-customize-btn');\n                const lbBtn = document.getElementById('menu-lb-btn');\n                const settingsPanel = document.getElementById('menu-settings-panel');\n                const customizePanel = document.getElementById('menu-customize-panel');\n\n                function startGame() {\n                    tutorialStep = 0;\n                    tutorialDragProgress = 0;\n                    prevTutorialDragProgress = -1;\n                    gameState = 'tutorial';\n                    renderUI();\n                }\n\n                if (bgClick) bgClick.addEventListener('click', (e) => {\n                    if (e.target === bgClick) startGame();\n                });\n                if (playBtn) playBtn.addEventListener('click', startGame);\n\n                if (settingsBtn && settingsPanel) {\n                    settingsBtn.addEventListener('click', () => {\n                        settingsPanel.style.display = 'flex';\n                    });\n                    // Music volume\n                    const musicVol = document.getElementById('s-music-vol');\n                    if (musicVol && bgMusic) {\n                        musicVol.addEventListener('input', () => {\n                            if (bgMusic) bgMusic.gain.gain.value = musicVol.value / 100 * 0.9;\n                        });\n                    }\n                }\n\n                if (customizeBtn && customizePanel) {\n                    customizeBtn.addEventListener('click', () => {\n                        customizePanel.style.display = 'flex';\n                    });\n                    // Color swatches\n                    document.querySelectorAll('.color-swatch').forEach(sw => {\n                        sw.addEventListener('click', () => {\n                            const col = parseInt(sw.dataset.color.replace('#',''), 16);\n                            // Update player color\n                            player.group.traverse(child => {\n                                if (child.isMesh && child.material && child.material.color) {\n                                    if (child.material.color.getHex() === 0x2277ff || child.material.color.getHex() === col) {\n                                        child.material.color.setHex(col);\n                                        child.material.specular && child.material.specular.setHex(col);\n                                    }\n                                }\n                            });\n                            document.querySelectorAll('.color-swatch').forEach(s => s.style.border = '3px solid rgba(255,255,255,0.3)');\n                            sw.style.border = '3px solid rgba(255,255,255,0.9)';\n                        });\n                    });\n                    // Field color swatches\n                    document.querySelectorAll('.field-swatch').forEach(sw => {\n                        sw.addEventListener('click', () => {\n                            const hex = sw.dataset.color;\n                            ground.material.color.set(hex);\n                            document.querySelectorAll('.field-swatch').forEach(s => s.style.border = '3px solid rgba(255,255,255,0.3)');\n                            sw.style.border = '3px solid rgba(255,255,255,0.9)';\n                        });\n                    });\n                }\n\n                if (lbBtn) lbBtn.addEventListener('click', () => {\n                    // Show a simple scores/leaderboard placeholder panel\n                    const panel = document.createElement('div');\n                    panel.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.78);z-index:40;display:flex;align-items:center;justify-content:center;';\n                    panel.innerHTML = `<div style=\"background:linear-gradient(160deg,#2a1f0a,#1a1200);border-radius:24px;padding:36px 44px;width:420px;max-width:88%;box-shadow:0 12px 40px rgba(0,0,0,0.7);border:2px solid rgba(255,200,0,0.25);text-align:center;\">\n                        <div style=\"font-size:38px;font-family:'Fredoka One',cursive;color:#ffd700;margin-bottom:16px;\">\ud83c\udfc6 Leaderboard</div>\n                        <div style=\"color:#aaa;font-family:'Fredoka One',cursive;font-size:20px;margin-bottom:24px;\">Top scores coming soon!<br>Beat all 3 levels to earn your spot.</div>\n                        <button onclick=\"this.closest('div[style]').remove()\" style=\"padding:14px 40px;background:linear-gradient(135deg,#e8a020,#b06000);border:none;border-radius:14px;font-size:22px;font-family:'Fredoka One',cursive;color:#fff;cursor:pointer;\">Back</button>\n                    </div>`;\n                    uiOverlay.appendChild(panel);\n                });\n            }\n\n        }\n\n        function completeTutorial() {\n            tutorialJustCompletedAt = performance.now();\n            gameState = 'playing';\n            if (!bgMusic) bgMusic = playSound('soccer_bg_music', 0.45, true);\n            renderUI();\n        }\n\n        let prevTutorialStep = -1;\n        let prevTutorialDragProgress = -1;\n        function renderUIIfChanged() {\n            const tutProgChanged = gameState === 'tutorial' && tutorialStep === 0 &&\n                Math.abs(tutorialDragProgress - prevTutorialDragProgress) > 0.01;\n            if (gameState !== prevRenderedState || (gameState === 'tutorial' && tutorialStep !== prevTutorialStep) || tutProgChanged) {\n                prevRenderedState = gameState;\n                prevTutorialStep = tutorialStep;\n                prevTutorialDragProgress = tutorialDragProgress;\n                renderUI();\n            }\n        }\n\n        function restartGame(fullReset = false) {\n            if (fullReset) { checkpointBigLevel = 0; }\n            enemies.forEach(e => scene.remove(e.group));\n            enemies = [];\n            particles.forEach(p => scene.remove(p.mesh));\n            particles = [];\n            playerHealth = config.playerHealth;\n            maxPlayerHealth = config.playerHealth;\n            currentWave = 1;\n            totalWaves = config.numWaves;\n            currentBigLevel = checkpointBigLevel;\n            currentCourse = 0;\n            levelTransitionTimer = 0;\n            currentCombo = 0;\n            shotsTakenThisCourse = 0;\n            comboToastTimer = 0;\n            gameState = 'playing';\n            hintVisible = true;\n            hintTimer = 5;\n            player.group.position.set(0, 0.14, 0);\n            Object.keys(player.limbs).forEach(key => {\n                player.limbs[key].obj.rotation.set(0, 0, 0);\n            });\n            \n            footballMesh.position.set((Math.random() - 0.5) * 10, 0.44, 8);\n            spawnLevelObstacles(0, 0);\n            // Clear trail\n            trailSamples.length = 0;\n            lastTrailPos.set(9999, 0, 9999);\n            if (trailMesh) { scene.remove(trailMesh); trailMesh = null; }\n            if (trailGeo) { trailGeo.dispose(); trailGeo = null; }\n            // Clear air trail\n            airTrailSamples.length = 0;\n            lastAirTrailPos.set(9999, 9999, 9999);\n            if (airTrailMesh) { scene.remove(airTrailMesh); airTrailMesh = null; }\n            if (airTrailGeo) { airTrailGeo.dispose(); airTrailGeo = null; }\n            ballVelX = 0;\n            ballVelY = 0;\n            ballVelZ = 0;\n            ballAirborne = false;\n            ballStrength = 0;\n            ballDriftVelX = 0;\n            \n            if (!(currentBigLevel === 0 && currentCourse === 0)) spawnWave(1);\n            if (bgMusic) { bgMusic.source.stop(); bgMusic = null; }\n            player.group.visible = false;\n            bgMusic = playSound('soccer_bg_music', 0.45, true);\n            renderUI();\n        }\n\n        // Input handling\n        function getCanvasCoords(e) {\n            const rect = canvas.getBoundingClientRect();\n            const x = (e.clientX !== undefined ? e.clientX : e.touches[0].clientX);\n            const y = (e.clientY !== undefined ? e.clientY : e.touches[0].clientY);\n            return new THREE.Vector2(\n                ((x - rect.left) / rect.width) * 2 - 1,\n                -((y - rect.top) / rect.height) * 2 + 1\n            );\n        }\n\n        // Ball dragging state\n        let isDraggingBall = false;\n        let lastDragX = 0;\n        let lastDragY = 0;\n        let lastDragTime = 0;\n        let dragHistory = [];\n\n        // Drag inertia: target position the ball smoothly chases while dragging\n        let targetBallX = 0;\n        let targetBallZ = 0;\n\n        // Ball physics for flick\n        let ballVelX = 0;\n        let ballVelY = 0; // vertical (world Y)\n        let ballVelZ = 0;\n        let ballAirborne = false;\n        let ballStrength = 0;   // 0=weak flick, 1=max flick \u2014 drives gravity & drift\n        let ballDriftVelX = 0;  // lateral wobble added for weak shots\n        let rollingSound = null; // looping rolling audio source\n\n        function getClientXY(e) {\n            if (e.touches) return { x: e.touches[0].clientX, y: e.touches[0].clientY };\n            return { x: e.clientX, y: e.clientY };\n        }\n\n        function onPointerDown(e) {\n            if (gameState === 'mainMenu') return;\n            if (ballAirborne && gameState !== 'tutorial') return;\n            const { x, y } = getClientXY(e);\n            isDraggingBall = true;\n            lastDragX = x;\n            lastDragY = y;\n            lastDragTime = performance.now();\n            dragHistory = [{x, y, time: lastDragTime}];\n            ballVelX = 0;\n            ballVelY = 0;\n            ballVelZ = 0;\n            ballAirborne = false;\n            ballStrength = 0;\n            ballDriftVelX = 0;\n            if (footballMesh) footballMesh.position.y = 0.44;\n            // Sync target to current ball position so there's no initial jump\n            targetBallX = footballMesh.position.x;\n            targetBallZ = footballMesh.position.z;\n        }\n\n        function onPointerMove(e) {\n            if (!isDraggingBall) return;\n            const { x, y } = getClientXY(e);\n            const dx = x - lastDragX;\n            const dy = y - lastDragY;\n            const now = performance.now();\n            \n            lastDragX = x;\n            lastDragY = y;\n            lastDragTime = now;\n            \n            dragHistory.push({x, y, time: now});\n            if (dragHistory.length > 20) dragHistory.shift();\n\n            // Tutorial step 0: track drag distance\n            if (gameState === 'tutorial' && tutorialStep === 0) {\n                const dist = Math.sqrt(dx * dx + dy * dy);\n                const rect = canvas.getBoundingClientRect();\n                tutorialDragProgress = Math.min(1, tutorialDragProgress + dist / (rect.width * 0.35));\n                // Re-render ring progress\n                prevRenderedState = null;\n                if (tutorialDragProgress >= 1 && prevTutorialStep === 0) {\n                    // Advance to flick step after brief delay\n                    tutorialStep = 1;\n                    prevTutorialStep = 1;\n                    renderUI();\n                }\n            }\n\n            // Map screen drag to world XZ \u2014 update TARGET position, not ball directly\n            const rect = canvas.getBoundingClientRect();\n            const worldScale = 8 / rect.width;\n            targetBallX += dx * worldScale;\n            targetBallZ += dy * worldScale;\n\n            // No field clamping \u2014 ball moves freely\n        }\n\n        function onPointerUp(e) {\n            if (isDraggingBall) {\n                const now = performance.now();\n                // Use history to calculate average velocity over the last ~100ms\n                // This prevents a final 0-distance move event from killing the flick speed\n                let past = dragHistory[0];\n                for (let i = 0; i < dragHistory.length; i++) {\n                    if (now - dragHistory[i].time <= 100) {\n                        past = dragHistory[i];\n                        break;\n                    }\n                }\n                const last = dragHistory[dragHistory.length - 1];\n                const histDt = Math.max(1, last.time - past.time);\n                const avgVelX = ((last.x - past.x) / histDt) * 1000;\n                const avgVelY = ((last.y - past.y) / histDt) * 1000;\n\n                const flickSpeed = Math.sqrt(avgVelX * avgVelX + avgVelY * avgVelY);\n                const rect = canvas.getBoundingClientRect();\n                const worldScale = 8 / rect.width;\n\n                // Launch in the direction of the flick\n                if (flickSpeed > 2200) {\n                    playSound('ball_kick_sfx', 1.0);\n                    // Count shots taken this course (play mode only)\n                    if (gameState === 'playing') {\n                        shotsTakenThisCourse++;\n                    }\n                    // Tutorial step 1 completed by flicking\n                    if (gameState === 'tutorial' && tutorialStep === 1) {\n                        setTimeout(() => completeTutorial(), 600);\n                    }\n                    const clamped = Math.min(flickSpeed, 5000);\n                    const strength = (clamped - 2200) / 2800; // 0\u21921 normalized\n                    ballStrength = strength;\n\n                    // Speed: weak shots are slow, strong shots are fast\n                    const launchSpeed = 10 + strength * 34;\n\n                    const flickDirX = avgVelX / flickSpeed;\n                    const flickDirZ = avgVelY / flickSpeed;\n\n                    // Weak shots: add random lateral drift so they curve off-axis\n                    const driftAmount = (1.0 - strength) * (Math.random() - 0.5) * 9.5;\n                    // Perpendicular to flick dir in XZ: (-flickDirZ, flickDirX)\n                    ballVelX = flickDirX * launchSpeed + (-flickDirZ) * driftAmount;\n                    ballVelZ = flickDirZ * launchSpeed + flickDirX * driftAmount;\n\n                    // Weak shots barely leave the ground; strong shots arc high and stay up\n                    ballVelY = 1.2 + strength * 14.0;\n                    ballAirborne = true;\n\n                    // Additional persistent lateral wobble for weak shots (applied each frame)\n                    ballDriftVelX = (1.0 - strength) * (Math.random() - 0.5) * 5.5;\n                } else {\n                    // Not a flick \u2014 carry over drag velocity as rolling momentum so ball\n                    // decelerates gradually instead of stopping dead on release\n                    ballVelX = avgVelX * worldScale * 1.2;\n                    ballVelZ = avgVelY * worldScale * 1.2;\n                    ballAirborne = false;\n                }\n            }\n            isDraggingBall = false;\n        }\n\n        canvas.addEventListener('mousedown', onPointerDown);\n        canvas.addEventListener('mousemove', onPointerMove);\n        window.addEventListener('mouseup', onPointerUp);\n        canvas.addEventListener('touchstart', (e) => { if (mode !== 'edit') e.preventDefault(); onPointerDown(e.touches[0]); }, { passive: false });\n        canvas.addEventListener('touchmove', (e) => { if (mode !== 'edit') e.preventDefault(); onPointerMove(e.touches[0]); }, { passive: false });\n        canvas.addEventListener('touchend', (e) => { if (mode !== 'edit') e.preventDefault(); onPointerUp(e); }, { passive: false });\n\n        // Mid-air curve: swipe while ball is airborne to nudge it\n        let airSwipeActive = false;\n        let airSwipeLastX = 0;\n        let airSwipeLastY = 0;\n\n        function onAirSwipeStart(e) {\n            if (!ballAirborne) return;\n            const { x, y } = getClientXY(e);\n            airSwipeActive = true;\n            airSwipeLastX = x;\n            airSwipeLastY = y;\n        }\n        function onAirSwipeMove(e) {\n            if (!airSwipeActive || !ballAirborne) return;\n            const { x, y } = getClientXY(e);\n            const dx = x - airSwipeLastX;\n            const dy = y - airSwipeLastY;\n            airSwipeLastX = x;\n            airSwipeLastY = y;\n            // Apply a strong curve force based on swipe direction\n            const curveFactor = 0.038;\n            ballVelX += dx * curveFactor;\n            ballVelZ += dy * curveFactor;\n        }\n        function onAirSwipeEnd() { airSwipeActive = false; }\n\n        canvas.addEventListener('mousedown', (e) => { if (ballAirborne) onAirSwipeStart(e); });\n        canvas.addEventListener('mousemove', (e) => { if (airSwipeActive) onAirSwipeMove(e); });\n        window.addEventListener('mouseup', onAirSwipeEnd);\n        canvas.addEventListener('touchstart', (e) => { if (ballAirborne) { e.preventDefault(); onAirSwipeStart(e.touches[0]); } }, { passive: false });\n        canvas.addEventListener('touchmove', (e) => { if (airSwipeActive) { e.preventDefault(); onAirSwipeMove(e.touches[0]); } }, { passive: false });\n        canvas.addEventListener('touchend', onAirSwipeEnd, { passive: false });\n\n        // WASD movement\n        const keys = {};\n        window.addEventListener('keydown', (e) => { keys[e.key.toLowerCase()] = true; });\n        window.addEventListener('keyup', (e) => { keys[e.key.toLowerCase()] = false; });\n\n        // Edit mode\n        if (mode === 'edit') {\n            uiOverlay.innerHTML = `<div class=\"edit-info\">Use the settings panel to configure game parameters.<br>Switch to Play mode to test!</div>`;\n            function editLoop() {\n                renderer.render(scene, camera);\n                requestAnimationFrame(editLoop);\n            }\n            requestAnimationFrame(editLoop);\n            return;\n        }\n\n        // Play mode\n        if (!(currentBigLevel === 0 && currentCourse === 0)) spawnWave(1);\n        player.group.visible = false;\n        spawnLevelObstacles(0, 0);\n        renderUI();\n\n        // Start background music (deferred to when player starts from main menu)\n        // bgMusic = playSound('soccer_bg_music', 0.45, true);\n\n        // === BALL TRAIL \u2014 single smooth CatmullRom ribbon ===\n        const TRAIL_COLOR_R = 0x4E / 255;\n        const TRAIL_COLOR_G = 0x9A / 255;\n        const TRAIL_COLOR_B = 0x3A / 255;\n        const TRAIL_MAX_SAMPLES = 35;   // max stored sample points\n        const TRAIL_SPACING = 0.14;     // min distance between new samples\n        const TRAIL_FADE_TIME = 1.2;    // seconds until oldest sample fully fades\n        const TRAIL_WIDTH = 0.42;       // ground trail width\n        const trailSamples = [];        // { pos: Vector3, time: number (ms) }\n        let lastTrailPos = new THREE.Vector3(9999, 0, 9999);\n\n        // Persistent ribbon mesh + material \u2014 geometry is swapped each frame\n        const trailMat = new THREE.MeshBasicMaterial({\n            vertexColors: true,\n            transparent: true,\n            opacity: 1.0,\n            depthWrite: false,\n            side: THREE.DoubleSide\n        });\n        let trailMesh = null;\n        let trailGeo = null;\n\n        // === AIR TRAIL \u2014 bright yellow ribbon tracking ball in 3D while airborne ===\n        const AIR_TRAIL_COLOR_R = 1.00;\n        const AIR_TRAIL_COLOR_G = 0.96;\n        const AIR_TRAIL_COLOR_B = 0.30;\n        const AIR_TRAIL_MAX_SAMPLES = 32;\n        const AIR_TRAIL_SPACING    = 0.13;\n        const AIR_TRAIL_FADE_TIME  = 0.75;   // fades fast for a crisp streak\n        const AIR_TRAIL_WIDTH      = 0.88;   // covers entire ball width (ball diameter)\n        const airTrailSamples = [];           // { pos: Vector3, time: number (ms) }\n        let lastAirTrailPos = new THREE.Vector3(9999, 9999, 9999);\n\n        const airTrailMat = new THREE.MeshBasicMaterial({\n            vertexColors: true,\n            transparent: true,\n            opacity: 1.0,\n            depthWrite: false,\n            side: THREE.DoubleSide\n        });\n        let airTrailMesh = null;\n        let airTrailGeo  = null;\n\n        function updateAirTrail(dt, nowMs) {\n            // Only active while ball is airborne\n            if (!ballAirborne) {\n                // Clear when ball lands\n                airTrailSamples.length = 0;\n                lastAirTrailPos.set(9999, 9999, 9999);\n                if (airTrailGeo)  { airTrailGeo.dispose();  airTrailGeo  = null; }\n                if (airTrailMesh) { scene.remove(airTrailMesh); airTrailMesh = null; }\n                return;\n            }\n\n            const bx = footballMesh.position.x;\n            const by = footballMesh.position.y;\n            const bz = footballMesh.position.z;\n\n            const dist3 = Math.sqrt(\n                (bx - lastAirTrailPos.x) ** 2 +\n                (by - lastAirTrailPos.y) ** 2 +\n                (bz - lastAirTrailPos.z) ** 2\n            );\n            if (dist3 >= AIR_TRAIL_SPACING) {\n                airTrailSamples.push({ pos: new THREE.Vector3(bx, by, bz), time: nowMs });\n                lastAirTrailPos.set(bx, by, bz);\n                if (airTrailSamples.length > AIR_TRAIL_MAX_SAMPLES) airTrailSamples.shift();\n            }\n\n            // Prune fully-faded samples\n            while (airTrailSamples.length > 0 &&\n                   (nowMs - airTrailSamples[0].time) / 1000 >= AIR_TRAIL_FADE_TIME) {\n                airTrailSamples.shift();\n            }\n\n            // Dispose old geometry\n            if (airTrailGeo)  { airTrailGeo.dispose();  airTrailGeo  = null; }\n            if (airTrailMesh) { scene.remove(airTrailMesh); airTrailMesh = null; }\n\n            if (airTrailSamples.length < 3) return;\n\n            // Build 3D spline through all sample positions\n            const splinePoints = airTrailSamples.map(s => s.pos);\n            const curve = new THREE.CatmullRomCurve3(splinePoints);\n            const numDivs = Math.min(splinePoints.length * 5, 200);\n            const curvePoints = curve.getPoints(numDivs);\n            const n = curvePoints.length;\n\n            const positions = new Float32Array(n * 2 * 3);\n            const colors    = new Float32Array(n * 2 * 3);\n            const indices   = [];\n\n            for (let i = 0; i < n; i++) {\n                const t = i / (n - 1);\n\n                // Interpolate sample age for smooth fade\n                const sampleT = t * (airTrailSamples.length - 1);\n                const si0 = Math.floor(sampleT);\n                const si1 = Math.min(si0 + 1, airTrailSamples.length - 1);\n                const sf  = sampleT - si0;\n                const age0 = (nowMs - airTrailSamples[si0].time) / 1000;\n                const age1 = (nowMs - airTrailSamples[si1].time) / 1000;\n                const age  = age0 + (age1 - age0) * sf;\n                const fadeRatio = Math.max(0, 1.0 - age / AIR_TRAIL_FADE_TIME);\n\n                // Taper: narrow at tail, full at head\n                const taper  = Math.pow(t, 0.55);\n                const halfW  = AIR_TRAIL_WIDTH * 0.5 * taper;\n\n                // Tangent along curve\n                let tx, ty, tz;\n                if (i < n - 1) {\n                    tx = curvePoints[i+1].x - curvePoints[i].x;\n                    ty = curvePoints[i+1].y - curvePoints[i].y;\n                    tz = curvePoints[i+1].z - curvePoints[i].z;\n                } else {\n                    tx = curvePoints[i].x - curvePoints[i-1].x;\n                    ty = curvePoints[i].y - curvePoints[i-1].y;\n                    tz = curvePoints[i].z - curvePoints[i-1].z;\n                }\n                const tLen = Math.sqrt(tx*tx + ty*ty + tz*tz);\n                if (tLen > 1e-5) { tx /= tLen; ty /= tLen; tz /= tLen; }\n\n                // Perpendicular: cross(tangent, worldUp=(0,1,0)) \u2192 keeps ribbon horizontal\n                // cross = (ty*0 - tz*1, tz*0 - tx*0, tx*1 - ty*0) = (-tz, 0, tx)\n                let px = -tz, py = 0, pz = tx;\n                const pLen = Math.sqrt(px*px + pz*pz);\n                if (pLen > 1e-5) { px /= pLen; pz /= pLen; }\n\n                const cp = curvePoints[i];\n                const vi = i * 2;\n\n                // Left / right ribbon vertices\n                positions[vi*3+0] = cp.x - px*halfW;\n                positions[vi*3+1] = cp.y;\n                positions[vi*3+2] = cp.z - pz*halfW;\n                positions[(vi+1)*3+0] = cp.x + px*halfW;\n                positions[(vi+1)*3+1] = cp.y;\n                positions[(vi+1)*3+2] = cp.z + pz*halfW;\n\n                // Full uniform yellow \u2014 no edge darkening, no taper dimming\n                colors[vi*3+0] = AIR_TRAIL_COLOR_R; colors[vi*3+1] = AIR_TRAIL_COLOR_G; colors[vi*3+2] = AIR_TRAIL_COLOR_B;\n                colors[(vi+1)*3+0] = AIR_TRAIL_COLOR_R; colors[(vi+1)*3+1] = AIR_TRAIL_COLOR_G; colors[(vi+1)*3+2] = AIR_TRAIL_COLOR_B;\n\n                if (i < n - 1) {\n                    const a = vi, b = vi+1, c = vi+2, d = vi+3;\n                    indices.push(a, c, b, b, c, d);\n                }\n            }\n\n            airTrailGeo = new THREE.BufferGeometry();\n            airTrailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));\n            airTrailGeo.setAttribute('color',    new THREE.BufferAttribute(colors, 3));\n            airTrailGeo.setIndex(indices);\n\n            airTrailMesh = new THREE.Mesh(airTrailGeo, airTrailMat);\n            scene.add(airTrailMesh);\n        }\n\n        function updateTrail(dt, nowMs) {\n            // Hide ground trail when ball is airborne\n            if (ballAirborne) {\n                trailSamples.length = 0;\n                lastTrailPos.set(9999, 0, 9999);\n                if (trailGeo) { trailGeo.dispose(); trailGeo = null; }\n                if (trailMesh) { scene.remove(trailMesh); trailMesh = null; }\n                return;\n            }\n\n            const bx = footballMesh.position.x;\n            const bz = footballMesh.position.z;\n\n            // Add sample if ball moved far enough\n            const dist = Math.sqrt((bx - lastTrailPos.x) ** 2 + (bz - lastTrailPos.z) ** 2);\n            if (dist >= TRAIL_SPACING) {\n                trailSamples.push({ pos: new THREE.Vector3(bx, 0.006, bz), time: nowMs });\n                lastTrailPos.set(bx, 0, bz);\n                if (trailSamples.length > TRAIL_MAX_SAMPLES) trailSamples.shift();\n            }\n\n            // Remove fully-faded samples\n            while (trailSamples.length > 0 && (nowMs - trailSamples[0].time) / 1000 >= TRAIL_FADE_TIME) {\n                trailSamples.shift();\n            }\n\n            // Dispose old geometry\n            if (trailGeo) { trailGeo.dispose(); trailGeo = null; }\n            if (trailMesh) { scene.remove(trailMesh); trailMesh = null; }\n\n            if (trailSamples.length < 3) return; // need enough points for a spline\n\n            // Build smooth spline through all sample positions\n            const splinePoints = trailSamples.map(s => s.pos);\n            const curve = new THREE.CatmullRomCurve3(splinePoints);\n            // More divisions = smoother ribbon; clamp to reasonable max\n            const numDivs = Math.min(splinePoints.length * 5, 200);\n            const curvePoints = curve.getPoints(numDivs);\n            const n = curvePoints.length;\n\n            // Build ribbon: two vertices (left/right) per curve point\n            const positions = new Float32Array(n * 2 * 3);\n            const colors    = new Float32Array(n * 2 * 3);\n            const indices   = [];\n\n            for (let i = 0; i < n; i++) {\n                // t=0 \u2192 tail (oldest), t=1 \u2192 head (newest, near ball)\n                const t = i / (n - 1);\n\n                // Map curve parameter back to a sample age for fade\n                const sampleT = t * (trailSamples.length - 1);\n                const si0 = Math.floor(sampleT);\n                const si1 = Math.min(si0 + 1, trailSamples.length - 1);\n                const sf  = sampleT - si0;\n                const age0 = (nowMs - trailSamples[si0].time) / 1000;\n                const age1 = (nowMs - trailSamples[si1].time) / 1000;\n                const age  = age0 + (age1 - age0) * sf;\n                const fadeRatio = Math.max(0, 1.0 - age / TRAIL_FADE_TIME);\n\n                // Taper: sharp at tail (t=0), full width at head (t=1)\n                const taper = Math.pow(t, 0.6);\n                const halfW = TRAIL_WIDTH * 0.5 * taper;\n\n                // Tangent direction along curve (forward vector)\n                let tx, tz;\n                if (i < n - 1) {\n                    tx = curvePoints[i + 1].x - curvePoints[i].x;\n                    tz = curvePoints[i + 1].z - curvePoints[i].z;\n                } else {\n                    tx = curvePoints[i].x - curvePoints[i - 1].x;\n                    tz = curvePoints[i].z - curvePoints[i - 1].z;\n                }\n                const tLen = Math.sqrt(tx * tx + tz * tz);\n                if (tLen > 1e-5) { tx /= tLen; tz /= tLen; }\n                // Perpendicular in XZ plane\n                const px = tz;\n                const pz = -tx;\n\n                const cp = curvePoints[i];\n                const vi = i * 2;\n\n                // Left vertex\n                positions[vi * 3 + 0] = cp.x - px * halfW;\n                positions[vi * 3 + 1] = 0.006;\n                positions[vi * 3 + 2] = cp.z - pz * halfW;\n                // Right vertex\n                positions[(vi + 1) * 3 + 0] = cp.x + px * halfW;\n                positions[(vi + 1) * 3 + 1] = 0.006;\n                positions[(vi + 1) * 3 + 2] = cp.z + pz * halfW;\n\n                // Full uniform color \u2014 no brightness fade, trail stays vivid until pruned\n                const cr = TRAIL_COLOR_R;\n                const cg = TRAIL_COLOR_G;\n                const cb = TRAIL_COLOR_B;\n                colors[vi * 3 + 0] = cr; colors[vi * 3 + 1] = cg; colors[vi * 3 + 2] = cb;\n                colors[(vi + 1) * 3 + 0] = cr; colors[(vi + 1) * 3 + 1] = cg; colors[(vi + 1) * 3 + 2] = cb;\n\n                // Two triangles per quad\n                if (i < n - 1) {\n                    const a = vi, b = vi + 1, c = vi + 2, d = vi + 3;\n                    indices.push(a, c, b, b, c, d);\n                }\n            }\n\n            trailGeo = new THREE.BufferGeometry();\n            trailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));\n            trailGeo.setAttribute('color',    new THREE.BufferAttribute(colors, 3));\n            trailGeo.setIndex(indices);\n\n            trailMesh = new THREE.Mesh(trailGeo, trailMat);\n            scene.add(trailMesh);\n        }\n\n        // === SMALL BARRIER HIT EFFECT ===\n        const barrierHitParts = [];\n        let barrierHitCooldown = 0; // prevent spam\n\n        function spawnSmallExplosion(pos) {\n            // Mini flash\n            const flashGeo = new THREE.SphereGeometry(0.22, 12, 12);\n            const flashMat = new THREE.MeshBasicMaterial({ color: 0xffcc33, transparent: true, opacity: 1.0 });\n            const flash = new THREE.Mesh(flashGeo, flashMat);\n            flash.position.copy(pos);\n            flash.position.y = Math.max(pos.y, 0.5);\n            scene.add(flash);\n            barrierHitParts.push({ mesh: flash, type: 'flash', life: 0.25, maxLife: 0.25 });\n\n            // Small ring\n            const ringGeo = new THREE.TorusGeometry(0.15, 0.07, 6, 20);\n            const ringMat = new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.85 });\n            const ring = new THREE.Mesh(ringGeo, ringMat);\n            ring.position.copy(flash.position);\n            ring.rotation.x = Math.PI / 2;\n            scene.add(ring);\n            barrierHitParts.push({ mesh: ring, type: 'ring', life: 0.35, maxLife: 0.35 });\n\n            // Small sparks\n            for (let i = 0; i < 10; i++) {\n                const geo = new THREE.SphereGeometry(0.04 + Math.random() * 0.06, 5, 5);\n                const col = Math.random() > 0.5 ? 0xff5500 : 0xffdd00;\n                const mat = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 1.0 });\n                const mesh = new THREE.Mesh(geo, mat);\n                mesh.position.copy(flash.position);\n                scene.add(mesh);\n                const angle = Math.random() * Math.PI * 2;\n                const speed = 3 + Math.random() * 6;\n                barrierHitParts.push({\n                    mesh, type: 'spark',\n                    vel: new THREE.Vector3(\n                        Math.cos(angle) * speed,\n                        1.5 + Math.random() * 4,\n                        Math.sin(angle) * speed\n                    ),\n                    life: 0.3 + Math.random() * 0.3,\n                    maxLife: 0.3 + Math.random() * 0.3\n                });\n            }\n        }\n\n        function updateBarrierHitParts(dt) {\n            barrierHitCooldown = Math.max(0, barrierHitCooldown - dt);\n            for (let i = barrierHitParts.length - 1; i >= 0; i--) {\n                const p = barrierHitParts[i];\n                p.life -= dt;\n                const t = 1.0 - (p.life / p.maxLife);\n                if (p.type === 'flash') {\n                    p.mesh.scale.setScalar(1 + t * 4);\n                    p.mesh.material.opacity = Math.max(0, 1.0 - t * 2.0);\n                } else if (p.type === 'ring') {\n                    const s = 1 + t * 7;\n                    p.mesh.scale.set(s, s, 1);\n                    p.mesh.material.opacity = Math.max(0, 0.85 - t * 1.5);\n                } else if (p.type === 'spark') {\n                    p.vel.y -= 14 * dt;\n                    p.mesh.position.add(p.vel.clone().multiplyScalar(dt));\n                    p.mesh.material.opacity = Math.max(0, p.life / p.maxLife);\n                }\n                if (p.life <= 0) {\n                    scene.remove(p.mesh);\n                    p.mesh.geometry.dispose();\n                    p.mesh.material.dispose();\n                    barrierHitParts.splice(i, 1);\n                }\n            }\n        }\n\n        // === EXPLOSION EFFECT ===\n        const explosionParts = [];\n\n        function spawnExplosion(pos) {\n            // Central flash sphere \u2014 bright expanding ball of light\n            const flashGeo = new THREE.SphereGeometry(0.5, 24, 24);\n            const flashMat = new THREE.MeshBasicMaterial({ color: 0xffdd44, transparent: true, opacity: 1.0 });\n            const flash = new THREE.Mesh(flashGeo, flashMat);\n            flash.position.copy(pos);\n            flash.position.y = Math.max(flash.position.y, 1.5);\n            scene.add(flash);\n            explosionParts.push({ mesh: flash, type: 'flash', life: 0.6, maxLife: 0.6 });\n\n            // Ring shockwave \u2014 flat torus that expands outward\n            const ringGeo = new THREE.TorusGeometry(0.3, 0.15, 8, 32);\n            const ringMat = new THREE.MeshBasicMaterial({ color: 0xff8800, transparent: true, opacity: 0.9 });\n            const ring = new THREE.Mesh(ringGeo, ringMat);\n            ring.position.copy(flash.position);\n            ring.rotation.x = Math.PI / 2;\n            scene.add(ring);\n            explosionParts.push({ mesh: ring, type: 'ring', life: 0.7, maxLife: 0.7 });\n\n            // Fire chunks \u2014 glowing orange/red spheres flying outward\n            for (let i = 0; i < 30; i++) {\n                const chunkGeo = new THREE.SphereGeometry(0.08 + Math.random() * 0.15, 8, 8);\n                const chunkColor = Math.random() > 0.5 ? 0xff4400 : 0xffaa00;\n                const chunkMat = new THREE.MeshBasicMaterial({ color: chunkColor, transparent: true, opacity: 1.0 });\n                const chunk = new THREE.Mesh(chunkGeo, chunkMat);\n                chunk.position.copy(flash.position);\n                scene.add(chunk);\n                const angle = Math.random() * Math.PI * 2;\n                const elev = (Math.random() - 0.3) * Math.PI;\n                const speed = 4 + Math.random() * 10;\n                explosionParts.push({\n                    mesh: chunk,\n                    type: 'chunk',\n                    vel: new THREE.Vector3(\n                        Math.cos(angle) * Math.cos(elev) * speed,\n                        Math.sin(elev) * speed + 3,\n                        Math.sin(angle) * Math.cos(elev) * speed\n                    ),\n                    life: 0.5 + Math.random() * 0.6,\n                    maxLife: 0.5 + Math.random() * 0.6\n                });\n            }\n\n            // Smoke puffs \u2014 larger, slower, grey spheres\n            for (let i = 0; i < 12; i++) {\n                const puffGeo = new THREE.SphereGeometry(0.25 + Math.random() * 0.3, 10, 10);\n                const grey = 0.3 + Math.random() * 0.3;\n                const puffMat = new THREE.MeshBasicMaterial({\n                    color: new THREE.Color(grey, grey, grey),\n                    transparent: true, opacity: 0.7\n                });\n                const puff = new THREE.Mesh(puffGeo, puffMat);\n                puff.position.copy(flash.position);\n                scene.add(puff);\n                const angle = Math.random() * Math.PI * 2;\n                const speed = 1.5 + Math.random() * 3;\n                explosionParts.push({\n                    mesh: puff,\n                    type: 'smoke',\n                    vel: new THREE.Vector3(\n                        Math.cos(angle) * speed,\n                        1 + Math.random() * 3,\n                        Math.sin(angle) * speed\n                    ),\n                    life: 1.0 + Math.random() * 0.8,\n                    maxLife: 1.0 + Math.random() * 0.8\n                });\n            }\n        }\n\n        function updateExplosion(dt) {\n            for (let i = explosionParts.length - 1; i >= 0; i--) {\n                const p = explosionParts[i];\n                p.life -= dt;\n                const t = 1.0 - (p.life / p.maxLife); // 0\u21921 over lifetime\n\n                if (p.type === 'flash') {\n                    const scale = 1 + t * 8;\n                    p.mesh.scale.setScalar(scale);\n                    p.mesh.material.opacity = Math.max(0, 1.0 - t * 1.8);\n                } else if (p.type === 'ring') {\n                    const scale = 1 + t * 12;\n                    p.mesh.scale.set(scale, scale, 1);\n                    p.mesh.material.opacity = Math.max(0, 0.9 - t * 1.4);\n                } else if (p.type === 'chunk') {\n                    p.vel.y -= 12 * dt;\n                    p.mesh.position.add(p.vel.clone().multiplyScalar(dt));\n                    p.mesh.material.opacity = Math.max(0, p.life / p.maxLife);\n                    p.mesh.scale.multiplyScalar(1 - dt * 1.5);\n                } else if (p.type === 'smoke') {\n                    p.vel.multiplyScalar(1 - 2 * dt);\n                    p.vel.y += 1.5 * dt;\n                    p.mesh.position.add(p.vel.clone().multiplyScalar(dt));\n                    p.mesh.scale.multiplyScalar(1 + dt * 1.2);\n                    p.mesh.material.opacity = Math.max(0, (p.life / p.maxLife) * 0.7);\n                }\n\n                if (p.life <= 0) {\n                    scene.remove(p.mesh);\n                    p.mesh.geometry.dispose();\n                    p.mesh.material.dispose();\n                    explosionParts.splice(i, 1);\n                }\n            }\n        }\n\n        // === CONFETTI SYSTEM ===\n        const confettiPieces = [];\n        const confettiColors = [0xff4444, 0x44ff44, 0x4444ff, 0xffff44, 0xff44ff, 0x44ffff, 0xff8800, 0xffffff];\n\n        // Screen shake state\n        let screenShakeIntensity = 0;\n        let screenShakeDecay = 0;\n\n        function triggerScreenShake(intensity, duration) {\n            screenShakeIntensity = intensity;\n            screenShakeDecay = intensity / duration;\n        }\n\n        function spawnConfetti() {\n            const GOAL_Z = -(hd + 0.5);\n\n            // === EXPLOSION FLASH SPHERE ===\n            // (handled separately in spawnExplosion)\n\n            // Multiple bursts for a more explosive feel\n            for (let i = 0; i < 250; i++) {\n                const color = confettiColors[Math.floor(Math.random() * confettiColors.length)];\n                const size = 0.12 + Math.random() * 0.22;\n                const geo = new THREE.PlaneGeometry(size, size * (0.5 + Math.random() * 0.5));\n                const mat = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide });\n                const mesh = new THREE.Mesh(geo, mat);\n                mesh.position.set(\n                    (Math.random() - 0.5) * 12,\n                    0.5 + Math.random() * 3.0,\n                    GOAL_Z + 0.5 + Math.random() * 2.5\n                );\n                mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);\n                scene.add(mesh);\n                // Explosive outward burst with high upward velocity\n                const angle = Math.random() * Math.PI * 2;\n                const outSpeed = 3 + Math.random() * 5;\n                confettiPieces.push({\n                    mesh,\n                    vel: new THREE.Vector3(\n                        Math.cos(angle) * outSpeed,\n                        Math.random() * 14 + 6,\n                        Math.sin(angle) * outSpeed + 3 + Math.random() * 4\n                    ),\n                    rotVel: new THREE.Vector3(\n                        (Math.random() - 0.5) * 15,\n                        (Math.random() - 0.5) * 15,\n                        (Math.random() - 0.5) * 15\n                    ),\n                    life: 3.5 + Math.random() * 2.5\n                });\n            }\n        }\n\n        function updateConfetti(dt) {\n            for (let i = confettiPieces.length - 1; i >= 0; i--) {\n                const c = confettiPieces[i];\n                c.life -= dt;\n                c.vel.y -= 6.0 * dt; // gravity\n                c.vel.x *= (1 - 0.5 * dt); // air drag\n                c.vel.z *= (1 - 0.5 * dt);\n                c.mesh.position.add(c.vel.clone().multiplyScalar(dt));\n                c.mesh.rotation.x += c.rotVel.x * dt;\n                c.mesh.rotation.y += c.rotVel.y * dt;\n                c.mesh.rotation.z += c.rotVel.z * dt;\n                // Fade out near end of life\n                if (c.life < 1.0) c.mesh.material.opacity = Math.max(0, c.life);\n                if (c.life < 1.0) c.mesh.material.transparent = true;\n                if (c.life <= 0 || c.mesh.position.y < -2) {\n                    scene.remove(c.mesh);\n                    c.mesh.geometry.dispose();\n                    c.mesh.material.dispose();\n                    confettiPieces.splice(i, 1);\n                }\n            }\n        }\n\n        let lastTime = 0;\n\n        function gameLoop(timestamp) {\n            let dt = (timestamp - lastTime) / 1000;\n            lastTime = timestamp;\n            if (dt > 0.11) dt = 0.11;\n            if (dt <= 0) dt = 0.016;\n\n            // Main menu \u2014 just animate the scene in background, skip all game logic\n            if (gameState === 'mainMenu' || gameState === 'tutorial') {\n                if (gameState === 'mainMenu') {\n                    // Slowly pan the camera for an atmospheric background effect\n                    const t = timestamp * 0.0003;\n                    camera.position.x = Math.sin(t) * 5;\n                    camera.position.y = 6 + Math.sin(t * 0.7) * 1;\n                    camera.position.z = 12;\n                    camera.lookAt(Math.sin(t * 0.5) * 2, 1.5, 0);\n                } else {\n                    // Tutorial: camera follows the ball closely\n                    const ballPos = footballMesh.position;\n                    const camTarget = ballPos.clone().add(new THREE.Vector3(0, 7, 18));\n                    camera.position.lerp(camTarget, 4 * dt);\n                    camera.lookAt(ballPos.clone().add(new THREE.Vector3(0, 0.5, 0)));\n                    camera.fov = THREE.MathUtils.lerp(camera.fov, 60, 3 * dt);\n                }\n                camera.updateProjectionMatrix();\n\n                // During tutorial, process ball drag so the player can actually move it\n                if (gameState === 'tutorial' && isDraggingBall) {\n                    const lerpSpeed = 9;\n                    const prevX = footballMesh.position.x;\n                    const prevZ = footballMesh.position.z;\n                    footballMesh.position.x += (targetBallX - footballMesh.position.x) * lerpSpeed * dt;\n                    footballMesh.position.z += (targetBallZ - footballMesh.position.z) * lerpSpeed * dt;\n                    // Rolling visual\n                    const movDX = footballMesh.position.x - prevX;\n                    const movDZ = footballMesh.position.z - prevZ;\n                    footballMesh.rotation.z -= movDX / 0.44;\n                    footballMesh.rotation.x += movDZ / 0.44;\n                }\n\n                renderer.render(scene, camera);\n                renderUIIfChanged();\n                requestAnimationFrame(gameLoop);\n                return;\n            }\n\n            // Rolling sound management\n            const ballGroundSpeed = Math.sqrt(ballVelX * ballVelX + ballVelZ * ballVelZ);\n            const isDraggingSpeed = isDraggingBall ? Math.sqrt((targetBallX - footballMesh.position.x) ** 2 + (targetBallZ - footballMesh.position.z) ** 2) * 9 : 0;\n            const effectiveRollSpeed = ballAirborne ? 0 : Math.max(ballGroundSpeed, isDraggingSpeed);\n            if (effectiveRollSpeed > 0.5 && !rollingSound) {\n                rollingSound = playSound('ball_rolling_sfx', 0, true);\n            }\n            if (rollingSound) {\n                const targetVol = Math.min(1.5, effectiveRollSpeed * 0.25);\n                if (effectiveRollSpeed < 0.3) {\n                    rollingSound.gain.gain.value = 0;\n                    rollingSound.source.stop();\n                    rollingSound = null;\n                } else {\n                    rollingSound.gain.gain.value = targetVol;\n                }\n            }\n\n            // Ball drag inertia \u2014 smoothly chase target position while dragging\n            if (isDraggingBall) {\n                const lerpSpeed = 9; // higher = snappier, lower = more lag/inertia\n                const prevX = footballMesh.position.x;\n                const prevZ = footballMesh.position.z;\n                footballMesh.position.x += (targetBallX - footballMesh.position.x) * lerpSpeed * dt;\n                footballMesh.position.z += (targetBallZ - footballMesh.position.z) * lerpSpeed * dt;\n\n                // Barrier collision while dragging \u2014 push ball out of any overlapping box\n                // and sync targetBall back so the drag doesn't fight the correction.\n                const BALL_R_DRAG = 0.44;\n                for (let bi = 0; bi < barrierBoxes.length; bi++) {\n                    const box = barrierBoxes[bi];\n                    if (footballMesh.position.y + BALL_R_DRAG < 0) continue;\n                    if (footballMesh.position.y - BALL_R_DRAG > box.maxY) continue;\n                    const bdx = footballMesh.position.x - box.cx;\n                    const bdz = footballMesh.position.z - box.cz;\n                    const ovX = (box.hwX + BALL_R_DRAG) - Math.abs(bdx);\n                    const ovZ = (box.hwZ + BALL_R_DRAG) - Math.abs(bdz);\n                    if (ovX > 0 && ovZ > 0) {\n                        if (ovX < ovZ) {\n                            footballMesh.position.x += ovX * Math.sign(bdx);\n                            targetBallX = footballMesh.position.x;\n                        } else {\n                            footballMesh.position.z += ovZ * Math.sign(bdz);\n                            targetBallZ = footballMesh.position.z;\n                        }\n                    }\n                }\n\n                // Rolling visual based on actual movement\n                const movDX = footballMesh.position.x - prevX;\n                const movDZ = footballMesh.position.z - prevZ;\n                footballMesh.rotation.z -= movDX / 0.44;\n                footballMesh.rotation.x += movDZ / 0.44;\n            }\n\n            // Ball physics (flick momentum) runs regardless of game state so it doesn't freeze mid-air\n            if (!isDraggingBall && (Math.abs(ballVelX) > 0.01 || Math.abs(ballVelZ) > 0.01 || ballAirborne || footballMesh.position.y > 0.48)) {\n                footballMesh.position.x += ballVelX * dt;\n                footballMesh.position.z += ballVelZ * dt;\n                footballMesh.position.y += ballVelY * dt;\n\n                // Gravity: weak shots fall fast, strong shots float long\n                // ballStrength=0 \u2192 gravity ~18, ballStrength=1 \u2192 gravity ~2.5\n                const ballGravity = 18.0 - ballStrength * 15.5;\n                ballVelY -= ballGravity * dt;\n\n                // Lateral drift: weak shots wobble sideways, fades out as strength increases\n                if (ballAirborne && ballStrength < 0.85) {\n                    ballVelX += ballDriftVelX * dt;\n                    // Slowly decay the drift so it doesn't spiral forever\n                    ballDriftVelX *= Math.pow(0.92, dt * 60);\n                }\n                if (footballMesh.position.y <= 0.44) {\n                    footballMesh.position.y = 0.44;\n                    ballVelY = -ballVelY * 0.3;\n                    if (Math.abs(ballVelY) < 0.5) { ballVelY = 0; ballAirborne = false; }\n                }\n                if (!ballAirborne && footballMesh.position.y <= 0.44) {\n                    ballVelX *= (1 - 3 * dt);\n                    ballVelZ *= (1 - 3 * dt);\n                    // Snap to zero only when truly negligible to avoid endless micro-roll\n                    if (Math.abs(ballVelX) < 0.02) ballVelX = 0;\n                    if (Math.abs(ballVelZ) < 0.02) ballVelZ = 0;\n                } else {\n                    ballVelX *= (1 - 0.015 * dt);\n                    ballVelZ *= (1 - 0.015 * dt);\n                }\n                if (!ballAirborne) {\n                    footballMesh.rotation.z -= ballVelX * dt / 0.44;\n                    footballMesh.rotation.x += ballVelZ * dt / 0.44;\n                }\n                // No invisible field boundary \u2014 ball rolls freely\n\n\n                // === GOAL NET HITBOXES ===\n                // Bounce the ball off back net, side nets, and top net.\n                // The front opening (z = GNF_Z) has no net, so the ball can enter freely.\n                const GNF_Z = -(hd + 0.5);   // front face of goal (opening)\n                const GNB_Z = GNF_Z - 3.2;   // back net z (matches DEPTH in createGoal)\n                const GN_HW = 6.5;            // half-width of goal (side posts \u00b16.5)\n                const GN_H  = 5.2;            // crossbar / top net height\n                const BALL_R = 0.44;\n                const NET_RESTITUTION = 0.35; // how bouncy the net is\n\n                // Only process if ball is anywhere near/inside the goal box\n                if (footballMesh.position.z < GNF_Z &&\n                    footballMesh.position.z > GNB_Z - BALL_R &&\n                    Math.abs(footballMesh.position.x) < GN_HW + BALL_R &&\n                    footballMesh.position.y >= 0 &&\n                    footballMesh.position.y < GN_H + BALL_R) {\n\n                    // Back net \u2014 ball hits and bounces back toward player\n                    if (footballMesh.position.z - BALL_R < GNB_Z) {\n                        footballMesh.position.z = GNB_Z + BALL_R;\n                        if (ballVelZ < 0) ballVelZ = -ballVelZ * NET_RESTITUTION;\n                    }\n                    // Left side net\n                    if (footballMesh.position.x - BALL_R < -GN_HW) {\n                        footballMesh.position.x = -GN_HW + BALL_R;\n                        if (ballVelX < 0) ballVelX = -ballVelX * NET_RESTITUTION;\n                    }\n                    // Right side net\n                    if (footballMesh.position.x + BALL_R > GN_HW) {\n                        footballMesh.position.x = GN_HW - BALL_R;\n                        if (ballVelX > 0) ballVelX = -ballVelX * NET_RESTITUTION;\n                    }\n                    // Top net (crossbar underside)\n                    if (footballMesh.position.y + BALL_R > GN_H) {\n                        footballMesh.position.y = GN_H - BALL_R;\n                        if (ballVelY > 0) ballVelY = -ballVelY * NET_RESTITUTION;\n                    }\n                }\n\n                // === BARRIER COLLISIONS ===\n                const BALL_R_BAR = 0.44;\n                for (let bi = 0; bi < barrierBoxes.length; bi++) {\n                    const box = barrierBoxes[bi];\n                    if (footballMesh.position.y - BALL_R_BAR > box.maxY) continue;\n                    if (footballMesh.position.y + BALL_R_BAR < 0) continue;\n                    const bdx = footballMesh.position.x - box.cx;\n                    const bdz = footballMesh.position.z - box.cz;\n                    const ovX = (box.hwX + BALL_R_BAR) - Math.abs(bdx);\n                    const ovZ = (box.hwZ + BALL_R_BAR) - Math.abs(bdz);\n                    if (ovX > 0 && ovZ > 0) {\n                        const ballSpeed = Math.sqrt(ballVelX*ballVelX + ballVelZ*ballVelZ + ballVelY*ballVelY);\n                        if (ballSpeed > 3.0 && barrierHitCooldown <= 0) {\n                            spawnSmallExplosion(footballMesh.position.clone());\n                            playSound('punch_impact_sfx', Math.min(1.0, ballSpeed * 0.08));\n                            barrierHitCooldown = 0.25;\n                        }\n                        if (ovX < ovZ) {\n                            footballMesh.position.x += ovX * Math.sign(bdx);\n                            ballVelX = Math.abs(ballVelX) * Math.sign(bdx) * 0.45;\n                        } else {\n                            footballMesh.position.z += ovZ * Math.sign(bdz);\n                            ballVelZ = Math.abs(ballVelZ) * Math.sign(bdz) * 0.45;\n                        }\n                    }\n                }\n            }\n\n            if (gameState === 'playing' || gameState === 'tackled') {\n                if (gameState === 'tackled') {\n                    // freeze ball in place during tackle animation\n                    ballVelX = 0; ballVelZ = 0; ballVelY = 0;\n                    renderer.render(scene, camera);\n                    requestAnimationFrame(gameLoop);\n                    return;\n                }\n\n                // Out of bounds check \u2014 ball left the grass field\n                const OOB_MARGIN = 1.0;\n                window._defeatReason = 'oob';\n                if (Math.abs(footballMesh.position.x) > FHW + OOB_MARGIN ||\n                    footballMesh.position.z > FHD + OOB_MARGIN ||\n                    footballMesh.position.z < -FHD - 5) { // extra margin on goal side\n                    gameState = 'defeat';\n                    if (bgMusic) { bgMusic.source.stop(); bgMusic = null; }\n                    playSound('oob_defeat_sfx', 0.9);\n                    renderUI();\n                }\n\n                // Goal detection: ball crosses into goal mouth at far end\n                // Goal is at z = -(hd+0.5), opening width \u00b11.8, height 0\u20132.2\n                // Goal mouth: group at z=-(hd+0.5), GOAL_W=13.0 (\u00b16.5), GOAL_H=5.2\n                // Ball radius = 0.44 \u2014 all checks account for the full ball radius\n                // so the ball must be ENTIRELY inside the goal box to count\n                const BALL_R = 0.44;\n                const GOAL_FRONT_Z = -(hd + 0.5);       // z = front face of goal\n                const GOAL_BACK_Z  = GOAL_FRONT_Z - 3.6; // z = slightly past back net\n                if (footballMesh.position.z < GOAL_FRONT_Z &&      // center past front face\n                    footballMesh.position.z > GOAL_BACK_Z  &&      // not beyond back net\n                    Math.abs(footballMesh.position.x) < 6.5 &&     // within side posts (use center)\n                    footballMesh.position.y >= 0 &&                // at or above ground\n                    footballMesh.position.y < 5.2) {               // below crossbar\n                    playSound('goal_kaching_sfx', 0.85);\n                    createImpactEffect(footballMesh.position.clone().add(new THREE.Vector3(0, 1, 0)), 0x4ecdc4, 20);\n                    spawnConfetti();\n                    spawnExplosion(footballMesh.position.clone());\n                    triggerScreenShake(0.4, 0.6);\n\n                    // Combo: first-shot goal keeps/extends the streak\n                    if (shotsTakenThisCourse === 1) {\n                        currentCombo++;\n                        comboToastCombo = currentCombo;\n                        comboToastTimer = currentCombo >= 2 ? 3.2 : 2.2;\n                    } else {\n                        currentCombo = 0;\n                        comboToastTimer = 0;\n                    }\n\n                    const totalBigLevels = config.levels ? config.levels.length : 1;\n                    const isLastCourse = currentCourse >= COURSES_PER_LEVEL - 1;\n                    const isLastLevel = currentBigLevel >= totalBigLevels - 1;\n                    if (isLastCourse && isLastLevel) {\n                        // All levels and courses done \u2014 full victory!\n                        gameState = 'victory';\n                        if (bgMusic) { bgMusic.source.stop(); bgMusic = null; }\n                    } else if (isLastCourse) {\n                        // All courses in this big level done \u2014 advance to next big level\n                        gameState = 'levelComplete';\n                        levelTransitionTimer = 3.0;\n                    } else {\n                        // More courses remain \u2014 advance to next course\n                        gameState = 'courseComplete';\n                        levelTransitionTimer = 2.0;\n                    }\n                    renderUI();\n                }\n\n                // Update enemies\n                for (let i = enemies.length - 1; i >= 0; i--) {\n                    const enemy = enemies[i];\n\n                    // Check defeat\n                    if (enemy.health <= 0) {\n                        createImpactEffect(enemy.group.position.clone().add(new THREE.Vector3(0,1,0)), 0xff4444, 12);\n\n                        scene.remove(enemy.group);\n                        enemies.splice(i, 1);\n                        enemiesDefeatedThisWave++;\n                        continue;\n                    }\n\n                    // Stun\n                    if (enemy.stunTimer > 0) {\n                        enemy.stunTimer -= dt;\n                        enemy.group.position.add(enemy.knockback.clone().multiplyScalar(dt * 3));\n                        enemy.knockback.multiplyScalar(0.9);\n                        enemy.group.position.y = Math.max(0, enemy.group.position.y);\n                        if (enemy.group.position.y > 0) enemy.knockback.y -= 9.8 * dt;\n                        continue;\n                    }\n\n                    enemy.knockback.set(0, 0, 0);\n                    enemy.group.position.y = Math.max(0, enemy.group.position.y);\n                    if (enemy.group.position.y > 0) {\n                        enemy.group.position.y -= 5 * dt;\n                        if (enemy.group.position.y < 0) enemy.group.position.y = 0;\n                    }\n\n                    // Chase the ball / player\n                    const dir = footballMesh.position.clone().sub(enemy.group.position);\n                    dir.y = 0;\n                    const dist = dir.length();\n\n                    if (dist > 1.2) {\n                        dir.normalize();\n                        enemy.group.position.add(dir.multiplyScalar(2 * config.enemySpeed * dt));\n                        enemy.group.lookAt(footballMesh.position.x, 0, footballMesh.position.z);\n                    } else if (gameState === 'playing') {\n                        // Enemy tackled the ball \u2014 steal it and trigger defeat\n                        createImpactEffect(footballMesh.position.clone().add(new THREE.Vector3(0, 0.5, 0)), 0xff2222, 18);\n                        triggerScreenShake(0.5, 0.5);\n                        playSound('punch_impact_sfx', 0.9);\n                        gameState = 'tackled';\n                        window._defeatReason = 'tackle';\n                        setTimeout(() => {\n                            gameState = 'defeat';\n                            if (bgMusic) { bgMusic.source.stop(); bgMusic = null; }\n                            playSound('oob_defeat_sfx', 0.9);\n                            renderUI();\n                        }, 800);\n                        renderUI();\n                    }\n\n                    // Enemy running animation\n                    const time = timestamp * 0.0065; // faster, more energetic cycle\n                    const ePhase = time + i * 1.7;   // stagger each enemy\n\n                    // Upper legs: wide swing for a powerful stride\n                    const elUpperA = Math.sin(ePhase) * 0.82;\n                    const erUpperA = Math.sin(ePhase + Math.PI) * 0.82;\n\n                    // Lower legs: knee bends sharply during the swing-through phase.\n                    // cos peaks when the leg is crossing neutral (mid-swing) \u2014 exactly\n                    // when the knee should lift highest. The 0.55 phase offset aligns\n                    // the bend peak to just after the leg passes center going forward.\n                    const elLowerB = 0.12 + Math.max(0, Math.cos(ePhase + 0.55)) * 1.05;\n                    const erLowerB = 0.12 + Math.max(0, Math.cos(ePhase + Math.PI + 0.55)) * 1.05;\n                    updateLegFK(enemy.limbs, 'left',  elUpperA, elLowerB);\n                    updateLegFK(enemy.limbs, 'right', erUpperA, erLowerB);\n\n                    // Arms pump hard, opposite to legs \u2014 elbows driven back and forward\n                    enemy.limbs.leftArm.obj.rotation.x  = Math.sin(ePhase + Math.PI) * 0.75;\n                    enemy.limbs.rightArm.obj.rotation.x = Math.sin(ePhase) * 0.75;\n                    // Slight inward arm swing (z-axis) for a natural pumping motion\n                    enemy.limbs.leftArm.obj.rotation.z  =  0.08 + Math.max(0, Math.sin(ePhase + Math.PI)) * 0.12;\n                    enemy.limbs.rightArm.obj.rotation.z = -0.08 - Math.max(0, Math.sin(ePhase)) * 0.12;\n\n                    // Body bob: hips rise slightly with each double-step\n                    enemy.group.position.y = Math.abs(Math.sin(ePhase * 2)) * 0.07;\n\n                    // Forward lean for a sprinting posture\n                    enemy.group.rotation.x = 0.18;\n\n                    // Subtle side-to-side sway (hips shifting weight each stride)\n                    enemy.group.rotation.z = Math.sin(ePhase * 2) * 0.045;\n                }\n\n                // Wave completion \u2014 next wave when all enemies cleared\n                if (!(currentBigLevel === 0 && currentCourse === 0)) {\n                    if (enemies.length === 0 && waveTransition <= 0) {\n                        if (currentWave >= totalWaves) {\n                            waveTransition = 2.5;\n                        } else {\n                            waveTransition = 2.0;\n                        }\n                    }\n\n                    if (waveTransition > 0) {\n                        waveTransition -= dt;\n                        if (waveTransition <= 0) {\n                            if (currentWave < totalWaves) currentWave++;\n                            spawnWave(currentWave);\n                        }\n                    }\n                }\n\n\n            }\n\n            // Course / Level transition timer\n            if (gameState === 'courseComplete' || gameState === 'levelComplete') {\n                levelTransitionTimer -= dt;\n                if (levelTransitionTimer <= 0) {\n                    if (gameState === 'courseComplete') {\n                        currentCourse++;\n                    } else {\n                        currentBigLevel++;\n                        currentCourse = 0;\n                        checkpointBigLevel = currentBigLevel; // save checkpoint on new big level\n                    }\n                    // Reset ball to near side\n                    footballMesh.position.set((Math.random() - 0.5) * 10, 0.44, 8);\n                    ballVelX = 0; ballVelY = 0; ballVelZ = 0;\n                    ballAirborne = false; ballStrength = 0; ballDriftVelX = 0;\n                    shotsTakenThisCourse = 0;\n                    // Clear trails\n                    trailSamples.length = 0;\n                    lastTrailPos.set(9999, 0, 9999);\n                    if (trailMesh) { scene.remove(trailMesh); trailMesh = null; }\n                    if (trailGeo) { trailGeo.dispose(); trailGeo = null; }\n                    airTrailSamples.length = 0;\n                    lastAirTrailPos.set(9999, 9999, 9999);\n                    if (airTrailMesh) { scene.remove(airTrailMesh); airTrailMesh = null; }\n                    if (airTrailGeo) { airTrailGeo.dispose(); airTrailGeo = null; }\n                    // Spawn new course obstacles\n                    spawnLevelObstacles(currentBigLevel, currentCourse);\n                    // Reset enemies\n                    enemies.forEach(e => scene.remove(e.group));\n                    enemies = [];\n                    currentWave = 1;\n                    waveTransition = 0;\n                    const shouldHaveEnemies = !(currentBigLevel === 0 && currentCourse === 0);\n                    if (shouldHaveEnemies) spawnWave(1);\n                    player.group.visible = false;\n                    gameState = 'playing';\n                    renderUI();\n                }\n            }\n\n            // Update confetti\n            updateConfetti(dt);\n            updateExplosion(dt);\n            updateBarrierHitParts(dt);\n\n            // Combo toast tick\n            if (comboToastTimer > 0) {\n                comboToastTimer -= dt;\n                if (comboToastTimer < 0) comboToastTimer = 0;\n                _updateComboToast();\n            } else {\n                _hideComboToast();\n            }\n\n            // Update particles\n            for (let i = particles.length - 1; i >= 0; i--) {\n                const p = particles[i];\n                p.life -= dt * 2;\n                p.vel.y -= 9.8 * dt;\n                p.mesh.position.add(p.vel.clone().multiplyScalar(dt));\n                p.mesh.scale.setScalar(p.life);\n                if (p.life <= 0) {\n                    scene.remove(p.mesh);\n                    particles.splice(i, 1);\n                }\n            }\n\n            // Goal indicator triangle \u2014 pulse when ball is inside goal box\n            if (goalTriIndicator) {\n                const bx = footballMesh.position.x;\n                const by = footballMesh.position.y;\n                const bz = footballMesh.position.z;\n                const GOAL_FRONT_ZT = -(hd + 0.5);\n                const inGoal = bz < GOAL_FRONT_ZT &&\n                               bz > GOAL_FRONT_ZT - 3.6 &&\n                               Math.abs(bx) < 6.5 &&\n                               by >= 0 && by < 5.2;\n                if (inGoal) {\n                    // Pulse the opacity to draw attention\n                    goalTriIndicator.material.opacity = 0.55 + 0.35 * Math.abs(Math.sin(timestamp * 0.005));\n                } else {\n                    // Fade back to visible guide opacity\n                    goalTriIndicator.material.opacity += (0.35 - goalTriIndicator.material.opacity) * Math.min(1, dt * 4);\n                }\n            }\n\n            // Ball trail\n            updateTrail(dt, timestamp);\n            updateAirTrail(dt, timestamp);\n\n            // Camera \u2014 return to start on victory/defeat, otherwise follow ball\n            const ballPos = footballMesh.position;\n            const isStrongShot = ballAirborne && ballStrength >= 0.5;\n            const isEndState = gameState === 'victory' || gameState === 'defeat' || gameState === 'levelComplete' || gameState === 'courseComplete';\n\n            if (isEndState) {\n                // Smoothly return to the starting overview position\n                const startPos = new THREE.Vector3(0, 6, 12);\n                const startLook = new THREE.Vector3(0, 1.5, 0);\n                camera.position.lerp(startPos, 2.5 * dt);\n                // Smoothly rotate lookAt toward center\n                const currentLook = new THREE.Vector3();\n                camera.getWorldDirection(currentLook);\n                const desiredDir = startLook.clone().sub(camera.position).normalize();\n                const blendedDir = currentLook.lerp(desiredDir, 2.5 * dt);\n                camera.lookAt(camera.position.clone().add(blendedDir));\n                camera.fov = THREE.MathUtils.lerp(camera.fov, 60, 2.5 * dt);\n            } else if (isStrongShot) {\n                // Position camera behind the ball's travel direction, close and slightly low\n                const shotDir = new THREE.Vector3(ballVelX, 0, ballVelZ);\n                if (shotDir.length() > 0.01) shotDir.normalize(); else shotDir.set(0, 0, 1);\n                const behind = shotDir.clone().multiplyScalar(-14);\n                const focusTarget = new THREE.Vector3(\n                    ballPos.x + behind.x,\n                    ballPos.y + 6.0,\n                    ballPos.z + behind.z\n                );\n                camera.position.lerp(focusTarget, 3 * dt);\n                camera.lookAt(ballPos.clone().add(new THREE.Vector3(0, 0.5, 0)));\n                // Subtle FOV narrowing \u2014 just a gentle zoom\n                camera.fov = THREE.MathUtils.lerp(camera.fov, 54, 3 * dt);\n            } else {\n                // Normal wide follow\n                const camTarget = ballPos.clone().add(new THREE.Vector3(0, 7, 18));\n                camera.position.lerp(camTarget, 3 * dt);\n                camera.lookAt(ballPos.clone().add(new THREE.Vector3(0, 0.5, 0)));\n                // Restore default FOV\n                camera.fov = THREE.MathUtils.lerp(camera.fov, 60, 3 * dt);\n            }\n            camera.updateProjectionMatrix();\n\n            // Screen shake\n            if (screenShakeIntensity > 0) {\n                const shakeX = (Math.random() - 0.5) * 2 * screenShakeIntensity;\n                const shakeY = (Math.random() - 0.5) * 2 * screenShakeIntensity;\n                camera.position.x += shakeX;\n                camera.position.y += shakeY;\n                screenShakeIntensity -= screenShakeDecay * dt;\n                if (screenShakeIntensity < 0) screenShakeIntensity = 0;\n            }\n\n            // Render\n            renderer.render(scene, camera);\n            renderUIIfChanged();\n            requestAnimationFrame(gameLoop);\n        }\n\n        requestAnimationFrame((ts) => { lastTime = ts; gameLoop(ts); });\n    }\n    </script>\n</body>\n</html>", "game_config": "{\n  \"playerHealth\": 100,\n  \"enemyHealth\": 50,\n  \"enemySpeed\": 1.0,\n  \"numWaves\": 3,\n  \"enemiesPerWave\": 3,\n  \"levels\": [\n    {\n      \"name\": \"Level 1 - Kick Off\",\n      \"courses\": [\n        {\n          \"name\": \"Course 1 - Warm Up\",\n          \"obstacles\": []\n        },\n        {\n          \"name\": \"Course 2 - First Barrier\",\n          \"obstacles\": [\n            {\n              \"x\": -4,\n              \"z\": 0,\n              \"rot\": 0,\n              \"count\": 2\n            },\n            {\n              \"x\": 4,\n              \"z\": 0,\n              \"rot\": 0,\n              \"count\": 2\n            }\n          ]\n        },\n        {\n          \"name\": \"Course 3 - The Wall\",\n          \"obstacles\": [\n            {\n              \"x\": 0,\n              \"z\": -4,\n              \"rot\": 0,\n              \"count\": 5\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"Level 2 - The Gauntlet\",\n      \"courses\": [\n        {\n          \"name\": \"Course 1 - Open Field\",\n          \"obstacles\": []\n        },\n        {\n          \"name\": \"Course 2 - Side Gates\",\n          \"obstacles\": [\n            {\n              \"x\": -5,\n              \"z\": -3,\n              \"rot\": 0,\n              \"count\": 2\n            },\n            {\n              \"x\": 5,\n              \"z\": -3,\n              \"rot\": 0,\n              \"count\": 2\n            }\n          ]\n        },\n        {\n          \"name\": \"Course 3 - Full Block\",\n          \"obstacles\": [\n            {\n              \"x\": -5,\n              \"z\": -3,\n              \"rot\": 0,\n              \"count\": 2\n            },\n            {\n              \"x\": 5,\n              \"z\": -3,\n              \"rot\": 0,\n              \"count\": 2\n            },\n            {\n              \"x\": 0,\n              \"z\": -8,\n              \"rot\": 0,\n              \"count\": 3\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"Level 3 - The Maze\",\n      \"courses\": [\n        {\n          \"name\": \"Course 1 - Narrow Path\",\n          \"obstacles\": [\n            {\n              \"x\": -4,\n              \"z\": -2,\n              \"rot\": 1.5708,\n              \"count\": 2\n            },\n            {\n              \"x\": 4,\n              \"z\": -2,\n              \"rot\": 1.5708,\n              \"count\": 2\n            }\n          ]\n        },\n        {\n          \"name\": \"Course 2 - Mid Maze\",\n          \"obstacles\": [\n            {\n              \"x\": -4,\n              \"z\": -2,\n              \"rot\": 1.5708,\n              \"count\": 2\n            },\n            {\n              \"x\": 4,\n              \"z\": -2,\n              \"rot\": 1.5708,\n              \"count\": 2\n            },\n            {\n              \"x\": 0,\n              \"z\": -5,\n              \"rot\": 0,\n              \"count\": 3\n            }\n          ]\n        },\n        {\n          \"name\": \"Course 3 - Full Maze\",\n          \"obstacles\": [\n            {\n              \"x\": -4,\n              \"z\": -2,\n              \"rot\": 1.5708,\n              \"count\": 2\n            },\n            {\n              \"x\": 4,\n              \"z\": -2,\n              \"rot\": 1.5708,\n              \"count\": 2\n            },\n            {\n              \"x\": 0,\n              \"z\": -5,\n              \"rot\": 0,\n              \"count\": 3\n            },\n            {\n              \"x\": -6,\n              \"z\": -8,\n              \"rot\": 0,\n              \"count\": 2\n            },\n            {\n              \"x\": 6,\n              \"z\": -8,\n              \"rot\": 0,\n              \"count\": 2\n            },\n            {\n              \"x\": 0,\n              \"z\": -11,\n              \"rot\": 0,\n              \"count\": 4\n            }\n          ]\n        }\n      ]\n    }\n  ]\n}", "game_design_document": "## Core Game Loop and Mechanics\n\n**Ragdoll Rumble** is a third-person 3D physics-based brawler where players directly manipulate their character's limbs to attack enemies. The core gameplay revolves around:\n\n1. **Direct Limb Control**: Players click/tap and drag individual limbs (arms and legs) to create attacking motions\n2. **Physics-Driven Combat**: Limbs follow the drag motion with realistic physics, creating momentum-based attacks\n3. **Enemy Encounters**: Enemies spawn in waves and must be knocked down/defeated using limb attacks\n4. **Progression**: Clear waves of increasingly challenging enemies to progress through the level\n\n**Moment-to-Moment Gameplay:**\n- Player sees enemies approaching their character\n- Player clicks/taps on an arm or leg\n- Player drags the limb in a sweeping motion (punch, kick, swing)\n- The limb follows with physics momentum, potentially hitting enemies\n- Successful hits knock enemies back or down\n- Player chains attacks to clear all enemies in the wave\n- New wave spawns with more or tougher enemies\n\n**What Makes It Compelling:**\n- Intuitive, tactile control scheme that feels satisfying\n- Physics-based combat creates emergent, unpredictable moments\n- Visual feedback of ragdoll physics makes every hit feel impactful\n- Simple to learn but allows for creative attack combinations\n- Immediate gratification from knocking enemies around\n\n**Win Condition**: Defeat all enemies in all waves\n**Lose Condition**: Player's health reaches zero (enemies can damage player when they get close)\n\n## Starting Scope\n\nFor the first iteration, we'll implement a **minimal but polished prototype** focusing on core mechanics:\n\n**MUST HAVE (First Iteration):**\n- Single arena-style level (flat ground with boundaries)\n- Player character with 4 draggable limbs (2 arms, 2 legs)\n- Basic physics-driven limb movement responding to drag input\n- 2-3 waves of simple enemy types (basic melee enemies)\n- Simple health system for player and enemies\n- Basic hit detection between limbs and enemies\n- Victory/defeat screens\n- Simple UI showing health and wave number\n\n**LATER ITERATIONS:**\n- Multiple levels with varied environments\n- More enemy types with different behaviors\n- Special moves or power-ups\n- Combo system and scoring\n- Environmental hazards or interactive objects\n- Character customization\n- More complex physics interactions\n\n## Game Perspective\n\n**3D Third-Person** - The camera is positioned behind and slightly above the player character, providing a clear view of:\n- The player character's full body (to see all limbs)\n- Surrounding enemies approaching from all directions\n- The immediate combat arena\n\nCamera should:\n- Follow the player character smoothly\n- Maintain a fixed distance and angle for consistency\n- Allow the player to see incoming enemies from multiple directions\n- Not require manual camera control (auto-follows player orientation)\n\n## Game World and Level Design\n\n**Setting**: A simple, stylized 3D arena that serves as a combat testing ground\n\n**Level 1 - The Arena:**\n- Circular or square flat platform (approximately 20x20 units)\n- Low walls or barriers around the perimeter to define boundaries\n- Simple ground texture (concrete, dirt, or stylized floor)\n- Minimal environmental details to keep focus on combat\n- Good lighting to clearly see character and enemy movements\n\n**Spatial Layout:**\n- Player starts in the center\n- Enemies spawn at the edges and move toward the player\n- Enough space for the player to maneuver and create attacking arcs\n- Clear visual distinction between play area and boundaries\n\n**Wave Structure:**\n- Wave 1: 3 basic enemies spawn from different edges\n- Wave 2: 5 basic enemies spawn in a circle around player\n- Wave 3: 7 enemies spawn in groups\n- Victory screen after completing all waves\n\n## Edit Mode Design\n\nFor the first iteration, keep edit mode **extremely simple** to prioritize gameplay polish:\n\n**Editable Properties:**\n- **Player Health**: Slider (50-200 HP, default 100)\n- **Enemy Health**: Slider (20-100 HP, default 50)\n- **Enemy Speed**: Slider (0.5-2.0x, default 1.0x)\n- **Number of Waves**: Slider (1-5, default 3)\n- **Enemies Per Wave**: Slider (2-10, default starts at 3)\n\n**Constraints:**\n- All values have reasonable min/max to prevent broken gameplay\n- Changes take effect on game restart\n- Simple UI with clear labels\n\n**Future Edit Mode Features:**\n- Arena size and shape\n- Enemy types and spawn patterns\n- Player character appearance\n- Special abilities or power-ups\n\n## Control Design\n\n**Genre**: 3D Third-Person Action/Brawler with unique limb-dragging mechanic\n\n**Core Mechanic - Limb Dragging:**\n\n**Desktop (Mouse):**\n1. **Hover over limb** - Limb highlights to show it's selectable\n2. **Click and hold** on a limb (arm or leg) to grab it\n3. **Drag mouse** in any direction - limb follows with physics momentum\n4. **Release mouse** - limb returns to neutral position with physics\n5. **Movement**: WASD or Arrow keys to move character around arena (optional for first iteration - can be stationary)\n\n**Mobile (Touch):**\n1. **Tap on limb** to select it (limb highlights)\n2. **Drag finger** in any direction - limb follows the touch point\n3. **Release touch** - limb returns to neutral position\n4. **Movement**: Optional virtual joystick in bottom-left for character movement (can be omitted for stationary first iteration)\n\n**Limb Selection Zones:**\n- Each limb has a clear hitbox for selection\n- Visual feedback (highlight, glow, outline) when hovering/touching\n- Only one limb can be controlled at a time for simplicity\n\n**Attack Execution:**\n- Fast drag = more momentum = stronger attack\n- Drag direction determines attack trajectory\n- Physics handles collision and impact\n- Visual and audio feedback on successful hits\n\n**Camera:**\n- Auto-rotating to keep enemies in view\n- No manual camera control needed\n- Smooth following of player position\n\n**Input Handling:**\n- Prevent default browser behavior for drag events\n- Clear input state on window blur\n- Track which limb is currently being controlled\n- Smooth interpolation between drag position and limb position\n\n**Simplicity Focus:**\n- One action at a time (drag one limb)\n- No complex button combinations\n- Intuitive, direct manipulation\n- Works equally well on mobile and desktop\n\n## Dynamic Text\n\nNo dynamic text generation needed for this game. All UI text (health, wave numbers, victory/defeat messages) will be static and predefined.\n\n## Art Style and Visual Theme\n\n**Overall Style**: Realistic-Cartoony Hybrid (3D)\n- Realistic proportions and physics behavior\n- Cartoony, exaggerated expressions and reactions\n- Vibrant, saturated colors\n- Smooth, stylized 3D models (not photorealistic)\n- Think: Fall Guys meets Gang Beasts aesthetic\n\n**Color Palette:**\n- Player: Bright, heroic colors (blue, red, or customizable)\n- Enemies: Contrasting colors (dark grays, purples, reds)\n- Environment: Neutral tones (grays, browns) to make characters pop\n- UI: Clean, modern with high contrast for readability\n\n**Character Design:**\n- Simplified humanoid forms with clear limb definition\n- Slightly exaggerated proportions (larger hands/feet for visibility)\n- Smooth, rounded shapes (no sharp edges)\n- Clear visual distinction between player and enemies\n- Expressive faces (simple eyes and mouth)\n\n**Visual Effects:**\n- Impact particles when limbs hit enemies\n- Motion trails on fast limb movements\n- Knockback animations for enemies\n- Health indicators (floating numbers or bars)\n- Subtle glow/outline for selected limbs\n\n**Technical Approach:**\n- All characters and environment rendered as 3D geometry (Three.js)\n- No 2D sprite overlays except for UI elements (health bars, text)\n- Simple geometric shapes combined to create characters (spheres, cylinders, boxes)\n- Flat shading or toon shading for cartoony look\n- Minimal textures - rely on solid colors and simple patterns\n\n**Asset Sizes & Scale:**\n- Player character: ~2 units tall\n- Enemy characters: ~1.8 units tall\n- Arena: ~20x20 units\n- Consistent scale across all 3D elements\n\n**Viewing Angles:**\n- All 3D models designed to look good from third-person camera angle\n- Clear silhouettes from behind and above\n- Limbs clearly visible and distinguishable", "persistent_storage_config": null, "game_metadata": {"game_id": "01KTY94AM77A1QFDPQ5HGSJX24", "title": "Crazy Shot", "description": "Control your character's limbs by dragging them to unleash physics-driven attacks on cartoony bad guys in chaotic 3D combat!", "thumbnail_url": "https://dzmv1mxivz48k.cloudfront.net/images/Google_103779509664467693555/1781510441809_01KV54R2THZB4MCZ9G9537X5YC.file_0000000078047209825f4a834b1bbb6a.png", "created_at": 1781280176, "updated_at": 1781586687128.7463, "art_style": null}, "task_plan": null}