Matter.js 2D Platformer Character Controller

Building a responsive platformer character controller using Matter.js requires balancing rigid-body physics with predictable, arcade-style movement. This guide explains how to configure a character body, prevent unwanted tipping, set up a ground-check sensor, handle horizontal movement with friction mitigation, and implement a consistent jump mechanic.

1. Creating the Character Body

Standard rigid bodies in physics engines tend to rotate and snag on terrain edges. To solve this, create a composite body consisting of a main body with locked rotation and slightly rounded edges (or a chamfered rectangle), paired with a sensor at the bottom to detect the floor.

const { Bodies, Body } = Matter;

const width = 40;
const height = 60;
const x = 200;
const y = 200;

// Main collision body
const playerBody = Bodies.rectangle(x, y, width, height, {
  chamfer: { radius: 10 },
  friction: 0.001,
  frictionAir: 0.05,
  render: { fillStyle: '#2ecc71' }
});

// Ground detection sensor placed at the player's feet
const sensorHeight = 6;
const groundSensor = Bodies.rectangle(x, y + height / 2, width * 0.8, sensorHeight, {
  isSensor: true,
  render: { visible: false }
});

// Compound body
const player = Body.create({
  parts: [playerBody, groundSensor],
  inertia: Infinity // Locks rotation to keep the player upright
});

Setting inertia: Infinity guarantees the character remains strictly vertical regardless of impacts.

2. Detecting Ground Contacts

To prevent infinite jumping in mid-air, track collisions specifically between the groundSensor and solid terrain bodies.

let isGrounded = false;

Matter.Events.on(engine, 'collisionStart', (event) => {
  event.pairs.forEach((pair) => {
    if (pair.bodyA === groundSensor || pair.bodyB === groundSensor) {
      isGrounded = true;
    }
  });
});

Matter.Events.on(engine, 'collisionEnd', (event) => {
  event.pairs.forEach((pair) => {
    if (pair.bodyA === groundSensor || pair.bodyB === groundSensor) {
      isGrounded = false;
    }
  });
});

3. Implementing Horizontal Movement

Applying raw forces (Body.applyForce) can lead to a floaty feeling. For snappy platformer controls, directly manipulate the horizontal velocity while preserving the vertical physics generated by gravity.

const moveSpeed = 5;
const keys = { left: false, right: false };

window.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = true;
  if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = true;
  if ((e.code === 'Space' || e.code === 'ArrowUp') && isGrounded) {
    jump();
  }
});

window.addEventListener('keyup', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = false;
  if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = false;
});

function updateMovement() {
  let targetVelocityX = 0;

  if (keys.left) targetVelocityX -= moveSpeed;
  if (keys.right) targetVelocityX += moveSpeed;

  Matter.Body.setVelocity(player, {
    x: targetVelocityX,
    y: player.velocity.y
  });
}

4. Executing the Jump

When the jump command triggers, override the vertical velocity directly to deliver an instantaneous, fixed-height upward burst.

const jumpForce = 12;

function jump() {
  Matter.Body.setVelocity(player, {
    x: player.velocity.x,
    y: -jumpForce
  });
}

Call updateMovement() inside the engine's beforeUpdate event loop:

Matter.Events.on(engine, 'beforeUpdate', () => {
  updateMovement();
});

5. Overcoming Common Platformer Issues