To make a wall block bullets and players in Three.js, wrap each wall in an invisible box collider, give that box geometry a three-mesh-bvh bounds tree so fast raycasts can hit it, and run a per-frame push-out that keeps the player outside the box. This tutorial pulls the pattern from Super Soldier Battle's real collision code.
Invisible box colliders, BVH-accelerated raycasts, and a closest-point push-out — the three ideas that make the new bunkers solid, simplified so you can drop them into your own game.
SEE IT LIVE
This collision code runs in Super Soldier Battle. Take cover in a bunker and watch the walls block shots and players for real. It's free and runs right in your browser — no download.
The blue and red soldiers drifting behind this page are a decorative preview — the real battle is in the game.
Introduction
Super Soldier Battle's new walk-in bunkers only work if their walls are solid: players can't walk through them, and bullets can't pass through them. This tutorial shows how the game does both with three ideas from its actual source code (models/v2/instancing.js): invisible box colliders placed around each bunker piece, a BVH bounds tree so fast raycasts can hit those boxes, and a small push-out function that keeps the player outside walls every frame.
The visible bunker models are detailed and expensive to test against. The colliders are plain boxes: cheap to raycast and easy to reason about. Keeping the two separate is the main trick. Read the dev log for the gameplay side: Walk-In Bunkers and Beaches Land in Super Soldier Battle.
What you should know first: this is an intermediate build. You'll get the most out of it if you've already set up a basic Three.js scene (a renderer, a camera, a mesh, and a Raycaster). Bullet blocking uses the three-mesh-bvh library. The game runs on Three.js r128, which is why some excerpts use BoxBufferGeometry.
What You'll Learn
Building invisible box colliders from data (center, size, rotation) and positioning them with a matrix
Why visible = false beats an invisible material for performance (fewer draw calls)
Why firstHitOnly raycasts with three-mesh-bvh need computeBoundsTree(), or they return no hits
Separating "walls" (block sideways) from "floors" (you can stand on them)
Push-out collision: moving a player out of a rotated box using its closest point
Prerequisites
Basic Three.js (scene, mesh, Raycaster) and JavaScript. Bullet blocking uses the three-mesh-bvh library. The game runs on Three.js r128, which is why the excerpts use BoxBufferGeometry; the runnable example below uses a current CDN build with BoxGeometry.
Frequently Asked Questions
Why are my three-mesh-bvh raycasts returning no hits?
If you set firstHitOnly = true with the accelerated raycast, the geometry needs a bounds tree. Call geometry.computeBoundsTree(). Super Soldier Battle had exactly this bug on its wall colliders.
Do invisible meshes cost draw calls in Three.js?
Yes, if you hide them with an invisible material, because they still submit a draw call. Set mesh.visible = false instead. It removes the mesh from the render list, and raycaster.intersectObjects() still hits it.
Should I raycast against my detailed 3D models for collision?
Usually not. Simple invisible box colliders are much cheaper and more predictable. Super Soldier Battle collides against boxes placed around each bunker piece, not the visible models.
How do I stop the player from walking through rotated walls?
Transform the player into the wall's local space, clamp to find the closest point on the box, and push the player out along that direction to their radius. Then rotate the push back to world space.
Where can I see this code in action?
In Super Soldier Battle's new bunkers, free at supersoldierbattle.com. The walls block both players and shots.
How It Works
One shared box geometry is reused for every collider and scaled per wall, so hundreds of colliders cost almost nothing to create.
Each collider is hidden withvisible = false — not an invisible material — so it leaves the render list entirely while still being raycastable.
The shared geometry gets a BVH bounds tree once via computeBoundsTree(), which is what makes firstHitOnly raycasts actually return hits.
Walls and floors are separated by akind flag: floors go in the ground-raycast list you stand on; walls go in the bullet / line-of-sight list and the sideways push-out list.
The gun raycaster tests walls and players together, so the nearest hit wins — a wall between you and a target blocks the shot and spawns an impact effect.
A per-frame push-out rotates the player into each box's local space, finds the closest point on the box, and nudges the player back out to their radius, with a small step-up tolerance for ramps.
That's the whole system from the player's point of view. The detailed code breakdown and the full copy-paste example are below — sign in free to unlock them.
Sign in to unlock the code
This tutorial is 100% free — just sign in with Google to reveal the full code breakdown, the complete copy-paste code, and the one-click Launch. It takes one click and unlocks every free tutorial.
Code Breakdown
Here is the whole collision system in plain English. Beginners: read the bold summaries. Developers: the notes after each explain the why and the performance choices. The snippets are simplified from Super Soldier Battle's models/v2/instancing.js.
1. One shared box, invisible colliders
// Simplified from Super Soldier Battle (models/v2/instancing.js)
const boxGeo = new THREE.BoxGeometry(1, 1, 1); // r128 game code: BoxBufferGeometry
const hidden = new THREE.MeshBasicMaterial({ visible: false });
function makeCollider(center, size, rotY) {
const c = new THREE.Mesh(boxGeo, hidden);
c.visible = false; // PERF: out of the render list entirely (no draw call)
c.matrixAutoUpdate = false; // static: compose the matrix once
c.matrix.compose(
new THREE.Vector3(...center),
new THREE.Quaternion().setFromEuler(new THREE.Euler(0, rotY || 0, 0)),
new THREE.Vector3(...size) // the 1x1x1 box scaled to the wall's size
);
return c;
}
In plain English: every wall shares one box shape, scaled and rotated into place, and each box is hidden so the player never sees it. Developer note: setting visible = false removes the mesh from the render list, but an invisible material still submits a draw call every frame — about 500 collider meshes were pushing draw calls from 35 to roughly 100. Raycasts still hit a visible = false mesh, so collision is unaffected. matrixAutoUpdate = false skips per-frame matrix recompute on static walls.
2. Give bullets something to hit (BVH)
import { computeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh';
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
if (boxGeo.computeBoundsTree) boxGeo.computeBoundsTree(); // once, shared by every wall
const wallMeshes = []; // game: window._v2WallMeshes
const raycaster = new THREE.Raycaster();
raycaster.firstHitOnly = true;
function shoot(origin, dir, targets) {
raycaster.set(origin, dir);
// walls are tested together with players, so the nearest hit wins
const hit = raycaster.intersectObjects([...wallMeshes, ...targets], false)[0];
if (hit && wallMeshes.includes(hit.object)) spawnImpactVFX(hit.point); // shot blocked by cover
return hit;
}
In plain English: the gun fires a ray; whatever it hits first — a wall or an enemy — is what counts. If that first thing is a wall, the shot is blocked and a spark plays there. Developer note: a firstHitOnly raycast needs a bounds tree on the geometry or it returns no hits — that was the exact bug where shooting base and perimeter walls produced no impact. Because every collider shares one geometry, computeBoundsTree() runs just once. Testing walls and targets in the same array means a wall in front of an enemy correctly eats the shot.
3. Walls vs floors
for (const b of colliderData) { // colliders.json in the game
const kind = b.kind || 'wall';
const c = makeCollider(b.center, b.size, b.rotY);
colGroup.add(c);
if (kind !== 'wall') gameObjects.push(c); // walkable (floors, tops, steps)
else wallMeshes.push(c); // bullets + sideways blocking only
const P = new THREE.Vector3(), Q = new THREE.Quaternion(), S = new THREE.Vector3();
const E = new THREE.Euler();
c.matrix.decompose(P, Q, S); E.setFromQuaternion(Q, 'YXZ');
if (kind === 'wall' || kind === 'top') wallBoxes.push({
x: P.x, z: P.z, c: Math.cos(E.y), s: Math.sin(E.y),
hx: Math.abs(S.x) / 2, hz: Math.abs(S.z) / 2,
y0: P.y - Math.abs(S.y) / 2, y1: P.y + Math.abs(S.y) / 2
});
}
In plain English: a collider is either something you can stand on (a floor, a ramp top, a step) or a wall that blocks you sideways. Floors go in one list; walls go in another, plus a compact math description for the push-out. Developer note: keeping walls out of the ground-raycast list is deliberate — otherwise the player could snap on top of a thin wall. Each wall is also flattened to center, rotation as cos/sin, half-extents, and a vertical span so the per-frame push-out never has to touch the mesh or its matrix again.
4. Push the player out of walls
function pushOutOfWalls(pos, radius = 1.6, eye = 5.7) {
const feet = pos.y - eye;
for (const w of wallBoxes) {
if (w.y1 < feet + 0.5 || w.y0 > pos.y) continue; // box not at the player's height
const dx = pos.x - w.x, dz = pos.z - w.z;
const lx = dx * w.c - dz * w.s, lz = dx * w.s + dz * w.c; // to box-local space
const qx = Math.max(-w.hx, Math.min(w.hx, lx)); // closest point on the box
const qz = Math.max(-w.hz, Math.min(w.hz, lz));
const ox = lx - qx, oz = lz - qz, d = Math.hypot(ox, oz);
if (d >= radius) continue; // not touching
let px, pz;
if (d > 1e-6) { px = ox / d * (radius - d); pz = oz / d * (radius - d); }
else { // centre inside: exit via nearest side
const ex = w.hx - Math.abs(lx), ez = w.hz - Math.abs(lz);
if (ex < ez) { px = Math.sign(lx || 1) * (ex + radius); pz = 0; }
else { px = 0; pz = Math.sign(lz || 1) * (ez + radius); }
}
pos.x += px * w.c + pz * w.s; // back to world space
pos.z += -px * w.s + pz * w.c;
}
return pos;
}
In plain English: every frame, for each wall near the player's height, find the closest point on that box and, if the player is inside their own radius of it, slide them back out. Developer note: rotating the player into the box's local space turns a rotated-box test into a simple axis-aligned clamp, then the result is rotated back to world space. The d > 1e-6 branch handles being exactly on an edge; the else branch handles the player's center being fully inside the box (exit via the nearest face). In the game this runs a few passes — adjacent boxes can shove you into each other — and falls back to the previous frame's position if you're still stuck, so you stop instead of tunnelling through.
The Full Code
Copy this into a file named index.html and open it in any modern browser — or hit Launch. Three.js and three-mesh-bvh load from a CDN via the import map, so it runs as-is. Drag to orbit; the shooter auto-fires at the wall and the shots stop and spark on the invisible box collider.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Walls That Stop Bullets</title>
<style>
html, body { margin: 0; height: 100%; background: #0c0d10; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
</style>
</head>
<body>
<canvas id="view"></canvas>
<script async src="https://unpkg.com/es-module-shims@1.8.0/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.186.1/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.186.1/examples/jsm/",
"three-mesh-bvh": "https://unpkg.com/three-mesh-bvh@0.7.8/build/index.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { computeBoundsTree, acceleratedRaycast } from "three-mesh-bvh";
// ============================================================
// BVH SETUP: patch BufferGeometry + Mesh so firstHitOnly raycasts
// are fast AND actually return hits. Without computeBoundsTree(),
// a firstHitOnly raycast returns nothing.
// ============================================================
THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
THREE.Mesh.prototype.raycast = acceleratedRaycast;
const canvas = document.getElementById("view");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0c0d10);
scene.fog = new THREE.FogExp2(0x0c0d10, 0.03);
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.set(7, 5, 10);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.5, 0);
controls.enableZoom = false; // let the wheel scroll the page
controls.enablePan = false;
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.6;
scene.add(new THREE.AmbientLight(0x8899bb, 1.0));
const key = new THREE.DirectionalLight(0xffffff, 1.4);
key.position.set(5, 9, 6);
scene.add(key);
// ground
const ground = new THREE.Mesh(
new THREE.CircleGeometry(20, 48),
new THREE.MeshStandardMaterial({ color: 0x2a2d33, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// ============================================================
// ONE SHARED BOX GEOMETRY for every collider. Give it a BVH
// bounds tree ONCE so firstHitOnly raycasts work for all walls.
// ============================================================
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
boxGeo.computeBoundsTree();
// A visible wall (what the player sees) + an invisible collider box
// sized to match it. In a real game the collider wraps a detailed model.
const wallSize = [6, 3, 0.6];
const wallMat = new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.9 });
const wall = new THREE.Mesh(new THREE.BoxGeometry(...wallSize), wallMat);
wall.position.set(0, wallSize[1] / 2, 0);
scene.add(wall);
const wallMeshes = []; // raycast + block list (game: _v2WallMeshes)
const hidden = new THREE.MeshBasicMaterial({ visible: false });
function makeCollider(center, size, rotY) {
const c = new THREE.Mesh(boxGeo, hidden);
c.visible = false; // PERF: out of the render list (no draw call)
c.matrixAutoUpdate = false; // static: compose the matrix once
c.matrix.compose(
new THREE.Vector3(...center),
new THREE.Quaternion().setFromEuler(new THREE.Euler(0, rotY || 0, 0)),
new THREE.Vector3(...size)
);
scene.add(c);
wallMeshes.push(c);
return c;
}
makeCollider([0, wallSize[1] / 2, 0], wallSize, 0);
// A faint wireframe so YOU can see where the invisible collider is
// (not part of the technique -- just for the demo).
const wire = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(...wallSize)),
new THREE.LineBasicMaterial({ color: 0x2ad1d6 })
);
wire.position.copy(wall.position);
scene.add(wire);
// ============================================================
// SHOOTER: a muzzle on one side fires rays at the wall. Each shot
// is a firstHitOnly raycast; if it hits the wall collider first,
// the shot stops there and a spark pops at the hit point.
// ============================================================
const muzzle = new THREE.Vector3(-1.5, 1.6, 7);
const marker = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 12, 12),
new THREE.MeshStandardMaterial({ color: 0x39ff14, emissive: 0x0d3b2e })
);
marker.position.copy(muzzle);
scene.add(marker);
const raycaster = new THREE.Raycaster();
raycaster.firstHitOnly = true; // needs the BVH bounds tree above
// a short tracer line + a spark sprite we reuse each shot
const tracerGeo = new THREE.BufferGeometry().setFromPoints([muzzle.clone(), muzzle.clone()]);
const tracer = new THREE.Line(tracerGeo, new THREE.LineBasicMaterial({ color: 0xffd27a }));
scene.add(tracer);
const spark = new THREE.Mesh(
new THREE.SphereGeometry(0.22, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xffd27a, transparent: true, opacity: 0 })
);
scene.add(spark);
let sparkLife = 0;
function shoot() {
// aim at a random point across the wall face so you can see it block
const aim = new THREE.Vector3((Math.random() - 0.5) * 7, 1.5, 0).sub(muzzle).normalize();
raycaster.set(muzzle, aim);
const hit = raycaster.intersectObjects(wallMeshes, false)[0];
const end = hit ? hit.point : muzzle.clone().addScaledVector(aim, 14);
tracer.geometry.setFromPoints([muzzle.clone(), end]);
if (hit) { // shot blocked by the wall collider
spark.position.copy(hit.point);
spark.material.opacity = 1;
sparkLife = 1;
}
}
let fireTimer = 0;
const clock = new THREE.Clock();
function frame() {
requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
fireTimer -= dt;
if (fireTimer <= 0) { fireTimer = 0.45; shoot(); }
if (sparkLife > 0) {
sparkLife -= dt * 2.2;
spark.material.opacity = Math.max(0, sparkLife);
spark.scale.setScalar(1 + (1 - sparkLife) * 1.5);
}
controls.update();
renderer.render(scene, camera);
}
function resize() {
const w = canvas.clientWidth || 1, h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
addEventListener("resize", resize);
frame();
</script>
</body>
</html>
What To Use It For
Author colliders as data (center, size, rotY, kind) next to each model piece — never collide against the detailed mesh.
Build them from one shared box geometry with visible = false to keep draw calls flat.
Call computeBoundsTree() on that geometry once if you use three-mesh-bvh with firstHitOnly.
Put walls in your bullet / line-of-sight list, and floors in your ground list, so you never stand on a wall.
Run pushOutOfWalls(player.position) every frame after movement, in a few passes for tight corners.
SEE THESE WALLS IN A REAL GAME
This collision code ships in Super Soldier Battle's new walk-in bunkers. Read the dev log on the bunkers and beaches update to see the gameplay side of the same feature.