uriel avalos

React + Matter.js Demo

In my spare time, I'm making a game / interactive book / art. So I decided to blog my progress, even though I don't normally blog ¯_ (ツ)_/¯.

The game will require billiards-like physics. So I direct your attention to matter.js, a 23kb 2D physics library for the web.

Next up, matter.js needs a renderer. We can use Canvas or WebGL. The game will be super simple so why not try using the DOM? That's what we explore in this experiment.

The idea is simple: let matter.js update x/y coordinates of objects, and then just render absolutely positioned DOM elements with these coordinates. Boom.

const engine = Engine.create();
const runner = Runner.create();

// start matter.js runner
Runner.run(runner, engine);

// add some objects to matter.js (not shown here)
// In this case we added Circles.

interface Circle {
  x: number;
  y: number;
}

const circles = useRef<Circle[]>([]);

// Get all the Bodies from matter.js
for (const circle of Composite.allBodies(engine.world)) {
  if (circle.isStatic) continue;
  circles.current.push({ x: circle.position.x, y: circle.position.y })
}

// And then we can render the circles
return {circles.current.map((circle, key) => (
  <div
    key={key}
    style={{ top: circle.y, left: circle.x, width: "50px", height: "50px" }}
  />
))}

There are many missing details above (full code below). But the key ideas are:

  • start the matter.js Engine. Since there's only one, it can live outside of the React components.
  • add objects to the Engine as you see fit (at component initialization, dynamically in response to user events, etc).
  • render the matter.js objects using HTML/CSS. (In the demo below, we added matter.js Circle primitives, which we rendered as round buttons.)
  • sync the matter.js Engine with the React DOM via requestAnimationFrame.

Performance and Thoughts

In the demo, we render 300 objects. In my non-scientific experiment of one old laptop and one run-of-the-mill phone, I see a very smooth frame rate. So I'm pretty happy with these results.

This DOM technique opens up interesting possibilities, like yet another way of adding physics animations to any DIV element.

Code

import { Bodies, Composite, Engine, Runner } from "matter-js";
import { useEffect, useRef, useState } from "react";
import styled from "styled-components";

const engine = Engine.create();
const runner = Runner.create();

Runner.run(runner, engine);

interface Circle {
  x: number;
  y: number;
}

const Canvas = styled.div`
  width: 100vw;
  height: 100vh;
  position: relative;
  background: gray;
`;

const Circle = styled.div`
  background-color: yellow;
  border-radius: 50%;
  box-shadow: 2px 2px;
  position: absolute;
`;

export function PressStart() {
  const ref = useRef<HTMLDivElement>(null);
  const dots = useRef<Circle[]>([]);
  const [, setAnim] = useState(0);

  useEffect(function init() {
    const width = ref.current?.clientWidth ?? 0;
    const height = ref.current?.clientHeight ?? 0;

    const ground = Bodies.rectangle(width / 2, height, width, 50, {
      isStatic: true,
    });
    const ceiling = Bodies.rectangle(width / 2, 0, width, 1, {
      isStatic: true,
    });
    const wallL = Bodies.rectangle(0, height / 2, 1, height, {
      isStatic: true,
    });
    const wallR = Bodies.rectangle(width, height / 2, 50, height, {
      isStatic: true,
    });

    Composite.add(engine.world, [ground, ceiling, wallL, wallR]);
  }, []);

  useEffect(() => {
    let unsubscribe: any;

    function addDot() {
      const width = ref.current?.clientWidth ?? 0;
      const height = ref.current?.clientHeight ?? 0;

      const circ = Bodies.circle(
        Math.random() * width * 0.75 + 50,
        Math.random() * height * 0.75 + 50,
        25
      );
      circ.friction = 0.05;
      circ.frictionAir = 0.00005;
      circ.restitution = 0.9;

      Composite.add(engine.world, circ);

      if (dots.current.length < 100) setTimeout(addDot, 300);
    }

    addDot();

    return () => {
      clearTimeout(unsubscribe);
    };
  }, []);

  useEffect(function triggerAnimation() {
    let unsubscribe: number;

    function animate() {
      let i = 0;
      for (const dot of Composite.allBodies(engine.world)) {
        if (dot.isStatic) continue;

        dots.current[i] = { x: dot.position.x, y: dot.position.y };

        i += 1;
      }

      setAnim((x) => x + 1);

      unsubscribe = requestAnimationFrame(animate);
    }

    unsubscribe = requestAnimationFrame(animate);

    return () => {
      cancelAnimationFrame(unsubscribe);
    };
  }, []);

  return (
    <Canvas ref={ref}>
      {dots.current.map((dot, key) => (
        <Circle
          key={key}
          style={{ top: dot.y, left: dot.x, width: "50px", height: "50px" }}
        />
      ))}
    </Canvas>
  );
}