three.js collision detection without a physics engine, so the player stops at walls and slides along them

How to stop a three.js player at walls and keep it inside the play area with Box3 and no physics engine, moving one axis at a time so it slides along a wall, and three faults the paprika3d plugin's interact trace, expect and test catch on a real page, a player that walks through a wall, one that jumps across the room when it slides, and one that sticks.

Boxes, one axis at a time

A game with walls, crates and a play area does not need a physics engine to keep the player out of them. Give each obstacle an axis-aligned box in world space, give the player one, and after every move ask whether the player's box overlaps any wall's box. three.js has the box: THREE.Box3, with setFromObject to fit one around a mesh and intersectsBox to test two.

The move is where most collision code goes wrong. Move along x, resolve, then move along z and resolve again:

function moveAxis(axis, d) {
  if (!d) return;
  const p = player.position;
  p[axis] += d;
  const b = playerBox(p.x, p.z);
  for (const c of colliders) {
    if (!overlaps(b, c)) continue;
    p[axis] = d > 0 ? c.min[axis] - half : c.max[axis] + half;   // against the face it ran into
    b.copy(playerBox(p.x, p.z));
  }
}
moveAxis('x', dir.x);
moveAxis('z', dir.y);

Three choices in it decide whether the player stops where the wall is drawn:

The play area is the same problem: four walls around it, or a clamp of the position to the area's bounds less the player's half width.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome on a paused clock. interact plays keys frame by frame and returns what changed in the page's state; with trace it reads paths in observe() every frame and gives each one's first and last value, its peak or low and the frame it changed, and its Motion line says when the player stopped under a held key. expect and save keep a play as a check in game-tests.json, and test replays every saved check from a fresh reload. eval runs code in the page, with find and THREE in scope.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

A room with four walls and a crate

The player is a green box 1 unit wide that the arrow keys move at 4 units a second. The room's walls and a crate sit in a group named level, placed 1 unit left of the origin, as a loaded level often is. window.__game.observe() returns the player's position and setState places it, so a play can start next to a wall.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Room</title>
<style>html,body{margin:0;height:100%;overflow:hidden;background:#1b1e24}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1b1e24);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 14, 10);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.5));

const mat = (color) => new THREE.MeshStandardMaterial({ color, roughness: 0.8 });
const floor = new THREE.Mesh(new THREE.PlaneGeometry(12, 12).rotateX(-Math.PI / 2), mat(0x3a4250));
floor.name = 'floor';
const grid = new THREE.GridHelper(12, 12, 0x56607a, 0x56607a);
grid.name = 'grid';
grid.position.y = 0.01;
scene.add(floor, grid);

// The level: walls and a crate in one group, placed 1 unit left of the origin, as a loaded level often is.
const level = new THREE.Group();
level.name = 'level';
level.position.x = -1;
const box = (name, w, d, x, z, color = 0x8a96a8) => {
  const m = new THREE.Mesh(new THREE.BoxGeometry(w, 1.2, d), mat(color));
  m.name = name;
  m.position.set(x, 0.6, z);
  level.add(m);
  return m;
};
const walls = [
  box('wall_east', 0.4, 12.4, 6, 0), box('wall_west', 0.4, 12.4, -4, 0),
  box('wall_north', 9.6, 0.4, 1, -6), box('wall_south', 9.6, 0.4, 1, 6),
  box('crate', 2, 2, 1, -2.5, 0xb07a3c),
];
scene.add(level);

const player = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), mat(0x4caf50));
player.name = 'player';
player.position.y = 0.5;
scene.add(player);

// The colliders: each wall's box in world space, so the level's own position is in it. setFromObject reads the
// wall's world matrix, which three.js first computes at the first render: update it now, before any frame.
scene.updateMatrixWorld();
const colliders = walls.map((w) => new THREE.Box3().setFromObject(w));
const half = 0.5, speed = 4;   // the player is 1 unit wide; speed in units per second
const playerBox = (x, z) => new THREE.Box3(new THREE.Vector3(x - half, 0, z - half), new THREE.Vector3(x + half, 1, z + half));
// Overlap on the floor plan, strictly: a player resting against a wall's face touches it, and Box3.intersectsBox
// counts touching as overlap, which would push it out along the other axis as soon as it slides.
const overlaps = (a, b) => a.min.x < b.max.x && a.max.x > b.min.x && a.min.z < b.max.z && a.max.z > b.min.z;

