# Agent guide - Canvas particle effects

## Scope and reading path

Import particle APIs from `@pibbl/core/particles`. This is a Canvas 2D feature, not the removed renderer-coupled 3D particle system. Use ordinary Three particle techniques inside a Three layer for a 3D scene. Read [the particle guide](/guides/particle-effects/), [Particles2D](/reference/components/particles-2d/), and [particle types](/reference/types/particles/) before changing an effect.

## Definition and ownership

`defineParticleEffect2D` describes an immutable population. `useParticleSystem` owns its mounted system; `Particles2D` binds it into Pibbl composition. Descriptor functions such as `particleRange`, `particleChoice`, and `particleCurve` describe deterministic channels rather than sampling immediately. Configure a seed when comparing runs.

Capacity is allocated up front in fixed typed arrays. Choose a realistic capacity and an explicit overflow policy. Conservative bounds must include shape, size, speed, acceleration, and lifetime so culling does not cut the effect short. Increasing capacity is not a substitute for deciding how many particles the interaction needs.

Ordinary particles calculate motion analytically at logical age. Component transforms and application values still belong to Pibbl animation/signals. Do not use particle motion as a replacement for animating a UI property, or start a separate host frame loop.

## Shared flow and physics boundaries

For stateful transport, one `useFlowField2D` field can serve multiple particle systems. Add sources through `useFlowSource2D` and pass the field to the system. Flow-bound particles use the field for translation: put wind/upward force on the source rather than also supplying competing translational particle velocity or motion modules.

`physicsObstacles2D` connects committed numerical obstacle information to flow. It does not create shared WASM memory or reciprocal smoke forces on rigid bodies. Both systems must use the same coordinates. Soft sprites may extend visually beyond obstacle boundaries even when particle centers do not cross them; this is not volumetric lighting or depth-aware clipping.

## Complete example and adaptation

