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