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:
- Proportional (\(K_p\)): Produces a corrective force proportional to the current angular error. A larger tilt produces a larger restoring torque.
- Integral (\(K_i\)): Accumulates residual error over time to eliminate steady-state offsets caused by continuous disturbances like lateral wind or asymmetric mass.
- Derivative (\(K_d\)): Responds to the rate of change of the error, acting as a rotational damper to prevent the rocket from overshooting the vertical axis.
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
- 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.
- 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.
- 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.