Model Archerfish Trajectory in Matter.js

This guide explains how to simulate an archerfish hunting aerial prey by modeling water droplet ballistic trajectories within the Matter.js 2D physics engine. You will learn the mechanics of calculating targeting velocities against hovering targets, accounting for Matter.js gravity and air resistance, modeling the projectile's behavior, and handling collision events when the droplet knocks the insect down.

Setting Up the Environment

To begin, initialize a standard Matter.js simulation containing the engine, renderer, and world. The scene requires three primary elements: the water surface, the underwater archerfish (the launch origin), and an aerial insect (the target body).

const { Engine, Render, Runner, Bodies, Composite, Body, Events, Vector } = Matter;

const engine = Engine.create();
const world = engine.world;

// Standard gravity in Matter.js acts downwards along the Y-axis
engine.gravity.y = 1; 

const render = Render.create({
    element: document.body,
    engine: engine,
    options: { width: 800, height: 600, wireframes: false }
});

Render.run(render);
Runner.run(Runner.create(), engine);

// Define the insect perched above the water
const insect = Bodies.circle(600, 150, 10, {
    isStatic: true,
    render: { fillStyle: '#e74c3c' }
});

Composite.add(world, insect);

Calculating the Launch Trajectory

Archerfish must compensate for gravity to hit prey above the water surface. In physics simulations, the most reliable way to hit a target at coordinates \((x_t, y_t)\) from launch point \((x_0, y_0)\) is using a fixed time-of-flight equation.

Given time \(t\) in frames or seconds, gravity \(g\), horizontal displacement \(\Delta x = x_t - x_0\), and vertical displacement \(\Delta y = y_t - y_0\):

\[v_x = \frac{\Delta x}{t}\] \[v_y = \frac{\Delta y - \frac{1}{2} g t^2}{t}\]

In Matter.js, gravity scale defaults to 0.001 per engine update step. When working with body velocities via Body.setVelocity, velocities are expressed in units of pixels per step.

function calculateLaunchVelocity(origin, target, flightTime) {
    const gravity = engine.gravity.y * engine.gravity.scale;
    
    const deltaX = target.x - origin.x;
    const deltaY = target.y - origin.y;
    
    const vx = deltaX / flightTime;
    const vy = (deltaY - 0.5 * gravity * Math.pow(flightTime, 2)) / flightTime;
    
    return { x: vx, y: vy };
}

Simulating Droplet Characteristics

A single jet of water fired by an archerfish behaves as a compact mass that experiences aerodynamic drag before impact. Create the droplet body at the mouth coordinate and set its properties accordingly:

function shootDroplet(mouthPosition, targetPosition) {
    const flightTime = 45; // Time in engine ticks to reach target
    const velocity = calculateLaunchVelocity(mouthPosition, targetPosition, flightTime);

    const droplet = Bodies.circle(mouthPosition.x, mouthPosition.y, 6, {
        density: 0.001,
        frictionAir: 0.005, // Minimal drag for high-cohesion jet
        restitution: 0.1,
        label: 'waterDroplet',
        render: { fillStyle: '#3498db' }
    });

    Composite.add(world, droplet);
    Body.setVelocity(droplet, velocity);
}

If drag (frictionAir) is greater than zero, slightly increase initial velocities to offset speed decay, or decrease flightTime to minimize air resistance interference.

Optical Refraction Offset

Real archerfish adapt their aim to optical refraction at the air-water interface. If the fish is submerged and viewing an aerial target through water, Snell's Law shifts the virtual image of the insect:

function getApparentPosition(fishPos, targetPos, waterLevel) {
    const nWater = 1.333; // Refractive index of water
    const nAir = 1.0;     // Refractive index of air
    
    // Calculate angle of incidence and adjust virtual target
    const dx = targetPos.x - fishPos.x;
    const dy = targetPos.y - waterLevel;
    const apparentAngle = Math.atan2(dx, Math.abs(dy));
    const trueAngle = Math.asin((nAir / nWater) * Math.sin(apparentAngle));
    
    // Corrected coordinate for direct ballistic firing
    return {
        x: fishPos.x + Math.tan(trueAngle) * Math.abs(targetPos.y - fishPos.y),
        y: targetPos.y
    };
}

Handling Impact and Target Dislodgement

Listen for collisions to simulate the insect being knocked off its perch into the water. Upon impact, set the insect to non-static and transfer the droplet's linear momentum.

Events.on(engine, 'collisionStart', (event) => {
    event.pairs.forEach((pair) => {
        const { bodyA, bodyB } = pair;

        if (
            (bodyA.label === 'waterDroplet' && bodyB === insect) ||
            (bodyB.label === 'waterDroplet' && bodyA === insect)
        ) {
            // Dislodge the target
            Body.setStatic(insect, false);

            // Apply impact impulse
            const droplet = bodyA.label === 'waterDroplet' ? bodyA : bodyB;
            Body.applyForce(insect, insect.position, {
                x: droplet.velocity.x * 0.002,
                y: droplet.velocity.y * 0.002
            });

            // Remove droplet from world after impact
            Composite.remove(world, droplet);
        }
    });
});

Using this architecture, the droplet follows a parabolic path across the air-water boundary and delivers kinetic energy sufficient to knock the prey out of equilibrium.