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

Generative worlds — living simulations on a canvas

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

When someone asks for an ecosystem, a terrarium, an ant farm, a meadow with rabbits and foxes, Conway's Game of Life, a cellular automaton, falling sand, a forest fire, a flock of boids, a petri dish, weather drifting over a map, or any screen where a small world runs by its own rules and the fun is watching it go, this is the recipe. The browser's own canvas is the whole engine: a grid of cells in a typed array, two functions that seed and step it, a seeded random generator so the same world grows on every device, a loop on its own clock, and the grid painted as pixels. Fireproof keeps a world as its recipe (seed, dials, tick, and any taps a person made), so a saved world is a few bytes and a card in a gallery is that world, alive, at the moment it was kept.

The sibling boundary: a sketch that draws (flow fields, kaleidoscopes, a drawing toy) is p5; bodies with mass that collide are physics; a block world with depth is voxel. Worlds is the right call when the fun is a population of simple things following simple rules over time.

The worked examples below were built and measured for this doc (Chromium, 2026-10-07): a meadow whose dials a person turns and whose moments they keep, and a falling-sand box whose brush strokes replay.

Project shape

App.jsx          the interface: the stage, play / pause / step / speed, dials, keep, a gallery
lib/world.js     the engine: seeded random, double-buffered grid, pixel paint, the tick loop, recipes
lib/rules.js     the world's rules: seed(world) and step(world, from, to), plus a census
access.js        who may keep a world in the shared gallery

The engine is the same for every world; the rules are the part a request changes. A second kind of world (sand, life, fire) is a second rules module imported into the same createWorld.

The engine — lib/world.js

lib/world.js

js// lib/world.js — a seeded, fixed-tick grid world painted as pixels.
// Every random number comes from one seeded generator, the tick counter has one owner, the grid
// is double-buffered and painted through ImageData onto a small offscreen canvas that is drawn
// scaled (no smoothing) onto the visible one, and the loop runs on its own clock.

