🎮ArcadeLab

Pixel Moto Stunts

by MysticKoala95
1726 lines69.8 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>Pixel Moto Stunts</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        html,
        body {
            width: 100%;
            height: 100%;
            background: #0b0b14;
            overflow: hidden;
            font-family: "Courier New", Courier, monospace;
            color: #fff;
            user-select: none;
            touch-action: none;
        }
        #wrap {
            position: fixed;
            inset: 0;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        #box {
            position: relative;
            width: 960px;
            height: 540px;
            box-shadow: 0 0 0 4px #1d1d2e, 0 0 60px rgba(0, 0, 0, .9);
            border-radius: 4px;
        }
        #game {
            width: 100%;
            height: 100%;
            display: block;
            image-rendering: pixelated;
            image-rendering: crisp-edges;
            background: #000;
            border-radius: 4px;
            position: relative;
            z-index: 1;
        }
        /* Touch overlay buttons */
        #touch-controls {
            position: absolute;
            inset: 0;
            z-index: 5;
            pointer-events: none;
            display: none;
            /* hidden by default, shown when touch mode is on */
        }
        #touch-controls.active {
            display: block;
            pointer-events: none;
        }
        #touch-controls .btn {
            position: absolute;
            pointer-events: auto;
            background: rgba(255, 255, 255, 0.15);
            border: 2px solid rgba(255, 255, 255, 0.3);
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 16px;
            font-weight: bold;
            color: #fff;
            text-shadow: 0 0 8px rgba(0, 0, 0, 0.8);
            backdrop-filter: blur(3px);
            transition: 0.08s ease;
            touch-action: none;
            user-select: none;
        }
        #touch-controls .btn:active,
        #touch-controls .btn.pressed {
            background: rgba(255, 215, 0, 0.35);
            border-color: #ffd23f;
            transform: scale(0.92);
        }
        #touch-controls .btn.gas {
            bottom: 60px;
            right: 20px;
            width: 70px;
            height: 70px;
            font-size: 22px;
            background: rgba(46, 204, 113, 0.25);
            border-color: rgba(46, 204, 113, 0.5);
        }
        #touch-controls .btn.brake {
            bottom: 60px;
            right: 100px;
            width: 60px;
            height: 60px;
            font-size: 18px;
            background: rgba(231, 76, 60, 0.25);
            border-color: rgba(231, 76, 60, 0.5);
        }
        #touch-controls .btn.lean-left {
            bottom: 130px;
            left: 20px;
            width: 56px;
            height: 56px;
            font-size: 16px;
            background: rgba(52, 152, 219, 0.25);
            border-color: rgba(52, 152, 219, 0.5);
        }
        #touch-controls .btn.lean-right {
            bottom: 60px;
            left: 20px;
            width: 56px;
            height: 56px;
            font-size: 16px;
            background: rgba(52, 152, 219, 0.25);
            border-color: rgba(52, 152, 219, 0.5);
        }
        #touch-controls .btn.hop {
            bottom: 130px;
            left: 90px;
            width: 52px;
            height: 52px;
            font-size: 14px;
            background: rgba(241, 196, 15, 0.25);
            border-color: rgba(241, 196, 15, 0.5);
        }
        #touch-controls .btn-label {
            position: absolute;
            bottom: 2px;
            font-size: 9px;
            opacity: 0.7;
            letter-spacing: 0.5px;
        }
        /* Menu adjustments */
        .screen {
            position: absolute;
            inset: 0;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            background: rgba(6, 6, 18, .88);
            text-align: center;
            padding: 4%;
            box-sizing: border-box;
            border-radius: 4px;
            transition: opacity 0.4s ease;
            z-index: 10;
        }
        .screen.hidden {
            display: none !important;
        }
        .screen.fade-out {
            opacity: 0;
            pointer-events: none;
        }
        h1 {
            font-size: clamp(32px, 8vmin, 72px);
            margin: 0 0 0.2em;
            letter-spacing: 0.06em;
            line-height: 1.05;
            color: #ffd23f;
            text-shadow: 3px 3px 0 #b3410e, 6px 6px 0 #000;
        }
        h1.win {
            color: #7CFC6B;
            text-shadow: 3px 3px 0 #1d6b2a, 6px 6px 0 #000;
        }
        h1.over {
            color: #ff4d4d;
            text-shadow: 3px 3px 0 #7a0e0e, 6px 6px 0 #000;
        }
        .sub {
            font-size: clamp(13px, 2.6vmin, 22px);
            margin: 0 0 1.2em;
            color: #cfe8ff;
            text-shadow: 2px 2px 0 #000;
            line-height: 1.4;
        }
        .blink {
            animation: bl 1s steps(2, start) infinite;
            font-size: clamp(12px, 2.6vmin, 22px);
            color: #fff;
            text-shadow: 2px 2px 0 #000;
            margin: 0.6em 0 0;
        }
        @keyframes bl {
            to {
                visibility: hidden;
            }
        }
        .menu-buttons {
            display: flex;
            flex-direction: column;
            gap: 14px;
            margin: 10px 0 6px;
            width: min(280px, 75%);
        }
        .menu-btn {
            background: #1d1d2e;
            border: 3px solid #ffd23f;
            color: #ffd23f;
            font-family: "Courier New", Courier, monospace;
            font-size: clamp(16px, 3vmin, 26px);
            font-weight: bold;
            padding: 12px 10px;
            cursor: pointer;
            border-radius: 4px;
            text-shadow: 2px 2px 0 #000;
            box-shadow: 4px 4px 0 #000;
            transition: all 0.1s ease;
            letter-spacing: 0.04em;
            background: rgba(0, 0, 0, 0.6);
            backdrop-filter: blur(2px);
        }
        .menu-btn:hover {
            background: #ffd23f;
            color: #0b0b14;
            transform: scale(1.02);
            box-shadow: 6px 6px 0 #000;
        }
        .menu-btn:active {
            transform: scale(0.96);
            box-shadow: 2px 2px 0 #000;
        }
        .menu-btn.secondary {
            border-color: #7a8a9a;
            color: #cfe8ff;
        }
        .menu-btn.secondary:hover {
            background: #7a8a9a;
            color: #0b0b14;
        }
        .menu-btn.toggle-on {
            border-color: #5ec44a;
            color: #7CFC6B;
            background: rgba(94, 196, 74, 0.15);
        }
        .menu-btn.toggle-off {
            border-color: #7a8a9a;
            color: #aaa;
        }
        .stats {
            font-size: clamp(13px, 2.4vmin, 20px);
            line-height: 1.7;
            text-shadow: 2px 2px 0 #000;
        }
        .stats .big {
            font-size: clamp(24px, 5vmin, 44px);
            color: #ffd23f;
        }
        .stunts {
            font-size: clamp(11px, 2vmin, 16px);
            color: #9fe3ff;
            margin: 0.4em 0 1em;
            line-height: 1.6;
            max-width: 90%;
        }
        .stunts-list {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 4px 14px;
        }
        #controls-overlay {
            position: absolute;
            inset: 0;
            background: rgba(6, 6, 18, 0.92);
            display: none;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 5%;
            border-radius: 4px;
            z-index: 20;
            backdrop-filter: blur(4px);
        }
        #controls-overlay.show {
            display: flex;
        }
        #controls-overlay h2 {
            font-size: clamp(22px, 5vmin, 44px);
            color: #ffd23f;
            text-shadow: 2px 2px 0 #000;
            margin-bottom: 16px;
        }
        .ctrl-grid {
            display: grid;
            grid-template-columns: auto auto;
            gap: 6px 28px;
            font-size: clamp(13px, 2.4vmin, 20px);
            text-align: left;
            background: rgba(0, 0, 0, 0.55);
            padding: 18px 28px;
            border: 3px solid #ffd23f;
            box-shadow: 4px 4px 0 #000;
            border-radius: 4px;
            margin-bottom: 18px;
            color: #cfe8ff;
        }
        .ctrl-grid .key {
            background: #1d1d2e;
            padding: 0 8px;
            border-radius: 3px;
            border: 1px solid #444;
            font-weight: bold;
            color: #fff;
            display: inline-block;
            min-width: 28px;
            text-align: center;
        }
        .close-ctrl {
            background: transparent;
            border: 2px solid #7a8a9a;
            color: #cfe8ff;
            font-family: "Courier New", Courier, monospace;
            font-size: clamp(14px, 2.4vmin, 20px);
            padding: 8px 30px;
            cursor: pointer;
            border-radius: 4px;
            transition: 0.15s;
            margin-top: 6px;
        }
        .close-ctrl:hover {
            background: #7a8a9a;
            color: #0b0b14;
        }
        @media (max-width: 600px) {
            .ctrl-grid {
                grid-template-columns: 1fr 1fr;
                gap: 4px 12px;
                padding: 12px 14px;
                font-size: clamp(11px, 2.2vmin, 16px);
            }
            .menu-buttons {
                width: min(220px, 80%);
            }
            .menu-btn {
                padding: 10px 8px;
                font-size: clamp(14px, 2.8vmin, 20px);
            }
            #touch-controls .btn.gas {
                width: 56px;
                height: 56px;
                right: 10px;
                bottom: 50px;
                font-size: 18px;
            }
            #touch-controls .btn.brake {
                width: 48px;
                height: 48px;
                right: 76px;
                bottom: 50px;
                font-size: 14px;
            }
            #touch-controls .btn.lean-left {
                width: 44px;
                height: 44px;
                left: 10px;
                bottom: 110px;
                font-size: 13px;
            }
            #touch-controls .btn.lean-right {
                width: 44px;
                height: 44px;
                left: 10px;
                bottom: 50px;
                font-size: 13px;
            }
            #touch-controls .btn.hop {
                width: 42px;
                height: 42px;
                left: 66px;
                bottom: 110px;
                font-size: 12px;
            }
        }
        @media (max-width: 400px) {
            .ctrl-grid {
                grid-template-columns: 1fr;
                gap: 2px 0;
                padding: 10px 12px;
            }
            .ctrl-grid div {
                display: flex;
                gap: 6px;
            }
            #touch-controls .btn.gas {
                width: 48px;
                height: 48px;
                right: 6px;
                bottom: 40px;
                font-size: 16px;
            }
            #touch-controls .btn.brake {
                width: 40px;
                height: 40px;
                right: 62px;
                bottom: 40px;
                font-size: 12px;
            }
            #touch-controls .btn.lean-left {
                width: 38px;
                height: 38px;
                left: 6px;
                bottom: 90px;
                font-size: 11px;
            }
            #touch-controls .btn.lean-right {
                width: 38px;
                height: 38px;
                left: 6px;
                bottom: 40px;
                font-size: 11px;
            }
            #touch-controls .btn.hop {
                width: 36px;
                height: 36px;
                left: 52px;
                bottom: 90px;
                font-size: 10px;
            }
        }
    </style>
