How to Build a Soft Body Jelly in Matter.js

This article explains how to simulate a soft-body jelly creature using the Matter.js 2D physics engine. By chaining together a perimeter ring of rigid circular point masses with elastic distance constraints, combined with interior cross-bracing springs to maintain volume, you can recreate convincing squishy, deformable organic physics inside a browser canvas.

Understanding the Physics Model

Matter.js is primarily a rigid-body physics engine, meaning it does not have a native deformable mesh engine. However, soft-body dynamics can be approximated using a mass-spring system composed of three elements:

  1. Point Masses: A circular array of small, low-radius rigid bodies (Matter.Bodies.circle) representing the outer skin of the creature.
  2. Perimeter Constraints: Distance constraints (Matter.Constraint.create) connecting each point mass to its immediate neighbors to form the outer boundary.
  3. Cross-Bracing Constraints: Internal distance constraints connecting opposing or diagonal point masses across the ring to act as internal pressure and prevent the shape from collapsing under gravity or impact.

Step 1: Set Up Matter.js

Initialize the foundational Matter.js modules:

const { Engine, Render, Runner, Bodies, Composite, Constraint } = Matter;

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

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

Render.run(render);
Runner.run(Runner.create(), engine);

Step 2: Generate the Outer Ring of Masses

Place the point masses in a circle around a central coordinate \((x, y)\).

const centerX = 400;
const centerY = 200;
const creatureRadius = 80;
const particleCount = 16;
const particleRadius = 6;

const particles = [];

for (let i = 0; i < particleCount; i++) {
  const angle = (i / particleCount) * Math.PI * 2;
  const px = centerX + Math.cos(angle) * creatureRadius;
  const py = centerY + Math.sin(angle) * creatureRadius;

  const particle = Bodies.circle(px, py, particleRadius, {
    friction: 0.5,
    restitution: 0.2,
    density: 0.002,
    collisionFilter: { group: -1 } // Negative group prevents self-collision
  });

  particles.push(particle);
}

Composite.add(world, particles);

Connect each node to the next in the array, wrapping the final connection back to the first node to close the loop.

const perimeterStiffness = 0.9;
const damping = 0.1;
const perimeterConstraints = [];

for (let i = 0; i < particleCount; i++) {
  const nextIndex = (i + 1) % particleCount;
  
  const spring = Constraint.create({
    bodyA: particles[i],
    bodyB: particles[nextIndex],
    stiffness: perimeterStiffness,
    damping: damping,
    render: { strokeStyle: '#444', lineWidth: 2 }
  });

  perimeterConstraints.push(spring);
}

Composite.add(world, perimeterConstraints);

Step 4: Add Cross-Bracing for Volume Preservation

Without internal tension, the perimeter ring will fold flat when it collides with a surface. Adding cross-braces across opposing nodes provides structural integrity while remaining flexible.

const internalStiffness = 0.2;
const crossConstraints = [];

for (let i = 0; i < particleCount; i++) {
  // Connect to opposite particle across the circle
  const oppositeIndex = (i + Math.floor(particleCount / 2)) % particleCount;
  
  // Avoid duplicate reverse constraints
  if (i < oppositeIndex) {
    const crossSpring = Constraint.create({
      bodyA: particles[i],
      bodyB: particles[oppositeIndex],
      stiffness: internalStiffness,
      damping: damping,
      render: { strokeStyle: '#888', lineWidth: 1 }
    });
    crossConstraints.push(crossSpring);
  }

  // Connect to an offset node (e.g., +4) for shear resistance
  const diagonalIndex = (i + Math.floor(particleCount / 4)) % particleCount;
  if (i < diagonalIndex) {
    const diagonalSpring = Constraint.create({
      bodyA: particles[i],
      bodyB: particles[diagonalIndex],
      stiffness: internalStiffness * 0.5,
      damping: damping,
      render: { visible: false }
    });
    crossConstraints.push(diagonalSpring);
  }
}

Composite.add(world, crossConstraints);

Tuning and Rendering

To refine the jelly-like behavior: