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:
- Unique Identifiers: String or numeric IDs assigned to bodies so constraints can resolve their connections.
- Bodies: Properties such as position, vertices, velocity, mass, collision filters, and render options.
- Constraints: Joint and spring definitions
referencing the corresponding
bodyAandbodyBIDs, along with local anchor points (pointA,pointB), stiffness, and length. - Nested Composites: Child composite definitions structured recursively to represent sub-assemblies (e.g., vehicles, ragdolls, or machinery).
Recommended JSON Schema
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:
- Pass 1 (Instantiation and Registry): Recursively
traverse the JSON tree, instantiate all
Matter.Bodyobjects, and store them in a flat lookup registry (Map<string, Matter.Body>). - 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
- Coordinate Systems: In Matter.js, a body's position defines its center of mass in global simulation space. When saving nested structures, ensure that saved child coordinates are stored either as absolute world positions or compute their offsets during reconstruction by passing down parent translation transforms.
- Collision Filtering: When reconstructing assemblies
like ragdolls, prevent adjacent connected bodies from exploding on spawn
by providing matching
collisionFilter.group(negative values) or configured masks in the body definition options. - Compound Bodies: If a node contains multiple shapes
forming a single rigid body, use
Body.create({ parts: [...] })instead of aComposite. The primary part becomes the root, and internal constraints are not required.