/** A tiny seeded PRNG (mulberry32): the same seed gives the same sequence on every device. */
export function seededRandom(seed) {
  let a = seed >>> 0;
  return function random() {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Resolve an injected theme token to a concrete colour once per mount (the p5 doc's probe). */
export function cssColor(host, token, fallback) {
  const probe = document.createElement("span");
  probe.style.display = "none";
  probe.style.color = `var(${token}, ${fallback})`;
  host.appendChild(probe);
  const value = getComputedStyle(probe).color;
  probe.remove();
  return value;
}

/**
 * Any CSS colour → [r, g, b] for ImageData. A canvas fill does the conversion, so a token that
 * computes to `oklch(…)`, `color-mix(…)` or `rgb(50% …)` (measured: a computed style reports an
 * oklch token verbatim) lands as bytes rather than being read as three numbers.
 */
export function rgb(color) {
  const probe = document.createElement("canvas").getContext("2d");
  probe.fillStyle = color;
  probe.fillRect(0, 0, 1, 1);
  const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
  return [r, g, b];
}

/**
 * createWorld(canvas, { cols, rows, seed, rules, palette, ticksPerSecond })
 *
 * `rules` is { seed(world) , step(world, from, to) }: `seed` fills `world.cells` once from the
 * seeded generator; `step` reads `from` and writes `to` (the two buffers swap each tick). Both use
 * `world.random()` for every random number. `palette` maps a cell value to [r, g, b].
 */
export function createWorld(canvas, { cols, rows, seed = 1, rules, palette, ticksPerSecond = 30 }) {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const ctx = canvas.getContext("2d");
  const offscreen = document.createElement("canvas");
  offscreen.width = cols;
  offscreen.height = rows;
  const octx = offscreen.getContext("2d");
  const image = octx.createImageData(cols, rows);

  const world = {
    cols,
    rows,
    seed,
    tick: 0,
    cells: new Uint8Array(cols * rows),
    next: new Uint8Array(cols * rows),
    random: seededRandom(seed),
    ticksPerSecond,
    running: false,
    events: [],
    applied: 0, // how many of `events` have landed on the grid (live or replayed)
    raf: 0,
    owed: 0,
    last: 0,
    listeners: new Set(),
  };

  function resize() {
    const w = canvas.clientWidth || cols;
    const h = canvas.clientHeight || rows;
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
  }

  world.paint = () => {
    const data = image.data;
    const cells = world.cells;
    for (let i = 0, p = 0; i < cells.length; i += 1, p += 4) {
      const c = palette[cells[i]] || palette[0];
      data[p] = c[0];
      data[p + 1] = c[1];
      data[p + 2] = c[2];
      data[p + 3] = 255;
    }
    octx.putImageData(image, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(offscreen, 0, 0, canvas.width, canvas.height);
  };

  /** Land every recorded event up to the current tick that has not landed yet (a replay's). */
  function applyDue() {
    while (world.applied < world.events.length && world.events[world.applied].tick <= world.tick) {
      rules.apply?.(world, world.events[world.applied]);
      world.applied += 1;
    }
  }

  /** The one place the tick advances: land this tick's events that are still due, then the rules. */
  world.step = () => {
    applyDue();
    rules.step(world, world.cells, world.next);
    const swap = world.cells;
    world.cells = world.next;
    world.next = swap;
    world.tick += 1;
    for (const fn of world.listeners) fn(world);
  };

  /** Record an input against the current tick and land it now; the cursor keeps step() from landing it twice. */
  world.record = (event) => {
    const stamped = { ...event, tick: world.tick };
    world.events.push(stamped);
    rules.apply?.(world, stamped);
    world.applied = world.events.length;
    world.paint();
  };

  world.frame = () => {
    if (!world.running) return;
    // performance.now(), not the frame's timestamp: the two clocks can disagree by whole seconds
    // in some browsers, and a negative delta would stall the loop.
    const now = performance.now();
    const tickMs = 1000 / world.ticksPerSecond;
    world.owed += Math.max(0, Math.min(now - world.last, 4 * tickMs));
    world.last = now;
    let stepped = false;
    while (world.owed >= tickMs) {
      world.step();
      world.owed -= tickMs;
      stepped = true;
    }
    if (stepped) world.paint();
    world.raf = requestAnimationFrame(world.frame);
  };

  world.start = () => {
    if (world.running) return;
    world.running = true;
    world.last = performance.now();
    world.owed = 0;
    world.raf = requestAnimationFrame(world.frame);
  };
  world.stop = () => {
    world.running = false;
    cancelAnimationFrame(world.raf);
  };
  world.stepOnce = () => {
    world.step();
    world.paint();
  };
  world.setSpeed = (tps) => {
    world.ticksPerSecond = tps;
  };
  world.onTick = (fn) => {
    world.listeners.add(fn);
    return () => world.listeners.delete(fn);
  };

  /** Back to tick 0 with the same seed (or a new one): the next run replays exactly. */
  world.reset = (nextSeed = world.seed, events = []) => {
    world.seed = nextSeed;
    world.random = seededRandom(nextSeed);
    world.tick = 0;
    world.cells.fill(0);
    world.next.fill(0);
    world.events = events.slice();
    world.applied = 0;
    rules.seed(world);
    world.paint();
  };

  /** Run from a recipe to a tick without drawing in between; the paint at the end is the card. */
  world.replay = ({ seed: s, tick, events = [] }) => {
    world.reset(s, events);
    while (world.tick < tick) world.step();
    applyDue(); // inputs recorded at the kept tick itself, after its last step
    world.paint();
  };

  world.recipe = () => ({ seed: world.seed, tick: world.tick, events: world.events.slice() });

  world.dispose = () => {
    world.stop();
    world.listeners.clear();
  };

  resize();
  world.reset(seed);
  return world;
}

/** Cell index helpers with wrap-around edges. */
export function at(world, x, y) {
  const { cols, rows } = world;
  return (((y % rows) + rows) % rows) * cols + (((x % cols) + cols) % cols);
}

Rules are two functions — lib/rules.js

A world's rules are seed(world), which fills the first grid, and step(world, from, to), which reads one buffer and writes the other. The engine swaps the buffers and counts the tick. Writing into to while reading from is what makes a step order-independent: every cell sees the same previous world. (The meadow below reads to for neighbours on purpose, so a cell a rabbit already moved into this tick counts as taken; that is the rule's choice, and the seed makes it reproducible either way.)

lib/rules.js

js// lib/rules.js — a meadow: grass grows back, rabbits graze and breed, foxes hunt and starve.
// Rules are two functions over the grid. Every random number comes from `world.random()`, so the
// same seed grows the same meadow on every device and a saved tick replays exactly.
import { at } from "./world.js";

export const EMPTY = 0;
export const GRASS = 1;
export const RABBIT = 2;
export const FOX = 3;

/** The dials a person can turn; each is a probability per cell per tick. */
export const DEFAULT_PARAMS = { regrow: 0.03, rabbitBreed: 0.15, rabbitDie: 0.03, foxBreed: 0.6, foxStarve: 0.01 };

export function meadowRules(params = DEFAULT_PARAMS) {
  const P = { ...DEFAULT_PARAMS, ...params };
  return {
    /** The first tick's world: half grass, a few rabbits, fewer foxes. */
    seed(world) {
      const cells = world.cells;
      for (let i = 0; i < cells.length; i += 1) {
        const r = world.random();
        cells[i] = r < 0.5 ? GRASS : r < 0.56 ? RABBIT : r < 0.58 ? FOX : EMPTY;
      }
    },
    /** Read `from`, write `to`; the engine swaps them after. */
    step(world, from, to) {
      to.set(from);
      for (let y = 0; y < world.rows; y += 1) {
        for (let x = 0; x < world.cols; x += 1) {
          const i = y * world.cols + x;
          const c = from[i];
          if (c === EMPTY) {
            if (world.random() < P.regrow) to[i] = GRASS;
            continue;
          }
          if (c === GRASS) continue;
          const dx = Math.floor(world.random() * 3) - 1;
          const dy = Math.floor(world.random() * 3) - 1;
          const j = at(world, x + dx, y + dy);
          const n = to[j];
          if (c === RABBIT) {
            if (n === GRASS) {
              to[j] = RABBIT;
              to[i] = world.random() < P.rabbitBreed ? RABBIT : EMPTY;
            } else if (n === EMPTY) {
              to[j] = RABBIT;
              to[i] = EMPTY;
            }
            if (world.random() < P.rabbitDie) to[i] = EMPTY;
          } else if (c === FOX) {
            if (n === RABBIT) {
              to[j] = FOX;
              to[i] = world.random() < P.foxBreed ? FOX : EMPTY;
            } else if (n === EMPTY || n === GRASS) {
              to[j] = FOX;
              to[i] = n;
              if (world.random() < P.foxStarve) to[j] = n;
            }
          }
        }
      }
    },
  };
}

/** How many of each kind are alive — read on a slow clock, every few ticks. */
export function census(world) {
  const counts = [0, 0, 0, 0];
  for (const c of world.cells) counts[c] += 1;
  return { grass: counts[GRASS], rabbits: counts[RABBIT], foxes: counts[FOX] };
}

Measured with these dials (160 × 100 cells, seed 11): grass, rabbits and foxes all alive at tick 2000, with foxes between about 300 and 500 the whole way. Dials that let one species win in the first few hundred ticks are the common first draft; the fix is a lower breed rate for the prey and a lower starve rate for the predator, then watch the census for a thousand ticks.

The interface — a meadow with dials, a keep button and a living gallery

App.jsx

jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { Slider } from "@vibes.diy/pouf/interactive";
import { createWorld, cssColor, rgb } from "./lib/world.js";
import { DEFAULT_PARAMS, census, meadowRules } from "./lib/rules.js";

const COLS = 160;
const ROWS = 90;

/** One palette per mount, from the app's theme tokens: the owner's restyle reaches the meadow. */
function makePalette(host) {
  return [
    rgb(cssColor(host, "--background", "#141618")),
    rgb(cssColor(host, "--primary", "#3c8c46")),
    rgb(cssColor(host, "--foreground", "#e6dcc8")),
    rgb(cssColor(host, "--accent", "#dc6e32")),
  ];
}

/** A gallery card: a tiny canvas that replays the kept world to its tick, once. */
function WorldCard({ doc, onOpen }) {
  const ref = useRef(null);
  useEffect(() => {
    const canvas = ref.current;
    const world = createWorld(canvas, { cols: COLS, rows: ROWS, seed: doc.seed, rules: meadowRules(doc.params), palette: makePalette(canvas) });
    world.replay({ seed: doc.seed, tick: doc.tick });
    return () => world.dispose();
  }, [doc.seed, doc.tick, doc.params]);
  return (
    <Card variant="tight">
      <Stack gap={2}>
        <canvas ref={ref} className="block w-full aspect-video rounded" />
        <Row gap={2} align="center" wrap>
          <Text size="sm">{doc.name}</Text>
          <Spacer />
          <Button size="sm" variant="quiet" onClick={() => onOpen(doc)}>Open</Button>
        </Row>
      </Stack>
    </Card>
  );
}

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

  const [seed, setSeed] = useState(11);
  const [params, setParams] = useState(DEFAULT_PARAMS);
  const [running, setRunning] = useState(true);
  const [speed, setSpeed] = useState(30);
  const [counts, setCounts] = useState({ grass: 0, rabbits: 0, foxes: 0 });
  const [tick, setTick] = useState(0);
  const [name, setName] = useState("");

  const canvasRef = useRef(null);
  const worldRef = useRef(null);
  const openRef = useRef(null); // a kept moment to replay once the next world exists
  const [opened, setOpened] = useState(0); // bumps on every Open, so the effect runs even when seed and dials match

  // The world lives in a ref: created once per seed and dials, stopped in the effect's cleanup.
  useEffect(() => {
    const canvas = canvasRef.current;
    const world = createWorld(canvas, { cols: COLS, rows: ROWS, seed, rules: meadowRules(params), palette: makePalette(canvas), ticksPerSecond: speed });
    worldRef.current = world;
    if (openRef.current) {
      world.replay(openRef.current);
      openRef.current = null;
    }
    // One reading right away, so a paused or replayed world shows its own numbers.
    setCounts(census(world));
    setTick(world.tick);
    // React state updates happen on a slow clock: a census every 15 ticks, not every tick.
    const off = world.onTick((w) => {
      if (w.tick % 15 === 0) {
        setCounts(census(w));
        setTick(w.tick);
      }
    });
    if (running) world.start();
    return () => {
      off();
      world.dispose();
      worldRef.current = null;
    };
  }, [seed, params, opened]);

  useEffect(() => {
    const world = worldRef.current;
    if (!world) return;
    world.setSpeed(speed);
    if (running) world.start();
    else world.stop();
  }, [running, speed]);

  function open(doc) {
    openRef.current = { seed: doc.seed, tick: doc.tick };
    setParams(doc.params);
    setSeed(doc.seed);
    setRunning(false);
    setOpened((n) => n + 1);
  }

  const draft = { type: "world", name: name || "a meadow", seed, params, tick, creatorHandle: me?.userHandle };
  const canKeep = Boolean(ready && me && can.create(draft).ok);

  async function keep() {
    const world = worldRef.current;
    if (!world || !canKeep) return;
    setName(""); // ready for the next name at once
    const { ok } = await database.put({ ...draft, _id: crypto.randomUUID(), tick: world.tick, createdAt: Date.now() });
    if (!ok) setName((now) => now || name);
  }

  const dial = (key, label, max) => (
    <Stack key={key} gap={1}>
      <Text size="sm">{label}</Text>
      <Slider label={label} min={0} max={max} step={max / 100} value={[params[key]]} onChange={([v]) => setParams({ ...params, [key]: v })} />
    </Stack>
  );

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Meadow</Heading>
          <Spacer />
          <Badge tone="up">{counts.rabbits} rabbits</Badge>
          <Badge tone="info">{counts.foxes} foxes</Badge>
          <Badge>tick {tick}</Badge>
        </Row>

        <Card variant="flush">
          <canvas ref={canvasRef} className="block w-full aspect-video touch-none" />
        </Card>

        <Row gap={2} align="center" wrap>
          <Button onClick={() => setRunning((r) => !r)}>{running ? "Pause" : "Play"}</Button>
          <Button variant="quiet" onClick={() => { setRunning(false); worldRef.current?.stepOnce(); }}>Step</Button>
          <Button variant="quiet" onClick={() => setSeed(Math.floor(Math.random() * 1e9))}>New meadow</Button>
          <Stack gap={1}>
            <Text size="sm">Speed</Text>
            <Slider label="Ticks per second" min={1} max={120} value={[speed]} onChange={([v]) => setSpeed(v)} />
          </Stack>
        </Row>

        <Card variant="tight">
          <Stack gap={2}>
            <Heading level={2}>Dials</Heading>
            <Text size="sm" muted>Each dial is a chance per cell per tick. Turning one restarts the meadow from the same seed.</Text>
            <Row gap={3} wrap>
              {dial("regrow", "Grass regrows", 0.2)}
              {dial("rabbitBreed", "Rabbits breed", 1)}
              {dial("rabbitDie", "Rabbits rest", 0.2)}
              {dial("foxBreed", "Foxes breed", 1)}
              {dial("foxStarve", "Foxes wander off", 0.1)}
            </Row>
          </Stack>
        </Card>

        <Card variant="tight">
          <Stack gap={3}>
            <Heading level={2}>Kept meadows</Heading>
            {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
            {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>}
            {canKeep && (
              <Row gap={2} align="top" wrap>
                <Field label="Name this moment">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="the fox spring" />}</Field>
                <Button size="sm" onClick={keep}>Keep</Button>
              </Row>
            )}
            <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
              {kept.map((doc) => (
                <Stack key={doc._id} gap={1}>
                  <WorldCard doc={doc} onOpen={open} />
                  <Row gap={2} align="center" wrap>
                    {doc.creatorHandle && <ViewerTag userHandle={doc.creatorHandle} />}
                    <Spacer />
                    {can.delete(doc).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(doc._id)}>Remove</Button>}
                  </Row>
                </Stack>
              ))}
            </div>
          </Stack>
        </Card>
      </Stack>
    </main>
  );
}

