@vibes.diy/prompts 14.3.83 → 14.3.85

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.
package/llms/worlds.md ADDED
@@ -0,0 +1,826 @@
1
+ # Generative worlds — living simulations on a canvas
2
+
3
+ When someone asks for an ecosystem, a terrarium, an ant farm, a meadow with
4
+ rabbits and foxes, Conway's Game of Life, a cellular automaton, falling sand,
5
+ a forest fire, a flock of boids, a petri dish, weather drifting over a map, or
6
+ any screen where a small world runs by its own rules and the fun is watching
7
+ it go, this is the recipe. The browser's own canvas is the whole engine: a
8
+ grid of cells in a typed array, two functions that seed and step it, a seeded
9
+ random generator so the same world grows on every device, a loop on its own
10
+ clock, and the grid painted as pixels. Fireproof keeps a world as its recipe
11
+ (seed, dials, tick, and any taps a person made), so a saved world is a few
12
+ bytes and a card in a gallery is that world, alive, at the moment it was kept.
13
+
14
+ The sibling boundary: a sketch that draws (flow fields, kaleidoscopes, a
15
+ drawing toy) is `p5`; bodies with mass that collide are `physics`; a block
16
+ world with depth is `voxel`. Worlds is the right call when the fun is a
17
+ population of simple things following simple rules over time.
18
+
19
+ The worked examples below were built and measured for this doc (Chromium,
20
+ 2026-10-07): a meadow whose dials a person turns and whose moments they keep,
21
+ and a falling-sand box whose brush strokes replay.
22
+
23
+ ## Project shape
24
+
25
+ ```
26
+ App.jsx the interface: the stage, play / pause / step / speed, dials, keep, a gallery
27
+ lib/world.js the engine: seeded random, double-buffered grid, pixel paint, the tick loop, recipes
28
+ lib/rules.js the world's rules: seed(world) and step(world, from, to), plus a census
29
+ access.js who may keep a world in the shared gallery
30
+ ```
31
+
32
+ The engine is the same for every world; the rules are the part a request
33
+ changes. A second kind of world (sand, life, fire) is a second rules module
34
+ imported into the same `createWorld`.
35
+
36
+ ## The engine — `lib/world.js`
37
+
38
+ ### `lib/world.js`
39
+ ```js app=meadow
40
+ // lib/world.js — a seeded, fixed-tick grid world painted as pixels.
41
+ // Every random number comes from one seeded generator, the tick counter has one owner, the grid
42
+ // is double-buffered and painted through ImageData onto a small offscreen canvas that is drawn
43
+ // scaled (no smoothing) onto the visible one, and the loop runs on its own clock.
44
+
45
+ /** A tiny seeded PRNG (mulberry32): the same seed gives the same sequence on every device. */
46
+ export function seededRandom(seed) {
47
+ let a = seed >>> 0;
48
+ return function random() {
49
+ a = (a + 0x6d2b79f5) >>> 0;
50
+ let t = a;
51
+ t = Math.imul(t ^ (t >>> 15), t | 1);
52
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
53
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
54
+ };
55
+ }
56
+
57
+ /** Resolve an injected theme token to a concrete colour once per mount (the p5 doc's probe). */
58
+ export function cssColor(host, token, fallback) {
59
+ const probe = document.createElement("span");
60
+ probe.style.display = "none";
61
+ probe.style.color = `var(${token}, ${fallback})`;
62
+ host.appendChild(probe);
63
+ const value = getComputedStyle(probe).color;
64
+ probe.remove();
65
+ return value;
66
+ }
67
+
68
+ /**
69
+ * Any CSS colour → [r, g, b] for ImageData. A canvas fill does the conversion, so a token that
70
+ * computes to `oklch(…)`, `color-mix(…)` or `rgb(50% …)` (measured: a computed style reports an
71
+ * oklch token verbatim) lands as bytes rather than being read as three numbers.
72
+ */
73
+ export function rgb(color) {
74
+ const probe = document.createElement("canvas").getContext("2d");
75
+ probe.fillStyle = color;
76
+ probe.fillRect(0, 0, 1, 1);
77
+ const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
78
+ return [r, g, b];
79
+ }
80
+
81
+ /**
82
+ * createWorld(canvas, { cols, rows, seed, rules, palette, ticksPerSecond })
83
+ *
84
+ * `rules` is { seed(world) , step(world, from, to) }: `seed` fills `world.cells` once from the
85
+ * seeded generator; `step` reads `from` and writes `to` (the two buffers swap each tick). Both use
86
+ * `world.random()` for every random number. `palette` maps a cell value to [r, g, b].
87
+ */
88
+ export function createWorld(canvas, { cols, rows, seed = 1, rules, palette, ticksPerSecond = 30 }) {
89
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
90
+ const ctx = canvas.getContext("2d");
91
+ const offscreen = document.createElement("canvas");
92
+ offscreen.width = cols;
93
+ offscreen.height = rows;
94
+ const octx = offscreen.getContext("2d");
95
+ const image = octx.createImageData(cols, rows);
96
+
97
+ const world = {
98
+ cols,
99
+ rows,
100
+ seed,
101
+ tick: 0,
102
+ cells: new Uint8Array(cols * rows),
103
+ next: new Uint8Array(cols * rows),
104
+ random: seededRandom(seed),
105
+ ticksPerSecond,
106
+ running: false,
107
+ events: [],
108
+ applied: 0, // how many of `events` have landed on the grid (live or replayed)
109
+ raf: 0,
110
+ owed: 0,
111
+ last: 0,
112
+ listeners: new Set(),
113
+ };
114
+
115
+ function resize() {
116
+ const w = canvas.clientWidth || cols;
117
+ const h = canvas.clientHeight || rows;
118
+ canvas.width = Math.round(w * dpr);
119
+ canvas.height = Math.round(h * dpr);
120
+ }
121
+
122
+ world.paint = () => {
123
+ const data = image.data;
124
+ const cells = world.cells;
125
+ for (let i = 0, p = 0; i < cells.length; i += 1, p += 4) {
126
+ const c = palette[cells[i]] || palette[0];
127
+ data[p] = c[0];
128
+ data[p + 1] = c[1];
129
+ data[p + 2] = c[2];
130
+ data[p + 3] = 255;
131
+ }
132
+ octx.putImageData(image, 0, 0);
133
+ ctx.imageSmoothingEnabled = false;
134
+ ctx.drawImage(offscreen, 0, 0, canvas.width, canvas.height);
135
+ };
136
+
137
+ /** Land every recorded event up to the current tick that has not landed yet (a replay's). */
138
+ function applyDue() {
139
+ while (world.applied < world.events.length && world.events[world.applied].tick <= world.tick) {
140
+ rules.apply?.(world, world.events[world.applied]);
141
+ world.applied += 1;
142
+ }
143
+ }
144
+
145
+ /** The one place the tick advances: land this tick's events that are still due, then the rules. */
146
+ world.step = () => {
147
+ applyDue();
148
+ rules.step(world, world.cells, world.next);
149
+ const swap = world.cells;
150
+ world.cells = world.next;
151
+ world.next = swap;
152
+ world.tick += 1;
153
+ for (const fn of world.listeners) fn(world);
154
+ };
155
+
156
+ /** Record an input against the current tick and land it now; the cursor keeps step() from landing it twice. */
157
+ world.record = (event) => {
158
+ const stamped = { ...event, tick: world.tick };
159
+ world.events.push(stamped);
160
+ rules.apply?.(world, stamped);
161
+ world.applied = world.events.length;
162
+ world.paint();
163
+ };
164
+
165
+ world.frame = () => {
166
+ if (!world.running) return;
167
+ // performance.now(), not the frame's timestamp: the two clocks can disagree by whole seconds
168
+ // in some browsers, and a negative delta would stall the loop.
169
+ const now = performance.now();
170
+ const tickMs = 1000 / world.ticksPerSecond;
171
+ world.owed += Math.max(0, Math.min(now - world.last, 4 * tickMs));
172
+ world.last = now;
173
+ let stepped = false;
174
+ while (world.owed >= tickMs) {
175
+ world.step();
176
+ world.owed -= tickMs;
177
+ stepped = true;
178
+ }
179
+ if (stepped) world.paint();
180
+ world.raf = requestAnimationFrame(world.frame);
181
+ };
182
+
183
+ world.start = () => {
184
+ if (world.running) return;
185
+ world.running = true;
186
+ world.last = performance.now();
187
+ world.owed = 0;
188
+ world.raf = requestAnimationFrame(world.frame);
189
+ };
190
+ world.stop = () => {
191
+ world.running = false;
192
+ cancelAnimationFrame(world.raf);
193
+ };
194
+ world.stepOnce = () => {
195
+ world.step();
196
+ world.paint();
197
+ };
198
+ world.setSpeed = (tps) => {
199
+ world.ticksPerSecond = tps;
200
+ };
201
+ world.onTick = (fn) => {
202
+ world.listeners.add(fn);
203
+ return () => world.listeners.delete(fn);
204
+ };
205
+
206
+ /** Back to tick 0 with the same seed (or a new one): the next run replays exactly. */
207
+ world.reset = (nextSeed = world.seed, events = []) => {
208
+ world.seed = nextSeed;
209
+ world.random = seededRandom(nextSeed);
210
+ world.tick = 0;
211
+ world.cells.fill(0);
212
+ world.next.fill(0);
213
+ world.events = events.slice();
214
+ world.applied = 0;
215
+ rules.seed(world);
216
+ world.paint();
217
+ };
218
+
219
+ /** Run from a recipe to a tick without drawing in between; the paint at the end is the card. */
220
+ world.replay = ({ seed: s, tick, events = [] }) => {
221
+ world.reset(s, events);
222
+ while (world.tick < tick) world.step();
223
+ applyDue(); // inputs recorded at the kept tick itself, after its last step
224
+ world.paint();
225
+ };
226
+
227
+ world.recipe = () => ({ seed: world.seed, tick: world.tick, events: world.events.slice() });
228
+
229
+ world.dispose = () => {
230
+ world.stop();
231
+ world.listeners.clear();
232
+ };
233
+
234
+ resize();
235
+ world.reset(seed);
236
+ return world;
237
+ }
238
+
239
+ /** Cell index helpers with wrap-around edges. */
240
+ export function at(world, x, y) {
241
+ const { cols, rows } = world;
242
+ return (((y % rows) + rows) % rows) * cols + (((x % cols) + cols) % cols);
243
+ }
244
+ ```
245
+
246
+ ## Rules are two functions — `lib/rules.js`
247
+
248
+ A world's rules are `seed(world)`, which fills the first grid, and
249
+ `step(world, from, to)`, which reads one buffer and writes the other. The
250
+ engine swaps the buffers and counts the tick. Writing into `to` while reading
251
+ `from` is what makes a step order-independent: every cell sees the same
252
+ previous world. (The meadow below reads `to` for neighbours on purpose, so a
253
+ cell a rabbit already moved into this tick counts as taken; that is the
254
+ rule's choice, and the seed makes it reproducible either way.)
255
+
256
+ ### `lib/rules.js`
257
+ ```js app=meadow
258
+ // lib/rules.js — a meadow: grass grows back, rabbits graze and breed, foxes hunt and starve.
259
+ // Rules are two functions over the grid. Every random number comes from `world.random()`, so the
260
+ // same seed grows the same meadow on every device and a saved tick replays exactly.
261
+ import { at } from "./world.js";
262
+
263
+ export const EMPTY = 0;
264
+ export const GRASS = 1;
265
+ export const RABBIT = 2;
266
+ export const FOX = 3;
267
+
268
+ /** The dials a person can turn; each is a probability per cell per tick. */
269
+ export const DEFAULT_PARAMS = { regrow: 0.03, rabbitBreed: 0.15, rabbitDie: 0.03, foxBreed: 0.6, foxStarve: 0.01 };
270
+
271
+ export function meadowRules(params = DEFAULT_PARAMS) {
272
+ const P = { ...DEFAULT_PARAMS, ...params };
273
+ return {
274
+ /** The first tick's world: half grass, a few rabbits, fewer foxes. */
275
+ seed(world) {
276
+ const cells = world.cells;
277
+ for (let i = 0; i < cells.length; i += 1) {
278
+ const r = world.random();
279
+ cells[i] = r < 0.5 ? GRASS : r < 0.56 ? RABBIT : r < 0.58 ? FOX : EMPTY;
280
+ }
281
+ },
282
+ /** Read `from`, write `to`; the engine swaps them after. */
283
+ step(world, from, to) {
284
+ to.set(from);
285
+ for (let y = 0; y < world.rows; y += 1) {
286
+ for (let x = 0; x < world.cols; x += 1) {
287
+ const i = y * world.cols + x;
288
+ const c = from[i];
289
+ if (c === EMPTY) {
290
+ if (world.random() < P.regrow) to[i] = GRASS;
291
+ continue;
292
+ }
293
+ if (c === GRASS) continue;
294
+ const dx = Math.floor(world.random() * 3) - 1;
295
+ const dy = Math.floor(world.random() * 3) - 1;
296
+ const j = at(world, x + dx, y + dy);
297
+ const n = to[j];
298
+ if (c === RABBIT) {
299
+ if (n === GRASS) {
300
+ to[j] = RABBIT;
301
+ to[i] = world.random() < P.rabbitBreed ? RABBIT : EMPTY;
302
+ } else if (n === EMPTY) {
303
+ to[j] = RABBIT;
304
+ to[i] = EMPTY;
305
+ }
306
+ if (world.random() < P.rabbitDie) to[i] = EMPTY;
307
+ } else if (c === FOX) {
308
+ if (n === RABBIT) {
309
+ to[j] = FOX;
310
+ to[i] = world.random() < P.foxBreed ? FOX : EMPTY;
311
+ } else if (n === EMPTY || n === GRASS) {
312
+ to[j] = FOX;
313
+ to[i] = n;
314
+ if (world.random() < P.foxStarve) to[j] = n;
315
+ }
316
+ }
317
+ }
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+ /** How many of each kind are alive — read on a slow clock, every few ticks. */
324
+ export function census(world) {
325
+ const counts = [0, 0, 0, 0];
326
+ for (const c of world.cells) counts[c] += 1;
327
+ return { grass: counts[GRASS], rabbits: counts[RABBIT], foxes: counts[FOX] };
328
+ }
329
+ ```
330
+
331
+ Measured with these dials (160 × 100 cells, seed 11): grass, rabbits and
332
+ foxes all alive at tick 2000, with foxes between about 300 and 500 the whole
333
+ way. Dials that let one species win in the first few hundred ticks are the
334
+ common first draft; the fix is a lower breed rate for the prey and a lower
335
+ starve rate for the predator, then watch the census for a thousand ticks.
336
+
337
+ ## The interface — a meadow with dials, a keep button and a living gallery
338
+
339
+ ### `App.jsx`
340
+ ```jsx app=meadow
341
+ import React, { useEffect, useMemo, useRef, useState } from "react";
342
+ import { useFireproof } from "use-fireproof";
343
+ import { useVibe, useViewer } from "use-vibes";
344
+ import { Badge, Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
345
+ import { Slider } from "@vibes.diy/pouf/interactive";
346
+ import { createWorld, cssColor, rgb } from "./lib/world.js";
347
+ import { DEFAULT_PARAMS, census, meadowRules } from "./lib/rules.js";
348
+
349
+ const COLS = 160;
350
+ const ROWS = 90;
351
+
352
+ /** One palette per mount, from the app's theme tokens: the owner's restyle reaches the meadow. */
353
+ function makePalette(host) {
354
+ return [
355
+ rgb(cssColor(host, "--background", "#141618")),
356
+ rgb(cssColor(host, "--primary", "#3c8c46")),
357
+ rgb(cssColor(host, "--foreground", "#e6dcc8")),
358
+ rgb(cssColor(host, "--accent", "#dc6e32")),
359
+ ];
360
+ }
361
+
362
+ /** A gallery card: a tiny canvas that replays the kept world to its tick, once. */
363
+ function WorldCard({ doc, onOpen }) {
364
+ const ref = useRef(null);
365
+ useEffect(() => {
366
+ const canvas = ref.current;
367
+ const world = createWorld(canvas, { cols: COLS, rows: ROWS, seed: doc.seed, rules: meadowRules(doc.params), palette: makePalette(canvas) });
368
+ world.replay({ seed: doc.seed, tick: doc.tick });
369
+ return () => world.dispose();
370
+ }, [doc.seed, doc.tick, doc.params]);
371
+ return (
372
+ <Card variant="tight">
373
+ <Stack gap={2}>
374
+ <canvas ref={ref} className="block w-full aspect-video rounded" />
375
+ <Row gap={2} align="center" wrap>
376
+ <Text size="sm">{doc.name}</Text>
377
+ <Spacer />
378
+ <Button size="sm" variant="quiet" onClick={() => onOpen(doc)}>Open</Button>
379
+ </Row>
380
+ </Stack>
381
+ </Card>
382
+ );
383
+ }
384
+
385
+ export default function App() {
386
+ const { database, useLiveQuery } = useFireproof("meadows");
387
+ const { me, can, ready } = useVibe("meadows");
388
+ const { requestLogin, ViewerTag } = useViewer();
389
+ const { docs } = useLiveQuery("type", { key: "world" });
390
+ const kept = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);
391
+
392
+ const [seed, setSeed] = useState(11);
393
+ const [params, setParams] = useState(DEFAULT_PARAMS);
394
+ const [running, setRunning] = useState(true);
395
+ const [speed, setSpeed] = useState(30);
396
+ const [counts, setCounts] = useState({ grass: 0, rabbits: 0, foxes: 0 });
397
+ const [tick, setTick] = useState(0);
398
+ const [name, setName] = useState("");
399
+
400
+ const canvasRef = useRef(null);
401
+ const worldRef = useRef(null);
402
+ const openRef = useRef(null); // a kept moment to replay once the next world exists
403
+ const [opened, setOpened] = useState(0); // bumps on every Open, so the effect runs even when seed and dials match
404
+
405
+ // The world lives in a ref: created once per seed and dials, stopped in the effect's cleanup.
406
+ useEffect(() => {
407
+ const canvas = canvasRef.current;
408
+ const world = createWorld(canvas, { cols: COLS, rows: ROWS, seed, rules: meadowRules(params), palette: makePalette(canvas), ticksPerSecond: speed });
409
+ worldRef.current = world;
410
+ if (openRef.current) {
411
+ world.replay(openRef.current);
412
+ openRef.current = null;
413
+ }
414
+ // One reading right away, so a paused or replayed world shows its own numbers.
415
+ setCounts(census(world));
416
+ setTick(world.tick);
417
+ // React state updates happen on a slow clock: a census every 15 ticks, not every tick.
418
+ const off = world.onTick((w) => {
419
+ if (w.tick % 15 === 0) {
420
+ setCounts(census(w));
421
+ setTick(w.tick);
422
+ }
423
+ });
424
+ if (running) world.start();
425
+ return () => {
426
+ off();
427
+ world.dispose();
428
+ worldRef.current = null;
429
+ };
430
+ }, [seed, params, opened]);
431
+
432
+ useEffect(() => {
433
+ const world = worldRef.current;
434
+ if (!world) return;
435
+ world.setSpeed(speed);
436
+ if (running) world.start();
437
+ else world.stop();
438
+ }, [running, speed]);
439
+
440
+ function open(doc) {
441
+ openRef.current = { seed: doc.seed, tick: doc.tick };
442
+ setParams(doc.params);
443
+ setSeed(doc.seed);
444
+ setRunning(false);
445
+ setOpened((n) => n + 1);
446
+ }
447
+
448
+ const draft = { type: "world", name: name || "a meadow", seed, params, tick, creatorHandle: me?.userHandle };
449
+ const canKeep = Boolean(ready && me && can.create(draft).ok);
450
+
451
+ async function keep() {
452
+ const world = worldRef.current;
453
+ if (!world || !canKeep) return;
454
+ await database.put({ ...draft, _id: crypto.randomUUID(), tick: world.tick, createdAt: Date.now() });
455
+ setName("");
456
+ }
457
+
458
+ const dial = (key, label, max) => (
459
+ <Stack key={key} gap={1}>
460
+ <Text size="sm">{label}</Text>
461
+ <Slider label={label} min={0} max={max} step={max / 100} value={[params[key]]} onChange={([v]) => setParams({ ...params, [key]: v })} />
462
+ </Stack>
463
+ );
464
+
465
+ return (
466
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
467
+ <Stack gap={4}>
468
+ <Row gap={2} align="center" wrap>
469
+ <Heading level={1}>Meadow</Heading>
470
+ <Spacer />
471
+ <Badge tone="up">{counts.rabbits} rabbits</Badge>
472
+ <Badge tone="info">{counts.foxes} foxes</Badge>
473
+ <Badge>tick {tick}</Badge>
474
+ </Row>
475
+
476
+ <Card variant="flush">
477
+ <canvas ref={canvasRef} className="block w-full aspect-video touch-none" />
478
+ </Card>
479
+
480
+ <Row gap={2} align="center" wrap>
481
+ <Button onClick={() => setRunning((r) => !r)}>{running ? "Pause" : "Play"}</Button>
482
+ <Button variant="quiet" onClick={() => { setRunning(false); worldRef.current?.stepOnce(); }}>Step</Button>
483
+ <Button variant="quiet" onClick={() => setSeed(Math.floor(Math.random() * 1e9))}>New meadow</Button>
484
+ <Stack gap={1}>
485
+ <Text size="sm">Speed</Text>
486
+ <Slider label="Ticks per second" min={1} max={120} value={[speed]} onChange={([v]) => setSpeed(v)} />
487
+ </Stack>
488
+ </Row>
489
+
490
+ <Card variant="tight">
491
+ <Stack gap={2}>
492
+ <Heading level={2}>Dials</Heading>
493
+ <Text size="sm" muted>Each dial is a chance per cell per tick. Turning one restarts the meadow from the same seed.</Text>
494
+ <Row gap={3} wrap>
495
+ {dial("regrow", "Grass regrows", 0.2)}
496
+ {dial("rabbitBreed", "Rabbits breed", 1)}
497
+ {dial("rabbitDie", "Rabbits rest", 0.2)}
498
+ {dial("foxBreed", "Foxes breed", 1)}
499
+ {dial("foxStarve", "Foxes wander off", 0.1)}
500
+ </Row>
501
+ </Stack>
502
+ </Card>
503
+
504
+ <Card variant="tight">
505
+ <Stack gap={3}>
506
+ <Heading level={2}>Kept meadows</Heading>
507
+ {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
508
+ {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your meadows stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep meadows</Button></Row>}
509
+ {canKeep && (
510
+ <Row gap={2} align="top" wrap>
511
+ <Field label="Name this moment">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="the fox spring" />}</Field>
512
+ <Button size="sm" onClick={keep}>Keep</Button>
513
+ </Row>
514
+ )}
515
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
516
+ {kept.map((doc) => (
517
+ <Stack key={doc._id} gap={1}>
518
+ <WorldCard doc={doc} onOpen={open} />
519
+ <Row gap={2} align="center" wrap>
520
+ {doc.creatorHandle && <ViewerTag userHandle={doc.creatorHandle} />}
521
+ <Spacer />
522
+ {can.delete(doc).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(doc._id)}>Remove</Button>}
523
+ </Row>
524
+ </Stack>
525
+ ))}
526
+ </div>
527
+ </Stack>
528
+ </Card>
529
+ </Stack>
530
+ </main>
531
+ );
532
+ }
533
+ ```
534
+
535
+ ### `access.js`
536
+ ```js app=meadow
537
+ // world: a kept meadow. Its keeper edits and removes it; everyone can open and watch it.
538
+ export function meadows(doc, oldDoc, user, ctx) {
539
+ if (!user) throw { forbidden: "sign in to keep meadows" };
540
+ if (doc.type === "world" || (doc._deleted && oldDoc && oldDoc.type === "world")) {
541
+ const keeper = oldDoc ? oldDoc.creatorHandle : doc.creatorHandle;
542
+ if (keeper !== user.userHandle) throw { forbidden: "meadows are kept as yourself" };
543
+ if (oldDoc && !doc._deleted && doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "a meadow keeps its keeper" };
544
+ return { channels: ["worlds"], grant: { public: ["worlds"] } };
545
+ }
546
+ throw { forbidden: "unknown document type" };
547
+ }
548
+ ```
549
+
550
+ ## One seeded generator
551
+
552
+ **Every random number the world uses comes from one seeded generator,
553
+ `world.random()`, so the same seed grows the same world on every device.**
554
+ `seededRandom(seed)` is twenty lines (mulberry32) and the engine owns the one
555
+ instance; the rules call `world.random()` for a neighbour, a coin flip, a
556
+ regrowth roll. Measured: two worlds from seed 7 stepped 500 times hold
557
+ identical cells, and a third world fed the first one's recipe lands on the
558
+ same grid. The interface picks a fresh seed with the browser's own random
559
+ (`Math.floor(Math.random() * 1e9)`) because that number is about to be saved;
560
+ inside the rules, the seeded one is the only source.
561
+
562
+ ## The tick counter has one owner, and ticks run on their own clock
563
+
564
+ **`world.step()` is the only place `tick` advances, and the frame loop pays
565
+ out whole ticks from the time that has passed, so a world runs at the same
566
+ speed on a 60 Hz laptop and a 120 Hz phone.** The accumulator owes
567
+ `1000 / ticksPerSecond` ms per tick, steps as many times as it owes, caps a
568
+ long absence at four ticks (a background tab resumes rather than fast-forwards),
569
+ and paints once per frame however many ticks ran. Measured: 29 ticks in a
570
+ second at 30, 119 at 120, with the frame rate unchanged. The speed slider
571
+ changes `ticksPerSecond` and nothing else; Step pauses and runs one tick;
572
+ Pause stops the loop and leaves the grid on screen.
573
+
574
+ The loop reads `performance.now()` rather than the frame's own timestamp:
575
+ the two clocks can disagree by whole seconds in some browsers, and a negative
576
+ delta would stall the loop (measured: the timestamp version ran zero ticks in
577
+ headless Chromium; the `performance.now()` version ran 29).
578
+
579
+ ## Paint the grid as pixels
580
+
581
+ **Paint the grid as pixels: one `ImageData` the size of the grid, filled from
582
+ the palette, put on a grid-sized offscreen canvas, and drawn scaled onto the
583
+ visible canvas with `imageSmoothingEnabled = false`.** That is one
584
+ `putImageData` and one `drawImage` per frame whatever the grid's size;
585
+ measured, 60 paints of a 160 × 100 grid cost 42 ms in total, and a 240 × 160
586
+ meadow steps in about 2.6 ms. A `fillRect` per cell is tens of thousands of
587
+ calls a frame and is where a world turns into a slideshow. The palette is
588
+ built once per mount from the theme tokens (`cssColor` then `rgb`), so cell
589
+ colours follow the app's theme. For a world drawn as sprites rather than
590
+ cells (boids, ants with heading), paint the agents on top after the grid.
591
+
592
+ ## A saved world is its seed, its parameters and its tick
593
+
594
+ **A saved world is its seed, its parameters and its tick, and when a person
595
+ tapped the world, the taps as events on the tick line.** The meadow keeps
596
+ `{ type: "world", seed, params, tick, name, creatorHandle }`; a gallery card
597
+ makes a world from the seed and dials and calls `replay({ seed, tick })`,
598
+ which steps to the tick without painting and paints once. The card is the
599
+ kept moment itself, alive, and Open puts the person in it. The sand box below
600
+ keeps `events: [{ tick, x, y, kind }]` as well, so a replay lands the brush at
601
+ the tick it landed. Measured: 31 recorded dabs over 300 ticks replay to an
602
+ identical grid from a different starting seed. A pixel snapshot is the one
603
+ thing a recipe cannot hold; an app that wants the literal image (an export
604
+ button) takes one with `canvas.toBlob` beside the recipe.
605
+
606
+ ## React stays on the slow clock
607
+
608
+ **React state updates happen on a slow clock: the loop paints the canvas
609
+ itself, and the interface reads a census every fifteen ticks or on demand.**
610
+ A `setState` per tick at 30 ticks a second re-renders the whole page thirty
611
+ times a second for numbers nobody can read that fast. The engine's `onTick`
612
+ listener is where the interface samples the world; the handlers for Play,
613
+ Step and the dials call into the world through the ref; and the dials restart
614
+ the world through the effect's dependency list so a turned dial is a fresh
615
+ run from the same seed.
616
+
617
+ ## Inputs are events on the tick line — a sand box
618
+
619
+ A world a person can touch keeps what they did as events keyed to the tick,
620
+ and `world.record(event)` lands the input now and remembers it for replay.
621
+ The rules' `apply(world, event)` is the one place an input touches the grid,
622
+ so the live tap and the replayed tap do the same thing, and the engine's
623
+ `applied` cursor is what keeps a tap from landing twice: `record` lands it
624
+ and moves the cursor past it, `step` lands only the events the cursor has
625
+ not reached (a replay's), and `replay` lands the events recorded at the kept
626
+ tick itself before its final paint. Measured with a toggle (an `apply` that
627
+ flips a cell, so a second landing would undo it): the live grid and the
628
+ replayed grid match.
629
+
630
+ ### `lib/sand.js`
631
+ ```js app=sand
632
+ // lib/sand.js — falling sand: sand piles, water flows, walls stay. Brush strokes are recorded
633
+ // events on the tick line, so a saved world replays the hand that drew it.
634
+ import { at } from "./world.js";
635
+
636
+ export const EMPTY = 0;
637
+ export const SAND = 1;
638
+ export const WALL = 2;
639
+ export const WATER = 3;
640
+
641
+ export const sandRules = {
642
+ seed() {},
643
+ /** A recorded brush dab: a 5×5 square of one material at the tick it happened. */
644
+ apply(world, event) {
645
+ for (let dy = -2; dy <= 2; dy += 1) {
646
+ for (let dx = -2; dx <= 2; dx += 1) world.cells[at(world, event.x + dx, event.y + dy)] = event.kind;
647
+ }
648
+ },
649
+ step(world, from, to) {
650
+ to.set(from);
651
+ for (let y = world.rows - 2; y >= 0; y -= 1) {
652
+ for (let x = 0; x < world.cols; x += 1) {
653
+ const i = y * world.cols + x;
654
+ const c = from[i];
655
+ if (c !== SAND && c !== WATER) continue;
656
+ const below = (y + 1) * world.cols + x;
657
+ if (to[below] === EMPTY) {
658
+ to[below] = c;
659
+ to[i] = EMPTY;
660
+ continue;
661
+ }
662
+ const side = world.random() < 0.5 ? -1 : 1;
663
+ const diagonal = at(world, x + side, y + 1);
664
+ if (to[diagonal] === EMPTY) {
665
+ to[diagonal] = c;
666
+ to[i] = EMPTY;
667
+ continue;
668
+ }
669
+ if (c === WATER) {
670
+ const beside = at(world, x + side, y);
671
+ if (to[beside] === EMPTY) {
672
+ to[beside] = c;
673
+ to[i] = EMPTY;
674
+ }
675
+ }
676
+ }
677
+ }
678
+ },
679
+ };
680
+ ```
681
+
682
+ ### `App.jsx`
683
+ ```jsx app=sand
684
+ import React, { useEffect, useRef, useState } from "react";
685
+ import { useFireproof } from "use-fireproof";
686
+ import { useVibe } from "use-vibes";
687
+ import { Button, Card, Heading, Row, Segmented, Spacer, Stack, Text } from "@vibes.diy/pouf";
688
+ import { createWorld, cssColor, rgb } from "./lib/world.js";
689
+ import { SAND, WALL, WATER, sandRules } from "./lib/sand.js";
690
+
691
+ const COLS = 128;
692
+ const ROWS = 72;
693
+ const MATERIALS = [
694
+ { value: "sand", label: "Sand" },
695
+ { value: "water", label: "Water" },
696
+ { value: "wall", label: "Wall" },
697
+ ];
698
+ const KIND = { sand: SAND, water: WATER, wall: WALL };
699
+
700
+ export default function App() {
701
+ const { database } = useFireproof("sandbox");
702
+ const { me, can, ready } = useVibe("sandbox");
703
+ const canvasRef = useRef(null);
704
+ const worldRef = useRef(null);
705
+ const [material, setMaterial] = useState("sand");
706
+ const [painting, setPainting] = useState(false);
707
+
708
+ useEffect(() => {
709
+ const canvas = canvasRef.current;
710
+ const palette = [
711
+ rgb(cssColor(canvas, "--background", "#141618")),
712
+ rgb(cssColor(canvas, "--accent", "#dcbe78")),
713
+ rgb(cssColor(canvas, "--muted-foreground", "#5a5a64")),
714
+ rgb(cssColor(canvas, "--primary", "#4682dc")),
715
+ ];
716
+ const world = createWorld(canvas, { cols: COLS, rows: ROWS, seed: 3, rules: sandRules, palette, ticksPerSecond: 60 });
717
+ worldRef.current = world;
718
+ world.start();
719
+ return () => {
720
+ world.dispose();
721
+ worldRef.current = null;
722
+ };
723
+ }, []);
724
+
725
+ // A pointer position in CSS pixels → a cell; the event is recorded against the current tick.
726
+ function dab(event) {
727
+ const world = worldRef.current;
728
+ const canvas = canvasRef.current;
729
+ if (!world || !canvas) return;
730
+ const rect = canvas.getBoundingClientRect();
731
+ const x = Math.floor(((event.clientX - rect.left) / rect.width) * COLS);
732
+ const y = Math.floor(((event.clientY - rect.top) / rect.height) * ROWS);
733
+ world.record({ kind: KIND[material], x, y });
734
+ }
735
+
736
+ const draft = { type: "world", seed: 3, creatorHandle: me?.userHandle };
737
+ const canKeep = Boolean(ready && me && can.create(draft).ok);
738
+
739
+ async function keep() {
740
+ const world = worldRef.current;
741
+ if (!world || !canKeep) return;
742
+ const recipe = world.recipe();
743
+ await database.put({ ...draft, _id: crypto.randomUUID(), tick: recipe.tick, events: recipe.events, createdAt: Date.now() });
744
+ }
745
+
746
+ return (
747
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
748
+ <Stack gap={4}>
749
+ <Row gap={2} align="center" wrap>
750
+ <Heading level={1}>Sand box</Heading>
751
+ <Spacer />
752
+ <Segmented label="Material" value={material} onChange={setMaterial} options={MATERIALS} />
753
+ </Row>
754
+ <Text size="sm" muted>Draw on the box. Sand piles, water flows, walls hold.</Text>
755
+ <Card variant="flush">
756
+ <canvas
757
+ ref={canvasRef}
758
+ className="block w-full aspect-video touch-none"
759
+ onPointerDown={(e) => { setPainting(true); dab(e); }}
760
+ onPointerMove={(e) => painting && dab(e)}
761
+ onPointerUp={() => setPainting(false)}
762
+ onPointerLeave={() => setPainting(false)}
763
+ />
764
+ </Card>
765
+ <Row gap={2} align="center" wrap>
766
+ <Button variant="quiet" onClick={() => worldRef.current?.reset()}>Clear</Button>
767
+ {canKeep && <Button onClick={keep}>Keep this drawing</Button>}
768
+ </Row>
769
+ </Stack>
770
+ </main>
771
+ );
772
+ }
773
+ ```
774
+
775
+ ## Mobile
776
+
777
+ The stage is the full width of the screen (`w-full` with an aspect ratio),
778
+ `touch-none` keeps a dab from scrolling the page, and the grid is sized by
779
+ cell count rather than by pixels, so a phone paints the same 160 × 90 world
780
+ a laptop does and the cost is the same. Pointer events cover mouse and
781
+ touch together. Play, Pause and Step sit under the stage as large buttons; the
782
+ dials live below, each a labelled slider. Keep `dpr` capped at 2. A world of
783
+ about 20,000 cells at 30 ticks a second is comfortable on a phone; a bigger
784
+ grid earns a lower tick rate rather than a smaller canvas.
785
+
786
+ ## Shapes a world takes
787
+
788
+ - **An ecosystem or terrarium** — grass, grazers and hunters as cell kinds,
789
+ dials for the rates, a census, kept moments in a gallery. The starter.
790
+ - **Game of Life and other automata** — `step` counts neighbours in `from`
791
+ and writes `to`; the seed is a random fill or a named pattern. Add a tap to
792
+ toggle cells as a recorded event, and a rule picker (B3/S23, HighLife).
793
+ - **Falling sand** — materials as cell kinds, a bottom-up scan, brush dabs as
794
+ events. The second example.
795
+ - **Forest fire** — trees grow with a small chance, lightning strikes with a
796
+ smaller one, fire spreads to neighbouring trees and burns out: three kinds
797
+ and two dials make a world that keeps changing.
798
+ - **Langton's ant and turmites** — one agent with a position and a heading
799
+ walking a grid that it recolours; the agent's state lives beside the grid on
800
+ the world object and the step moves it once.
801
+ - **Boids, ants with pheromone trails** — agents on a continuous field in
802
+ typed arrays (`x`, `y`, `vx`, `vy`), a grid underneath for the trail or the
803
+ food, painted as the grid then the agents on top. Keep the agent count in
804
+ the hundreds and the step cheap.
805
+ - **Weather over a map** — a grid of pressure or moisture diffusing each
806
+ tick, painted through a palette ramp; a map image drawn underneath.
807
+
808
+ ## Gotchas
809
+
810
+ - **The world runs at a different speed on another device** — the loop
811
+ steps once per frame. Pay out whole ticks from elapsed time with the
812
+ accumulator, as `frame` does.
813
+ - **A kept world opens looking different** — a `Math.random()` inside the
814
+ rules, or a tick counted twice. One generator, one owner.
815
+ - **The loop stalls on some browsers** — the frame's timestamp was read as
816
+ the clock. Read `performance.now()`.
817
+ - **The page re-renders thirty times a second** — a state setter inside
818
+ `onTick` with no sampling. Sample every N ticks, or on demand.
819
+ - **A big grid turns into a slideshow** — a `fillRect` per cell. One
820
+ `ImageData`, one `drawImage`.
821
+ - **Two worlds run after a hot reload or a dial change** — the effect's
822
+ cleanup is missing `dispose()`. Stop the loop in the effect's cleanup.
823
+ - **A tap lands in the wrong cell** — pointer coordinates were divided by the
824
+ canvas's pixel size rather than its CSS size. Use the bounding rect.
825
+ - **Cells smear at the edges** — a scaled `drawImage` with smoothing on. Set
826
+ `imageSmoothingEnabled = false` after every resize (a resize resets it).