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.
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.
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.
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.