Drone Swarm Simulation — Flocking + Formation Control
Real drone swarms (Amazon Prime Air, Starling Murmuration drones, military OFFSET program) combine two layers: a bottom-up Boids flocking layer that provides collision avoidance, and a top-down formation control layer that assigns position targets. This tutorial builds both from scratch in JavaScript.
1. Agent Architecture
Each drone is a lightweight data object. Keep physics and rendering separate — the physics loop runs at a fixed timestep (e.g., 60 Hz) while Three.js renders at whatever rate the GPU allows.
class Drone {
constructor(id, x, y, z) {
this.id = id;
this.pos = new THREE.Vector3(x, y, z);
this.vel = new THREE.Vector3();
this.acc = new THREE.Vector3();
this.target = new THREE.Vector3(); // formation slot
this.state = 'FLOCK'; // FLOCK | FORM | AVOID | RETURN
this.maxSpeed = 5.0;
this.maxForce = 0.3;
this.mesh = null; // assigned by renderer
}
applyForce(f) {
this.acc.add(f);
}
update(dt) {
this.vel.addScaledVector(this.acc, dt);
this.vel.clampLength(0, this.maxSpeed);
this.pos.addScaledVector(this.vel, dt);
this.acc.set(0, 0, 0);
}
}
2. Layer 1: Boids Flocking
The Boids layer handles collision avoidance between drones and gives the swarm organic movement. Three steering forces are combined — the same rules as the classic Reynolds (1987) algorithm, but tuned for 3D:
function boidsForces(drone, drones) {
const SEP_RADIUS = 4; // personal space (metres equivalent)
const VIEW_RADIUS = 12;
const W_SEP = 1.8;
const W_ALI = 1.0;
const W_COH = 0.8;
const sep = new THREE.Vector3();
const ali = new THREE.Vector3();
const coh = new THREE.Vector3();
let ns = 0, nv = 0;
for (const other of drones) {
if (other === drone) continue;
const d = drone.pos.distanceTo(other.pos);
if (d < SEP_RADIUS) {
sep.addScaledVector(
new THREE.Vector3().subVectors(drone.pos, other.pos).normalize(),
1 / d); // weight by inverse distance
ns++;
}
if (d < VIEW_RADIUS) {
ali.add(other.vel);
coh.add(other.pos);
nv++;
}
}
const total = new THREE.Vector3();
if (ns > 0) total.addScaledVector(
seek(drone, drone.pos.clone().add(sep)), W_SEP);
if (nv > 0) {
ali.divideScalar(nv).normalize().multiplyScalar(drone.maxSpeed);
total.addScaledVector(steer(drone, ali), W_ALI);
coh.divideScalar(nv);
total.addScaledVector(seek(drone, coh), W_COH);
}
return total;
}
// Seek: steer toward target at max speed
function seek(drone, target) {
const desired = new THREE.Vector3()
.subVectors(target, drone.pos).normalize()
.multiplyScalar(drone.maxSpeed);
return steer(drone, desired);
}
function steer(drone, desired) {
return desired.sub(drone.vel).clampLength(0, drone.maxForce);
}
3. Layer 2: Formation Patterns
The formation layer assigns each drone a world-space
slot target. The drone then steers toward its slot.
When drone.state === 'FORM', the Boids cohesion weight is
reduced and a strong seek-to-slot force is added.
V-Formation (migratory birds)
function vFormationSlots(n, spacing = 4) {
const slots = [];
for (let i = 0; i < n; i++) {
const side = i % 2 === 0 ? 1 : -1;
const rank = Math.ceil(i / 2);
slots.push(new THREE.Vector3(
side * rank * spacing, // X: left/right wing
0, // Y: same altitude
-rank * spacing * 0.8 // Z: behind leader
));
}
return slots; // slot[0] = leader at origin
}
Circle Formation
function circleFormationSlots(n, radius = 10) {
return Array.from({length: n}, (_, i) => {
const angle = (i / n) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
});
}
Grid Formation
function gridFormationSlots(n, cols = 4, spacing = 5) {
return Array.from({length: n}, (_, i) => {
const col = i % cols, row = Math.floor(i / cols);
return new THREE.Vector3(
(col - (cols - 1) / 2) * spacing,
0,
row * spacing);
});
}
To assign slots to drones, use a greedy
nearest-neighbor assignment to minimize total travel distance — or,
for optimal assignment with up to ~30 drones, solve the
linear sum assignment problem (Hungarian algorithm) via a
JavaScript port of scipy's linear_sum_assignment.
// Greedy slot assignment: each drone takes its nearest slot
function assignSlots(drones, slots) {
const used = new Set();
for (const d of drones) {
let best = -1, bestDist = Infinity;
for (let j = 0; j < slots.length; j++) {
if (used.has(j)) continue;
const dist = d.pos.distanceTo(slots[j]);
if (dist < bestDist) { bestDist = dist; best = j; }
}
d.target.copy(slots[best]);
used.add(best);
}
}
4. Leader-Follower Control
One drone acts as the leader and follows a predefined path (waypoints or user input). Followers maintain a fixed offset from the leader's position and orientation. This is simpler than full formation control and works well for convoys.
function leaderFollowerForce(follower, leader, offsetLocal) {
// Transform the offset from leader's local frame to world frame
const leaderQ = new THREE.Quaternion()
.setFromUnitVectors(
new THREE.Vector3(0, 0, 1),
leader.vel.clone().normalize()
);
const worldTarget = offsetLocal.clone()
.applyQuaternion(leaderQ)
.add(leader.pos);
// Arrival steering: slow down as we approach the slot
const d = follower.pos.distanceTo(worldTarget);
const SLOW_RADIUS = 6;
const speed = d < SLOW_RADIUS
? follower.maxSpeed * (d / SLOW_RADIUS)
: follower.maxSpeed;
const desired = worldTarget.clone()
.sub(follower.pos).normalize().multiplyScalar(speed);
return desired.sub(follower.vel).clampLength(0, follower.maxForce);
}
5. Obstacle Avoidance — Artificial Potential Fields
Obstacles generate a repulsive potential field. Targets generate an attractive field. The drone follows the net gradient. This is fast (O(1) per obstacle) and works well for convex obstacles.
function obstacleAvoidForce(drone, obstacles) {
const OBS_RADIUS = 8; // influence radius
const force = new THREE.Vector3();
for (const obs of obstacles) {
const diff = drone.pos.clone().sub(obs.center);
const d = diff.length() - obs.radius; // clearance distance
if (d < OBS_RADIUS && d > 0) {
// APF: F = k * (1/d - 1/OBS_RADIUS) * (1/d²) * unitVec
const k = 50;
const mag = k * (1 / d - 1 / OBS_RADIUS) * (1 / (d * d));
force.addScaledVector(diff.normalize(), mag);
}
}
return force.clampLength(0, drone.maxForce * 3);
}
6. Agent State Machine
Each drone's behavior is governed by a finite state machine. This cleanly separates the different behavior modes:
| State | Active Forces | Transition To |
|---|---|---|
| FLOCK | Separation + Alignment + Cohesion | FORM (user command), AVOID (obstacle detected) |
| FORM | Separation (high W) + Seek slot (high W) | FLOCK (form dissolved), AVOID |
| AVOID | Obstacle repulsion + Boids separation | FLOCK/FORM (obstacle cleared) |
| RETURN | Seek home base + Separation | LAND (reached base) |
function updateDrone(drone, drones, obstacles, formation, dt) {
const obsForce = obstacleAvoidForce(drone, obstacles);
const inDanger = obsForce.length() > 0.5;
if (inDanger) {
drone.state = 'AVOID';
} else if (drone.state === 'AVOID') {
drone.state = formation ? 'FORM' : 'FLOCK';
}
switch (drone.state) {
case 'FLOCK':
drone.applyForce(boidsForces(drone, drones));
break;
case 'FORM':
drone.applyForce(boidsForces(drone, drones).multiplyScalar(0.3));
drone.applyForce(seek(drone, drone.target).multiplyScalar(2.0));
break;
case 'AVOID':
drone.applyForce(obsForce);
drone.applyForce(boidsForces(drone, drones).multiplyScalar(1.5));
break;
}
drone.applyForce(obsForce); // always applies
drone.update(dt);
}
7. Three.js Rendering with InstancedMesh
For 100+ drones, use THREE.InstancedMesh to render all
drones in a single draw call. Each drone's position and quaternion are
written into the instance matrix every frame.
// Create instanced mesh for N drones
const geo = new THREE.ConeGeometry(0.3, 1.0, 6);
geo.rotateX(Math.PI / 2); // point along Z axis
const mat = new THREE.MeshStandardMaterial({ color: 0x34d399 });
const mesh = new THREE.InstancedMesh(geo, mat, drones.length);
scene.add(mesh);
const dummy = new THREE.Object3D();
const _up = new THREE.Vector3(0, 1, 0);
function syncInstancedMesh() {
for (let i = 0; i < drones.length; i++) {
const d = drones[i];
dummy.position.copy(d.pos);
if (d.vel.lengthSq() > 0.001) {
dummy.quaternion.setFromUnitVectors(_up, d.vel.clone().normalize());
}
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
}
8. Tuning Guide
| Parameter | Default | Effect of Increasing |
|---|---|---|
SEP_RADIUS |
4 | Drones spread farther apart — less density, more spacing |
VIEW_RADIUS |
12 | Larger neighborhood → more uniform direction, slower to split |
W_SEP |
1.8 | Harder collision avoidance, less cohesion |
W_ALI |
1.0 | More disciplined direction alignment |
W_COH |
0.8 | Tighter clustering around group center |
maxForce |
0.3 | More responsive turns but can cause oscillation |
maxSpeed |
5.0 | Faster swarm — may need larger separation radius |