// One axis at a time: move, and if the box now overlaps a wall, put it against that wall's face.
// Moving x and z apart is what lets the player slide along a wall it runs into at an angle.
function moveAxis(axis, d) {
  if (!d) return;
  const p = player.position;
  p[axis] += d;
  const b = playerBox(p.x, p.z);
  for (const c of colliders) {
    if (!overlaps(b, c)) continue;
    p[axis] = d > 0 ? c.min[axis] - half : c.max[axis] + half;
    b.copy(playerBox(p.x, p.z));
  }
}

const held = new Set();
const KEYS = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
addEventListener('keydown', (e) => { if (KEYS[e.code]) held.add(e.code); });
addEventListener('keyup', (e) => held.delete(e.code));

let last = 0;
renderer.setAnimationLoop((t) => {
  const dt = last ? Math.min((t - last) / 1000, 0.1) : 0;   // seconds since the last frame, never more than 0.1
  last = t;
  const dir = new THREE.Vector2();
  for (const k of held) dir.add(new THREE.Vector2(...KEYS[k]));
  if (dir.lengthSq()) dir.normalize().multiplyScalar(speed * dt);
  moveAxis('x', dir.x);
  moveAxis('z', dir.y);
  renderer.render(scene, camera);
});

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});

const r = (n) => +n.toFixed(2);
window.__game = {
  subject: 'player',
  describe: () => ({ name: 'room', keys: { ArrowRight: 'move east', ArrowLeft: 'move west', ArrowUp: 'move north', ArrowDown: 'move south' } }),
  observe: () => ({ player: { x: r(player.position.x), z: r(player.position.z) } }),
  setState: (s) => { if (s.player) { player.position.x = s.player.x ?? player.position.x; player.position.z = s.player.z ?? player.position.z; } },
};
</script>
</body></html>

