Matter.js Platformer Controller with Smooth Slopes
Building a 2D platformer character controller in Matter.js often leads to issues on inclined surfaces, such as catching on edges, sliding backward, or moving at inconsistent speeds. This guide outlines how to construct a robust character controller in Matter.js that navigates slopes smoothly by using rounded compound bodies, custom velocity projections, ground sensors, and downward slope clamping.
1. Constructing a Rounded Compound Body
Default rectangular hitboxes snag against the vertices of angled static bodies. To achieve smooth movement over slopes, construct a compound body with a rounded base (such as a capsule or a rectangle paired with a circle at the bottom) and lock its rotation.
const { Bodies, Body } = Matter;
const width = 40;
const height = 60;
const radius = width / 2;
// Main torso and rounded bottom
const torso = Bodies.rectangle(x, y - radius / 2, width, height - radius, {
collisionFilter: { group: -1 }
});
const feet = Bodies.circle(x, y + height / 2 - radius, radius, {
collisionFilter: { group: -1 }
});
// Ground check sensor positioned slightly below the feet
const sensor = Bodies.rectangle(x, y + height / 2, width * 0.8, 10, {
isSensor: true,
isStatic: false
});
const player = Body.create({
parts: [torso, feet, sensor],
inertia: Infinity, // Locks rotation
friction: 0, // Prevents catching on walls/slopes
frictionStatic: 0,
restitution: 0
});2. Detecting the Ground and Surface Normal
Matter.js generates collision pairs containing contact normals during the update loop. To walk up and down an incline at a uniform speed, you need to read the slope's normal vector rather than assuming horizontal movement.
Listen to collisionActive events to identify when the
bottom sensor contacts a surface and store the surface's normal
vector:
let isGrounded = false;
let groundNormal = { x: 0, y: -1 };
Matter.Events.on(engine, 'collisionActive', (event) => {
isGrounded = false;
event.pairs.forEach((pair) => {
if (pair.bodyA === sensor || pair.bodyB === sensor) {
isGrounded = true;
// Extract the normal pointing outward from the surface
const normal = pair.collision.normal;
if (pair.bodyA === sensor) {
groundNormal = { x: -normal.x, y: -normal.y };
} else {
groundNormal = { x: normal.x, y: normal.y };
}
}
});
});3. Projecting Velocity Along the Incline
Applying velocity solely on the X-axis causes a character to push directly into a slope, killing momentum, or to launch into the air when cresting a hill. Instead, calculate the tangent of the ground normal and scale your movement along that tangent:
function updatePlayer(horizontalInput, moveSpeed) {
if (isGrounded && Math.abs(groundNormal.x) > 0.01) {
// Calculate the tangent perpendicular to the ground normal
// Normal: (nx, ny) -> Tangent: (-ny, nx)
const tangent = { x: -groundNormal.y, y: groundNormal.x };
// Direct movement along the surface tangent
const targetVelocityX = tangent.x * horizontalInput * moveSpeed;
const targetVelocityY = tangent.y * horizontalInput * moveSpeed;
Body.setVelocity(player, {
x: targetVelocityX,
y: targetVelocityY
});
} else {
// Standard horizontal movement while airborne or on flat surfaces
Body.setVelocity(player, {
x: horizontalInput * moveSpeed,
y: player.velocity.y
});
}
}4. Preventing Downhill Bouncing and Slope Sliding
When descending a slope, momentum can cause the player to detach from
the incline and skip downward. Additionally, setting
friction: 0 can cause the player to slide down slopes while
idle.
Handle these issues in the engine's beforeUpdate
cycle:
- Slope Snapping: If
isGroundedis true and horizontal input is moving down the slope, apply a slight downward force (Body.applyForce) or an explicit downward velocity clamp to ensure the sensor does not break contact with the surface. - Idle Slope Holding: When
horizontalInput === 0andisGroundedis true, temporarily zero out both the X and Y velocities to resist gravity without needing dynamic friction adjustments:
Matter.Events.on(engine, 'beforeUpdate', () => {
if (isGrounded && horizontalInput === 0) {
Body.setVelocity(player, { x: 0, y: 0 });
}
});By combining a rounded collision boundary, normal-projected movement, and an active velocity lock when stationary, the character moves up and down sloped geometry with consistent speed and full directional control.