access.js

js// world: a kept meadow. Its keeper edits and removes it; everyone can open and watch it.
export function meadows(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "sign in to keep meadows" };
  if (doc.type === "world" || (doc._deleted && oldDoc && oldDoc.type === "world")) {
    const keeper = oldDoc ? oldDoc.creatorHandle : doc.creatorHandle;
    if (keeper !== user.userHandle) throw { forbidden: "meadows are kept as yourself" };
    if (oldDoc && !doc._deleted && doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "a meadow keeps its keeper" };
    return { channels: ["worlds"], grant: { public: ["worlds"] } };
  }
  throw { forbidden: "unknown document type" };
}

One seeded generator

Every random number the world uses comes from one seeded generator, world.random(), so the same seed grows the same world on every device. seededRandom(seed) is twenty lines (mulberry32) and the engine owns the one instance; the rules call world.random() for a neighbour, a coin flip, a regrowth roll. Measured: two worlds from seed 7 stepped 500 times hold identical cells, and a third world fed the first one's recipe lands on the same grid. The interface picks a fresh seed with the browser's own random (Math.floor(Math.random() * 1e9)) because that number is about to be saved; inside the rules, the seeded one is the only source.

The tick counter has one owner, and ticks run on their own clock

world.step() is the only place tick advances, and the frame loop pays out whole ticks from the time that has passed, so a world runs at the same speed on a 60 Hz laptop and a 120 Hz phone. The accumulator owes 1000 / ticksPerSecond ms per tick, steps as many times as it owes, caps a long absence at four ticks (a background tab resumes rather than fast-forwards), and paints once per frame however many ticks ran. Measured: 29 ticks in a second at 30, 119 at 120, with the frame rate unchanged. The speed slider changes ticksPerSecond and nothing else; Step pauses and runs one tick; Pause stops the loop and leaves the grid on screen.

