@vibes.diy/prompts 14.3.82 → 14.3.83

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,634 @@
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
+ for (const body of Composite.allBodies(engine.world)) if (body.label !== "wall") Composite.remove(engine.world, body);
173
+ Composite.add(engine.world, mouseConstraint);
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
+ return { channels: ["levels"], grant: { public: ["levels"] } };
400
+ }
401
+ throw { forbidden: "unknown document type" };
402
+ }
403
+ ```
404
+
405
+ ## The engine lives in a ref and steps by a fixed STEP
406
+
407
+ **Create the world once inside `useEffect(() => {…}, [])`, keep it in a ref,
408
+ and dispose the world in the effect's cleanup** (`world.dispose()` above:
409
+ stop the loop with `cancelAnimationFrame`, release the mouse listeners,
410
+ `Composite.clear` the world and `Engine.clear(engine)`). React renders the
411
+ chrome; the engine owns the canvas. Per-change values the loop needs (the
412
+ pieces, the mode) travel through refs the handlers read.
413
+
414
+ **The loop steps the engine by a fixed `STEP` as many times as the frame
415
+ owes, and leaves the frame's own delta out of the physics.** `Engine.update(engine, STEP)` with
416
+ `STEP = 1000 / 60` makes the simulation independent of the display: a
417
+ 60 Hz laptop and a 120 Hz phone compute the same trajectory, a dropped frame
418
+ is paid back as two steps rather than one long one, and the cap
419
+ (`Math.min(now - last, 4 * STEP)`) keeps a background tab from stepping
420
+ hundreds of times on return. Measured: two worlds built the same way and
421
+ stepped 300 times land every body on identical coordinates. `Matter.Runner`
422
+ does the same fixed stepping with its own `requestAnimationFrame`
423
+ (`Runner.run(runner, engine)`, `Runner.stop(runner)`); the loop above exists
424
+ so painting and stepping share one frame.
425
+
426
+ **Paint from `body.vertices` on a DPR-scaled canvas in the app's theme
427
+ colours.** The canvas is `width * dpr` pixels wide and `width` CSS pixels
428
+ wide, `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` once, and every body is a
429
+ closed path of its vertices filled by label. The colours come from the
430
+ injected tokens through `cssColor` (the p5 doc's probe trick), so the
431
+ owner's restyle reaches the stage. Matter's own `Render` is a fine wireframe
432
+ prototype (`Render.create({ element, engine, options: { wireframes: true } })`,
433
+ `Render.run`), with one measured catch: **create it before the first
434
+ `Engine.update`.** `Render.create` recursively merges its configuration,
435
+ the supplied engine included, and once collision pairs have put cycles in
436
+ the engine that merge walks pair → body → parent and overflows the stack
437
+ (measured: fine on a fresh engine, `RangeError` after 120 steps of a
438
+ colliding world). Creating it first is the safe ordering; the custom canvas
439
+ loop above has no such order.
440
+
441
+ **Set `mouse.pixelRatio = dpr`** right after `Mouse.create(canvas)`, so the
442
+ mouse constraint picks the body under the finger on a retina screen rather
443
+ than one half a stage away. The same `Mouse` handles touch (its touch
444
+ listeners are registered with `passive: false`), so dragging a marble on a
445
+ phone needs nothing more than `touch-none` on the canvas.
446
+
447
+ ## Build mode and play mode — the document is the source of truth
448
+
449
+ **A piece is a document and its body is a stand-in**: `bodyFromPiece` makes
450
+ the body, `reset(pieces)` rebuilds the whole world from the array, and a
451
+ change to the pieces calls `reset` rather than nudging a body, so what is
452
+ saved and what is on the stage are the same thing by construction. Marbles
453
+ are runtime-only: dropped from a button, removed when they score, gone on
454
+ reset. Static pieces are placed by a tap (`Query.point` through `pick`
455
+ decides whether the tap landed on a piece to remove or on empty stage to
456
+ fill), and the mouse constraint drags only the dynamic bodies, which is the
457
+ behaviour a builder wants: a ramp stays where it was put, a marble can be
458
+ flicked.
459
+
460
+ Autosave the pieces on a short debounce when `can.edit(level).ok`; a
461
+ visitor without edit rights builds on their own screen and sees the
462
+ view-only badge; "Keep" writes a new level as the visitor, gated on
463
+ `can.create`. Seed `seed.json` with two or three finished levels so the
464
+ first visit has somewhere to drop a marble.
465
+
466
+ ## Seeded replays
467
+
468
+ **Reset puts the clock back to zero, re-seeds `Common._seed`, and rebuilds
469
+ the bodies from the pieces, so the next run replays the last one exactly.**
470
+ Measured: drop a marble, step 600 times, reset, drop again, step 600 times,
471
+ and the marble lands on the same coordinates to the hundredth of a pixel
472
+ with the same goal count. That is what makes "share this run" honest: a
473
+ saved level plus its seed plus the same drops is the same show on every
474
+ device. Any randomness the toy wants (a bumper's kick, a spawn position)
475
+ goes through `Common.random()` so the seed covers it.
476
+
477
+ ## Collisions and scoring
478
+
479
+ **Label bodies and listen on `collisionStart` for the pair you care about.**
480
+ `onMeet("marble", "goal", fn)` above wraps it; `isSensor: true` on the goal
481
+ makes it fire without stopping the marble. The same hook drives sounds (the
482
+ web-audio doc's restraint rule: a sound on the goal, none on every peg),
483
+ particle bursts, or a tower's "knocked down" check (count the pieces whose
484
+ `position.y` crossed a line). `collisionActive` and `collisionEnd` exist for
485
+ resting contact and release.
486
+
487
+ ## A second shape — words with mass
488
+
489
+ Type a sentence; every word becomes a body sized to its text, tumbles into
490
+ the pile, and can be flicked. The same module, no persistence, a shake
491
+ button that applies a force to every body.
492
+
493
+ ### `App.jsx`
494
+ ```jsx app=tumble
495
+ import React, { useEffect, useRef, useState } from "react";
496
+ import { Button, Field, Heading, Input, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
497
+ import { Matter, createWorld, cssColor } from "./lib/physics.js";
498
+
499
+ const { Bodies, Body, Composite } = Matter;
500
+ const WIDTH = 360;
501
+ const HEIGHT = 420;
502
+
503
+ export default function App() {
504
+ const canvasRef = useRef(null);
505
+ const worldRef = useRef(null);
506
+ const [text, setText] = useState("");
507
+
508
+ useEffect(() => {
509
+ const canvas = canvasRef.current;
510
+ const world = createWorld(canvas, { width: WIDTH, height: HEIGHT, seed: 3 });
511
+ worldRef.current = world;
512
+ // Paint each word inside its body: rotate with the body, centre the text.
513
+ const ctx = canvas.getContext("2d");
514
+ const ink = cssColor(canvas, "--background", "#ffffff");
515
+ const basePaint = world.paint;
516
+ world.paint = () => {
517
+ basePaint();
518
+ ctx.fillStyle = ink;
519
+ ctx.font = "600 16px system-ui, sans-serif";
520
+ ctx.textAlign = "center";
521
+ ctx.textBaseline = "middle";
522
+ for (const body of world.bodies()) {
523
+ if (!body.word) continue;
524
+ ctx.save();
525
+ ctx.translate(body.position.x, body.position.y);
526
+ ctx.rotate(body.angle);
527
+ ctx.fillText(body.word, 0, 0);
528
+ ctx.restore();
529
+ }
530
+ };
531
+ world.start();
532
+ return () => {
533
+ world.dispose();
534
+ worldRef.current = null;
535
+ };
536
+ }, []);
537
+
538
+ function addWords(event) {
539
+ event.preventDefault();
540
+ const world = worldRef.current;
541
+ if (!world || !text.trim()) return;
542
+ const ctx = canvasRef.current.getContext("2d");
543
+ ctx.font = "600 16px system-ui, sans-serif";
544
+ text.trim().split(/\s+/).forEach((word, i) => {
545
+ const w = ctx.measureText(word).width + 16;
546
+ const body = Bodies.rectangle(40 + ((i * 70) % (WIDTH - 80)), -20 - i * 30, w, 28, { label: "word", chamfer: { radius: 8 }, restitution: 0.3 });
547
+ body.word = word;
548
+ Composite.add(world.engine.world, body);
549
+ });
550
+ setText("");
551
+ }
552
+
553
+ function shake() {
554
+ const world = worldRef.current;
555
+ if (!world) return;
556
+ for (const body of world.bodies()) {
557
+ Body.applyForce(body, body.position, { x: (Math.random() - 0.5) * 0.04 * body.mass, y: -0.05 * body.mass });
558
+ }
559
+ }
560
+
561
+ return (
562
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
563
+ <Stack gap={4}>
564
+ <Heading level={1}>Word Pile</Heading>
565
+ <form onSubmit={addWords}>
566
+ <Row gap={2} align="top" wrap>
567
+ <Field label="Say something">{(id, d) => <Input id={id} describedBy={d} value={text} onChange={setText} placeholder="gravity is a habit" />}</Field>
568
+ <Button type="submit">Drop the words</Button>
569
+ <Spacer />
570
+ <Button type="button" variant="quiet" onClick={shake}>Shake</Button>
571
+ </Row>
572
+ </form>
573
+ <canvas ref={canvasRef} className="block touch-none mx-auto rounded-md" />
574
+ <Text size="sm" muted>Drag a word to throw it. Shake lifts the whole pile.</Text>
575
+ </Stack>
576
+ </main>
577
+ );
578
+ }
579
+ ```
580
+
581
+ `Body.applyForce` takes a force in Matter's units, so scale it by `body.mass`
582
+ (as the engine's own examples do) and apply it once; `Body.setVelocity` sets
583
+ a speed outright, which is the right call for a launch. `chamfer` rounds a
584
+ rectangle's corners so a word pile settles like beanbags rather than bricks.
585
+
586
+ ## Mobile
587
+
588
+ Touch is handled by Matter's `Mouse` with the `pixelRatio` set; `touch-none`
589
+ on the canvas keeps a drag from scrolling the page. Size the stage from the
590
+ host (`Math.min(host.clientWidth, 480)`) and rebuild the world on resize (a
591
+ new `createWorld`, then `reset(pieces)`), since bodies keep their
592
+ coordinates. Cap `dpr` at 2 and `enableSleeping: true` so a settled pile
593
+ costs nothing; a phone handles a few hundred bodies at 60 Hz.
594
+
595
+ ## Shapes a physics vibe takes
596
+
597
+ - **A marble run or Rube Goldberg builder** — pieces as documents, build and
598
+ play modes, a goal sensor, seeded replays, a gallery of runs. The starter.
599
+ - **A slingshot or catapult** — a `Constraint` with low `stiffness` from an
600
+ anchor point to a projectile, released when the mouse lets go (the
601
+ engine's slingshot example: `Constraint.create({ pointA: anchor, bodyB: rock,
602
+ stiffness: 0.05, damping: 0.01 })`), a tower of `Bodies.rectangle` from
603
+ `Composites.pyramid`, and a score from how many blocks fell past a line.
604
+ - **Dominoes and towers** — tall thin rectangles placed by tap, one push
605
+ (`Body.applyForce`) to start the chain, a replay to watch it again.
606
+ - **Plinko and pachinko** — a `Composites.stack` of static pegs, marbles
607
+ dropped from a slider-chosen column, buckets as sensors with points.
608
+ - **Pinball** — flippers as static bodies rotated with `Body.setAngle` on a
609
+ key or tap, bumpers with `restitution` above 1, a drain sensor.
610
+ - **A word pile or a page with mass** — the second example; a landing page
611
+ whose cards fall when tapped is the same shape with cards as bodies.
612
+ - **Ragdolls and soft bodies** — `Composites.softBody` for jelly,
613
+ `Constraint`s between circles for a ragdoll; the engine's examples have
614
+ both.
615
+
616
+ ## Common gotchas
617
+
618
+ - **`import { Engine } from "matter-js"` reads undefined** — the esm.sh
619
+ build has a default export only. `import Matter from …` and destructure.
620
+ - **The simulation runs at a different speed on another device** — the
621
+ update used the frame delta. Step by a fixed `STEP` and pay back owed time
622
+ in whole steps.
623
+ - **A tap or drag lands half a stage away** — the mouse has no `pixelRatio`
624
+ on a DPR-scaled canvas. Set `mouse.pixelRatio = dpr`.
625
+ - **`Render.create` overflows the stack** — it was created after the engine
626
+ had simulated collisions. Create it before the first update, or paint from
627
+ vertices as the module does.
628
+ - **A level reloads with pieces slightly off** — bodies were saved instead
629
+ of pieces. Save the piece documents and rebuild bodies from them.
630
+ - **Bodies freeze mid-air** — sleeping bodies stay asleep until something
631
+ touches them; `Sleeping.set(body, false)` or `Body.setVelocity` wakes one,
632
+ and a dropped marble wakes what it hits.
633
+ - **A big frame hitch makes bodies tunnel through walls** — the cap on owed
634
+ 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 strudelConfig: LlmConfig;
@@ -0,0 +1,36 @@
1
+ export const strudelConfig = {
2
+ name: "strudel",
3
+ label: "Strudel live coding",
4
+ description: "Live-coding music with Strudel, where the music is a pattern written as text: a pattern pad " +
5
+ "with an editor, examples, play and stop and saved patterns; a groovebox whose pads write " +
6
+ "mini-notation and whose lights follow the engine's events; knob instruments that re-evaluate " +
7
+ "a pattern as sliders move; generative radios; a prompt box that asks callAI to rewrite the " +
8
+ "groove; a shared groovebook with an access rule. Covers the AGPL engine fetched at play time " +
9
+ "by a pinned esm.sh dynamic import in a memoized loader (never bundled, credited visibly), the " +
10
+ "Dirt-Samples kit and the drum-machine banks from jsDelivr, mini-notation and the effect chain, " +
11
+ "the verbatim text-to-pattern system prompt for callAI, cpm as cycles per minute with the " +
12
+ "beats-per-cycle table, " +
13
+ "onEvalError for patterns that did not compile, hush on stop, and onTrigger for visuals. " +
14
+ "strudel, tidal, tidalcycles, live coding, livecoding, algorave, mini-notation, pattern music, " +
15
+ "algorithmic music, generative music, text patterns, code music",
16
+ cues: [
17
+ "strudel",
18
+ "tidal",
19
+ "tidalcycles",
20
+ "tidal cycles",
21
+ "live coding",
22
+ "live-coding",
23
+ "livecoding",
24
+ "live code music",
25
+ "algorave",
26
+ "mini-notation",
27
+ "mininotation",
28
+ "pattern music",
29
+ "algorithmic music",
30
+ "generative music",
31
+ "code music",
32
+ "music from code",
33
+ "text patterns",
34
+ ],
35
+ };
36
+ //# sourceMappingURL=strudel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strudel.js","sourceRoot":"","sources":["../../jsr/llms/strudel.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,qBAAqB;IAC5B,WAAW,EACT,8FAA8F;QAC9F,2FAA2F;QAC3F,+FAA+F;QAC/F,6FAA6F;QAC7F,+FAA+F;QAC/F,gGAAgG;QAChG,iGAAiG;QACjG,2FAA2F;QAC3F,yBAAyB;QACzB,0FAA0F;QAC1F,gGAAgG;QAChG,gEAAgE;IAClE,IAAI,EAAE;QACJ,SAAS;QACT,OAAO;QACP,aAAa;QACb,cAAc;QACd,aAAa;QACb,aAAa;QACb,YAAY;QACZ,iBAAiB;QACjB,UAAU;QACV,eAAe;QACf,cAAc;QACd,eAAe;QACf,mBAAmB;QACnB,kBAAkB;QAClB,YAAY;QACZ,iBAAiB;QACjB,eAAe;KAChB;CACF,CAAC"}