Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Physics — things with mass, built with Matter.js

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/physics.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

When someone asks for physics, gravity, things that bounce, collide, tumble or pile up, a marble run, a Rube Goldberg machine, plinko, a catapult or slingshot, dominoes, a tower to knock down, pinball, a ragdoll, a page where the words fall and can be flicked around, or any toy where objects behave like objects, this is the recipe. Matter.js is a 2D rigid-body engine: the app decides which shapes exist and where, the engine moves them, a canvas paints them, and Fireproof keeps the pieces so a build is there tomorrow and for everyone who opens it.

The sibling boundary: a sketch that draws without collisions is p5; a scene with depth is three-js; a block world is voxel. Physics is the right call when the fun is bodies with mass meeting other bodies.

The worked examples below were built and measured for this doc (Chromium, 2026-10-07, [email protected] from esm.sh): a marble run builder whose pieces are documents, and a page where typed words tumble.

Importing Matter.js — one pinned default import

Import the engine as import Matter from "https://esm.sh/[email protected]" and take the modules off it (const { Engine, Bodies, Composite } = Matter). The esm.sh build of the package exposes Matter as its default export and nothing as named exports (measured: import { Engine } … reads undefined), so the default import is the one shape that works. Pin the version: a physics toy's feel is the engine's numbers, and a saved level assumes the engine it was built on.

Concave shapes (an L, an arrow, a cup) need a decomposer the engine does not ship: Common.setDecomp(await import("https://esm.sh/[email protected]")) once, then Bodies.fromVertices(x, y, points). Rectangles, circles and regular polygons need nothing extra, and most toys are made of those.

Project shape

App.jsx          the chrome: mode switch, piece picker, play controls, score, saved levels
lib/physics.js   the engine: world creation, the fixed-step loop, painting, picking, collisions
access.js        who may save a level
seed.json        two or three finished levels so the first visit has something to drop a marble into

Relative imports include the extension (./lib/physics.js). Everything that touches Matter lives in the module; App.jsx reads as an interface.

Complete starter — a marble run builder

The engine module. One world per canvas: a DPR-scaled context, three walls, a mouse constraint that knows the DPR, a loop that steps the engine by a fixed STEP and paints every body from its vertices in the app's theme colours, and helpers that turn piece documents into bodies and back.

lib/physics.js

jsimport Matter from "https://esm.sh/[email protected]";

const { Engine, Bodies, Body, Composite, Mouse, MouseConstraint, Events, Common, Query } = Matter;
export { Matter };

/** One physics step, in milliseconds. Every world steps by exactly this, so a replay is a replay. */
export const STEP = 1000 / 60;

/** Resolve one of the app's theme tokens to a colour the canvas can paint with. */
export function cssColor(host, token, fallback) {
  const probe = document.createElement("span");
  probe.style.display = "none";
  probe.style.color = `var(${token}, ${fallback})`;
  host.appendChild(probe);
  const resolved = window.getComputedStyle(probe).color;
  probe.remove();
  return resolved || fallback;
}

/** A body from a piece document; the document is the source of truth, the body is its stand-in. */
export function bodyFromPiece(piece) {
  const common = { label: piece.kind, isStatic: piece.kind !== "marble", angle: piece.angle ?? 0, isSensor: piece.kind === "goal", restitution: piece.kind === "bumper" ? 1.2 : 0.4, friction: 0.05 };
  const body =
    piece.kind === "peg" || piece.kind === "bumper" || piece.kind === "marble"
      ? Bodies.circle(piece.x, piece.y, piece.r ?? 12, common)
      : Bodies.rectangle(piece.x, piece.y, piece.w ?? 120, piece.h ?? 12, common);
  body.pieceId = piece.id;
  return body;
}

/**
 * Create a world on a canvas: a DPR-scaled 2D context, walls, a mouse constraint that
 * understands the DPR, and a loop that steps the engine by STEP as many times as the frame
 * owes (a handful at most), then paints every body from its vertices.
 */
