@vibes.diy/prompts 14.3.82 → 14.3.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,635 @@
1
+ # Physics — things with mass, built with Matter.js
2
+
3
+ When someone asks for physics, gravity, things that bounce, collide, tumble or
4
+ pile up, a marble run, a Rube Goldberg machine, plinko, a catapult or
5
+ slingshot, dominoes, a tower to knock down, pinball, a ragdoll, a page where
6
+ the words fall and can be flicked around, or any toy where objects behave
7
+ like objects, this is the recipe. Matter.js is a 2D rigid-body engine: the
8
+ app decides which shapes exist and where, the engine moves them, a canvas
9
+ paints them, and Fireproof keeps the pieces so a build is there tomorrow and
10
+ for everyone who opens it.
11
+
12
+ The sibling boundary: a sketch that draws without collisions is `p5`; a
13
+ scene with depth is `three-js`; a block world is `voxel`. Physics is the
14
+ right call when the fun is bodies with mass meeting other bodies.
15
+
16
+ The worked examples below were built and measured for this doc (Chromium,
17
+ 2026-10-07, `matter-js@0.20.0` from esm.sh): a marble run builder whose
18
+ pieces are documents, and a page where typed words tumble.
19
+
20
+ ## Importing Matter.js — one pinned default import
21
+
22
+ **Import the engine as `import Matter from "https://esm.sh/matter-js@0.20.0"`
23
+ and take the modules off it** (`const { Engine, Bodies, Composite } = Matter`).
24
+ The esm.sh build of the package exposes Matter as its default export and
25
+ nothing as named exports (measured: `import { Engine } …` reads undefined),
26
+ so the default import is the one shape that works. Pin the version: a
27
+ physics toy's feel is the engine's numbers, and a saved level assumes the
28
+ engine it was built on.
29
+
30
+ Concave shapes (an L, an arrow, a cup) need a decomposer the engine does not
31
+ ship: `Common.setDecomp(await import("https://esm.sh/poly-decomp@0.3.0"))`
32
+ once, then `Bodies.fromVertices(x, y, points)`. Rectangles, circles and
33
+ regular polygons need nothing extra, and most toys are made of those.
34
+
35
+ ## Project shape
36
+
37
+ ```
38
+ App.jsx the chrome: mode switch, piece picker, play controls, score, saved levels
39
+ lib/physics.js the engine: world creation, the fixed-step loop, painting, picking, collisions
40
+ access.js who may save a level
41
+ seed.json two or three finished levels so the first visit has something to drop a marble into
42
+ ```
43
+
44
+ Relative imports include the extension (`./lib/physics.js`). Everything that
45
+ touches Matter lives in the module; `App.jsx` reads as an interface.
46
+
47
+ ## Complete starter — a marble run builder
48
+
49
+ The engine module. One world per canvas: a DPR-scaled context, three walls,
50
+ a mouse constraint that knows the DPR, a loop that steps the engine by a
51
+ fixed `STEP` and paints every body from its vertices in the app's theme
52
+ colours, and helpers that turn piece documents into bodies and back.
53
+
54
+ ### `lib/physics.js`
55
+ ```js
56
+ import Matter from "https://esm.sh/matter-js@0.20.0";
57
+
58
+ const { Engine, Bodies, Body, Composite, Mouse, MouseConstraint, Events, Common, Query } = Matter;
59
+ export { Matter };
60
+
61
+ /** One physics step, in milliseconds. Every world steps by exactly this, so a replay is a replay. */
62
+ export const STEP = 1000 / 60;
63
+
64
+ /** Resolve one of the app's theme tokens to a colour the canvas can paint with. */
65
+ export function cssColor(host, token, fallback) {
66
+ const probe = document.createElement("span");
67
+ probe.style.display = "none";
68
+ probe.style.color = `var(${token}, ${fallback})`;
69
+ host.appendChild(probe);
70
+ const resolved = window.getComputedStyle(probe).color;
71
+ probe.remove();
72
+ return resolved || fallback;
73
+ }
74
+
75
+ /** A body from a piece document; the document is the source of truth, the body is its stand-in. */
76
+ export function bodyFromPiece(piece) {
77
+ 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 };
78
+ const body =
79
+ piece.kind === "peg" || piece.kind === "bumper" || piece.kind === "marble"
80
+ ? Bodies.circle(piece.x, piece.y, piece.r ?? 12, common)
81
+ : Bodies.rectangle(piece.x, piece.y, piece.w ?? 120, piece.h ?? 12, common);
82
+ body.pieceId = piece.id;
83
+ return body;
84
+ }
85
+
86
+ /**
87
+ * Create a world on a canvas: a DPR-scaled 2D context, walls, a mouse constraint that
88
+ * understands the DPR, and a loop that steps the engine by STEP as many times as the frame
89
+ * owes (a handful at most), then paints every body from its vertices.
90
+ */
91
+ export function createWorld(canvas, { width, height, gravity = 1, seed = 1 } = {}) {
92
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
93
+ canvas.width = width * dpr;
94
+ canvas.height = height * dpr;
95
+ canvas.style.width = `${width}px`;
96
+ canvas.style.height = `${height}px`;
97
+ const ctx = canvas.getContext("2d");
98
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
99
+
100
+ Common._seed = seed;
101
+ const engine = Engine.create({ enableSleeping: true });
102
+ engine.gravity.y = gravity;
103
+ const wall = { isStatic: true, label: "wall" };
104
+ Composite.add(engine.world, [
105
+ Bodies.rectangle(width / 2, height + 20, width + 80, 40, wall),
106
+ Bodies.rectangle(-20, height / 2, 40, height * 2, wall),
107
+ Bodies.rectangle(width + 20, height / 2, 40, height * 2, wall),
108
+ ]);
109
+
110
+ const mouse = Mouse.create(canvas);
111
+ mouse.pixelRatio = dpr;
112
+ const mouseConstraint = MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.2 } });
113
+ Composite.add(engine.world, mouseConstraint);
114
+
115
+ const colours = {
116
+ paper: cssColor(canvas, "--background", "#ffffff"),
117
+ ink: cssColor(canvas, "--text-primary", "#111111"),
118
+ lead: cssColor(canvas, "--primary", "#4f46e5"),
119
+ accent: cssColor(canvas, "--accent", "#f59e0b"),
120
+ };
121
+ const fillFor = (body) => (body.label === "marble" ? colours.lead : body.label === "goal" || body.label === "bumper" ? colours.accent : colours.ink);
122
+
123
+ const world = {
124
+ engine,
125
+ mouse,
126
+ mouseConstraint,
127
+ running: false,
128
+ paused: false,
129
+ raf: 0,
130
+ last: 0,
131
+ owed: 0,
132
+ paint() {
133
+ ctx.fillStyle = colours.paper;
134
+ ctx.fillRect(0, 0, width, height);
135
+ for (const body of Composite.allBodies(engine.world)) {
136
+ if (body.label === "wall") continue;
137
+ ctx.beginPath();
138
+ for (const v of body.vertices) ctx.lineTo(v.x, v.y);
139
+ ctx.closePath();
140
+ ctx.globalAlpha = body.isSensor ? 0.35 : 1;
141
+ ctx.fillStyle = fillFor(body);
142
+ ctx.fill();
143
+ ctx.globalAlpha = 1;
144
+ }
145
+ },
146
+ frame(now) {
147
+ if (!world.running) return;
148
+ if (!world.paused) {
149
+ world.owed += Math.min(now - world.last, 4 * STEP);
150
+ while (world.owed >= STEP) {
151
+ Engine.update(engine, STEP);
152
+ world.owed -= STEP;
153
+ }
154
+ }
155
+ world.last = now;
156
+ world.paint();
157
+ world.raf = requestAnimationFrame(world.frame);
158
+ },
159
+ start() {
160
+ if (world.running) return;
161
+ world.running = true;
162
+ world.last = performance.now();
163
+ world.owed = 0;
164
+ world.raf = requestAnimationFrame(world.frame);
165
+ },
166
+ stop() {
167
+ world.running = false;
168
+ cancelAnimationFrame(world.raf);
169
+ },
170
+ /** Every non-wall body out, the clock back to zero, the same seed: the next run replays. */
171
+ reset(pieces) {
172
+ // Bodies go; the walls and the mouse constraint stay attached across every reset.
173
+ for (const body of Composite.allBodies(engine.world)) if (body.label !== "wall") Composite.remove(engine.world, body);
174
+ Common._seed = seed;
175
+ engine.timing.timestamp = 0;
176
+ for (const piece of pieces) Composite.add(engine.world, bodyFromPiece(piece));
177
+ world.paint();
178
+ },
179
+ add(piece) {
180
+ const body = bodyFromPiece(piece);
181
+ Composite.add(engine.world, body);
182
+ return body;
183
+ },
184
+ remove(body) {
185
+ Composite.remove(engine.world, body);
186
+ },
187
+ bodies() {
188
+ return Composite.allBodies(engine.world).filter((b) => b.label !== "wall");
189
+ },
190
+ /** The body under a point in canvas coordinates, or undefined. */
191
+ pick(point) {
192
+ return Query.point(world.bodies(), point)[0];
193
+ },
194
+ /** Fire `fn(a, b)` when a body labelled `a` meets one labelled `b`. */
195
+ onMeet(a, b, fn) {
196
+ const handler = (event) => {
197
+ for (const pair of event.pairs) {
198
+ const labels = [pair.bodyA.label, pair.bodyB.label];
199
+ if (labels.includes(a) && labels.includes(b)) fn(pair.bodyA.label === a ? pair.bodyA : pair.bodyB, pair.bodyA.label === a ? pair.bodyB : pair.bodyA);
200
+ }
201
+ };
202
+ Events.on(engine, "collisionStart", handler);
203
+ return () => Events.off(engine, "collisionStart", handler);
204
+ },
205
+ dispose() {
206
+ world.stop();
207
+ Mouse.clearSourceEvents(mouse);
208
+ Composite.clear(engine.world, false);
209
+ Engine.clear(engine);
210
+ },
211
+ };
212
+ world.paint();
213
+ return world;
214
+ }
215
+ ```
216
+
217
+ The interface. A level is one document whose `pieces` array is the run; in
218
+ build mode a tap places the chosen piece and a tap on a piece removes it,
219
+ and the pieces autosave when this person may edit the level; in play mode a
220
+ button drops a marble, the goal sensor scores it, and reset rebuilds the run
221
+ from the pieces so the next drop replays the last one exactly.
222
+
223
+ ### `App.jsx`
224
+ ```jsx
225
+ import React, { useEffect, useMemo, useRef, useState } from "react";
226
+ import { useFireproof } from "use-fireproof";
227
+ import { useVibe, useViewer } from "use-vibes";
228
+ import { Badge, Button, Card, Field, Heading, Input, Row, Segmented, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
229
+ import { createWorld } from "./lib/physics.js";
230
+
231
+ const WIDTH = 360;
232
+ const HEIGHT = 520;
233
+ const KINDS = [
234
+ { value: "ramp", label: "Ramp" },
235
+ { value: "peg", label: "Peg" },
236
+ { value: "bumper", label: "Bumper" },
237
+ { value: "goal", label: "Goal" },
238
+ ];
239
+ const STARTER_PIECES = [
240
+ { id: "p1", kind: "ramp", x: 110, y: 150, w: 170, h: 12, angle: 0.35 },
241
+ { id: "p2", kind: "peg", x: 230, y: 250, r: 8 },
242
+ { id: "p3", kind: "bumper", x: 120, y: 330, r: 16 },
243
+ { id: "p4", kind: "goal", x: 280, y: 505, w: 110, h: 10 },
244
+ ];
245
+
246
+ export default function App() {
247
+ const { database, useLiveQuery } = useFireproof("marbleRuns");
248
+ const { me, can, ready } = useVibe("marbleRuns");
249
+ const { requestLogin, ViewerTag } = useViewer();
250
+ const { docs } = useLiveQuery("type", { key: "level" });
251
+ const levels = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);
252
+
253
+ const [activeId, setActiveId] = useState(null);
254
+ const [pieces, setPieces] = useState(STARTER_PIECES);
255
+ const [mode, setMode] = useState("build");
256
+ const [kind, setKind] = useState("ramp");
257
+ const [score, setScore] = useState(0);
258
+ const [drops, setDrops] = useState(0);
259
+ const [name, setName] = useState("");
260
+
261
+ const canvasRef = useRef(null);
262
+ const worldRef = useRef(null);
263
+ const piecesRef = useRef(pieces);
264
+ const saveTimer = useRef(null);
265
+ piecesRef.current = pieces;
266
+
267
+ const active = levels.find((l) => l._id === activeId) ?? null;
268
+ const canEdit = Boolean(active && ready && can.edit(active).ok);
269
+ const canMake = Boolean(ready && me && can.create({ type: "level", name: "x", pieces: [], creatorHandle: me.userHandle }).ok);
270
+
271
+ // The engine lives in a ref: created once, disposed in the effect's cleanup.
272
+ useEffect(() => {
273
+ const world = createWorld(canvasRef.current, { width: WIDTH, height: HEIGHT, seed: 7 });
274
+ worldRef.current = world;
275
+ world.reset(piecesRef.current);
276
+ const off = world.onMeet("marble", "goal", (marble) => {
277
+ world.remove(marble);
278
+ setScore((s) => s + 1);
279
+ });
280
+ world.start();
281
+ return () => {
282
+ off();
283
+ world.dispose();
284
+ worldRef.current = null;
285
+ };
286
+ }, []);
287
+
288
+ // A level switch loads its pieces into the world.
289
+ useEffect(() => {
290
+ if (!active) return;
291
+ setPieces(active.pieces);
292
+ worldRef.current?.reset(active.pieces);
293
+ setScore(0);
294
+ setDrops(0);
295
+ }, [active?._id]);
296
+
297
+ function commit(next) {
298
+ setPieces(next);
299
+ worldRef.current?.reset(next);
300
+ setScore(0);
301
+ setDrops(0);
302
+ if (active && canEdit) {
303
+ clearTimeout(saveTimer.current);
304
+ saveTimer.current = setTimeout(() => database.put({ ...active, pieces: next, updatedAt: Date.now() }), 500);
305
+ }
306
+ }
307
+
308
+ function tap(event) {
309
+ const world = worldRef.current;
310
+ if (!world || mode !== "build") return;
311
+ const rect = canvasRef.current.getBoundingClientRect();
312
+ const point = { x: event.clientX - rect.left, y: event.clientY - rect.top };
313
+ const hit = world.pick(point);
314
+ if (hit && hit.pieceId) return commit(pieces.filter((p) => p.id !== hit.pieceId));
315
+ const size = kind === "peg" ? { r: 8 } : kind === "bumper" ? { r: 16 } : kind === "goal" ? { w: 110, h: 10 } : { w: 150, h: 12, angle: 0.3 };
316
+ commit([...pieces, { id: crypto.randomUUID(), kind, x: point.x, y: point.y, ...size }]);
317
+ }
318
+
319
+ function drop() {
320
+ const world = worldRef.current;
321
+ if (!world) return;
322
+ world.add({ id: crypto.randomUUID(), kind: "marble", x: 40 + (drops % 5) * 12, y: 16, r: 9 });
323
+ setDrops((d) => d + 1);
324
+ setMode("play");
325
+ }
326
+
327
+ function replay() {
328
+ worldRef.current?.reset(pieces);
329
+ setScore(0);
330
+ setDrops(0);
331
+ }
332
+
333
+ async function keep() {
334
+ if (!canMake) return;
335
+ const doc = { _id: crypto.randomUUID(), type: "level", name: name || "My run", pieces, seed: 7, creatorHandle: me.userHandle, updatedAt: Date.now() };
336
+ await database.put(doc);
337
+ setName("");
338
+ setActiveId(doc._id);
339
+ }
340
+
341
+ return (
342
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
343
+ <Stack gap={4}>
344
+ <Row gap={2} align="center" wrap>
345
+ <Heading level={1}>Marble Run</Heading>
346
+ <Spacer />
347
+ <Badge tone="up">{score} in the goal</Badge>
348
+ {active && ready && !canEdit && <Badge tone="info">View only</Badge>}
349
+ </Row>
350
+
351
+ <Row gap={2} align="center" wrap>
352
+ <Segmented label="Mode" value={mode} onChange={setMode} options={[{ value: "build", label: "Build" }, { value: "play", label: "Play" }]} />
353
+ {mode === "build" && <Segmented label="Piece" value={kind} onChange={setKind} options={KINDS} />}
354
+ <Spacer />
355
+ <Button onClick={drop}>Drop a marble</Button>
356
+ <Button variant="quiet" onClick={replay}>Reset</Button>
357
+ </Row>
358
+ <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>
359
+
360
+ <Card variant="flush">
361
+ <canvas ref={canvasRef} onPointerDown={tap} className="block touch-none mx-auto" />
362
+ </Card>
363
+
364
+ <Card variant="tight">
365
+ <Stack gap={3}>
366
+ <Heading level={2}>Runs</Heading>
367
+ {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
368
+ {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>}
369
+ {ready && me && canMake && (
370
+ <Row gap={2} align="top" wrap>
371
+ <Field label="Name this run">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="the long drop" />}</Field>
372
+ <Button size="sm" onClick={keep}>Keep</Button>
373
+ </Row>
374
+ )}
375
+ {levels.map((l) => (
376
+ <Row key={l._id} gap={2} align="center" wrap>
377
+ <Button size="sm" variant={l._id === active?._id ? "solid" : "quiet"} onClick={() => setActiveId(l._id)}>{l.name}</Button>
378
+ {l.creatorHandle && <ViewerTag userHandle={l.creatorHandle} />}
379
+ <Spacer />
380
+ {can.delete(l).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(l._id)}>Remove</Button>}
381
+ </Row>
382
+ ))}
383
+ </Stack>
384
+ </Card>
385
+ </Stack>
386
+ </main>
387
+ );
388
+ }
389
+ ```
390
+
391
+ ### `access.js`
392
+ ```js
393
+ // level: a marble run. Its creator edits it; everyone can open and play it.
394
+ export function marbleRuns(doc, oldDoc, user, ctx) {
395
+ if (!user) throw { forbidden: "sign in to keep runs" };
396
+ if (doc.type === "level" || (doc._deleted && oldDoc && oldDoc.type === "level")) {
397
+ const creator = oldDoc ? oldDoc.creatorHandle : doc.creatorHandle;
398
+ if (creator !== user.userHandle) throw { forbidden: "runs are kept as yourself" };
399
+ if (oldDoc && !doc._deleted && doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "a run keeps its maker" };
400
+ return { channels: ["levels"], grant: { public: ["levels"] } };
401
+ }
402
+ throw { forbidden: "unknown document type" };
403
+ }
404
+ ```
405
+
406
+ ## The engine lives in a ref and steps by a fixed STEP
407
+
408
+ **Create the world once inside `useEffect(() => {…}, [])`, keep it in a ref,
409
+ and dispose the world in the effect's cleanup** (`world.dispose()` above:
410
+ stop the loop with `cancelAnimationFrame`, release the mouse listeners,
411
+ `Composite.clear` the world and `Engine.clear(engine)`). React renders the
412
+ chrome; the engine owns the canvas. Per-change values the loop needs (the
413
+ pieces, the mode) travel through refs the handlers read.
414
+
415
+ **The loop steps the engine by a fixed `STEP` as many times as the frame
416
+ owes, and leaves the frame's own delta out of the physics.** `Engine.update(engine, STEP)` with
417
+ `STEP = 1000 / 60` makes the simulation independent of the display: a
418
+ 60 Hz laptop and a 120 Hz phone compute the same trajectory, a dropped frame
419
+ is paid back as two steps rather than one long one, and the cap
420
+ (`Math.min(now - last, 4 * STEP)`) keeps a background tab from stepping
421
+ hundreds of times on return. Measured: two worlds built the same way and
422
+ stepped 300 times land every body on identical coordinates. `Matter.Runner`
423
+ does the same fixed stepping with its own `requestAnimationFrame`
424
+ (`Runner.run(runner, engine)`, `Runner.stop(runner)`); the loop above exists
425
+ so painting and stepping share one frame.
426
+
427
+ **Paint from `body.vertices` on a DPR-scaled canvas in the app's theme
428
+ colours.** The canvas is `width * dpr` pixels wide and `width` CSS pixels
429
+ wide, `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` once, and every body is a
430
+ closed path of its vertices filled by label. The colours come from the
431
+ injected tokens through `cssColor` (the p5 doc's probe trick), so the
432
+ owner's restyle reaches the stage. Matter's own `Render` is a fine wireframe
433
+ prototype (`Render.create({ element, engine, options: { wireframes: true } })`,
434
+ `Render.run`), with one measured catch: **create it before the first
435
+ `Engine.update`.** `Render.create` recursively merges its configuration,
436
+ the supplied engine included, and once collision pairs have put cycles in
437
+ the engine that merge walks pair → body → parent and overflows the stack
438
+ (measured: fine on a fresh engine, `RangeError` after 120 steps of a
439
+ colliding world). Creating it first is the safe ordering; the custom canvas
440
+ loop above has no such order.
441
+
442
+ **Set `mouse.pixelRatio = dpr`** right after `Mouse.create(canvas)`, so the
443
+ mouse constraint picks the body under the finger on a retina screen rather
444
+ than one half a stage away. The same `Mouse` handles touch (its touch
445
+ listeners are registered with `passive: false`), so dragging a marble on a
446
+ phone needs nothing more than `touch-none` on the canvas.
447
+
448
+ ## Build mode and play mode — the document is the source of truth
449
+
450
+ **A piece is a document and its body is a stand-in**: `bodyFromPiece` makes
451
+ the body, `reset(pieces)` rebuilds the whole world from the array, and a
452
+ change to the pieces calls `reset` rather than nudging a body, so what is
453
+ saved and what is on the stage are the same thing by construction. Marbles
454
+ are runtime-only: dropped from a button, removed when they score, gone on
455
+ reset. Static pieces are placed by a tap (`Query.point` through `pick`
456
+ decides whether the tap landed on a piece to remove or on empty stage to
457
+ fill), and the mouse constraint drags only the dynamic bodies, which is the
458
+ behaviour a builder wants: a ramp stays where it was put, a marble can be
459
+ flicked.
460
+
461
+ Autosave the pieces on a short debounce when `can.edit(level).ok`; a
462
+ visitor without edit rights builds on their own screen and sees the
463
+ view-only badge; "Keep" writes a new level as the visitor, gated on
464
+ `can.create`. Seed `seed.json` with two or three finished levels so the
465
+ first visit has somewhere to drop a marble.
466
+
467
+ ## Seeded replays
468
+
469
+ **Reset puts the clock back to zero, re-seeds `Common._seed`, and rebuilds
470
+ the bodies from the pieces, so the next run replays the last one exactly.**
471
+ Measured: drop a marble, step 600 times, reset, drop again, step 600 times,
472
+ and the marble lands on the same coordinates to the hundredth of a pixel
473
+ with the same goal count. That is what makes "share this run" honest: a
474
+ saved level plus its seed plus the same drops is the same show on every
475
+ device. Any randomness the toy wants (a bumper's kick, a spawn position)
476
+ goes through `Common.random()` so the seed covers it.
477
+
478
+ ## Collisions and scoring
479
+
480
+ **Label bodies and listen on `collisionStart` for the pair you care about.**
481
+ `onMeet("marble", "goal", fn)` above wraps it; `isSensor: true` on the goal
482
+ makes it fire without stopping the marble. The same hook drives sounds (the
483
+ web-audio doc's restraint rule: a sound on the goal, none on every peg),
484
+ particle bursts, or a tower's "knocked down" check (count the pieces whose
485
+ `position.y` crossed a line). `collisionActive` and `collisionEnd` exist for
486
+ resting contact and release.
487
+
488
+ ## A second shape — words with mass
489
+
490
+ Type a sentence; every word becomes a body sized to its text, tumbles into
491
+ the pile, and can be flicked. The same module, no persistence, a shake
492
+ button that applies a force to every body.
493
+
494
+ ### `App.jsx`
495
+ ```jsx app=tumble
496
+ import React, { useEffect, useRef, useState } from "react";
497
+ import { Button, Field, Heading, Input, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
498
+ import { Matter, createWorld, cssColor } from "./lib/physics.js";
499
+
500
+ const { Bodies, Body, Composite } = Matter;
501
+ const WIDTH = 360;
502
+ const HEIGHT = 420;
503
+
504
+ export default function App() {
505
+ const canvasRef = useRef(null);
506
+ const worldRef = useRef(null);
507
+ const [text, setText] = useState("");
508
+
509
+ useEffect(() => {
510
+ const canvas = canvasRef.current;
511
+ const world = createWorld(canvas, { width: WIDTH, height: HEIGHT, seed: 3 });
512
+ worldRef.current = world;
513
+ // Paint each word inside its body: rotate with the body, centre the text.
514
+ const ctx = canvas.getContext("2d");
515
+ const ink = cssColor(canvas, "--background", "#ffffff");
516
+ const basePaint = world.paint;
517
+ world.paint = () => {
518
+ basePaint();
519
+ ctx.fillStyle = ink;
520
+ ctx.font = "600 16px system-ui, sans-serif";
521
+ ctx.textAlign = "center";
522
+ ctx.textBaseline = "middle";
523
+ for (const body of world.bodies()) {
524
+ if (!body.word) continue;
525
+ ctx.save();
526
+ ctx.translate(body.position.x, body.position.y);
527
+ ctx.rotate(body.angle);
528
+ ctx.fillText(body.word, 0, 0);
529
+ ctx.restore();
530
+ }
531
+ };
532
+ world.start();
533
+ return () => {
534
+ world.dispose();
535
+ worldRef.current = null;
536
+ };
537
+ }, []);
538
+
539
+ function addWords(event) {
540
+ event.preventDefault();
541
+ const world = worldRef.current;
542
+ if (!world || !text.trim()) return;
543
+ const ctx = canvasRef.current.getContext("2d");
544
+ ctx.font = "600 16px system-ui, sans-serif";
545
+ text.trim().split(/\s+/).forEach((word, i) => {
546
+ const w = ctx.measureText(word).width + 16;
547
+ const body = Bodies.rectangle(40 + ((i * 70) % (WIDTH - 80)), -20 - i * 30, w, 28, { label: "word", chamfer: { radius: 8 }, restitution: 0.3 });
548
+ body.word = word;
549
+ Composite.add(world.engine.world, body);
550
+ });
551
+ setText("");
552
+ }
553
+
554
+ function shake() {
555
+ const world = worldRef.current;
556
+ if (!world) return;
557
+ for (const body of world.bodies()) {
558
+ Body.applyForce(body, body.position, { x: (Math.random() - 0.5) * 0.04 * body.mass, y: -0.05 * body.mass });
559
+ }
560
+ }
561
+
562
+ return (
563
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
564
+ <Stack gap={4}>
565
+ <Heading level={1}>Word Pile</Heading>
566
+ <form onSubmit={addWords}>
567
+ <Row gap={2} align="top" wrap>
568
+ <Field label="Say something">{(id, d) => <Input id={id} describedBy={d} value={text} onChange={setText} placeholder="gravity is a habit" />}</Field>
569
+ <Button type="submit">Drop the words</Button>
570
+ <Spacer />
571
+ <Button type="button" variant="quiet" onClick={shake}>Shake</Button>
572
+ </Row>
573
+ </form>
574
+ <canvas ref={canvasRef} className="block touch-none mx-auto rounded-md" />
575
+ <Text size="sm" muted>Drag a word to throw it. Shake lifts the whole pile.</Text>
576
+ </Stack>
577
+ </main>
578
+ );
579
+ }
580
+ ```
581
+
582
+ `Body.applyForce` takes a force in Matter's units, so scale it by `body.mass`
583
+ (as the engine's own examples do) and apply it once; `Body.setVelocity` sets
584
+ a speed outright, which is the right call for a launch. `chamfer` rounds a
585
+ rectangle's corners so a word pile settles like beanbags rather than bricks.
586
+
587
+ ## Mobile
588
+
589
+ Touch is handled by Matter's `Mouse` with the `pixelRatio` set; `touch-none`
590
+ on the canvas keeps a drag from scrolling the page. Size the stage from the
591
+ host (`Math.min(host.clientWidth, 480)`) and rebuild the world on resize (a
592
+ new `createWorld`, then `reset(pieces)`), since bodies keep their
593
+ coordinates. Cap `dpr` at 2 and `enableSleeping: true` so a settled pile
594
+ costs nothing; a phone handles a few hundred bodies at 60 Hz.
595
+
596
+ ## Shapes a physics vibe takes
597
+
598
+ - **A marble run or Rube Goldberg builder** — pieces as documents, build and
599
+ play modes, a goal sensor, seeded replays, a gallery of runs. The starter.
600
+ - **A slingshot or catapult** — a `Constraint` with low `stiffness` from an
601
+ anchor point to a projectile, released when the mouse lets go (the
602
+ engine's slingshot example: `Constraint.create({ pointA: anchor, bodyB: rock,
603
+ stiffness: 0.05, damping: 0.01 })`), a tower of `Bodies.rectangle` from
604
+ `Composites.pyramid`, and a score from how many blocks fell past a line.
605
+ - **Dominoes and towers** — tall thin rectangles placed by tap, one push
606
+ (`Body.applyForce`) to start the chain, a replay to watch it again.
607
+ - **Plinko and pachinko** — a `Composites.stack` of static pegs, marbles
608
+ dropped from a slider-chosen column, buckets as sensors with points.
609
+ - **Pinball** — flippers as static bodies rotated with `Body.setAngle` on a
610
+ key or tap, bumpers with `restitution` above 1, a drain sensor.
611
+ - **A word pile or a page with mass** — the second example; a landing page
612
+ whose cards fall when tapped is the same shape with cards as bodies.
613
+ - **Ragdolls and soft bodies** — `Composites.softBody` for jelly,
614
+ `Constraint`s between circles for a ragdoll; the engine's examples have
615
+ both.
616
+
617
+ ## Common gotchas
618
+
619
+ - **`import { Engine } from "matter-js"` reads undefined** — the esm.sh
620
+ build has a default export only. `import Matter from …` and destructure.
621
+ - **The simulation runs at a different speed on another device** — the
622
+ update used the frame delta. Step by a fixed `STEP` and pay back owed time
623
+ in whole steps.
624
+ - **A tap or drag lands half a stage away** — the mouse has no `pixelRatio`
625
+ on a DPR-scaled canvas. Set `mouse.pixelRatio = dpr`.
626
+ - **`Render.create` overflows the stack** — it was created after the engine
627
+ had simulated collisions. Create it before the first update, or paint from
628
+ vertices as the module does.
629
+ - **A level reloads with pieces slightly off** — bodies were saved instead
630
+ of pieces. Save the piece documents and rebuild bodies from them.
631
+ - **Bodies freeze mid-air** — sleeping bodies stay asleep until something
632
+ touches them; `Sleeping.set(body, false)` or `Body.setVelocity` wakes one,
633
+ and a dropped marble wakes what it hits.
634
+ - **A big frame hitch makes bodies tunnel through walls** — the cap on owed
635
+ time keeps steps small; make walls thick (40 px) so a fast body meets them.
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const richtextConfig: LlmConfig;
@@ -0,0 +1,47 @@
1
+ export const richtextConfig = {
2
+ name: "richtext",
3
+ label: "Rich text (Tiptap)",
4
+ description: "Notebooks, journals, wikis, notes apps and document editors built on Tiptap: pages with headings, " +
5
+ "lists, checklists, links and images, a list of documents and one editor, a shared page a team " +
6
+ "edits with the last editor named, meeting notes with action items, a recipe book with real pages, " +
7
+ "a journal with one entry per day. Covers every Tiptap package pinned to one version by its esm.sh " +
8
+ "URL (one ProseMirror, no second React), an editor module with createEditor, load, flush, run and " +
9
+ "destroy, a saved note as its JSON plus plain text and a derived title, saving after typing pauses " +
10
+ "with flush on blur and switch, loading a document without a save echo (setContent with emitUpdate " +
11
+ "false), destroy in the effect's cleanup, the page styled from the host with arbitrary variants, " +
12
+ "toolbar commands and active states, and images as _files. " +
13
+ "notebook, notes, note taking, journal, diary, wiki, document editor, rich text editor, text editor, " +
14
+ "wysiwyg, writing app, markdown editor, meeting notes, knowledge base, tiptap, prosemirror",
15
+ cues: [
16
+ "notebook",
17
+ "notes app",
18
+ "note taking",
19
+ "note-taking",
20
+ "take notes",
21
+ "journal",
22
+ "diary",
23
+ "wiki",
24
+ "document editor",
25
+ "doc editor",
26
+ "rich text",
27
+ "rich-text",
28
+ "text editor",
29
+ "wysiwyg",
30
+ "writing app",
31
+ "writing space",
32
+ "markdown editor",
33
+ "meeting notes",
34
+ "knowledge base",
35
+ "formatting toolbar",
36
+ "bold and italic",
37
+ "headings and lists",
38
+ "checklist in",
39
+ "tiptap",
40
+ "prosemirror",
41
+ "quill",
42
+ "lexical",
43
+ "slate editor",
44
+ "editor.js",
45
+ ],
46
+ };
47
+ //# sourceMappingURL=richtext.js.map