Skip to content

Particles

The @yagejs/particles package provides a lightweight particle system with pooled rendering, configurable emitters, and built-in presets for common effects.

import { ParticlesPlugin } from "@yagejs/particles";
engine.use(new ParticlesPlugin());

The plugin depends on @yagejs/renderer.

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 emission
PropertyTypeDefaultDescription
textureTextureInput—Particle texture, texture handle, or asset key
shapeParticleShape | ShapeConfig"pixel"Built-in shape, optionally sized
maxParticlesnumber100Maximum live particles
ratenumber10Emission rate (particles/sec)
lifetimeNumberRange—Particle lifetime in seconds (required)
speedNumberRange0Initial speed (px/s)
angleNumberRange0Emission angle (radians)
scaleNumberRange | Lerped1Size, or start→end interpolation
alphaNumberRange | Lerped1Opacity, or start→end interpolation
alphaFadeInnumber0Fraction of each particle’s lifetime spent fading in (0–1)
alphaFadeOutnumber0Fraction of each particle’s lifetime spent fading out (0–1)
rotationNumberRange0Initial rotation (radians)
rotationSpeedNumberRange0Rotation speed (rad/s)
tintnumber0xffffffColor tint
blendModeBlendModethe layer’s modeHow the particles combine with what is beneath
gravity{ x, y }—Per-particle gravity (px/s²)
dampingnumber0Velocity damping (0–1)
spawnOffset{ x?, y? } or { radius, angle? }—Random offset from the emitter, in a rectangle or on a ring
radialSpeedNumberRange—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
layerstring"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.

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.

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 accessor

The 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.

Six shapes ship with the package, so effects work before you have any art:

ShapeLooks likeDefault size
"pixel"A white rectangle1×1
"circle"A solid disc, an ellipse on a non-square size64×64
"softCircle"A disc fading to transparent at the edge — smoke, glows, embers64×64
"diamond"A solid diamond64×64
"softDiamond"A diamond fading to transparent, which reads as a four-point sparkle64×64
"line"A filled streak — rain, speed trails64×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.

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:

  • size sets the shape’s own dimensions. Keep to a few fixed values — every distinct size generates and caches its own texture.
  • scale sizes 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.

emitter.emit(); // start continuous emission
emitter.stop(); // stop emission started by emit()
const request = emitter.requestEmission(); // independent temporary request
request.release(); // release only this request
emitter.burst(50); // spawn 50 at the entity's world position
emitter.burst(10, 400, 300); // burst at an explicit world position
emitter.burst(10, { tint: 0xff0000 }); // these 10 particles only
emitter.burst(10, 400, 300, { tint: 0xff0000 }); // position and overrides together
emitter.configure({ rate: 40 }); // change the emitter from now on
emitter.isEmitting; // boolean
emitter.activeCount; // number of live particles
emitter.blendMode = "add"; // read/write

emit() 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.

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:

OptionsWhen a change takes effect
lifetime, speed, angle, scale, alpha, rotation, rotationSpeed, tint, spawnOffset, radialSpeedFor particles spawned afterwards. Particles already alive keep the values they were spawned with.
gravity, damping, alphaFadeIn, alphaFadeOutFor every live particle, on the next frame.
rateFor continuous emission, on the next frame.
blendModeFor 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.

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, shrinking
entity.add(new ParticleEmitterComponent(ParticlePresets.fire()));
// Smoke — slow, expanding, fading
entity.add(new ParticleEmitterComponent(ParticlePresets.smoke()));
// Sparks — fast, short-lived, gravity-affected
entity.add(new ParticleEmitterComponent(ParticlePresets.sparks()));
// Rain — downward, uniform, long-lived
entity.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.

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 TS2379
new ParticleEmitterComponent({ ...ParticlePresets.fire(), texture: Particle });
// Pass it as the argument instead
new ParticleEmitterComponent({ ...ParticlePresets.fire(Particle), rate: 50 });

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.

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 rotationSpeed turns 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 ySort layer 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 maxParticles reasonable. 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.