Hitscan vs projectile: shooting mechanics in a browser FPS

Every FPS has to answer one fundamental question the moment the player pulls the trigger: does the bullet arrive instantly, or does it travel? The first approach — hitscan — casts an invisible ray the instant you click and checks what it intersects. The second — projectile — spawns an actual object that flies through the scene with velocity, gravity, and collision. Both feel completely different to play, and they have completely different engineering trade-offs in a browser game built with Three.js. This post walks through implementing each one, from the raw Raycaster call to an object-pooled projectile system, and when to use which.

Hitscan: instant rays

Hitscan is what most classic arena shooters use — Quake's railgun, Counter-Strike's rifles, Overwatch's Widowmaker. You click, a ray fires from the camera, whatever it touches first takes damage. There is no travel time. The bullet doesn't exist as a physics object; it's just a geometric query resolved in a single frame.

In Three.js, that geometric query is Raycaster. You set its origin to the camera position, its direction to the camera's forward vector, and call intersectObjects(). The result is a sorted array of intersection objects — the first one is the closest hit.

const raycaster = new THREE.Raycaster();
const direction = new THREE.Vector3();

function fireHitscan(camera, targets) {
  camera.getWorldDirection(direction);
  raycaster.set(camera.position, direction);
  raycaster.far = 200; // max range in world units

  const hits = raycaster.intersectObjects(targets, true);
  if (hits.length > 0) {
    const hit = hits[0];
    console.log('Hit:', hit.object.name, 'at', hit.distance, 'm');
    console.log('Impact point:', hit.point);
    // Apply damage, spawn decal, play sound
    spawnImpactDecal(hit.point, hit.face.normal);
    return hit;
  }
  return null;
}

That's it for the core mechanic. hit.point gives you the exact world-space coordinate where the bullet landed — you use that to place a decal sprite, a spark particle, or a bullet hole. hit.face.normal tells you the surface orientation so the decal sits flush against the wall instead of floating.

For a basic prototype this runs fine. For anything with more than a handful of objects in the scene, you need to think about what you're raycasting against. Calling raycaster.intersectObjects(scene.children, true) tests every mesh in the scene graph — including the skybox, particle sprites, UI overlays, and the player's own gun model. That's both slow and wrong (you don't want to shoot yourself in the gun).

The fix is maintaining a dedicated targets array — only the meshes that should be hittable. Enemies, walls, props. Rebuild it when things spawn or despawn. If the level geometry is complex, drop the static meshes into a separate Group and raycast against that. Three.js's raycaster already tests bounding spheres before doing triangle intersection, so grouping hittable geometry is usually enough to keep it fast.

Adding bullet spread

A perfectly accurate raycast feels robotic. Real weapons have spread — a cone of possible directions around the centre. Implementing it means rotating the ray direction by a small random angle before casting:

function getSpreadDirection(camera, spreadAngle) {
  const dir = new THREE.Vector3();
  camera.getWorldDirection(dir);

  // random point in a circle, scaled by the spread cone
  const theta = Math.random() * Math.PI * 2;
  const r = Math.random() * Math.tan(spreadAngle);
  const offset = new THREE.Vector3(
    Math.cos(theta) * r,
    Math.sin(theta) * r,
    0
  );

  // rotate offset into camera space
  const quat = new THREE.Quaternion();
  quat.setFromUnitVectors(new THREE.Vector3(0, 0, -1), dir);
  offset.applyQuaternion(quat);

  return dir.add(offset).normalize();
}

spreadAngle is in radians — something like 0.02 for a tight rifle or 0.12 for a shotgun. Increase it when the player is moving, jumping, or holding down the fire button to simulate recoil. A shotgun fires multiple rays per click (typically 8–12), each with a wide spread — same function, just called in a loop.

Hitscan vs Projectile — How Each Frame Works Hitscan Player click → fire Raycaster (instant, same frame) Target hit.point + normal Result Damage + decal 1 frame total Projectile Player click → spawn f1 f2 f3 f4 f5 Target collision check Result Damage + explosion 5+ frames travel velocity + gravity each frame ↓ Hitscan strengths • Zero latency — instant feedback • Cheap — one raycast per shot • Simple netcode (position + time) Projectile strengths • Visible tracers — satisfying visuals • Gravity + drop — skill-based aim • Physics interactions (bouncing, etc.)
Hitscan resolves in one frame via Raycaster. Projectile travels across multiple frames with velocity and gravity.

