# Fire and Flow
[Full-page experience](/experience/fire-and-flow/) · [Open playground](/playground/#/examples/composition/fire-and-flow)

Animated fire and buoyant smoke flowing around shapes you can move, resize, and rotate.

## Try it

Drag a shape, use its resize and rotation handles, then click empty space to deselect.

## What to learn

- Share a flow field between smoke particles and solid obstacles.
- Manipulate solid shapes through decorative particles.

## Integration boundaries

- Flow sources drive velocity and heat independently of particle emission.
- The obstacle source supplies packed committed geometry; particles are not rigid bodies.

This is a Pibbl Canvas example. Its mount owns its runtime resources; call its controller's dispose method when removing it. The preview starts automatically unless reduced motion is enabled; Stop disposes the mount. Preserve the source's logical canvas size and aspect ratio. Native HTML is not captured by Canvas filters.

## Related APIs

- [Particles2D](/reference/components/particles-2d/)
- [useParticleSystem](/reference/hooks/use-particle-system/)
- [useFlowField2D](/reference/hooks/use-flow-field/)
- [useFlowSource2D](/reference/hooks/use-flow-field/)

## Complete source

Host setup for this source: use a canvas with width 720 and height 480, compile with `jsx: "react-jsx"` and `jsxImportSource: "@pibbl/core"`, import its default `mount`, and call `const controller = mount(canvas)` after attaching the canvas. Call `controller.dispose()` before removing it.

### fire-and-flow.example.tsx

```tsx
import {
  PhysicsWorld2D,
  pibblPhysicsWorld2D,
  physicsObstacles2D,
  kinematicBody2D,
  collider2D,
} from "@pibbl/core/physics/2d";
import {
  Group,
  Rectangle,
  Ellipse,
  Polygon,
  Text,
  pibbl,
  definePrimitive,
  useConst,
  useComputed,
  usePlayback,
  useSignal,
  useManipulation,
  ManipulationFrame,
  DragHandle,
  ResizeHandles,
  RotationHandle,
  type WritableSignal,
  type PibblManipulationBox,
  repeat,
  sequence,
  wait,
  marker,
  type PibblController,
} from "@pibbl/core";
import {
  Particles2D,
  defineParticleEffect2D,
  particleCurve,
  particleRange,
  useParticleSystem,
  useFlowField2D,
  useFlowSource2D,
  type PibblParticleEmitter2D,
} from "@pibbl/core/particles";

// Original procedural artwork; buffers live with this mounted example.
function smokeSprite(variant: number): OffscreenCanvas {
  const canvas = new OffscreenCanvas(96, 96);
  const context = canvas.getContext("2d")!;
  // Overlapping shaded lobes give each soft sprite internal volume, rather
  // than a single bright dot. Three silhouettes keep the plume irregular.
  for (let i = 0; i < 7; i++) {
    const angle = i * 2.4 + variant;
    const x = 48 + Math.cos(angle) * (10 + (i % 3) * 4),
      y = 48 + Math.sin(angle) * 13;
    const radius = 22 + ((i + variant) % 3) * 3;
    const body = context.createRadialGradient(x - 5, y - 7, 0, x, y, radius);
    body.addColorStop(0, "rgba(193,204,211,0.52)");
    body.addColorStop(0.28, "rgba(156,171,183,0.48)");
    body.addColorStop(0.6, "rgba(84,102,119,0.34)");
    body.addColorStop(0.82, "rgba(61,78,94,0.14)");
    body.addColorStop(1, "rgba(61,78,94,0)");
    context.fillStyle = body;
    context.fillRect(0, 0, 96, 96);
  }
  return canvas;
}

function createFlameSurface() {
  const canvas = new OffscreenCanvas(112, 160);
  const context = canvas.getContext("2d")!;
  const pixels = context.createImageData(canvas.width, canvas.height);
  const lattice = new Float32Array(4096);
  let seed = 7919;
  for (let i = 0; i < lattice.length; i++) {
    seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
    lattice[i] = seed / 4294967296;
  }
  const noise = (x: number, y: number) => {
    const ix = Math.floor(x),
      iy = Math.floor(y);
    let fx = x - ix,
      fy = y - iy;
    fx = fx * fx * (3 - 2 * fx);
    fy = fy * fy * (3 - 2 * fy);
    const a = lattice[((iy & 63) << 6) | (ix & 63)];
    const b = lattice[((iy & 63) << 6) | ((ix + 1) & 63)];
    const c = lattice[(((iy + 1) & 63) << 6) | (ix & 63)];
    const d = lattice[(((iy + 1) & 63) << 6) | ((ix + 1) & 63)];
    return a + (b - a) * fx + (c - a + (a - b - c + d) * fx) * fy;
  };
  let lastTime = -1,
    lastIntensity = -1;
  return {
    canvas,
    update(time: number, intensity: number) {
      if (time === lastTime && intensity === lastIntensity)
        return;
      lastTime = time;
      lastIntensity = intensity;
      for (let y = 0; y < canvas.height; y++) {
        const height = 1 - y / (canvas.height - 1);
        for (let x = 0; x < canvas.width; x++) {
          const u = (x / (canvas.width - 1) - 0.5) * 2;
          // Height increases upward; subtract time so the noise rises with it.
          const lift = height * 5 - time * 2.4;
          const curl = noise(u * 2.4 + 8, lift * 0.65) - 0.5;
          const bent =
            u - curl * height * 0.75;
          const grain =
            noise(bent * 5 + 20, lift) * 0.62 +
            noise(bent * 11 + 40, lift * 1.8) * 0.26 +
            noise(bent * 23 + 30, lift * 3.5) * 0.12;
          const taper = 0.5 * Math.pow(1 - height, 0.65);
          const envelope = 1 - Math.abs(bent) / Math.max(0.025, taper);
          const density = Math.max(
            0,
            Math.min(
              1,
              (envelope * 0.65 + grain * 0.9 - height * 0.7 - 0.2) * 3.4,
            ),
          );
          const hot = Math.min(1, density * 0.76 + (1 - height) * 0.27);
          const offset = (y * canvas.width + x) * 4;
          pixels.data[offset] = 255;
          pixels.data[offset + 1] = Math.round(55 + 196 * hot * hot);
          pixels.data[offset + 2] = Math.round(8 + 170 * Math.pow(hot, 5));
          pixels.data[offset + 3] = Math.round(
            255 * density * Math.min(1, (1 - height) * 14),
          );
        }
      }
      context.putImageData(pixels, 0, 0);
    },
  };
}

// A small effect primitive, composed in normal Pibbl paint order. The shared Pibbl
// clock changes time; rendering reuses one raster, with no separate frame loop.
const Flame = definePrimitive<{
  surface: ReturnType<typeof createFlameSurface>;
  time: number;
  intensity: number;
  x: number;
  y: number;
  pointerEvents: "none";
}>((props, _style, context) => {
  props.surface.update(props.time, props.intensity);
  const height = 100 + props.intensity * 45;
  context.drawImage(
    props.surface.canvas,
    props.x - 66,
    props.y - height + 6,
    132,
    height,
  );
});

const clockProgram = repeat(sequence(wait(40), marker("emit")), {
  iterations: Infinity,
});

function Obstacle({
  id,
  kind,
  initial,
  selected,
}: {
  id: string;
  kind: "rectangle" | "ellipse" | "triangle";
  initial: PibblManipulationBox;
  selected: WritableSignal<string | null>;
}) {
  const box = useSignal(initial);
  const rotation = useSignal(0);
  const controller = useManipulation({
    box,
    rotation,
    move: { bounds: { left: 30, top: 92, width: 660, height: 338 } },
    resize: { minWidth: 36, minHeight: 30, maxWidth: 250, maxHeight: 200 },
    rotate: {},
    onStart: () => selected.set(id),
  });
  const position = useComputed(() => {
    const b = box.get(),
      angle = rotation.get(),
      c = Math.cos(angle),
      sn = Math.sin(angle);
    return [
      b.left + b.width / 2 - (c * b.width) / 2 + (sn * b.height) / 2,
      b.top + b.height / 2 - (sn * b.width) / 2 - (c * b.height) / 2,
    ] as const;
  });
  const angleDegrees = useComputed(() => (rotation.get() * 180) / Math.PI);
  const { width, height } = box.get();
  const active = selected.get() === id;
  const paint = {
    fill:
      kind === "ellipse"
        ? "#657e9b"
        : kind === "triangle"
          ? "#997254"
          : "#397b83",
    stroke: active ? "#f2d6a1" : "#b3c7c755",
    strokeWidth: active ? 2 : 1,
    cursor: "grab" as const,
  };
  const select = () => selected.set(id);
  return (
    <ManipulationFrame {...controller.frameProps}>
      <DragHandle controller={controller}>
        <Group
          physics={kinematicBody2D({
            collider: false,
            target: { position, rotationDegrees: angleDegrees },
          })}
        >
          {kind === "rectangle" ? (
            <Rectangle
              keyboardFocusable
              onPointerDown={select}
              physics={collider2D()}
              style={{
                width,
                height,
                fill: paint.fill,
                stroke: paint.stroke,
                strokeWidth: paint.strokeWidth,
                cursor: paint.cursor,
              }}
            />
          ) : kind === "ellipse" ? (
            <Ellipse
              keyboardFocusable
              onPointerDown={select}
              physics={collider2D()}
              style={{
                cx: width / 2,
                cy: height / 2,
                radiusX: width / 2,
                radiusY: height / 2,
                fill: paint.fill,
                stroke: paint.stroke,
                strokeWidth: paint.strokeWidth,
                cursor: paint.cursor,
              }}
            />
          ) : (
            <Polygon
              keyboardFocusable
              onPointerDown={select}
              physics={collider2D()}
              style={{
                coords: [
                  [width / 2, 0],
                  [width, height],
                  [0, height],
                ],
                fill: paint.fill,
                stroke: paint.stroke,
                strokeWidth: paint.strokeWidth,
                cursor: paint.cursor,
              }}
            />
          )}
        </Group>
      </DragHandle>
      {active && <ResizeHandles controller={controller} />}
      {active && (
        <RotationHandle
          controller={controller}
          style={{ arc: true, readout: true }}
        />
      )}
    </ManipulationFrame>
  );
}

function FireAndFlow() {
  const burning = useSignal(true);
  const intensity = useSignal(0.8);
  const origin = useSignal<readonly [number, number]>([360, 370]);
  const drag = useSignal<number | null>(null);
  const fuelGesture = useConst(() => ({ x: 0, y: 0, moved: false }));
  const selected = useSignal<string | null>(null);
  const obstacles = physicsObstacles2D(pibblPhysicsWorld2D());
  const flow = useFlowField2D({
    bounds: { width: 720, height: 480 },
    resolution: [96, 64],
    obstacles,
  });
  const force = useComputed(
    () => [0, -180 * intensity.get()] as const,
  );
  const heat = useComputed(() => 4 * intensity.get());
  useFlowSource2D(flow, {
    position: origin,
    radius: 40,
    acceleration: force,
    heat,
    enabled: burning,
  });
  const effects = useConst(() => {
    const smokeEmitter = (variant: number): PibblParticleEmitter2D => ({
      bounds: { min: [-720, -480], max: [720, 480] },
      name: `smoke${variant}`,
      capacity: 360,
      overflow: "recycle-oldest" as const,
      emission: [{ type: "manual" as const, count: 1 }],
      shape: {
        type: "line" as const,
        from: [-18, -30] as const,
        to: [18, -30] as const,
      },
      initial: {
        lifetimeMilliseconds: particleRange(6500, 9000),
        rotationDegrees: particleRange(0, 360),
      },
      appearance: {
        size: particleCurve([
          [0, 20 + variant * 3],
          [0.25, 44 + variant * 5],
          [1, 85 + variant * 8],
        ]),
        opacity: particleCurve([
          [0, 0],
          [0.08, 0.32],
          [0.6, 0.2],
          [1, 0],
        ]),
      },
      renderer: { type: "sprite" as const, source: smokeSprite(variant) },
    });
    return {
      smoke: defineParticleEffect2D({
        emitters: [smokeEmitter(0), smokeEmitter(1), smokeEmitter(2)],
      }),
      fire: defineParticleEffect2D({
        emitters: [
          {
            name: "embers",
            capacity: 80,
            overflow: "recycle-oldest",
            emission: [{ type: "manual", count: 1 }],
            shape: { type: "line", from: [-22, -10], to: [22, -10] },
            initial: {
              lifetimeMilliseconds: particleRange(1300, 2600),
              velocity: [particleRange(-30, 30), particleRange(-160, -90)],
              color: "#ffb85c",
              size: [2, 4],
            },
            motion: [{ type: "sway", amplitude: 8, frequency: [0.3, 0.8] }],
            appearance: {
              opacity: particleCurve([
                [0, 0],
                [0.1, 1],
                [0.55, 0.6],
                [1, 0],
              ]),
            },
            renderer: { type: "rectangle", blend: "additive" },
          },
        ],
      }),
    };
  });
  const smoke = useParticleSystem(effects.smoke, { flow, seed: 42 });
  const fire = useParticleSystem(effects.fire, { seed: 74 });
  const tick = useConst(() => ({ value: 0 }));
  const flameSurface = useConst(createFlameSurface);
  const flameTime = useSignal(0);
  usePlayback(clockProgram, {
    autoplay: true,
    onEvent: (event) => {
      if (event.type !== "marker") return;
      if (!burning.get()) return;
      const position = origin.get();
      flameTime.update((time) => time + 0.04);
      tick.value++;
      if (tick.value % 4 === 0) fire.emit("embers", { position });
      smoke.emit(`smoke${tick.value % 3}`, {
          position,
          count: Math.ceil(3 * intensity.get()),
        });
    },
  });
  const [x, y] = origin.get();
  return (
    <Group
      onKeyDown={(event) => {
        if (event.key === "Escape") selected.set(null);
      }}
    >
      <Rectangle
        onPointerDown={() => selected.set(null)}
        style={{ width: 720, height: 480, fill: "#111820" }}
      />
      <Rectangle
        pointerEvents="none"
        style={{
          left: 24,
          top: 80,
          width: 672,
          height: 360,
          fill: "#18232d",
          stroke: "#2d3b47",
        }}
      />
      <Text
        pointerEvents="none"
        style={{
          left: 32,
          top: 34,
          fill: "#f4e9d5",
          font: "700 24px sans-serif",
        }}
      >
        FIRE & FLOW
      </Text>
      <Text
        pointerEvents="none"
        style={{ left: 32, top: 59, fill: "#9eafbd", font: "13px sans-serif" }}
      >
        Drag a shape. Select to resize or rotate. Click empty space to deselect.
      </Text>
      <Rectangle
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId);
          drag.set(e.pointerId);
          fuelGesture.x = e.x;
          fuelGesture.y = e.y;
          fuelGesture.moved = false;
        }}
        onPointerMove={(e) => {
          if (drag.get() === e.pointerId) {
            fuelGesture.moved ||=
              Math.hypot(e.x - fuelGesture.x, e.y - fuelGesture.y) > 3;
            if (fuelGesture.moved)
              origin.set([
                Math.max(90, Math.min(630, e.x)),
                Math.max(320, Math.min(395, e.y)),
              ]);
          }
        }}
        onPointerUp={(e) => {
          if (drag.get() === e.pointerId) {
            e.currentTarget.releasePointerCapture(e.pointerId);
            drag.set(null);
          }
        }}
        onPointerCancel={() => drag.set(null)}
        onClick={() => {
          if (!fuelGesture.moved) burning.update((v) => !v);
        }}
        style={{
          left: x - 40,
          top: y,
          width: 80,
          height: 54,
          fill: burning.get() ? "#954526" : "#4d5660",
          stroke: "#c78958",
          cursor: drag.get() === null ? "grab" : "grabbing",
        }}
      />
      <Particles2D
        system={smoke}
        pointerEvents="none"
        style={{ width: 720, height: 480 }}
      />
      {burning.get() && (
        <Flame
          surface={flameSurface}
          time={flameTime.get()}
          intensity={intensity.get()}
          x={x}
          y={y}
          pointerEvents="none"
        />
      )}
      <Particles2D
        system={fire}
        pointerEvents="none"
        style={{ width: 720, height: 480 }}
      />
      <Rectangle
        pointerEvents="none"
        style={{
          left: x - 45,
          top: y + 39,
          width: 90,
          height: 15,
          fill: "#39434b",
          stroke: "#76808a",
        }}
      />
      <Obstacle
        id="rectangle"
        kind="rectangle"
        initial={{ left: 285, top: 245, width: 100, height: 55 }}
        selected={selected}
      />
      <Obstacle
        id="ellipse"
        kind="ellipse"
        initial={{ left: 385, top: 145, width: 125, height: 65 }}
        selected={selected}
      />
      <Obstacle
        id="triangle"
        kind="triangle"
        initial={{ left: 190, top: 125, width: 90, height: 85 }}
        selected={selected}
      />
      <Rectangle
        onClick={() => intensity.update((v) => (v >= 1.2 ? 0.4 : v + 0.4))}
        style={{
          left: 32,
          top: 447,
          width: 180,
          height: 26,
          fill: "#2d3d49",
          cursor: "pointer",
        }}
      />
      <Text
        pointerEvents="none"
        style={{ left: 44, top: 465, fill: "#d5e1e8", font: "12px sans-serif" }}
      >
        Intensity {Math.round(intensity.get() * 100)}%
      </Text>
      <Rectangle
        onClick={() => selected.set(null)}
        style={{
          left: 226,
          top: 447,
          width: 462,
          height: 26,
          fill: "#2d3d49",
          cursor: "pointer",
        }}
      />
      <Text
        pointerEvents="none"
        style={{
          left: 239,
          top: 465,
          fill: "#d5e1e8",
          font: "12px sans-serif",
        }}
      >
        {selected.get() ? "Deselect shape" : "Select a shape"}
      </Text>
    </Group>
  );
}
export default function mount(canvas: HTMLCanvasElement): PibblController {
  return pibbl(
    canvas,
    <PhysicsWorld2D space="local" gravity={[0, 0]}>
      <FireAndFlow />
    </PhysicsWorld2D>,
    { pixelRatio: 1 },
  );
}

```

## Documentation version

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.
