Particles
The @yagejs/particles package provides a lightweight particle system with
pooled rendering, configurable emitters, and built-in presets for common effects.
ParticlesPlugin Setup
Section titled “ParticlesPlugin Setup”import { ParticlesPlugin } from "@yagejs/particles";
engine.use(new ParticlesPlugin());The plugin depends on @yagejs/renderer.
Creating an Emitter
Section titled “Creating an Emitter”Add a ParticleEmitterComponent to an entity that has a Transform. Particles
spawn centred on the entity’s world position, so a child entity emits where it
is drawn.
The Transform is required, not optional: the system that drives emitters looks
for entities that have both, so an emitter on its own never emits and never ages
the particles a burst already spawned. The first emit() or burst() on an
entity with no Transform logs a warning to the console.
The smallest emitter needs only a lifetime — with no texture, particles are
white squares you can color with tint. The default shape is a single pixel,
so set scale to the size you want:
import { ParticleEmitterComponent } from "@yagejs/particles";
const sparkle = entity.add(new ParticleEmitterComponent({ lifetime: [0.3, 0.6], speed: [60, 120], angle: [0, Math.PI * 2], scale: [2, 4], tint: 0xffdd55,}));
sparkle.burst(20);For an image asset, pass texture — a texture, a texture handle, or an
asset key string:
import { texture } from "@yagejs/renderer";import { ParticleEmitterComponent } from "@yagejs/particles";
const Particle = texture("assets/particle.png");
const emitter = new ParticleEmitterComponent({ texture: Particle, maxParticles: 200, rate: 20, // particles per second lifetime: [0.5, 1.5], // seconds (random range) speed: [50, 150], // px/s angle: [-Math.PI, Math.PI], // emission direction (radians) scale: { start: 1, end: 0 }, alpha: { start: 1, end: 0 }, tint: 0xff6600, gravity: { x: 0, y: 200 }, layer: "effects",});
entity.add(emitter);emitter.emit(); // start continuous emissionEmitter Config
Section titled “Emitter Config”| Property | Type | Default | Description |
|---|---|---|---|
texture | TextureInput | — | Particle texture, texture handle, or asset key |
shape | ParticleShape | ShapeConfig | "pixel" | Built-in shape, optionally sized |
maxParticles | number | 100 | Maximum live particles |
rate | number | 10 | Emission rate (particles/sec) |
lifetime | NumberRange | — | Particle lifetime in seconds (required) |
speed | NumberRange | 0 | Initial speed (px/s) |
angle | NumberRange | 0 | Emission angle (radians) |
scale | NumberRange | Lerped | 1 | Size, or start→end interpolation |
alpha | NumberRange | Lerped | 1 | Opacity, or start→end interpolation |
alphaFadeIn | number | 0 | Fraction of each particle’s lifetime spent fading in (0–1) |
alphaFadeOut | number | 0 | Fraction of each particle’s lifetime spent fading out (0–1) |
rotation | NumberRange | 0 | Initial rotation (radians) |
rotationSpeed | NumberRange | 0 | Rotation speed (rad/s) |
tint | number | 0xffffff | Color tint |
blendMode | BlendMode | the layer’s mode | How the particles combine with what is beneath |
gravity | { x, y } | — | Per-particle gravity (px/s²) |
damping | number | 0 | Velocity damping (0–1) |
spawnOffset | { x?, y? } or { radius, angle? } | — | Random offset from the emitter, in a rectangle or on a ring |
radialSpeed | NumberRange | — | Speed along the spawn offset (px/s); negative moves inward |
simulationSpace | "world" | "local" | "world" | Whether particles stay where they were spawned or follow the emitter |
layer | string | "default" | Render layer name |
Every number in the config is checked when the emitter is constructed. A value
that cannot be simulated — a non-finite number, a damping outside 0–1, a
lifetime of 0, a fractional maxParticles — throws an error naming the
option and the value, instead of turning particle positions into NaN later.
NumberRange can be a single number or a [min, max] tuple for random values.
Lerped interpolates from start to end over the particle’s lifetime:
import type { EmitterOptions } from "@yagejs/particles";
const fade: Pick<EmitterOptions, "scale" | "alpha"> = { scale: { start: [0.8, 1.2], end: 0 }, // shrink to nothing alpha: { start: 1, end: [0, 0.3] }, // fade out with variation};The two texture options, texture and shape, are mutually exclusive: setting
both is a type error. An emitter that sets neither uses the "pixel" shape.
Fading In and Out
Section titled “Fading In and Out”alphaFadeIn and alphaFadeOut fade each particle in at the start of its life
and out at the end. Each is a fraction of that particle’s own lifetime, from 0
to 1, and a value outside that range throws. With alphaFadeIn: 0.25, a
particle that lives 2 seconds fades in over its first half second. Both default
to 0, which means no fade.
import { ParticleEmitterComponent } from "@yagejs/particles";
const dust = new ParticleEmitterComponent({ shape: { type: "softCircle", size: 8 }, lifetime: [1, 2], alpha: 0.6, alphaFadeIn: 0.15, alphaFadeOut: 0.4,});A fade-in starts each particle fully transparent, so it appears gradually. The
fades multiply whatever alpha produces instead of replacing it: each particle
in the example above fades in to 0.6, stays there, and then fades out.
If alpha is a start-to-end ramp that fades the particle as well, the particle
fades twice, once from each option. With alpha: { start: 0, end: 1 } and
alphaFadeIn: 0.5, a particle a quarter of the way through its life has an
opacity of 0.125. The ramp alone would give it 0.25, and the fade alone 0.5. For
a single fade, use either a constant alpha with the fade options or an alpha
ramp on its own.
When alphaFadeIn and alphaFadeOut add up to more than 1, the fade-in and the
fade-out overlap and multiply, so a particle never reaches the opacity alpha
gives it. That combination is allowed and does not throw.
Blend Mode
Section titled “Blend Mode”blendMode decides how the particles combine with what is already drawn
beneath them. Fire, sparks, and magic usually want "add", which brightens
the background instead of covering it:
import { ParticleEmitterComponent, ParticlePresets } from "@yagejs/particles";
const emitter = new ParticleEmitterComponent({ ...ParticlePresets.fire(), blendMode: "add",});
emitter.blendMode = "normal"; // also a live accessorThe mode applies to the emitter as a whole, so every one of its particles
blends the same way. "add", "multiply", "screen" and the other GPU-native
modes work with no setup; the photoshop-style ones need a side-effect import.
See Blend modes for the full list.
Built-in Shapes
Section titled “Built-in Shapes”Six shapes ship with the package, so effects work before you have any art:
| Shape | Looks like | Default size |
|---|---|---|
"pixel" | A white rectangle | 1×1 |
"circle" | A solid disc, an ellipse on a non-square size | 64×64 |
"softCircle" | A disc fading to transparent at the edge — smoke, glows, embers | 64×64 |
"diamond" | A solid diamond | 64×64 |
"softDiamond" | A diamond fading to transparent, which reads as a four-point sparkle | 64×64 |
"line" | A filled streak — rain, speed trails | 64×8 |
import { ParticleEmitterComponent } from "@yagejs/particles";
entity.add(new ParticleEmitterComponent({ shape: "softCircle", lifetime: [0.6, 1.2], speed: [30, 60], angle: [-Math.PI / 2 - 0.3, -Math.PI / 2 + 0.3], scale: { start: 0.5, end: 1.2 }, alpha: { start: 0.5, end: 0 }, tint: 0x888888, // shapes are white; tint gives them their color}));Every shape is white so tint fully controls the color. The first emitter to
ask for a shape at a given size builds it; the rest share that one texture.
Sizing a Shape
Section titled “Sizing a Shape”Pass an object instead of the shape name to choose the texture size — one
number for a square, or [width, height]:
import type { TextureSource } from "@yagejs/particles";
const sources: TextureSource[] = [ { shape: "softCircle" }, // 64×64 { shape: { type: "softCircle", size: 16 } }, // 16×16 texture { shape: { type: "circle", size: [32, 16] } }, // an ellipse { shape: { type: "line", size: [4, 32] } }, // a vertical streak, for rain];size is the generated texture’s size in pixels, which at the default
scale: 1 is also the size a particle covers on screen. The two options do
different jobs:
sizesets the shape’s own dimensions. Keep to a few fixed values — every distinct size generates and caches its own texture.scalesizes each particle relative to that and animates over its lifetime, and generates nothing. Vary particle size here.
A size has to be a finite number above 0; anything else throws instead of producing an empty texture.
No shape forces an aspect ratio: a "circle" in a [32, 16] texture is an
ellipse that fills it. "line" is horizontal at its default 64×8, so for
falling rain either size it vertically ([4, 32]) or set rotation to aim it
along the direction of travel — diagonal rain needs rotation either way.
Every shape stays visible at every size, down to 1×1. "pixel" and "line"
fill their texture edge to edge. The other four draw an outline inside the
texture with a one-pixel antialiased edge, and fill their texture instead once
they get too thin to hold one — at 3 pixels or less on either axis, a 1-pixel
border would be the whole shape.
Generated shapes are cached by type and size, so emitters using the same shape configuration share one texture.
Runtime Control
Section titled “Runtime Control”emitter.emit(); // start continuous emissionemitter.stop(); // stop emission started by emit()const request = emitter.requestEmission(); // independent temporary requestrequest.release(); // release only this requestemitter.burst(50); // spawn 50 at the entity's world positionemitter.burst(10, 400, 300); // burst at an explicit world positionemitter.burst(10, { tint: 0xff0000 }); // these 10 particles onlyemitter.burst(10, 400, 300, { tint: 0xff0000 }); // position and overrides togetheremitter.configure({ rate: 40 }); // change the emitter from now on
emitter.isEmitting; // booleanemitter.activeCount; // number of live particlesemitter.blendMode = "add"; // read/writeemit() and stop() control the emitter’s manual state. Temporary systems
can call requestEmission() instead. Emission continues while manual state or
at least one request is active. Releasing one request cannot stop another
request or a later emit() call. Existing particles always continue to their
end of life.
Changing an Emitter
Section titled “Changing an Emitter”configure changes an emitter from now on. Overrides passed to burst change
only the particles of that one burst, and leave the emitter’s configuration and
every live particle as they were.
import type { ParticleEmitterComponent } from "@yagejs/particles";
declare const torch: ParticleEmitterComponent;declare const sparks: ParticleEmitterComponent;
// From now on the torch emits more particles, tinted blue.torch.configure({ rate: 40, tint: 0x3399ff });
// Each hit sends its sparks along the swing, without changing the emitter.function onHit(hitX: number, hitY: number, swing: number) { sparks.burst(8, hitX, hitY, { angle: [swing - 0.2, swing + 0.2] });}When a configure change takes effect depends on the option:
| Options | When a change takes effect |
|---|---|
lifetime, speed, angle, scale, alpha, rotation, rotationSpeed, tint, spawnOffset, radialSpeed | For particles spawned afterwards. Particles already alive keep the values they were spawned with. |
gravity, damping, alphaFadeIn, alphaFadeOut | For every live particle, on the next frame. |
rate | For continuous emission, on the next frame. |
blendMode | For every particle, including those already alive, because the whole emitter shares one blend mode. |
Burst overrides accept only the options in the first row, which are read once
as each particle spawns. gravity, damping, alphaFadeIn and alphaFadeOut
apply to every live particle on every frame, so only configure changes them.
maxParticles, texture, shape, layer and simulationSpace are fixed
when the emitter is built. Neither method accepts them, and naming one in the
options is a type error.
TypeScript reports an option a method does not accept only when the options are
written as an object literal at the call site. A spread, or a variable typed as
the full EmitterConfig, compiles and passes the option anyway. The method
ignores that option. For example, configure({ ...ParticlePresets.fire() })
applies everything in the preset except its maxParticles and shape.
An option passed as undefined leaves that setting unchanged.
Both methods check the emitter’s configuration combined with the new values,
the same way the constructor does, and throw on a bad value. A configure call
that throws changes nothing, and a burst call that throws spawns nothing.
Because the check covers the combined configuration,
configure({ radialSpeed: 50 }) works on an emitter that already has a
spawnOffset.
Like the constructor, configure copies what it is given, so changing that
object afterwards has no effect.
Presets
Section titled “Presets”ParticlePresets provides ready-made configs for common effects. Each preset
returns a complete EmitterConfig and works with no arguments at all:
import { ParticleEmitterComponent, ParticlePresets } from "@yagejs/particles";
// Fire — upward, warm colors, shrinkingentity.add(new ParticleEmitterComponent(ParticlePresets.fire()));
// Smoke — slow, expanding, fadingentity.add(new ParticleEmitterComponent(ParticlePresets.smoke()));
// Sparks — fast, short-lived, gravity-affectedentity.add(new ParticleEmitterComponent(ParticlePresets.sparks()));
// Rain — downward, uniform, long-livedentity.add(new ParticleEmitterComponent(ParticlePresets.rain()));Each one falls back to a built-in shape sized for the effect: a 32px soft circle for fire, a 40px one for smoke, a 10×3 streak for sparks, a 2×20 one for rain.
Pass your own art as the argument — a texture, a texture handle, or an asset key:
import { ParticlePresets } from "@yagejs/particles";
entity.add(new ParticleEmitterComponent(ParticlePresets.fire(Particle)));entity.add(new ParticleEmitterComponent(ParticlePresets.fire("assets/flame.png")));The presets keep on-screen size in the shape’s size and use scale only for
lifetime animation and per-particle variation, centred on 1. That is why the
same preset reads correctly both ways: with your texture the effect animates it
at its natural size instead of scaling it against a size the preset guessed.
Overriding a Preset
Section titled “Overriding a Preset”Spread a preset to override anything except where its particles get their look:
import { ParticleEmitterComponent, ParticlePresets } from "@yagejs/particles";
entity.add(new ParticleEmitterComponent({ ...ParticlePresets.fire(), rate: 50, tint: 0x00ccff, // blue fire}));A preset already sets a texture source, and the two sources are mutually exclusive, so adding another by spreading does not compile:
// Type error — the preset's shape and your texture are both sources// yage-expect-error TS2379new ParticleEmitterComponent({ ...ParticlePresets.fire(), texture: Particle });
// Pass it as the argument insteadnew ParticleEmitterComponent({ ...ParticlePresets.fire(Particle), rate: 50 });Converging and exploding rings
Section titled “Converging and exploding rings”spawnOffset also takes a ring — radius, plus an optional angle arc that
defaults to the full circle. radialSpeed then moves each particle along the
direction from the emitter to where it spawned: negative converges on the
emitter, positive explodes outward.
import { ParticleEmitterComponent } from "@yagejs/particles";
// Sparks that rush inward to the caster while a spell charges.const charge = new ParticleEmitterComponent({ shape: { type: "line", size: [6, 2] }, rate: 40, lifetime: 0.38, spawnOffset: { radius: 42 }, radialSpeed: -110, tint: 0x66ddff,});radialSpeed adds to the velocity speed and angle produce, so an outward
rush plus a little random drift is just both together.
Following the emitter
Section titled “Following the emitter”By default particles stay where they were spawned, which is what a torch trail
or an explosion needs. simulationSpace: "local" makes them follow the emitter
instead — for an aura, a shield, or a charge-up glow that has to stay centred on
a moving character. It follows position only; the emitter’s rotation and scale
are not applied.
import { ParticleEmitterComponent, ParticlePresets } from "@yagejs/particles";
const aura = new ParticleEmitterComponent({ ...ParticlePresets.fire(), simulationSpace: "local",});- Position the emitter entity where you want particles to spawn — particles take the entity’s world position at the moment they’re created, including when the entity is a child of another one.
- A particle is centred on its spawn point, and
rotationSpeedturns it about its own centre. That holds for your own textures too, so a 64px sprite covers 32px either side of the emitter rather than hanging down and to the right. - Emitters depth-sort like any other visual: on a
ySortlayer an emitter keys off its entity’s position, so a torch’s sparks interleave with the characters walking past it. - Destroying the emitter entity stops emission and cleans up all particles.
- Keep
maxParticlesreasonable. Hundreds are fine; thousands may impact performance on lower-end devices. - Use render layers to put particles above or below whole groups of entities.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/particles.md.