Matter.js Fish Schooling and Obstacle Avoidance
This article explains how to simulate realistic fish schooling behaviors—primarily cohesion—while navigating around dynamic rigid obstacles using the Matter.js 2D physics engine. By combining Craig Reynolds’ Boids flocking model with proximity-based obstacle avoidance algorithms, you can apply corrective forces to Matter.js rigid bodies each frame. The result is an organic, self-organizing school of entities that stay together as a collective unit while actively steering clear of moving hazards.
Setting Up Fish as Matter.js Bodies
To integrate flocking behaviors into Matter.js, each fish is represented as a rigid body. However, to prevent standard rigid-body collisions from disrupting the fluid movement of the school, fish should generally pass through each other while still interacting with solid objects.
const { Engine, Render, Runner, Bodies, Composite, Body, Vector, Query } = Matter;
function createFish(x, y) {
return Bodies.circle(x, y, 6, {
frictionAir: 0.05,
collisionFilter: {
category: 0x0002,
mask: 0x0001 // Collides with obstacles (category 0x0001), ignores other fish
}
});
}Obstacles should be assigned to the standard collision category so that traditional physics apply if a fish fails to steer away in time.
Calculating Cohesion
Cohesion requires each fish to steer toward the average position (center of mass) of its local neighbors within a specified perception radius.
- Find Neighbors: Iterate through all fish within a set radius (e.g., 80 pixels).
- Compute Center of Mass: Sum their positions and divide by the neighbor count.
- Generate Steering Force: Calculate a vector pointing from the fish's current position to the center of mass, normalize it, and scale it by an appropriate steering weight.
function getCohesionForce(fish, school, perceptionRadius) {
let centerOfMass = { x: 0, y: 0 };
let totalNeighbors = 0;
for (let other of school) {
if (other === fish) continue;
const dist = Vector.magnitude(Vector.sub(other.position, fish.position));
if (dist < perceptionRadius) {
centerOfMass = Vector.add(centerOfMass, other.position);
totalNeighbors++;
}
}
if (totalNeighbors === 0) return { x: 0, y: 0 };
centerOfMass = Vector.div(centerOfMass, totalNeighbors);
const desired = Vector.sub(centerOfMass, fish.position);
const normalized = Vector.normalise(desired);
// Scale by desired cohesion strength
return Vector.mult(normalized, 0.0003);
}Detecting and Avoiding Dynamic Obstacles
To avoid moving obstacles before a physical impact occurs, dynamic look-ahead sensing is required. This can be implemented either through raycasting or proximity field queries.
Raycasting Look-Ahead
Project rays forward along the fish's current velocity vector to
detect obstacles in its immediate path using Query.ray:
function getObstacleAvoidanceForce(fish, obstacles, lookAheadDistance) {
const velocity = fish.velocity;
const speed = Vector.magnitude(velocity);
if (speed === 0) return { x: 0, y: 0 };
const forwardVector = Vector.normalise(velocity);
const rayEnd = Vector.add(fish.position, Vector.mult(forwardVector, lookAheadDistance));
// Check collisions along the ray path
const collisions = Query.ray(obstacles, fish.position, rayEnd);
if (collisions.length > 0) {
const hit = collisions[0];
// Calculate lateral steering perpendicular to the obstacle normal or trajectory
const awayFromObstacle = Vector.sub(fish.position, hit.body.position);
const avoidDirection = Vector.normalise(awayFromObstacle);
// Scale avoidance inversely with distance
const distance = Vector.magnitude(Vector.sub(hit.point, fish.position));
const urgency = 1 - (distance / lookAheadDistance);
return Vector.mult(avoidDirection, 0.002 * urgency);
}
return { x: 0, y: 0 };
}Integrating the Forces in the Simulation Loop
The forces must be calculated and applied continuously on the
beforeUpdate engine event. Ensure that obstacle avoidance
is prioritized over cohesion; otherwise, the tendency to stay with the
flock will cause fish to collide with dynamic obstacles.
Matter.Events.on(engine, 'beforeUpdate', () => {
school.forEach(fish => {
const cohesion = getCohesionForce(fish, school, 100);
const avoidance = getObstacleAvoidanceForce(fish, dynamicObstacles, 60);
// Weighting: Avoidance overrides cohesion
const totalForce = Vector.add(cohesion, avoidance);
Body.applyForce(fish, fish.position, totalForce);
// Optional: Align body angle with current movement direction
if (Vector.magnitude(fish.velocity) > 0.1) {
Body.setAngle(fish, Math.atan2(fish.velocity.y, fish.velocity.x));
}
});
});By prioritizing the reactive avoidance vector over the cohesion vector, fish smoothly break formation when an obstacle approaches and naturally rejoin the flock once the path is clear.