export function createWorld(canvas, { width, height, gravity = 1, seed = 1 } = {}) {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = width * dpr;
  canvas.height = height * dpr;
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  const ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

  Common._seed = seed;
  const engine = Engine.create({ enableSleeping: true });
  engine.gravity.y = gravity;
  const wall = { isStatic: true, label: "wall" };
  Composite.add(engine.world, [
    Bodies.rectangle(width / 2, height + 20, width + 80, 40, wall),
    Bodies.rectangle(-20, height / 2, 40, height * 2, wall),
    Bodies.rectangle(width + 20, height / 2, 40, height * 2, wall),
  ]);

  const mouse = Mouse.create(canvas);
  mouse.pixelRatio = dpr;
  const mouseConstraint = MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.2 } });
  Composite.add(engine.world, mouseConstraint);

  const colours = {
    paper: cssColor(canvas, "--background", "#ffffff"),
    ink: cssColor(canvas, "--text-primary", "#111111"),
    lead: cssColor(canvas, "--primary", "#4f46e5"),
    accent: cssColor(canvas, "--accent", "#f59e0b"),
  };
  const fillFor = (body) => (body.label === "marble" ? colours.lead : body.label === "goal" || body.label === "bumper" ? colours.accent : colours.ink);

  const world = {
    engine,
    mouse,
    mouseConstraint,
    running: false,
    paused: false,
    raf: 0,
    last: 0,
    owed: 0,
    paint() {
      ctx.fillStyle = colours.paper;
      ctx.fillRect(0, 0, width, height);
      for (const body of Composite.allBodies(engine.world)) {
        if (body.label === "wall") continue;
        ctx.beginPath();
        for (const v of body.vertices) ctx.lineTo(v.x, v.y);
        ctx.closePath();
        ctx.globalAlpha = body.isSensor ? 0.35 : 1;
        ctx.fillStyle = fillFor(body);
        ctx.fill();
        ctx.globalAlpha = 1;
      }
    },
    frame(now) {
      if (!world.running) return;
      if (!world.paused) {
        world.owed += Math.min(now - world.last, 4 * STEP);
        while (world.owed >= STEP) {
          Engine.update(engine, STEP);
          world.owed -= STEP;
        }
      }
      world.last = now;
      world.paint();
      world.raf = requestAnimationFrame(world.frame);
    },
    start() {
      if (world.running) return;
      world.running = true;
      world.last = performance.now();
      world.owed = 0;
      world.raf = requestAnimationFrame(world.frame);
    },
    stop() {
      world.running = false;
      cancelAnimationFrame(world.raf);
    },
    /** Every non-wall body out, the clock back to zero, the same seed: the next run replays. */
    reset(pieces) {
      // Bodies go; the walls and the mouse constraint stay attached across every reset.
      for (const body of Composite.allBodies(engine.world)) if (body.label !== "wall") Composite.remove(engine.world, body);
      Common._seed = seed;
      engine.timing.timestamp = 0;
      for (const piece of pieces) Composite.add(engine.world, bodyFromPiece(piece));
      world.paint();
    },
    add(piece) {
      const body = bodyFromPiece(piece);
      Composite.add(engine.world, body);
      return body;
    },
    remove(body) {
      Composite.remove(engine.world, body);
    },
    bodies() {
      return Composite.allBodies(engine.world).filter((b) => b.label !== "wall");
    },
    /** The body under a point in canvas coordinates, or undefined. */
    pick(point) {
      return Query.point(world.bodies(), point)[0];
    },
    /** Fire `fn(a, b)` when a body labelled `a` meets one labelled `b`. */
    onMeet(a, b, fn) {
      const handler = (event) => {
        for (const pair of event.pairs) {
          const labels = [pair.bodyA.label, pair.bodyB.label];
          if (labels.includes(a) && labels.includes(b)) fn(pair.bodyA.label === a ? pair.bodyA : pair.bodyB, pair.bodyA.label === a ? pair.bodyB : pair.bodyA);
        }
      };
      Events.on(engine, "collisionStart", handler);
      return () => Events.off(engine, "collisionStart", handler);
    },
    dispose() {
      world.stop();
      Mouse.clearSourceEvents(mouse);
      Composite.clear(engine.world, false);
      Engine.clear(engine);
    },
  };
  world.paint();
  return world;
}

The interface. A level is one document whose pieces array is the run; in build mode a tap places the chosen piece and a tap on a piece removes it, and the pieces autosave when this person may edit the level; in play mode a button drops a marble, the goal sensor scores it, and reset rebuilds the run from the pieces so the next drop replays the last one exactly.

