Physics — things with mass, built with Matter.js
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
Constraintwith lowstiffnessfrom 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 ofBodies.rectanglefromComposites.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.stackof static pegs, marbles dropped from a slider-chosen column, buckets as sensors with points. - Pinball — flippers as static bodies rotated with
Body.setAngleon a key or tap, bumpers withrestitutionabove 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.softBodyfor 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
STEPand pay back owed time in whole steps. - A tap or drag lands half a stage away — the mouse has no
pixelRatioon a DPR-scaled canvas. Setmouse.pixelRatio = dpr. Render.createoverflows 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)orBody.setVelocitywakes 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.