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:

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.