Physics Playground with Matter.js

Mental model: Integrating a library is renaming things you already understand. Build the small version first, then put the real engine behind an adapter with the same shape, and own the teardown.

Level: advanced · about 28 minutes

Every job you take will hand you a library you did not choose. This lab is the pattern for that: build a small version you fully understand, then drive the real thing through the same interface. You will end up with an integration you can swap, a load path that survives a blocked CDN, and a teardown that leaves nothing running.

The lab opens with a physics engine written in about a hundred lines in the lab file itself: circles with a position and a velocity, gravity as an acceleration, collisions solved by pushing overlapping circles apart and exchanging some momentum. Nothing is downloaded. Press Load Matter.js and the same scene, described as data, is rebuilt with Matter.Bodies.circle and Matter.Constraint, and a mapping table shows which of your concepts became which of theirs. If the CDN is blocked, nothing on the stage even flickers.

The build, decision by decision

  1. Write the version you can read first A body is an object with a position, a velocity and two material numbers. Mass comes from area, so a big ball really does shove a small one out of the way, and a static body gets an inverse mass of zero, which is how "unmovable" is expressed in one number.
  2. Step in fixed substeps, not in one display frame Solve a 50 ms frame in one go and a fast body tunnels straight through a wall, because between the two positions there was no moment when they overlapped. Accumulate real time and run the solver at a steady 1/120 s, with a cap so a slow frame cannot cause a slower one.
  3. Order the substep: integrate, constrain, collide, contain Four phases, always in that order. Constraints run several passes because fixing one link disturbs its neighbour, which is what a solver is: the same cheap correction applied until it stops arguing with itself.
  4. Separate first, then exchange momentum A contact is two steps: move the bodies apart along the line between their centres, split by inverse mass, then apply one impulse along that same line. Restitution is the fraction of approach speed handed back as separation speed.
  5. Describe the scene as data A list of circles and a list of links between them. Both engines are built from the same description, which is the only honest way to compare them and the reason swapping engines does not touch your presets.
  6. Load the library on demand, never at import time A top-level import of a CDN script means a blocked request is a blank page. Inject it when the learner asks, with a timeout, and treat all three failure modes the same way: no network, a blocked request, and a script that loads without installing its global.
  7. Wrap it in an adapter with the shape you already use Everything Matter-specific lives in one factory. The rest of the lab calls load, step, circles, links, stats and destroy without knowing which engine answered. That is what makes a swap a morning of work instead of a rewrite.
  8. Subscribe to their events, and unsubscribe in teardown Your collision counter became Matter.Events.on(engine, "collisionStart", fn). Every handle a library gives you is something you owe back: the listener, the world contents, and the engine itself.
  9. Keep the fallback as the default The built-in engine is what runs when the lab opens, and what keeps running if the load fails. Design every third-party dependency this way and a blocked CDN becomes a missing feature instead of a blank page.

The core mechanism

function createStepper(stepMs, maxSteps) {
  let accumulator = 0;
  return {
    advance(dtMs) {
      accumulator += Math.min(Math.max(dtMs, 0), 60);   // never trust a huge frame
      let steps = 0;
      while (accumulator >= stepMs && steps < maxSteps) {
        accumulator -= stepMs;
        steps += 1;
      }
      if (steps === maxSteps) accumulator = 0;          // drop the backlog
      return { steps, leftover: Math.round(accumulator * 100) / 100 };
    },
  };
}

const stepper = createStepper(1000 / 120, 4); // solve at 120 Hz, at most 4 substeps per frame

for (const frameMs of [16.7, 16.7, 8.3, 33.4, 400]) {
  const { steps, leftover } = stepper.advance(frameMs);
  console.log('frame ' + frameMs + 'ms -> ' + steps + ' substeps, ' + leftover + 'ms carried');
}
// -> frame 16.7ms -> 2 substeps, 0.03ms carried
// -> frame 16.7ms -> 2 substeps, 0.07ms carried
// -> frame 8.3ms -> 1 substeps, 0.03ms carried
// -> frame 33.4ms -> 4 substeps, 0ms carried    (cap reached, backlog dropped)
// -> frame 400ms -> 4 substeps, 0ms carried     (clamped to 60ms, then capped)

The accumulator that turns irregular display frames into a steady solver rate.