App.jsx

jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Input, Row, Segmented, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { createWorld } from "./lib/physics.js";

const WIDTH = 360;
const HEIGHT = 520;
const KINDS = [
  { value: "ramp", label: "Ramp" },
  { value: "peg", label: "Peg" },
  { value: "bumper", label: "Bumper" },
  { value: "goal", label: "Goal" },
];
const STARTER_PIECES = [
  { id: "p1", kind: "ramp", x: 110, y: 150, w: 170, h: 12, angle: 0.35 },
  { id: "p2", kind: "peg", x: 230, y: 250, r: 8 },
  { id: "p3", kind: "bumper", x: 120, y: 330, r: 16 },
  { id: "p4", kind: "goal", x: 280, y: 505, w: 110, h: 10 },
];

export default function App() {
  const { database, useLiveQuery } = useFireproof("marbleRuns");
  const { me, can, ready } = useVibe("marbleRuns");
  const { requestLogin, ViewerTag } = useViewer();
  const { docs } = useLiveQuery("type", { key: "level" });
  const levels = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);

  const [activeId, setActiveId] = useState(null);
  const [pieces, setPieces] = useState(STARTER_PIECES);
  const [mode, setMode] = useState("build");
  const [kind, setKind] = useState("ramp");
  const [score, setScore] = useState(0);
  const [drops, setDrops] = useState(0);
  const [name, setName] = useState("");

  const canvasRef = useRef(null);
  const worldRef = useRef(null);
  const piecesRef = useRef(pieces);
  const saveTimer = useRef(null);
  piecesRef.current = pieces;

  const active = levels.find((l) => l._id === activeId) ?? null;
  const canEdit = Boolean(active && ready && can.edit(active).ok);
  const canMake = Boolean(ready && me && can.create({ type: "level", name: "x", pieces: [], creatorHandle: me.userHandle }).ok);

  // The engine lives in a ref: created once, disposed in the effect's cleanup.
  useEffect(() => {
    const world = createWorld(canvasRef.current, { width: WIDTH, height: HEIGHT, seed: 7 });
    worldRef.current = world;
    world.reset(piecesRef.current);
    const off = world.onMeet("marble", "goal", (marble) => {
      world.remove(marble);
      setScore((s) => s + 1);
    });
    world.start();
    return () => {
      off();
      world.dispose();
      worldRef.current = null;
    };
  }, []);

  // A level switch loads its pieces into the world.
  useEffect(() => {
    if (!active) return;
    setPieces(active.pieces);
    worldRef.current?.reset(active.pieces);
    setScore(0);
    setDrops(0);
  }, [active?._id]);

  function commit(next) {
    setPieces(next);
    worldRef.current?.reset(next);
    setScore(0);
    setDrops(0);
    if (active && canEdit) {
      clearTimeout(saveTimer.current);
      saveTimer.current = setTimeout(() => database.put({ ...active, pieces: next, updatedAt: Date.now() }), 500);
    }
  }

  function tap(event) {
    const world = worldRef.current;
    if (!world || mode !== "build") return;
    const rect = canvasRef.current.getBoundingClientRect();
    const point = { x: event.clientX - rect.left, y: event.clientY - rect.top };
    const hit = world.pick(point);
    if (hit && hit.pieceId) return commit(pieces.filter((p) => p.id !== hit.pieceId));
    const size = kind === "peg" ? { r: 8 } : kind === "bumper" ? { r: 16 } : kind === "goal" ? { w: 110, h: 10 } : { w: 150, h: 12, angle: 0.3 };
    commit([...pieces, { id: crypto.randomUUID(), kind, x: point.x, y: point.y, ...size }]);
  }

  function drop() {
    const world = worldRef.current;
    if (!world) return;
    world.add({ id: crypto.randomUUID(), kind: "marble", x: 40 + (drops % 5) * 12, y: 16, r: 9 });
    setDrops((d) => d + 1);
    setMode("play");
  }

  function replay() {
    worldRef.current?.reset(pieces);
    setScore(0);
    setDrops(0);
  }

  async function keep() {
    if (!canMake) return;
    const doc = { _id: crypto.randomUUID(), type: "level", name: name || "My run", pieces, seed: 7, creatorHandle: me.userHandle, updatedAt: Date.now() };
    setName(""); // ready for the next name at once
    const { ok } = await database.put(doc);
    if (ok) setActiveId(doc._id);
    else setName((now) => now || name);
  }

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Marble Run</Heading>
          <Spacer />
          <Badge tone="up">{score} in the goal</Badge>
          {active && ready && !canEdit && <Badge tone="info">View only</Badge>}
        </Row>

        <Row gap={2} align="center" wrap>
          <Segmented label="Mode" value={mode} onChange={setMode} options={[{ value: "build", label: "Build" }, { value: "play", label: "Play" }]} />
          {mode === "build" && <Segmented label="Piece" value={kind} onChange={setKind} options={KINDS} />}
          <Spacer />
          <Button onClick={drop}>Drop a marble</Button>
          <Button variant="quiet" onClick={replay}>Reset</Button>
        </Row>
        <Text size="sm" muted>{mode === "build" ? "Tap the stage to place a piece; tap a piece to take it away." : "Drag a marble while it rolls. Reset replays the run from the same start."}</Text>

        <Card variant="flush">
          <canvas ref={canvasRef} onPointerDown={tap} className="block touch-none mx-auto" />
        </Card>

        <Card variant="tight">
          <Stack gap={3}>
            <Heading level={2}>Runs</Heading>
            {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
            {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your runs stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep runs</Button></Row>}
            {ready && me && canMake && (
              <Row gap={2} align="top" wrap>
                <Field label="Name this run">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="the long drop" />}</Field>
                <Button size="sm" onClick={keep}>Keep</Button>
              </Row>
            )}
            {levels.map((l) => (
              <Row key={l._id} gap={2} align="center" wrap>
                <Button size="sm" variant={l._id === active?._id ? "solid" : "quiet"} onClick={() => setActiveId(l._id)}>{l.name}</Button>
                {l.creatorHandle && <ViewerTag userHandle={l.creatorHandle} />}
                <Spacer />
                {can.delete(l).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(l._id)}>Remove</Button>}
              </Row>
            ))}
          </Stack>
        </Card>
      </Stack>
    </main>
  );
}