Projectile: spawning real bullets

Projectile systems are what you see in Battlefield, Fortnite, and any game with rocket launchers or grenade arcs. The bullet is a real object in the scene. It has a position, a velocity vector, and — usually — gravity pulling it downward every frame. You update it in the animation loop, check for collisions each step, and remove it when it hits something or times out.

The naive version spawns a new Mesh per shot:

function fireProjectile(camera, scene) {
  const geo = new THREE.SphereGeometry(0.05, 6, 6);
  const mat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });
  const bullet = new THREE.Mesh(geo, mat);

  bullet.position.copy(camera.position);

  const velocity = new THREE.Vector3();
  camera.getWorldDirection(velocity);
  velocity.multiplyScalar(80); // 80 units/sec muzzle velocity

  bullet.userData.velocity = velocity;
  bullet.userData.life = 0;
  scene.add(bullet);
  activeBullets.push(bullet);
}

And in your render loop, you move every bullet and check for hits:

const gravity = new THREE.Vector3(0, -9.8, 0);
const _ray = new THREE.Raycaster();

function updateBullets(dt, targets, scene) {
  for (let i = activeBullets.length - 1; i >= 0; i--) {
    const b = activeBullets[i];
    b.userData.life += dt;

    // apply gravity
    b.userData.velocity.addScaledVector(gravity, dt);

    // move
    const step = b.userData.velocity.clone().multiplyScalar(dt);
    b.position.add(step);

    // collision: short ray in the direction of travel
    _ray.set(b.position, step.clone().normalize());
    _ray.far = step.length();
    const hits = _ray.intersectObjects(targets, true);

    if (hits.length > 0 || b.userData.life > 3) {
      if (hits.length > 0) {
        spawnExplosion(hits[0].point);
        applyDamage(hits[0].object);
      }
      scene.remove(b);
      b.geometry.dispose();
      b.material.dispose();
      activeBullets.splice(i, 1);
    }
  }
}

This works but it creates garbage. Every bullet allocates a new geometry, a new material, and a new mesh, only to dispose them moments later. At 10 rounds per second from a machine gun, you're creating and destroying 600 meshes a minute. The garbage collector will notice.

Object pooling: keeping the GC quiet

The fix is object pooling — pre-allocate a fixed number of bullet meshes at startup and recycle them. When a bullet fires, grab one from the pool. When it hits or times out, put it back. No allocations during gameplay, no GC spikes.

class BulletPool {
  constructor(scene, size = 50) {
    this.pool = [];
    this.active = [];
    const geo = new THREE.SphereGeometry(0.05, 6, 6);
    const mat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });

    for (let i = 0; i < size; i++) {
      const m = new THREE.Mesh(geo, mat);
      m.visible = false;
      scene.add(m);
      this.pool.push(m);
    }
  }

  spawn(position, velocity) {
    const b = this.pool.pop();
    if (!b) return null; // pool exhausted
    b.position.copy(position);
    b.userData.velocity = velocity.clone();
    b.userData.life = 0;
    b.visible = true;
    this.active.push(b);
    return b;
  }

  release(bullet) {
    bullet.visible = false;
    const idx = this.active.indexOf(bullet);
    if (idx !== -1) this.active.splice(idx, 1);
    this.pool.push(bullet);
  }
}

Fifty bullets in the pool is usually plenty — even a machine gun at 10 rounds/sec with a 3-second bullet lifetime only needs 30 active at once. Share the geometry and material across all instances to keep the memory footprint tiny. If you need hundreds of simultaneous projectiles (like a bullet-hell game), switch to InstancedMesh where one draw call handles all of them — the WebGPU post covers instanced meshes in detail.

Collision detection: the short-ray trick

Notice the collision check in the update loop above: a short Raycaster cast in the direction of travel, with far set to the distance the bullet moved this frame. This is sometimes called a swept test — instead of checking whether the bullet's current position overlaps something, you check whether the path it just traveled intersects anything.