</head>
<body>

    <div id="wrap">
        <div id="box">
            <canvas id="game"></canvas>

            <!-- Touch controls overlay -->
            <div id="touch-controls">
                <div class="btn gas" id="t-gas">▲<span class="btn-label">GAS</span></div>
                <div class="btn brake" id="t-brake">▼<span class="btn-label">BRAKE</span></div>
                <div class="btn lean-left" id="t-leanL">◀<span class="btn-label">LEAN</span></div>
                <div class="btn lean-right" id="t-leanR">▶<span class="btn-label">LEAN</span></div>
                <div class="btn hop" id="t-hop">⤊<span class="btn-label">HOP</span></div>
            </div>

            <!-- ====== MAIN MENU ====== -->
            <div id="main-menu" class="screen">
                <h1>PIXEL MOTO<br>STUNTS</h1>
                <p class="sub">Race the clock. Land sick stunts. Reach the finish!</p>
                <div class="menu-buttons">
                    <button class="menu-btn" id="btn-play">▶&nbsp; RIDE</button>
                    <button class="menu-btn secondary" id="btn-controls">⌨&nbsp; CONTROLS</button>
                    <button class="menu-btn toggle-off" id="btn-touch">📱 TOUCH: OFF</button>
                </div>
                <p style="font-size:clamp(10px,1.8vmin,14px);color:#6a7a8a;margin-top:8px;">v2.1 — with touch controls</p>
            </div>

            <!-- ====== CONTROLS OVERLAY ====== -->
            <div id="controls-overlay">
                <h2>⌨ CONTROLS</h2>
                <div class="ctrl-grid">
                    <div><span class="key">↑</span> / <span class="key">W</span></div><div>Gas / accelerate</div>
                    <div><span class="key">↓</span> / <span class="key">S</span></div><div>Brake</div>
                    <div><span class="key">←</span> / <span class="key">A</span></div><div>Lean back · Wheelie · Backflip</div>
                    <div><span class="key">→</span> / <span class="key">D</span></div><div>Lean forward · Frontflip</div>
                    <div><span class="key">␣</span></div><div>Hop (jump)</div>
                    <div><span class="key">R</span></div><div>Restart run</div>
                </div>
                <p style="color:#7a8a9a;font-size:clamp(10px,1.6vmin,14px);margin-bottom:10px;">💡 Touch controls can be enabled in the main menu.</p>
                <button class="close-ctrl" id="close-ctrl">✕ CLOSE</button>
            </div>

            <!-- ====== END SCREEN ====== -->
            <div id="end" class="screen hidden"></div>
        </div>
    </div>

    <script>
        (function() {
            'use strict';

            /* ============================================================
               SETUP
               ============================================================ */
            const W = 480,
                H = 270;
            const box = document.getElementById('box'),
                cv = document.getElementById('game'),
                ctx = cv.getContext('2d');
            const mainMenu = document.getElementById('main-menu');
            const endEl = document.getElementById('end');
            const ctrlOverlay = document.getElementById('controls-overlay');
            const btnPlay = document.getElementById('btn-play');
            const btnControls = document.getElementById('btn-controls');
            const closeCtrl = document.getElementById('close-ctrl');
            const btnTouch = document.getElementById('btn-touch');
            const touchControls = document.getElementById('touch-controls');

            cv.width = W;
            cv.height = H;
            ctx.imageSmoothingEnabled = false;

            let touchMode = false; // default: keyboard

            function fit() {
                const s = Math.min(innerWidth / W, innerHeight / H);
                box.style.width = Math.floor(W * s) + 'px';
                box.style.height = Math.floor(H * s) + 'px';
            }
            addEventListener('resize', fit);
            fit();

            // Toggle touch mode
            function setTouchMode(on) {
                touchMode = on;
                if (touchMode) {
                    touchControls.classList.add('active');
                    btnTouch.textContent = '📱 TOUCH: ON';
                    btnTouch.className = 'menu-btn toggle-on';
                } else {
                    touchControls.classList.remove('active');
                    btnTouch.textContent = '📱 TOUCH: OFF';
                    btnTouch.className = 'menu-btn toggle-off';
                }
            }

            btnTouch.addEventListener('click', () => {
                setTouchMode(!touchMode);
            });

            // Initially off
            setTouchMode(false);

            /* ============================================================
               CONSTANTS — SPEED INCREASED
               ============================================================ */
            const TRACK = 8000,
                FINISH_X = 7600,
                BASE = 190,
                TIME_LIMIT = 75,
                CP_SPACING = 1000;
            const G = 420,
                L = 26,
                R = 6,
                MAXSPD = 400,
                ACC = 500,
                LEAN_AIR = 200,
                LEAN_GND = 280,
                HOP_V = 2.2,
                PIT_DEPTH = 100,
                PENALTY = 2;
            const GLEN = TRACK + 800;
            const PAL = ['#ffffff', '#fff3a0', '#ffd23f', '#ff8c1a', '#ff3d00', '#b71c1c', '#5a5a5a', '#8a8a8a'];

            function rng(seed) {
                return function() {
                    seed |= 0;
                    seed = seed + 0x6D2B79F5 | 0;
                    let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
                    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
                    return ((t ^ t >>> 14) >>> 0) / 4294967296;
                };
            }

            /* ============================================================
               TERRAIN
               ============================================================ */
            const ground = new Float32Array(GLEN),
                pitMask = new Uint8Array(GLEN);
            const obstacles = [],
                checkpoints = [],
                clouds = [];

            function gY(x) {
                if (x < 0) x = 0;
                if (x > GLEN - 2) x = GLEN - 2;
                const i = x | 0,
                    f = x - i;
                return ground[i] + (ground[i + 1] - ground[i]) * f;
            }

            function slopeAt(x) {
                return (gY(x + 4) - gY(x - 4)) / 8;
            }

            function generate() {
                const rand = rng(20240601);
                let x = 0,
                    h = 0;

                const set = (i, v) => { if (i >= 0 && i < GLEN) ground[i] = BASE - v; };

                const addObstacle = (ox) => {
                    const r = rand();
                    let o;
                    if (r < 0.35) o = { type: 'crate', w: 13, h: 13 };
                    else if (r < 0.65) o = { type: 'barrel', w: 10, h: 14 };
                    else o = { type: 'rock', w: 18, h: 9 };
                    o.x = Math.round(ox);
                    o.y = BASE - h - o.h;
                    obstacles.push(o);
                    const kl = 30,
                        kx = o.x - 50;
                    for (let i = 0; i < kl; i++) {
                        const t = i / kl;
                        set(kx + i, h + 6 * t * (1 - t) + 4);
                    }
                };

                const flat = (len, obs) => {
                    len = Math.round(len);
                    for (let i = 0; i < len; i++) set(x + i, h);
                    if (obs && len >= 140 && x > 800 && rand() < 0.6) {
                        const ox = x + 80 + rand() * (len - 120);
                        if (ox > x + 40 && ox < x + len - 40) addObstacle(ox);
                    }
                    x += len;
                };

                const gentleHill = (len, amp) => {
                    len |= 0;
                    const peak = amp * (0.6 + rand() * 0.4);
                    for (let i = 0; i < len; i++) {
                        const t = i / len;
                        const val = peak * (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t));
                        set(x + i, h + val);
                    }
                    x += len;
                };

                const slope = (len, dh) => {
                    len |= 0;
                    for (let i = 0; i < len; i++) { set(x + i, h + dh * i / len); }
                    x += len;
                    h += dh;
                };

                const kicker = (len, rise) => {
                    len |= 0;
                    for (let i = 0; i < len; i++) {
                        const t = i / len;
                        set(x + i, h + rise * t * (0.5 + 0.5 * t));
                    }
                    x += len;
                };

                const plateau = (len, rise) => {
                    slope(50, rise);
                    flat(len, false);
                    h -= rise;
                };

                flat(220, false);

                while (x < FINISH_X - 500) {
                    const nextCp = Math.ceil((x + 1) / CP_SPACING) * CP_SPACING;
                    if (nextCp < FINISH_X && nextCp - x < 500) {
                        const gap = nextCp + 60 - x;
                        if (gap > 300) { gentleHill(120 + rand() * 80, 12 + rand() * 20);
                            flat(gap - 80, true); } else flat(gap, false);
                        continue;
                    }

                    const d = x / FINISH_X;
                    const r = rand();

                    if (r < 0.25) {
                        flat(100 + rand() * 140, true);
                    } else if (r < 0.45) {
                        gentleHill(100 + rand() * 120, 12 + rand() * 22);
                    } else if (r < 0.60) {
                        kicker(50 + rand() * 40, 22 + rand() * 20);
                        flat(60 + rand() * 80, false);
                    } else if (r < 0.72) {
                        const dh = (rand() < 0.5 ? -1 : 1) * (10 + rand() * 20);
                        if (h + dh < -15 || h + dh > 50) slope(80, -dh * 0.4);
                        else slope(70 + rand() * 80, dh);
                    } else if (r < 0.84) {
                        for (let k = 0; k < 2 + (rand() * 2 | 0); k++) {
                            gentleHill(20 + rand() * 30, 5 + rand() * 10);
                        }
                        flat(40, false);
                    } else if (r < 0.94) {
                        plateau(50 + rand() * 60, 15 + rand() * 20);
                    } else {
                        flat(90 + rand() * 100, true);
                    }

                    if (x > 1200 && x < FINISH_X - 400 && rand() < 0.08 + d * 0.08) {
                        const pw = 20 + rand() * 20;
                        const depth = 30 + rand() * 40;
                        for (let i = 0; i < pw; i++) {
                            const idx = x + i;
                            if (idx < GLEN) {
                                ground[idx] = BASE - h + depth;
                                pitMask[idx] = 1;
                            }
                        }
                        x += pw;
                    }
                }

                flat(GLEN - x, false);

                for (let c = CP_SPACING; c < FINISH_X; c += CP_SPACING) {
                    checkpoints.push({ x: c, passed: false });
                }

                for (let i = 0; i < 14; i++) {
                    clouds.push({ x: rand() * 2400, y: 14 + rand() * 60, s: 0.8 + rand() * 1.4 });
                }
            }
            generate();

            /* ============================================================
               INPUT — KEYBOARD + TOUCH BUTTONS
               ============================================================ */
            const keys = {};
            const K = {
                gas: () => keys.ArrowUp || keys.KeyW,
                brake: () => keys.ArrowDown || keys.KeyS,
                back: () => keys.ArrowLeft || keys.KeyA,
                fwd: () => keys.ArrowRight || keys.KeyD
            };
            let hopBuf = 0,
                hintSeen = false;

            // Keyboard events
            addEventListener('keydown', e => {
                if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
                initAudio();
                if (e.repeat) return;
                keys[e.code] = true;
                if (e.code === 'Space' && state === 'play') hopBuf = 0.15;
                if (e.code === 'KeyR') newGame();
                if (state === 'play') hintSeen = true;
            });
            addEventListener('keyup', e => { keys[e.code] = false; });

            // Touch buttons (only if touchMode is active)
            const touchState = { gas: false, brake: false, leanL: false, leanR: false };

            function setupTouchButton(id, keyOn, keyOff) {
                const el = document.getElementById(id);
                if (!el) return;
                el.addEventListener('touchstart', (e) => {
                    e.preventDefault();
                    if (state !== 'play') return;
                    initAudio();
                    touchState[keyOn] = true;
                    if (keyOff) touchState[keyOff] = false;
                    el.classList.add('pressed');
                    if (id === 't-hop') {
                        hopBuf = 0.15;
                        sfxHop();
                    }
                }, { passive: false });
                el.addEventListener('touchend', (e) => {
                    e.preventDefault();
                    touchState[keyOn] = false;
                    el.classList.remove('pressed');
                }, { passive: false });
                el.addEventListener('touchcancel', (e) => {
                    touchState[keyOn] = false;
                    el.classList.remove('pressed');
                }, { passive: false });
                // Also support mouse for debugging
                el.addEventListener('mousedown', (e) => {
                    e.preventDefault();
                    if (state !== 'play') return;
                    initAudio();
                    touchState[keyOn] = true;
                    if (keyOff) touchState[keyOff] = false;
                    el.classList.add('pressed');
                    if (id === 't-hop') {
                        hopBuf = 0.15;
                        sfxHop();
                    }
                });
                el.addEventListener('mouseup', (e) => {
                    e.preventDefault();
                    touchState[keyOn] = false;
                    el.classList.remove('pressed');
                });
                el.addEventListener('mouseleave', () => {
                    touchState[keyOn] = false;
                    el.classList.remove('pressed');
                });
            }

            setupTouchButton('t-gas', 'gas');
            setupTouchButton('t-brake', 'brake');
            setupTouchButton('t-leanL', 'leanL');
            setupTouchButton('t-leanR', 'leanR');
            setupTouchButton('t-hop', 'hop');

            // Override K functions to include touch state when touchMode is on
            const originalGas = K.gas,
                originalBrake = K.brake,
                originalBack = K.back,
                originalFwd = K.fwd;
            K.gas = () => (touchMode ? touchState.gas : false) || originalGas();
            K.brake = () => (touchMode ? touchState.brake : false) || originalBrake();
            K.back = () => (touchMode ? touchState.leanL : false) || originalBack();
            K.fwd = () => (touchMode ? touchState.leanR : false) || originalFwd();

            // Menu buttons
            btnPlay.addEventListener('click', () => { initAudio();
                newGame(); });
            btnControls.addEventListener('click', () => { ctrlOverlay.classList.add('show'); });
            closeCtrl.addEventListener('click', () => { ctrlOverlay.classList.remove('show'); });
            ctrlOverlay.addEventListener('click', (e) => {
                if (e.target === ctrlOverlay) ctrlOverlay.classList.remove('show');
            });

            endEl.addEventListener('click', () => { initAudio();
                newGame(); });

            /* ============================================================
               AUDIO
               ============================================================ */
            let AC = null,
                eng = null,
                engGain = null;

            function initAudio() {
                if (AC) return;
                try {
                    AC = new(window.AudioContext || window.webkitAudioContext)();
                    eng = AC.createOscillator();
                    eng.type = 'sawtooth';
                    engGain = AC.createGain();
                    engGain.gain.value = 0;
                    const lp = AC.createBiquadFilter();
                    lp.type = 'lowpass';
                    lp.frequency.value = 700;
                    eng.connect(lp).connect(engGain).connect(AC.destination);
                    eng.start();
                } catch (_) { AC = null; }
            }

            function updEngine() {
                if (!AC) return;
                if (AC.state === 'suspended') AC.resume();
                const on = state === 'play';
                const f = 38 + Math.abs(bike ? bike.speed : 0) * 0.5 + (K.gas() && on ? 35 : 0);
                eng.frequency.setTargetAtTime(f, AC.currentTime, 0.05);
                engGain.gain.setTargetAtTime(on ? 0.03 : 0, AC.currentTime, 0.08);
            }

            function beep(freq, dur, type, vol, when) {
                if (!AC) return;
                type = type || 'square';
                vol = vol || 0.07;
                when = when || 0;
                const o = AC.createOscillator(),
                    g = AC.createGain();
                o.type = type;
                o.frequency.value = freq;
                g.gain.setValueAtTime(vol, AC.currentTime + when);
                g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + when + dur);
                o.connect(g).connect(AC.destination);
                o.start(AC.currentTime + when);
                o.stop(AC.currentTime + when + dur + 0.03);
            }

            function sfxExplode() {
                if (!AC) return;
                const n = (AC.sampleRate * 0.6) | 0,
                    buf = AC.createBuffer(1, n, AC.sampleRate),
                    d = buf.getChannelData(0);
                for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 2.5);
                const s = AC.createBufferSource();
                s.buffer = buf;
                const g = AC.createGain();
                g.gain.value = 0.5;
                const lp = AC.createBiquadFilter();
                lp.type = 'lowpass';
                lp.frequency.value = 900;
                s.connect(lp).connect(g).connect(AC.destination);
                s.start();
                beep(70, 0.4, 'sawtooth', 0.12);
            }

            function sfxStunt() { beep(660, 0.07);
                beep(880, 0.07, 'square', 0.06, 0.07);
                beep(1320, 0.15, 'square', 0.06, 0.14); }

            function sfxCheckpoint() { beep(523, 0.08);
                beep(784, 0.18, 'square', 0.06, 0.08); }

            function sfxWin() { [523, 659, 784, 1047, 1319].forEach((f, i) => beep(f, 0.2, 'square', 0.07, i * 0.10)); }

            function sfxOver() { [400, 300, 220, 150].forEach((f, i) => beep(f, 0.25, 'sawtooth', 0.07, i * 0.18)); }

            function sfxHop() { beep(300, 0.05, 'square', 0.04); }

            /* ============================================================
               GAME STATE
               ============================================================ */
            let state = 'menu',
                timeLeft = TIME_LIMIT,
                score = 0,
                stunts = {},
                stuntCount = 0,
                crashes = 0,
                lastCp = 60;
            let camX = 0,
                camY = 30,
                shake = 0,
                flash = 0,
                crashT = 0,
                elapsed = 0,
                frameCount = 0;
            let particles = [],
                rings = [],
                popups = [];
            let bike = null;

            function mkWheel(x) {
                const y = gY(x) - R;
                return { x, y, px: x, py: y };
            }

            function spawn(x) {
                bike = {
                    p: [mkWheel(x - L / 2), mkWheel(x + L / 2)],
                    c: [true, true],
                    spin: 0,
                    air: 0,
                    rot: 0,
                    prevAng: 0,
                    wheelie: 0,
                    wScored: 0,
                    safe: 0.6,
                    speed: 0,
                    smokeT: 0
                };
                const p1 = bike.p[0],
                    p2 = bike.p[1];
                bike.prevAng = Math.atan2(p2.y - p1.y, p2.x - p1.x);
            }

            function newGame() {
                state = 'play';
                timeLeft = TIME_LIMIT;
                score = 0;
                stunts = {};
                stuntCount = 0;
                crashes = 0;
                lastCp = 60;
                elapsed = 0;
                hintSeen = false;
                particles = [];
                rings = [];
                popups = [];
                shake = 0;
                flash = 0;
                checkpoints.forEach(c => c.passed = false);
                spawn(60);
                camX = 0;
                camY = 30;
                mainMenu.classList.add('hidden');
                endEl.classList.add('hidden');
                ctrlOverlay.classList.remove('show');
                // reset touch states
                touchState.gas = false;
                touchState.brake = false;
                touchState.leanL = false;
                touchState.leanR = false;
                document.querySelectorAll('#touch-controls .btn').forEach(el => el.classList.remove('pressed'));
            }

            function addPopup(text, color, big) {
                popups.push({ text, color: color || '#ffd23f', t: 0, life: 1.6, big: !!big, off: popups.length * 12 });
            }

            function addStunt(name, pts) {
                score += pts;
                stunts[name] = (stunts[name] || 0) + 1;
                stuntCount++;
                addPopup(name + '  +' + pts, '#ffd23f', true);
                sfxStunt();
            }

            spawn(60);

            /* ============================================================
               PHYSICS
               ============================================================ */
            function step(h) {
                const b = bike,
                    p1 = b.p[0],
                    p2 = b.p[1];
                b.safe -= h;
                hopBuf -= h;

                for (const p of b.p) {
                    const vx = p.x - p.px,
                        vy = p.y - p.py;
                    p.px = p.x;
                    p.py = p.y;
                    p.x += vx * 0.999;
                    p.y += vy * 0.999 + G * h * h;
                }

                let dx = p2.x - p1.x,
                    dy = p2.y - p1.y,
                    dl = Math.hypot(dx, dy) || 1;
                const ux = dx / dl,
                    uy = dy / dl;
                const grounded = b.c[0] || b.c[1];
                const spd = (((p1.x - p1.px) + (p2.x - p2.px)) / 2 * ux + ((p1.y - p1.py) + (p2.y - p2.py)) / 2 * uy) / h;
                b.speed = spd;

                if (K.gas() && b.c[0] && spd < MAXSPD) {
                    const s = slopeAt(p1.x);
                    let tx = 1,
                        ty = s;
                    const tl = Math.hypot(tx, ty);
                    tx /= tl;
                    ty /= tl;
                    const a = ACC * h * h;
                    p1.x += tx * a;
                    p1.y += ty * a;
                    p2.x += tx * a;
                    p2.y += ty * a;
                }

                if (K.brake()) {
                    for (let i = 0; i < 2; i++) {
                        if (b.c[i]) {
                            const p = b.p[i];
                            p.px += (p.x - p.px) * 0.04;
                            p.py += (p.y - p.py) * 0.04;
                        }
                    }
                }

                let lean = 0;
                if (K.back()) lean -= 1;
                if (K.fwd()) lean += 1;
                if (lean) {
                    const a = (grounded ? LEAN_GND : LEAN_AIR) * h * h * (-lean);
                    p2.x += uy * a;
                    p2.y += -ux * a;
                    p1.x -= uy * a;
                    p1.y -= -ux * a;
                }

                if (hopBuf > 0 && grounded) {
                    p1.py += HOP_V;
                    p2.py += HOP_V;
                    hopBuf = 0;
                    sfxHop();
                }

                for (let i = 0; i < 2; i++) {
                    if (b.c[i]) {
                        const p = b.p[i];
                        p.px += (p.x - p.px) * 0.002;
                    }
                }

                for (let k = 0; k < 3; k++) {
                    dx = p2.x - p1.x;
                    dy = p2.y - p1.y;
                    const d = Math.hypot(dx, dy) || 1,
                        diff = (d - L) / d * 0.5;
                    p1.x += dx * diff;
                    p1.y += dy * diff;
                    p2.x -= dx * diff;
                    p2.y -= dy * diff;
                }

                b.c = [false, false];
                let crash = null;

                for (let i = 0; i < 2; i++) {
                    const p = b.p[i];
                    const gy = gY(p.x);
                    if (p.y + R > gy) {
                        const s = slopeAt(p.x);
                        let nx = s,
                            ny = -1;
                        const nl = Math.hypot(nx, ny);
                        nx /= nl;
                        ny /= nl;
                        const pen = (p.y + R - gy) * (-ny);
                        if (pen > 0) {
                            if (pen > R * 2.5) crash = 'wall';
                            p.x += nx * pen;
                            p.y += ny * pen;
                            b.c[i] = true;
                            p.px += (p.x - p.px) * 0.02;
                            if (i === 1) {
                                const tx = -ny,
                                    ty = nx;
                                const dot = ux * tx + uy * ty;
                                if (dot < 0.2) crash = 'angle';
                            }
                            if (ux < -0.15) crash = 'flip';
                        }
                    }

                    const xi = p.x | 0;
                    if (xi >= 0 && xi < GLEN && pitMask[xi] && p.y > gY(xi) - PIT_DEPTH + 30) crash = 'pit';

                    for (const o of obstacles) {
                        if (p.x + R < o.x || p.x - R > o.x + o.w || p.y + R < o.y || p.y - R > o.y + o.h) continue;
                        const cx = Math.max(o.x, Math.min(p.x, o.x + o.w));
                        const cy = Math.max(o.y, Math.min(p.y, o.y + o.h));
                        const ddx = p.x - cx,
                            ddy = p.y - cy,
                            d2 = ddx * ddx + ddy * ddy;
                        if (d2 >= R * R) continue;
                        let nx, ny, pen;
                        if (d2 < 1e-6) { nx = 0;
                            ny = -1;
                            pen = p.y + R - o.y; } else { const d = Math.sqrt(d2);
                            nx = ddx / d;
                            ny = ddy / d;
                            pen = R - d; }
                        const vn = ((p.x - p.px) * nx + (p.y - p.py) * ny) / h;
                        if (Math.abs(nx) > 0.6 && vn < -40) crash = 'obstacle';
                        p.x += nx * pen;
                        p.y += ny * pen;
                        if (ny < -0.4) b.c[i] = true;
                    }
                }

                const mx = (p1.x + p2.x) / 2,
                    my = (p1.y + p2.y) / 2;
                const hx = mx - 2 * ux + 26 * uy,
                    hy = my - 2 * uy - 26 * ux;
                if (hy + 3 > gY(hx)) crash = 'head';
                for (const o of obstacles) {
                    if (hx > o.x - 3 && hx < o.x + o.w + 3 && hy > o.y - 3 && hy < o.y + o.h + 3) { crash = 'head'; break; }
                }

                if (crash && b.safe <= 0) doCrash();
            }

            function doCrash() {
                const b = bike,
                    p1 = b.p[0],
                    p2 = b.p[1];
                const mx = (p1.x + p2.x) / 2,
                    my = (p1.y + p2.y) / 2 - 8;
                state = 'crash';
                crashT = 1.2;
                crashes++;
                timeLeft = Math.max(0, timeLeft - PENALTY);
                explode(mx, my, p1, p2);
                addPopup('CRASH!  -' + PENALTY + 's', '#ff4d4d', true);
                sfxExplode();
            }

            function explode(x, y, p1, p2) {
                for (let i = 0; i < 90; i++) {
                    const a = Math.random() * Math.PI * 2,
                        sp = 30 + Math.random() * 200;
                    particles.push({ k: 'px', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 80, s: 1 + (Math.random() *
                            4 | 0), ci: (Math.random() * 3) | 0, t: 0, life: 0.4 + Math.random() * 0.9 });
                }
                for (let i = 0; i < 16; i++) {
                    particles.push({ k: 'smoke', x: x + (Math.random() - 0.5) * 12, y: y + (Math.random() - 0.5) * 12,
                        vx: (Math.random() - 0.5) * 35, vy: -25 - Math.random() * 35, s: 3 + Math.random() * 3, t: 0,
                        life: 1.0 + Math.random() * 0.8 });
                }
                particles.push({ k: 'wheel', x: p1.x, y: p1.y, vx: -80 - Math.random() * 50, vy: -180, spin: 0, t: 0,
                    life: 2.2 });
                particles.push({ k: 'wheel', x: p2.x, y: p2.y, vx: 80 + Math.random() * 60, vy: -160, spin: 0, t: 0,
                    life: 2.2 });
                rings.push({ x, y, t: 0 });
                rings.push({ x, y, t: -0.08 });
                flash = 0.10;
                shake = 0.8;
            }

            function afterPhysics(dt) {
                const b = bike,
                    p1 = b.p[0],
                    p2 = b.p[1];
                timeLeft -= dt;
                if (timeLeft <= 0) { timeLeft = 0;
                    gameOver(); return; }

                const ux = (p2.x - p1.x) / L,
                    uy = (p2.y - p1.y) / L;
                b.spin += b.speed * dt / R;

                const ang = Math.atan2(uy, ux);
                let dA = ang - b.prevAng;
                if (dA > Math.PI) dA -= 2 * Math.PI;
                if (dA < -Math.PI) dA += 2 * Math.PI;
                b.prevAng = ang;
                const grounded = b.c[0] || b.c[1];

                if (!grounded) {
                    b.air += dt;
                    b.rot += dA;
                    b.wheelie = 0;
                    b.wScored = 0;
                } else {
                    if (b.air > 0.2) {
                        const flips = Math.floor((Math.abs(b.rot) + 0.8) / (2 * Math.PI));
                        if (flips > 0) {
                            const name = b.rot < 0 ? 'BACKFLIP' : 'FRONTFLIP';
                            addStunt(flips > 1 ? flips + 'x ' + name : name, 400 * flips * flips);
                        } else if (b.air > 1.2) addStunt('HUGE AIR', 350);
                        else if (b.air > 0.6) addStunt('BIG AIR', 120);
                    }
                    b.air = 0;
                    b.rot = 0;
                    if (b.c[0] && !b.c[1] && ux > 0.3) {
                        b.wheelie += dt;
                        const s = Math.floor(b.wheelie);
                        if (s >= 1 && s > b.wScored) { b.wScored = s;
                            addStunt('WHEELIE ' + s + 's', 80 + s * 20); }
                    } else { b.wheelie = 0;
                        b.wScored = 0; }
                }

                if (K.gas() && grounded) {
                    b.smokeT += dt;
                    if (b.smokeT > 0.08) {
                        b.smokeT = 0;
                        const mx = (p1.x + p2.x) / 2,
                            my = (p1.y + p2.y) / 2;
                        const sx = mx - 14 * ux - 3 * (-uy),
                            sy = my - 14 * uy - 3 * ux;
                        particles.push({ k: 'smoke', x: sx, y: sy, vx: -35 + Math.random() * 8, vy: -12 - Math.random() * 8,
                            s: 1.3, t: 0, life: 0.5 });
                    }
                }

                const mx = (p1.x + p2.x) / 2;
                for (const cp of checkpoints) {
                    if (!cp.passed && mx > cp.x) {
                        cp.passed = true;
                        lastCp = cp.x;
                        score += 50;
                        addPopup('CHECKPOINT!', '#7CFC6B', true);
                        sfxCheckpoint();
                    }
                }
                if (mx > FINISH_X) win();
            }

            function gameOver() {
                state = 'over';
                sfxOver();
                const mx = (bike.p[0].x + bike.p[1].x) / 2;
                endEl.innerHTML =
                    '<h1 class="over">TIME\'S UP!</h1><p class="sub">You made it ' + Math.round(Math.min(100, mx / FINISH_X *
                        100)) + '% of the way.</p>' +
                    '<div class="stats">SCORE <span class="big">' + score + '</span><br>STUNTS ' + stuntCount +
                    ' &nbsp;·&nbsp; CRASHES ' + crashes + '</div>' + stuntList() +
                    '<p class="blink">PRESS ENTER OR TAP TO RETRY</p>';
                endEl.classList.remove('hidden');
            }

            function win() {
                state = 'win';
                sfxWin();
                const bonus = Math.round(timeLeft) * 10;
                score += bonus;
                endEl.innerHTML =
                    '<h1 class="win">🏁 YOU WIN!</h1><p class="sub">Crossed the finish with ' + timeLeft.toFixed(1) +
                    's to spare!</p>' +
                    '<div class="stats">FINAL SCORE <span class="big">' + score + '</span><br>TIME BONUS +' + bonus +
                    ' &nbsp;·&nbsp; STUNTS ' + stuntCount + ' &nbsp;·&nbsp; CRASHES ' + crashes + '</div>' + stuntList() +
                    '<p class="blink">PRESS ENTER OR TAP TO RIDE AGAIN</p>';
                endEl.classList.remove('hidden');
            }

            function stuntList() {
                const k = Object.keys(stunts);
                if (!k.length) return '<div class="stunts">No stunts performed… try some flips!</div>';
                return '<div class="stunts"><span style="opacity:0.7;">STUNTS:</span><br><span class="stunts-list">' + k.map(
                    n => n + ' × ' + stunts[n]).join(' &nbsp;|&nbsp; ') + '</span></div>';
            }

            /* ============================================================
               PARTICLES / CAMERA
               ============================================================ */
            function updateParticles(dt) {
                for (let i = particles.length - 1; i >= 0; i--) {
                    const p = particles[i];
                    p.t += dt;
                    if (p.t > p.life) { particles.splice(i, 1); continue; }
                    if (p.k === 'px' || p.k === 'wheel') {
                        p.vy += G * dt;
                        p.x += p.vx * dt;
                        p.y += p.vy * dt;
                        const off = p.k === 'wheel' ? R : 0,
                            gy = gY(p.x);
                        if (p.y > gy - off) { p.y = gy - off;
                            p.vy *= -0.4;
                            p.vx *= 0.65; }
                        if (p.k === 'wheel') p.spin += p.vx * dt / R;
                    } else {
                        p.x += p.vx * dt;
                        p.y += p.vy * dt;
                        p.s += dt * 2.2;
                    }
                }
                for (let i = rings.length - 1; i >= 0; i--) { rings[i].t += dt; if (rings[i].t > 0.45) rings.splice(i, 1); }
            }

            function updatePopups(dt) {
                for (let i = popups.length - 1; i >= 0; i--) { popups[i].t += dt; if (popups[i].t > popups[i].life) popups
                        .splice(i, 1); }
            }

            function updateCamera(dt) {
                const p1 = bike.p[0],
                    p2 = bike.p[1];
                const mx = (p1.x + p2.x) / 2,
                    my = (p1.y + p2.y) / 2;
                const tx = Math.max(0, mx - 140),
                    ty = Math.max(-200, Math.min(60, my - 155));
                const k = 1 - Math.pow(0.001, dt);
                camX += (tx - camX) * Math.min(1, k * 1.6);
                camY += (ty - camY) * k;
            }

            /* ============================================================
               RENDERING
               ============================================================ */
            function txt(s, x, y, c, font, align) {
                ctx.font = font || 'bold 10px monospace';
                ctx.textAlign = align || 'left';
                ctx.textBaseline = 'top';
                ctx.fillStyle = '#000';
                ctx.fillText(s, x + 1, y + 1);
                ctx.fillStyle = c || '#fff';
                ctx.fillText(s, x, y);
            }

            function circ(x, y, r, c) {
                ctx.fillStyle = c;
                ctx.beginPath();
                ctx.arc(x, y, r, 0, Math.PI * 2);
                ctx.fill();
            }

            function drawSky(cx, cy) {
                const bands = ['#2b6fd6', '#3f86e6', '#5ea2f2', '#86bdf7', '#a9d4fb', '#c8e6fd'];
                for (let i = 0; i < 6; i++) { ctx.fillStyle = bands[i];
                    ctx.fillRect(0, i * 45, W, 45); }
                const sx = 390,
                    sy = 40 - cy * 0.04;
                circ(sx, sy, 14, '#ffe9a8');
                circ(sx, sy, 11, '#ffd23f');
                ctx.fillStyle = '#f4f9ff';
                for (const c of clouds) {
                    let x = ((c.x - cx * 0.2) % 2400 + 2400) % 2400 - 100;
                    const y = c.y - cy * 0.07,
                        s = c.s;
                    if (x > W + 40) continue;
                    ctx.fillRect(x | 0, y | 0, (18 * s) | 0, (6 * s) | 0);
                    ctx.fillRect((x + 4 * s) | 0, (y - 4 * s) | 0, (10 * s) | 0, (5 * s) | 0);
                    ctx.fillRect((x + 9 * s) | 0, (y - 7 * s) | 0, (6 * s) | 0, (4 * s) | 0);
                }
                ctx.fillStyle = '#6d7fb3';
                for (let sx2 = 0; sx2 < W; sx2 += 2) {
                    const wx = sx2 + cx * 0.25;
                    const y = 120 - (24 * Math.sin(wx * 0.011) + 16 * Math.sin(wx * 0.027 + 2) + 10 * Math.abs(Math.sin(wx *
                        0.045))) - cy * 0.14;
                    ctx.fillRect(sx2, y | 0, 2, H);
                }
                ctx.fillStyle = '#8f9fcc';
                for (let sx2 = 0; sx2 < W; sx2 += 2) {
                    const wx = sx2 + cx * 0.25;
                    const y = 120 - (24 * Math.sin(wx * 0.011) + 16 * Math.sin(wx * 0.027 + 2) + 10 * Math.abs(Math.sin(wx *
                        0.045))) - cy * 0.14;
                    if (y < 76) ctx.fillRect(sx2, y | 0, 2, 3);
                }
                ctx.fillStyle = '#3f8f4f';
                for (let sx2 = 0; sx2 < W; sx2 += 2) {
                    const wx = sx2 + cx * 0.5;
                    const y = 174 - (16 * Math.sin(wx * 0.017) + 10 * Math.sin(wx * 0.041 + 1)) - cy * 0.38;
                    ctx.fillRect(sx2, y | 0, 2, H);
                }
                ctx.fillStyle = '#2f7a3f';
                for (let sx2 = 0; sx2 < W; sx2 += 2) {
                    const wx = sx2 + cx * 0.5;
                    const y = 174 - (16 * Math.sin(wx * 0.017) + 10 * Math.sin(wx * 0.041 + 1)) - cy * 0.38;
                    ctx.fillRect(sx2, (y + 8) | 0, 2, H);
                }
            }

            function drawGround(cx, cy) {
                for (let sx = 0; sx < W; sx++) {
                    const wx = cx + sx;
                    const gyw = gY(wx);
                    const gy = Math.round(gyw - cy);
                    const xi = wx | 0;
                    if (xi >= 0 && xi < GLEN && pitMask[xi]) {
                        const rim = Math.round(gyw - PIT_DEPTH - cy);
                        ctx.fillStyle = '#1a120c';
                        ctx.fillRect(sx, rim, 1, H - rim);
                        const m = ((xi % 8) + 8) % 8,
                            hh = (m < 4 ? m : 8 - m) * 2 + 2;
                        ctx.fillStyle = '#b9c0c8';
                        ctx.fillRect(sx, gy - hh, 1, hh);
                        ctx.fillStyle = '#3a2a1e';
                        ctx.fillRect(sx, gy, 1, H - gy);
                        continue;
                    }
                    if (gy >= H) continue;
                    ctx.fillStyle = '#8a5a2b';
                    ctx.fillRect(sx, gy + 4, 1, H - gy - 4);
                    ctx.fillStyle = '#5ec44a';
                    ctx.fillRect(sx, gy, 1, 3);
                    ctx.fillStyle = '#3c8f34';
                    ctx.fillRect(sx, gy + 3, 1, 2);
                    ctx.fillStyle = '#6b4420';
                    if ((xi * 7 + (gyw | 0)) % 13 === 0) ctx.fillRect(sx, gy + 8, 1, 1);
                    if ((xi * 13) % 19 === 0) ctx.fillRect(sx, gy + 16, 1, 2);
                    if ((xi * 5 + 3) % 31 === 0) ctx.fillRect(sx, gy + 28, 1, 1);
                    if (gy + 42 < H) { ctx.fillStyle = '#5a3618';
                        ctx.fillRect(sx, gy + 42, 1, H - gy - 42); }
                    if (xi % 25 === 0) { ctx.fillStyle = '#7be36a';
                        ctx.fillRect(sx, gy - 2, 1, 2); }
                }
            }

            function drawCheckpoints(cx, cy) {
                for (let i = 0; i < checkpoints.length; i++) {
                    const cp = checkpoints[i];
                    const sx = Math.round(cp.x - cx);
                    if (sx < -20 || sx > W + 20) continue;
                    const gy = Math.round(gY(cp.x) - cy);
                    ctx.fillStyle = '#e8e8e8';
                    ctx.fillRect(sx, gy - 36, 2, 36);
                    ctx.fillStyle = cp.passed ? '#7CFC6B' : '#ffd23f';
                    ctx.fillRect(sx + 2, gy - 36, 10, 7);
                    ctx.fillRect(sx + 12, gy - 35, 3, 5);
                    ctx.fillStyle = '#222';
                    ctx.fillRect(sx - 5, gy - 12, 12, 8);
                    txt(String(i + 1), sx + 1, gy - 12, '#fff', 'bold 7px monospace', 'center');
                }
                const fx = FINISH_X,
                    sx = Math.round(fx - cx);
                if (sx > -60 && sx < W + 60) {
                    const gy = Math.round(gY(fx) - cy);
                    ctx.fillStyle = '#ddd';
                    ctx.fillRect(sx - 24, gy - 52, 3, 52);
                    ctx.fillRect(sx + 22, gy - 52, 3, 52);
                    for (let i = 0; i < 13; i++)
                        for (let j = 0; j < 4; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#fff';
                            ctx.fillRect(sx - 24 + i * 4, gy - 54 + j * 4, 4, 4); }
                    ctx.fillStyle = '#c0392b';
                    ctx.fillRect(sx - 17, gy - 44, 34, 10);
                    txt('FINISH', sx, gy - 43, '#fff', 'bold 8px monospace', 'center');
                    for (let i = 0; i < 4; i++)
                        for (let j = 0; j < 2; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#fff';
                            const gx = fx - 4 + i * 2;
                            ctx.fillRect(sx - 4 + i * 2, Math.round(gY(gx) - cy) - 1 + j * 2, 2, 2); }
                }
            }

            function drawObstacles(cx, cy) {
                for (const o of obstacles) {
                    const x = Math.round(o.x - cx),
                        y = Math.round(o.y - cy);
                    if (x + o.w < 0 || x > W) continue;
                    if (o.type === 'crate') {
                        ctx.fillStyle = '#b5773a';
                        ctx.fillRect(x, y, o.w, o.h);
                        ctx.fillStyle = '#6b4420';
                        ctx.fillRect(x, y, o.w, 1);
                        ctx.fillRect(x, y + o.h - 1, o.w, 1);
                        ctx.fillRect(x, y, 1, o.h);
                        ctx.fillRect(x + o.w - 1, y, 1, o.h);
                        for (let i = 1; i < o.w - 1; i++) { ctx.fillRect(x + i, y + i, 1, 1);
                            ctx.fillRect(x + i, y + o.h - 1 - i, 1, 1); }
                        ctx.fillStyle = '#d9a066';
                        ctx.fillRect(x + 1, y + 1, o.w - 2, 1);
                    } else if (o.type === 'barrel') {
                        ctx.fillStyle = '#c0392b';
                        ctx.fillRect(x, y, o.w, o.h);
                        ctx.fillStyle = '#f1c40f';
                        ctx.fillRect(x, y + 3, o.w, 2);
                        ctx.fillRect(x, y + o.h - 5, o.w, 2);
                        ctx.fillStyle = '#7b1e14';
                        ctx.fillRect(x, y, 1, o.h);
                        ctx.fillRect(x + o.w - 1, y, 1, o.h);
                        ctx.fillStyle = '#ff7b6b';
                        ctx.fillRect(x + 2, y + 1, 1, o.h - 2);
                    } else {
                        ctx.fillStyle = '#6f6f7c';
                        ctx.fillRect(x + 2, y, o.w - 4, o.h);
                        ctx.fillRect(x, y + 3, o.w, o.h - 3);
                        ctx.fillStyle = '#9a9aa8';
                        ctx.fillRect(x + 4, y + 1, 5, 2);
                        ctx.fillRect(x + 3, y + 3, 2, 1);
                        ctx.fillStyle = '#4b4b56';
                        ctx.fillRect(x + 2, y + o.h - 2, o.w - 4, 2);
                        ctx.fillRect(x + 10, y + 4, 3, 2);
                    }
                }
            }

            function drawWheel(x, y, spin) {
                circ(x, y, 6, '#151515');
                circ(x, y, 3.6, '#8d8d8d');
                ctx.strokeStyle = '#e5e5e5';
                ctx.lineWidth = 1;
                ctx.beginPath();
                for (let k = 0; k < 3; k++) {
                    const a = spin + k * Math.PI * 2 / 3;
                    ctx.moveTo(x, y);
                    ctx.lineTo(x + Math.cos(a) * 3.5, y + Math.sin(a) * 3.5);
                }
                ctx.stroke();
                circ(x, y, 1.2, '#333');
            }

            function drawBike(cx, cy) {
                const b = bike,
                    p1 = b.p[0],
                    p2 = b.p[1];
                const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x);
                const mx = (p1.x + p2.x) / 2 - cx,
                    my = (p1.y + p2.y) / 2 - cy;
                drawWheel(p1.x - cx, p1.y - cy, b.spin);
                drawWheel(p2.x - cx, p2.y - cy, b.spin);
                ctx.save();
                ctx.translate(mx, my);
                ctx.rotate(ang);
                const line = (x1, y1, x2, y2, c, w) => { ctx.strokeStyle = c;
                    ctx.lineWidth = w;
                    ctx.beginPath();
                    ctx.moveTo(x1, y1);
                    ctx.lineTo(x2, y2);
                    ctx.stroke(); };
                const rect = (x, y, w, h, c) => { ctx.fillStyle = c;
                    ctx.fillRect(x, y, w, h); };
                line(-13, 0, -3, -5, '#4a4a4a', 2);
                line(13, 0, 6, -11, '#b0b0b0', 2);
                rect(-15, -3, 10, 2, '#bdbdbd');
                rect(-6, -8, 10, 7, '#666');
                rect(-5, -7, 8, 1, '#999');
                rect(-5, -4, 8, 1, '#444');
                rect(-16, -7, 7, 2, '#e63946');
                rect(8, -9, 8, 2, '#e63946');
                line(-3, -5, 3, -12, '#e63946', 2);
                rect(-1, -13, 10, 5, '#e63946');
                rect(1, -12, 6, 1, '#ff8a92');
                rect(-12, -12, 11, 3, '#222');
                rect(12, -12, 2, 3, '#ffe66d');
                rect(5, -16, 5, 2, '#333');
                line(-6, -11, -3, -4, '#2a4d9b', 2);
                rect(-4, -4, 4, 2, '#222');
                ctx.fillStyle = '#2e86de';
                ctx.beginPath();
                ctx.moveTo(-10, -11);
                ctx.lineTo(-3, -11);
                ctx.lineTo(-1, -23);
                ctx.lineTo(-8, -23);
                ctx.closePath();
                ctx.fill();
                rect(-7, -19, 4, 3, '#1b5fa8');
                line(-3, -20, 6, -15, '#2e86de', 2);
                rect(4, -16, 3, 2, '#222');
                rect(-6, -30, 8, 8, '#f4f4f4');
                rect(-6, -30, 8, 2, '#e63946');
                rect(-2, -27, 5, 3, '#1a1a2e');
                rect(-5, -23, 3, 1, '#bbb');
                ctx.restore();
            }

            function drawParticles(cx, cy) {
                for (const p of particles) {
                    if (p.k === 'px') {
                        const age = p.t / p.life;
                        if (age > 0.7 && (frameCount & 1)) continue;
                        const ci = Math.min(7, p.ci + Math.floor(age * 6));
                        ctx.fillStyle = PAL[ci];
                        ctx.fillRect(Math.round(p.x - cx), Math.round(p.y - cy), p.s, p.s);
                    } else if (p.k === 'smoke') {
                        const age = p.t / p.life;
                        ctx.fillStyle = age < 0.5 ? '#9a9a9a' : '#c9c9c9';
                        if (age > 0.6 && (frameCount & 1)) continue;
                        const s = Math.round(p.s);
                        ctx.fillRect(Math.round(p.x - cx - s / 2), Math.round(p.y - cy - s / 2), s, s);
                    } else drawWheel(p.x - cx, p.y - cy, p.spin);
                }
                for (const r of rings) {
                    if (r.t < 0) continue;
                    const rad = r.t * 110;
                    const c = r.t < 0.10 ? '#ffffff' : r.t < 0.22 ? '#ffd23f' : r.t < 0.34 ? '#ff8c1a' : '#ff3d00';
                    ctx.fillStyle = c;
                    const n = Math.max(10, Math.round(rad * 1.0));
                    for (let i = 0; i < n; i++) {
                        const a = i / n * Math.PI * 2;
                        ctx.fillRect(Math.round(r.x + Math.cos(a) * rad - cx) - 1, Math.round(r.y + Math.sin(a) * rad * 0.8 -
                            cy) - 1, 3, 3);
                    }
                }
            }

            function drawHUD() {
                ctx.fillStyle = 'rgba(0,0,0,.45)';
                ctx.fillRect(6, 6, 80, 26);
                txt('TIME', 10, 8, '#9fe3ff', 'bold 8px monospace');
                const low = timeLeft <= 10;
                const tc = low ? ((Math.floor(elapsed * 4) % 2) ? '#ff4d4d' : '#ffb3b3') : '#fff';
                txt(timeLeft.toFixed(1), 10, 15, tc, 'bold 15px monospace');

                ctx.fillStyle = 'rgba(0,0,0,.45)';
                ctx.fillRect(W - 96, 6, 90, 26);
                txt('SCORE', W - 10, 8, '#9fe3ff', 'bold 8px monospace', 'right');
                txt(String(score).padStart(6, '0'), W - 10, 15, '#ffd23f', 'bold 15px monospace', 'right');

                const bx = 140,
                    bw = 200,
                    by = 12;
                ctx.fillStyle = 'rgba(0,0,0,.5)';
                ctx.fillRect(bx - 4, by - 5, bw + 12, 16);
                ctx.fillStyle = '#333';
                ctx.fillRect(bx, by, bw, 5);
                const mx = (bike.p[0].x + bike.p[1].x) / 2;
                const prog = Math.max(0, Math.min(1, mx / FINISH_X));
                ctx.fillStyle = '#5ec44a';
                ctx.fillRect(bx, by, Math.round(bw * prog), 5);
                for (const cp of checkpoints) {
                    const tx = bx + Math.round(bw * cp.x / FINISH_X);
                    ctx.fillStyle = cp.passed ? '#7CFC6B' : '#ddd';
                    ctx.fillRect(tx, by - 2, 2, 9);
                }
                for (let i = 0; i < 2; i++)
                    for (let j = 0; j < 3; j++) { ctx.fillStyle = (i + j) % 2 ? '#111' : '#fff';
                        ctx.fillRect(bx + bw + 1 + i * 3, by - 2 + j * 3, 3, 3); }
                ctx.fillStyle = '#e63946';
                ctx.fillRect(bx + Math.round(bw * prog) - 2, by - 3, 5, 11);
                ctx.fillStyle = '#fff';
                ctx.fillRect(bx + Math.round(bw * prog) - 1, by - 2, 3, 3);

                txt('STUNTS ' + stuntCount + '   CRASHES ' + crashes, W - 10, 36, '#fff', 'bold 8px monospace', 'right');
                txt('CP ' + (lastCp > 60 ? checkpoints.findIndex(c => c.x === lastCp) + 1 : '-') + '/' + checkpoints.length,
                    10, 36, '#fff', 'bold 8px monospace');

                let i = 0;
                for (const p of popups) {
                    const age = p.t / p.life;
                    if (age > 0.75 && (frameCount & 2)) continue;
                    const y = 80 - p.t * 16 + i * 14;
                    txt(p.text, W / 2, y, p.color, p.big ? 'bold 13px monospace' : 'bold 10px monospace', 'center');
                    i++;
                }

                if (!hintSeen && elapsed < 7 && state === 'play') {
                    ctx.fillStyle = 'rgba(0,0,0,.55)';
                    ctx.fillRect(0, H - 22, W, 22);
                    txt('↑ GAS   ↓ BRAKE   ← → LEAN   ␣ HOP   R RESTART', W / 2, H - 16, '#fff', 'bold 9px monospace',
                        'center');
                }
                if (state === 'crash') {
                    txt('RESPAWNING…', W / 2, H - 30, '#ffb3b3', 'bold 10px monospace', 'center');
                }
            }

            function render() {
                let cx = Math.round(camX),
                    cy = Math.round(camY);
                if (shake > 0) { cx += Math.round((Math.random() - 0.5) * shake * 8);
                    cy += Math.round((Math.random() - 0.5) * shake * 8); }
                drawSky(cx, cy);
                drawGround(cx, cy);
                drawCheckpoints(cx, cy);
                drawObstacles(cx, cy);
                if (state !== 'crash') drawBike(cx, cy);
                drawParticles(cx, cy);
                if (flash > 0) { ctx.fillStyle = 'rgba(255,255,255,' + Math.min(1, flash * 6) + ')';
                    ctx.fillRect(0, 0, W, H); }
                if (state !== 'menu') drawHUD();
            }

            /* ============================================================
               MAIN LOOP
               ============================================================ */
            let last = performance.now(),
                acc = 0;
            const STEP = 1 / 120;

            function frame(now) {
                let dt = (now - last) / 1000;
                last = now;
                if (dt > 0.1) dt = 0.1;
                frameCount++;

                if (state === 'play') {
                    acc += dt;
                    while (acc >= STEP) { step(STEP);
                        acc -= STEP; if (state !== 'play') { acc = 0; break; } }
                    if (state === 'play') afterPhysics(dt);
                } else if (state === 'crash') {
                    crashT -= dt;
                    if (crashT <= 0) {
                        if (timeLeft <= 0) gameOver();
                        else { spawn(lastCp);
                            state = 'play'; }
                    }
                }

                updateParticles(dt);
                updatePopups(dt);
                updateCamera(dt);
                elapsed += dt;
                shake = Math.max(0, shake - dt * 2);
                flash = Math.max(0, flash - dt);
                render();
                updEngine();
                requestAnimationFrame(frame);
            }

            requestAnimationFrame(frame);

        })();
    </script>
</body>
</html>

Game Source: Pixel Moto Stunts

Creator: MysticKoala95

Libraries: none

Complexity: complex (1726 lines, 69.8 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: pixel-moto-stunts-mystickoala95" to link back to the original. Then publish at arcadelab.ai/publish.