access.js

js// level: a marble run. Its creator edits it; everyone can open and play it.
export function marbleRuns(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "sign in to keep runs" };
  if (doc.type === "level" || (doc._deleted && oldDoc && oldDoc.type === "level")) {
    const creator = oldDoc ? oldDoc.creatorHandle : doc.creatorHandle;
    if (creator !== user.userHandle) throw { forbidden: "runs are kept as yourself" };
    if (oldDoc && !doc._deleted && doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "a run keeps its maker" };
    return { channels: ["levels"], grant: { public: ["levels"] } };
  }
  throw { forbidden: "unknown document type" };
}

The engine lives in a ref and steps by a fixed STEP

Create the world once inside useEffect(() => {…}, []), keep it in a ref, and dispose the world in the effect's cleanup (world.dispose() above: stop the loop with cancelAnimationFrame, release the mouse listeners, Composite.clear the world and Engine.clear(engine)). React renders the chrome; the engine owns the canvas. Per-change values the loop needs (the pieces, the mode) travel through refs the handlers read.

The loop steps the engine by a fixed STEP as many times as the frame owes, and leaves the frame's own delta out of the physics. Engine.update(engine, STEP) with STEP = 1000 / 60 makes the simulation independent of the display: a 60 Hz laptop and a 120 Hz phone compute the same trajectory, a dropped frame is paid back as two steps rather than one long one, and the cap (Math.min(now - last, 4 * STEP)) keeps a background tab from stepping hundreds of times on return. Measured: two worlds built the same way and stepped 300 times land every body on identical coordinates. Matter.Runner does the same fixed stepping with its own requestAnimationFrame (Runner.run(runner, engine), Runner.stop(runner)); the loop above exists so painting and stepping share one frame.