The loop reads performance.now() rather than the frame's own timestamp: the two clocks can disagree by whole seconds in some browsers, and a negative delta would stall the loop (measured: the timestamp version ran zero ticks in headless Chromium; the performance.now() version ran 29).

Paint the grid as pixels

Paint the grid as pixels: one ImageData the size of the grid, filled from the palette, put on a grid-sized offscreen canvas, and drawn scaled onto the visible canvas with imageSmoothingEnabled = false. That is one putImageData and one drawImage per frame whatever the grid's size; measured, 60 paints of a 160 × 100 grid cost 42 ms in total, and a 240 × 160 meadow steps in about 2.6 ms. A fillRect per cell is tens of thousands of calls a frame and is where a world turns into a slideshow. The palette is built once per mount from the theme tokens (cssColor then rgb), so cell colours follow the app's theme. For a world drawn as sprites rather than cells (boids, ants with heading), paint the agents on top after the grid.

A saved world is its seed, its parameters and its tick

A saved world is its seed, its parameters and its tick, and when a person tapped the world, the taps as events on the tick line. The meadow keeps { type: "world", seed, params, tick, name, creatorHandle }; a gallery card makes a world from the seed and dials and calls replay({ seed, tick }), which steps to the tick without painting and paints once. The card is the kept moment itself, alive, and Open puts the person in it. The sand box below keeps events: [{ tick, x, y, kind }] as well, so a replay lands the brush at the tick it landed. Measured: 31 recorded dabs over 300 ticks replay to an identical grid from a different starting seed. A pixel snapshot is the one thing a recipe cannot hold; an app that wants the literal image (an export button) takes one with canvas.toBlob beside the recipe.