function solveContact(a, b, restitution) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const dist = Math.hypot(dx, dy) || 0.0001;
  const overlap = a.r + b.r - dist;
  if (overlap <= 0) return 'no contact';

  const nx = dx / dist;
  const ny = dy / dist;
  const invSum = a.invMass + b.invMass;
  if (!invSum) return 'both static';

  // 1. push them apart, split by inverse mass
  a.x -= nx * overlap * (a.invMass / invSum);
  b.x += nx * overlap * (b.invMass / invSum);

  // 2. one impulse along the normal
  const along = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
  if (along > 0) return 'already separating';
  const j = (-(1 + restitution) * along) / invSum;
  a.vx -= j * nx * a.invMass;
  b.vx += j * nx * b.invMass;
  return 'resolved';
}

const a = { x: 0, y: 0, r: 10, vx: 100, vy: 0, invMass: 1 };
const b = { x: 15, y: 0, r: 10, vx: 0, vy: 0, invMass: 1 };

console.log(solveContact(a, b, 0.8));                      // -> resolved
console.log('positions', a.x.toFixed(1), b.x.toFixed(1));  // -> positions -2.5 17.5
console.log('velocities', a.vx.toFixed(0), b.vx.toFixed(0)); // -> velocities 10 90
console.log(solveContact(a, b, 0.8));                      // -> no contact

One contact, solved: separate along the normal, then swap momentum along it.

Your engine, named their way

ConceptYour engineMatter.js
worldthis.bodies and this.constraintsMatter.Composite (engine.world)
bodynew Circle({ x, y, r })Matter.Bodies.circle(x, y, r, opts)
static wallsolveWalls() clamps to the stageMatter.Bodies.rectangle(..., { isStatic: true })
gravityb.vy += gravity * dtengine.gravity.y (scaled by gravityScale)
materialb.restitution, b.frictionbody.restitution, body.friction, body.frictionAir
linksolveLink(), a distance constraintMatter.Constraint.create({ bodyA, bodyB, stiffness })
stepstep(dtMs) in 1/120 s substepsMatter.Engine.update(engine, delta)
collision eventcollisions += 1 in solveContactMatter.Events.on(engine, 'collisionStart', fn)
draggrabAt / moveGrab / releaseGrabMatter.MouseConstraint or Body.setPosition
teardownengine.destroy()Events.off, Composite.clear, Engine.clear
// two separate places in your app ask for the same module
const a = await import('./physics-adapter.js');
const b = await import('./physics-adapter.js');

console.log(a === b, a.default === b.default);

A module is evaluated once per URL and cached in the module registry, so the second import() resolves with the identical namespace object. That is why a lazily loaded adapter does not need its own memoisation, and also why module-level state is shared by every importer whether you wanted that or not.

Library everywhere

import Matter from 'matter-js';

// in the renderer
body.position.x
// in the input handler
Matter.Body.setPosition(...)
// in the tests
Matter.Engine.create()

// swapping engines now means
// touching every file

Library behind one door

const engine = createMatterEngine(Matter, view);

// everywhere else
engine.step(dtMs);
engine.circles();
engine.destroy();

// swapping engines means
// writing one more factory

The adapter is not indirection for its own sake. It gives you one file to read when the library misbehaves, one place to fake in a test, and a fallback that can run when the dependency is unavailable.

Extend it

  1. Add a rectangle body type to the scene data, and map it to Bodies.rectangle in the adapter.
  2. Add a Subresource Integrity hash to the injected script tag and prove it fails closed when the hash is wrong.
  3. Replace the hand-rolled drag with Matter.MouseConstraint in the adapter only, leaving your engine untouched.
  4. Add a third adapter that talks to a physics worker, so the solver runs off the main thread.
  5. Write a smoke test with a fake engine object, and assert your renderer never reads a Matter-specific field.

Try it yourself

Watch a body tunnel through a wall

// A wall at x = 100, one pixel thick. A body moving at 4000 px/s.
function willHitWall(speed, frameMs, substeps) {
  const dt = frameMs / 1000 / substeps;
  let x = 0;
  for (let i = 0; i < substeps; i += 1) {
    x += speed * dt;
    if (x >= 100 && x <= 101) return 'hit at x=' + x.toFixed(1);
  }
  return 'missed, ended at x=' + x.toFixed(1);
}

