PID Attitude Control in Matter.js for Rocket Landers

This article demonstrates how to implement a Proportional-Integral-Derivative (PID) attitude controller to stabilize a 2D rocket lander upright within the Matter.js physics engine. You will learn how to measure rotational error, calculate real-time corrective torques using PID math, apply those corrections inside the Matter.js update loop, and systematically tune your controller gains to eliminate oscillation and overshoot.

Understanding the Control Mechanics

To keep a simulated rocket lander upright, the target angle is set to zero radians (or vertical alignment). The controller evaluates the orientation of the rocket body on each physics tick, computes the deviation from the upright position, and applies a corrective rotational force (torque) or asymmetric thruster impulse to counter the tilt.

The PID algorithm relies on three components:

Implementation in Matter.js

In Matter.js, continuous physics adjustments must take place within the beforeUpdate event hook. You can influence rotational motion either by setting body.torque directly or by applying forces at specific offsets via Matter.Body.applyForce.

Here is a self-contained implementation demonstrating a PID-stabilized rocket:

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

// Initialize Matter.js environment
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);

// Create the rocket lander body
const rocket = Bodies.rectangle(400, 300, 20, 80, {
    mass: 5,
    inertia: 2500, // Explicit moment of inertia helps stabilize simulation
    frictionAir: 0.01
});
Composite.add(world, rocket);

// PID Controller configuration
const PID = {
    kp: 12.0,   // Proportional gain
    ki: 0.05,   // Integral gain
    kd: 35.0,   // Derivative gain
    integral: 0,
    lastError: 0,
    maxTorque: 5.0 // Prevent excessive torque from destabilizing the engine
};

// Normalize angle to the range [-Math.PI, Math.PI]
function normalizeAngle(angle) {
    while (angle > Math.PI) angle -= 2 * Math.PI;
    while (angle < -Math.PI) angle += 2 * Math.PI;
    return angle;
}

// Controller loop executed before every physics step
Events.on(engine, 'beforeUpdate', (event) => {
    const dt = event.source.timing.lastDelta / 1000; // Convert delta to seconds
    if (dt <= 0) return;

    // Target is upright (0 radians)
    const targetAngle = 0;
    const currentAngle = normalizeAngle(rocket.angle);
    const error = targetAngle - currentAngle;

    // Integral calculation with clamp to prevent integral windup
    PID.integral += error * dt;
    PID.integral = Math.max(-1.0, Math.min(1.0, PID.integral));

    // Derivative calculation based on angular velocity to reduce noise
    // Matter.js exposes body.angularVelocity directly
    const derivative = -rocket.angularVelocity;

    // Compute PID output
    let controlTorque = (PID.kp * error) + (PID.ki * PID.integral) + (PID.kd * derivative);

    // Clamp output torque
    controlTorque = Math.max(-PID.maxTorque, Math.min(PID.maxTorque, controlTorque));

    // Apply torque directly to the rocket body
    rocket.torque = controlTorque;

    // Optional: Add main vertical thruster against gravity when tilted
    if (rocket.position.y > 200) {
        const thrustMagnitude = 0.0055 * rocket.mass;
        const thrustAngle = rocket.angle - Math.PI / 2;
        Body.applyForce(rocket, rocket.position, {
            x: Math.cos(thrustAngle) * thrustMagnitude,
            y: Math.sin(thrustAngle) * thrustMagnitude
        });
    }
});

Calculating Angle Wrap-Around

Matter.js tracks body.angle continuously without wrapping it to \(2\pi\). If a rocket performs full rotations, the raw angle value grows unbounded (e.g., \(6.28, 12.56\)). Passing an unconstrained angle directly to the PID controller will cause the error term to grow dramatically, forcing unnecessary spins. The normalizeAngle function constrains the orientation between \(-\pi\) and \(\pi\), ensuring the lander always rotates along the shortest path back to upright.

Using Angular Velocity for the Derivative Term

Standard PID formulations compute the derivative as (currentError - lastError) / dt. In physics simulations, rapid frame-rate fluctuations can introduce derivative kick. Because the target angle is static (\(0\)), the derivative of error equals the negative rotational speed (\(\frac{d}{dt}(0 - \theta) = -\omega\)). Using rocket.angularVelocity directly produces a smoother, jitter-free damping term.

Tuning Strategy

  1. Set \(K_i\) and \(K_d\) to zero: Increase \(K_p\) until the rocket moves toward vertical but oscillates continuously around the zero-angle mark.
  2. Increase \(K_d\): Add derivative damping to counteract the oscillation. Increase \(K_d\) gradually until the rocket smoothly settles to zero without bouncing back and forth.
  3. Introduce \(K_i\): If external factors like constant sideways thrust or asymmetric geometry leave the rocket resting at a slight angle off-vertical, increase \(K_i\) in small increments to force the steady-state error to zero.