Reconstruct Composite Hierarchies in Matter.js

This article provides a comprehensive guide on deserializing and reconstructing complex composite physics hierarchies from stored JSON configuration files using Matter.js. You will learn how to design a declarative JSON schema that captures recursive composite structures, map body references cleanly without circular dependency errors, and instantiate physics entities—including nested composites, compound bodies, and constraints—back into a live Matter.js physics simulation.

Understanding the Serialization Challenge

A direct call to JSON.stringify() fails on a live Matter.Composite instance due to circular references (such as bodies referencing parent composites and constraints referencing linked bodies) and lost prototype methods. To successfully reconstruct a hierarchy, your stored JSON must represent the composite as a declarative specification containing:

  1. Unique Identifiers: String or numeric IDs assigned to bodies so constraints can resolve their connections.
  2. Bodies: Properties such as position, vertices, velocity, mass, collision filters, and render options.
  3. Constraints: Joint and spring definitions referencing the corresponding bodyA and bodyB IDs, along with local anchor points (pointA, pointB), stiffness, and length.
  4. Nested Composites: Child composite definitions structured recursively to represent sub-assemblies (e.g., vehicles, ragdolls, or machinery).

Below is an example of a declarative JSON format that supports arbitrary nesting:

{
  "id": "root",
  "bodies": [
    {
      "id": "chassis",
      "type": "rectangle",
      "x": 400,
      "y": 300,
      "width": 120,
      "height": 40,
      "options": { "density": 0.002 }
    }
  ],
  "composites": [
    {
      "id": "wheelAssembly",
      "bodies": [
        {
          "id": "wheelLeft",
          "type": "circle",
          "x": 360,
          "y": 330,
          "radius": 20,
          "options": { "friction": 0.8 }
        },
        {
          "id": "wheelRight",
          "type": "circle",
          "x": 440,
          "y": 330,
          "radius": 20,
          "options": { "friction": 0.8 }
        }
      ],
      "constraints": [
        {
          "id": "axleLeft",
          "bodyA": "chassis",
          "bodyB": "wheelLeft",
          "pointA": { "x": -40, "y": 30 },
          "pointB": { "x": 0, "y": 0 },
          "stiffness": 0.9
        },
        {
          "id": "axleRight",
          "bodyA": "chassis",
          "bodyB": "wheelRight",
          "pointA": { "x": 40, "y": 30 },
          "pointB": { "x": 0, "y": 0 },
          "stiffness": 0.9
        }
      ]
    }
  ]
}

The Reconstruction Algorithm

Reconstruction requires a two-phase traversal:

  1. Pass 1 (Instantiation and Registry): Recursively traverse the JSON tree, instantiate all Matter.Body objects, and store them in a flat lookup registry (Map<string, Matter.Body>).
  2. Pass 2 (Constraint and Hierarchy Assembly): Instantiate constraints by pulling body references directly from the registry, assemble child composites, and attach them to their parent composites.

Implementation

import Matter from 'matter-js';

const { Body, Bodies, Composite, Constraint } = Matter;

/**
 * Reconstructs a Matter.Composite tree from a JSON object.
 * @param {Object} data - The root JSON configuration.
 * @returns {Matter.Composite} The reconstructed composite.
 */
export function reconstructComposite(data) {
  const bodyRegistry = new Map();
  const pendingConstraints = [];

  // Phase 1: Recursive creation of composites and bodies
  function buildCompositeNode(nodeData) {
    const composite = Composite.create({ label: nodeData.id || 'composite' });

    // Build bodies in this composite
    if (Array.isArray(nodeData.bodies)) {
      for (const bodyDef of nodeData.bodies) {
        let body;
        const opts = bodyDef.options || {};

        if (bodyDef.type === 'rectangle') {
          body = Bodies.rectangle(bodyDef.x, bodyDef.y, bodyDef.width, bodyDef.height, opts);
        } else if (bodyDef.type === 'circle') {
          body = Bodies.circle(bodyDef.x, bodyDef.y, bodyDef.radius, opts);
        } else if (bodyDef.type === 'polygon') {
          body = Bodies.polygon(bodyDef.x, bodyDef.y, bodyDef.sides, bodyDef.radius, opts);
        } else if (bodyDef.type === 'fromVertices') {
          body = Bodies.fromVertices(bodyDef.x, bodyDef.y, bodyDef.vertexSets, opts);
        }

        if (body) {
          body.id = bodyDef.id; // Preserve tracking ID
          bodyRegistry.set(bodyDef.id, body);
          Composite.add(composite, body);
        }
      }
    }

    // Queue constraints defined at this level for phase 2
    if (Array.isArray(nodeData.constraints)) {
      for (const constraintDef of nodeData.constraints) {
        pendingConstraints.push({
          targetComposite: composite,
          definition: constraintDef
        });
      }
    }

    // Recursively process child composites
    if (Array.isArray(nodeData.composites)) {
      for (const childNode of nodeData.composites) {
        const childComposite = buildCompositeNode(childNode);
        Composite.add(composite, childComposite);
      }
    }

    return composite;
  }

  const rootComposite = buildCompositeNode(data);

  // Phase 2: Resolve constraint references and add to target composites
  for (const { targetComposite, definition } of pendingConstraints) {
    const bodyA = definition.bodyA ? bodyRegistry.get(definition.bodyA) : null;
    const bodyB = definition.bodyB ? bodyRegistry.get(definition.bodyB) : null;

    // A constraint must have at least one valid body or anchor point
    const constraintConfig = {
      ...definition,
      bodyA: bodyA || undefined,
      bodyB: bodyB || undefined
    };

    // Remove the serialized identifier keys before passing to Matter.js
    delete constraintConfig.id;

    const constraint = Constraint.create(constraintConfig);
    Composite.add(targetComposite, constraint);
  }

  return rootComposite;
}

Key Considerations