How It Works

The FPS game is built around four core systems working together: PointerLockControls for immersive mouse-look on desktop, touch controls (virtual joystick + fire button) for mobile, a projectile system that fires spheres along the camera's forward vector, raycaster collision detection between projectiles and enemy cubes, and a layered explosion system that bursts triangle shrapnel plus rockfire-style sparks, a flash, and a shockwave ring on impact.

The game detects whether you're on a phone or PC and adapts its controls accordingly. On desktop, clicking "Play Now" locks the pointer for mouse-look and WASD movement. On mobile, the game requires landscape orientation and provides a virtual joystick (bottom-left) for movement, a fire button (bottom-right) for shooting, and touch-drag anywhere else for aiming — replicating the same Euler+quaternion camera rotation that PointerLockControls uses internally.

Mobile Touch Controls

The mobile control system detects the device type on load and swaps the entire input layer. Instead of pointer lock (which doesn't work on mobile browsers), we create HTML overlay elements positioned with position: fixed and handle all input through touch events.

Virtual Joystick: A 120px circle in the bottom-left with a draggable knob. Touch events track the delta from center, normalize it to -1/+1, and feed it into the same movement logic as WASD keys. The threshold is 20% — you need to drag at least 20% of the max distance before movement registers, preventing accidental drift.

Fire Button: An 80px red circle in the bottom-right. A simple touchstart listener calls the same createParticle() function used by mouse click on desktop.

Touch Look (Aiming): Any touch that isn't on the joystick or fire button becomes a look input. The critical insight is using the same Euler+quaternion method that PointerLockControls uses internally — extract an Euler with 'YXZ' order from the camera quaternion, modify yaw (Y) and pitch (X), clamp pitch to ±90°, then set back. This prevents the gimbal lock and camera flipping that happens if you modify camera.rotation directly.

// The correct way to handle touch look (matches PointerLockControls):
let _euler = new THREE.Euler(0, 0, 0, 'YXZ');
_euler.setFromQuaternion(camera.quaternion);
_euler.y -= dx * 0.004;  // Horizontal drag = yaw
_euler.x -= dy * 0.004;  // Vertical drag = pitch
_euler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, _euler.x));
camera.quaternion.setFromEuler(_euler);

Scene & Renderer Setup

The renderer uses logarithmicDepthBuffer: true to prevent z-fighting on overlapping geometry, and Reinhard tone mapping for balanced lighting. It's positioned as a fixed background layer with z-index: -1.

let scene = new THREE.Scene();
let camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 4000);
camera.position.set(9, 0.75, 3);

