Matter.js Virtual Joystick for Touchscreen Devices

Controlling dynamic bodies in Matter.js on mobile screens requires translating multi-touch screen coordinates into physical vector forces. This guide demonstrates how to build an on-screen, touch-enabled virtual joystick using standard DOM elements and JavaScript, and how to map its output to continuous directional forces applied to Matter.js rigid bodies.

1. Setup the Matter.js Environment

Begin by setting up a standard Matter.js engine, renderer, and runner, along with a controllable player body.

const { Engine, Render, Runner, Bodies, Composite, Body, Vector, Events } = Matter;

const engine = Engine.create();
const world = engine.world;

const render = Render.create({
  element: document.body,
  engine: engine,
  options: {
    width: window.innerWidth,
    height: window.innerHeight,
    wireframes: false
  }
});

Render.run(render);
const runner = Runner.create();
Runner.run(runner, engine);

// Create boundary walls and the player body
const player = Bodies.circle(window.innerWidth / 2, window.innerHeight / 2, 25, {
  frictionAir: 0.05,
  render: { fillStyle: '#007acc' }
});

const ground = Bodies.rectangle(window.innerWidth / 2, window.innerHeight, window.innerWidth, 40, { isStatic: true });
const ceiling = Bodies.rectangle(window.innerWidth / 2, 0, window.innerWidth, 40, { isStatic: true });
const leftWall = Bodies.rectangle(0, window.innerHeight / 2, 40, window.innerHeight, { isStatic: true });
const rightWall = Bodies.rectangle(window.innerWidth, window.innerHeight / 2, 40, window.innerHeight, { isStatic: true });

Composite.add(world, [player, ground, ceiling, leftWall, rightWall]);

2. Create the Joystick DOM Structure and Styling

A functional virtual joystick requires a fixed outer ring (the base) and a draggable inner node (the stick). Add these elements directly to your document.

<div id="joystick-base">
  <div id="joystick-stick"></div>
</div>

<style>
  #joystick-base {
    position: fixed;
    bottom: 40px;
    left: 40px;
    width: 120px;
    height: 120px;
    background: rgba(255, 255, 255, 0.2);
    border: 2px solid rgba(255, 255, 255, 0.4);
    border-radius: 50%;
    touch-action: none;
    user-select: none;
    z-index: 1000;
  }

  #joystick-stick {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 50px;
    height: 50px;
    background: rgba(255, 255, 255, 0.8);
    border-radius: 50%;
    transform: translate(-50%, -50%);
    pointer-events: none;
  }
</style>

3. Capture Touch Coordinates and Normalize Input

Track touch interactions on #joystick-base. Calculate the delta between the center of the base and the current touch point, clamping the stick's movement inside the base radius. Normalize the resulting coordinates into values between -1.0 and 1.0.

const base = document.getElementById('joystick-base');
const stick = document.getElementById('joystick-stick');

const maxRadius = 45; // Maximum stick travel distance from center
let activeTouchId = null;
let inputVector = { x: 0, y: 0 };

function getBaseCenter() {
  const rect = base.getBoundingClientRect();
  return {
    x: rect.left + rect.width / 2,
    y: rect.top + rect.height / 2
  };
}

base.addEventListener('touchstart', (e) => {
  if (activeTouchId === null) {
    const touch = e.changedTouches[0];
    activeTouchId = touch.identifier;
    handleJoystickMove(touch.clientX, touch.clientY);
  }
}, { passive: false });

window.addEventListener('touchmove', (e) => {
  if (activeTouchId === null) return;
  for (let i = 0; i < e.changedTouches.length; i++) {
    if (e.changedTouches[i].identifier === activeTouchId) {
      handleJoystickMove(e.changedTouches[i].clientX, e.changedTouches[i].clientY);
      break;
    }
  }
}, { passive: false });

function resetJoystick() {
  activeTouchId = null;
  inputVector = { x: 0, y: 0 };
  stick.style.transform = `translate(-50%, -50%)`;
}

window.addEventListener('touchend', (e) => {
  for (let i = 0; i < e.changedTouches.length; i++) {
    if (e.changedTouches[i].identifier === activeTouchId) {
      resetJoystick();
      break;
    }
  }
});

window.addEventListener('touchcancel', (e) => {
  for (let i = 0; i < e.changedTouches.length; i++) {
    if (e.changedTouches[i].identifier === activeTouchId) {
      resetJoystick();
      break;
    }
  }
});

function handleJoystickMove(clientX, clientY) {
  const center = getBaseCenter();
  const deltaX = clientX - center.x;
  const deltaY = clientY - center.y;
  const distance = Math.hypot(deltaX, deltaY);

  const angle = Math.atan2(deltaY, deltaX);
  const clampedDistance = Math.min(distance, maxRadius);

  const stickX = Math.cos(angle) * clampedDistance;
  const stickY = Math.sin(angle) * clampedDistance;

  // Visual displacement
  stick.style.transform = `translate(calc(-50% + ${stickX}px), calc(-50% + ${stickY}px))`;

  // Normalized directional vector (-1 to 1)
  inputVector.x = stickX / maxRadius;
  inputVector.y = stickY / maxRadius;
}

4. Apply Dynamic Forces in the Engine Update Loop

Apply the normalized input vector to the Matter.js body inside the engine's beforeUpdate event. Multiplying the normalized vector by a force constant ensures uniform acceleration relative to how far the stick is dragged.

const forceMagnitude = 0.005;

Events.on(engine, 'beforeUpdate', () => {
  if (inputVector.x !== 0 || inputVector.y !== 0) {
    const force = {
      x: inputVector.x * forceMagnitude,
      y: inputVector.y * forceMagnitude
    };
    
    Body.applyForce(player, player.position, force);
  }
});

Using Body.applyForce maintains continuous acceleration while allowing Matter.js to handle mass, air friction, and collisions with world boundaries.