DevelopmentAugust 12, 2026·12 min read

How to Build a Browser FPS with Three.js

A complete developer's guide to building a AAA-quality first-person shooter that runs in the browser — no game engine, no download, no plugins.

Building a first-person shooter that runs in a browser used to mean compromises — blocky graphics, simplified physics, and a game loop that struggled to hit 30fps. That's no longer the case. With Three.js and modern WebGL, you can build a browser FPS with visuals and combat mechanics that rival native console releases. We know because we did it: Forkspawn is a browser-native FPS with AAA-grade graphics, hitscan ballistics, and wave-survival combat — all delivered in a ~4MB bundle that loads in seconds.

This guide walks through the full architecture of a browser FPS built with Three.js, from scene setup to enemy AI to performance optimization. Whether you're building a full game or just curious how browser FPS games achieve 60fps, you'll find the key techniques here. You can see the results on our technical architecture page, or play the free demo to experience the gunplay firsthand.

Why Three.js for a Browser FPS?

Three.js is a high-level WebGL library that gives you a scene graph, lighting pipeline, camera system, and shader management without the overhead of a full game engine. For a browser FPS, it offers several key advantages:

The main trade-off vs. a dedicated game engine (Unity WebGL, Unreal) is that you build your own game loop, physics, and input handling. For an FPS, that's actually an advantage — you get fine-grained control over performance-critical systems like ballistics and rendering, which is exactly what you need to hit 60fps in a browser.

1. Scene Setup: The Arena

Every FPS needs a world. In Three.js, that means a Scene containing your camera, lights, and geometry. For a wave-survival FPS like Forkspawn, the arena is a bounded play space with walls, cover objects, and a ground plane.

import * as THREE from 'three';

// Core scene setup
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a0a12, 0.015); // depth fog

const camera = new THREE.PerspectiveCamera(
  75, window.innerWidth / window.innerHeight, 0.1, 1000
);
camera.position.set(0, 1.7, 0); // eye height ~1.7m

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

// Lighting: directional "sun" + ambient fill
const ambient = new THREE.AmbientLight(0x404060, 0.4);
scene.add(ambient);

const sun = new THREE.DirectionalLight(0xffeecc, 1.2);
sun.position.set(20, 30, 10);
sun.castShadow = true;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
scene.add(sun);

// Ground plane
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(100, 100),
  new THREE.MeshStandardMaterial({ color: 0x1a1a22, roughness: 0.9 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

A few things worth noting: the exponential fog adds depth and atmosphere while also serving as a performance optimization — distant geometry is culled by the fog, reducing overdraw. The shadow map is capped at 2048×2048, which is the sweet spot for visual quality vs. GPU cost on mid-range hardware. And the pixel ratio is clamped to 2 — rendering at 3x or 4x on high-DPI phones kills framerate for minimal visual gain.

2. Pointer-Lock Controls: Looking Around

The defining input mechanic of any FPS is mouse-look — moving the mouse rotates the camera without moving the cursor. The browser API for this is requestPointerLock(), which hides the cursor and delivers raw mouse deltas via the mousemove event.

// Pointer-lock FPS controls
let yaw = 0, pitch = 0;
const SENSITIVITY = 0.0022;
const PITCH_LIMIT = Math.PI / 2 - 0.01;

renderer.domElement.addEventListener('click', () => {
  renderer.domElement.requestPointerLock();
});

document.addEventListener('mousemove', (e) => {
  if (document.pointerLockElement !== renderer.domElement) return;
  yaw -= e.movementX * SENSITIVITY;
  pitch -= e.movementY * SENSITIVITY;
  pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch));
});

function updateCamera() {
  camera.rotation.order = 'YXZ'; // yaw first, then pitch
  camera.rotation.y = yaw;
  camera.rotation.x = pitch;
}

The movementX and movementY properties give you raw mouse deltas regardless of screen resolution — essential for consistent sensitivity across devices. The pitch clamp prevents the camera from flipping when looking straight up or down. For mobile, you'll need touch-based controls: a virtual joystick for movement and a drag-to-look area for camera rotation. Forkspawn implements both — see the playable demo on mobile.

3. Movement: WASD + Momentum

