Ballistic Pendulum Mechanics in Matter.js

This article explains how to simulate a ballistic pendulum using the Matter.js 2D physics engine to derive an unknown projectile launch speed. By modeling an inelastic collision between a high-speed projectile and a suspended pendulum bob, you can measure the maximum swing height of the combined mass and apply the laws of conservation of momentum and mechanical energy to calculate the projectile's initial velocity.

Physics Principles of the Ballistic Pendulum

A ballistic pendulum operates in two distinct stages:

  1. The Inelastic Collision: A projectile of mass \(m\) moving with an initial horizontal velocity \(v\) embeds itself into a stationary pendulum bob of mass \(M\). Momentum is conserved: \[m \cdot v = (m + M) \cdot V\] where \(V\) is the combined velocity immediately after impact.

  2. The Swing (Mechanical Energy Conservation): The combined mass swings upward to a maximum height \(h\). Kinetic energy converts into gravitational potential energy: \[\frac{1}{2}(m + M)V^2 = (m + M)gh \implies V = \sqrt{2gh}\]

Combining both stages allows you to calculate the projectile's launch velocity: \[v = \frac{m + M}{m}\sqrt{2gh}\]

In computer coordinates, \(h = y_{\text{rest}} - y_{\text{peak}}\), representing the upward vertical displacement of the pendulum's center of mass.

Setting Up the Simulation Environment

To implement this in Matter.js, include the library and set up the foundational components: the physics engine, a renderer, and an update runner.

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

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

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

Render.run(render);
const runner = Runner.create();
Runner.run(runner, engine);

Constructing the Pendulum and Projectile

Construct the pendulum using a stationary anchor point and a dynamic body connected by a rigid Constraint. Place the projectile at a distance aligned with the resting bob's center.

const pendulumLength = 300;
const anchorX = 400;
const anchorY = 100;

// Pendulum Bob (Mass M)
const bobRadius = 40;
const bob = Bodies.circle(anchorX, anchorY + pendulumLength, bobRadius, {
    mass: 2.0,
    restitution: 0, // Prevent elastic bouncing
    frictionAir: 0  // Minimize drag to preserve energy
});

// Rigid Arm Constraint
const arm = Constraint.create({
    pointA: { x: anchorX, y: anchorY },
    bodyB: bob,
    length: pendulumLength,
    stiffness: 1
});

// Projectile (Mass m)
const projectileRadius = 10;
const projectile = Bodies.circle(100, anchorY + pendulumLength, projectileRadius, {
    mass: 0.1,
    restitution: 0,
    frictionAir: 0
});

Composite.add(world, [bob, arm, projectile]);

Simulating the Inelastic Capture

A classical ballistic pendulum captures the bullet completely. In Matter.js, rigid body collisions tend to push bodies apart. To simulate capture, listen for the collision event between the projectile and the bob, then bind them together using a fixed constraint.

let isCaptured = false;
let initialRestY = bob.position.y;
let peakY = initialRestY;
let trackingActive = false;

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

        if (involvesProjectile && involvesBob && !isCaptured) {
            isCaptured = true;
            trackingActive = true;

            // Pin projectile to bob to mimic an embedded impact
            const joint = Constraint.create({
                bodyA: bob,
                bodyB: projectile,
                stiffness: 1,
                length: 0
            });
            Composite.add(world, joint);
        }
    });
});

Launching the Projectile

Apply an initial velocity to the projectile using Body.setVelocity. This represents the launch speed \(v\) that the simulation will later calculate from the swing height.

const actualLaunchSpeed = 25; // Pixels per frame
Body.setVelocity(projectile, { x: actualLaunchSpeed, y: 0 });

Tracking Displacement and Calculating Launch Speed

Monitor the bob's vertical position across engine update cycles. Matter.js screen coordinates increase downward, so the peak height corresponds to the minimum \(y\)-value reached during the upward swing.

Events.on(engine, 'afterUpdate', () => {
    if (!trackingActive) return;

    // Track highest elevation (lowest Y coordinate)
    if (bob.position.y < peakY) {
        peakY = bob.position.y;
    }

    // Detect when the pendulum stops rising and begins falling
    if (bob.velocity.y > 0 && peakY < initialRestY) {
        trackingActive = false;

        const h = initialRestY - peakY;
        const g = engine.gravity.y * engine.gravity.scale * 1000; // Gravity scaled to frame time
        const m = projectile.mass;
        const M = bob.mass;

        // Ballistic pendulum equation: v = ((m + M) / m) * sqrt(2 * g * h)
        const calculatedVelocity = ((m + M) / m) * Math.sqrt(2 * g * h);

        console.log(`Measured Rise (h): ${h.toFixed(2)} px`);
        console.log(`Calculated Speed: ${calculatedVelocity.toFixed(2)} px/frame`);
        console.log(`Actual Launch Speed: ${actualLaunchSpeed} px/frame`);
    }
});

Adjusting for Simulation Constraints

Physics engines resolve constraints iteratively, which can introduce small numerical damping during instantaneous high-speed impacts. To maximize calculation accuracy:

  1. Set engine.positionIterations = 10 and engine.velocityIterations = 10 to reduce solver compliance.
  2. Ensure frictionAir remains 0 on both dynamic bodies to satisfy the ideal conservation of energy assumption.
  3. Ensure the projectile's trajectory hits precisely at the center of mass of the bob to avoid introducing unwanted rotational kinetic energy into the arm system.