let renderer = new THREE.WebGLRenderer({
    alpha: true,
    depth: true,
    logarithmicDepthBuffer: true
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.setClearColor(0x000000, 1);
renderer.domElement.style.position = 'fixed';
renderer.domElement.style.zIndex = '-1';
document.body.appendChild(renderer.domElement);

PointerLockControls & Movement

PointerLockControls captures the mouse cursor and converts mouse movement into camera rotation. When locked, WASD keys move the player with acceleration and deceleration for smooth feel. Boundary collision prevents walking off the grid.

let controls = new PointerLockControls(camera, document.body);

// Movement with acceleration -- all values are per-SECOND and scaled by delta
// (the real frame time from clock.getDelta()). No fixed-frame fudge factors,
// so the speed is identical at any frame rate.
if (controls.isLocked) {
    const MOVE_ACCEL = 11;      // acceleration, units/sec^2
    const MAX_MOVE_SPEED = 6.6; // top speed, units/sec

    if (moveForward || moveBackward || moveLeft || moveRight) {
        controls.speed = Math.min(controls.speed + MOVE_ACCEL * delta, MAX_MOVE_SPEED);
    } else {
        controls.speed *= Math.exp(-12 * delta); // frame-rate independent decay
    }

    const moveAmount = controls.speed * delta;

    if (moveForward) {
        controls.moveForward(moveAmount);
        if (checkCollision(controls.getObject().position))
            controls.moveForward(-moveAmount); // undo if hitting boundary
    }
}

The collision check is simple — if the player's position exceeds the grid bounds (plus or minus 9.9 units), the movement is reversed. This "move then undo" pattern is common in FPS games because it handles all edge cases cleanly.

Projectile System

Projectiles are small spheres spawned at the camera position and given a velocity in the camera's forward direction. They travel in a straight line each frame and are removed when they exceed a distance threshold.

// NOTE: firing is "hitscan" -- the flying sphere is purely a VISUAL
// projectile. The actual hit is decided in checkParticleCollision()
// by casting a ray from the sphere each frame.
function createParticle() {
    playLaserSound();
    const geometry = new THREE.SphereGeometry(0.05, 16, 16);
    const material = new THREE.MeshBasicMaterial({ color: 0xADD8E6 });
    const particle = new THREE.Mesh(geometry, material);

    particle.position.copy(camera.position);
    particle.initialDirection = camera.getWorldDirection(new THREE.Vector3());
    // Velocity in units-per-SECOND; scaled by delta each frame so the bullet
    // travels the same distance regardless of frame rate.
    particle.velocity = particle.initialDirection.clone().multiplyScalar(PARTICLE_SPEED);

    scene.add(particle);
    particles.push(particle);
}

// delta = seconds since last frame (from clock.getDelta() in the loop)
function updateParticles(delta) {
    for (let i = particles.length - 1; i >= 0; i--) {
        particles[i].position.add(particles[i].velocity.clone().multiplyScalar(delta));
        if (particles[i].position.distanceTo(camera.position) > 20) {
            scene.remove(particles[i]);
            particles.splice(i, 1);
        }
    }
}

Firing is triggered by left mouse click or spacebar. The key insight is using camera.getWorldDirection() to get the exact direction the player is looking, then multiplying by a speed scalar for the velocity vector.

Collision Detection

Hit detection uses a Raycaster cast from each projectile in its travel direction. If the ray intersects an enemy cube, the cube explodes and teleports to a new random position with a new color.

function checkParticleCollision() {
    for (let j = 0; j < cubes.length; j++) {
        let cube = cubes[j];
        for (let i = 0; i < particles.length; i++) {
            let particle = particles[i];
            let particleEdge = particle.position.clone()
                .add(particle.velocity.clone().normalize().multiplyScalar(0.1));

            raycaster.set(particle.position,
                particleEdge.sub(particle.position).normalize());

            let intersects = raycaster.intersectObject(cube);
            if (intersects.length > 0) {
                explosion(cube);
                moveCubeRandomly(cube);
                break;
            }
        }
    }
}

The raycaster is set from the projectile's current position toward a point slightly ahead (0.1 units in the velocity direction). This short-range ray catches collisions even at high projectile speeds where frame-by-frame position checks might miss.

Explosion Particle System

When a projectile hits a cube, 50 triangle shrapnel pieces spawn at the cube's position. Each triangle gets a random direction, speed, rotation axis, and rotation speed — creating a satisfying burst effect. Triangles are removed after traveling 3 units.

On top of that shrapnel we layer three rockfire-style glow effects, all spawned at the same hit point: a warm cloud of additive sparks that fly out and fall under gravity, a bright flash (a short point light plus an additive sphere that scales up and fades), and a flat shockwave ring that expands across the floor and fades. Every effect is tracked in its own array and pruned the instant it finishes, so firing again and again never piles up stray objects.

function explosion(cube) {
    playExplosionSound();
    for (let i = 0; i < 50; i++) {
        let triangle = createTriangle(cube);
        scene.add(triangle);
        triangles.push(triangle);
        triangle.userData = {
            direction: new THREE.Vector3(
                Math.random() * 2 - 1,
                Math.random() * 2 - 1,
                Math.random() * 2 - 1
            ).normalize(),
            speed: Math.random() * 0.1 + 0.02,
            rotationAxis: new THREE.Vector3(
                Math.random(), Math.random(), Math.random()
            ).normalize(),
            rotationSpeed: Math.random() * 0.1 + 0.005,
            distance: 0
        };
    }

    // Rockfire-style glow added on top of the shrapnel
    spawnSparks(cube.position);    // warm additive THREE.Points cloud
    spawnFlash(cube.position);     // point light + fading flash sphere
    spawnShockwave(cube.position); // expanding additive ring on the floor
}

// A soft radial-gradient sprite, built once and reused by the sparks.
function getSparkTexture() {
    if (sparkTexture) return sparkTexture;
    let c = document.createElement('canvas');
    c.width = c.height = 64;
    let g = c.getContext('2d');
    let grd = g.createRadialGradient(32, 32, 1, 32, 32, 32);
    grd.addColorStop(0, 'rgba(255,245,210,1)');
    grd.addColorStop(0.4, 'rgba(255,150,40,0.55)');
    grd.addColorStop(1, 'rgba(0,0,0,0)');
    g.fillStyle = grd; g.fillRect(0, 0, 64, 64);
    sparkTexture = new THREE.CanvasTexture(c);
    sparkTexture.colorSpace = THREE.SRGBColorSpace;
    return sparkTexture;
}

// SPARKS: outward velocity + gravity, fading per-vertex color by k.
function spawnSparks(position) {
    let count = 50, positions = new Float32Array(count * 3);
    let colors = new Float32Array(count * 3), velocities = [];
    for (let i = 0; i < count; i++) {
        positions[i*3] = position.x; positions[i*3+1] = position.y; positions[i*3+2] = position.z;
        colors[i*3] = 1.0; colors[i*3+1] = 0.7 + Math.random()*0.3; colors[i*3+2] = 0.25;
        let dir = new THREE.Vector3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1).normalize();
        velocities.push(dir.multiplyScalar(2 + Math.random()*3));
    }
    let geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
    let mat = new THREE.PointsMaterial({
        size: 0.35, map: getSparkTexture(), vertexColors: true,
        blending: THREE.AdditiveBlending, transparent: true, depthWrite: false
    });
    let points = new THREE.Points(geo, mat);
    scene.add(points);
    sparkSystems.push({ points: points, velocities: velocities,
        baseColors: colors.slice(), age: 0, life: 0.6 + Math.random()*0.4 });
}

Each triangle inherits the cube's color, faces the camera on spawn, and gets a random scale between 0.5x and 3.5x. The rotateOnWorldAxis call each frame gives each piece a unique tumbling motion as it flies outward.

Each triangle inherits the cube's color, faces the camera on spawn, and gets a random scale. The rotateOnWorldAxis call each frame gives each piece a unique tumble as it flies outward. The three glow effects below are what turn that burst into a real explosion — read the bold line for the idea, the gray note for the why.

  1. Sparks — the flying heat. A cloud of ~50 glowing dots shoots out of the hit point, arcs under gravity, and burns out. Developer note: it is a single THREE.Points drawn in one call, not 50 meshes. All dots share ONE soft radial-gradient sprite built once in getSparkTexture(), use AdditiveBlending so overlaps glow hotter, and fade by multiplying each vertex color by k (1 → 0 over its life) — so there is no per-particle opacity to juggle.
  2. Flash — the bang you feel. A bright warm light snaps on at the blast center and a small glowing sphere balloons out and vanishes in a fifth of a second. Developer note: a PointLight removed via setTimeout after 300ms reads as a flash, not a lamp; the additive sphere scales up while its opacity rides k toward 0, so it blooms then disappears.
  3. Shockwave — the ground punch. A ring races outward flat across the floor and fades, selling the force of the hit. Developer note: a RingGeometry laid flat with rotation.x = -Math.PI/2, lifted 0.05 off the grid to dodge z-fighting, scaled up and faded over half a second.

Why it stays fast: each effect lives in its own array (sparkSystems, flashes, shockwaves) and its update* function in the render loop ages it, then scene.remove()s and dispose()s it the instant it finishes — so you can fire again and again and nothing piles up to drag the frame rate down.

Enemy AI & Orbit Camera

Enemy cubes patrol horizontally with simple back-and-forth movement. They check for collisions with other cubes and reverse direction on contact or when hitting boundaries. This creates unpredictable movement patterns from very simple logic.

When the pointer isn't locked, the camera smoothly orbits a center point using sine/cosine on the elapsed time. This gives the game a polished "attract mode" that shows off the scene before the player engages.

// Orbit camera when not playing
function orbitCameraAroundPoint() {
    const angle = Date.now() * 0.000025;
    const x = center.x + radius * Math.cos(angle);
    const z = center.z + radius * Math.sin(angle);
    camera.position.set(x, center.y, z);
    camera.lookAt(center);
}

Audio System

The game uses the Web Audio API for three sound channels: background music (looped with fade-in), laser fire sounds, and explosion sounds. Each has independent volume control. Music toggles with the M key, and audio context is created on first user interaction to comply with browser autoplay policies.

Sound buffers are loaded once and reused — the laser and explosion sounds create new BufferSource nodes each time they play, which is the correct pattern for overlapping sound effects.

Key Takeaways

COMPLETE SOURCE CODE

Copy this code into an HTML file and open it in your browser to see it in action:

HTML / Three.js
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js First Person Shooter Game Starter</title>
    <style>
        /* Load a custom font for the play button */
        @font-face {
            font-family: 'Robus-BWqOd';
            src: url('https://www.shanebrumback.com/fonts/Robus-BWqOd.otf') format('opentype');
        }

        /* Remove default page margins so the game fills the whole screen */
        body { margin: 0; }

        /* Make the 3D canvas fill its container */
        canvas { display: block; }

        /* Semi-transparent dark overlay that covers the screen when paused */
        #blocker {
            position: fixed; width: 100%; height: 100%;
            background-color: rgba(0, 0, 0, 0.5);
        }

        /* Center the instructions/play button in the middle of the screen */
        #instructions {
            position: absolute; top: 50%; left: 50%;
            transform: translate(-50%, -50%); color: white;
        }

        /* Crosshair image — centered on screen, hidden until the game starts */
        #crosshair {
            position: fixed; top: 50%; left: 50%;
            transform: translate(-50%, -50%);
            width: 100px; height: 100px; display: none;
        }

        /* Style the "Play Now" button with the custom font */
        #playButton {
            font-family: 'Robus-BWqOd'; font-size: 5vw;
            color: white; text-shadow: 2px 2px 4px rgba(0,0,0,0.75);
            position: absolute; top: 50%; left: 50%;
            transform: translate(-50%, -50%); white-space: nowrap;
        }

        /* Default paragraph styling for the control instructions */
        p { font-family: Arial; font-size: medium; text-align: center; }

        /* On mobile screens, make the play button and text bigger */
        @media (max-width: 900px) {
            #playButton { font-size: 15vw; }
            p { font-size: 4vw; }
        }
    </style>
