Multi-Touch Dragging of Multiple Matter.js Bodies

Matter.js provides a built-in MouseConstraint designed primarily for single-pointer inputs, making it incapable of handling simultaneous interactions with separate physics bodies on touch devices. This article explains how to bypass the default single-pointer constraint system and implement true multi-touch dragging in Matter.js. By binding custom constraints to individual touch identifiers through native DOM touch events, you can allow users to grab, move, and throw distinct bodies simultaneously.

The Limitation of MouseConstraint

The built-in Matter.MouseConstraint pairs with a single Matter.Mouse instance. Even though mobile browsers fire touch events with multiple simultaneous contacts, Matter.Mouse tracks only one primary contact at any given moment. To enable multi-touch interactions across different bodies, you must replace MouseConstraint with native DOM touch listeners that create and update dynamic Matter.Constraint instances on the fly.

Implementation Architecture

To support distinct simultaneous drags:

  1. Track Touches: Use a JavaScript Map where the key is the native Touch.identifier and the value holds the active Matter.js constraint.
  2. Query Bodies (touchstart): Detect which physics body lies beneath each new touch point using Matter.Query.point.
  3. Bind Constraints: Create an elastic or rigid Matter.Constraint connecting the touch position directly to the detected body and add it to the physics world.
  4. Update Coordinates (touchmove): Update the constraint's anchor point as the touch moves across the screen.
  5. Clean Up (touchend / touchcancel): Remove the constraint from the physics world when the user lifts their finger or the gesture is canceled.

Complete Implementation

Below is a complete implementation using standard Matter.js modules:

const { Engine, Render, Runner, Bodies, Composite, Constraint, Query, Vector } = Matter;

// Initialize Engine and World
const engine = Engine.create();
const world = engine.world;

const canvas = document.getElementById('world-canvas');
const render = Render.create({
  canvas: canvas,
  engine: engine,
  options: {
    width: window.innerWidth,
    height: window.innerHeight,
    wireframes: false
  }
});

Render.run(render);
Runner.run(Runner.create(), engine);

// Store active touch constraints: identifier -> constraint
const activeTouches = new Map();

// Helper to convert screen touch coordinates to canvas/render coordinates
function getCanvasTouchPos(touch, canvasElement) {
  const rect = canvasElement.getBoundingClientRect();
  return {
    x: touch.clientX - rect.left,
    y: touch.clientY - rect.top
  };
}

// 1. Touch Start: Find body and attach constraint
canvas.addEventListener('touchstart', (event) => {
  event.preventDefault();

  const bodies = Composite.allBodies(world).filter(body => !body.isStatic);

  for (let i = 0; i < event.changedTouches.length; i++) {
    const touch = event.changedTouches[i];
    const touchPos = getCanvasTouchPos(touch, canvas);

    // Query for dynamic bodies directly beneath the touch point
    const hitBodies = Query.point(bodies, touchPos);

    if (hitBodies.length > 0) {
      // Pick the top-most body
      const targetedBody = hitBodies[hitBodies.length - 1];

      // Create a temporary constraint attaching the body to the touch coordinate
      const touchConstraint = Constraint.create({
        pointA: touchPos,
        bodyB: targetedBody,
        pointB: Vector.sub(touchPos, targetedBody.position),
        stiffness: 0.8,
        damping: 0.1,
        render: {
          visible: true,
          lineWidth: 2,
          strokeStyle: '#ff0055'
        }
      });

      Composite.add(world, touchConstraint);
      activeTouches.set(touch.identifier, touchConstraint);
    }
  }
}, { passive: false });

// 2. Touch Move: Update position of the active constraint anchor
canvas.addEventListener('touchmove', (event) => {
  event.preventDefault();

  for (let i = 0; i < event.changedTouches.length; i++) {
    const touch = event.changedTouches[i];
    const constraint = activeTouches.get(touch.identifier);

    if (constraint) {
      // Update the external anchor point to track the finger
      constraint.pointA = getCanvasTouchPos(touch, canvas);
    }
  }
}, { passive: false });

// 3. Touch End / Cancel: Detach and clean up constraints
function handleTouchEnd(event) {
  event.preventDefault();

  for (let i = 0; i < event.changedTouches.length; i++) {
    const touch = event.changedTouches[i];
    const constraint = activeTouches.get(touch.identifier);

    if (constraint) {
      Composite.remove(world, constraint);
      activeTouches.delete(touch.identifier);
    }
  }
}

canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
canvas.addEventListener('touchcancel', handleTouchEnd, { passive: false });

Key Considerations