React stays on the slow clock

React state updates happen on a slow clock: the loop paints the canvas itself, and the interface reads a census every fifteen ticks or on demand. A setState per tick at 30 ticks a second re-renders the whole page thirty times a second for numbers nobody can read that fast. The engine's onTick listener is where the interface samples the world; the handlers for Play, Step and the dials call into the world through the ref; and the dials restart the world through the effect's dependency list so a turned dial is a fresh run from the same seed.

Inputs are events on the tick line — a sand box

A world a person can touch keeps what they did as events keyed to the tick, and world.record(event) lands the input now and remembers it for replay. The rules' apply(world, event) is the one place an input touches the grid, so the live tap and the replayed tap do the same thing, and the engine's applied cursor is what keeps a tap from landing twice: record lands it and moves the cursor past it, step lands only the events the cursor has not reached (a replay's), and replay lands the events recorded at the kept tick itself before its final paint. Measured with a toggle (an apply that flips a cell, so a second landing would undo it): the live grid and the replayed grid match.

lib/sand.js

js// lib/sand.js — falling sand: sand piles, water flows, walls stay. Brush strokes are recorded
// events on the tick line, so a saved world replays the hand that drew it.
import { at } from "./world.js";

export const EMPTY = 0;
export const SAND = 1;
export const WALL = 2;
export const WATER = 3;

export const sandRules = {
  seed() {},
  /** A recorded brush dab: a 5×5 square of one material at the tick it happened. */
  apply(world, event) {
    for (let dy = -2; dy <= 2; dy += 1) {
      for (let dx = -2; dx <= 2; dx += 1) world.cells[at(world, event.x + dx, event.y + dy)] = event.kind;
    }
  },
  step(world, from, to) {
    to.set(from);
    for (let y = world.rows - 2; y >= 0; y -= 1) {
      for (let x = 0; x < world.cols; x += 1) {
        const i = y * world.cols + x;
        const c = from[i];
        if (c !== SAND && c !== WATER) continue;
        const below = (y + 1) * world.cols + x;
        if (to[below] === EMPTY) {
          to[below] = c;
          to[i] = EMPTY;
          continue;
        }
        const side = world.random() < 0.5 ? -1 : 1;
        const diagonal = at(world, x + side, y + 1);
        if (to[diagonal] === EMPTY) {
          to[diagonal] = c;
          to[i] = EMPTY;
          continue;
        }
        if (c === WATER) {
          const beside = at(world, x + side, y);
          if (to[beside] === EMPTY) {
            to[beside] = c;
            to[i] = EMPTY;
          }
        }
      }
    }
  },
};

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe } from "use-vibes";
import { Button, Card, Heading, Row, Segmented, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { createWorld, cssColor, rgb } from "./lib/world.js";
import { SAND, WALL, WATER, sandRules } from "./lib/sand.js";

const COLS = 128;
const ROWS = 72;
const MATERIALS = [
  { value: "sand", label: "Sand" },
  { value: "water", label: "Water" },
  { value: "wall", label: "Wall" },
];
const KIND = { sand: SAND, water: WATER, wall: WALL };

export default function App() {
  const { database } = useFireproof("sandbox");
  const { me, can, ready } = useVibe("sandbox");
  const canvasRef = useRef(null);
  const worldRef = useRef(null);
  const [material, setMaterial] = useState("sand");
  const [painting, setPainting] = useState(false);

  useEffect(() => {
    const canvas = canvasRef.current;
    const palette = [
      rgb(cssColor(canvas, "--background", "#141618")),
      rgb(cssColor(canvas, "--accent", "#dcbe78")),
      rgb(cssColor(canvas, "--muted-foreground", "#5a5a64")),
      rgb(cssColor(canvas, "--primary", "#4682dc")),
    ];
    const world = createWorld(canvas, { cols: COLS, rows: ROWS, seed: 3, rules: sandRules, palette, ticksPerSecond: 60 });
    worldRef.current = world;
    world.start();
    return () => {
      world.dispose();
      worldRef.current = null;
    };
  }, []);

  // A pointer position in CSS pixels → a cell; the event is recorded against the current tick.
  function dab(event) {
    const world = worldRef.current;
    const canvas = canvasRef.current;
    if (!world || !canvas) return;
    const rect = canvas.getBoundingClientRect();
    const x = Math.floor(((event.clientX - rect.left) / rect.width) * COLS);
    const y = Math.floor(((event.clientY - rect.top) / rect.height) * ROWS);
    world.record({ kind: KIND[material], x, y });
  }

  const draft = { type: "world", seed: 3, creatorHandle: me?.userHandle };
  const canKeep = Boolean(ready && me && can.create(draft).ok);

  async function keep() {
    const world = worldRef.current;
    if (!world || !canKeep) return;
    const recipe = world.recipe();
    await database.put({ ...draft, _id: crypto.randomUUID(), tick: recipe.tick, events: recipe.events, createdAt: Date.now() });
  }

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Sand box</Heading>
          <Spacer />
          <Segmented label="Material" value={material} onChange={setMaterial} options={MATERIALS} />
        </Row>
        <Text size="sm" muted>Draw on the box. Sand piles, water flows, walls hold.</Text>
        <Card variant="flush">
          <canvas
            ref={canvasRef}
            className="block w-full aspect-video touch-none"
            onPointerDown={(e) => { setPainting(true); dab(e); }}
            onPointerMove={(e) => painting && dab(e)}
            onPointerUp={() => setPainting(false)}
            onPointerLeave={() => setPainting(false)}
          />
        </Card>
        <Row gap={2} align="center" wrap>
          <Button variant="quiet" onClick={() => worldRef.current?.reset()}>Clear</Button>
          {canKeep && <Button onClick={keep}>Keep this drawing</Button>}
        </Row>
      </Stack>
    </main>
  );
}