The source below is the existing Particle Effects lesson. [Open it in the editor](/playground/#/examples/composition/particle-effects). Host setup and full source follow. The example uses procedural marks and public imports; no private helper is required.

For a native success button, keep confirmation state and activation in HTML, then trigger a bounded burst only after the real operation succeeds. Decorative particles should opt out of input targeting. Respect reduced motion by choosing a static confirmation or omitting the burst. Do not claim a named application helper such as `useFireAndSmoke` is a Pibbl export.

## Verification

Hold seed and clock constant and assert emitted counts, capacity behavior, and lifetime completion before comparing pixels. Exercise overflow, repeated activation, pause/resume if used, and removal during an active burst. Verify the owning root releases the system and its flow sources.

Test bounds and coordinate mapping under Group transforms and layout changes. A particle at a pointer should start at the intended logical point, not CSS coordinates accidentally interpreted as local units. For flow, test replay/timing separately from ordinary analytical motion; read [useFlowField2D](/reference/hooks/use-flow-field/) for its stateful limits. Report whether the test covers a simple burst or a flow-coupled system.

## Complete source

Host setup for this source: use a canvas with width 720 and height 420, 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.

### particle-effects.example.tsx

```tsx
import {
  Group,
  Rectangle,
  Text,
  pibbl,
  marker,
  repeat,
  sequence,
  usePlayback,
  useSignal,
  wait,
  type PibblController,
} from '@pibbl/core';
import {
  Particles2D,
  defineParticleEffect2D,
  particleChoice,
  particleCurve,
  particleRange,
  useParticleSystem,
} from '@pibbl/core/particles';

const burningSquareEffect = defineParticleEffect2D({
  debugName: 'learning burning square',
  emitters: [
    {
      name: 'flame',
      capacity: 180,
      overflow: 'recycle-oldest',
      emission: [{ type: 'manual', count: 5 }],
      shape: { type: 'line', from: [-34, 0], to: [34, 0] },
      initial: {
        lifetimeMilliseconds: particleRange(420, 820),
        velocity: [particleRange(-28, 28), particleRange(-145, -82)],
        color: particleChoice([
          { value: '#fff3a3', weight: 1 },
          { value: '#fbbf24', weight: 3 },
          { value: '#f97316', weight: 3 },
          { value: '#dc2626', weight: 1 },
        ]),
      },
      motion: [
        { type: 'sway', amplitude: 13, frequency: [1.1, 2.1] },
        { type: 'drag', amount: 0.45 },
      ],
      appearance: {
        size: particleCurve([
          [0, 5],
          [0.3, 17],
          [1, 2],
        ]),
        opacity: particleCurve([
          [0, 0],
          [0.08, 1],
          [0.76, 0.9],
          [1, 0],
        ]),
      },
      renderer: { type: 'circle', blend: 'additive' },
      bounds: { min: [-80, -150], max: [80, 24] },
    },
    {
      name: 'smoke',
      capacity: 100,
      overflow: 'recycle-oldest',
      emission: [{ type: 'manual', count: 2 }],
      shape: { type: 'line', from: [-24, -2], to: [24, -2] },
      initial: {
        lifetimeMilliseconds: particleRange(1_500, 2_700),
        velocity: [particleRange(-16, 16), particleRange(-62, -34)],
        color: particleChoice([
          { value: '#334155', weight: 2 },
          { value: '#475569', weight: 3 },
          { value: '#64748b', weight: 1 },
        ]),
      },
      motion: [
        { type: 'sway', amplitude: 25, frequency: [0.28, 0.58] },
        { type: 'drag', amount: 0.16 },
      ],
      appearance: {
        size: particleCurve([
          [0, 9],
          [0.35, 22],
          [1, 42],
        ]),
        opacity: particleCurve([
          [0, 0],
          [0.16, 0.52],
          [0.72, 0.3],
          [1, 0],
        ]),
      },
      renderer: { type: 'circle', blend: 'source-over' },
      bounds: { min: [-120, -190], max: [120, 32] },
    },
  ],
});

const emissionClock = repeat(
  sequence(
    wait(40),
    marker('flame'),
    wait(80),
    marker('flame'),
    wait(40),
    marker('smoke'),
  ),
  { iterations: Number.POSITIVE_INFINITY },
);

interface Point {
  readonly x: number;
  readonly y: number;
}

interface DragState {
  readonly pointerId: number;
  readonly offsetX: number;
  readonly offsetY: number;
}

const SQUARE_SIZE = 88;

function clamp(value: number, minimum: number, maximum: number): number {
  return Math.min(maximum, Math.max(minimum, value));
}

function ParticleEffectsStudy() {
  const position = useSignal<Point>({ x: 360, y: 300 });
  const drag = useSignal<DragState | null>(null);
  const fire = useParticleSystem(burningSquareEffect, {
    autoplay: false,
    seed: 42,
    debugName: 'learning burning square particle system',
  });

  usePlayback(emissionClock, {
    autoplay: true,
    debugName: 'learning burning square emission clock',
    onEvent: (event) => {
      if (event.type !== 'marker') return;
      const current = position.get();
      const emitterPosition = [
        current.x,
        current.y - SQUARE_SIZE / 2,
      ] as const;
      if (event.marker === 'flame') {
        fire.emit('flame', { position: emitterPosition, count: 5 });
      } else if (event.marker === 'smoke') {
        fire.emit('smoke', { position: emitterPosition, count: 2 });
      }
    },
  });

  const current = position.get();
  const isDragging = drag.get() !== null;

  return (
    <Group>
      <Rectangle style={{ width: 720, height: 420, fill: '#090b10' }} />
      <Rectangle
        pointerEvents="none"
        style={{ left: 28, top: 88, width: 664, height: 284, fill: '#111827' }}
      />
      <Particles2D
        system={fire}
        pointerEvents="none"
        style={{ width: 720, height: 420 }}
      />
      <Rectangle
        style={{
          left: current.x - SQUARE_SIZE / 2,
          top: current.y - SQUARE_SIZE / 2,
          width: SQUARE_SIZE,
          height: SQUARE_SIZE,
          fill: '#481b12',
          stroke: isDragging ? '#fde68a' : '#f97316',
          strokeWidth: isDragging ? 5 : 4,
          cursor: isDragging ? 'grabbing' : 'grab',
        }}
        onPointerDown={(event) => {
          event.currentTarget.setPointerCapture(event.pointerId);
          drag.set({
            pointerId: event.pointerId,
            offsetX: event.x - current.x,
            offsetY: event.y - current.y,
          });
        }}
        onPointerMove={(event) => {
          const active = drag.get();
          if (
            active?.pointerId !== event.pointerId ||
            !event.currentTarget.hasPointerCapture(event.pointerId)
          ) {
            return;
          }
          position.set({
            x: clamp(event.x - active.offsetX, 76, 644),
            y: clamp(event.y - active.offsetY, 184, 324),
          });
        }}
        onPointerUp={(event) => {
          if (drag.get()?.pointerId !== event.pointerId) return;
          event.currentTarget.releasePointerCapture(event.pointerId);
          drag.set(null);
        }}
        onPointerCancel={(event) => {
          if (drag.get()?.pointerId !== event.pointerId) return;
          if (event.currentTarget.hasPointerCapture(event.pointerId)) {
            event.currentTarget.releasePointerCapture(event.pointerId);
          }
          drag.set(null);
        }}
      />
      <Rectangle
        pointerEvents="none"
        style={{
          left: current.x - 30,
          top: current.y - 30,
          width: 60,
          height: 60,
          fill: '#7c2d12',
          stroke: '#fb923c',
          strokeWidth: 2,
        }}
      />
      <Text
        pointerEvents="none"
        style={{
          left: current.x,
          top: current.y + 5,
          fill: '#fed7aa',
          font: '900 12px monospace',
          textAlign: 'center',
        }}
      >
        DRAG
      </Text>
      <Text
        pointerEvents="none"
        style={{
          left: 360,
          top: 38,
          fill: '#f8fafc',
          font: '900 24px sans-serif',
          textAlign: 'center',
        }}
      >
        PARTICLE EFFECTS
      </Text>
      <Text
        pointerEvents="none"
        style={{
          left: 360,
          top: 64,
          fill: '#fb923c',
          font: '700 12px monospace',
          textAlign: 'center',
        }}
      >
        FIRE + SMOKE · WORLD-SPACE BIRTHS
      </Text>
      <Text
        pointerEvents="none"
        style={{
          left: 360,
          top: 398,
          fill: '#cbd5e1',
          font: '600 12px monospace',
          textAlign: 'center',
        }}
      >
        DRAG THE SQUARE · OLD PARTICLES STAY BEHIND
      </Text>
    </Group>
  );
}

export default function mount(canvas: HTMLCanvasElement): PibblController {
  return pibbl(canvas, <ParticleEffectsStudy />, { pixelRatio: 1 });
}

```

## Documentation version

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