Agent guide - Canvas particle effects
Scope and reading path
Section titled “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, Particles2D, and particle types before changing an effect.
Definition and ownership
Section titled “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
Section titled “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
Section titled “Complete example and adaptation”The source below is the existing Particle Effects lesson. Open it in the editor. 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
Section titled “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 for its stateful limits. Report whether the test covers a simple burst or a flow-coupled system.
Complete source
Section titled “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
Section titled “particle-effects.example.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
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.