</head>
<body>

    <!-- BLOCKER: This dark overlay is shown when the game is paused -->
    <div id="blocker">
        <div id="instructions">
            <div id="playButton">
                Play Now
                <!-- Control instructions shown below the play button -->
                <p>
                    ESC - Menu / Show Cursor<br>
                    WASD or ARROWS - Move<br>
                    SHIFT - Run (hold to sprint)<br>
                    LEFT MOUSE - Fire<br>
                    SPACEBAR - Fire<br>
                    M - Play / Pause Music
                </p>
            </div>
        </div>
    </div>

    <!-- CROSSHAIR: Aiming reticle shown in the center of the screen during gameplay -->
    <img id="crosshair" src="https://www.shanebrumback.com/images/reticle.png" alt="Crosshair">

    <!-- LIBRARIES: Load Three.js (3D engine) and PointerLockControls (FPS camera) from CDN -->
    <script src="https://cdn.jsdelivr.net/npm/three@latest/build/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@latest/examples/js/controls/PointerLockControls.js"></script>

    <script>

        // ============================================================
        // MOBILE DETECTION
        // Check if the user is on a phone/tablet so we can show
        // touch controls instead of mouse/keyboard controls.
        // ============================================================
        let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
        let mobileActive = false; // Tracks if mobile game mode is running

        // ============================================================
        // SCENE SETUP
        // The scene is the container that holds all 3D objects.
        // The camera is the player's "eyes" — it defines what you see.
        // ============================================================
        let scene = new THREE.Scene();
        let camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(9, 1.6, 3);

        // ============================================================
        // RENDERER SETUP
        // The renderer draws the 3D scene onto a 2D canvas element.
        // ============================================================
        let renderer = new THREE.WebGLRenderer({ alpha: true, depth: true });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.toneMapping = THREE.ReinhardToneMapping;
        renderer.setClearColor(0x000000, 1);
        renderer.domElement.style.position = 'fixed';
        renderer.domElement.style.zIndex = '-1';
        renderer.domElement.style.left = '0';
        renderer.domElement.style.top = '0';
        document.body.appendChild(renderer.domElement);

        // ============================================================
        // GAME VARIABLES
        // ============================================================
        let raycaster = new THREE.Raycaster();
        let mouse = new THREE.Vector2();
        let particles = [];   // Active bullet projectiles
        let triangles = [];   // Active explosion pieces (triangle shrapnel)
        let sparkSystems = []; // Active additive spark clouds
        let flashes = [];      // Active flash spheres
        let shockwaves = [];   // Active expanding rings
        let sparkTexture = null; // Soft radial sprite, built once & reused
        let cubes = [];       // Enemy cubes

        // Frame-rate independent timing: measure real seconds per frame instead
        // of assuming a fixed step, so the game runs at the same speed on every
        // computer regardless of frame rate.
        let clock = new THREE.Clock();
        // Projectile travel in units-per-SECOND (old 0.25/frame at ~60fps => 15).
        let PARTICLE_SPEED = 15;

        // ============================================================
        // POINTER LOCK CONTROLS (Desktop FPS camera)
        // ============================================================
        let controls = new THREE.PointerLockControls(camera, document.body);

        // ============================================================
        // GRID (THE FLOOR)
        // ============================================================
        let gridHelper = new THREE.GridHelper(20, 20);
        gridHelper.material.color.set(0xffffff);
        scene.add(gridHelper);

        // ============================================================
        // ENEMY CUBES — 5 colored cubes that move back and forth
        // ============================================================
        let colors = [0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff, 0xffa500];
        let cube; // declared outside the loop so camera.lookAt(cube...) below can see it
        for (let i = 0; i < 5; i++) {
            let geometry = new THREE.BoxGeometry(1, 1, 1);
            let material = new THREE.MeshBasicMaterial({
                color: colors[Math.floor(Math.random() * colors.length)]
            });
            cube = new THREE.Mesh(geometry, material);
            cube.position.set(Math.random() * 16 - 8, 0.5, Math.random() * 16 - 8);
            cube.userData.speed = 1 + Math.random() * 2;
            cube.userData.direction = Math.random() > 0.5 ? 1 : -1;
            scene.add(cube);
            cubes.push(cube);
        }

        // ============================================================
        // FIRE FOUNTAIN — decorative particle effect in the center
        // ============================================================
        let fireParticles = [];
        let fireSpawnAccumulator = 0; // carries fractional spawn counts between frames
        let fireGroup = new THREE.Group();
        fireGroup.position.set(0, 0, 0);
        scene.add(fireGroup);

        function createFireParticle() {
            let geo = new THREE.SphereGeometry(0.05 + Math.random() * 0.05, 8, 8);
            let mat = new THREE.MeshBasicMaterial({
                color: new THREE.Color().setHSL(0.05 + Math.random() * 0.08, 1, 0.5)
            });
            let p = new THREE.Mesh(geo, mat);
            p.position.set((Math.random() - 0.5) * 0.3, 0, (Math.random() - 0.5) * 0.3);
            // Velocity in units-per-SECOND (applied as velocity * delta each frame).
            p.userData.velocity = new THREE.Vector3(
                (Math.random() - 0.5) * 1.2, 3 + Math.random() * 3, (Math.random() - 0.5) * 1.2
            );
            p.userData.life = 1.0;
            fireGroup.add(p);
            fireParticles.push(p);
        }

        function updateFire(delta) {
            // Everything is in per-SECOND units scaled by delta (clock.getDelta())
            // so the fountain looks the same at any frame rate.
            fireSpawnAccumulator += 180 * delta; // ~180 particles/sec
            while (fireSpawnAccumulator >= 1) { createFireParticle(); fireSpawnAccumulator -= 1; }
            for (let i = fireParticles.length - 1; i >= 0; i--) {
                let p = fireParticles[i];
                p.position.add(p.userData.velocity.clone().multiplyScalar(delta));
                p.userData.velocity.y -= 3.6 * delta; // gravity, units/sec^2
                p.userData.life -= 1.2 * delta;        // fade ~1.2/sec (lasts ~0.83s)
                p.material.opacity = p.userData.life;
                p.material.transparent = true;
                let s = p.userData.life * 0.8;
                p.scale.set(s, s, s);
                if (p.userData.life <= 0) {
                    fireGroup.remove(p);
                    fireParticles.splice(i, 1);
                }
            }
        }

        camera.lookAt(cube.position);

        // ============================================================
        // UI SETUP — PLAY BUTTON & POINTER LOCK
        // On desktop: clicking Play locks the pointer for FPS controls
        // On mobile: tapping Play in landscape shows touch controls
        // ============================================================
        let blocker = document.getElementById('blocker');
        let instructions = document.getElementById('instructions');
        let playButton = document.getElementById('playButton');

        if (isMobile) {
            // Mobile: change button text, require landscape
            playButton.textContent = 'Tap to Play (Landscape)';
            playButton.addEventListener('touchstart', function (e) {
                e.preventDefault();
                if (window.innerWidth > window.innerHeight) {
                    // Landscape — start mobile game
                    mobileActive = true;
                    blocker.style.display = 'none';
                    instructions.style.display = 'none';
                    document.getElementById('crosshair').style.display = 'block';
                    showMobileUI();
                } else {
                    playButton.textContent = 'Rotate to Landscape';
                    setTimeout(function() { playButton.textContent = 'Tap to Play (Landscape)'; }, 2000);
                }
            });
        } else {
            // Desktop: pointer lock
            playButton.addEventListener('click', function () { controls.lock(); });
        }

        controls.addEventListener('lock', function () {
            instructions.style.display = 'none';
            blocker.style.display = 'none';
            document.getElementById('crosshair').style.display = 'block';
        });

        controls.addEventListener('unlock', function () {
            blocker.style.display = 'block';
            instructions.style.display = '';
            document.getElementById('crosshair').style.display = 'none';
        });

        scene.add(controls.getObject());

        // ============================================================
        // KEYBOARD MOVEMENT (Desktop)
        // ============================================================
        let moveForward = false, moveBackward = false, moveLeft = false, moveRight = false;
        let running = false; // true while Shift is held (sprint)

        document.addEventListener('keydown', function (event) {
            if (event.keyCode === 16) running = true; // Shift
            switch (event.keyCode) {
                case 38: case 87: moveForward = true; break;
                case 37: case 65: moveLeft = true; break;
                case 40: case 83: moveBackward = true; break;
                case 39: case 68: moveRight = true; break;
            }
        });

        document.addEventListener('keyup', function (event) {
            if (event.keyCode === 16) running = false; // Shift
            switch (event.keyCode) {
                case 38: case 87: moveForward = false; break;
                case 37: case 65: moveLeft = false; break;
                case 40: case 83: moveBackward = false; break;
                case 39: case 68: moveRight = false; break;
            }
        });

        // ============================================================
        // BOUNDARY COLLISION — keeps player inside the grid
        // ============================================================
        function checkCollision(position) {
            let margin = 0.1;
            return (
                position.x < -9.9 + margin || position.x > 9.9 - margin ||
                position.z < -9.9 + margin || position.z > 9.9 - margin
            );
        }

        // ============================================================
        // MOBILE TOUCH CONTROLS
        // Left side: virtual joystick for movement (drag to move)
        // Right side: fire button (tap to shoot)
        // Touch drag anywhere else: camera look (aim)
        // ============================================================
        let joystickData = { active: false, startX: 0, startY: 0, moveX: 0, moveY: 0 };
        let lookData = { active: false, lastX: 0, lastY: 0, touchId: null };

        // Creates and shows the mobile UI elements (joystick + fire button)
        function showMobileUI() {
            if (document.getElementById('mobile-joystick')) {
                document.getElementById('mobile-joystick').style.display = 'flex';
                document.getElementById('mobile-fire-btn').style.display = 'flex';
                return;
            }

            // Virtual joystick — bottom left corner
            let joystick = document.createElement('div');
            joystick.id = 'mobile-joystick';
            joystick.style.cssText = 'position:fixed;bottom:20px;left:20px;width:120px;height:120px;border-radius:50%;background:rgba(255,255,255,0.1);border:2px solid rgba(42,209,214,0.5);z-index:100;touch-action:none;display:flex;align-items:center;justify-content:center;';
            joystick.innerHTML = '<div id="joystick-knob" style="width:50px;height:50px;border-radius:50%;background:rgba(42,209,214,0.6);pointer-events:none;"></div>';
            document.body.appendChild(joystick);

            // Fire button — bottom right corner
            let fireBtn = document.createElement('div');
            fireBtn.id = 'mobile-fire-btn';
            fireBtn.style.cssText = 'position:fixed;bottom:30px;right:30px;width:80px;height:80px;border-radius:50%;background:rgba(255,50,50,0.7);border:3px solid rgba(255,100,100,0.8);z-index:100;touch-action:none;display:flex;align-items:center;justify-content:center;font-family:Arial;font-weight:bold;font-size:14px;color:#fff;user-select:none;';
            fireBtn.textContent = 'FIRE';
            document.body.appendChild(fireBtn);

            // Joystick touch events
            joystick.addEventListener('touchstart', function(e) {
                e.preventDefault(); e.stopPropagation();
                let rect = joystick.getBoundingClientRect();
                joystickData.active = true;
                joystickData.startX = rect.left + rect.width / 2;
                joystickData.startY = rect.top + rect.height / 2;
            }, { passive: false });

            joystick.addEventListener('touchmove', function(e) {
                e.preventDefault(); e.stopPropagation();
                if (!joystickData.active) return;
                let touch = e.touches[0];
                let dx = touch.clientX - joystickData.startX;
                let dy = touch.clientY - joystickData.startY;
                let maxDist = 40;
                let dist = Math.sqrt(dx * dx + dy * dy);
                if (dist > maxDist) { dx = dx / dist * maxDist; dy = dy / dist * maxDist; }
                joystickData.moveX = dx / maxDist; // -1 to 1 (left/right)
                joystickData.moveY = dy / maxDist; // -1 to 1 (up/down)
                let knob = document.getElementById('joystick-knob');
                if (knob) knob.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
            }, { passive: false });

            joystick.addEventListener('touchend', function(e) {
                e.preventDefault(); e.stopPropagation();
                joystickData.active = false;
                joystickData.moveX = 0; joystickData.moveY = 0;
                let knob = document.getElementById('joystick-knob');
                if (knob) knob.style.transform = 'translate(0,0)';
            }, { passive: false });

            // Fire button — tap to shoot
            fireBtn.addEventListener('touchstart', function(e) {
                e.preventDefault();
                if (mobileActive) createParticle();
            }, { passive: false });

            // Camera look — touch drag on the rest of the screen
            document.addEventListener('touchstart', function(e) {
                if (e.target.closest('#mobile-joystick') || e.target.closest('#mobile-fire-btn')) return;
                if (!mobileActive) return;
                let touch = e.changedTouches[0];
                lookData.active = true;
                lookData.lastX = touch.clientX;
                lookData.lastY = touch.clientY;
                lookData.touchId = touch.identifier;
            }, { passive: false });

            document.addEventListener('touchmove', function(e) {
                if (!lookData.active || !mobileActive) return;
                for (let i = 0; i < e.changedTouches.length; i++) {
                    let touch = e.changedTouches[i];
                    if (touch.identifier === lookData.touchId) {
                        let dx = touch.clientX - lookData.lastX;
                        let dy = touch.clientY - lookData.lastY;
                        lookData.lastX = touch.clientX;
                        lookData.lastY = touch.clientY;
                        // Replicate PointerLockControls mouse look exactly:
                        // Extract Euler (YXZ order) from camera, modify yaw+pitch, set back
                        let _euler = new THREE.Euler(0, 0, 0, 'YXZ');
                        _euler.setFromQuaternion(camera.quaternion);
                        _euler.y -= dx * 0.004;
                        _euler.x -= dy * 0.004;
                        // Clamp pitch so you can't look upside down
                        _euler.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, _euler.x));
                        camera.quaternion.setFromEuler(_euler);
                        break;
                    }
                }
            }, { passive: false });

            document.addEventListener('touchend', function(e) {
                for (let i = 0; i < e.changedTouches.length; i++) {
                    if (e.changedTouches[i].identifier === lookData.touchId) {
                        lookData.active = false; break;
                    }
                }
            }, { passive: false });
        }

        // Pause mobile game if user rotates back to portrait
        window.addEventListener('resize', function() {
            if (isMobile && mobileActive && window.innerHeight > window.innerWidth) {
                mobileActive = false;
                blocker.style.display = 'block';
                instructions.style.display = '';
                document.getElementById('crosshair').style.display = 'none';
                let j = document.getElementById('mobile-joystick');
                let f = document.getElementById('mobile-fire-btn');
                if (j) j.style.display = 'none';
                if (f) f.style.display = 'none';
            }
        });

        // ============================================================
        // MAIN GAME LOOP — runs ~60 times per second
        // Handles movement (keyboard + joystick), bullets, explosions
        // ============================================================
        function animate() {
            requestAnimationFrame(animate);

            // Real seconds since the last frame — everything that moves is
            // scaled by this so the game runs the same speed on every machine.
            const frameDelta = clock.getDelta();

            updateParticles(frameDelta);
            checkParticleCollision();
            updateFire(frameDelta);

            // Move enemy cubes back and forth (0.02/frame at the old ~60fps => 1.2/sec)
            cubes.forEach(function(c) {
                c.position.x += 1.2 * (c.userData.speed || 2) * (c.userData.direction || 1) * frameDelta;
                if (c.position.x > 9 || c.position.x < -9) c.userData.direction *= -1;
            });

            // Player movement — works with both keyboard AND joystick
            if (controls.isLocked || mobileActive) {
                // Movement speed in units-per-SECOND, scaled by delta (frame-rate independent).
                let step = 1.92 * frameDelta;
                if (running) step *= 1.8; // hold Shift to sprint

                // Read joystick input (0 if not touching)
                let joyFwd = joystickData.moveY < -0.2;
                let joyBack = joystickData.moveY > 0.2;
                let joyLeft = joystickData.moveX < -0.2;
                let joyRight = joystickData.moveX > 0.2;

                if (moveForward || joyFwd) {
                    controls.moveForward(step);
                    if (checkCollision(controls.getObject().position)) controls.moveForward(-step);
                }
                if (moveBackward || joyBack) {
                    controls.moveForward(-step);
                    if (checkCollision(controls.getObject().position)) controls.moveForward(step);
                }
                if (moveLeft || joyLeft) {
                    controls.moveRight(-step);
                    if (checkCollision(controls.getObject().position)) controls.moveRight(step);
                }
                if (moveRight || joyRight) {
                    controls.moveRight(step);
                    if (checkCollision(controls.getObject().position)) controls.moveRight(-step);
                }
            }

            updateTriangles(frameDelta);
            updateSparks(frameDelta);
            updateFlashes(frameDelta);
            updateShockwaves(frameDelta);
            renderer.render(scene, camera);
        }
        animate();

        // ============================================================
        // PROJECTILE SYSTEM — fires light-blue spheres
        // ============================================================
        // NOTE: firing is "hitscan" — this flying sphere is purely a VISUAL
        // projectile. The actual hit is decided in checkParticleCollision() by
        // casting a ray from the sphere. A production game would either give the
        // projectile real physics/collision or hit instantly along the aim ray.
        function createParticle() {
            playLaserSound();
            let geo = new THREE.SphereGeometry(0.05, 16, 16);
            let mat = new THREE.MeshBasicMaterial({ color: 0xADD8E6 });
            let particle = new THREE.Mesh(geo, mat);
            particle.position.copy(camera.position);
            particle.initialDirection = camera.getWorldDirection(new THREE.Vector3());
            // Velocity in units-per-SECOND, scaled by delta each frame.
            particle.velocity = particle.initialDirection.clone().multiplyScalar(PARTICLE_SPEED);
            scene.add(particle);
            particles.push(particle);
        }

        function updateParticles(delta) {
            for (let i = particles.length - 1; i >= 0; i--) {
                particles[i].position.add(particles[i].velocity.clone().multiplyScalar(delta));
                if (particles[i].position.distanceTo(camera.position) > 20) {
                    scene.remove(particles[i]);
                    particles.splice(i, 1);
                }
            }
        }

        // Desktop firing: mouse click and spacebar
        document.addEventListener('mousedown', function (event) {
            if (controls.isLocked && event.button === 0) createParticle();
        });
        document.addEventListener('keydown', function (event) {
            if (event.key === ' ' && controls.isLocked) { event.preventDefault(); createParticle(); }
            if (event.key === 'm' || event.key === 'M') playMusic();
        });

        // ============================================================
        // COLLISION DETECTION — raycaster checks bullet vs cube hits
        // ============================================================
        function checkParticleCollision() {
            for (let j = 0; j < cubes.length; j++) {
                let cube = cubes[j];
                if (!cube.visible) continue;
                for (let i = 0; i < particles.length; i++) {
                    let p = particles[i];
                    let edge = p.position.clone().add(p.velocity.clone().normalize().multiplyScalar(0.1));
                    raycaster.set(p.position, edge.sub(p.position).normalize());
                    if (raycaster.intersectObject(cube).length > 0) {
                        explosion(cube);
                        moveCubeRandomly(cube);
                        break;
                    }
                }
            }
        }

        function moveCubeRandomly(cube) {
            cube.position.x = Math.floor(Math.random() * 20) - 10;
            cube.position.z = Math.floor(Math.random() * 20) - 10;
            cube.material.color.set(colors[Math.floor(Math.random() * colors.length)]);
        }

        // ============================================================
        // EXPLOSION SYSTEM — 50 triangles burst outward on hit
        // ============================================================
        function explosion(cube) {
            playExplosionSound();
            for (let i = 0; i < 50; i++) {
                let triangle = createTriangle(cube);
                scene.add(triangle);
                triangles.push(triangle);
                // speed/rotationSpeed are in units-per-SECOND; updateTriangles
                // scales them by delta (clock.getDelta()) so they stay FPS-independent.
                triangle.userData = {
                    direction: new THREE.Vector3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1).normalize(),
                    speed: Math.random() * 3 + 0.6,        // ~0.6 to 3.6 units/sec
                    rotationAxis: new THREE.Vector3(Math.random(), Math.random(), Math.random()).normalize(),
                    rotationSpeed: Math.random() * 6 + 0.3, // ~0.3 to 6.3 rad/sec
                    distance: 0, remove: false
                };
            }

            // Rockfire-style glow layered on top of the shrapnel
            spawnSparks(cube.position);    // warm additive point cloud
            spawnFlash(cube.position);     // point light + fading sphere
            spawnShockwave(cube.position); // expanding ring on the floor
        }

        function createTriangle(cube) {
            let geo = new THREE.BufferGeometry();
            geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.1,0,0, 0.1,0,0, 0,0.1,0]), 3));
            geo.setIndex(new THREE.BufferAttribute(new Uint16Array([0,1,2]), 1));
            let mat = new THREE.MeshBasicMaterial({ color: cube.material.color.getHex(), side: THREE.DoubleSide });
            let tri = new THREE.Mesh(geo, mat);
            tri.position.copy(cube.position);
            tri.lookAt(camera.position);
            let s = Math.random() * 1 + 0.5;
            tri.scale.set(s, s, s);
            return tri;
        }

        function updateTriangles(delta) {
            for (let i = 0; i < triangles.length; i++) {
                let t = triangles[i], u = t.userData;
                t.position.add(u.direction.clone().multiplyScalar(u.speed * delta));
                t.rotateOnWorldAxis(u.rotationAxis, u.rotationSpeed * delta);
                u.distance += u.speed * delta;
                if (u.distance >= 2) u.remove = true;
            }
            for (let i = triangles.length - 1; i >= 0; i--) {
                if (triangles[i].userData.remove) { scene.remove(triangles[i]); triangles.splice(i, 1); }
            }
        }

        // ============================================================
        // ROCKFIRE-STYLE VFX — sparks + flash + shockwave on top
        // of the shrapnel. Spawned by explosion(), pruned every frame.
        // ============================================================

        // One soft radial-gradient sprite, built once and reused.
        function getSparkTexture() {
            if (sparkTexture) return sparkTexture;
            let c = document.createElement('canvas');
            c.width = c.height = 64;
            let g = c.getContext('2d');
            let grd = g.createRadialGradient(32, 32, 1, 32, 32, 32);
            grd.addColorStop(0, 'rgba(255,245,210,1)');
            grd.addColorStop(0.4, 'rgba(255,150,40,0.55)');
            grd.addColorStop(1, 'rgba(0,0,0,0)');
            g.fillStyle = grd; g.fillRect(0, 0, 64, 64);
            sparkTexture = new THREE.CanvasTexture(c);
            sparkTexture.colorSpace = THREE.SRGBColorSpace;
            return sparkTexture;
        }

        // 1) SPARKS: warm additive THREE.Points that fly out, fall, and fade.
        function spawnSparks(position) {
            let count = 50;
            let positions = new Float32Array(count * 3);
            let colors = new Float32Array(count * 3);
            let velocities = [];
            for (let i = 0; i < count; i++) {
                positions[i*3] = position.x; positions[i*3+1] = position.y; positions[i*3+2] = position.z;
                colors[i*3] = 1.0; colors[i*3+1] = 0.7 + Math.random()*0.3; colors[i*3+2] = 0.25;
                let dir = new THREE.Vector3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1).normalize();
                velocities.push(dir.multiplyScalar(2 + Math.random()*3));
            }
            let geo = new THREE.BufferGeometry();
            geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
            geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
            let mat = new THREE.PointsMaterial({
                size: 0.35, map: getSparkTexture(), vertexColors: true,
                blending: THREE.AdditiveBlending, transparent: true, depthWrite: false
            });
            let points = new THREE.Points(geo, mat);
            scene.add(points);
            sparkSystems.push({ points: points, velocities: velocities,
                baseColors: colors.slice(), age: 0, life: 0.6 + Math.random()*0.4 });
        }

        function updateSparks(delta) {
            for (let s = sparkSystems.length - 1; s >= 0; s--) {
                let sys = sparkSystems[s];
                sys.age += delta;
                let k = 1 - sys.age / sys.life;
                if (k <= 0) {
                    scene.remove(sys.points);
                    sys.points.geometry.dispose(); sys.points.material.dispose();
                    sparkSystems.splice(s, 1); continue;
                }
                let pos = sys.points.geometry.attributes.position;
                let col = sys.points.geometry.attributes.color;
                for (let i = 0; i < sys.velocities.length; i++) {
                    let v = sys.velocities[i];
                    v.y -= 6 * delta; // gravity
                    pos.array[i*3] += v.x * delta;
                    pos.array[i*3+1] += v.y * delta;
                    pos.array[i*3+2] += v.z * delta;
                    col.array[i*3] = sys.baseColors[i*3] * k;
                    col.array[i*3+1] = sys.baseColors[i*3+1] * k;
                    col.array[i*3+2] = sys.baseColors[i*3+2] * k;
                }
                pos.needsUpdate = true; col.needsUpdate = true;
            }
        }

        // 2) FLASH: a quick warm point light plus a fading additive sphere.
        function spawnFlash(position) {
            let light = new THREE.PointLight(0xffaa33, 40, 120);
            light.position.copy(position);
            scene.add(light);
            setTimeout(function () { scene.remove(light); }, 300);

            let mat = new THREE.MeshBasicMaterial({ color: 0xffcc66,
                transparent: true, opacity: 1, blending: THREE.AdditiveBlending, depthWrite: false });
            let mesh = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 16), mat);
            mesh.position.copy(position);
            scene.add(mesh);
            flashes.push({ mesh: mesh, age: 0, life: 0.2 });
        }

        function updateFlashes(delta) {
            for (let f = flashes.length - 1; f >= 0; f--) {
                let fl = flashes[f];
                fl.age += delta;
                let k = 1 - fl.age / fl.life;
                if (k <= 0) {
                    scene.remove(fl.mesh);
                    fl.mesh.geometry.dispose(); fl.mesh.material.dispose();
                    flashes.splice(f, 1); continue;
                }
                let scale = 1 + (1 - k) * 4;
                fl.mesh.scale.set(scale, scale, scale);
                fl.mesh.material.opacity = k;
            }
        }

        // 3) SHOCKWAVE: a flat additive ring that expands and fades.
        function spawnShockwave(position) {
            let mat = new THREE.MeshBasicMaterial({ color: 0xffbb55,
                transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending,
                side: THREE.DoubleSide, depthWrite: false });
            let mesh = new THREE.Mesh(new THREE.RingGeometry(0.2, 0.4, 48), mat);
            mesh.position.set(position.x, 0.05, position.z);
            mesh.rotation.x = -Math.PI / 2;
            scene.add(mesh);
            shockwaves.push({ mesh: mesh, age: 0, life: 0.5 });
        }

        function updateShockwaves(delta) {
            for (let w = shockwaves.length - 1; w >= 0; w--) {
                let sw = shockwaves[w];
                sw.age += delta;
                let k = 1 - sw.age / sw.life;
                if (k <= 0) {
                    scene.remove(sw.mesh);
                    sw.mesh.geometry.dispose(); sw.mesh.material.dispose();
                    shockwaves.splice(w, 1); continue;
                }
                let scale = 1 + (1 - k) * 10;
                sw.mesh.scale.set(scale, scale, scale);
                sw.mesh.material.opacity = 0.6 * k;
            }
        }

        // ============================================================
        // AUDIO SYSTEM — Web Audio API for music, laser, explosion
        // ============================================================
        let audioContext = null, musicBuffer = null, laserSoundBuffer = null;
        let explosionSoundBuffer = null, isMusicPlaying = false, musicSource = null;

        function loadAudioFile(url, callback) {
            let req = new XMLHttpRequest();
            req.open('GET', url, true);
            req.responseType = 'arraybuffer';
            req.onload = function () { audioContext.decodeAudioData(req.response, callback); };
            req.send();
        }

        function playMusic() {
            if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
            if (!musicBuffer) {
                loadAudioFile('https://www.shanebrumback.com/sounds/first-person-shooter-music.wav', function (buf) {
                    musicBuffer = buf; playLoopedSound(buf, 0.35); isMusicPlaying = true;
                });
            } else {
                if (isMusicPlaying) { pauseSound(); isMusicPlaying = false; }
                else { resumeSound(); isMusicPlaying = true; }
            }
        }

        function playLoopedSound(buffer, volume) {
            musicSource = audioContext.createBufferSource();
            musicSource.buffer = buffer; musicSource.loop = true;
            let gain = audioContext.createGain();
            gain.gain.setValueAtTime(0, audioContext.currentTime);
            gain.gain.linearRampToValueAtTime(volume, audioContext.currentTime + 2);
            musicSource.connect(gain); gain.connect(audioContext.destination);
            musicSource.start(audioContext.currentTime + 0.1);
        }

        function pauseSound() { if (musicSource) { musicSource.stop(); musicSource.disconnect(); musicSource = null; } }
        function resumeSound() { if (musicBuffer) playLoopedSound(musicBuffer, 0.35); }

        function playLaserSound() {
            if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
            if (!laserSoundBuffer) { loadAudioFile('https://www.shanebrumback.com/sounds/laser.wav', function (buf) { laserSoundBuffer = buf; playSound(buf, 1); }); }
            else playSound(laserSoundBuffer, 1);
        }

        function playExplosionSound() {
            if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
            if (!explosionSoundBuffer) { loadAudioFile('https://www.shanebrumback.com/sounds/explosion.wav', function (buf) { explosionSoundBuffer = buf; playSound(buf, 0.25); }); }
            else playSound(explosionSoundBuffer, 0.25);
        }

        function playSound(buffer, volume) {
            let src = audioContext.createBufferSource();
            let gain = audioContext.createGain(); gain.gain.value = volume;
            src.buffer = buffer; src.connect(gain); gain.connect(audioContext.destination); src.start(0);
        }

        // ============================================================
        // WINDOW RESIZE HANDLER
        // ============================================================
        window.addEventListener('resize', function () {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });

    </script>
</body>
</html>