FPS movement feels bad when it's too sticky or too floaty. The secret is acceleration and friction — apply force toward the target velocity and let friction bring you to a stop naturally, rather than setting velocity directly.

const keys = {};
document.addEventListener('keydown', e => keys[e.code] = true);
document.addEventListener('keyup',   e => keys[e.code] = false);

const SPEED = 8, ACCEL = 60, FRICTION = 12;
const velocity = new THREE.Vector3();

function updateMovement(dt) {
  const forward = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
  const right = new THREE.Vector3(Math.cos(yaw), 0, -Math.sin(yaw));
  const target = new THREE.Vector3();
  if (keys['KeyW']) target.add(forward);
  if (keys['KeyS']) target.sub(forward);
  if (keys['KeyD']) target.add(right);
  if (keys['KeyA']) target.sub(right);

  if (target.lengthSq() > 0) {
    target.normalize().multiplyScalar(SPEED);
    velocity.lerp(target, 1 - Math.exp(-ACCEL * dt));
  } else {
    velocity.multiplyScalar(Math.exp(-FRICTION * dt));
  }
  camera.position.addScaledVector(velocity, dt);
}

Using 1 - Math.exp(-rate * dt) instead of a fixed lerp factor ensures the acceleration is frame-rate independent — the movement feels the same at 60fps and 144fps. This is critical for browser games where frame rates vary wildly across devices.

4. Hitscan Ballistics: Raycasting for Gunfire

Most browser FPS games use hitscan weapons — when you fire, the game instantly casts a ray from the camera and checks what it hits. This is simpler and more bandwidth-friendly than projectile simulation, and it's what most AAA shooters use for bullet weapons.

const raycaster = new THREE.Raycaster();
const enemies = [];

function fireWeapon() {
  raycaster.setFromCamera({ x: 0, y: 0 }, camera);
  const intersects = raycaster.intersectObjects(
    enemies.map(e => e.hitbox), false
  );

  if (intersects.length > 0) {
    const hit = intersects[0];
    const enemy = hit.object.userData.enemy;
    const localY = hit.point.y - enemy.position.y;
    const isHeadshot = localY > 1.5; // above 1.5m = head
    const damage = isHeadshot
      ? WEAPON.damage * WEAPON.headshotMultiplier
      : WEAPON.damage;
    enemy.takeDamage(damage);
    spawnHitEffect(hit.point, isHeadshot);
  }
}

Headshot detection is a matter of checking the Y-coordinate of the hit point relative to the enemy. If the ray hits the upper portion of the hitbox, it's a headshot — apply a damage multiplier (typically 2x or 3x). This single mechanic transforms the skill ceiling of your FPS. Weapon spread and recoil — the cone of inaccuracy and the visual camera kick after each shot — create the "feel" that distinguishes a good FPS from a great one. Experience this directly in the Forkspawn free demo.

5. Enemy AI: State Machines for Wave Combat

For a wave-survival FPS, enemies need to spawn, pathfind toward the player, and attack. A simple finite state machine (FSM) handles this without the overhead of a full behavior tree:

const STATES = { SPAWNING:0, CHASING:1, ATTACKING:2, DEAD:3 };

class Enemy {
  constructor(pos) {
    this.state = STATES.SPAWNING;
    this.health = 100; this.speed = 3.5;
    this.mesh = createEnemyMesh();
    this.mesh.position.copy(pos);
    this.hitbox = this.mesh;
    this.hitbox.userData.enemy = this;
    this.attackCooldown = 0;
  }
  update(dt, playerPos) {
    switch (this.state) {
      case STATES.SPAWNING:
        this.spawnTimer -= dt;
        if (this.spawnTimer <= 0) this.state = STATES.CHASING;
        break;
      case STATES.CHASING:
        const dir = playerPos.clone().sub(this.mesh.position);
        dir.y = 0; dir.normalize();
        this.mesh.position.addScaledVector(dir, this.speed * dt);
        if (this.mesh.position.distanceTo(playerPos) < 2.0)
          this.state = STATES.ATTACKING;
        break;
      case STATES.ATTACKING:
        this.attackCooldown -= dt;
        if (this.attackCooldown <= 0) {
          dealDamageToPlayer(15);
          this.attackCooldown = 1.0;
        }
        if (this.mesh.position.distanceTo(playerPos) > 2.5)
          this.state = STATES.CHASING;
        break;
    }
  }
  takeDamage(amount) {
    this.health -= amount;
    if (this.health <= 0) { this.state = STATES.DEAD; onEnemyKilled(this); }
  }
}

