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.

  1. Find Neighbors: Iterate through all fish within a set radius (e.g., 80 pixels).
  2. Compute Center of Mass: Sum their positions and divide by the neighbor count.
  3. 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.