Paint from body.vertices on a DPR-scaled canvas in the app's theme colours. The canvas is width * dpr pixels wide and width CSS pixels wide, ctx.setTransform(dpr, 0, 0, dpr, 0, 0) once, and every body is a closed path of its vertices filled by label. The colours come from the injected tokens through cssColor (the p5 doc's probe trick), so the owner's restyle reaches the stage. Matter's own Render is a fine wireframe prototype (Render.create({ element, engine, options: { wireframes: true } }), Render.run), with one measured catch: create it before the first Engine.update. Render.create recursively merges its configuration, the supplied engine included, and once collision pairs have put cycles in the engine that merge walks pair → body → parent and overflows the stack (measured: fine on a fresh engine, RangeError after 120 steps of a colliding world). Creating it first is the safe ordering; the custom canvas loop above has no such order.

Set mouse.pixelRatio = dpr right after Mouse.create(canvas), so the mouse constraint picks the body under the finger on a retina screen rather than one half a stage away. The same Mouse handles touch (its touch listeners are registered with passive: false), so dragging a marble on a phone needs nothing more than touch-none on the canvas.

Build mode and play mode — the document is the source of truth

A piece is a document and its body is a stand-in: bodyFromPiece makes the body, reset(pieces) rebuilds the whole world from the array, and a change to the pieces calls reset rather than nudging a body, so what is saved and what is on the stage are the same thing by construction. Marbles are runtime-only: dropped from a button, removed when they score, gone on reset. Static pieces are placed by a tap (Query.point through pick decides whether the tap landed on a piece to remove or on empty stage to fill), and the mouse constraint drags only the dynamic bodies, which is the behaviour a builder wants: a ramp stays where it was put, a marble can be flicked.

Autosave the pieces on a short debounce when can.edit(level).ok; a visitor without edit rights builds on their own screen and sees the view-only badge; "Keep" writes a new level as the visitor, gated on can.create. Seed seed.json with two or three finished levels so the first visit has somewhere to drop a marble.

Seeded replays

