
My Science Lab is a Three.js scene with nine themed rooms, an atrium in the middle, and ten AI "scientists" who walk between their desks, a coffee bar and each other's rooms. The first version looked fine in screenshots and terrible in motion. People walked through walls, clipped into each other at the coffee machine, and sometimes ended up standing inside someone who was sitting down.
Here's what actually fixed it. There are four layers, and each one handles a problem the layer before it can't.
My first idea was a tile grid with A* over every cell. For a lab that's mostly corridors and doorways, that's overkill. Instead I placed about 40 hand-picked waypoints: corridor junctions, a point just inside and just outside each door, and a ring of eight points around the big table in the atrium. Each waypoint knows which region it belongs to.
const node = (x, z, region) => { nodes.push({ p: V(x, z), region, n: [] }); return nodes.length - 1; };
const link = (a, b) => { nodes[a].n.push(b); nodes[b].n.push(a); };
// every room is two nodes: one just inside the door, one at the room's hub
const roomLink = (rid, corridorKey, inside, hub) => {
const i = node(inside[0], inside[1], rid), h = node(hub[0], hub[1], rid);
link(C[corridorKey], i); link(i, h);
};
Because the only edges into a room go through its door node, a path can't cut through a wall. I never had to write wall collision for walkers at all.
If you're already in the same room as your target, you just walk straight there. A* runs only when the start and the goal are in different regions:
function route(from, to) {
const ra = regionOf(from), rb = regionOf(to);
if (ra === rb && ra !== 'corridor' && !(ra === 'atrium' && crossesVault(from, to))) return [];
const s = nearest(from, ra), g = nearest(to, rb);
// ...standard A*: g = distance so far, h = straight-line distance to goal
}
There's one exception: the atrium has a big round table in the middle (the "vault"). crossesVault checks whether the straight line passes within 1.9 m of the center. If it does, the scientist goes around using the ring nodes. With about 40 nodes, A* with a plain Set as the open list is instant. A priority queue would be wasted effort here.
A* knows about walls, not people. Two scientists heading opposite ways down a corridor would walk straight into each other. So every frame, each walker looks at everyone within 1.4 m and works out how much they're ahead (the dot product of the offset with my walking direction):
const ahead = -off.dot(dir) / d; // > 0 means they're in front of me
if (ahead > .2) steer.addScaledVector(off.normalize(), (1.4 - d) / 1.4 * (1 + ahead));
if (ahead > .75 && d < .95 && (o.mode !== 'walk' || o.id < s.id)) yieldTo = true;
The steering vector bends my direction sideways, so people pass each other instead of colliding head-on. The yield rule is the important trick. When two walkers meet, only one of them slows down, and it's decided by comparing IDs. Without a tie-breaker, both politely slowed down, both stepped the same way, and they did an awkward hallway dance forever.
Most of the clipping wasn't happening in corridors. It was at the destinations. Three people would pick the same coffee spot because it was free when they decided. So now a spot counts as taken if anyone is standing on it, already walking to it, or ending their path near it:
function taken(s, p) {
return all().some(o => o !== s && (
(o.mode !== 'walk' && o.spot?.p?.distanceTo(p) < .9) ||
(o.mode === 'walk' && (o.nextSpot?.p?.distanceTo(p) < .9 || o.path?.at(-1)?.distanceTo(p) < .9)) ||
o.root.position.distanceTo(p) < .6));
}
Steering is soft, so it can still lose to a crowded doorway. The last pass in every frame enforces a hard minimum distance of 0.55 m between any two people. Who moves depends on posture. A seated person never gets pushed, so the standing one takes the whole correction. Two standing people split it.
if (d < MIN) {
const fix = MIN - d;
if (o.sit > .5 && s.sit <= .5) pos.addScaledVector(off, fix); // they're seated: I move
else if (s.sit <= .5) { pos.addScaledVector(off, fix / 2); // both standing: split it
o.root.position.addScaledVector(off, -fix / 2); }
}
Watching for clipping by eye doesn't work. So I exposed a small debug hook, window.__lab, which can step the simulation forward without rendering. Then I ran thousands of frames and logged the closest distance between any two people. (This also got around browsers pausing requestAnimationFrame in background tabs.)
| Version | Overlaps (< 0.55 m) |
|---|---|
| Waypoints + A* only | constant, worst at the coffee bar |
| + steering and yielding | rare, mostly at destinations |
| + spot reservation + hard rule | 0 |
world.js and people.js fixed that.Open the live lab → (it runs in demo mode in your browser)