console.log('1 substep  ->', willHitWall(4000, 50, 1));
console.log('4 substeps ->', willHitWall(4000, 50, 4));
console.log('40 substeps->', willHitWall(4000, 50, 40));
console.log('200 substeps->', willHitWall(4000, 50, 200));

Raise the substep count until the body stops passing through. That number is why engines run faster than your display.

Two engines, one interface

const makeFakeEngine = (label) => {
  let bodies = [];
  return {
    id: label,
    load(scene) {
      bodies = scene.circles.map((c, i) => ({ ...c, id: label + i }));
    },
    step(dtMs) {
      for (const b of bodies) b.y += (dtMs / 1000) * 100;
    },
    circles() {
      return bodies.map((b) => ({ x: b.x, y: Math.round(b.y), r: b.r }));
    },
    destroy() {
      bodies = [];
    },
  };
};

const scene = { circles: [{ x: 10, y: 0, r: 8 }, { x: 30, y: 0, r: 8 }], links: [] };

// The caller does not know or care which engine it has.
function runFrames(engine, frames) {
  engine.load(scene);
  for (let i = 0; i < frames; i += 1) engine.step(16.7);
  const out = engine.circles();
  engine.destroy();
  return out;
}

console.log(runFrames(makeFakeEngine('mine'), 60));
console.log(runFrames(makeFakeEngine('theirs'), 60));

Add a stats() method to both. Notice you can write the caller before either engine exists.

Exercises

The fixed-step accumulator

Write createStepper(options) with { stepMs = 1000 / 120, maxSteps = 8, maxFrameMs = 60 }. advance(dtMs) clamps the incoming frame time into 0 to maxFrameMs, adds it to an accumulator, and returns how many whole steps of stepMs fit. Carry the remainder to the next call, but if you hit maxSteps, drop the backlog by resetting the accumulator to zero. Expose remainderMs as a live read, and a reset().

Load a dependency once, and retry after a failure

Write loadOnce(loader) which returns a get() function. The first get() calls loader() and returns its promise. Later calls return the identical promise without calling the loader again. If the load fails, the cache is cleared so a later get() retries. A loader that throws synchronously must produce a rejected promise, not a thrown error, and must also stay retryable.

Check yourself

Why does the engine run substeps of 1/120 s instead of stepping once per display frame?
Because a large step can move a fast body from one side of a wall to the other with no overlap in between — Collision detection compares positions, so if two positions never overlap, there was no collision to detect. Smaller steps mean more sample points, which is why every engine decouples its solver rate from the display rate.
What does this print?
2 5 then 3 0 — 25 ms is two whole steps with 5 ms carried. The 1000 ms frame is clamped to 60 ms, which would be six steps, but the cap is three, and hitting the cap drops the leftover rather than saving up a debt you can never pay.
The CDN script tag loads successfully but window.Matter is undefined. What should the loader do?
Reject with a message saying the script loaded without installing its global, and keep using the built-in engine — A load event only tells you bytes arrived. A proxy, an extension or the wrong URL can deliver something that is not the library. Treat it as a failure with a specific message, and stay on the fallback that is already running.
Which of these does NOT belong in the adapter teardown?
Removing the injected <script> tag to unload the library — Removing the script element does not unevaluate the code or delete the global: the library is in memory for the life of the page. Teardown is about the handles you created (listeners, worlds, engines), not about undoing the download.

Common mistakes

  • Importing a heavy dependency at module top level, so a blocked request breaks a page that did not need it.
  • Stepping the solver once per display frame, then wondering why fast bodies pass through walls.
  • Trying to catch up on a long backlog of substeps, which turns one slow frame into many.
  • Letting library types leak into your renderer, so swapping engines means touching every file.
  • Forgetting Events.off, so the old engine keeps counting collisions after the view is gone.
  • Assuming a load event means the global is installed.
  • Pulling an unpinned CDN URL, so a release you did not read changes your app overnight.

Takeaways

  • Build the readable version first: integration is mostly renaming concepts you already have.
  • Decouple the solver rate from the display rate with an accumulator, and cap the substeps.
  • A contact is two steps: separate along the normal, then apply one impulse along it.
  • Describe the scene as data so two engines can be built from the same description.
  • Load on demand, pin the version, and treat every load failure as a missing feature not a broken page.
  • One adapter file, one teardown, and no library types outside it.