Build a Submarine with Ballast Tanks in Matter.js

This guide explains how to construct a controllable submarine in the Matter.js physics engine using realistic buoyancy, hydrodynamic drag, and adjustable ballast tanks. Because Matter.js is a rigid-body engine without built-in fluid dynamics, you must simulate the interaction between water and the vessel manually. By dynamically adjusting the contents of virtual ballast tanks and applying buoyant and damping forces on every physics tick, you can achieve smooth diving, surfacing, and neutral buoyancy.

1. The Core Physics Mechanics

To simulate a submarine, you need to balance two primary vertical forces and counteract kinetic energy using fluid resistance:

2. Creating the Submarine Body

Using a compound body allows you to simulate separate fore and aft ballast tanks, giving you realistic pitch control alongside depth control.

const { Bodies, Body, Composite } = Matter;

// Submarine dimensions
const hullWidth = 160;
const hullHeight = 40;

// Main hull
const hull = Bodies.rectangle(400, 300, hullWidth, hullHeight, {
    density: 0.001,
    frictionAir: 0.02
});

const submarine = Body.create({
    parts: [hull],
    frictionAir: 0.02
});

// Ballast tank state (values from 0.0 = empty to 1.0 = fully flooded)
submarine.ballast = {
    tankLevel: 0.5,     // 0.5 represents neutral buoyancy
    maxCapacity: 0.0008 // Force modifier
};

Composite.add(engine.world, submarine);

3. Calculating Submergence and Buoyancy

Define a vertical coordinate in your world representing the water surface line (waterLineY). On every engine update, determine how much of the submarine is below this line.

const waterLineY = 250;
const waterDensity = 0.0015;

function getSubmergedRatio(body, waterY) {
    const top = body.bounds.min.y;
    const bottom = body.bounds.max.y;

    if (bottom <= waterY) return 0; // Fully above water
    if (top >= waterY) return 1;    // Fully submerged
    return (bottom - waterY) / (bottom - top);
}

4. Applying Ballast and Hydrodynamic Forces

Hook into the beforeUpdate event of your engine. In this loop, calculate the displacement, compute the upward buoyant force against the submarine's mass and current ballast level, and apply opposing drag forces.

Matter.Events.on(engine, 'beforeUpdate', () => {
    const submergedRatio = getSubmergedRatio(submarine, waterLineY);

    if (submergedRatio > 0) {
        // 1. Calculate Base Buoyant Force
        const displacedMass = submarine.mass * submergedRatio;
        const gravity = engine.gravity.scale * engine.gravity.y;
        let buoyantForceMagnitude = displacedMass * gravity;

        // 2. Factor in Ballast Tanks
        // A lower tank level produces net positive buoyancy; higher produces negative buoyancy
        const ballastOffset = (0.5 - submarine.ballast.tankLevel) * submarine.ballast.maxCapacity;
        const netUpwardForce = buoyantForceMagnitude + (ballastOffset * submergedRatio);

        // Apply buoyant force at the center of mass
        Body.applyForce(submarine, submarine.position, {
            x: 0,
            y: -netUpwardForce
        });

        // 3. Fluid Drag (Linear and Angular Damping)
        const dragFactor = 0.05 * submergedRatio;
        Body.applyForce(submarine, submarine.position, {
            x: -submarine.velocity.x * dragFactor * submarine.mass * 0.01,
            y: -submarine.velocity.y * dragFactor * submarine.mass * 0.01
        });

        // Angular resistance to stabilize rotation underwater
        submarine.torque -= submarine.angularVelocity * 0.15 * submergedRatio;
    }
});

5. Controlling Ballast to Submerge and Surface

To submerge, open the flood valves to increase the ballast level. To surface, blow the tanks with compressed air to decrease the level.

// Flood ballast (Submerge)
function floodBallast(rate = 0.01) {
    submarine.ballast.tankLevel = Math.min(1.0, submarine.ballast.tankLevel + rate);
}

// Blow ballast (Surface)
function blowBallast(rate = 0.01) {
    submarine.ballast.tankLevel = Math.max(0.0, submarine.ballast.tankLevel - rate);
}

// Example keybindings
window.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowDown') floodBallast();
    if (e.key === 'ArrowUp') blowBallast();
});

6. Tuning for Realism