The east wall is 0.4 units thick at x 5 in the world (6 in the group, less the group's 1), so its west face is at x 4.8 and a player 1 unit wide should stop with its centre at 4.3.

Walk into the wall, then slide along it

The calls below were made on 2026-10-28. Run folder paths are left out and replies are cut to the lines this guide discusses, with nothing reworded. The machine had no GPU (Chrome drew with SwiftShader); nothing here depends on frame times.

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:35371/index.html in 0.2 s, paused at frame 0.

interact {"steps": [{"key": "ArrowRight", "frames": 90}], "trace": ["player.x"]}

{"changed":{"player":{"x":4.3}}}
Motion (player): travelled 4.3 units, it stopped at frame 66 and did not move again.
player.x: 0 → 4.3 at frame 66; first changed at frame 2

eval {"code": "const b = new THREE.Box3().setFromObject(find(\"wall_east\")); return { min: b.min.toArray(), max: b.max.toArray() };"}

{"min":[4.8,0,-6.2],"max":[5.2,1.2,6.2]}

Held for 90 frames, the right arrow takes the player to 4.3 and no farther, and the Motion line says it stopped at frame 66 under a key still held. The eval reads the wall's box from the live scene: its west face is at 4.8, half a player's width from where the player stopped.

The slide starts the player at x 3.5 and holds right and up together:

interact {"steps": [{"state": {"player": {"x": 3.5, "z": 0}}}, {"key": "ArrowRight", "down": true, "frames": 0}, {"key": "ArrowUp", "frames": 60}, {"key": "ArrowRight", "down": false}], "trace": ["player.x", "player.z"]}

{"changed":{"player":{"z":-2.83}}}
Motion (player): travelled 3.16 units (2.94 from start to end), its direction of travel turned 45° left.
player.x: 3.5 → 4.3 at frame 17; first changed at frame 1
player.z: 0 → -2.83 at frame 60; first changed at frame 1

x reaches the wall at frame 17 and stays at 4.3, while z keeps falling to -2.83 at frame 60: the player runs north along the wall. The Motion line names the turn, from diagonal to straight up the wall.

Fault 1: the player walks through the wall

The same page without the scene.updateMatrixWorld() line before the colliders are built:

const colliders = walls.map((w) => new THREE.Box3().setFromObject(w));   // before any frame has drawn
interact {"steps": [{"key": "ArrowRight", "frames": 90}], "trace": ["player.x"]}

{"changed":{"player":{"x":5.3}}}
player.x: 0 → 5.3 at frame 81; first changed at frame 2

The player stops at 5.3, so its box runs from 4.8 to 5.8: it has gone through the whole wall, which spans 4.8 to 5.2, and stopped against nothing a player can see, 1 unit past where it should. One unit is the group's offset. eval builds the same box the page built, in a group moved by -1, before and after updating the group's world matrix:

eval {"code": "const box = (o) => new THREE.Box3().setFromObject(o).min.x; const group = new THREE.Group(); group.position.x = -1; const wall = find(\"wall_east\").clone(); group.add(wall); const early = box(wall); group.updateMatrixWorld(); return { drawn: box(find(\"wall_east\")), early, after: box(wall) };"}

{"drawn":4.8,"early":5.8,"after":4.8}

Before the update the box's face is at 5.8, as if the group were at the origin; after it, 4.8, where the wall is drawn. setFromObject updates the mesh's own world matrix from its parent's, and the parent's had not been computed yet. Any collider built at load from a mesh inside a moved, turned or scaled group has this fault; so does one built before a loaded model is scaled. Update the world matrices first, or rebuild the box after the object moves.

Fault 2: the player jumps across the room when it slides

The same page with the strict test swapped for three.js's own:

if (!b.intersectsBox(c)) continue;
interact {"steps": [{"state": {"player": {"x": 3.5, "z": 0}}}, {"key": "ArrowRight", "down": true, "frames": 0}, {"key": "ArrowUp", "frames": 60}, {"key": "ArrowRight", "down": false}], "trace": ["player.x", "player.z"]}

{"changed":{"player":{"x":-5.3,"z":6.7}}}
Motion (player): travelled 18.1 units (11.1 from start to end), its direction of travel turned 90° right.
player.x: 3.5 → peak 4.3 at frame 17 → -5.3 at frame 18; first changed at frame 1
player.z: 0 → low -0.75 at frame 16 → 6.7 at frame 17; first changed at frame 1

Walking straight into the wall still stops at 4.3; the fault shows once the player slides. At frame 17 the player rests against the east wall, its face exactly on the wall's. The next move north finds the player's box "intersecting" the east wall, which it touches, and resolves that overlap along z, the axis it is moving on: it puts the player past the east wall's south end, and z jumps from -0.75 to 6.7 in one frame. There it touches the south wall too, and the next x move puts it past that wall's west end, at -5.3. In two frames the player has left the room by its south-west corner, and the Motion line counts 18.1 units of travel where 60 frames at 4 units a second make 4. A trace shows a jump like this as a peak or low followed by a new value one frame later.

Fault 3: the player sticks to the wall

The same page moving both axes at once and undoing the whole step on any overlap, the shortest answer to "how do I stop at a wall":

function move(dx, dz) {
  const p = player.position;
  p.x += dx; p.z += dz;
  const b = playerBox(p.x, p.z);
  if (colliders.some((c) => overlaps(b, c))) { p.x -= dx; p.z -= dz; }
}
interact {"steps": [{"key": "ArrowRight", "frames": 90}], "trace": ["player.x"]}

{"changed":{"player":{"x":4.27}}}
Motion (player): travelled 4.27 units, it stopped at frame 65 and did not move again.
player.x: 0 → 4.27 at frame 65; first changed at frame 2

interact {"steps": [{"state": {"player": {"x": 3.5, "z": 0}}}, {"key": "ArrowRight", "down": true, "frames": 0}, {"key": "ArrowUp", "frames": 60}, {"key": "ArrowRight", "down": false}], "trace": ["player.x", "player.z"]}

{"changed":{"player":{"x":4.25,"z":-0.75}}}
Motion (player): travelled 1.07 units, it stopped at frame 16 and did not move again.
player.x: 3.5 → 4.25 at frame 16; first changed at frame 1
player.z: 0 → -0.75 at frame 16; first changed at frame 1

Two things differ from the page at the top. The player stops at 4.27, short of the wall by less than one frame's step, since the step that would have overlapped is undone whole; a fast player stops a visible gap away. And on the diagonal both values freeze at frame 16, though the up arrow is held to frame 60: the player sticks to the wall instead of running along it. Resolving one axis at a time, and putting the player against the face rather than back where it was, fixes both.

Keep the walls as checks

expect and save keep each play in game-tests.json once its values hold:

interact {"steps": [{"key": "ArrowRight", "frames": 90}], "expect": {"player.x": [4.29, 4.31]}, "save": "east-wall"}

{"changed":{"player":{"x":4.3}}}
Motion (player): travelled 4.3 units, it stopped at frame 66 and did not move again.
Expect: player.x 4.3 ✓
Saved check "east-wall" in game-tests.json (1 check now); `test` reruns them all, each from a fresh reload, and the after-edit hook reruns them after each edit, speaking only when one starts or stops failing.

interact {"steps": [{"state": {"player": {"x": 3.5, "z": 0}}}, {"key": "ArrowRight", "down": true, "frames": 0}, {"key": "ArrowUp", "frames": 60}, {"key": "ArrowRight", "down": false}], "expect": {"player.x": [4.29, 4.31], "player.z": [-2.9, -2.7]}, "save": "slide-north"}

{"changed":{"player":{"x":4.3,"z":-2.83}}}
Expect: player.x 4.3 ✓ · player.z -2.83 ✓
Saved check "slide-north" in game-tests.json (2 checks now); `test` reruns them all, each from a fresh reload.

test {}

✓ east-wall
✓ slide-north
2 of 2 saved checks passed in 1.0 s

test on each faulty page, in the order above:

test {}

✗ east-wall: player.x 5.3, expected 4.29 to 4.31
✗ slide-north: player.x 5.3, expected 4.29 to 4.31
0 of 2 saved checks passed in 0.9 s

test {}

✓ east-wall
✗ slide-north: player.x -5.3, expected 4.29 to 4.31; player.z 6.7, expected -2.9 to -2.7
1 of 2 saved checks passed in 0.7 s

test {}

✗ east-wall: player.x 4.27, expected 4.29 to 4.31
✗ slide-north: player.x 4.25, expected 4.29 to 4.31; player.z -0.75, expected -2.9 to -2.7
0 of 2 saved checks passed in 0.8 s

The straight walk alone passes fault 2, which is why the slide is a check of its own. In a Claude Code session the after-edit hook reruns both after each edit and speaks when one starts or stops failing, so a later change to the level or the movement code that lets the player through a wall is named at the edit that did it.

What to read in the replies

Pitfalls

What paprika3d does not do

It does not write or fix collision code, and it has no collision detection of its own to add to a page: it plays the keys, reads the positions the page's own code produced, and keeps the play as a check. A check covers the plays saved; a wall no play walks into is not tested. Its clock steps 1/60 s a frame, so a fault that appears at another frame rate does not show in these plays. When the game wants pushing, stacking, rolling or falling, a physics engine is the answer: the threejs-game skill has a Rapier template, and three.js physics in a web worker runs one off the main thread.

Without paprika3d

Draw the boxes: scene.add(new THREE.Box3Helper(box, 0xff0000)) for each collider shows at once whether the boxes sit where the walls are drawn. Log the player's position each frame while you hold a key into a wall, and again on a diagonal; a value that stops on both axes is a stick, and one that changes by more than a frame's step is a jump. A small test page that places the player against each wall with code and moves it one step answers the same questions without a keyboard.