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:
- Track Touches: Use a JavaScript
Mapwhere the key is the nativeTouch.identifierand the value holds the active Matter.js constraint. - Query Bodies (
touchstart): Detect which physics body lies beneath each new touch point usingMatter.Query.point. - Bind Constraints: Create an elastic or rigid
Matter.Constraintconnecting the touch position directly to the detected body and add it to the physics world. - Update Coordinates (
touchmove): Update the constraint's anchor point as the touch moves across the screen. - 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
- Coordinate Normalization: If your render canvas
uses CSS scaling or dynamic viewport adjustments, ensure
getCanvasTouchPosaccounts for canvas scaling ratios (canvas.width / rect.width), otherwise touch points will deviate from the rendered bodies. - Sleeping Bodies: If
engine.enableSleepingis set totrue, dragging a sleeping body may fail to wake it immediately. Explicitly invokeMatter.Sleeping.set(targetedBody, false)inside thetouchstartevent handler. - Stiffness Tuning: A
stiffnessvalue of0.8to1.0delivers immediate dragging, while lower stiffness provides an elastic, sling-like interaction.