Fluid Simulation with Particles in Matter.js

This guide explains how to simulate 2D fluids using particle systems in the Matter.js physics engine. While Matter.js is fundamentally a rigid-body engine, you can achieve realistic liquid behavior by combining hundreds of low-friction circular particles with custom attraction forces and a visual blending technique known as the metaball effect. Below, you will learn the core physics configuration, particle interaction tuning, and canvas rendering methods required to bring fluid dynamics to life.

The Approach: Pseudo-Fluid Mechanics

True computational fluid dynamics (CFD) models Navier-Stokes equations, which are computationally expensive for real-time web applications. In Matter.js, fluids are typically approximated by:

  1. Generating Particle Clustered Bodies: Creating numerous small circular bodies.
  2. Minimizing Rigid Characteristics: Lowering friction and setting optimal density.
  3. Simulating Surface Tension: Applying cohesion forces between neighboring particles.
  4. Post-Processing the Visuals: Blurring and thresholding the rendered particles so they merge visually into a single continuous body of liquid.

Step 1: Initialize the Engine and Boundaries

Set up a standard Matter.js engine, world, and static container walls to hold the liquid.

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

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

const canvas = document.getElementById('fluidCanvas');
const ctx = canvas.getContext('2d');

// Boundary walls
const ground = Bodies.rectangle(400, 590, 810, 30, { isStatic: true });
const leftWall = Bodies.rectangle(5, 300, 30, 600, { isStatic: true });
const rightWall = Bodies.rectangle(795, 300, 30, 600, { isStatic: true });

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

Step 2: Create the Fluid Particles

Create a cluster of small circular bodies. Crucially, set friction and frictionStatic to 0 to prevent particles from stacking like sand, and set restitution (bounciness) very low.

const particles = [];
const particleRadius = 7;
const rows = 15;
const cols = 20;

for (let i = 0; i < rows; i++) {
  for (let j = 0; j < cols; j++) {
    const particle = Bodies.circle(300 + j * 16, 100 + i * 16, particleRadius, {
      friction: 0.0,
      frictionAir: 0.02,
      frictionStatic: 0.0,
      restitution: 0.05,
      density: 0.002,
      render: { visible: false } // Hidden for custom canvas rendering
    });
    particles.push(particle);
  }
}

Composite.add(world, particles);

Step 3: Implement Cohesion (Surface Tension)

To make particles behave cohesively like water rather than disconnected ball bearings, apply a gentle attractive force between particles that are close to one another before each physics update.

Matter.Events.on(engine, 'beforeUpdate', () => {
  const maxDistance = particleRadius * 4;
  const attractionStrength = 0.00005;

  for (let i = 0; i < particles.length; i++) {
    const pA = particles[i];
    for (let j = i + 1; j < particles.length; j++) {
      const pB = particles[j];
      const delta = Vector.sub(pB.position, pA.position);
      const distance = Vector.magnitude(delta);

      if (distance < maxDistance && distance > 0) {
        // Normalize and scale force inversely proportional to distance
        const forceMagnitude = (1 - distance / maxDistance) * attractionStrength;
        const force = Vector.mult(Vector.normalise(delta), forceMagnitude);

        Body.applyForce(pA, pA.position, force);
        Body.applyForce(pB, pB.position, Vector.neg(force));
      }
    }
  }
});

Step 4: Render Particles as Liquid (Metaballs)

Instead of using the default Matter.js wireframe renderer, draw radial gradients on a dedicated HTML5 canvas. Using an alpha threshold filter combines overlapping particles into a smooth fluid surface.

Method A: CSS Filter (Easiest & Fastest)

Apply a blur combined with a high-contrast filter to the canvas element in CSS. This blends overlapping semi-transparent circles and cuts off soft edges:

#fluidCanvas {
  filter: blur(8px) contrast(20);
  background: #ffffff;
}

In your JavaScript animation loop, clear the canvas and draw circles:

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#0077be';
  for (let i = 0; i < particles.length; i++) {
    const { x, y } = particles[i].position;
    ctx.beginPath();
    ctx.arc(x, y, particleRadius * 2, 0, Math.PI * 2);
    ctx.fill();
  }

  requestAnimationFrame(render);
}

// Start simulation and rendering
const runner = Runner.create();
Runner.run(runner, engine);
render();

Performance Tuning Tips