The FSM keeps enemy logic simple and debuggable — each state has clear entry and exit conditions. For more complex behavior (flanking, cover usage, group tactics), you can layer a behavior tree on top, but for wave-survival combat, an FSM is more than enough.

6. The Game Loop: requestAnimationFrame + Delta Time

The heart of any real-time game is the main loop. In a browser, that means requestAnimationFrame with delta-time updates. The critical rule: always use delta time. Never assume 60fps — some devices will run at 30fps, 90fps, or 144fps, and your physics should behave identically at all of them.

let lastTime = performance.now();
function gameLoop(now) {
  requestAnimationFrame(gameLoop);
  const dt = Math.min((now - lastTime) / 1000, 0.1);
  lastTime = now;
  updateCamera();
  updateMovement(dt);
  updateEnemies(dt);
  updateWeapon(dt);
  renderer.render(scene, camera);
}
requestAnimationFrame(gameLoop);

The 0.1 clamp on delta time prevents a "spiral of death" — if the tab is backgrounded and then refocused, the accumulated delta could be several seconds, which would catapult enemies across the map and break physics. Clamping to 100ms means the worst case is a single slow frame.

7. Performance Optimization: Hitting 60fps in a Browser

Getting 60fps in a browser FPS requires discipline. Here are the techniques that matter most:

Draw Call Batching with InstancedMesh

Every renderer.render() call issues draw calls to the GPU. Too many draw calls bottleneck framerate even if the GPU could handle the geometry. Use InstancedMesh to draw 100 identical enemies in a single draw call:

const enemyGeo = new THREE.BoxGeometry(0.6, 1.8, 0.6);
const enemyMat = new THREE.MeshStandardMaterial({ color: 0x882222 });
const instanced = new THREE.InstancedMesh(enemyGeo, enemyMat, 100);
instanced.castShadow = true;
scene.add(instanced);

const dummy = new THREE.Object3D();
for (let i = 0; i < 100; i++) {
  dummy.position.set(Math.random()*40, 0.9, Math.random()*40);
  dummy.updateMatrix();
  instanced.setMatrixAt(i, dummy.matrix);
}
instanced.instanceMatrix.needsUpdate = true;

Level-of-Detail, Texture Atlases, and Object Pooling

8. Building for Zero Download

The final piece is delivery. A browser FPS should load in seconds, not minutes. Forkspawn's entire client — engine, assets, audio, and game logic — is approximately 4MB. We achieve this through:

The result: a player clicks "Play" and is in combat within 3–5 seconds. Learn more on our no-download FPS guide.

Conclusion: Browser FPS Games Are Ready for Prime Time

Three.js and WebGL have matured to the point where browser FPS games can compete with native releases on both visuals and combat feel. The key architecture decisions — hitscan ballistics, FSM enemy AI, delta-time game loop, draw-call batching, procedural textures, and a 4MB asset budget — are all proven patterns that work in production.

If you want to see what a browser FPS built with these techniques looks like, play the Forkspawn demo — it runs at 60fps in any modern browser with no download. For a deeper technical breakdown, check our architecture page. And if you're comparing options, see our ranking of the best browser FPS games in 2026 or our free alternative to Call of Duty comparison.

Related Articles

Development · Rankings

WebGL Shooter Games in 2026 — The State of Browser FPS

How far WebGL has come, which browser shooters are pushing the boundary, and why a 4 MB Three.

Read Article →
Rankings

Best Browser FPS Games in 2026

The definitive ranked list of the best browser FPS games in 2026.

Read Article →
Comparison

Browser Games Like Call of Duty

Want Call of Duty combat without the 150GB download? Here's how the best browser FPS games compare to CoD on visuals, gunplay, and progression.

Read Article →
View Pricing →

No sign-up · No download · Plays in your browser