This matters for fast bullets. At 80 units/sec and 60 fps, a bullet moves about 1.3 units per frame. A thin wall might be only 0.2 units thick. If you check the bullet's position against the wall each frame, it can teleport through — the bullet was in front of the wall last frame and behind it this frame, but never inside it. The swept ray catches this because it tests the full line of travel, not just the endpoint.

For very fast projectiles (a sniper round at 300+ units/sec), the swept distance per frame is so large that you might as well use hitscan for the damage check and a projectile purely for the visible tracer. Many games do exactly this — the hit is registered instantly via raycast, but a tracer mesh flies from the barrel to the impact point over a few frames for visual effect.

Visual feedback: tracers and decals

Hitscan weapons need something visual to feel good. A click with no feedback feels broken even if the damage is applying. The cheapest option is a quick line from the muzzle to the hit point, drawn for one or two frames:

function drawTracer(start, end, scene) {
  const points = [start.clone(), end.clone()];
  const geo = new THREE.BufferGeometry().setFromPoints(points);
  const mat = new THREE.LineBasicMaterial({
    color: 0xffdd44,
    transparent: true,
    opacity: 0.6,
  });
  const line = new THREE.Line(geo, mat);
  scene.add(line);

  // remove after 60ms
  setTimeout(() => {
    scene.remove(line);
    geo.dispose();
    mat.dispose();
  }, 60);
}

For impact decals, a small transparent sprite placed at hit.point and oriented along hit.face.normal works well. Offset it slightly (0.01 units) along the normal to avoid z-fighting with the wall surface. Fade old decals after a few seconds or cap the total count to keep memory bounded.

When to use which

The decision isn't always one or the other. Many games mix both, weapon by weapon:

  • Pistols, rifles, SMGs: hitscan. The bullet is effectively instant at indoor ranges. Simpler logic, cheaper per-shot cost, tighter feedback loop.
  • Rocket launchers, grenade launchers: projectile. The visible arc is part of the gameplay — leading a target, bouncing off walls, area denial.
  • Shotguns: multiple hitscan rays (8–12) with wide spread. Projectile shotguns exist in some games but are much heavier to compute.
  • Sniper rifles: hitscan for hit detection, projectile tracer for the visual streak across the map. Best of both worlds.

In a browser game, performance pressure pushes toward hitscan. Each projectile is an extra object in the scene graph, an extra collision check per frame, and an extra thing the GC eventually needs to deal with. Hitscan is a single raycast that resolves in the same frame. On mobile browsers or low-end hardware, that difference adds up fast.

The exception is if bullet travel time is central to your game design. A game where dodging is possible because you can see bullets coming at you — that needs real projectiles. A game where reaction time and crosshair placement matter more — hitscan lets the engine stay out of the way.

Related
→ WebGPU in 2026: porting a Three.js game from WebGL to WebGPU → Building real-time multiplayer games with Supabase Realtime → Three.js Raycaster documentation
Should I use hitscan or projectile for my Three.js FPS?

Use hitscan for fast-paced arcade shooters where instant feedback matters — it's simpler to implement, cheaper to run, and feels snappier. Use projectile for games where bullet travel time, drop, or visible tracers are part of the gameplay — sniping, grenades, or anything where physics matters. Many games use both: hitscan for close-range weapons and projectile for rockets or grenades.

How does Three.js Raycaster work for shooting?

Three.js Raycaster casts an invisible ray from an origin point in a direction you specify. For an FPS, set the origin to the camera position and the direction to where the camera is facing. Calling raycaster.intersectObjects() returns a sorted list of every object the ray hits, with the distance and exact hit point. The first result is your hit — apply damage, spawn a decal, or play an effect.

How do I handle bullet spread and accuracy in Three.js?

For hitscan, rotate the ray direction by a small random angle before casting. Create a cone of possible directions: generate random x and y offsets within a circle, scale them by your spread radius, and apply them to the camera's forward direction using a quaternion. For projectile, apply the same random offset to the bullet's initial velocity vector. Increase the spread radius when the player is moving or firing continuously to simulate recoil.