How to Build a Matter.js Visual Level Editor

Building an in-game level editor using Matter.js requires combining a physics simulation with a direct-manipulation user interface. This guide outlines how to implement an interactive system that allows designers to spawn, select, move, and visually resize rigid bodies directly on a HTML5 canvas. You will learn how to capture pointer events, manipulate Matter.js body dimensions dynamically, render selection bounds with transform handles, and serialize the resulting layout into reusable JSON level data.

1. Canvas Setup and Interaction States

To edit physics entities visually, decouple editing interactions from the active physics simulation. Pause or disable the physics update loop (Runner.stop(runner)) while in edit mode so bodies remain stationary during manipulation.

Set up an interaction state machine to track user actions:

2. Selecting Bodies with Spatial Queries

Matter.js provides built-in query methods to detect which body lies under the cursor. Use Matter.Query.point() to detect clicks:

function getBodyAtMouse(engine, mousePosition) {
  const allBodies = Matter.Composite.allBodies(engine.world);
  const clickedBodies = Matter.Query.point(allBodies, mousePosition);
  
  // Filter out boundary walls or non-editable utility bodies
  return clickedBodies.find(body => !body.isStaticLevelBoundary) || null;
}

When a body is clicked, store it as the activeBody in your editor state.

3. Rendering Bounding Boxes and Resize Handles

Once a body is selected, draw an overlay on a dedicated UI canvas layer directly above the Matter.js render canvas.

  1. Calculate Dimensions: For a box, use the body's local width and height, or derive the bounding box using body.bounds.
  2. Draw Transform Gizmo: Render an outline around the body and draw small interactive squares (handles) on the four corners and the midpoints of the edges.
  3. Handle Hit Detection: Before checking for body selection on pointerdown, check if the click intersects any handle's hit radius. If it hits a handle, enter the RESIZING state and record which handle was grabbed (e.g., 'top-left', 'right').

4. Resizing Matter.js Bodies

Matter.js bodies are defined by geometric vertices. Unlike standard game engine sprites, changing a body's width and height dynamically requires updating its vertex data.

Method A: Using Matter.Body.scale()

The simplest approach is applying scale factors relative to the current size:

function resizeBodyProportional(body, scaleFactorX, scaleFactorY) {
  Matter.Body.scale(body, scaleFactorX, scaleFactorY);
}

Note: Continuous scaling can introduce floating-point inaccuracies over time, as Body.scale modifies existing vertices incrementally.

Method B: Rebuilding Vertices from Exact Dimensions

For a precise level editor, store the absolute width and height in a custom property on the body (e.g., body.customData.width). When dragging an edge or corner handle:

  1. Calculate the new width and height based on pointer displacement.
  2. Clamp values to a minimum threshold (e.g., 10px) to prevent inverted geometry.
  3. Generate new vertices or recreate the body definition:
function setRectangleDimensions(body, newWidth, newHeight) {
  const newVertices = Matter.Bodies.rectangle(
    body.position.x,
    body.position.y,
    newWidth,
    newHeight
  ).vertices;

  Matter.Body.setVertices(body, newVertices);
  
  // Update internal dimensions
  body.customData.width = newWidth;
  body.customData.height = newHeight;
}

Always update the body's position offset if the resize anchor is a corner rather than the center, ensuring the opposite edge remains stationary during the drag.

5. Placing New Bodies

Implement a tool palette for spawning primitive shapes:

  1. Select a tool (e.g., "Box", "Circle", "Platform").
  2. On canvas pointerdown, instantiate a new body using Matter.Bodies.rectangle(x, y, width, height, options).
  3. Set isStatic: true by default for level architecture, or leave it dynamic for interactive props.
  4. Add the body to the world: Matter.Composite.add(engine.world, newBody).
  5. Immediately set the new body as the activeBody to allow fine-tuning position and scale.

6. Level Serialization and Deserialization

To save levels, map the active Matter.js world bodies to a clean, serializable JSON format:

function exportLevel(engine) {
  const bodies = Matter.Composite.allBodies(engine.world);
  
  const levelData = bodies.map(body => ({
    type: body.customData?.type || 'rectangle',
    position: { x: body.position.x, y: body.position.y },
    angle: body.angle,
    isStatic: body.isStatic,
    width: body.customData?.width,
    height: body.customData?.height,
    radius: body.circleRadius || null
  }));

  return JSON.stringify(levelData, null, 2);
}

To load the level, iterate over the parsed JSON array, instantiate the corresponding Matter.Bodies factory methods with the stored transform and dimension data, and populate engine.world.