Mobile

The stage is the full width of the screen (w-full with an aspect ratio), touch-none keeps a dab from scrolling the page, and the grid is sized by cell count rather than by pixels, so a phone paints the same 160 × 90 world a laptop does and the cost is the same. Pointer events cover mouse and touch together. Play, Pause and Step sit under the stage as large buttons; the dials live below, each a labelled slider. Keep dpr capped at 2. A world of about 20,000 cells at 30 ticks a second is comfortable on a phone; a bigger grid earns a lower tick rate rather than a smaller canvas.

Shapes a world takes

  • An ecosystem or terrarium — grass, grazers and hunters as cell kinds, dials for the rates, a census, kept moments in a gallery. The starter.
  • Game of Life and other automata — step counts neighbours in from and writes to; the seed is a random fill or a named pattern. Add a tap to toggle cells as a recorded event, and a rule picker (B3/S23, HighLife).
  • Falling sand — materials as cell kinds, a bottom-up scan, brush dabs as events. The second example.
  • Forest fire — trees grow with a small chance, lightning strikes with a smaller one, fire spreads to neighbouring trees and burns out: three kinds and two dials make a world that keeps changing.
  • Langton's ant and turmites — one agent with a position and a heading walking a grid that it recolours; the agent's state lives beside the grid on the world object and the step moves it once.
  • Boids, ants with pheromone trails — agents on a continuous field in typed arrays (x, y, vx, vy), a grid underneath for the trail or the food, painted as the grid then the agents on top. Keep the agent count in the hundreds and the step cheap.
  • Weather over a map — a grid of pressure or moisture diffusing each tick, painted through a palette ramp; a map image drawn underneath.

Gotchas

  • The world runs at a different speed on another device — the loop steps once per frame. Pay out whole ticks from elapsed time with the accumulator, as frame does.
  • A kept world opens looking different — a Math.random() inside the rules, or a tick counted twice. One generator, one owner.
  • The loop stalls on some browsers — the frame's timestamp was read as the clock. Read performance.now().
  • The page re-renders thirty times a second — a state setter inside onTick with no sampling. Sample every N ticks, or on demand.
  • A big grid turns into a slideshow — a fillRect per cell. One ImageData, one drawImage.
  • Two worlds run after a hot reload or a dial change — the effect's cleanup is missing dispose(). Stop the loop in the effect's cleanup.
  • A tap lands in the wrong cell — pointer coordinates were divided by the canvas's pixel size rather than its CSS size. Use the bounding rect.
  • Cells smear at the edges — a scaled drawImage with smoothing on. Set imageSmoothingEnabled = false after every resize (a resize resets it).