{"asset_map": {"player_bean_blue": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780567432205_01KT91DQ0DY1W42KXD63EB36HW.player-blue.png", "type": "image", "prompt": null, "description": "A blue bean-shaped player character with expressive eyes and a cartoon style.", "subtype": null, "aspect_ratio": [1536, 1024], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}, "ai_bean_red": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780567432299_01KT91DQ3BFJ7GME2WNZ5X7FM8.player-red.png", "type": "image", "prompt": null, "description": "A red bean-shaped AI opponent character with a bow, matching the player style.", "subtype": null, "aspect_ratio": [1536, 1024], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}, "tennis_racket": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/tennis_racket_0b36a79a-4b6c-4946-8896-e12dde4bb5a8.webp", "type": "image", "prompt": "Change the main body and filler color of the tennis racket in image 1 from dark blue to a neutral mid-tone gray (hex #808080). Maintain the dark blue handle, frame outline, and string details exactly as they appear in the original. Ensure the gray area is flat and clean, matching the cartoon style of the existing asset while keeping the background transparent.", "description": "A neutral gray version of the cartoon tennis racket with its original frame and handle structure preserved.", "subtype": "object", "aspect_ratio": [514, 1248], "compression_suggestion": "medium", "duration_secs": null, "loop": null, "animation": null}, "tennis_ball": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/tennis_ball_147de84a-a497-463b-9751-b201e7f6e873.webp", "type": "image", "prompt": "Generate a bright yellow-green tennis ball for a casual game. The ball is a perfect circle with a simple white curved seam line and a small white highlight for dimension. Bold 2px black outline, flat 2D cartoon vector style. Transparent background.", "description": "A bright yellow-green cartoon tennis ball.", "subtype": "object", "aspect_ratio": [827, 832], "compression_suggestion": "high", "duration_secs": null, "loop": null, "animation": null}, "ball_hit_sound": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/ball_hit_sound_1b113f47-ba8c-4800-8254-ed757ac63436.mp3", "type": "audio", "prompt": "A sharp, punchy 'pock' sound effect of a tennis racket hitting a tennis ball. High-quality, hollow rubbery texture, 0.2 seconds duration, arcade-style clarity, bright and energetic, minimal reverb tail.", "description": "Short, punchy tennis racket impact sound with a hollow 'pock' quality.", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0.15, "loop": false, "animation": null}, "ball_bounce_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780664480749_01KTBXZCZCRQV47XNKH20NVXRN.bounce-1.wav", "type": "audio", "prompt": null, "description": "A single, crisp impact sound of a tennis ball hitting a hard court surface, optimized for rapid gameplay triggers.", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "score_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780715146510_01KTDE9K8EXDFM8WDJ88WWFC4Y.score-increase.wav", "type": "audio", "prompt": null, "description": "Celebratory chime sound for when a player scores a point", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "scale_event_start_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780734141259_01KTE0D8TB7CFSYJ3RY5FK8S83.snd-match-event-start.wav", "type": "audio", "prompt": null, "description": "Sound that plays when a match event starts (e.g. Giant Players, Tiny Players, Big Ball).", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "scale_event_end_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780734141360_01KTE0D8XG4K7QS3G18H2N6SJM.snd-match-event-stop.wav", "type": "audio", "prompt": null, "description": "Sound that plays when a match event ends and players return to normal.", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "victory_sound": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/victory_sound_3d2e80fd-6d22-4f57-be4e-4baed6fd289d.mp3", "type": "audio", "prompt": "Triumphant, upbeat victory fanfare for a mobile game. Bright major-key melody, 2.5 seconds long, brass and glockenspiel, celebratory and cheerful, high-energy finish.", "description": "Short, triumphant fanfare for winning the tennis match.", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 3, "loop": false, "animation": null}, "defeat_sound": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/defeat_sound_b64b60fe-227a-4724-b257-2b387c60c1a2.mp3", "type": "audio", "prompt": "Gentle, slightly sad 'game over' melody. Descending musical notes, 1.5 seconds duration, soft synth or marimba, lighthearted and encouraging rather than harsh.", "description": "Soft, descending 'game over' sound for losing the match.", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 3, "loop": false, "animation": null}, "jump_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780664876522_01KTBYBFFA34E3WVX9N4FH1W4T.jump-swoosh.wav", "type": "audio", "prompt": null, "description": "A light and bouncy cartoony jump sound for the bean character", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "stadium_backdrop": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780716760847_01KTDFTVRFJEHXYG9T33DKYBTR.stadium-02.png", "type": "image", "prompt": null, "description": "A 2D cartoon stadium with colorful curved bleachers, arch-shaped seats in rainbow colors, small flags along the top rail, and a transparent/white background. Wide landscape format.", "subtype": null, "aspect_ratio": [1536, 1024], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}, "tennis_court_surface": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780566515381_01KT90HQNNY5NM8PRA8KJ77DYA.court-01.png", "type": "image", "prompt": null, "description": "A 2D green tennis court with perspective, white lines, viewed from a shallow angle for a mobile tennis game.", "subtype": null, "aspect_ratio": [1536, 1024], "compression_suggestion": "low", "duration_secs": null, "loop": null, "animation": null}, "sky_background": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780666907067_01KTC09EDV6M1T5TV5PPR0K0QS.sky.png", "type": "image", "prompt": null, "description": "Large sky background with scattered clouds on a blue gradient, used as a world-space background that reveals more when camera zooms out.", "subtype": null, "aspect_ratio": [941, 1672], "compression_suggestion": "original", "duration_secs": null, "loop": null, "animation": null}, "button_pop_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780735401684_01KTE1KQPMRYBCGFNH4N2JZ9AC.snd-button-pop.mp3", "type": "audio", "prompt": null, "description": "A short, snappy pop sound effect for button clicks in the game UI.", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "countdown_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780739263443_01KTE59JYKMTM2FGMPX95YJYJH.snd-countdown.mp3", "type": "audio", "prompt": null, "description": "A countdown beep sound that plays on each number (3, 2, 1) during the match intro sequence.", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "go_sound": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780739486827_01KTE5GD3AQXPBBKZ5GX81QFT2.snd-go-positive-mp3.mp3", "type": "audio", "prompt": null, "description": "A positive \"GO!\" sound effect that plays when the match countdown reaches GO.", "subtype": null, "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 0, "loop": null, "animation": null}, "menu_background": {"url": "https://dzmv1mxivz48k.cloudfront.net/images/3891a370-a061-70c0-7fd1-14cc6ef142af/1780803514664_01KTG2JC98AXZKGZBXWSHBA96F.tiny-tennis-capsule-9by16.png", "type": "image", "prompt": null, "description": "Main menu background image featuring the Tiny Tennis logo, blue and red bean characters, tennis ball, and stadium. Used as the full-screen background for the main menu.", "subtype": null, "aspect_ratio": [941, 1672], "compression_suggestion": "original", "duration_secs": null, "loop": null, "animation": null}}, "game_code": "<!DOCTYPE html>\n<html>\n<head>\n    <title>Tiny Tennis</title>\n    <link href=\"https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n    <style>\n        :root {\n            --transition-fast: 150ms;\n            --transition-normal: 300ms;\n            --transition-slow: 500ms;\n            --ease-out: cubic-bezier(0.0, 0, 0.2, 1);\n            --ease-bounce: cubic-bezier(0.68, -0.6, 0.32, 1.6);\n            --space-xs: 8px;\n            --space-sm: 16px;\n            --space-md: 24px;\n            --space-lg: 32px;\n            --space-xl: 48px;\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', sans-serif;\n        }\n\n        #game-world {\n            position: relative;\n            width: 100%;\n            height: 100%;\n            background-color: #87CEEB;\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        .score-container {\n            position: absolute;\n            top: 16px;\n            left: 50%;\n            transform: translateX(-50%);\n            display: flex;\n            align-items: center;\n            gap: 16px;\n        }\n\n        .score-box {\n            padding: 16px 32px;\n            border-radius: 20px;\n            font-size: 58px;\n            font-weight: 700;\n            color: white;\n            text-shadow: 3px 3px 8px rgba(0,0,0,0.5);\n            border: 4px solid rgba(255,255,255,0.3);\n            min-width: 80px;\n            text-align: center;\n            box-shadow: 0 4px 16px rgba(0,0,0,0.3);\n        }\n\n        .score-player { \n            background: #1a4fa0;\n            border-color: rgba(255,255,255,0.6);\n            box-shadow: 0 4px 16px rgba(0,0,0,0.4);\n        }\n        .score-ai { background: #E74C3C; }\n\n        .vs-text {\n            font-size: 32px;\n            font-weight: 700;\n            color: white;\n            text-shadow: 2px 2px 4px rgba(0,0,0,0.4);\n        }\n\n        .rally-counter {\n            display: none;\n        }\n\n        .event-timer {\n            position: absolute;\n            top: 130px;\n            left: 50%;\n            transform: translateX(-50%);\n            background: rgba(0,0,0,0.75);\n            color: white;\n            padding: 14px 28px;\n            border-radius: 20px;\n            font-size: 30px;\n            font-weight: 700;\n            text-shadow: 1px 1px 3px rgba(0,0,0,0.4);\n            border: 3px solid rgba(255,255,255,0.25);\n            display: none;\n            min-width: 220px;\n            text-align: center;\n        }\n\n        .event-timer-label {\n            margin-bottom: 10px;\n            letter-spacing: 1px;\n        }\n\n        .event-timer-bar-bg {\n            width: 100%;\n            height: 10px;\n            background: rgba(255,255,255,0.15);\n            border-radius: 5px;\n            overflow: hidden;\n        }\n\n        .event-timer-bar {\n            height: 100%;\n            background: linear-gradient(90deg, #F39C12, #9B59B6);\n            border-radius: 5px;\n            width: 100%;\n        }\n\n        .btn-circle {\n            width: 80px;\n            height: 80px;\n            border-radius: 50%;\n            background: rgba(10, 22, 54, 0.72);\n            border: 2px solid rgba(255,255,255,0.18);\n            box-shadow: 0 4px 16px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.12);\n            backdrop-filter: blur(8px);\n            -webkit-backdrop-filter: blur(8px);\n            color: white;\n            font-size: 32px;\n            line-height: 1;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            padding-bottom: 2px;\n            cursor: pointer;\n            transition: transform var(--transition-fast) var(--ease-out), background var(--transition-fast);\n        }\n\n        .btn-circle:hover {\n            background: rgba(30, 55, 120, 0.85);\n        }\n\n        .btn-circle:active {\n            transform: scale(0.88);\n            background: rgba(10, 22, 54, 0.9);\n        }\n\n        .pause-btn {\n            position: absolute;\n            top: 16px;\n            left: 16px;\n        }\n\n        .sound-btn {\n            position: absolute;\n            top: 16px;\n            right: 16px;\n        }\n\n        .game-over-overlay {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            background: rgba(0,0,0,0.8);\n            display: none;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            z-index: 20;\n            gap: 24px;\n            padding: 40px;\n            box-sizing: border-box;\n        }\n\n        .game-over-emoji {\n            font-size: 120px;\n            animation: emojiBounce 0.6s var(--ease-bounce) 0.2s both;\n        }\n\n        @keyframes emojiBounce {\n            0% { transform: scale(0) rotate(-20deg); }\n            100% { transform: scale(1) rotate(0deg); }\n        }\n\n        .game-over-text {\n            font-size: 72px;\n            font-weight: 700;\n            color: white;\n            text-shadow: 3px 3px 12px rgba(0,0,0,0.6);\n            margin-bottom: 4px;\n        }\n\n        .game-over-difficulty {\n            font-size: 28px;\n            font-weight: 600;\n            color: rgba(255,255,255,0.6);\n            margin-bottom: 4px;\n        }\n\n        .game-over-score {\n            font-size: 48px;\n            font-weight: 700;\n            color: #FFD700;\n            text-shadow: 2px 2px 6px rgba(0,0,0,0.5);\n            margin-bottom: 24px;\n        }\n\n        .game-over-bottom-section {\n            width: 100%;\n            max-width: 500px;\n            display: flex;\n            flex-direction: column;\n            align-items: stretch;\n            gap: 0;\n            transform: translateY(80px);\n            opacity: 0;\n            transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.4s ease;\n        }\n\n        .game-over-bottom-section.slide-in {\n            transform: translateY(0);\n            opacity: 1;\n        }\n\n        .game-over-difficulty-section {\n            width: 100%;\n            max-width: 500px;\n            margin-bottom: 12px;\n        }\n\n        .game-over-diff-label {\n            font-size: 32px;\n            font-weight: 700;\n            color: white;\n            text-align: center;\n            margin-bottom: 16px;\n            transition: color 0.3s ease;\n        }\n\n        .game-over-slider-container {\n            width: 100%;\n            position: relative;\n            padding: 20px 0;\n            margin-bottom: 8px;\n        }\n\n        .game-over-slider-track {\n            width: 100%;\n            height: 14px;\n            border-radius: 7px;\n            background: linear-gradient(90deg, #2ECC71, #F1C40F, #E67E22, #E74C3C, #8E44AD);\n            position: relative;\n            box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);\n        }\n\n        .game-over-slider-thumb {\n            position: absolute;\n            top: 50%;\n            transform: translate(-50%, -50%);\n            width: 48px;\n            height: 48px;\n            border-radius: 50%;\n            background: white;\n            border: 4px solid rgba(0,0,0,0.2);\n            box-shadow: 0 4px 12px rgba(0,0,0,0.4);\n            cursor: pointer;\n            transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n            z-index: 2;\n        }\n\n        .game-over-slider-slots {\n            position: absolute;\n            top: 50%;\n            left: 0;\n            right: 0;\n            transform: translateY(-50%);\n            display: flex;\n            justify-content: space-between;\n            padding: 0 2px;\n            pointer-events: none;\n        }\n\n        .game-over-slot-dot {\n            width: 10px;\n            height: 10px;\n            border-radius: 50%;\n            background: rgba(255,255,255,0.6);\n            border: 2px solid rgba(0,0,0,0.2);\n        }\n\n        .game-over-labels-row {\n            display: none;\n            justify-content: space-between;\n            width: 100%;\n            margin-top: 10px;\n        }\n\n        .game-over-slot-label {\n            font-size: 14px;\n            font-weight: 600;\n            color: rgba(255,255,255,0.5);\n            text-align: center;\n            width: 20%;\n            text-transform: uppercase;\n            transition: color 0.3s ease, transform 0.3s ease;\n        }\n\n        .game-over-slot-label.active {\n            color: white;\n            transform: scale(1.1);\n        }\n\n        .game-over-buttons {\n            display: flex;\n            flex-direction: column;\n            gap: 18px;\n            width: 100%;\n            max-width: 500px;\n            align-items: stretch;\n        }\n\n        .game-over-btn {\n            padding: 32px 52px;\n            border-radius: 22px;\n            font-size: 36px;\n            font-weight: 700;\n            letter-spacing: 2px;\n            text-transform: uppercase;\n            border: none;\n            border-top: 5px solid rgba(255,255,255,0.4);\n            border-bottom: 6px solid rgba(0,0,0,0.35);\n            cursor: pointer;\n            font-family: 'Fredoka', sans-serif;\n            transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;\n            color: white;\n            text-shadow: 0 3px 5px rgba(0,0,0,0.4);\n            box-shadow: 0 8px 0 rgba(0,0,0,0.4), 0 14px 32px rgba(0,0,0,0.5);\n            text-align: center;\n        }\n\n        .game-over-btn:hover {\n            transform: scale(1.04);\n            filter: brightness(1.08);\n        }\n\n        .game-over-btn:active {\n            transform: scale(0.96) translateY(4px);\n            box-shadow: 0 2px 0 rgba(0,0,0,0.35), 0 4px 10px rgba(0,0,0,0.3);\n        }\n\n        .btn-again { background: linear-gradient(180deg, #2ECC71 0%, #1E8449 100%); }\n        .btn-harder { background: linear-gradient(180deg, #F39C12 0%, #D35400 100%); }\n        .btn-easier { background: linear-gradient(180deg, #5DADE2 0%, #2471A3 100%); }\n\n        .pause-overlay {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            background: rgba(0,0,0,0.5);\n            display: none;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n        }\n\n        .pause-text {\n            font-size: 48px;\n            font-weight: 700;\n            color: white;\n            text-shadow: 3px 3px 6px rgba(0,0,0,0.5);\n        }\n\n        .resume-btn {\n            margin-top: 24px;\n            padding: 28px 56px;\n            border-radius: 20px;\n            background: linear-gradient(180deg, #2ECC71 0%, #1E8449 100%);\n            color: white;\n            font-size: 32px;\n            font-weight: 700;\n            letter-spacing: 1.5px;\n            text-transform: uppercase;\n            font-family: 'Fredoka', sans-serif;\n            cursor: pointer;\n            border: none;\n            border-top: 4px solid rgba(255,255,255,0.35);\n            border-bottom: 5px solid rgba(0,0,0,0.3);\n            text-shadow: 0 2px 4px rgba(0,0,0,0.4);\n            box-shadow: 0 8px 0 rgba(0,0,0,0.35), 0 12px 24px rgba(0,0,0,0.4);\n            transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;\n        }\n\n        .resume-btn:hover {\n            transform: scale(1.04);\n            filter: brightness(1.08);\n        }\n\n        .resume-btn:active {\n            transform: scale(0.96) translateY(4px);\n            box-shadow: 0 2px 0 rgba(0,0,0,0.35), 0 4px 10px rgba(0,0,0,0.3);\n        }\n\n        .intro-overlay {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            background: rgba(0,0,0,0.92);\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            z-index: 25;\n            transition: opacity 0.5s ease;\n        }\n\n        .intro-overlay.fade-out {\n            opacity: 0;\n        }\n\n        .intro-text {\n            color: white;\n            font-size: 52px;\n            font-weight: 700;\n            text-align: center;\n            text-shadow: 2px 2px 8px rgba(0,0,0,0.5);\n            animation: introPopIn 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6) forwards;\n        }\n\n        .intro-emoji {\n            font-size: 72px;\n            margin-bottom: 16px;\n            animation: introPopIn 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6) forwards;\n        }\n\n        .intro-countdown {\n            color: #FFD700;\n            font-size: 96px;\n            font-weight: 700;\n            margin-top: 16px;\n            animation: introPopIn 0.25s cubic-bezier(0.68, -0.6, 0.32, 1.6) forwards;\n        }\n\n        .intro-go {\n            color: #4ADE80;\n            font-size: 96px;\n            font-weight: 700;\n            animation: introPopIn 0.25s cubic-bezier(0.68, -0.6, 0.32, 1.6) forwards;\n        }\n\n        @keyframes introPopIn {\n            0% { transform: scale(0.3); opacity: 0; }\n            100% { transform: scale(1); opacity: 1; }\n        }\n\n        .tap-hint {\n            position: absolute;\n            bottom: 200px;\n            left: 50%;\n            transform: translateX(-50%);\n            color: white;\n            font-size: 24px;\n            font-weight: 600;\n            text-shadow: 2px 2px 4px rgba(0,0,0,0.5);\n            opacity: 0.8;\n            animation: pulse 1.5s infinite;\n        }\n\n        @keyframes pulse {\n            0%, 100% { opacity: 0.8; transform: translateX(-50%) scale(1); }\n            50% { opacity: 0.4; transform: translateX(-50%) scale(0.95); }\n        }\n\n        .tutorial-overlay {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            background: rgba(0,0,0,0.55);\n            display: none;\n            flex-direction: column;\n            align-items: center;\n            justify-content: flex-start;\n            z-index: 26;\n            transition: opacity 0.4s ease;\n        }\n\n        .tutorial-overlay.fade-out {\n            opacity: 0;\n        }\n\n        .tutorial-card {\n            position: absolute;\n            top: 50%;\n            left: 20px;\n            background: rgba(255,255,255,0.97);\n            border-radius: 24px;\n            padding: 28px 40px 24px;\n            text-align: center;\n            box-shadow: 0 8px 40px rgba(0,0,0,0.5);\n            animation: tutorialBounce 1.8s ease-in-out infinite;\n        }\n\n        .tutorial-card::after {\n            content: '';\n            position: absolute;\n            bottom: -20px;\n            left: 28%;\n            transform: translateX(-50%);\n            width: 0; height: 0;\n            border-left: 16px solid transparent;\n            border-right: 16px solid transparent;\n            border-top: 22px solid rgba(255,255,255,0.97);\n        }\n\n        @keyframes tutorialBounce {\n            0%, 100% { transform: translateY(0); }\n            50% { transform: translateY(-10px); }\n        }\n\n        .tutorial-card-text {\n            font-size: 36px;\n            font-weight: 700;\n            color: #1a1a2e;\n            margin-bottom: 12px;\n        }\n\n        .tutorial-tap-icon {\n            font-size: 48px;\n            animation: tapPulse 1s ease-in-out infinite;\n        }\n\n        @keyframes tapPulse {\n            0%, 100% { transform: scale(1); opacity: 1; }\n            50% { transform: scale(0.85); opacity: 0.6; }\n        }\n\n        .tutorial-arrow {\n            position: absolute;\n            top: 70%;\n            left: 18%;\n            transform: translateX(-50%);\n            font-size: 56px;\n            color: #FFD700;\n            animation: arrowPulse 1s ease-in-out infinite;\n        }\n\n        @keyframes arrowPulse {\n            0%, 100% { transform: translateX(-50%) translateY(0); opacity: 1; }\n            50% { transform: translateX(-50%) translateY(8px); opacity: 0.6; }\n        }\n\n        /* ========== MAIN MENU ========== */\n        /* ========== LOADING SCREEN ========== */\n        .loading-overlay {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            z-index: 60;\n            background: #0a0f1e;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            transition: opacity 0.5s ease;\n        }\n\n        .loading-overlay.fade-out {\n            opacity: 0;\n            pointer-events: none;\n        }\n\n        .loading-logo {\n            width: 70%;\n            max-width: 500px;\n            margin-bottom: 48px;\n            object-fit: contain;\n        }\n\n        .loading-text {\n            font-size: 32px;\n            font-weight: 700;\n            color: rgba(255,255,255,0.8);\n            letter-spacing: 4px;\n            text-transform: uppercase;\n        }\n\n        .loading-dots {\n            display: inline-block;\n            width: 40px;\n            text-align: left;\n        }\n\n        @keyframes loadingDots {\n            0% { content: ''; }\n            25% { content: '.'; }\n            50% { content: '..'; }\n            75% { content: '...'; }\n        }\n\n        .loading-dots::after {\n            content: '';\n            animation: loadingDots 1.2s infinite steps(1);\n        }\n\n        .main-menu-overlay {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            z-index: 50;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: flex-start;\n            overflow: hidden;\n            transition: opacity 0.5s ease;\n        }\n\n        .main-menu-overlay.fade-out {\n            opacity: 0;\n            pointer-events: none;\n        }\n\n        .menu-bg {\n            position: absolute;\n            top: 0; left: 0; width: 100%; height: 100%;\n            object-fit: cover;\n            z-index: 0;\n        }\n\n        .menu-content {\n            position: relative;\n            z-index: 1;\n            width: 100%;\n            height: 100%;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: flex-end;\n            padding-bottom: 100px;\n        }\n\n        .menu-play-btn {\n            padding: 36px 100px;\n            border-radius: 24px;\n            background: linear-gradient(180deg, #FF9500 0%, #FF6B00 100%);\n            color: white;\n            font-size: 48px;\n            font-weight: 700;\n            letter-spacing: 3px;\n            text-transform: uppercase;\n            font-family: 'Fredoka', sans-serif;\n            cursor: pointer;\n            border: none;\n            border-top: 5px solid rgba(255,255,255,0.4);\n            border-bottom: 6px solid rgba(0,0,0,0.35);\n            text-shadow: 0 3px 6px rgba(0,0,0,0.4);\n            box-shadow: 0 10px 0 rgba(0,0,0,0.4), 0 16px 40px rgba(0,0,0,0.5);\n            transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;\n            animation: menuBtnPulse 2s ease-in-out infinite;\n        }\n\n        .menu-play-btn:hover {\n            transform: scale(1.05);\n            filter: brightness(1.1);\n        }\n\n        .menu-play-btn:active {\n            transform: scale(0.95) translateY(5px);\n            box-shadow: 0 3px 0 rgba(0,0,0,0.4), 0 6px 12px rgba(0,0,0,0.4);\n            animation: none;\n        }\n\n        @keyframes menuBtnPulse {\n            0%, 100% { transform: scale(1); }\n            50% { transform: scale(1.03); }\n        }\n\n        /* State 2: Difficulty Selection (now on game over screen) */\n        .menu-difficulty-section {\n            display: none;\n            flex-direction: column;\n            align-items: center;\n            width: 88%;\n            max-width: 600px;\n            background: rgba(10, 15, 30, 0.88);\n            border-radius: 32px;\n            padding: 40px 32px 44px;\n            border: 3px solid rgba(255,255,255,0.12);\n            box-shadow: 0 20px 60px rgba(0,0,0,0.6);\n            backdrop-filter: blur(10px);\n            animation: cardPop 0.4s var(--ease-bounce) forwards;\n        }\n\n        .difficulty-label {\n            font-size: 42px;\n            font-weight: 700;\n            color: white;\n            text-transform: uppercase;\n            letter-spacing: 2px;\n            text-shadow: 0 2px 8px rgba(0,0,0,0.5);\n            margin-bottom: 32px;\n            text-align: center;\n            transition: color 0.3s ease;\n        }\n\n        .difficulty-slider-container {\n            width: 100%;\n            position: relative;\n            padding: 20px 0;\n            margin-bottom: 16px;\n        }\n\n        .difficulty-slider-track {\n            width: 100%;\n            height: 16px;\n            border-radius: 8px;\n            background: linear-gradient(90deg, #2ECC71, #F1C40F, #E67E22, #E74C3C, #8E44AD);\n            position: relative;\n            box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);\n        }\n\n        .difficulty-slider-thumb {\n            position: absolute;\n            top: 50%;\n            transform: translate(-50%, -50%);\n            width: 56px;\n            height: 56px;\n            border-radius: 50%;\n            background: white;\n            border: 4px solid rgba(0,0,0,0.2);\n            box-shadow: 0 4px 12px rgba(0,0,0,0.4);\n            cursor: pointer;\n            transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n            z-index: 2;\n        }\n\n        .difficulty-slider-thumb:active {\n            transform: translate(-50%, -50%) scale(1.15);\n            box-shadow: 0 6px 16px rgba(0,0,0,0.5);\n        }\n\n        .difficulty-slider-slots {\n            position: absolute;\n            top: 50%;\n            left: 0;\n            right: 0;\n            transform: translateY(-50%);\n            display: flex;\n            justify-content: space-between;\n            padding: 0 2px;\n            pointer-events: none;\n        }\n\n        .difficulty-slot-dot {\n            width: 12px;\n            height: 12px;\n            border-radius: 50%;\n            background: rgba(255,255,255,0.6);\n            border: 2px solid rgba(0,0,0,0.2);\n        }\n\n        .difficulty-labels-row {\n            display: none;\n            justify-content: space-between;\n            width: 100%;\n            margin-top: 12px;\n            margin-bottom: 36px;\n        }\n\n        .difficulty-slot-label {\n            font-size: 16px;\n            font-weight: 600;\n            color: rgba(255,255,255,0.55);\n            text-align: center;\n            width: 20%;\n            text-transform: uppercase;\n            letter-spacing: 0.5px;\n            transition: color 0.3s ease, transform 0.3s ease;\n        }\n\n        .difficulty-slot-label.active {\n            color: white;\n            transform: scale(1.1);\n        }\n\n        .menu-start-btn {\n            padding: 32px 80px;\n            border-radius: 22px;\n            background: linear-gradient(180deg, #F39C12 0%, #D35400 100%);\n            color: white;\n            font-size: 38px;\n            font-weight: 700;\n            letter-spacing: 2.5px;\n            text-transform: uppercase;\n            font-family: 'Fredoka', sans-serif;\n            cursor: pointer;\n            border: none;\n            border-top: 4px solid rgba(255,255,255,0.4);\n            border-bottom: 6px solid rgba(0,0,0,0.35);\n            text-shadow: 0 3px 5px rgba(0,0,0,0.4);\n            box-shadow: 0 8px 0 rgba(0,0,0,0.4), 0 14px 32px rgba(0,0,0,0.5);\n            transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;\n            width: 100%;\n        }\n\n        .menu-start-btn:hover {\n            transform: scale(1.04);\n            filter: brightness(1.08);\n        }\n\n        .menu-start-btn:active {\n            transform: scale(0.96) translateY(4px);\n            box-shadow: 0 2px 0 rgba(0,0,0,0.4), 0 4px 10px rgba(0,0,0,0.3);\n        }\n    </style>\n</head>\n<body>\n    <div id=\"game-world\">\n        <canvas id=\"game-canvas\" style=\"visibility:hidden;\"></canvas>\n\n        <!-- LOADING SCREEN -->\n        <div class=\"loading-overlay\" id=\"loading-overlay\">\n            <img class=\"loading-logo\" id=\"loading-logo\" src=\"\" alt=\"\">\n            <div class=\"loading-text\">LOADING<span class=\"loading-dots\"></span></div>\n        </div>\n\n        <!-- MAIN MENU -->\n        <div class=\"main-menu-overlay\" id=\"main-menu-overlay\" style=\"display:none;\">\n            <img class=\"menu-bg\" id=\"menu-bg\" src=\"\" alt=\"\">\n            <div class=\"menu-content\">\n                <button class=\"menu-play-btn\" id=\"menu-play-btn\">PLAY</button>\n                <div class=\"menu-difficulty-section\" id=\"menu-difficulty-section\">\n                    <div class=\"difficulty-label\" id=\"difficulty-label\">NORMAL</div>\n                    <div class=\"difficulty-slider-container\" id=\"difficulty-slider-container\">\n                        <div class=\"difficulty-slider-track\">\n                            <div class=\"difficulty-slider-slots\">\n                                <div class=\"difficulty-slot-dot\"></div>\n                                <div class=\"difficulty-slot-dot\"></div>\n                                <div class=\"difficulty-slot-dot\"></div>\n                                <div class=\"difficulty-slot-dot\"></div>\n                                <div class=\"difficulty-slot-dot\"></div>\n                            </div>\n                        </div>\n                        <div class=\"difficulty-slider-thumb\" id=\"difficulty-thumb\"></div>\n                    </div>\n                    <div class=\"difficulty-labels-row\" id=\"difficulty-labels-row\">\n                        <span class=\"difficulty-slot-label\">Very Easy</span>\n                        <span class=\"difficulty-slot-label\">Easy</span>\n                        <span class=\"difficulty-slot-label active\">Normal</span>\n                        <span class=\"difficulty-slot-label\">Hard</span>\n                        <span class=\"difficulty-slot-label\">Expert</span>\n                    </div>\n                    <button class=\"menu-start-btn\" id=\"menu-start-btn\">START MATCH</button>\n                </div>\n            </div>\n        </div>\n\n        <div class=\"ui-overlay\">\n            <div class=\"score-container\">\n                <div class=\"score-box score-player\" id=\"player-score\">0</div>\n                <span class=\"vs-text\">VS</span>\n                <div class=\"score-box score-ai\" id=\"ai-score\">0</div>\n            </div>\n            <div class=\"rally-counter\" id=\"rally-counter\">Rally: 0</div>\n            <div class=\"event-timer\" id=\"event-timer\">\n                <div class=\"event-timer-label\">\u26a1 GIANT PLAYERS</div>\n                <div class=\"event-timer-bar-bg\">\n                    <div class=\"event-timer-bar\" id=\"event-timer-bar\"></div>\n                </div>\n            </div>\n            <div class=\"btn-circle pause-btn\" id=\"pause-btn\">\u23f8</div>\n            <div class=\"btn-circle sound-btn\" id=\"sound-btn\">\ud83d\udd0a</div>\n\n            <div class=\"intro-overlay\" id=\"intro-overlay\" style=\"display:none;\"></div>\n            <div class=\"tutorial-overlay\" id=\"tutorial-overlay\">\n                <div class=\"tutorial-card\">\n                    <div class=\"tutorial-card-text\">TAP TO JUMP</div>\n                </div>\n            </div>\n            <div class=\"pause-overlay\" id=\"pause-overlay\">\n                <div class=\"pause-text\">PAUSED</div>\n                <div class=\"resume-btn\" id=\"resume-btn\">RESUME</div>\n            </div>\n            <div class=\"game-over-overlay\" id=\"game-over-overlay\">\n                <div class=\"game-over-emoji\" id=\"game-over-emoji\">\ud83c\udfc6</div>\n                <div class=\"game-over-text\" id=\"game-over-text\">You Win!</div>\n                <div class=\"game-over-difficulty\" id=\"game-over-difficulty\">Difficulty 2</div>\n                <div class=\"game-over-score\" id=\"game-over-score\">Score: 7 - 5</div>\n                <div class=\"game-over-bottom-section\" id=\"game-over-bottom-section\">\n                    <div class=\"game-over-difficulty-section\">\n                        <div class=\"game-over-diff-label\" id=\"game-over-diff-label\">NORMAL</div>\n                        <div class=\"game-over-slider-container\" id=\"game-over-slider-container\">\n                            <div class=\"game-over-slider-track\">\n                                <div class=\"game-over-slider-slots\">\n                                    <div class=\"game-over-slot-dot\"></div>\n                                    <div class=\"game-over-slot-dot\"></div>\n                                    <div class=\"game-over-slot-dot\"></div>\n                                    <div class=\"game-over-slot-dot\"></div>\n                                    <div class=\"game-over-slot-dot\"></div>\n                                </div>\n                            </div>\n                            <div class=\"game-over-slider-thumb\" id=\"game-over-slider-thumb\"></div>\n                        </div>\n                        <div class=\"game-over-labels-row\" id=\"game-over-labels-row\">\n                            <span class=\"game-over-slot-label\">Very Easy</span>\n                            <span class=\"game-over-slot-label\">Easy</span>\n                            <span class=\"game-over-slot-label active\">Normal</span>\n                            <span class=\"game-over-slot-label\">Hard</span>\n                            <span class=\"game-over-slot-label\">Expert</span>\n                        </div>\n                    </div>\n                    <div class=\"game-over-buttons\">\n                        <button class=\"game-over-btn btn-again\" id=\"btn-again\">PLAY AGAIN</button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <script>\n    /*\n     * TINY TENNIS\n     * A one-tap mobile tennis game with bean-shaped characters.\n     * Player taps to jump, auto-swings racket when near ball.\n     * Features Scale Event that makes one player giant and other tiny.\n     */\n    async function run(mode) {\n        const canvas = document.getElementById('game-canvas');\n        const ctx = canvas.getContext('2d');\n        const gameWorld = document.getElementById('game-world');\n\n        function resizeCanvas() {\n            canvas.width = gameWorld.clientWidth;\n            canvas.height = gameWorld.clientHeight;\n        }\n        resizeCanvas();\n        window.addEventListener('resize', resizeCanvas);\n\n        const config = window.gameConfig;\n\n        // ========================================\n        // ASSET LOADING\n        // ========================================\n\n        const imageCache = {};\n        const audioBuffers = {};\n        let audioCtx = null;\n\n        function getAudioContext() {\n            if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();\n            return audioCtx;\n        }\n\n        const assetIds = ['player_bean_blue', 'ai_bean_red', 'tennis_racket', 'tennis_ball', 'tennis_court_surface', 'sky_background', 'stadium_backdrop', 'menu_background'];\n        const audioIds = ['ball_hit_sound', 'ball_bounce_sound', 'score_sound', 'scale_event_start_sound', 'scale_event_end_sound', 'victory_sound', 'defeat_sound', 'jump_sound', 'button_pop_sound', 'countdown_sound', 'go_sound'];\n\n        function preloadImages() {\n            return Promise.all(assetIds.map(id => {\n                return new Promise((resolve) => {\n                    const info = lib.getAsset(id);\n                    if (info && info.type === 'image') {\n                        const img = new Image();\n                        img.crossOrigin = 'anonymous';\n                        img.onload = () => { imageCache[id] = img; resolve(); };\n                        img.onerror = () => resolve();\n                        img.src = info.url;\n                    } else resolve();\n                });\n            }));\n        }\n\n        function preloadAudio() {\n            return Promise.all(audioIds.map(id => {\n                return new Promise((resolve) => {\n                    const info = lib.getAsset(id);\n                    if (info && info.type === 'audio') {\n                        fetch(info.url)\n                            .then(r => r.arrayBuffer())\n                            .then(buf => getAudioContext().decodeAudioData(buf))\n                            .then(decoded => { audioBuffers[id] = decoded; resolve(); })\n                            .catch(() => resolve());\n                    } else resolve();\n                });\n            }));\n        }\n\n        // Set loading screen logo\n        const loadingLogo = document.getElementById('loading-logo');\n        const logoAsset = lib.getAsset('menu_background');\n        if (logoAsset) loadingLogo.src = logoAsset.url;\n        else loadingLogo.style.display = 'none';\n\n        await preloadImages();\n        await preloadAudio();\n\n        // Dismiss loading screen, show main menu\n        const loadingOverlay = document.getElementById('loading-overlay');\n        loadingOverlay.classList.add('fade-out');\n        setTimeout(() => { loadingOverlay.style.display = 'none'; }, 500);\n        document.getElementById('main-menu-overlay').style.display = 'flex';\n\n        let soundEnabled = true;\n\n        function playSound(id) {\n            if (!soundEnabled || !audioBuffers[id]) return;\n            try {\n                const ctx = getAudioContext();\n                if (ctx.state === 'suspended') ctx.resume();\n                const source = ctx.createBufferSource();\n                source.buffer = audioBuffers[id];\n                source.connect(ctx.destination);\n                source.start(0);\n            } catch(e) {}\n        }\n\n        // ========================================\n        // MAIN MENU SYSTEM\n        // ========================================\n        const MENU_DIFFICULTY_NAMES = ['VERY EASY', 'EASY', 'NORMAL', 'HARD', 'EXPERT'];\n        const MENU_DIFFICULTY_COLORS = ['#2ECC71', '#A3D831', '#F1C40F', '#E67E22', '#8E44AD'];\n        let menuActive = true;\n        let menuState = 1; // 1 = play button, 2 = difficulty selection\n        let selectedDifficulty = config.ai.difficulty || 1; // 1-5, default Very Easy\n\n        function initMainMenu() {\n            const menuOverlay = document.getElementById('main-menu-overlay');\n            const menuBg = document.getElementById('menu-bg');\n            const playBtn = document.getElementById('menu-play-btn');\n            const diffSection = document.getElementById('menu-difficulty-section');\n            const diffLabel = document.getElementById('difficulty-label');\n            const diffThumb = document.getElementById('difficulty-thumb');\n            const sliderContainer = document.getElementById('difficulty-slider-container');\n            const labelsRow = document.getElementById('difficulty-labels-row');\n            const startBtn = document.getElementById('menu-start-btn');\n\n            // Set background image\n            const bgAsset = lib.getAsset('menu_background');\n            if (bgAsset) {\n                menuBg.src = bgAsset.url;\n            }\n\n            // Position thumb for current difficulty\n            function updateSliderVisuals() {\n                const idx = selectedDifficulty - 1; // 0-4\n                const pct = (idx / 4) * 100;\n                diffThumb.style.left = pct + '%';\n                diffLabel.textContent = MENU_DIFFICULTY_NAMES[idx];\n                diffLabel.style.color = MENU_DIFFICULTY_COLORS[idx];\n\n                // Update active label\n                const labels = labelsRow.querySelectorAll('.difficulty-slot-label');\n                labels.forEach((lbl, i) => {\n                    lbl.classList.toggle('active', i === idx);\n                });\n            }\n\n            updateSliderVisuals();\n\n            // PLAY button \u2192 start game directly\n            playBtn.addEventListener('pointerdown', (e) => {\n                e.stopPropagation();\n                if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();\n                playSound('button_pop_sound');\n                dismissMenu();\n            });\n\n            // Difficulty slider interaction\n            let dragging = false;\n\n            function getSlotFromX(clientX) {\n                const rect = sliderContainer.getBoundingClientRect();\n                const relX = (clientX - rect.left) / rect.width;\n                const clamped = Math.max(0, Math.min(1, relX));\n                const slot = Math.round(clamped * 4); // 0-4\n                return slot + 1; // 1-5\n            }\n\n            function handleSliderMove(clientX) {\n                const newDiff = getSlotFromX(clientX);\n                if (newDiff !== selectedDifficulty) {\n                    selectedDifficulty = newDiff;\n                    updateSliderVisuals();\n                    playSound('button_pop_sound');\n                }\n            }\n\n            sliderContainer.addEventListener('pointerdown', (e) => {\n                e.stopPropagation();\n                e.preventDefault();\n                dragging = true;\n                handleSliderMove(e.clientX);\n                sliderContainer.setPointerCapture(e.pointerId);\n            });\n\n            sliderContainer.addEventListener('pointermove', (e) => {\n                if (!dragging) return;\n                e.stopPropagation();\n                e.preventDefault();\n                handleSliderMove(e.clientX);\n            });\n\n            sliderContainer.addEventListener('pointerup', (e) => {\n                dragging = false;\n                sliderContainer.releasePointerCapture(e.pointerId);\n            });\n\n            sliderContainer.addEventListener('pointercancel', (e) => {\n                dragging = false;\n            });\n\n            // START MATCH button (legacy)\n            if (startBtn) startBtn.addEventListener('pointerdown', (e) => {\n                e.stopPropagation();\n                if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();\n                playSound('button_pop_sound');\n                config.ai.difficulty = selectedDifficulty;\n                dismissMenu();\n            });\n        }\n\n        function dismissMenu() {\n            const menuOverlay = document.getElementById('main-menu-overlay');\n            menuOverlay.classList.add('fade-out');\n            setTimeout(() => {\n                menuOverlay.style.display = 'none';\n                menuActive = false;\n                // Show gameplay canvas\n                canvas.style.visibility = 'visible';\n                // Start the game intro sequence\n                startGameAfterMenu();\n            }, 500);\n        }\n\n        // This will be set later when the game loop is ready\n        let startGameAfterMenu = () => {};\n\n        if (mode === 'play') {\n            initMainMenu();\n        } else {\n            // Hide menu in edit mode\n            document.getElementById('main-menu-overlay').style.display = 'none';\n        }\n\n        // ========================================\n        // GAME PARAMETERS (Settings Panel)\n        // ========================================\n\n        lib.showGameParameters({\n            name: 'Tiny Tennis',\n            params: {\n                'Target Score': { key: 'settings.targetScore', type: 'slider', min: 1, max: 10, step: 1 },\n                'Event Interval (s)': { key: 'settings.scaleEventInterval', type: 'slider', min: 10, max: 30, step: 1 },\n                'Event Duration (s)': { key: 'settings.scaleEventDuration', type: 'slider', min: 5, max: 15, step: 1 },\n                'Ball Speed': { key: 'settings.ballSpeedMultiplier', type: 'slider', min: 0.5, max: 2.0, step: 0.1 },\n                'Ball Deflection': { key: 'physics.ballDeflectionFactor', type: 'slider', min: 1, max: 30, step: 0.5 },\n                'Ball Gravity': { key: 'physics.ballGravity', type: 'slider', min: 200, max: 2500, step: 50 },\n                'Ball Bounce': { key: 'physics.groundBounceStrength', type: 'slider', min: 0, max: 2, step: 0.05 },\n                'Giant Event Ball Speed': { key: 'settings.giantEventBallSpeedMult', type: 'slider', min: 0.5, max: 3.0, step: 0.05 },\n                'Giant Event Ball Gravity': { key: 'settings.giantEventBallGravityMult', type: 'slider', min: 0.3, max: 2.0, step: 0.05 },\n                'Giant Event Ball Y': { key: 'settings.giantEventBallY', type: 'slider', min: 0.2, max: 0.85, step: 0.01 },\n                'Tiny Event Player Scale': { key: 'settings.tinyEventPlayerScale', type: 'slider', min: 1.5, max: 5.0, step: 0.1 },\n                'Tiny Event Hitbox Scale': { key: 'settings.tinyEventHitboxScale', type: 'slider', min: 1.0, max: 4.0, step: 0.1 },\n                'Tiny Event Ball Y': { key: 'settings.tinyEventBallY', type: 'slider', min: 0.2, max: 0.85, step: 0.01 },\n                'Tiny Event Player Gravity': { key: 'settings.tinyEventPlayerGravityMult', type: 'slider', min: 0.3, max: 2.0, step: 0.05 },\n                'Big Ball Scale': { key: 'settings.bigBallScale', type: 'slider', min: 1.5, max: 5.0, step: 0.1 },\n                'Big Ball Gravity': { key: 'settings.bigBallGravityMult', type: 'slider', min: 0.3, max: 2.0, step: 0.05 },\n                'Big Ball Speed': { key: 'settings.bigBallSpeedMult', type: 'slider', min: 0.3, max: 2.0, step: 0.05 },\n                'Big Ball Bounce': { key: 'settings.bigBallBounceMult', type: 'slider', min: 0.5, max: 3.0, step: 0.05 },\n                'Event Cooldown (s)': { key: 'settings.eventCooldown', type: 'slider', min: 15, max: 25, step: 1 },\n                'AI Difficulty': { key: 'ai.difficulty', type: 'dropdown', options: [\n                    { label: '1 - Very Easy', value: 1 },\n                    { label: '2 - Easy', value: 2 },\n                    { label: '3 - Normal', value: 3 },\n                    { label: '4 - Hard', value: 4 },\n                    { label: '5 - Expert', value: 5 }\n                ]},\n                'Gravity': { key: 'physics.gravity', type: 'slider', min: 800, max: 2000, step: 50 },\n                'Jump Power': { key: 'physics.jumpPower', type: 'slider', min: 400, max: 900, step: 25 },\n                'Player Color': { key: 'visuals.playerColor', type: 'color' },\n                'AI Color': { key: 'visuals.aiColor', type: 'color' },\n                'Court Color': { key: 'visuals.courtColor', type: 'color' }\n            }\n        });\n\n        // ========================================\n        // EDIT MODE\n        // ========================================\n\n        if (mode === 'edit') {\n            function drawEditMode() {\n                ctx.clearRect(0, 0, canvas.width, canvas.height);\n                drawBackground();\n                drawBean(canvas.width * 0.24, canvas.height * 0.88, 1, config.visuals.playerColor, 'player_bean_blue');\n                drawBean(canvas.width * 0.76, canvas.height * 0.88, 1, config.visuals.aiColor, 'ai_bean_red');\n                drawBall(canvas.width * 0.5, canvas.height * 0.65, 1);\n                requestAnimationFrame(drawEditMode);\n            }\n            requestAnimationFrame(drawEditMode);\n            return;\n        }\n\n        // ========================================\n        // PLAY MODE\n        // ========================================\n\n        // ----------------------------------------\n        // CONFIG - Tunable values from gameConfig\n        // ----------------------------------------\n        const W = 720;\n        const H = 1280;\n        const getGroundY = () => config.debug.playerYPosition;\n        const NET_X = 0.5;\n\n        const getPlayerGravity = () => config.physics.playerGravity;\n        const getBallGravity = () => config.physics.ballGravity;\n        const getJumpPower = () => config.physics.jumpPower;\n        const getBallSpeed = () => config.settings.ballSpeedMultiplier;\n        const getScaleMult = () => config.debug.scaleEventSizeMultiplier || 1;\n\n        // ----------------------------------------\n        // GAME STATE\n        // ----------------------------------------\n        let playerScore = 0;\n        let aiScore = 0;\n        let rallyCount = 0;\n        let paused = false;\n        let gameOver = false;\n        let firstJump = false;\n        let jumpCooldown = 0;\n        let serveDelay = 0;\n        let particles = [];\n\n        // Scale event state\n        let eventTimer = 0;\n        let eventActive = false;\n        let eventTimeLeft = 0;\n        let currentEventId = null;\n        let eventCooldownTimer = 0;\n        let eventScoreThreshold = 2; // first event triggers after 2 total scores\n        let firstEventTriggered = false;\n        let eventsThisMatch = 0;\n        let playerScale = 1;\n        let aiScale = 1;\n        let targetPlayerScale = 1;\n        let targetAiScale = 1;\n        let cameraZoom = 1;\n        let targetCameraZoom = 1;\n        // Giant Players event additions\n        let eventXSpread = 0;\n        let targetEventXSpread = 0;\n        let eventJumpMult = 1;\n        let targetEventJumpMult = 1;\n        let eventBallSpeedMult = 1;\n        let targetEventBallSpeedMult = 1;\n        let eventBallGravityMult = 1;\n        let targetEventBallGravityMult = 1;\n        let eventCourtBgY = config.debug.courtBgYPosition;\n        let targetEventCourtBgY = config.debug.courtBgYPosition;\n        let eventStadiumYOffset = config.debug.stadiumYOffset || 0;\n        let targetEventStadiumYOffset = config.debug.stadiumYOffset || 0;\n        let eventBallY = config.debug.ballYPosition;\n        let targetEventBallY = config.debug.ballYPosition;\n        let eventPlayerGravityMult = 1;\n        let targetEventPlayerGravityMult = 1;\n        let eventHitboxScale = 1;\n        let targetEventHitboxScale = 1;\n        let eventBallScale = 1;\n        let targetEventBallScale = 1;\n        let eventBounceMult = 1;\n        let targetEventBounceMult = 1;\n\n        // ----------------------------------------\n        // PLAYER & BALL DATA\n        // ----------------------------------------\n        let player = {\n            x: 0.5 - config.debug.playerXSpread,\n            y: getGroundY(),\n            vy: 0,\n            onGround: true,\n            scale: 1,\n            squash: 1,\n            squashX: 1,\n            squashY: 1,\n            jumpPhase: null,\n            jumpPhaseTimer: 0,\n            swinging: false,\n            swingTimer: 0,\n            windingUp: false,\n            windupTimer: 0\n        };\n\n        // AI bean\n        let ai = {\n            x: 0.5 + config.debug.playerXSpread,\n            y: getGroundY(),\n            vy: 0,\n            onGround: true,\n            scale: 1,\n            squash: 1,\n            squashX: 1,\n            squashY: 1,\n            jumpPhase: null,\n            jumpPhaseTimer: 0,\n            swinging: false,\n            swingTimer: 0,\n            windingUp: false,\n            windupTimer: 0,\n            // AI brain state (two-phase: predict once per approach, then time the jump)\n            hasPrediction: false,   // Whether AI has a valid prediction this approach\n            predictedY: 0,          // Predicted ball Y at AI's X position\n            predictedFrames: 0,     // Predicted frames until ball reaches AI's X\n            predictionAge: 0,       // Frames elapsed since prediction was made\n            misreading: false,      // Currently locked into a wrong read\n            misreadTimer: 0,        // Time remaining in misread commitment\n            misreadPredY: 0,        // The wrong prediction it's committed to\n            jumpExecuted: false,    // Whether jump has been executed this approach\n            jumpCooldown: 0,\n            lastBallVx: 0,          // Track ball direction changes\n            reactionDelayTimer: 0,  // Delay before making prediction\n            reactionDelayActive: false, // Currently waiting to predict\n            // Attack state - rally-breaking behavior\n            groundedRallyCount: 0,      // Consecutive grounded exchanges\n            attackMode: false,          // Currently attempting a rally-breaker\n            attackThreshold: 0,         // Randomized threshold for next attack (4-6)\n            attackJumpExecuted: false,   // Whether the attack jump has been done\n            // Multi-jump plan state\n            _activeJumpPlan: null,       // Jump schedule array (frame numbers) or null\n            _jumpPlanFrame: 0,           // Elapsed frames since plan started\n            _nextJumpIdx: 0,             // Next index in schedule to execute\n            _jumpPlanActive: false,      // Currently executing a multi-jump plan\n            debug: {\n                state: 'WAIT',\n                jumpNeeded: false,\n                canJump: true,\n                distToTarget: 0,\n                ballAboveMe: false\n            }\n        };\n\n        // Difficulty presets \u2014 all use the same AI system, only tuning changes\n        // Level 3 (Normal) is the baseline; others apply multipliers\n        const AI_DIFFICULTY_PRESETS = {\n            1: { // Very Easy \u2014 85-95% player win rate\n                predictionNoiseMult: 1.50,    // +50% prediction error\n                misreadChanceMult: 2.50,      // 50% misread rate \u2192 frequent total whiffs\n                timingToleranceMult: 1.75,    // loose timing \u2192 occasional mistimed jumps\n                multiJumpSkipChance: 0.85,    // skips 85% of multi-jumps \u2192 can't handle high balls\n                attackThresholdAdd: 99,        // attack mode effectively disabled\n                misreadDurationMult: 2.0,     // locked into wrong read for 2 seconds\n                misreadOffsetMin: 0.07,       // bigger wrong predictions\n                misreadOffsetMax: 0.14,       // much more dramatic misses\n                reactionDelay: 0.4            // 400ms delay before AI even sees the ball\n            },\n            2: { // Easy \u2014 65-80% player win rate\n                predictionNoiseMult: 1.25,    // +25% prediction error\n                misreadChanceMult: 1.60,      // 32% misread rate \u2192 obvious mistakes\n                timingToleranceMult: 1.40,    // slightly worse jump timing\n                multiJumpSkipChance: 0.45,    // skips 45% of multi-jumps\n                attackThresholdAdd: 3,         // attacks are rare (higher threshold)\n                misreadDurationMult: 1.4,     // locked into wrong read for 1.4 seconds\n                misreadOffsetMin: 0.05,       // slightly bigger wrong predictions\n                misreadOffsetMax: 0.10,       // more noticeable misses\n                reactionDelay: 0.3            // 300ms delay before prediction\n            },\n            3: { // Normal (baseline)\n                predictionNoiseMult: 1.0,\n                misreadChanceMult: 1.0,\n                timingToleranceMult: 1.0,\n                multiJumpSkipChance: 0.30,\n                attackThresholdAdd: 0,\n                misreadDurationMult: 1.0,\n                misreadOffsetMin: 0.04,\n                misreadOffsetMax: 0.08,\n                reactionDelay: 0.2            // 200ms delay before prediction\n            },\n            4: { // Hard\n                predictionNoiseMult: 0.90,\n                misreadChanceMult: 0.90,\n                timingToleranceMult: 0.8,\n                multiJumpSkipChance: 0.20,\n                attackThresholdAdd: -1,\n                misreadDurationMult: 0.8,\n                misreadOffsetMin: 0.04,\n                misreadOffsetMax: 0.08,\n                reactionDelay: 0\n            },\n            5: { // Expert\n                predictionNoiseMult: 0.80,\n                misreadChanceMult: 0.80,\n                timingToleranceMult: 0.6,\n                multiJumpSkipChance: 0.10,\n                attackThresholdAdd: -2,\n                misreadDurationMult: 0.6,\n                misreadOffsetMin: 0.03,\n                misreadOffsetMax: 0.06,\n                reactionDelay: 0\n            }\n        };\n\n        // Baseline constants (Normal / Level 3)\n        const AI_BASE = {\n            predictionNoise: 0.015,\n            misreadChance: 0.20,\n            misreadOffsetMin: 0.04,\n            misreadOffsetMax: 0.08,\n            misreadDuration: 1.0,\n            timingToleranceFrames: 6,\n            panicThreshold: 0.3\n        };\n\n        function getAIDifficulty() {\n            const level = config.ai.difficulty || 2;\n            return AI_DIFFICULTY_PRESETS[level] || AI_DIFFICULTY_PRESETS[3];\n        }\n\n        function getAIParams() {\n            const diff = getAIDifficulty();\n            return {\n                predictionNoise: AI_BASE.predictionNoise * diff.predictionNoiseMult,\n                misreadChance: AI_BASE.misreadChance * diff.misreadChanceMult,\n                misreadOffsetMin: diff.misreadOffsetMin || AI_BASE.misreadOffsetMin,\n                misreadOffsetMax: diff.misreadOffsetMax || AI_BASE.misreadOffsetMax,\n                misreadDuration: AI_BASE.misreadDuration * (diff.misreadDurationMult || 1.0),\n                timingToleranceFrames: Math.round(AI_BASE.timingToleranceFrames * diff.timingToleranceMult),\n                panicThreshold: AI_BASE.panicThreshold,\n                multiJumpSkipChance: diff.multiJumpSkipChance,\n                attackThresholdAdd: diff.attackThresholdAdd,\n                reactionDelay: diff.reactionDelay || 0\n            };\n        }\n\n        // Initialize first attack threshold (adjusted by difficulty)\n        function getAttackThreshold() {\n            const params = getAIParams();\n            const min = (config.ai.attackThresholdMin || 4) + params.attackThresholdAdd;\n            const max = (config.ai.attackThresholdMax || 6) + params.attackThresholdAdd;\n            return Math.max(2, min) + Math.floor(Math.random() * (Math.max(2, max) - Math.max(2, min) + 1));\n        }\n        ai.attackThreshold = getAttackThreshold();\n\n        // Ball\n        let ball = {\n            x: 0.5,\n            y: config.debug.ballYPosition,\n            vx: 0,\n            vy: 0,\n            active: false,\n            radius: 0.02,\n            lastHitBy: null\n        };\n\n        // ----------------------------------------\n        // CONTACT ANALYSIS - Debug logger\n        // ----------------------------------------\n        let contactAnalysis = {\n            pending: false,\n            snapshot: null\n        };\n\n        function captureContactSnapshot(ballVyBefore) {\n            if (!config.debug.contactAnalysis) return;\n            const pScale = playerScale * config.debug.playerDefaultScale;\n            const pHB = getHitbox(player.x, player.y, pScale);\n            // Contact height category based on ball position relative to hitbox thirds\n            const relY = ball.y - pHB.cy;\n            const thirdH = pHB.hh / 3;\n            let category;\n            if (relY < -thirdH) category = 'HIGH';\n            else if (relY > thirdH) category = 'LOW';\n            else category = 'MID';\n\n            contactAnalysis.pending = true;\n            contactAnalysis.snapshot = {\n                category,\n                ballY: ball.y,\n                playerY: player.y,\n                playerVY: player.vy,\n                ballVyBefore: ballVyBefore,\n                ballVyAfter: 0,\n                ballVxAfter: 0\n            };\n        }\n\n        function finalizeContactSnapshot() {\n            if (!contactAnalysis.pending || !contactAnalysis.snapshot) return;\n            contactAnalysis.snapshot.ballVyAfter = ball.vy;\n            contactAnalysis.snapshot.ballVxAfter = ball.vx;\n        }\n\n        function resolveContactAnalysis(outcome) {\n            if (!contactAnalysis.pending) return;\n            const s = contactAnalysis.snapshot;\n            const toVal = (v) => Math.round(v * 1000);\n            const sign = (v) => v >= 0 ? '+' : '';\n\n            let log = '\\nPLAYER HIT\\n';\n            log += `Contact: ${s.category}\\n`;\n            log += `Player VY: ${sign(toVal(s.playerVY))}${toVal(s.playerVY)}\\n`;\n            log += `Ball VY Before: ${sign(toVal(s.ballVyBefore))}${toVal(s.ballVyBefore)}\\n`;\n            log += `Ball VY After: ${sign(toVal(s.ballVyAfter))}${toVal(s.ballVyAfter)}\\n`;\n            log += `Ball VX After: ${sign(toVal(s.ballVxAfter))}${toVal(s.ballVxAfter)}\\n`;\n            log += `\\nRESULT\\n`;\n            log += outcome;\n\n            lib.log(log);\n\n            contactAnalysis.pending = false;\n            contactAnalysis.snapshot = null;\n        }\n\n        // ----------------------------------------\n        // SCORING & SERVE LOGIC\n        // ----------------------------------------\n        function resetBall(serveTo) {\n            ball.x = 0.5;\n            ball.y = eventBallY;\n            ball.vx = 0;\n            ball.vy = 0;\n            ball.active = false;\n            ball.lastHitBy = null;\n            serveDelay = 1.0;\n            rallyCount = 0;\n            // Clear any pending contact analysis\n            contactAnalysis.pending = false;\n            contactAnalysis.snapshot = null;\n            // Reset AI attack state on point reset\n            ai.groundedRallyCount = 0;\n            ai.attackMode = false;\n            ai.attackJumpExecuted = false;\n            ai.attackThreshold = getAttackThreshold();\n            updateUI();\n        }\n\n        function serveBall() {\n            const speed = 0.35 * getBallSpeed();\n            const dir = Math.random() > 0.5 ? 1 : -1;\n            ball.vx = speed * dir;\n            ball.vy = -0.15;\n            ball.active = true;\n        }\n\n        function scorePoint(scorer) {\n            if (scorer === 'player') {\n                playerScore++;\n                playSound('score_sound');\n            } else {\n                aiScore++;\n                playSound('score_sound');\n            }\n            updateUI();\n            addParticles(ball.x, ball.y, 10);\n\n            if (!config.debug.disableGameOver && (playerScore >= config.settings.targetScore || aiScore >= config.settings.targetScore)) {\n                endGame();\n            } else {\n                resetBall();\n            }\n        }\n\n        // ----------------------------------------\n        // GAME OVER UI - Text pools & display\n        // ----------------------------------------\n        const WIN_TEXTS = ['You Win!', 'Nice Shot!', 'Victory!', 'Match Won!', 'Champion!'];\n        const LOSE_TEXTS = ['Try Again?', 'So Close!', 'Match Lost', 'Almost!', 'Rematch?'];\n        const DIFFICULTY_NAMES = { 1: 'Very Easy', 2: 'Easy', 3: 'Normal', 4: 'Hard', 5: 'Expert' };\n\n        function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }\n\n        function showGameOver(won) {\n            const emoji = document.getElementById('game-over-emoji');\n            const text = document.getElementById('game-over-text');\n            const diff = document.getElementById('game-over-difficulty');\n            const score = document.getElementById('game-over-score');\n\n            emoji.textContent = won ? '\ud83c\udfc6' : '\ud83d\ude05';\n            text.textContent = won ? pickRandom(WIN_TEXTS) : pickRandom(LOSE_TEXTS);\n            text.style.color = won ? '#4ADE80' : '#FB7185';\n\n            const currentDiff = config.ai.difficulty || 1;\n            diff.textContent = `Difficulty ${currentDiff} \u00b7 ${DIFFICULTY_NAMES[currentDiff] || 'Very Easy'}`;\n            score.textContent = `Score: ${playerScore} - ${aiScore}`;\n\n            // Sync slider to current difficulty\n            gameOverSelectedDifficulty = currentDiff;\n            updateGameOverSliderVisuals();\n\n            document.getElementById('game-over-overlay').style.display = 'flex';\n\n            // Reset bottom section, then slide it in after 1 second\n            const bottomSection = document.getElementById('game-over-bottom-section');\n            bottomSection.classList.remove('slide-in');\n            setTimeout(() => {\n                bottomSection.classList.add('slide-in');\n            }, 1000);\n        }\n\n        function endGame() {\n            gameOver = true;\n            const won = playerScore >= config.settings.targetScore;\n            showGameOver(won);\n            playSound(won ? 'victory_sound' : 'defeat_sound');\n        }\n\n        function restartGame() {\n            playerScore = 0;\n            aiScore = 0;\n            rallyCount = 0;\n            gameOver = false;\n            eventActive = false;\n            eventTimer = 0;\n            currentEventId = null;\n            eventCooldownTimer = 0;\n            firstEventTriggered = false;\n            eventScoreThreshold = 2;\n            eventsThisMatch = 0;\n            playerScale = 1; aiScale = 1;\n            targetPlayerScale = 1; targetAiScale = 1;\n            cameraZoom = 1; targetCameraZoom = 1;\n            eventXSpread = 0; targetEventXSpread = 0;\n            eventJumpMult = 1; targetEventJumpMult = 1;\n            eventBallSpeedMult = 1; targetEventBallSpeedMult = 1;\n            eventBallGravityMult = 1; targetEventBallGravityMult = 1;\n            eventCourtBgY = config.debug.courtBgYPosition; targetEventCourtBgY = config.debug.courtBgYPosition;\n            eventBallY = config.debug.ballYPosition; targetEventBallY = config.debug.ballYPosition;\n            eventStadiumYOffset = config.debug.stadiumYOffset || 0; targetEventStadiumYOffset = config.debug.stadiumYOffset || 0;\n            eventPlayerGravityMult = 1; targetEventPlayerGravityMult = 1;\n            eventHitboxScale = 1; targetEventHitboxScale = 1;\n            eventBallScale = 1; targetEventBallScale = 1;\n            eventBounceMult = 1; targetEventBounceMult = 1;\n            player.y = getGroundY(); player.vy = 0; player.onGround = true;\n            ai.y = getGroundY(); ai.vy = 0; ai.onGround = true;\n            document.getElementById('game-over-overlay').style.display = 'none';\n            document.getElementById('event-timer').style.display = 'none';\n            resetBall();\n            updateUI();\n            introActive = true;\n            playIntro();\n        }\n\n        function updateUI() {\n            document.getElementById('player-score').textContent = playerScore;\n            document.getElementById('ai-score').textContent = aiScore;\n            document.getElementById('rally-counter').textContent = `Rally: ${rallyCount}`;\n        }\n\n        // ----------------------------------------\n        // INPUT - Tap handling & jump logic\n        // ----------------------------------------\n        function handleJump() {\n            if (menuActive || paused || gameOver) return;\n            if (jumpCooldown > 0) return;\n            // Stage 1: Anticipation squash (50ms)\n            player.jumpPhase = 'anticipation';\n            player.jumpPhaseTimer = 0.05;\n            jumpCooldown = 0.18;\n            playSound('jump_sound');\n            if (!firstJump) {\n                firstJump = true;\n            }\n        }\n\n        canvas.addEventListener('pointerdown', (e) => {\n            e.preventDefault();\n            if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();\n            handleJump();\n        });\n\n        document.addEventListener('keydown', (e) => {\n            if (e.code === 'Space' || e.code === 'ArrowUp') {\n                e.preventDefault();\n                handleJump();\n            }\n            if (e.code === 'KeyP') togglePause();\n            if (e.code === 'KeyM') toggleSound();\n        });\n\n        function togglePause() {\n            if (gameOver) return;\n            paused = !paused;\n            document.getElementById('pause-overlay').style.display = paused ? 'flex' : 'none';\n            document.getElementById('pause-btn').textContent = paused ? '\u25b6' : '\u23f8';\n        }\n\n        function toggleSound() {\n            soundEnabled = !soundEnabled;\n            document.getElementById('sound-btn').textContent = soundEnabled ? '\ud83d\udd0a' : '\ud83d\udd07';\n        }\n\n        document.getElementById('pause-btn').addEventListener('pointerdown', (e) => {\n            e.stopPropagation();\n            playSound('button_pop_sound');\n            togglePause();\n        });\n\n        document.getElementById('resume-btn').addEventListener('pointerdown', (e) => {\n            e.stopPropagation();\n            playSound('button_pop_sound');\n            togglePause();\n        });\n\n        document.getElementById('sound-btn').addEventListener('pointerdown', (e) => {\n            e.stopPropagation();\n            playSound('button_pop_sound');\n            toggleSound();\n        });\n\n        document.getElementById('btn-again').addEventListener('pointerdown', (e) => {\n            e.stopPropagation();\n            playSound('button_pop_sound');\n            config.ai.difficulty = gameOverSelectedDifficulty;\n            restartGame();\n        });\n\n        // Game over difficulty slider\n        let gameOverSelectedDifficulty = config.ai.difficulty || 1;\n\n        function initGameOverSlider() {\n            const sliderContainer = document.getElementById('game-over-slider-container');\n            const thumb = document.getElementById('game-over-slider-thumb');\n            const diffLabel = document.getElementById('game-over-diff-label');\n            const labelsRow = document.getElementById('game-over-labels-row');\n\n            function updateVisuals() {\n                const idx = gameOverSelectedDifficulty - 1;\n                const pct = (idx / 4) * 100;\n                thumb.style.left = pct + '%';\n                diffLabel.textContent = MENU_DIFFICULTY_NAMES[idx];\n                diffLabel.style.color = MENU_DIFFICULTY_COLORS[idx];\n                const labels = labelsRow.querySelectorAll('.game-over-slot-label');\n                labels.forEach((lbl, i) => lbl.classList.toggle('active', i === idx));\n            }\n\n            updateVisuals();\n\n            let dragging = false;\n            function getSlotFromX(clientX) {\n                const rect = sliderContainer.getBoundingClientRect();\n                const relX = (clientX - rect.left) / rect.width;\n                return Math.round(Math.max(0, Math.min(1, relX)) * 4) + 1;\n            }\n\n            sliderContainer.addEventListener('pointerdown', (e) => {\n                e.stopPropagation(); e.preventDefault();\n                dragging = true;\n                const newDiff = getSlotFromX(e.clientX);\n                if (newDiff !== gameOverSelectedDifficulty) { gameOverSelectedDifficulty = newDiff; updateVisuals(); playSound('button_pop_sound'); }\n                sliderContainer.setPointerCapture(e.pointerId);\n            });\n            sliderContainer.addEventListener('pointermove', (e) => {\n                if (!dragging) return;\n                e.stopPropagation(); e.preventDefault();\n                const newDiff = getSlotFromX(e.clientX);\n                if (newDiff !== gameOverSelectedDifficulty) { gameOverSelectedDifficulty = newDiff; updateVisuals(); playSound('button_pop_sound'); }\n            });\n            sliderContainer.addEventListener('pointerup', (e) => { dragging = false; sliderContainer.releasePointerCapture(e.pointerId); });\n            sliderContainer.addEventListener('pointercancel', () => { dragging = false; });\n\n            return updateVisuals;\n        }\n\n        const updateGameOverSliderVisuals = initGameOverSlider();\n\n        // ----------------------------------------\n        // RENDERING - Court, players, ball, UI\n        // ----------------------------------------\n        // ---- LAYER 1: Sky Background (world-space asset) ----\n        function drawSky() {\n            const skyImg = imageCache['sky_background'];\n            if (skyImg) {\n                // The sky is a large world-space image. In normal mode we show\n                // only the center crop; when the camera zooms out we reveal more.\n                // We draw it BEFORE the camera transform so it stays fixed in world space.\n                // The image is sized larger than the viewport \u2014 1.45x \u2014 so edges are off-screen normally.\n                const worldScale = 1.45;\n                const imgAspect = skyImg.width / skyImg.height;\n                const drawH = canvas.height * worldScale;\n                const drawW = drawH * imgAspect;\n                // Center it\n                const dx = (canvas.width - drawW) / 2;\n                const dy = (canvas.height * 0.7 - drawH) / 2; // vertically center in sky area\n                ctx.drawImage(skyImg, dx, dy, drawW, drawH);\n            } else {\n                // Fallback gradient\n                const grad = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.7);\n                grad.addColorStop(0, '#4A9FE8');\n                grad.addColorStop(1, '#87CEEB');\n                ctx.fillStyle = grad;\n                ctx.fillRect(-canvas.width, -canvas.height, canvas.width * 3, canvas.height * 3);\n            }\n        }\n\n        // ---- LAYER 2: Stadium Backdrop (world-space asset, same zoom behavior as sky) ----\n        function drawStadium() {\n            const stadiumImg = imageCache['stadium_backdrop'];\n            if (!stadiumImg) return;\n            // Same approach as sky: draw larger than viewport, centered, so zooming out reveals more\n            const worldScale = config.debug.stadiumScale || 1.45;\n            const imgAspect = stadiumImg.width / stadiumImg.height; // ~1.5 (3:2)\n            const drawW = canvas.width * worldScale;\n            const drawH = drawW / imgAspect;\n            const dx = (canvas.width - drawW) / 2;\n            // Position so the bottom of the stadium sits just above the court bg (+ tweakable Y offset)\n            const stadiumBottomY = (eventCourtBgY + eventStadiumYOffset) * canvas.height;\n            const dy = stadiumBottomY - drawH;\n            ctx.drawImage(stadiumImg, dx, dy, drawW, drawH);\n        }\n\n        // ---- LAYER 3: Court Surface ----\n        function drawCourtBg() {\n            // Drawn inside camera zoom, so make it extra wide to cover zoomed-out edges\n            const courtBgTop = eventCourtBgY * canvas.height;\n            ctx.fillStyle = '#51AB4D';\n            ctx.fillRect(-canvas.width, courtBgTop, canvas.width * 3, canvas.height * 3);\n        }\n\n        function drawCourt() {\n            const courtImg = imageCache['tennis_court_surface'];\n            const courtH = canvas.height * 0.30 * config.debug.courtScale;\n            const courtTop = config.debug.courtYPosition * canvas.height;\n\n            if (courtImg) {\n                const aspect = courtImg.width / courtImg.height;\n                const courtW = Math.max(canvas.width, courtH * aspect);\n                const cx = (canvas.width - courtW) / 2;\n                ctx.drawImage(courtImg, cx, courtTop, courtW, courtH);\n            } else {\n                // Fallback: drawn court\n                // Dark green surround\n                const fallbackTop = canvas.height * 0.70;\n                const fallbackH = canvas.height * 0.30;\n                ctx.fillStyle = '#2E7D32';\n                ctx.fillRect(0, fallbackTop, canvas.width, fallbackH);\n                \n                // Main court surface\n                const courtPad = canvas.width * 0.05;\n                ctx.fillStyle = '#4CAF50';\n                ctx.fillRect(courtPad, courtTop + 10, canvas.width - courtPad * 2, courtH - 20);\n                \n                // Court lines\n                ctx.strokeStyle = 'rgba(255,255,255,0.9)';\n                ctx.lineWidth = 3;\n                // Baseline\n                ctx.strokeRect(courtPad + 10, courtTop + 15, canvas.width - courtPad * 2 - 20, courtH - 35);\n                // Center service line\n                ctx.beginPath();\n                ctx.moveTo(canvas.width / 2, courtTop + 15);\n                ctx.lineTo(canvas.width / 2, courtTop + courtH - 20);\n                ctx.stroke();\n                // Service box horizontal\n                ctx.beginPath();\n                ctx.moveTo(courtPad + 10, courtTop + courtH * 0.5);\n                ctx.lineTo(canvas.width - courtPad - 10, courtTop + courtH * 0.5);\n                ctx.stroke();\n            }\n        }\n\n        // ---- Combined background draw (all layers in order) ----\n        function drawBackground() {\n            drawSky();\n            drawStadium();\n            drawCourt();\n        }\n\n\n        function drawBean(relX, relY, scale, color, assetId) {\n            const x = relX * canvas.width;\n            const y = relY * canvas.height;\n            const baseH = 90 * scale;\n            const baseW = 60 * scale;\n            const img = imageCache[assetId];\n\n            if (img) {\n                const aspect = img.width / img.height;\n                const h = baseH;\n                const w = h * aspect;\n                const flipX = (assetId === 'ai_bean_red') ? -1 : 1;\n                ctx.save();\n                ctx.translate(x, y - h/2);\n                ctx.scale(flipX, 1);\n                ctx.drawImage(img, -w/2, -h/2, w, h);\n                ctx.restore();\n            } else {\n                // Fallback drawn bean\n                ctx.fillStyle = color;\n                ctx.beginPath();\n                ctx.ellipse(x, y - baseH/2, baseW/2, baseH/2, 0, 0, Math.PI * 2);\n                ctx.fill();\n                ctx.strokeStyle = '#000';\n                ctx.lineWidth = 3;\n                ctx.stroke();\n                // Eyes\n                ctx.fillStyle = 'white';\n                ctx.beginPath();\n                ctx.arc(x - 10*scale, y - baseH*0.6, 8*scale, 0, Math.PI*2);\n                ctx.arc(x + 10*scale, y - baseH*0.6, 8*scale, 0, Math.PI*2);\n                ctx.fill();\n                ctx.fillStyle = 'black';\n                ctx.beginPath();\n                ctx.arc(x - 8*scale, y - baseH*0.6, 4*scale, 0, Math.PI*2);\n                ctx.arc(x + 8*scale, y - baseH*0.6, 4*scale, 0, Math.PI*2);\n                ctx.fill();\n            }\n        }\n\n        function drawRacketGhost(x, y, scale, facingLeft, angle, opacity) {\n            const rImg = imageCache['tennis_racket'];\n            const offset = facingLeft ? -35 * scale : 35 * scale;\n            ctx.save();\n            ctx.globalAlpha = opacity;\n            ctx.translate(x + offset, y - 50 * scale);\n            ctx.rotate(angle);\n            if (rImg) {\n                const h = 60 * scale;\n                const w = h * (rImg.width / rImg.height);\n                ctx.drawImage(rImg, -w/2, -h/2, w, h);\n            } else {\n                ctx.fillStyle = '#2C3E50';\n                ctx.beginPath();\n                ctx.ellipse(0, -15*scale, 12*scale, 20*scale, 0, 0, Math.PI*2);\n                ctx.fill();\n                ctx.fillRect(-3*scale, 5*scale, 6*scale, 20*scale);\n            }\n            ctx.restore();\n        }\n\n        function drawRacket(relX, relY, scale, facingLeft, swinging, windingUp, bean) {\n            // Racket squash decay\n            if (bean && bean.racketSquash) {\n                bean.racketSquash *= 0.7;\n                if (bean.racketSquash < 0.005) bean.racketSquash = 0;\n            }\n            const x = relX * canvas.width;\n            const y = relY * canvas.height;\n            const rImg = imageCache['tennis_racket'];\n            const offset = facingLeft ? -35 * scale : 35 * scale;\n            let angle = facingLeft ? -0.2 : 0.2;\n            // Anticipation: rotate backward before hit\n            if (windingUp) {\n                const windupAngle = 0.55; // ~31 degrees \u2014 big visible pullback\n                angle += facingLeft ? windupAngle : -windupAngle;\n            }\n            // Follow-through: rotate forward past hit point, then ease back\n            if (bean && bean.followThrough) {\n                const progress = 1 - (bean.followThroughTimer / 0.2);\n                // Quick forward swing (first 40%), then ease back\n                let ftAngle;\n                if (progress < 0.4) {\n                    ftAngle = (progress / 0.4) * 0.45;\n                } else {\n                    ftAngle = 0.45 * (1 - (progress - 0.4) / 0.6);\n                }\n                angle += facingLeft ? -ftAngle : ftAngle;\n            }\n\n            // Draw motion trail ghosts during any swing phase (windup, swing, follow-through)\n            const isSwinging = swinging || windingUp || (bean && bean.followThrough);\n            if (isSwinging) {\n                const trailAngles = [0.18, 0.36, 0.54];\n                const trailOpacities = [0.55, 0.35, 0.18];\n                for (let i = 0; i < 3; i++) {\n                    const ghostAngle = angle + (facingLeft ? trailAngles[i] : -trailAngles[i]);\n                    drawRacketGhost(x, y, scale, facingLeft, ghostAngle, trailOpacities[i]);\n                }\n            }\n\n            ctx.save();\n            ctx.translate(x + offset, y - 50 * scale);\n            ctx.rotate(angle);\n            // Apply squash/stretch on impact\n            const sq = (bean && bean.racketSquash) ? bean.racketSquash : 0;\n            if (sq > 0) ctx.scale(1 + sq, 1 - sq);\n            if (rImg) {\n                const h = 60 * scale;\n                const w = h * (rImg.width / rImg.height);\n                ctx.drawImage(rImg, -w/2, -h/2, w, h);\n            } else {\n                ctx.fillStyle = '#2C3E50';\n                ctx.beginPath();\n                ctx.ellipse(0, -15*scale, 12*scale, 20*scale, 0, 0, Math.PI*2);\n                ctx.fill();\n                ctx.strokeStyle = '#000';\n                ctx.lineWidth = 2;\n                ctx.stroke();\n                ctx.fillRect(-3*scale, 5*scale, 6*scale, 20*scale);\n            }\n            ctx.restore();\n        }\n\n        // Ball trail history\n        const ballTrail = [];\n        const TRAIL_MAX = 12;\n\n        function updateBallTrail() {\n            if (ball.active) {\n                ballTrail.unshift({ x: ball.x, y: ball.y });\n                if (ballTrail.length > TRAIL_MAX) ballTrail.length = TRAIL_MAX;\n            } else {\n                ballTrail.length = 0;\n            }\n        }\n\n        function drawBallTrail(scale) {\n            if (ballTrail.length < 2) return;\n            const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);\n            // No trail at low speeds\n            if (speed < 0.15) return;\n            // Number of points scales with speed\n            const visibleCount = Math.min(ballTrail.length, Math.floor(2 + speed * 14));\n            const r = 15 * scale;\n\n            ctx.save();\n            for (let i = 1; i < visibleCount; i++) {\n                const t = i / visibleCount;\n                const p = ballTrail[i];\n                const px = p.x * canvas.width;\n                const py = p.y * canvas.height;\n                const taperR = r * (1 - t * 0.8);\n                ctx.globalAlpha = (1 - t) * 0.45;\n                ctx.fillStyle = '#FFFFFF';\n                ctx.beginPath();\n                ctx.arc(px, py, taperR, 0, Math.PI * 2);\n                ctx.fill();\n            }\n            ctx.restore();\n        }\n\n        function drawBall(relX, relY, scale) {\n            drawBallTrail(scale);\n            const x = relX * canvas.width;\n            const y = relY * canvas.height;\n            const r = 15 * scale;\n            const img = imageCache['tennis_ball'];\n            if (img) {\n                ctx.drawImage(img, x - r, y - r, r*2, r*2);\n            } else {\n                ctx.fillStyle = config.visuals.ballColor || '#C8E64E';\n                ctx.beginPath();\n                ctx.arc(x, y, r, 0, Math.PI * 2);\n                ctx.fill();\n                ctx.strokeStyle = '#000';\n                ctx.lineWidth = 2;\n                ctx.stroke();\n            }\n        }\n\n        function addParticles(x, y, count) {\n            for (let i = 0; i < count; i++) {\n                particles.push({\n                    x, y,\n                    vx: (Math.random() - 0.5) * 0.3,\n                    vy: (Math.random() - 0.5) * 0.3 - 0.1,\n                    life: 0.5 + Math.random() * 0.3,\n                    color: ['#FFD700', '#FF6B6B', '#4ECDC4', '#FFF'][Math.floor(Math.random()*4)],\n                    size: 3 + Math.random() * 5\n                });\n            }\n        }\n\n        function updateParticles(dt) {\n            for (let i = particles.length - 1; i >= 0; i--) {\n                const p = particles[i];\n                p.x += p.vx * dt;\n                p.y += p.vy * dt;\n                p.vy += 0.5 * dt;\n                p.life -= dt;\n                if (p.life <= 0) particles.splice(i, 1);\n            }\n        }\n\n        function drawParticles() {\n            for (const p of particles) {\n                ctx.globalAlpha = Math.max(0, p.life * 2);\n                ctx.fillStyle = p.color;\n                ctx.fillRect(p.x * canvas.width - p.size/2, p.y * canvas.height - p.size/2, p.size, p.size);\n            }\n            ctx.globalAlpha = 1;\n        }\n\n        // ----------------------------------------\n        // PHYSICS & COLLISIONS\n        // ----------------------------------------\n        function updateJumpSquash(bean, dt) {\n            if (bean.jumpPhase === 'anticipation') {\n                // Lerp toward anticipation squash smoothly (no instant snap)\n                const antEase = 1 - Math.pow(0.001, dt);\n                bean.squashX += (1.3 - bean.squashX) * antEase;\n                bean.squashY += (0.7 - bean.squashY) * antEase;\n                bean.jumpPhaseTimer -= dt;\n                if (bean.jumpPhaseTimer <= 0) {\n                    // Stage 2: Launch stretch\n                    bean.jumpPhase = 'launch';\n                    bean.jumpPhaseTimer = 0.1;\n                    bean.squashX = 0.7;\n                    bean.squashY = 1.4;\n                    // Actually launch (adjusted by event jump multiplier)\n                    bean.vy = -(getJumpPower() * eventJumpMult) / 1000;\n                    bean.onGround = false;\n                }\n            } else if (bean.jumpPhase === 'launch') {\n                bean.jumpPhaseTimer -= dt;\n                if (bean.jumpPhaseTimer <= 0) {\n                    // Stage 3: Recovery - smooth ease back\n                    bean.jumpPhase = 'recovery';\n                }\n            } else if (bean.jumpPhase === 'recovery' || bean.jumpPhase === null) {\n                // Smooth ease toward 1,1\n                const ease = 1 - Math.pow(0.02, dt);\n                bean.squashX += (1 - bean.squashX) * ease;\n                bean.squashY += (1 - bean.squashY) * ease;\n            }\n        }\n\n        function circlesCollide(x1, y1, r1, x2, y2, r2) {\n            const dx = x1 - x2;\n            const dy = y1 - y2;\n            const dist = Math.sqrt(dx*dx + dy*dy);\n            return dist < r1 + r2;\n        }\n\n        // Rectangle hitbox: returns true if ball circle overlaps player rect\n        function ballRectsCollide(ballX, ballY, ballR, rectCX, rectCY, rectHW, rectHH) {\n            // Find closest point on rect to ball center\n            const closestX = Math.max(rectCX - rectHW, Math.min(ballX, rectCX + rectHW));\n            const closestY = Math.max(rectCY - rectHH, Math.min(ballY, rectCY + rectHH));\n            const dx = ballX - closestX;\n            const dy = ballY - closestY;\n            return (dx * dx + dy * dy) < (ballR * ballR);\n        }\n\n        // ----------------------------------------\n        // AI - Difficulty presets & brain logic\n        // ----------------------------------------\n        // ----------------------------------------\n        // AI - Two-phase prediction & timed jump system\n        // ----------------------------------------\n\n        const SIM_DT = 1 / 60; // Frame duration for step-by-step simulations\n\n        function simulateBallTrajectory() {\n            // Step-by-step physics simulation of ball's path toward AI.\n            // Uses same gravity, air resistance, and ground bounces as real ball.\n            // Returns { y, frames } \u2014 predicted Y at AI's X and frame count to arrival.\n            if (!ball.active || ball.vx <= 0) return null;\n\n            let simX = ball.x;\n            let simY = ball.y;\n            let simVx = ball.vx;\n            let simVy = ball.vy;\n            const targetX = ai.x;\n            const groundY = getGroundY() - 0.01;\n            const gravity = (getBallGravity() * eventBallGravityMult) / 1000;\n            const bounceStr = config.physics.groundBounceStrength;\n\n            let frames = 0;\n            const maxFrames = 360; // 6 second safety cap\n\n            while (frames < maxFrames) {\n                simVy += gravity * SIM_DT;\n                simX += simVx * SIM_DT;\n                simY += simVy * SIM_DT;\n                simVx *= (1 - 0.01 * SIM_DT); // air resistance\n\n                // Ground bounce (same logic as real ball)\n                if (simY >= groundY) {\n                    simY = groundY;\n                    simVy *= -bounceStr;\n                    if (Math.abs(simVy) < 0.03) {\n                        return { y: simY, frames };\n                    }\n                }\n\n                frames++;\n\n                // Ball reached or crossed AI's X position\n                if (simX >= targetX) {\n                    return { y: simY, frames };\n                }\n\n                // Ball reversed \u2014 won't reach AI\n                if (simVx <= 0) return null;\n            }\n\n            return null;\n        }\n\n        function analyzeJump(targetBallY) {\n            // Simulates a jump from ground and finds:\n            // - framesToTarget: frames from jump initiation until hitbox center reaches targetBallY (on ascent)\n            // - totalAirFrames: total frames the bean is airborne\n            // Accounts for the 3-frame (0.05s) anticipation squash before launch.\n            const gravity = (getPlayerGravity() * eventPlayerGravityMult) / 1000;\n            const jumpVy = -(getJumpPower() * eventJumpMult) / 1000;\n            const groundY = getGroundY();\n            const aScale = aiScale * config.debug.playerDefaultScale;\n            const hh = getHitbox(ai.x, groundY, aScale).hh;\n\n            // Bean Y where hitbox center aligns with ball\n            const targetBeanY = targetBallY + hh;\n\n            const anticipationFrames = 3; // 0.05s squash before launch\n\n            let simY = groundY;\n            let simVy = jumpVy;\n            let frame = anticipationFrames;\n            let framesToTarget = -1;\n            let airFrames = 0;\n\n            const maxFrames = 300;\n            while (frame < maxFrames) {\n                simVy += gravity * SIM_DT;\n                simY += simVy * SIM_DT;\n                frame++;\n                airFrames++;\n\n                // On ascent (simVy < 0 = moving up), check if we crossed targetBeanY\n                if (framesToTarget === -1 && simVy < 0 && simY <= targetBeanY) {\n                    framesToTarget = frame;\n                }\n\n                // Landed back on ground\n                if (simY >= groundY && airFrames > 3) {\n                    break;\n                }\n            }\n\n            // If target never reached (ball too high), use apex frame\n            if (framesToTarget === -1) {\n                let aVy = jumpVy, aF = anticipationFrames;\n                while (aVy < 0 && aF < 300) {\n                    aVy += gravity * SIM_DT;\n                    aF++;\n                }\n                framesToTarget = aF;\n            }\n\n            return { framesToTarget, totalAirFrames: frame };\n        }\n\n        function getSingleJumpReachY() {\n            // Returns the highest Y (smallest number) that the hitbox TOP can reach with a single jump\n            const gravity = (getPlayerGravity() * eventPlayerGravityMult) / 1000;\n            const jumpVy = -(getJumpPower() * eventJumpMult) / 1000;\n            const groundY = getGroundY();\n            const aScale = aiScale * config.debug.playerDefaultScale;\n            const hh = getHitbox(ai.x, groundY, aScale).hh;\n            // Apex Y of the bean foot position\n            const apexTime = -jumpVy / gravity;\n            const apexY = groundY + jumpVy * apexTime + 0.5 * gravity * apexTime * apexTime;\n            // Hitbox center at apex = apexY - hh, top of hitbox = apexY - 2*hh\n            return apexY - 2 * hh;\n        }\n\n        function planMultiJump(targetY) {\n            // Simulates chaining jumps until hitbox center reaches targetY.\n            // Returns { schedule: [frame numbers], framesToTarget: total frames to reach target }\n            const gravity = (getPlayerGravity() * eventPlayerGravityMult) / 1000;\n            const jumpVy = -(getJumpPower() * eventJumpMult) / 1000;\n            const groundY = getGroundY();\n            const aScale = aiScale * config.debug.playerDefaultScale;\n            const hh = getHitbox(ai.x, groundY, aScale).hh;\n            const jumpCooldownFrames = 11; // ~0.18s at 60fps\n            const anticipationFrames = 3;  // 0.05s squash before first launch\n\n            // First jump fires at frame 0, but launch happens after anticipation\n            const schedule = [0];\n            let simY = groundY;\n            let simVy = 0;\n            let frame = 0;\n            let lastJumpFrame = 0;\n            const maxFrames = 300;\n            const maxJumps = 5;\n\n            // Simulate anticipation delay then launch\n            frame = anticipationFrames;\n            simVy = jumpVy;\n\n            while (frame < maxFrames) {\n                simVy += gravity * SIM_DT;\n                simY += simVy * SIM_DT;\n                frame++;\n\n                // Check if hitbox center has reached target (hitbox center = simY - hh)\n                const hitboxCenterY = simY - hh;\n                if (hitboxCenterY <= targetY) {\n                    return { schedule, framesToTarget: frame };\n                }\n\n                // If falling and haven't reached target, and cooldown elapsed, chain another jump\n                if (simVy > 0 && schedule.length < maxJumps) {\n                    const framesSinceLastJump = frame - lastJumpFrame;\n                    if (framesSinceLastJump >= jumpCooldownFrames) {\n                        simVy = jumpVy; // Reset velocity (mid-air jump)\n                        schedule.push(frame);\n                        lastJumpFrame = frame;\n                    }\n                }\n\n                // Back on ground = can't reach target\n                if (simY >= groundY && frame > anticipationFrames + 5) {\n                    break;\n                }\n            }\n\n            // Return best-effort schedule even if target not reached\n            return { schedule, framesToTarget: frame };\n        }\n\n        function executeAIJump(midAir) {\n            if (ai.jumpCooldown > 0) return false;\n            if (!midAir && !ai.onGround) return false;\n\n            if (midAir) {\n                // Mid-air jump: directly set velocity (no anticipation squash)\n                ai.vy = -(getJumpPower() * eventJumpMult) / 1000;\n                ai.onGround = false;\n            } else {\n                // Ground jump: anticipation animation leading to launch\n                ai.jumpPhase = 'anticipation';\n                ai.jumpPhaseTimer = 0.05;\n            }\n            ai.jumpCooldown = 0.18;\n            playSound('jump_sound');\n            return true;\n        }\n\n        // ----------------------------------------\n        // AI HELPER FUNCTIONS\n        // ----------------------------------------\n\n        /** Advance the multi-jump plan, firing jumps at scheduled frames */\n        function aiTickMultiJumpPlan(dt) {\n            if (!ai._jumpPlanActive || !ai._activeJumpPlan) return;\n            ai._jumpPlanFrame += dt / SIM_DT;\n            while (ai._nextJumpIdx < ai._activeJumpPlan.length &&\n                   ai._jumpPlanFrame >= ai._activeJumpPlan[ai._nextJumpIdx]) {\n                executeAIJump(ai._nextJumpIdx > 0); // first from ground, rest mid-air\n                ai._nextJumpIdx++;\n            }\n            if (ai._nextJumpIdx >= ai._activeJumpPlan.length) {\n                ai._jumpPlanActive = false;\n            }\n        }\n\n        /** Commit a multi-jump plan to execution */\n        function aiCommitMultiJumpPlan(plan) {\n            ai._activeJumpPlan = plan.schedule;\n            ai._jumpPlanFrame = 0;\n            ai._nextJumpIdx = 0;\n            ai._jumpPlanActive = true;\n            aiMarkJumpExecuted();\n        }\n\n        /** Mark jump as executed and handle attack mode flag */\n        function aiMarkJumpExecuted() {\n            ai.jumpExecuted = true;\n            if (ai.attackMode) ai.attackJumpExecuted = true;\n        }\n\n        /** Generate a new prediction with noise, optionally applying a misread */\n        function aiMakePrediction() {\n            const prediction = simulateBallTrajectory();\n            if (!prediction) return false;\n            const params = getAIParams();\n\n            let predY = prediction.y + (Math.random() * 2 - 1) * params.predictionNoise;\n\n            // Misread roll\n            if (Math.random() < params.misreadChance) {\n                const sign = Math.random() > 0.5 ? 1 : -1;\n                const offset = params.misreadOffsetMin +\n                    Math.random() * (params.misreadOffsetMax - params.misreadOffsetMin);\n                ai.misreading = true;\n                ai.misreadTimer = params.misreadDuration;\n                ai.misreadPredY = predY + sign * offset;\n                ai.predictedY = ai.misreadPredY;\n            } else {\n                ai.misreading = false;\n                ai.predictedY = predY;\n            }\n\n            ai.hasPrediction = true;\n            ai.predictedFrames = prediction.frames;\n            ai.predictionAge = 0;\n            ai.jumpExecuted = false;\n            return true;\n        }\n\n        /** Re-predict cleanly (no misread roll) after self-correction */\n        function aiRePredictClean() {\n            const prediction = simulateBallTrajectory();\n            if (!prediction) return;\n            const params = getAIParams();\n            ai.predictedY = prediction.y + (Math.random() * 2 - 1) * params.predictionNoise;\n            ai.predictedFrames = prediction.frames;\n            ai.predictionAge = 0;\n        }\n\n        /** Tick misread self-correction timer */\n        function aiTickMisread(dt) {\n            if (!ai.misreading) return;\n            ai.misreadTimer -= dt;\n            if (ai.misreadTimer <= 0) {\n                ai.misreading = false;\n                aiRePredictClean();\n            }\n        }\n\n        /** Attempt single-jump timing against predicted ball */\n        function aiTrySingleJump(framesUntilBallArrival) {\n            const params = getAIParams();\n            const jumpAnalysis = analyzeJump(ai.predictedY);\n            if (jumpAnalysis.framesToTarget < 0) return;\n\n            const idealJumpIn = framesUntilBallArrival - jumpAnalysis.framesToTarget;\n\n            // Jump when timing is within tolerance\n            if (Math.abs(idealJumpIn) <= params.timingToleranceFrames) {\n                executeAIJump(false);\n                aiMarkJumpExecuted();\n                ai.debug.state = 'JUMP';\n                return;\n            }\n\n            // Panic: ball arriving imminently\n            const panicFrames = jumpAnalysis.totalAirFrames * params.panicThreshold;\n            if (framesUntilBallArrival <= panicFrames) {\n                executeAIJump(false);\n                aiMarkJumpExecuted();\n                ai.debug.state = 'PANIC';\n            }\n        }\n\n        /** Attempt multi-jump plan against predicted ball */\n        function aiTryMultiJump(framesUntilBallArrival) {\n            const params = getAIParams();\n            // Skip chance based on difficulty (higher = less confident)\n            if (Math.random() < params.multiJumpSkipChance) {\n                ai.jumpExecuted = true;\n                ai.debug.state = 'SKIP';\n                return;\n            }\n\n            const plan = planMultiJump(ai.predictedY);\n            const idealStartIn = framesUntilBallArrival - plan.framesToTarget;\n\n            if (idealStartIn <= params.timingToleranceFrames) {\n                aiCommitMultiJumpPlan(plan);\n                ai.debug.state = 'MULTI-JUMP';\n                return;\n            }\n\n            // Panic: ball arriving imminently\n            if (framesUntilBallArrival <= 8) {\n                aiCommitMultiJumpPlan(plan);\n                ai.debug.state = 'PANIC-MULTI';\n            }\n        }\n\n        /** Reset AI tracking state when ball is no longer approaching */\n        function aiResetTracking() {\n            ai.hasPrediction = false;\n            ai.jumpExecuted = false;\n            ai.misreading = false;\n            ai.reactionDelayActive = false;\n            ai.reactionDelayTimer = 0;\n            ai._jumpPlanActive = false;\n            ai._activeJumpPlan = null;\n            ai._jumpPlanFrame = 0;\n            ai._nextJumpIdx = 0;\n            ai.debug.state = 'WAIT';\n            ai.debug.jumpNeeded = false;\n        }\n\n        // ----------------------------------------\n        // AI MAIN UPDATE\n        // ----------------------------------------\n\n        function updateAI(dt) {\n            const GROUND_Y = getGroundY();\n            ai.jumpCooldown -= dt;\n\n            aiTickMultiJumpPlan(dt);\n\n            // Detect ball direction\n            const ballHeadingToAI = ball.active && ball.vx > 0;\n            const ballJustTurnedToAI = ballHeadingToAI && ai.lastBallVx <= 0;\n            ai.lastBallVx = ball.active ? ball.vx : 0;\n\n            // Reset emergency jump flag when ball leaves AI side\n            if (!ballHeadingToAI && ball.vx <= 0) {\n                ai._emergencyJumpUsed = false;\n            }\n\n            // Emergency jump fallback: prevent AI standing idle while ball passes overhead\n            if (ball.active && ball.x > NET_X && ai.onGround && ai.jumpCooldown <= 0\n                && !ai._jumpPlanActive\n                && !ai._emergencyJumpUsed) {\n                const hDist = Math.abs(ball.x - ai.x);\n                const aScale = aiScale * config.debug.playerDefaultScale;\n                const aHB = getHitbox(ai.x, ai.y, aScale);\n                const ballAboveHead = ball.y < (aHB.cy - aHB.hh);\n                if (hDist < 0.1 && ballAboveHead) {\n                    executeAIJump(false);\n                    ai._emergencyJumpUsed = true;\n                }\n            }\n\n            // Debug info\n            ai.debug.canJump = ai.onGround && ai.jumpCooldown <= 0;\n            ai.debug.ballAboveMe = ball.y < ai.y;\n            ai.debug.distToTarget = Math.abs(ball.x - ai.x);\n\n            // Attack mode check\n            if (!ai.attackMode && ai.groundedRallyCount >= ai.attackThreshold) {\n                ai.attackMode = true;\n                ai.attackJumpExecuted = false;\n            }\n\n            // PHASE 1: New prediction when ball turns toward AI (with reaction delay)\n            if (ballJustTurnedToAI) {\n                const params = getAIParams();\n                if (params.reactionDelay > 0) {\n                    ai.reactionDelayTimer = params.reactionDelay;\n                    ai.reactionDelayActive = true;\n                    ai.debug.state = 'DELAY';\n                } else {\n                    aiMakePrediction();\n                    ai.debug.state = 'PREDICT';\n                }\n            }\n\n            // Tick reaction delay\n            if (ai.reactionDelayActive && ballHeadingToAI) {\n                ai.reactionDelayTimer -= dt;\n                if (ai.reactionDelayTimer <= 0) {\n                    ai.reactionDelayActive = false;\n                    aiMakePrediction();\n                    ai.debug.state = 'PREDICT';\n                }\n            }\n\n            // Misread self-correction\n            aiTickMisread(dt);\n\n            // PHASE 2: Timing the jump\n            if (ai.hasPrediction && ballHeadingToAI && !ai.jumpExecuted) {\n                ai.predictionAge += dt / SIM_DT;\n                const framesUntilBallArrival = Math.max(0, ai.predictedFrames - ai.predictionAge);\n\n                // Check if ball lands within standing hitbox\n                const aiHB = getHitbox(ai.x, GROUND_Y, aiScale * config.debug.playerDefaultScale);\n                const threshold = config.ai.jumpThreshold || 0.03;\n                const ballInStandingHitbox = Math.abs(ai.predictedY - aiHB.cy) < (aiHB.hh + threshold);\n\n                ai.debug.jumpNeeded = !ballInStandingHitbox;\n                ai.debug.ballInHitbox = ballInStandingHitbox;\n\n                const shouldJump = (ai.attackMode && !ai.attackJumpExecuted) || !ballInStandingHitbox;\n\n                if (shouldJump && ai.onGround && ai.jumpCooldown <= 0) {\n                    const singleJumpReach = getSingleJumpReachY();\n                    if (ai.predictedY >= singleJumpReach) {\n                        aiTrySingleJump(framesUntilBallArrival);\n                    } else {\n                        aiTryMultiJump(framesUntilBallArrival);\n                    }\n                } else if (ballInStandingHitbox && !ai.attackMode) {\n                    ai.debug.state = 'STAND';\n                }\n            }\n\n            // Reset when ball goes away\n            if (!ballHeadingToAI) {\n                aiResetTracking();\n            }\n        }\n\n        // ----------------------------------------\n        // MATCH EVENTS - Scale event logic\n        // ----------------------------------------\n        // Match event registry \u2014 each event has an ID and activation logic\n        const MATCH_EVENTS = {\n            giant_players: {\n                label: '\u26a1 GIANT PLAYERS',\n                activate() {\n                    const scaleFactor = 2.0 * getScaleMult();\n                    targetPlayerScale = scaleFactor;\n                    targetAiScale = scaleFactor;\n                    targetCameraZoom = 0.7;\n                    targetEventXSpread = 0.06;\n                    targetEventJumpMult = 1.4;\n                    targetEventBallSpeedMult = config.settings.giantEventBallSpeedMult || 1.25;\n                    targetEventBallGravityMult = config.settings.giantEventBallGravityMult || 0.85;\n                    targetEventCourtBgY = 0.61;\n                    targetEventBallY = config.settings.giantEventBallY !== undefined ? config.settings.giantEventBallY : 0.4;\n                    targetEventStadiumYOffset = 0.16;\n                    targetEventPlayerGravityMult = 1.3;\n                },\n                deactivate() {\n                    targetPlayerScale = 1;\n                    targetAiScale = 1;\n                    targetCameraZoom = 1;\n                    targetEventXSpread = 0;\n                    targetEventJumpMult = 1;\n                    targetEventBallSpeedMult = 1;\n                    targetEventBallGravityMult = 1;\n                    targetEventCourtBgY = config.debug.courtBgYPosition;\n                    targetEventBallY = config.debug.ballYPosition;\n                    targetEventStadiumYOffset = config.debug.stadiumYOffset || 0;\n                    targetEventPlayerGravityMult = 1;\n                }\n            },\n            big_ball: {\n                label: '\ud83c\udfc0 BIG BALL',\n                activate() {\n                    targetEventBallScale = config.settings.bigBallScale || 3.0;\n                    targetEventBallGravityMult = config.settings.bigBallGravityMult || 0.7;\n                    targetEventBallSpeedMult = config.settings.bigBallSpeedMult || 0.8;\n                    targetEventBounceMult = config.settings.bigBallBounceMult || 1.5;\n                },\n                deactivate() {\n                    targetEventBallScale = 1;\n                    targetEventBallGravityMult = 1;\n                    targetEventBallSpeedMult = 1;\n                    targetEventBounceMult = 1;\n                }\n            },\n            tiny_players: {\n                label: '\ud83d\udd2c TINY PLAYERS',\n                activate() {\n                    const tinyScale = 1 / (config.settings.tinyEventPlayerScale || 3);\n                    targetPlayerScale = tinyScale;\n                    targetAiScale = tinyScale;\n                    targetCameraZoom = 1;\n                    targetEventXSpread = 0;\n                    targetEventJumpMult = 1.3;\n                    targetEventBallSpeedMult = 0.7;\n                    targetEventBallGravityMult = 0.75;\n                    targetEventCourtBgY = config.debug.courtBgYPosition;\n                    targetEventBallY = config.settings.tinyEventBallY !== undefined ? config.settings.tinyEventBallY : config.debug.ballYPosition;\n                    targetEventStadiumYOffset = config.debug.stadiumYOffset || 0;\n                    targetEventPlayerGravityMult = config.settings.tinyEventPlayerGravityMult !== undefined ? config.settings.tinyEventPlayerGravityMult : 0.8;\n                    targetEventHitboxScale = config.settings.tinyEventHitboxScale !== undefined ? config.settings.tinyEventHitboxScale : 1.5;\n                },\n                deactivate() {\n                    targetPlayerScale = 1;\n                    targetAiScale = 1;\n                    targetCameraZoom = 1;\n                    targetEventXSpread = 0;\n                    targetEventJumpMult = 1;\n                    targetEventBallSpeedMult = 1;\n                    targetEventBallGravityMult = 1;\n                    targetEventCourtBgY = config.debug.courtBgYPosition;\n                    targetEventBallY = config.debug.ballYPosition;\n                    targetEventStadiumYOffset = config.debug.stadiumYOffset || 0;\n                    targetEventPlayerGravityMult = 1;\n                    targetEventHitboxScale = 1;\n                }\n            }\n        };\n\n        // Available events for random selection\n        const EVENT_IDS = ['giant_players', 'tiny_players', 'big_ball'];\n\n        function updateScaleEvent(dt) {\n            const testEvent = config.debug.testEvent;\n\n            // Test event mode: activate immediately and never end\n            if (testEvent && testEvent.enabled) {\n                if (!eventActive) {\n                    eventActive = true;\n                    currentEventId = testEvent.eventId;\n                    const evt = MATCH_EVENTS[currentEventId];\n                    if (evt) evt.activate();\n                    playSound('scale_event_start_sound');\n                    document.getElementById('event-timer').style.display = 'block';\n                    document.getElementById('event-timer').querySelector('.event-timer-label').textContent = `\u26a1 TEST: ${currentEventId}`;\n                }\n                // Smooth transitions\n                const lerpSpeed = dt * 4;\n                playerScale += (targetPlayerScale - playerScale) * Math.min(1, lerpSpeed);\n                aiScale += (targetAiScale - aiScale) * Math.min(1, lerpSpeed);\n                cameraZoom += (targetCameraZoom - cameraZoom) * Math.min(1, lerpSpeed);\n                eventXSpread += (targetEventXSpread - eventXSpread) * Math.min(1, lerpSpeed);\n                eventJumpMult += (targetEventJumpMult - eventJumpMult) * Math.min(1, lerpSpeed);\n                eventBallSpeedMult += (targetEventBallSpeedMult - eventBallSpeedMult) * Math.min(1, lerpSpeed);\n                eventBallGravityMult += (targetEventBallGravityMult - eventBallGravityMult) * Math.min(1, lerpSpeed);\n            eventCourtBgY += (targetEventCourtBgY - eventCourtBgY) * Math.min(1, lerpSpeed);\n                eventBallY += (targetEventBallY - eventBallY) * Math.min(1, lerpSpeed);\n                eventPlayerGravityMult += (targetEventPlayerGravityMult - eventPlayerGravityMult) * Math.min(1, lerpSpeed);\n                eventStadiumYOffset += (targetEventStadiumYOffset - eventStadiumYOffset) * Math.min(1, lerpSpeed);\n                eventHitboxScale += (targetEventHitboxScale - eventHitboxScale) * Math.min(1, lerpSpeed);\n                eventBallScale += (targetEventBallScale - eventBallScale) * Math.min(1, lerpSpeed);\n                eventBounceMult += (targetEventBounceMult - eventBounceMult) * Math.min(1, lerpSpeed);\n                return;\n            } else if (!testEvent?.enabled && eventActive && !eventTimeLeft) {\n                // Test was disabled \u2014 deactivate\n                const evt = MATCH_EVENTS[currentEventId || 'giant_players'];\n                if (evt) evt.deactivate();\n                eventActive = false;\n                currentEventId = null;\n                playSound('scale_event_end_sound');\n                document.getElementById('event-timer').style.display = 'none';\n            }\n\n            eventTimer += dt;\n            if (eventCooldownTimer > 0) eventCooldownTimer -= dt;\n\n            // Determine if an event should trigger\n            const totalScore = playerScore + aiScore;\n            let shouldTrigger = false;\n\n            if (!eventActive && eventCooldownTimer <= 0) {\n                if (!firstEventTriggered && totalScore >= eventScoreThreshold) {\n                    shouldTrigger = true;\n                    firstEventTriggered = true;\n                } else if (firstEventTriggered && eventTimer >= config.settings.scaleEventInterval) {\n                    shouldTrigger = true;\n                }\n            }\n\n            if (shouldTrigger) {\n                // Randomly pick an event\n                currentEventId = EVENT_IDS[Math.floor(Math.random() * EVENT_IDS.length)];\n                const selectedEvent = MATCH_EVENTS[currentEventId];\n\n                eventActive = true;\n                eventTimeLeft = config.settings.scaleEventDuration;\n                eventTimer = 0;\n                eventsThisMatch++;\n\n                selectedEvent.activate();\n\n                playSound('scale_event_start_sound');\n                document.getElementById('event-timer').style.display = 'block';\n                document.getElementById('event-timer').querySelector('.event-timer-label').textContent = selectedEvent.label;\n                document.getElementById('event-timer-bar').style.width = '100%';\n\n            }\n\n            if (eventActive) {\n                eventTimeLeft -= dt;\n                const barProgress = Math.max(0, eventTimeLeft / config.settings.scaleEventDuration);\n                document.getElementById('event-timer-bar').style.width = `${barProgress * 100}%`;\n\n                if (eventTimeLeft <= 0) {\n                    // End event \u2014 return everything to normal\n                    eventActive = false;\n                    const endingEvent = MATCH_EVENTS[currentEventId];\n                    if (endingEvent) endingEvent.deactivate();\n                    currentEventId = null;\n                    eventCooldownTimer = config.settings.eventCooldown || 20;\n                    eventTimer = 0;\n                    playSound('scale_event_end_sound');\n                    document.getElementById('event-timer').style.display = 'none';\n                }\n            }\n\n            // Smooth transitions for all event values\n            const lerpSpeed = dt * 4;\n            playerScale += (targetPlayerScale - playerScale) * Math.min(1, lerpSpeed);\n            aiScale += (targetAiScale - aiScale) * Math.min(1, lerpSpeed);\n            cameraZoom += (targetCameraZoom - cameraZoom) * Math.min(1, lerpSpeed);\n            eventXSpread += (targetEventXSpread - eventXSpread) * Math.min(1, lerpSpeed);\n            eventJumpMult += (targetEventJumpMult - eventJumpMult) * Math.min(1, lerpSpeed);\n            eventBallSpeedMult += (targetEventBallSpeedMult - eventBallSpeedMult) * Math.min(1, lerpSpeed);\n            eventBallGravityMult += (targetEventBallGravityMult - eventBallGravityMult) * Math.min(1, lerpSpeed);\n            eventCourtBgY += (targetEventCourtBgY - eventCourtBgY) * Math.min(1, lerpSpeed);\n            eventBallY += (targetEventBallY - eventBallY) * Math.min(1, lerpSpeed);\n            eventPlayerGravityMult += (targetEventPlayerGravityMult - eventPlayerGravityMult) * Math.min(1, lerpSpeed);\n            eventStadiumYOffset += (targetEventStadiumYOffset - eventStadiumYOffset) * Math.min(1, lerpSpeed);\n            eventHitboxScale += (targetEventHitboxScale - eventHitboxScale) * Math.min(1, lerpSpeed);\n            eventBallScale += (targetEventBallScale - eventBallScale) * Math.min(1, lerpSpeed);\n            eventBounceMult += (targetEventBounceMult - eventBounceMult) * Math.min(1, lerpSpeed);\n        }\n\n        // ----------------------------------------\n        // MAIN GAME LOOP - Update & Render\n        // ----------------------------------------\n        // ---- Update sub-systems ----\n\n        function updateBeanPhysics(bean, dt) {\n            const GROUND_Y = getGroundY();\n            if (!bean.onGround) {\n                bean.vy += (getPlayerGravity() * eventPlayerGravityMult / 1000) * dt;\n                bean.y += bean.vy * dt;\n                if (bean.y >= GROUND_Y) {\n                    bean.y = GROUND_Y;\n                    bean.vy = 0;\n                    bean.onGround = true;\n                    bean.squashX = 1.3;\n                    bean.squashY = 0.7;\n                    bean.jumpPhase = null;\n                }\n            } else { bean.y = GROUND_Y; }\n            updateJumpSquash(bean, dt);\n        }\n\n        function updateBallPhysics(dt) {\n            if (!ball.active) return false;\n\n            ball.vy += (getBallGravity() * eventBallGravityMult / 1000) * dt;\n            ball.x += ball.vx * dt;\n            ball.y += ball.vy * dt;\n            ball.vx *= (1 - 0.01 * dt);\n\n            // Ground bounce\n            if (ball.y >= getGroundY() - 0.01) {\n                ball.y = getGroundY() - 0.01;\n                ball.vy *= -config.physics.groundBounceStrength * eventBounceMult;\n                playSound('ball_bounce_sound');\n                if (Math.abs(ball.vy) < 0.03) {\n                    const side = ball.x > NET_X ? 'Opponent' : 'Player';\n                    resolveContactAnalysis(`Ball Scored A Point (${side} Side)`);\n                    scorePoint(ball.x < NET_X ? 'ai' : 'player');\n                    return true;\n                }\n                // Resolve contact analysis on first bounce after player hit\n                if (contactAnalysis.pending) {\n                    const side = ball.x > NET_X ? 'Opponent' : 'Player';\n                    const bounceHeightPx = Math.round(ball.y * canvas.height);\n                    resolveContactAnalysis(`Ball Bounced On ${side} Side\\nBounce Height: ${bounceHeightPx}`);\n                }\n            }\n\n            // Out of bounds\n            const outLeft = -(0.05 / cameraZoom) + (1 - 1/cameraZoom) / 2;\n            const outRight = 1 + (0.05 / cameraZoom) - (1 - 1/cameraZoom) / 2;\n            if (ball.x < outLeft) { resolveContactAnalysis('Ball Went Out Of Bounds (Left)'); scorePoint('ai'); return true; }\n            if (ball.x > outRight) { resolveContactAnalysis('Ball Went Out Of Bounds (Right)'); scorePoint('player'); return true; }\n\n            return false;\n        }\n\n        // Hitbox dimensions for a bean (half-width, half-height in normalized coords)\n        function getHitbox(beanX, beanY, scale) {\n            const hw = 0.04 * scale * eventHitboxScale; // half-width (bean is narrower than tall)\n            const hh = 0.035 * scale * eventHitboxScale; // half-height\n            const cx = beanX;\n            const cy = beanY - hh; // center of rect is offset up from foot\n            return { cx, cy, hw, hh };\n        }\n\n        function updateBallCollisions() {\n            if (!ball.active) return;\n            // Player collision (rectangle)\n            const pHB = getHitbox(player.x, player.y, playerScale * config.debug.playerDefaultScale);\n            if (ball.x < NET_X && ballRectsCollide(ball.x, ball.y, 0.02, pHB.cx, pHB.cy, pHB.hw, pHB.hh) && ball.lastHitBy !== 'player') {\n                hitBall('player');\n            }\n            // AI collision (rectangle)\n            const aHB = getHitbox(ai.x, ai.y, aiScale * config.debug.playerDefaultScale);\n            if (ball.x > NET_X && ballRectsCollide(ball.x, ball.y, 0.02, aHB.cx, aHB.cy, aHB.hw, aHB.hh) && ball.lastHitBy !== 'ai') {\n                resolveContactAnalysis(`Opponent Contacted Ball\\nOpponent Airborne: ${!ai.onGround}`);\n                hitBall('ai');\n            }\n        }\n\n        function updateSwingTimers(bean, dt) {\n            if (bean.swinging) { bean.swingTimer -= dt; if (bean.swingTimer <= 0) bean.swinging = false; }\n            if (bean.followThrough) { bean.followThroughTimer -= dt; if (bean.followThroughTimer <= 0) bean.followThrough = false; }\n            if (bean.windingUp) { bean.windupTimer -= dt; if (bean.windupTimer <= 0) bean.windingUp = false; }\n        }\n\n        function updateWindupAnticipation() {\n            if (!ball.active) return;\n            const windupDist = 0.12;\n            if (ball.vx < 0 && !player.windingUp && !player.swinging) {\n                const dx = Math.abs(ball.x - player.x);\n                const pScale = playerScale * config.debug.playerDefaultScale;\n                const pHB = getHitbox(player.x, player.y, pScale);\n                const dy = Math.abs(ball.y - pHB.cy);\n                if (dx < windupDist && dx > 0.02 && dy < pHB.hh + 0.04) {\n                    player.windingUp = true;\n                    player.windupTimer = 0.1;\n                }\n            }\n            if (ball.vx > 0 && !ai.windingUp && !ai.swinging) {\n                const dx = Math.abs(ball.x - ai.x);\n                const aScale = aiScale * config.debug.playerDefaultScale;\n                const aHB = getHitbox(ai.x, ai.y, aScale);\n                const dy = Math.abs(ball.y - aHB.cy);\n                if (dx < windupDist && dx > 0.02 && dy < aHB.hh + 0.04) {\n                    ai.windingUp = true;\n                    ai.windupTimer = 0.1;\n                }\n            }\n        }\n\n        // ---- Main update orchestrator ----\n\n        function update(dt) {\n            if (menuActive || introActive || paused || gameOver) return;\n\n            // Serve delay\n            if (!ball.active) { serveDelay -= dt; if (serveDelay <= 0) serveBall(); }\n\n            jumpCooldown -= dt;\n            ai.jumpCooldown -= dt;\n\n            // Live-update positions\n            player.x = 0.5 - (config.debug.playerXSpread + eventXSpread);\n            ai.x = 0.5 + (config.debug.playerXSpread + eventXSpread);\n\n            // Physics\n            updateBeanPhysics(player, dt);\n            updateBeanPhysics(ai, dt);\n            updateAI(dt);\n            updateBallTrail();\n            if (updateBallPhysics(dt)) return;\n            updateBallCollisions();\n\n            // Events & particles\n            if (!config.debug.disableMatchEvents) updateScaleEvent(dt);\n            updateParticles(dt);\n\n            // Swing animations\n            updateSwingTimers(player, dt);\n            updateSwingTimers(ai, dt);\n            updateWindupAnticipation();\n        }\n\n        // Impact flash state\n        let impactFlash = { active: false, x: 0, y: 0, timer: 0 };\n\n        function hitBall(hitter) {\n            const bean = hitter === 'player' ? player : ai;\n            const direction = hitter === 'player' ? 1 : -1;\n            const rallyBonus = Math.min(1.5, 1 + rallyCount * 0.05);\n            const speed = (0.35 + Math.random() * 0.1) * getBallSpeed() * eventBallSpeedMult * rallyBonus;\n\n            // Contact analysis: capture ball vy before modification (player only)\n            const ballVyBefore = ball.vy;\n            if (hitter === 'player') captureContactSnapshot(ballVyBefore);\n\n            const hbScale = (hitter === 'player' ? playerScale : aiScale) * config.debug.playerDefaultScale;\n            const beanHB = getHitbox(bean.x, bean.y, hbScale);\n            const beanCenterY = beanHB.cy;\n\n            // Track grounded rally exchanges for AI attack state\n            if (hitter === 'ai') {\n                if (ai.onGround && !ai.attackMode) {\n                    // Normal grounded hit \u2014 increment counter\n                    ai.groundedRallyCount++;\n                } else if (ai.attackMode) {\n                    // Attack mode hit \u2014 apply extra downward angle for rally-breaking shot\n                    // The jump gives a higher contact point, so we add more downward velocity\n                    const attackSpeed = (0.35 + Math.random() * 0.1) * getBallSpeed() * eventBallSpeedMult * rallyBonus;\n                    ball.vx = attackSpeed * direction * (config.physics.ballBounceSpeedUp || 1.2) * 1.15;\n                    // Attack mode also uses offset-based deflection\n                    const atkOffset = ball.y - beanCenterY;\n                    const atkDeflection = config.physics.ballDeflectionFactor || 15.0;\n                    const atkMaxVy = config.physics.ballMaxSpeedY || 1.0;\n                    ball.vy = Math.max(-atkMaxVy, Math.min(atkMaxVy, atkOffset * atkDeflection));\n                    ball.lastHitBy = hitter;\n                    // Reset attack state\n                    ai.attackMode = false;\n                    ai.attackJumpExecuted = false;\n                    ai.groundedRallyCount = 0;\n                    ai.attackThreshold = getAttackThreshold();\n\n                    bean.swinging = true;\n                    bean.swingTimer = 0.2;\n                    bean.followThrough = true;\n                    bean.followThroughTimer = 0.2;\n                    bean.racketSquash = 0.38;\n                    impactFlash = { active: true, x: ball.x, y: ball.y, timer: 0.22 };\n                    rallyCount++;\n                    updateUI();\n                    playSound('ball_hit_sound');\n                    for (let i = 0; i < 6; i++) {\n                        particles.push({\n                            x: ball.x, y: ball.y,\n                            vx: (Math.random() - 0.5) * 0.4,\n                            vy: (Math.random() - 0.5) * 0.4,\n                            life: 0.2 + Math.random() * 0.15,\n                            color: ['#FFF', '#FFF', '#FFE082'][Math.floor(Math.random()*3)],\n                            size: 2 + Math.random() * 4\n                        });\n                    }\n                    return; // Early return \u2014 custom hit logic applied\n                }\n            } else if (hitter === 'player') {\n                // Player grounded hit also counts toward the AI's read of a \"boring\" rally\n                if (player.onGround) {\n                    // Don't reset \u2014 AI is tracking overall rally monotony\n                } else {\n                    // Player jumped \u2014 rally broke naturally, reset AI attack counter\n                    ai.groundedRallyCount = 0;\n                    ai.attackThreshold = getAttackThreshold();\n                }\n            }\n\n            ball.vx = speed * direction * (config.physics.ballBounceSpeedUp || 1.2);\n            // Deflection: offset from bean center determines vy (signed, replaces previous vy)\n            const offset = ball.y - beanCenterY; // negative = ball above center (HIGH hit -> LOB)\n            const deflectionFactor = config.physics.ballDeflectionFactor || 15.0;\n            const maxVy = config.physics.ballMaxSpeedY || 1.0;\n            ball.vy = Math.max(-maxVy, Math.min(maxVy, offset * deflectionFactor));\n            ball.lastHitBy = hitter;\n\n            // Finalize contact analysis snapshot with post-hit velocities\n            if (hitter === 'player') finalizeContactSnapshot();\n\n            bean.swinging = true;\n            bean.swingTimer = 0.2;\n            bean.followThrough = true;\n            bean.followThroughTimer = 0.2;\n            // Racket squash on impact\n            bean.racketSquash = 0.38;\n            // White flash at contact point\n            impactFlash = { active: true, x: ball.x, y: ball.y, timer: 0.22 };\n            rallyCount++;\n            updateUI();\n            playSound('ball_hit_sound');\n            // Impact particles (small white/yellow)\n            for (let i = 0; i < 6; i++) {\n                particles.push({\n                    x: ball.x, y: ball.y,\n                    vx: (Math.random() - 0.5) * 0.4,\n                    vy: (Math.random() - 0.5) * 0.4,\n                    life: 0.2 + Math.random() * 0.15,\n                    color: ['#FFF', '#FFF', '#FFE082'][Math.floor(Math.random()*3)],\n                    size: 2 + Math.random() * 4\n                });\n            }\n        }\n\n        function renderCharacter(bean, scale, color, assetId, facingLeft) {\n            const beanScale = scale * config.debug.playerDefaultScale;\n            ctx.save();\n            const bx = bean.x * canvas.width;\n            const by = bean.y * canvas.height;\n            ctx.translate(bx, by);\n            ctx.scale(bean.squashX, bean.squashY);\n            ctx.translate(-bx, -by);\n            drawBean(bean.x, bean.y, beanScale, color, assetId);\n            drawRacket(bean.x, bean.y, beanScale, facingLeft, bean.swinging, bean.windingUp, bean);\n            ctx.restore();\n        }\n\n        function renderImpactFlash() {\n            if (!impactFlash.active) return;\n            impactFlash.timer -= 1/60;\n            if (impactFlash.timer <= 0) { impactFlash.active = false; return; }\n            ctx.save();\n            const flashProgress = 1 - impactFlash.timer / 0.22;\n            ctx.globalAlpha = Math.max(0, (1 - flashProgress) * 0.9);\n            ctx.fillStyle = '#FFF';\n            ctx.beginPath();\n            ctx.arc(impactFlash.x * canvas.width, impactFlash.y * canvas.height, 18 + flashProgress * 48, 0, Math.PI * 2);\n            ctx.fill();\n            ctx.globalAlpha = Math.max(0, 1 - flashProgress * 2.5);\n            ctx.fillStyle = '#FFFDE7';\n            ctx.beginPath();\n            ctx.arc(impactFlash.x * canvas.width, impactFlash.y * canvas.height, 22, 0, Math.PI * 2);\n            ctx.fill();\n            ctx.restore();\n        }\n\n        // ---- AI State Overlay Panel ----\n        const aiPanelState = {\n            state: 'WAIT',\n            jumpsNeeded: 0,\n            currentJumps: 0,\n            contactY: 0,\n            reason: 'Idle',\n            predictedY: '-',\n            predictedFrames: '-',\n            predictionAge: '-',\n            framesUntilArrival: '-',\n            ballVx: '-',\n            ballVy: '-',\n            ballY: '-',\n            aiY: '-',\n            aiVy: '-',\n            aiOnGround: true,\n            hitboxTop: '-',\n            hitboxBot: '-',\n            hitboxCenterY: '-',\n            jumpFramesToTarget: '-',\n            jumpTotalAirFrames: '-',\n            idealJumpIn: '-',\n            jumpApexY: '-',\n            misreading: false,\n            attackMode: false,\n            groundedRallyCount: 0\n        };\n\n        // Store panel bounds for click detection\n        let aiPanelBounds = { x: 0, y: 0, w: 0, h: 0 };\n\n        function renderAIPanel() {\n            if (!config.debug.showAIPanel) return;\n            const aHB = getHitbox(ai.x, ai.y, aiScale * config.debug.playerDefaultScale);\n            const px = aHB.cx * canvas.width;\n            const py = (aHB.cy - aHB.hh - 0.02) * canvas.height;\n            const panelW = 480;\n            const panelH = 680;\n            const panelX = Math.min(px - panelW + 60, canvas.width - panelW - 8);\n            const panelY = Math.max(8, py - panelH - 10);\n\n            aiPanelBounds = { x: panelX, y: panelY, w: panelW, h: panelH };\n\n            ctx.save();\n            ctx.fillStyle = 'rgba(0,0,0,0.92)';\n            ctx.strokeStyle = 'rgba(255,255,255,0.28)';\n            ctx.lineWidth = 2;\n            ctx.beginPath();\n            ctx.roundRect(panelX, panelY, panelW, panelH, 12);\n            ctx.fill();\n            ctx.stroke();\n\n            ctx.font = '600 20px Fredoka, sans-serif';\n            ctx.textBaseline = 'top';\n            const x0 = panelX + 14;\n            let y0 = panelY + 12;\n            const lineH = 30;\n\n            const stateColors = { JUMP: '#4FC3F7', PANIC: '#FF7043', WAIT: '#A5D6A7', STAND: '#A5D6A7', PREDICT: '#FFD54F' };\n            ctx.fillStyle = stateColors[aiPanelState.state] || '#fff';\n            ctx.fillText(`State: ${aiPanelState.state}`, x0, y0); y0 += lineH + 4;\n\n            ctx.fillStyle = '#fff';\n            ctx.font = '500 18px Fredoka, sans-serif';\n            const lines = [\n                `Reason: ${aiPanelState.reason}`,\n                `\u2500\u2500\u2500\u2500\u2500 PREDICTION \u2500\u2500\u2500\u2500\u2500`,\n                `Predicted Y: ${aiPanelState.predictedY}`,\n                `Predicted Frames: ${aiPanelState.predictedFrames}`,\n                `Prediction Age: ${aiPanelState.predictionAge}`,\n                `Frames Until Arrival: ${aiPanelState.framesUntilArrival}`,\n                `\u2500\u2500\u2500\u2500\u2500 BALL \u2500\u2500\u2500\u2500\u2500`,\n                `Ball Y: ${aiPanelState.ballY}  VX: ${aiPanelState.ballVx}  VY: ${aiPanelState.ballVy}`,\n                `\u2500\u2500\u2500\u2500\u2500 AI BODY \u2500\u2500\u2500\u2500\u2500`,\n                `AI Y: ${aiPanelState.aiY}  VY: ${aiPanelState.aiVy}  Ground: ${aiPanelState.aiOnGround}`,\n                `Hitbox Top: ${aiPanelState.hitboxTop}  Bot: ${aiPanelState.hitboxBot}`,\n                `Hitbox Center Y: ${aiPanelState.hitboxCenterY}`,\n                `\u2500\u2500\u2500\u2500\u2500 JUMP ANALYSIS \u2500\u2500\u2500\u2500\u2500`,\n                `Jump Path: ${aiPanelState.jumpPath || 'none'}`,\n                `Jumps Needed: ${aiPanelState.jumpsNeeded}`,\n                `Schedule: ${aiPanelState.jumpSchedule || '-'}`,\n                `Frames To Target: ${aiPanelState.jumpFramesToTarget}`,\n                `Total Air Frames: ${aiPanelState.jumpTotalAirFrames}`,\n                `Ideal Jump In: ${aiPanelState.idealJumpIn}`,\n                `Single Jump Reach Y: ${aiPanelState.singleJumpReachY || '-'}`,\n                `Jump Apex Y: ${aiPanelState.jumpApexY}`,\n                `Plan Active: ${ai._jumpPlanActive} (idx ${ai._nextJumpIdx}/${ai._activeJumpPlan ? ai._activeJumpPlan.length : 0})`,\n                `\u2500\u2500\u2500\u2500\u2500 STATUS \u2500\u2500\u2500\u2500\u2500`,\n                `Misreading: ${aiPanelState.misreading}`,\n                `Attack Mode: ${aiPanelState.attackMode} (rally: ${aiPanelState.groundedRallyCount})`\n            ];\n            for (const line of lines) {\n                if (line.includes('\u2500\u2500\u2500\u2500\u2500')) {\n                    ctx.fillStyle = 'rgba(255,255,255,0.4)';\n                    ctx.font = '500 16px Fredoka, sans-serif';\n                } else {\n                    ctx.fillStyle = '#fff';\n                    ctx.font = '500 18px Fredoka, sans-serif';\n                }\n                ctx.fillText(line, x0, y0);\n                y0 += lineH - 2;\n            }\n\n            // Tap anywhere hint\n            ctx.font = '400 16px Fredoka, sans-serif';\n            ctx.fillStyle = 'rgba(255,255,255,0.4)';\n            ctx.fillText('Tap panel to copy', x0, panelY + panelH - 22);\n\n            ctx.restore();\n        }\n\n        // Click anywhere inside AI panel to copy all details\n        canvas.addEventListener('pointerdown', function aiPanelCopy(e) {\n            if (!config.debug.showAIPanel) return;\n            const rect = canvas.getBoundingClientRect();\n            const scaleX = canvas.width / rect.width;\n            const scaleY = canvas.height / rect.height;\n            const cx = (e.clientX - rect.left) * scaleX;\n            const cy = (e.clientY - rect.top) * scaleY;\n            const b = aiPanelBounds;\n            if (cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h) {\n                const s = aiPanelState;\n                const txt = [\n                    `AI STATE: ${s.state}`,\n                    `Reason: ${s.reason}`,\n                    ``,\n                    `\u2500\u2500 PREDICTION \u2500\u2500`,\n                    `Predicted Y: ${s.predictedY}`,\n                    `Predicted Frames: ${s.predictedFrames}`,\n                    `Prediction Age: ${s.predictionAge}`,\n                    `Frames Until Arrival: ${s.framesUntilArrival}`,\n                    ``,\n                    `\u2500\u2500 BALL \u2500\u2500`,\n                    `Ball Y: ${s.ballY}`,\n                    `Ball VX: ${s.ballVx}`,\n                    `Ball VY: ${s.ballVy}`,\n                    ``,\n                    `\u2500\u2500 AI BODY \u2500\u2500`,\n                    `AI Y: ${s.aiY}`,\n                    `AI VY: ${s.aiVy}`,\n                    `AI On Ground: ${s.aiOnGround}`,\n                    `Hitbox Top: ${s.hitboxTop}`,\n                    `Hitbox Bot: ${s.hitboxBot}`,\n                    `Hitbox Center Y: ${s.hitboxCenterY}`,\n                    ``,\n                    `\u2500\u2500 JUMP ANALYSIS \u2500\u2500`,\n                    `Jump Path: ${s.jumpPath || 'none'}`,\n                    `Jumps Needed: ${s.jumpsNeeded}`,\n                    `Schedule: ${s.jumpSchedule || '-'}`,\n                    `Single Jump Reach Y: ${s.singleJumpReachY || '-'}`,\n                    `Frames To Target: ${s.jumpFramesToTarget}`,\n                    `Total Air Frames: ${s.jumpTotalAirFrames}`,\n                    `Ideal Jump In: ${s.idealJumpIn}`,\n                    `Jump Apex Y: ${s.jumpApexY}`,\n                    `Plan Active: ${ai._jumpPlanActive} (idx ${ai._nextJumpIdx}/${ai._activeJumpPlan ? ai._activeJumpPlan.length : 0})`,\n                    ``,\n                    `\u2500\u2500 STATUS \u2500\u2500`,\n                    `Misreading: ${s.misreading}`,\n                    `Attack Mode: ${s.attackMode}`,\n                    `Grounded Rally Count: ${s.groundedRallyCount}`\n                ].join('\\n');\n                const ta = document.createElement('textarea');\n                ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0';\n                document.body.appendChild(ta); ta.select();\n                try { document.execCommand('copy'); } catch(err) {}\n                document.body.removeChild(ta);\n                e.stopPropagation();\n            }\n        });\n\n        function updateAIPanelState() {\n            if (!config.debug.showAIPanel) return;\n            aiPanelState.state = ai.debug.state;\n            aiPanelState.currentJumps = ai.onGround ? 0 : 1;\n            aiPanelState.aiY = ai.y.toFixed(3);\n            aiPanelState.aiVy = (ai.vy * 1000).toFixed(0);\n            aiPanelState.aiOnGround = ai.onGround;\n            aiPanelState.ballY = ball.y.toFixed(3);\n            aiPanelState.ballVx = (ball.vx * 1000).toFixed(0);\n            aiPanelState.ballVy = (ball.vy * 1000).toFixed(0);\n            aiPanelState.misreading = ai.misreading;\n            aiPanelState.attackMode = ai.attackMode;\n            aiPanelState.groundedRallyCount = ai.groundedRallyCount;\n\n            const aiHB2 = getHitbox(ai.x, getGroundY(), aiScale * config.debug.playerDefaultScale);\n            aiPanelState.hitboxTop = (aiHB2.cy - aiHB2.hh).toFixed(3);\n            aiPanelState.hitboxBot = (aiHB2.cy + aiHB2.hh).toFixed(3);\n            aiPanelState.hitboxCenterY = aiHB2.cy.toFixed(3);\n\n            if (!ai.hasPrediction || !ball.active || ball.vx <= 0) {\n                aiPanelState.jumpsNeeded = 0;\n                aiPanelState.predictedY = '-';\n                aiPanelState.predictedFrames = '-';\n                aiPanelState.predictionAge = '-';\n                aiPanelState.framesUntilArrival = '-';\n                aiPanelState.jumpFramesToTarget = '-';\n                aiPanelState.jumpTotalAirFrames = '-';\n                aiPanelState.idealJumpIn = '-';\n                aiPanelState.jumpApexY = '-';\n                aiPanelState.reason = 'Ball not approaching';\n                return;\n            }\n\n            aiPanelState.predictedY = ai.predictedY.toFixed(3);\n            aiPanelState.predictedFrames = ai.predictedFrames.toFixed(0);\n            aiPanelState.predictionAge = ai.predictionAge.toFixed(0);\n            const framesUntilArrival = Math.max(0, ai.predictedFrames - ai.predictionAge);\n            aiPanelState.framesUntilArrival = framesUntilArrival.toFixed(0);\n\n            const threshold = config.ai.jumpThreshold || 0.03;\n            const ballInStandingHitbox = Math.abs(ai.predictedY - aiHB2.cy) < (aiHB2.hh + threshold);\n\n            // Compute single jump reach and determine path\n            const singleReach = getSingleJumpReachY();\n            aiPanelState.singleJumpReachY = singleReach.toFixed(3);\n            const needsMulti = ai.predictedY < singleReach;\n            aiPanelState.jumpPath = needsMulti ? 'MULTI' : 'SINGLE';\n\n            // Always compute jump analysis for display\n            const jumpAnalysis = analyzeJump(ai.predictedY);\n            aiPanelState.jumpFramesToTarget = jumpAnalysis.framesToTarget.toFixed(0);\n            aiPanelState.jumpTotalAirFrames = jumpAnalysis.totalAirFrames.toFixed(0);\n\n            if (needsMulti) {\n                const plan = planMultiJump(ai.predictedY);\n                aiPanelState.jumpSchedule = plan.schedule.map(f => f.toFixed(0)).join(', ');\n                const idealStartIn = framesUntilArrival - plan.framesToTarget;\n                aiPanelState.idealJumpIn = idealStartIn.toFixed(0);\n                aiPanelState.jumpFramesToTarget = plan.framesToTarget.toFixed(0);\n            } else {\n                aiPanelState.jumpSchedule = '-';\n                const idealJumpIn = framesUntilArrival - jumpAnalysis.framesToTarget;\n                aiPanelState.idealJumpIn = idealJumpIn.toFixed(0);\n            }\n\n            // Compute jump apex Y for reference\n            const jumpVy = -(getJumpPower() * eventJumpMult) / 1000;\n            const grav = (getPlayerGravity() * eventPlayerGravityMult) / 1000;\n            const apexTime = -jumpVy / grav;\n            const apexY = getGroundY() + jumpVy * apexTime + 0.5 * grav * apexTime * apexTime;\n            aiPanelState.jumpApexY = apexY.toFixed(3);\n\n            if (ballInStandingHitbox) {\n                aiPanelState.jumpsNeeded = 0;\n                aiPanelState.reason = `Ground hit: predY ${ai.predictedY.toFixed(3)} within [${(aiHB2.cy - aiHB2.hh - threshold).toFixed(3)}, ${(aiHB2.cy + aiHB2.hh + threshold).toFixed(3)}]`;\n            } else {\n                // Determine single vs multi-jump\n                const singleReach = getSingleJumpReachY();\n                const needsMulti = ai.predictedY < singleReach;\n                const diff = ai.predictedY - aiHB2.cy;\n                if (needsMulti) {\n                    const plan = planMultiJump(ai.predictedY);\n                    aiPanelState.jumpsNeeded = plan.schedule.length;\n                    aiPanelState.reason = `MULTI-JUMP (${plan.schedule.length}x): predY ${Math.abs(diff).toFixed(3)} above reach ${singleReach.toFixed(3)}`;\n                } else {\n                    aiPanelState.jumpsNeeded = 1;\n                    aiPanelState.reason = `Single jump: predY ${diff > 0 ? 'BELOW' : 'ABOVE'} hitbox by ${Math.abs(diff).toFixed(3)}`;\n                }\n            }\n\n            if (ai.attackMode) {\n                aiPanelState.reason += ' [ATTACK]';\n            }\n            if (ai.misreading) {\n                aiPanelState.reason += ' [MISREAD]';\n            }\n        }\n\n        function renderHitboxes() {\n            if (!config.debug.showHitboxes) return;\n            ctx.save();\n            // Player hitbox\n            const pHB = getHitbox(player.x, player.y, playerScale * config.debug.playerDefaultScale);\n            ctx.strokeStyle = 'rgba(74, 144, 226, 0.8)';\n            ctx.lineWidth = 3;\n            ctx.strokeRect(\n                (pHB.cx - pHB.hw) * canvas.width, (pHB.cy - pHB.hh) * canvas.height,\n                pHB.hw * 2 * canvas.width, pHB.hh * 2 * canvas.height\n            );\n            // AI hitbox\n            const aHB = getHitbox(ai.x, ai.y, aiScale * config.debug.playerDefaultScale);\n            ctx.strokeStyle = 'rgba(231, 76, 60, 0.8)';\n            ctx.strokeRect(\n                (aHB.cx - aHB.hw) * canvas.width, (aHB.cy - aHB.hh) * canvas.height,\n                aHB.hw * 2 * canvas.width, aHB.hh * 2 * canvas.height\n            );\n            ctx.restore();\n        }\n\n        function renderAIDebug() {\n            if (!config.debug.showAI) return;\n            ctx.save();\n            if (ball.active && ball.vx > 0) {\n                const truePred = simulateBallTrajectory();\n                if (truePred) {\n                    ctx.fillStyle = 'rgba(33, 150, 243, 0.8)';\n                    ctx.beginPath();\n                    ctx.arc(ai.x * canvas.width, truePred.y * canvas.height, 10, 0, Math.PI * 2);\n                    ctx.fill();\n                    ctx.strokeStyle = '#0D47A1';\n                    ctx.lineWidth = 2;\n                    ctx.stroke();\n                }\n            }\n            if (ai.hasPrediction) {\n                ctx.fillStyle = 'rgba(231, 76, 60, 0.8)';\n                ctx.beginPath();\n                ctx.arc(ai.x * canvas.width, ai.predictedY * canvas.height, 10, 0, Math.PI * 2);\n                ctx.fill();\n                ctx.strokeStyle = '#C0392B';\n                ctx.lineWidth = 2;\n                ctx.stroke();\n            }\n            const aHB = getHitbox(ai.x, ai.y, aiScale * config.debug.playerDefaultScale);\n            const hbCenterY = aHB.cy * canvas.height;\n            const hbRadius = aHB.hh * canvas.height;\n            const hbX = aHB.cx * canvas.width;\n            const ballInHitbox = ai.debug.ballInHitbox;\n            ctx.strokeStyle = ballInHitbox ? '#1B5E20' : '#4CAF50';\n            ctx.lineWidth = ballInHitbox ? 4 : 2;\n            ctx.beginPath();\n            ctx.arc(hbX, hbCenterY, hbRadius, 0, Math.PI * 2);\n            ctx.stroke();\n            ctx.restore();\n        }\n\n        function render() {\n            ctx.clearRect(0, 0, canvas.width, canvas.height);\n            ctx.fillStyle = '#2A8FD4';\n            ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n            ctx.save();\n            const zoomOffset = (1 - cameraZoom) / 2;\n            ctx.translate(canvas.width * zoomOffset, canvas.height * zoomOffset);\n            ctx.scale(cameraZoom, cameraZoom);\n\n            drawSky();\n            drawCourtBg();\n            drawStadium();\n            drawCourt();\n\n            renderCharacter(player, playerScale, config.visuals.playerColor, 'player_bean_blue', false);\n            renderCharacter(ai, aiScale, config.visuals.aiColor, 'ai_bean_red', true);\n\n            if (ball.active || serveDelay < 0.5) {\n                drawBall(ball.x, ball.y, config.debug.ballDefaultScale * eventBallScale);\n            }\n\n            renderImpactFlash();\n            drawParticles();\n            renderHitboxes();\n            renderAIDebug();\n            renderAIPanel();\n\n            ctx.restore();\n        }\n\n        // ----------------------------------------\n        // INTRO SEQUENCE\n        // ----------------------------------------\n        // ----------------------------------------\n        // TUTORIAL CONTROL\n        // Set FORCE_TUTORIAL = true to always show the tutorial,\n        // ignoring any saved \"completed\" state. Useful for testing.\n        // ----------------------------------------\n        const FORCE_TUTORIAL = true;\n\n        let introActive = true;\n        let tutorialCompleted = false;\n        let tutorialShowing = false;\n\n        // Load tutorial state from saved data\n        (async function loadTutorialState() {\n            if (FORCE_TUTORIAL) return; // always show tutorial when forced\n            try {\n                const saved = await lib.getUserGameState();\n                if (saved && saved.state && saved.state.tutorialCompleted) {\n                    tutorialCompleted = true;\n                }\n            } catch(e) {\n                lib.log('Failed to load tutorial state: ' + e.message);\n            }\n        })();\n\n        async function playIntro() {\n            const overlay = document.getElementById('intro-overlay');\n            overlay.style.display = 'flex';\n            overlay.style.opacity = '1';\n            overlay.classList.remove('fade-out');\n\n            function showContent(html) {\n                overlay.innerHTML = html;\n            }\n\n            const target = config.settings.targetScore;\n            showContent('<div class=\"intro-emoji\">\\u{1F3C6}</div><div class=\"intro-text\">FIRST TO ' + target + ' WINS</div>');\n            await new Promise(r => setTimeout(r, 1500));\n\n            for (let i = 3; i >= 1; i--) {\n                showContent('<div class=\"intro-countdown\">' + i + '</div>');\n                playSound('countdown_sound');\n                await new Promise(r => setTimeout(r, 750));\n            }\n\n            if (tutorialCompleted) {\n                // Returning player: show GO! and start\n                showContent('<div class=\"intro-go\">GO!</div>');\n                playSound('go_sound');\n                overlay.classList.add('fade-out');\n                await new Promise(r => setTimeout(r, 500));\n                overlay.style.display = 'none';\n                introActive = false;\n            } else {\n                // First-time player: show tutorial\n                overlay.style.display = 'none';\n                showTutorial();\n            }\n        }\n\n        function showTutorial() {\n            tutorialShowing = true;\n            document.getElementById('tutorial-overlay').style.display = 'flex';\n        }\n\n        async function dismissTutorial() {\n            if (!tutorialShowing) return;\n            tutorialShowing = false;\n            const tutOverlay = document.getElementById('tutorial-overlay');\n            tutOverlay.classList.add('fade-out');\n            await new Promise(r => setTimeout(r, 400));\n            tutOverlay.style.display = 'none';\n            tutOverlay.classList.remove('fade-out');\n\n            // Mark completed and save (skip save when FORCE_TUTORIAL is on)\n            tutorialCompleted = true;\n            if (!FORCE_TUTORIAL) {\n                try { await lib.saveUserGameState({ tutorialCompleted: true }); } catch(e) {}\n            }\n\n            // Start gameplay immediately \u2014 no GO! screen\n            introActive = false;\n        }\n\n        // Tutorial tap handler\n        document.getElementById('tutorial-overlay').addEventListener('pointerdown', (e) => {\n            e.preventDefault();\n            e.stopPropagation();\n            if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();\n            if (!tutorialShowing) return;\n            tutorialShowing = false;\n            const tutOverlay = document.getElementById('tutorial-overlay');\n            tutOverlay.classList.add('fade-out');\n            setTimeout(() => {\n                tutOverlay.style.display = 'none';\n                tutOverlay.classList.remove('fade-out');\n            }, 400);\n\n            // Trigger actual jump\n            player.jumpPhase = 'anticipation';\n            player.jumpPhaseTimer = 0.05;\n            playSound('jump_sound');\n\n            // Mark tutorial completed (skip save when FORCE_TUTORIAL is on)\n            tutorialCompleted = true;\n            if (!FORCE_TUTORIAL) {\n                try { lib.saveUserGameState({ tutorialCompleted: true }); } catch(e2) {}\n            }\n\n            // Wait for player to land, then start game\n            function waitForGround() {\n                if (player.onGround) {\n                    introActive = false;\n                } else {\n                    requestAnimationFrame(waitForGround);\n                }\n            }\n            // Start physics updates for the jump while intro is still active\n            function tutorialPhysicsLoop() {\n                if (introActive) {\n                    const dt = 1/60;\n                    updateBeanPhysics(player, dt);\n                    if (!player.onGround) {\n                        requestAnimationFrame(tutorialPhysicsLoop);\n                    } else {\n                        introActive = false;\n                    }\n                }\n            }\n            requestAnimationFrame(tutorialPhysicsLoop);\n        });\n\n        // Don't auto-play intro; wait for menu to dismiss\n        // Connect the menu system to the game start\n        startGameAfterMenu = function() {\n            // Show game UI\n            document.querySelector('.score-container').style.display = 'flex';\n            document.getElementById('pause-btn').style.display = 'flex';\n            document.getElementById('sound-btn').style.display = 'flex';\n            playIntro();\n        };\n\n        // ----------------------------------------\n        // INITIALIZATION\n        // ----------------------------------------\n        resetBall();\n        updateUI();\n\n        // Hide game UI while menu is active\n        document.querySelector('.score-container').style.display = 'none';\n        document.getElementById('pause-btn').style.display = 'none';\n        document.getElementById('sound-btn').style.display = 'none';\n\n        // ----------------------------------------\n        // DEBUG PANEL - Accordion-style dev tools\n        // ----------------------------------------\n        if (config.debug && config.debug.enableDevTools) {\n            // Section definitions with nested sub-accordions\n            const sections = [\n                {\n                    title: 'Size & Position',\n                    groups: [\n                        {\n                            title: 'Player',\n                            sliders: [\n                                { label: 'X Spread', key: 'debug.playerXSpread', min: 0.1, max: 0.45, step: 0.01 },\n                                { label: 'Scale', key: 'debug.playerDefaultScale', min: 0.3, max: 3, step: 0.1 },\n                                { label: 'Y Position', key: 'debug.playerYPosition', min: 0.3, max: 0.95, step: 0.01 }\n                            ]\n                        },\n                        {\n                            title: 'Ball',\n                            sliders: [\n                                { label: 'Scale', key: 'debug.ballDefaultScale', min: 0.3, max: 3, step: 0.1 },\n                                { label: 'Y Position', key: 'debug.ballYPosition', min: 0.3, max: 0.9, step: 0.01 }\n                            ]\n                        },\n                        {\n                            title: 'Court',\n                            sliders: [\n                                { label: 'Scale', key: 'debug.courtScale', min: 0.3, max: 5, step: 0.05 },\n                                { label: 'Y Position', key: 'debug.courtYPosition', min: 0.0, max: 1.5, step: 0.01 },\n                                { label: 'BG Y Position', key: 'debug.courtBgYPosition', min: 0.0, max: 1.5, step: 0.01 }\n                            ]\n                        },\n                        {\n                            title: 'Stadium',\n                            sliders: [\n                                { label: 'Scale', key: 'debug.stadiumScale', min: 0.5, max: 3.0, step: 0.05 },\n                                { label: 'Y Offset', key: 'debug.stadiumYOffset', min: -0.5, max: 0.5, step: 0.01 }\n                            ]\n                        }\n                    ]\n                },\n                {\n                    title: 'Physics',\n                    groups: [\n                        {\n                            title: 'Player',\n                            sliders: [\n                                { label: 'Gravity', key: 'physics.playerGravity', min: 400, max: 2500, step: 50 },\n                                { label: 'Jump Force', key: 'physics.jumpPower', min: 200, max: 1200, step: 25 }\n                            ]\n                        },\n                        {\n                            title: 'Ball',\n                            sliders: [\n                                { label: 'Gravity', key: 'physics.ballGravity', min: 400, max: 2500, step: 50 },\n                                { label: 'Speed', key: 'settings.ballSpeedMultiplier', min: 0.3, max: 3, step: 0.1 },\n                                { label: 'Ground Bounce', key: 'physics.groundBounceStrength', min: 0, max: 2, step: 0.05 }\n                            ]\n                        },\n                        {\n                            title: 'Events',\n                            sliders: [\n                                { label: 'Scale Mult', key: 'debug.scaleEventSizeMultiplier', min: 0.5, max: 3, step: 0.1 }\n                            ]\n                        }\n                    ]\n                },\n                {\n                    title: 'AI',\n                    groups: [\n                        {\n                            title: 'Difficulty',\n                            sliders: [],\n                            dropdown: { label: 'Level', key: 'ai.difficulty', options: [1, 2, 3, 4, 5] },\n                            checkbox: { label: 'Show AI Debug', key: 'debug.showAI' }\n                        },\n                        {\n                            title: 'Brain',\n                            isLive: true,\n                            data: () => [\n                                { label: 'AI State', value: ai.debug.state },\n                                { label: 'Frames to Arrival', value: Math.max(0, ai.predictedFrames - ai.predictionAge).toFixed(0) },\n                                { label: 'Predicted Y', value: ai.predictedY.toFixed(3) },\n                                { label: 'Distance to Target', value: ai.debug.distToTarget.toFixed(2) },\n                                { label: 'Ball Above Me', value: ai.debug.ballAboveMe },\n                                { label: 'Jump Needed', value: ai.debug.jumpNeeded },\n                                { label: 'Can Jump', value: ai.debug.canJump }\n                            ],\n                            sliders: [\n                                { label: 'Jump Height Thresh', key: 'ai.jumpThreshold', min: 0.01, max: 0.3, step: 0.01 },\n                                { label: 'Reaction Delay', key: 'ai.reactionDelay', min: 0.0, max: 1.0, step: 0.05 },\n                                { label: 'Timing Error', key: 'ai.jumpTimingVariance', min: 0.0, max: 0.5, step: 0.01 }\n                            ]\n                        }\n                    ]\n                }\n            ];\n\n            function getConfigValue(key) {\n                return key.split('.').reduce((o, k) => o[k], config);\n            }\n\n            function setConfigValue(key, value) {\n                const keys = key.split('.');\n                let obj = config;\n                for (let i = 0; i < keys.length - 1; i++) obj = obj[keys[i]];\n                obj[keys[keys.length - 1]] = value;\n            }\n\n            // Minimized button\n            const minBtn = document.createElement('div');\n            minBtn.id = 'debug-min-btn';\n            minBtn.textContent = '\ud83d\udee0 Dev Tools';\n            minBtn.style.cssText = `\n                position: absolute; top: 80px; right: 8px;\n                background: rgba(10, 15, 30, 0.95); color: #fff;\n                padding: 14px 24px; border-radius: 16px;\n                font-size: 28px; font-weight: 700; z-index: 100;\n                pointer-events: auto; cursor: pointer;\n                border: 2px solid rgba(255,255,255,0.15);\n                font-family: 'Fredoka', sans-serif;\n                display: none;\n            `;\n\n            // Full panel\n            const panel = document.createElement('div');\n            panel.id = 'debug-panel';\n            panel.style.cssText = `\n                position: absolute; top: 80px; right: 8px; left: 8px;\n                background: rgba(10, 15, 30, 0.95);\n                color: #fff; padding: 20px; border-radius: 20px;\n                font-size: 26px; z-index: 100;\n                pointer-events: auto; max-height: 75vh;\n                overflow-y: auto; backdrop-filter: blur(6px);\n                border: 2px solid rgba(255,255,255,0.15);\n                font-family: 'Fredoka', sans-serif;\n            `;\n\n            // Minimize/maximize logic\n            function minimizePanel() {\n                panel.style.display = 'none';\n                minBtn.style.display = 'block';\n            }\n\n            function maximizePanel() {\n                panel.style.display = 'block';\n                minBtn.style.display = 'none';\n            }\n\n            minBtn.addEventListener('pointerdown', (e) => { e.stopPropagation(); maximizePanel(); });\n\n            function createSliderRow(s) {\n                const row = document.createElement('div');\n                row.style.cssText = 'margin-bottom: 20px;';\n\n                const lbl = document.createElement('div');\n                lbl.style.cssText = 'display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 24px; opacity: 0.9;';\n                const labelText = document.createElement('span');\n                labelText.textContent = s.label;\n                const valueText = document.createElement('span');\n                valueText.style.fontWeight = '600';\n                valueText.textContent = getConfigValue(s.key);\n                lbl.appendChild(labelText);\n                lbl.appendChild(valueText);\n\n                const inp = document.createElement('input');\n                inp.type = 'range';\n                inp.min = s.min; inp.max = s.max; inp.step = s.step;\n                inp.value = getConfigValue(s.key);\n                inp.style.cssText = 'width: 100%; accent-color: #4A90E2; height: 18px; cursor: pointer;';\n                inp.addEventListener('input', (e) => {\n                    e.stopPropagation();\n                    const v = parseFloat(inp.value);\n                    setConfigValue(s.key, v);\n                    valueText.textContent = v;\n                });\n                inp.addEventListener('pointerdown', (e) => e.stopPropagation());\n\n                row.appendChild(lbl);\n                row.appendChild(inp);\n                return row;\n            }\n\n            function createDropdownRow(d) {\n                const row = document.createElement('div');\n                row.style.cssText = 'margin-bottom: 20px;';\n\n                const lbl = document.createElement('div');\n                lbl.style.cssText = 'display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 24px; opacity: 0.9;';\n                const labelText = document.createElement('span');\n                labelText.textContent = d.label;\n                lbl.appendChild(labelText);\n\n                const sel = document.createElement('select');\n                sel.style.cssText = `\n                    width: 100%; padding: 10px 14px; font-size: 22px;\n                    border-radius: 10px; border: 1px solid rgba(255,255,255,0.2);\n                    background: rgba(255,255,255,0.1); color: #fff;\n                    font-family: 'Fredoka', sans-serif; cursor: pointer;\n                `;\n                d.options.forEach(opt => {\n                    const o = document.createElement('option');\n                    o.value = opt;\n                    o.textContent = opt.charAt(0).toUpperCase() + opt.slice(1);\n                    o.style.background = '#1a1a2e';\n                    if (getConfigValue(d.key) === opt) o.selected = true;\n                    sel.appendChild(o);\n                });\n                sel.addEventListener('change', (e) => {\n                    e.stopPropagation();\n                    setConfigValue(d.key, sel.value);\n                });\n                sel.addEventListener('pointerdown', (e) => e.stopPropagation());\n\n                row.appendChild(lbl);\n                row.appendChild(sel);\n                return row;\n            }\n\n            function createCheckboxRow(c) {\n                const row = document.createElement('div');\n                row.style.cssText = 'margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;';\n\n                const lbl = document.createElement('span');\n                lbl.style.cssText = 'font-size: 24px; opacity: 0.9;';\n                lbl.textContent = c.label;\n\n                const inp = document.createElement('input');\n                inp.type = 'checkbox';\n                inp.checked = getConfigValue(c.key);\n                inp.style.cssText = 'width: 30px; height: 30px; cursor: pointer;';\n                inp.addEventListener('change', (e) => {\n                    e.stopPropagation();\n                    setConfigValue(c.key, inp.checked);\n                });\n                inp.addEventListener('pointerdown', (e) => e.stopPropagation());\n\n                row.appendChild(lbl);\n                row.appendChild(inp);\n                return row;\n            }\n\n            function createLiveRow(item) {\n                const row = document.createElement('div');\n                row.style.cssText = 'margin-bottom: 12px; display: flex; justify-content: space-between; font-size: 22px;';\n                const label = document.createElement('span');\n                label.style.opacity = '0.7';\n                label.textContent = item.label;\n                const value = document.createElement('span');\n                value.style.fontWeight = '600';\n                value.className = 'live-value';\n                value.dataset.label = item.label;\n                row.appendChild(label);\n                row.appendChild(value);\n                return row;\n            }\n\n            function createSubAccordion(group) {\n                const wrapper = document.createElement('div');\n                wrapper.style.cssText = 'margin-bottom: 8px; border-radius: 10px; overflow: hidden; border: 1px solid rgba(255,255,255,0.08);';\n\n                const subHeader = document.createElement('div');\n                subHeader.style.cssText = `\n                    padding: 12px 16px; background: rgba(255,255,255,0.05);\n                    cursor: pointer; display: flex; align-items: center;\n                    font-weight: 600; font-size: 24px;\n                    user-select: none;\n                `;\n\n                const subTitle = document.createElement('span');\n                subTitle.textContent = group.title;\n                subTitle.style.flex = '1';\n\n                const subArrow = document.createElement('span');\n                subArrow.textContent = '\u25be';\n                subArrow.style.cssText = 'transition: transform 0.2s ease; transform: rotate(-90deg); font-size: 20px; opacity: 0.6;';\n\n                subHeader.appendChild(subTitle);\n                subHeader.appendChild(subArrow);\n\n                const subContent = document.createElement('div');\n                subContent.style.cssText = 'padding: 12px 16px; background: rgba(0,0,0,0.15); display: none;';\n\n                subHeader.addEventListener('pointerdown', (e) => {\n                    e.stopPropagation();\n                    const isOpen = subContent.style.display !== 'none';\n                    subContent.style.display = isOpen ? 'none' : 'block';\n                    subArrow.style.transform = isOpen ? 'rotate(-90deg)' : 'rotate(0deg)';\n                });\n\n                if (group.dropdown) subContent.appendChild(createDropdownRow(group.dropdown));\n                if (group.checkbox) subContent.appendChild(createCheckboxRow(group.checkbox));\n\n                if (group.sliders) group.sliders.forEach(s => subContent.appendChild(createSliderRow(s)));\n\n                wrapper.appendChild(subHeader);\n                wrapper.appendChild(subContent);\n                return wrapper;\n            }\n\n            function getAllSliders(section) {\n                const all = [];\n                if (section.groups) {\n                    section.groups.forEach(g => { all.push(...g.sliders); });\n                }\n                if (section.sliders) {\n                    all.push(...section.sliders);\n                }\n                return all;\n            }\n\n            // Section Selector Dropdown\n            const selectorRow = document.createElement('div');\n            selectorRow.style.cssText = 'display: flex; gap: 10px; margin-bottom: 20px; align-items: center;';\n\n            const sectionSelect = document.createElement('select');\n            sectionSelect.style.cssText = `\n                flex: 1; padding: 12px 16px; font-size: 26px;\n                border-radius: 12px; border: 2px solid rgba(255,255,255,0.2);\n                background: rgba(255,255,255,0.1); color: #fff;\n                font-family: 'Fredoka', sans-serif; cursor: pointer; outline: none;\n            `;\n\n            sections.forEach((s, idx) => {\n                const opt = document.createElement('option');\n                opt.value = idx;\n                opt.textContent = s.title;\n                opt.style.background = '#0a0f1e';\n                sectionSelect.appendChild(opt);\n            });\n\n            const sectionCopyBtn = document.createElement('div');\n            sectionCopyBtn.textContent = '\ud83d\udccb';\n            sectionCopyBtn.title = 'Copy section values';\n            sectionCopyBtn.style.cssText = `\n                width: 60px; height: 60px; border-radius: 12px;\n                background: rgba(255,255,255,0.1); display: flex;\n                align-items: center; justify-content: center;\n                font-size: 32px; cursor: pointer; border: 2px solid rgba(255,255,255,0.1);\n                user-select: none;\n            `;\n\n            selectorRow.appendChild(sectionSelect);\n            selectorRow.appendChild(sectionCopyBtn);\n            panel.appendChild(selectorRow);\n\n            const activeSectionContainer = document.createElement('div');\n            panel.appendChild(activeSectionContainer);\n\n            function renderActiveSection() {\n                activeSectionContainer.innerHTML = '';\n                const section = sections[sectionSelect.value];\n                \n                if (section.groups) {\n                    section.groups.forEach(g => activeSectionContainer.appendChild(createSubAccordion(g)));\n                }\n                if (section.sliders) {\n                    section.sliders.forEach(s => activeSectionContainer.appendChild(createSliderRow(s)));\n                }\n\n                // Update Copy Logic\n                sectionCopyBtn.onpointerdown = (e) => {\n                    e.stopPropagation();\n                    const allSliders = getAllSliders(section);\n                    const values = allSliders.map(s => `${s.label}: ${getConfigValue(s.key)}`).join('\\n');\n                    \n                    const copyToClipboard = (txt) => {\n                        const ta = document.createElement('textarea');\n                        ta.value = txt;\n                        ta.style.position = 'fixed'; ta.style.opacity = '0';\n                        document.body.appendChild(ta);\n                        ta.select();\n                        try { document.execCommand('copy'); } catch(err) {}\n                        document.body.removeChild(ta);\n                    };\n\n                    if (navigator.clipboard) {\n                        navigator.clipboard.writeText(values).catch(() => copyToClipboard(values));\n                    } else {\n                        copyToClipboard(values);\n                    }\n\n                    const oldText = sectionCopyBtn.textContent;\n                    sectionCopyBtn.textContent = '\u2705';\n                    setTimeout(() => { sectionCopyBtn.textContent = oldText; }, 1000);\n                };\n            }\n\n            sectionSelect.addEventListener('change', renderActiveSection);\n            renderActiveSection();\n\n            const overlay = document.querySelector('.ui-overlay');\n            overlay.appendChild(panel);\n            overlay.appendChild(minBtn);\n        }\n\n        let lastTime = performance.now();\n\n        function gameLoop(timestamp) {\n            let dt = (timestamp - lastTime) / 1000;\n            lastTime = timestamp;\n            dt = Math.min(dt, 0.11);\n\n            update(dt);\n            updateAIPanelState();\n            render();\n            requestAnimationFrame(gameLoop);\n        }\n        requestAnimationFrame(gameLoop);\n    }\n    /*\n    ============================================================================\n    AI DOCUMENTATION\n    ============================================================================\n    Last updated: This version\n    Purpose: Document exactly how the AI works for difficulty tuning.\n\n    ============================================================================\n    1. AI DECISION PIPELINE\n    ============================================================================\n\n    Full sequence:\n\n    Ball is hit toward AI (ball.vx > 0)\n    \u2192 ballJustTurnedToAI detected (vx went from <=0 to >0)\n    \u2192 aiMakePrediction() called ONCE per approach\n      \u2192 simulateBallTrajectory() runs step-by-step physics sim\n      \u2192 Adds noise to predicted Y (predictionNoise * predictionNoiseMult)\n      \u2192 Rolls for misread (misreadChance * misreadChanceMult)\n      \u2192 Stores: ai.predictedY, ai.predictedFrames\n    \u2192 Each frame while ball approaches:\n      \u2192 predictionAge increments (frames elapsed since prediction)\n      \u2192 framesUntilBallArrival = predictedFrames - predictionAge\n      \u2192 Checks if predictedY is within standing hitbox\n      \u2192 If jump needed AND ai is on ground AND cooldown elapsed:\n        \u2192 Determines SINGLE vs MULTI jump path\n        \u2192 Calls aiTrySingleJump() or aiTryMultiJump()\n        \u2192 Jump executes when timing is within tolerance window\n    \u2192 Ball leaves AI side (vx <= 0):\n      \u2192 aiResetTracking() clears all state\n\n    Key functions:\n    - simulateBallTrajectory(): Physics sim, returns {y, frames}\n    - aiMakePrediction(): One-shot prediction + misread roll\n    - analyzeJump(targetY): How many frames a single jump takes to reach targetY\n    - getSingleJumpReachY(): Highest Y reachable with one jump\n    - planMultiJump(targetY): Generates multi-jump schedule\n    - aiTrySingleJump(): Timing check for single jump\n    - aiTryMultiJump(): Timing check + plan commit for multi-jump\n    - executeAIJump(midAir): Actually fires the jump\n    - updateAI(dt): Main orchestrator called each frame\n\n    ============================================================================\n    2. BALL PREDICTION\n    ============================================================================\n\n    simulateBallTrajectory() does a frame-by-frame physics simulation:\n    - Starts from current ball position and velocity\n    - Applies gravity (getBallGravity() * eventBallGravityMult) each frame\n    - Applies air resistance (vx *= 1 - 0.01 * SIM_DT)\n    - Handles ground bounces (same logic as real ball)\n    - Stops when simX >= ai.x (ball reached AI's X position)\n    - Returns { y: predicted Y at AI's X, frames: frame count to arrival }\n    - Returns null if ball is moving away or won't reach AI\n\n    Values calculated:\n    - predictedY: Where the ball will be (vertically) when it reaches AI's X\n    - predictedFrames: How many frames (at 60fps) until arrival\n\n    Prediction error is introduced in aiMakePrediction():\n      predY += (Math.random() * 2 - 1) * predictionNoise\n    Where predictionNoise = AI_BASE.predictionNoise * diff.predictionNoiseMult\n\n    AI_BASE.predictionNoise = 0.015 (in normalized 0-1 coords)\n\n    At Difficulty 1: noise = 0.015 * 1.50 = 0.0225\n    At Difficulty 3: noise = 0.015 * 1.00 = 0.015\n    At Difficulty 5: noise = 0.015 * 0.80 = 0.012\n\n    This means the predicted landing spot can be off by up to \u00b10.0225 (about \u00b129px\n    at 1280px height) on Very Easy. This alone doesn't cause many misses \u2014 misreads\n    and timing tolerance are the bigger factors.\n\n    ============================================================================\n    3. REACTION DELAY\n    ============================================================================\n\n    IMPORTANT: The config value `ai.reactionDelay` EXISTS but is NOT currently\n    wired into the AI decision pipeline. It is a legacy/unused variable.\n\n    The AI currently has NO explicit reaction delay between seeing the ball and\n    making a prediction. The prediction happens INSTANTLY on the frame the ball\n    turns toward AI (ballJustTurnedToAI).\n\n    The effective \"delay\" comes from:\n    1. timingToleranceFrames \u2014 how early/late the AI can be with its jump\n    2. misreadDuration \u2014 how long the AI is locked into a wrong prediction (1.0s)\n    3. The natural delay of waiting for the correct jump timing window\n\n    If you WANTED to add reaction delay, you would:\n    - Add a timer between ballJustTurnedToAI and calling aiMakePrediction()\n    - This would make the AI \"see\" the ball later, reducing available jump timing\n\n    ============================================================================\n    4. MISREADS\n    ============================================================================\n\n    How they work:\n    - On each new prediction (aiMakePrediction), a random roll occurs:\n      if (Math.random() < misreadChance) \u2192 misread happens\n    - misreadChance = AI_BASE.misreadChance (0.20) * diff.misreadChanceMult\n\n    At Difficulty 1: chance = 0.20 * 1.75 = 0.35 (35% of approaches)\n    At Difficulty 2: chance = 0.20 * 1.35 = 0.27 (27% of approaches)\n    At Difficulty 3: chance = 0.20 * 1.00 = 0.20 (20% of approaches)\n    At Difficulty 4: chance = 0.20 * 0.90 = 0.18\n    At Difficulty 5: chance = 0.20 * 0.80 = 0.16\n\n    What happens during a misread:\n    - ai.misreading = true\n    - ai.misreadTimer = 1.0 seconds (misreadDuration)\n    - ai.predictedY is set to a WRONG value:\n      predY + randomSign * (misreadOffsetMin + random * (misreadOffsetMax - misreadOffsetMin))\n      = predY \u00b1 (0.04 to 0.08) in normalized coords (51-102px off)\n    - The AI plans its jump based on this WRONG prediction\n    - After 1.0 seconds, misread expires \u2192 aiRePredictClean() gives correct prediction\n    - BUT if the ball has already arrived, it's too late \u2014 the AI missed\n\n    The misread is the PRIMARY mechanism that causes the AI to miss on lower\n    difficulties. A misread of 0.04-0.08 is large enough to cause the AI to\n    jump too high or too low, completely whiffing the ball.\n\n    ============================================================================\n    5. JUMP PLANNING\n    ============================================================================\n\n    Decision: No Jump\n    - If predictedY falls within the AI's standing hitbox (\u00b1 jumpThreshold):\n      |predictedY - hitboxCenterY| < (hitboxHalfHeight + jumpThreshold)\n    - jumpThreshold = 0.03 (config.ai.jumpThreshold)\n    - AI stays on ground and lets the ball hit its body\n    - This is the \"STAND\" state\n\n    Decision: Single Jump\n    - If predictedY is ABOVE standing hitbox BUT reachable by one jump\n    - Determined by: predictedY >= getSingleJumpReachY()\n    - getSingleJumpReachY() calculates the apex of a single jump minus hitbox height\n    - Uses analyzeJump(predictedY) to find framesToTarget\n    - Waits until: |framesUntilArrival - framesToTarget| <= timingToleranceFrames\n    - Then executes\n\n    Decision: Multi-Jump\n    - If predictedY < getSingleJumpReachY() (too high for single jump)\n    - Calls planMultiJump(predictedY) to generate a schedule of jump frames\n    - Schedule example: [0, 14, 28] means jump at frame 0, 14, 28\n    - First jump from ground, subsequent jumps mid-air\n    - Subject to multiJumpSkipChance \u2014 AI may SKIP the multi-jump entirely\n\n    Key variables:\n    - timingToleranceFrames: How early/late AI can be (window size)\n      Difficulty 1: 6 * 1.75 = 10.5 \u2192 11 frames (very loose)\n      Difficulty 3: 6 * 1.00 = 6 frames (baseline)\n      Difficulty 5: 6 * 0.60 = 3.6 \u2192 4 frames (very tight)\n    - multiJumpSkipChance: Probability AI refuses to multi-jump\n      Difficulty 1: 0.60 (skips 60% of multi-jumps \u2192 misses high balls)\n      Difficulty 3: 0.30\n      Difficulty 5: 0.10\n    - panicThreshold: 0.3 \u2014 if ball arrives within 30% of air time, force jump\n\n    ============================================================================\n    6. MULTI-JUMP SYSTEM\n    ============================================================================\n\n    planMultiJump(targetY):\n    - Simulates frame-by-frame: first jump from ground, then chains additional\n      jumps whenever:\n      a) The bean is falling (simVy > 0)\n      b) Cooldown since last jump >= 11 frames (~0.18s)\n    - Each chained jump resets velocity to full jumpVy (mid-air jump)\n    - Maximum 5 jumps in one plan\n    - Returns { schedule: [frame numbers], framesToTarget }\n\n    Execution (aiTickMultiJumpPlan):\n    - ai._activeJumpPlan = [0, 14, 28, ...] (frame numbers)\n    - ai._jumpPlanFrame increments each tick (dt / SIM_DT)\n    - When _jumpPlanFrame >= schedule[nextIdx], fires executeAIJump(midAir=true)\n    - First jump (idx 0) is from ground, rest are mid-air\n\n    The multi-jump system lets the AI reach balls that are very high up.\n    On Very Easy, 60% of multi-jump attempts are SKIPPED entirely, meaning\n    the AI will stand and watch high balls sail over.\n\n    ============================================================================\n    7. DIFFICULTY SCALING \u2014 EXACT VALUES\n    ============================================================================\n\n    Level 1 (VERY EASY):\n      predictionNoiseMult:  1.50  \u2192 noise = 0.0225 (\u00b129px)\n      misreadChanceMult:    1.75  \u2192 chance = 35%\n      timingToleranceMult:  1.75  \u2192 window = 11 frames\n      multiJumpSkipChance:  0.60  \u2192 skips 60% of multi-jumps\n      attackThresholdAdd:   99    \u2192 attacks effectively disabled\n\n    Level 2 (EASY):\n      predictionNoiseMult:  1.25  \u2192 noise = 0.01875 (\u00b124px)\n      misreadChanceMult:    1.35  \u2192 chance = 27%\n      timingToleranceMult:  1.40  \u2192 window = 8 frames\n      multiJumpSkipChance:  0.30  \u2192 skips 30% of multi-jumps\n      attackThresholdAdd:   3     \u2192 attacks rare (threshold 7-9)\n\n    Level 3 (NORMAL \u2014 baseline):\n      predictionNoiseMult:  1.00  \u2192 noise = 0.015 (\u00b119px)\n      misreadChanceMult:    1.00  \u2192 chance = 20%\n      timingToleranceMult:  1.00  \u2192 window = 6 frames\n      multiJumpSkipChance:  0.30  \u2192 skips 30% of multi-jumps\n      attackThresholdAdd:   0     \u2192 attacks at rally 4-6\n\n    Level 4 (HARD):\n      predictionNoiseMult:  0.90  \u2192 noise = 0.0135 (\u00b117px)\n      misreadChanceMult:    0.90  \u2192 chance = 18%\n      timingToleranceMult:  0.80  \u2192 window = 5 frames\n      multiJumpSkipChance:  0.20  \u2192 skips 20%\n      attackThresholdAdd:   -1    \u2192 attacks at rally 3-5\n\n    Level 5 (EXPERT):\n      predictionNoiseMult:  0.80  \u2192 noise = 0.012 (\u00b115px)\n      misreadChanceMult:    0.80  \u2192 chance = 16%\n      timingToleranceMult:  0.60  \u2192 window = 4 frames\n      multiJumpSkipChance:  0.10  \u2192 skips 10%\n      attackThresholdAdd:   -2    \u2192 attacks at rally 2-4\n\n    ============================================================================\n    8. TUNING GUIDE\n    ============================================================================\n\n    ---- predictionNoiseMult ----\n    Controls: How far off the AI's predicted ball landing Y can be\n    Increase \u2192 AI becomes WORSE (predicted Y can be further from truth)\n    Decrease \u2192 AI becomes BETTER (more accurate prediction)\n    Impact: LOW-MEDIUM. Noise alone rarely causes full misses because the\n            hitbox is fairly large. Main effect is slightly sub-optimal jumps.\n    For Very Easy: Current 1.50 is adequate. Going to 2.0+ has diminishing returns.\n\n    ---- misreadChanceMult ----\n    Controls: Probability the AI commits to a WRONG prediction for 1 second\n    Increase \u2192 AI becomes MUCH WORSE (more frequent total whiffs)\n    Decrease \u2192 AI becomes BETTER (fewer unforced errors)\n    Impact: HIGH. This is the #1 variable that causes visible AI failures.\n            A misread of 0.04-0.08 offset almost always results in a miss.\n    For Very Easy: Current 1.75 gives 35% misread rate. To make easier:\n      - Increase to 2.0+ (40%+ misread rate)\n      - Or increase misreadOffsetMin/Max for bigger wrong predictions\n\n    ---- timingToleranceMult ----\n    Controls: How many frames early/late the AI can trigger its jump\n    Increase \u2192 AI becomes WORSE (counterintuitively: wider window means it\n              may jump too early, reaching apex before ball arrives)\n    Decrease \u2192 AI becomes BETTER (tighter timing = more precise contact)\n    Impact: MEDIUM. A very wide window (11+ frames) means the AI often jumps\n            too early and is already falling when the ball arrives. But it can\n            also mean it \"accidentally\" times well. Effect is inconsistent.\n    For Very Easy: Current 1.75 is reasonable. Not the best lever for tuning.\n\n    ---- multiJumpSkipChance ----\n    Controls: Probability AI refuses to attempt a multi-jump for high balls\n    Increase \u2192 AI becomes WORSE (more high balls fly over its head)\n    Decrease \u2192 AI becomes BETTER (reaches more high shots)\n    Impact: HIGH for high balls, NONE for ground-level rallies.\n            On Very Easy (0.60), the AI misses 60% of balls that require\n            multi-jumping. Since many rallies stay low, this only activates\n            on lob shots or during events.\n    For Very Easy: Increase to 0.80+ to make AI miss almost all high balls.\n\n    ---- attackThresholdAdd ----\n    Controls: How many grounded rallies before AI attempts an attack jump\n    Increase \u2192 AI becomes MORE PASSIVE (never breaks rallies)\n    Decrease \u2192 AI becomes MORE AGGRESSIVE (breaks rallies quickly)\n    Impact: LOW on Very Easy (set to 99, effectively disabled).\n    For Very Easy: Already disabled. No change needed.\n\n    ---- misreadDuration (AI_BASE.misreadDuration = 1.0s) ----\n    Controls: How long the AI is locked into a wrong prediction\n    Increase \u2192 AI stays wrong longer, more likely ball arrives during misread\n    Decrease \u2192 AI self-corrects sooner, may still save the point\n    Impact: HIGH. At 1.0s, most balls arrive within that window on fast rallies.\n            If the ball takes longer (slow lob), AI may self-correct in time.\n    For Very Easy: Could increase to 1.5-2.0s to ensure misreads always result\n                   in missed points.\n\n    ---- misreadOffsetMin / misreadOffsetMax (0.04 / 0.08) ----\n    Controls: How far off the wrong prediction is (in normalized Y coords)\n    Increase \u2192 Misreads cause bigger positional errors (more dramatic misses)\n    Decrease \u2192 Misreads are subtle (AI might still accidentally hit)\n    Impact: MEDIUM-HIGH. Combined with misreadChance, determines how punishing\n            each misread is.\n    For Very Easy: Could increase to 0.06/0.12 for more obvious whiffs.\n\n    ---- jumpThreshold (config.ai.jumpThreshold = 0.03) ----\n    Controls: How close the predicted ball must be to standing hitbox center\n              for the AI to decide \"no jump needed\"\n    Increase \u2192 AI stands more often (thinks more balls are reachable standing)\n    Decrease \u2192 AI jumps more often (more cautious)\n    Impact: LOW. Affects edge cases where ball barely grazes top/bottom of hitbox.\n    For Very Easy: Not a useful tuning lever.\n\n    ---- panicThreshold (AI_BASE.panicThreshold = 0.3) ----\n    Controls: When ball is within 30% of total air time away, AI force-jumps\n    Increase \u2192 AI panics sooner (worse timing, less optimal jumps)\n    Decrease \u2192 AI holds its nerve longer (better timing)\n    Impact: LOW. Panic jumps are usually too late anyway.\n\n    ============================================================================\n    SUMMARY: MAKING VERY EASY EASIER\n    ============================================================================\n\n    Most impactful changes (in priority order):\n\n    1. misreadChanceMult: Increase from 1.75 \u2192 2.0 or 2.5\n       (Makes AI fail 40-50% of approaches outright)\n\n    2. multiJumpSkipChance: Increase from 0.60 \u2192 0.80 or 0.90\n       (AI can't handle any high balls)\n\n    3. misreadOffsetMin/Max: Increase from 0.04/0.08 \u2192 0.06/0.12\n       (When AI misreads, the error is larger and more punishing)\n\n    4. misreadDuration: Increase from 1.0 \u2192 1.5 or 2.0\n       (AI stays committed to wrong prediction longer)\n\n    5. Add actual reaction delay (not currently implemented):\n       - Delay aiMakePrediction() by 0.3-0.5s after ball turns toward AI\n       - This gives AI less time to plan, causing more panic jumps\n\n    Least impactful (don't bother):\n    - predictionNoiseMult: Marginal effect, hitbox is forgiving\n    - timingToleranceMult: Inconsistent effect, can accidentally help AI\n    - attackThresholdAdd: Already disabled on Very Easy\n    - jumpThreshold: Edge case only\n\n    ============================================================================\n    */\n    </script>\n</body>\n</html>", "game_config": "{\n  \"settings\": {\n    \"targetScore\": 7,\n    \"scaleEventInterval\": 12,\n    \"scaleEventDuration\": 10,\n    \"ballSpeedMultiplier\": 2,\n    \"ballSpawnHeight\": 0.8,\n    \"giantEventBallSpeedMult\": 0.6,\n    \"giantEventBallGravityMult\": 0.75,\n    \"giantEventBallY\": 0.59,\n    \"tinyEventPlayerScale\": 2,\n    \"tinyEventHitboxScale\": 1.3,\n    \"tinyEventBallY\": 0.71,\n    \"tinyEventPlayerGravityMult\": 1.3,\n    \"bigBallScale\": 2.5,\n    \"bigBallGravityMult\": 1.15,\n    \"bigBallSpeedMult\": 0.9,\n    \"bigBallBounceMult\": 0.9,\n    \"eventCooldown\": 20\n  },\n  \"ai\": {\n    \"difficulty\": 1,\n    \"reactionDelay\": 0,\n    \"movementAccuracy\": 0.7,\n    \"jumpTimingVariance\": 0,\n    \"missChance\": 0.15,\n    \"jumpThreshold\": 0.03,\n    \"attackThresholdMin\": 4,\n    \"attackThresholdMax\": 6\n  },\n  \"physics\": {\n    \"gravity\": 1200,\n    \"jumpPower\": 600,\n    \"ballBounceDamping\": 0.7,\n    \"playerGravity\": 1500,\n    \"ballGravity\": 900,\n    \"groundBounceStrength\": 1.75,\n    \"ballDeflectionFactor\": 6,\n    \"ballBounceSpeedUp\": 1.2,\n    \"ballMaxSpeedY\": 1\n  },\n  \"visuals\": {\n    \"playerColor\": \"#4A90E2\",\n    \"aiColor\": \"#ff301a\",\n    \"courtColor\": \"#4CAF50\",\n    \"ballColor\": \"#C8E64E\"\n  },\n  \"debug\": {\n    \"enableDevTools\": false,\n    \"scaleEventSizeMultiplier\": 1,\n    \"disableMatchEvents\": false,\n    \"disableGameOver\": false,\n    \"playerXSpread\": 0.4,\n    \"playerDefaultScale\": 1.9,\n    \"ballDefaultScale\": 1.7,\n    \"courtScale\": 1.8,\n    \"courtYPosition\": 0.38,\n    \"ballYPosition\": 0.58,\n    \"courtBgYPosition\": 0.67,\n    \"playerYPosition\": 0.78,\n    \"showAI\": false,\n    \"testEvent\": {\n      \"enabled\": false,\n      \"eventId\": \"big_ball\"\n    },\n    \"stadiumScale\": 1.7,\n    \"stadiumYOffset\": 0.12,\n    \"showAIPanel\": false,\n    \"showHitboxes\": false,\n    \"contactAnalysis\": false\n  }\n}", "game_design_document": "## Core Game Loop and Mechanics\n\n**Moment-to-Moment Gameplay:**\n- Player taps anywhere on screen to make their blue bean character jump\n- The bean automatically swings its racket when near the ball\n- Ball bounces back and forth across the net in a rally\n- Players must time their jumps to position themselves to hit the ball\n- AI opponent on the right side attempts to return shots with imperfect timing\n- Score increases when opponent fails to return the ball\n- Rally counter tracks consecutive successful hits\n\n**Progression and Engagement:**\n- Each successful hit in a rally builds tension and excitement\n- The Scale Event triggers every 15 seconds, dramatically changing gameplay dynamics\n- When giant, players have easier reach but feel powerful\n- When tiny, players must be more precise but feel scrappy\n- Score race to target (first to 5 points wins)\n- Immediate restart option after match ends\n\n**Win/Lose Conditions:**\n- First player to reach 5 points wins the match\n- Points scored when opponent misses the ball (ball hits ground on their side)\n- Match ends with victory/defeat screen showing final score\n\n**Compelling and Replayable Elements:**\n- Fast matches (1-2 minutes) encourage \"one more game\"\n- Skill-based timing creates satisfying mastery curve\n- Scale Event adds unpredictable chaos and humor\n- AI difficulty tuned to feel beatable but challenging\n- Visual feedback and sound effects make every hit satisfying\n\n## Starting Scope\n\n**First Iteration Must Include:**\n- Single tennis court with net\n- Player-controlled blue bean (left side)\n- AI-controlled red bean (right side)\n- One-tap jump controls (tap anywhere)\n- Ball physics with realistic bounce and trajectory\n- Automatic racket swing when bean is near ball\n- Score tracking (first to 5 wins)\n- Rally counter display\n- Scale Event system (every 15 seconds, lasts 8 seconds, alternates who grows/shrinks)\n- Dynamic camera zoom during Scale Event\n- Victory/defeat screen with restart button\n- Pause button\n- Sound toggle button\n- Basic sound effects (ball hit, score, event trigger)\n\n**Defer to Future Iterations:**\n- Multiple AI difficulty levels\n- Additional match events (Giant Ball, Low Gravity, etc.)\n- Tournament mode\n- Character customization\n- Power-ups\n- Multiplayer\n- Progression systems\n- Unlockables\n- Menu screens beyond basic restart\n\n## Game Perspective\n\n**Fixed-screen side view** - The entire tennis court is visible at all times in a side-scrolling perspective. The camera is positioned to show both sides of the court clearly, with slight dynamic zoom during Scale Events to emphasize size differences while keeping both players visible.\n\n## Game World and Level Design\n\n**Tennis Court Layout:**\n- Court dimensions: 600px wide \u00d7 400px tall (playable area)\n- Net positioned at center (x: 360px) with height of 80px\n- Left service area: x: 0-340px (player zone)\n- Right service area: x: 380-720px (AI zone)\n- Ground level at y: 1000px (bottom of visible court)\n- Court surface: green with white boundary lines\n- Net: white posts with dark net mesh\n\n**Background Elements:**\n- Stadium crowd in bleachers behind court (simplified silhouettes)\n- Stadium lights on left and right sides\n- Colorful pennant banners strung across top\n- Sky gradient (light blue to deeper blue)\n- Clouds drifting slowly in background\n\n**Character Positioning:**\n- Player bean starts at x: 170px (left third of their court)\n- AI bean starts at x: 550px (right third of their court)\n- Both beans start on ground, can jump up to 200px high\n- Beans cannot cross the net (x: 360px boundary)\n\n**Ball Behavior:**\n- Starts at center net position for serve\n- Bounces realistically with gravity\n- Travels in arcs based on hit angle and power\n- Bounces once on ground before being \"out\"\n- Speed increases slightly with each successful rally hit (caps at 2x initial speed)\n\n**Scale Event Zones:**\n- When Scale Event triggers, camera zooms out 20% to accommodate giant player\n- Giant player: 2.5x normal size\n- Tiny player: 0.4x normal size\n- Visual indicator (particle effect or glow) shows event is active\n- Event timer displayed prominently\n\n## Edit Mode Design\n\n**Editable Properties:**\n\n1. **Game Settings:**\n   - Target score (default: 5, range: 1-10)\n   - Scale Event interval (default: 15 seconds, range: 10-30)\n   - Scale Event duration (default: 8 seconds, range: 5-15)\n   - Ball speed multiplier (default: 1.0, range: 0.5-2.0)\n\n2. **AI Difficulty:**\n   - Reaction delay (default: 0.3 seconds, range: 0.1-0.8)\n   - Movement accuracy (default: 0.7, range: 0.3-1.0)\n   - Jump timing variance (default: 0.15, range: 0.05-0.4)\n\n3. **Physics:**\n   - Gravity strength (default: 1200, range: 800-2000)\n   - Jump power (default: 600, range: 400-900)\n   - Ball bounce damping (default: 0.7, range: 0.5-0.9)\n\n4. **Visual Customization:**\n   - Player bean color (color picker, default: blue)\n   - AI bean color (color picker, default: red)\n   - Court color (color picker, default: green)\n   - Ball color (color picker, default: yellow-green)\n\n**Constraints:**\n- Target score must be odd number (prevents ties)\n- Scale Event interval must be longer than duration\n- All numeric values have reasonable min/max to prevent broken gameplay\n- Color changes maintain sufficient contrast for readability\n\n## Control Design\n\n**Mobile Touch Controls (Primary):**\n- **Tap anywhere on screen**: Makes player bean jump\n- **No movement controls**: Bean stays in its zone automatically\n- **No aiming controls**: Racket swing is automatic when near ball\n- **Pause button**: Top-left corner (96\u00d796px touch target)\n- **Sound toggle**: Top-right corner (96\u00d796px touch target)\n\n**Desktop Controls (Secondary Support):**\n- **Spacebar or Up Arrow**: Makes player bean jump\n- **Mouse click anywhere**: Also triggers jump\n- **P key**: Pause/unpause\n- **M key**: Mute/unmute\n\n**Input Handling:**\n- Prevent default behavior for spacebar and arrow keys\n- Clear input state on window blur\n- Touch events tracked by ID to prevent multi-touch interference\n- Jump has cooldown (0.1 seconds) to prevent spam\n- Inputs disabled during victory/defeat screen\n\n**Control Feedback:**\n- Bean squashes slightly when landing\n- Bean stretches slightly when jumping\n- Racket swings with animation when hitting ball\n- Visual \"tap to jump\" prompt fades after first successful jump\n\n## Physics and Collision\n\n**Bean Physics:**\n- Gravity: 1200 px/s\u00b2\n- Jump velocity: -600 px/s (upward)\n- Horizontal position: fixed to their zone\n- Collision box: circular, radius based on current scale\n- Ground collision: stops downward movement, allows jump\n\n**Ball Physics:**\n- Gravity: 1200 px/s\u00b2\n- Initial serve velocity: random angle toward opponent\n- Bounce damping: 0.7 (loses 30% velocity on ground bounce)\n- Air resistance: minimal (0.98 multiplier per frame)\n- Maximum speed cap: 1200 px/s\n- Collision box: circular, radius 15px (scales with Scale Event)\n\n**Collision Detection:**\n- Bean-to-ball: circular collision, triggers racket swing\n- Ball-to-ground: bounce with damping\n- Ball-to-net: bounce back toward hitter\n- Ball out-of-bounds: point scored for opponent\n\n**Hit Mechanics:**\n- Ball velocity determined by bean's vertical velocity at contact\n- Upward bean movement = higher arc\n- Downward bean movement = lower, faster shot\n- Horizontal velocity always toward opponent\n- Hit power increases with rally count (up to 1.5x)\n\n## AI Opponent Behavior\n\n**AI Strategy:**\n- Tracks ball position and predicts landing spot\n- Moves toward predicted position with imperfect accuracy\n- Reaction delay: 0.3 seconds before responding to ball direction change\n- Jump timing: attempts to jump when ball is within range, with variance\n\n**Difficulty Parameters (Tunable in Edit Mode):**\n- `reactionDelay`: 0.3 seconds (time before AI reacts to ball changes)\n- `movementAccuracy`: 0.7 (70% accurate positioning, 30% random offset)\n- `jumpTimingVariance`: 0.15 (\u00b10.15 second variance in jump timing)\n- `missChance`: 0.15 (15% chance to completely miss even when positioned correctly)\n\n**AI Behavior Details:**\n- Predicts ball landing position using physics calculation\n- Adds random offset based on `movementAccuracy`\n- Waits `reactionDelay` before updating target position\n- Jumps when ball is within vertical range, with timing variance\n- Occasionally \"gives up\" on impossible shots (ball too far/fast)\n- Never perfect - should feel like playing against a human\n\n## Scale Event System\n\n**Event Architecture:**\n- Event manager tracks time since last event\n- Supports multiple event types (only Scale implemented initially)\n- Event data structure includes: type, duration, affected player, scale values\n\n**Scale Event Implementation:**\n- Triggers every 15 seconds\n- Lasts 8 seconds\n- Randomly selects which player becomes giant (50/50 chance)\n- Other player becomes tiny\n- Visual transition: smooth scale animation over 0.5 seconds\n- Audio cue when event starts and ends\n\n**Scale Values:**\n- Giant: 2.5x normal size (both visual and collision)\n- Tiny: 0.4x normal size (both visual and collision)\n- Normal: 1.0x size\n\n**Camera Behavior During Event:**\n- Zooms out 20% to keep both players visible\n- Smooth camera transition (0.5 seconds)\n- Maintains focus on center of court\n- Returns to normal zoom when event ends\n\n**Gameplay Impact:**\n- Giant player: easier to hit ball, larger collision box, more imposing\n- Tiny player: harder to hit ball, smaller collision box, more agile feeling\n- Ball physics unchanged (doesn't scale)\n- Net height unchanged\n\n**Visual Feedback:**\n- Particle effect or glow around affected players\n- Event timer displayed at top center\n- Color-coded indicator (gold for giant, purple for tiny)\n- Screen shake or flash when event triggers\n\n**Future Event Hooks:**\n- Event system designed to easily add new event types\n- Each event type has: trigger condition, duration, effect function, cleanup function\n- Events can affect: player scale, ball properties, physics, visual effects\n\n## Art Style and Visual Theme\n\n**Overall Style:**\n- Bright, colorful 2D cartoon art\n- Clean shapes with bold outlines (3-4px black strokes)\n- Flat colors with subtle gradients for depth\n- High contrast for mobile readability\n- Playful and energetic mood\n- Inspired by casual mobile games and the provided reference image\n\n**Character Design:**\n- Bean-shaped bodies (oval/pill shape)\n- Large expressive eyes with black pupils\n- Tiny feet at bottom (simple rounded shapes)\n- No arms - racket floats in front during swings\n- Player bean: bright blue with darker blue shading\n- AI bean: bright red/coral with darker red shading\n- Optional accessories: small cap or visor on head\n\n**Court and Environment:**\n- Tennis court: bright green surface with white lines\n- Net: white posts with dark gray mesh pattern\n- Stadium crowd: simplified colorful silhouettes in rows\n- Stadium lights: tall posts with circular light clusters\n- Pennant banners: alternating red and blue triangular flags\n- Sky: gradient from light blue (top) to slightly deeper blue\n- Clouds: simple white rounded shapes, drifting slowly\n\n**UI Elements:**\n- Score display: large rounded rectangles at top\n  - Player score: blue background, white text\n  - AI score: red background, white text\n  - \"VS\" text between scores\n  - Rally counter below scores (yellow text on dark background)\n- Pause button: circular, dark blue with white pause icon\n- Sound button: circular, dark blue with speaker icon\n- All buttons have 3-4px black outlines\n- Victory/defeat text: large, bold, with drop shadow\n\n**Ball and Racket:**\n- Tennis ball: bright yellow-green with curved line detail\n- Racket: simple oval shape with string pattern, dark blue handle\n- Ball trail: subtle motion blur or particle trail during fast movement\n\n**Effects and Polish:**\n- Dust puff when ball bounces\n- Impact stars when ball is hit\n- Squash and stretch on bean jumps and landings\n- Racket swing motion blur\n- Scale Event glow/particles around affected player\n- Screen shake on powerful hits\n- Confetti burst on scoring\n\n**Asset Sizes:**\n- Bean character: ~120px tall (normal size)\n- Tennis ball: ~30px diameter\n- Racket: ~80px tall\n- Court: full screen width (720px)\n- UI elements: 96\u00d796px minimum for touch targets", "persistent_storage_config": null, "game_metadata": {"game_id": "01KT6SSP24E247J71XSDVWMXB7", "title": "Tiny Tennis", "description": "A silly tennis game where size changes everything.", "thumbnail_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/thumbnail_348ecc02-e059-4d19-a342-c9c3342053ee.webp", "created_at": 1780495146, "updated_at": 1780807707301.3909, "art_style": null}, "task_plan": null}