Reset puts the clock back to zero, re-seeds Common._seed, and rebuilds the bodies from the pieces, so the next run replays the last one exactly. Measured: drop a marble, step 600 times, reset, drop again, step 600 times, and the marble lands on the same coordinates to the hundredth of a pixel with the same goal count. That is what makes "share this run" honest: a saved level plus its seed plus the same drops is the same show on every device. Any randomness the toy wants (a bumper's kick, a spawn position) goes through Common.random() so the seed covers it.

Collisions and scoring

Label bodies and listen on collisionStart for the pair you care about. onMeet("marble", "goal", fn) above wraps it; isSensor: true on the goal makes it fire without stopping the marble. The same hook drives sounds (the web-audio doc's restraint rule: a sound on the goal, none on every peg), particle bursts, or a tower's "knocked down" check (count the pieces whose position.y crossed a line). collisionActive and collisionEnd exist for resting contact and release.

A second shape — words with mass

Type a sentence; every word becomes a body sized to its text, tumbles into the pile, and can be flicked. The same module, no persistence, a shake button that applies a force to every body.

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { Button, Field, Heading, Input, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { Matter, createWorld, cssColor } from "./lib/physics.js";

const { Bodies, Body, Composite } = Matter;
const WIDTH = 360;
const HEIGHT = 420;

export default function App() {
  const canvasRef = useRef(null);
  const worldRef = useRef(null);
  const [text, setText] = useState("");

  useEffect(() => {
    const canvas = canvasRef.current;
    const world = createWorld(canvas, { width: WIDTH, height: HEIGHT, seed: 3 });
    worldRef.current = world;
    // Paint each word inside its body: rotate with the body, centre the text.
    const ctx = canvas.getContext("2d");
    const ink = cssColor(canvas, "--background", "#ffffff");
    const basePaint = world.paint;
    world.paint = () => {
      basePaint();
      ctx.fillStyle = ink;
      ctx.font = "600 16px system-ui, sans-serif";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      for (const body of world.bodies()) {
        if (!body.word) continue;
        ctx.save();
        ctx.translate(body.position.x, body.position.y);
        ctx.rotate(body.angle);
        ctx.fillText(body.word, 0, 0);
        ctx.restore();
      }
    };
    world.start();
    return () => {
      world.dispose();
      worldRef.current = null;
    };
  }, []);

  function addWords(event) {
    event.preventDefault();
    const world = worldRef.current;
    if (!world || !text.trim()) return;
    const ctx = canvasRef.current.getContext("2d");
    ctx.font = "600 16px system-ui, sans-serif";
    text.trim().split(/\s+/).forEach((word, i) => {
      const w = ctx.measureText(word).width + 16;
      const body = Bodies.rectangle(40 + ((i * 70) % (WIDTH - 80)), -20 - i * 30, w, 28, { label: "word", chamfer: { radius: 8 }, restitution: 0.3 });
      body.word = word;
      Composite.add(world.engine.world, body);
    });
    setText("");
  }

  function shake() {
    const world = worldRef.current;
    if (!world) return;
    for (const body of world.bodies()) {
      Body.applyForce(body, body.position, { x: (Math.random() - 0.5) * 0.04 * body.mass, y: -0.05 * body.mass });
    }
  }

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Heading level={1}>Word Pile</Heading>
        <form onSubmit={addWords}>
          <Row gap={2} align="top" wrap>
            <Field label="Say something">{(id, d) => <Input id={id} describedBy={d} value={text} onChange={setText} placeholder="gravity is a habit" />}</Field>
            <Button type="submit">Drop the words</Button>
            <Spacer />
            <Button type="button" variant="quiet" onClick={shake}>Shake</Button>
          </Row>
        </form>
        <canvas ref={canvasRef} className="block touch-none mx-auto rounded-md" />
        <Text size="sm" muted>Drag a word to throw it. Shake lifts the whole pile.</Text>
      </Stack>
    </main>
  );
}

Body.applyForce takes a force in Matter's units, so scale it by body.mass (as the engine's own examples do) and apply it once; Body.setVelocity sets a speed outright, which is the right call for a launch. chamfer rounds a rectangle's corners so a word pile settles like beanbags rather than bricks.

Mobile

Touch is handled by Matter's Mouse with the pixelRatio set; touch-none on the canvas keeps a drag from scrolling the page. Size the stage from the host (Math.min(host.clientWidth, 480)) and rebuild the world on resize (a new createWorld, then reset(pieces)), since bodies keep their coordinates. Cap dpr at 2 and enableSleeping: true so a settled pile costs nothing; a phone handles a few hundred bodies at 60 Hz.

Shapes a physics vibe takes

  • A marble run or Rube Goldberg builder — pieces as documents, build and play modes, a goal sensor, seeded replays, a gallery of runs. The starter.
  • A slingshot or catapult — a Constraint with low stiffness from an anchor point to a projectile, released when the mouse lets go (the engine's slingshot example: Constraint.create({ pointA: anchor, bodyB: rock, stiffness: 0.05, damping: 0.01 })), a tower of Bodies.rectangle from Composites.pyramid, and a score from how many blocks fell past a line.
  • Dominoes and towers — tall thin rectangles placed by tap, one push (Body.applyForce) to start the chain, a replay to watch it again.
  • Plinko and pachinko — a Composites.stack of static pegs, marbles dropped from a slider-chosen column, buckets as sensors with points.
  • Pinball — flippers as static bodies rotated with Body.setAngle on a key or tap, bumpers with restitution above 1, a drain sensor.
  • A word pile or a page with mass — the second example; a landing page whose cards fall when tapped is the same shape with cards as bodies.
  • Ragdolls and soft bodies — Composites.softBody for jelly, Constraints between circles for a ragdoll; the engine's examples have both.

Common gotchas

  • import { Engine } from "matter-js" reads undefined — the esm.sh build has a default export only. import Matter from … and destructure.
  • The simulation runs at a different speed on another device — the update used the frame delta. Step by a fixed STEP and pay back owed time in whole steps.
  • A tap or drag lands half a stage away — the mouse has no pixelRatio on a DPR-scaled canvas. Set mouse.pixelRatio = dpr.
  • Render.create overflows the stack — it was created after the engine had simulated collisions. Create it before the first update, or paint from vertices as the module does.
  • A level reloads with pieces slightly off — bodies were saved instead of pieces. Save the piece documents and rebuild bodies from them.
  • Bodies freeze mid-air — sleeping bodies stay asleep until something touches them; Sleeping.set(body, false) or Body.setVelocity wakes one, and a dropped marble wakes what it hits.
  • A big frame hitch makes bodies tunnel through walls — the cap on owed time keeps steps small; make walls thick (40 px) so a fast body meets them.