Skip to content

Particle effects ​

Build a crackling campfire with rising smoke that drifts in the wind, a burst of sparks when a player stokes the fire (seen by everyone in the level), and a /firework command that launches rockets which burst into color, with no image assets needed.

What you'll learn

The finished files are in docs/examples/particle-effects/:

FileRunsGoes to
npcs/campfire.npc.client.tsevery clientthe Clientside tab of a campfire NPC
npcs/campfire.npc.tsserverthe Serverside tab of the same NPC
weapons/fireworks.client.tsyour clientthe Weapons editor, fireworks entry, Clientside tab

Particles are clientside

The particle engine runs entirely on the client: the server never sees a particle, and each client simulates its own. To make an effect shared, send the event through the server and let every client play it, as the sparks do in step 4. See Images, text & particles.

The model in one minute ​

An emitter belongs to a script image (findimg(id)) and sits at the image's position, in world tiles. Every delaymin–delaymax seconds it spawns a burst of nrofparticles particles, each a copy of emitter.particle (the template). A particle then flies on its own: angle + speed (plus movex/movey), for lifetime seconds. Modifiers change particles over time. Without an image, a particle is a solid 16×16 square, and zoom plus color channels go a long way.

Units: distances are tiles, speeds tiles per second, angles radians with Math.PI / 2 pointing up the screen (0 = right, Math.PI = left).

Step 1: An anchor image ​

Each emitter needs its own image. The image itself should not draw, so:

ts
/** An invisible image at (x, y) whose only job is to carry an emitter. */
function anchor(id: number, x: number, y: number): ParticleEmitter {
    const img = findimg(id)
    img.x = x
    img.y = y
    img.visible = false      // an empty image would draw a white 16x16 square
    return img.emitter
}

Hide your anchors

A script image with no image, text or polygon draws a solid white 16×16 rectangle as soon as it has a position. Setting visible = false hides the image but not its particles.

The campfire NPC's clientside onCreated builds three emitters, one per image id, around the middle of its 2×2-tile shape:

ts
function onCreated(this: NpcThis) {
    const cx = this.x + 1
    const cy = this.y + 1.4
    buildFire(cx, cy)
    buildSmoke(cx, cy)
    buildSparks(cx, cy)
}

Image ids are per script, and every campfire NPC runs its own copy of the script, so two campfires don't fight over findimg(1).

Step 2: Fire ​

ts
function buildFire(cx: number, cy: number) {
    const fire = anchor(1, cx, cy)
    fire.delaymin = 0.03              // a burst every 30-60 ms...
    fire.delaymax = 0.06
    fire.nrofparticles = 2            // ...of two particles
    fire.maxparticles = 80

    // Template for every new particle (tiles, tiles/s, radians).
    const p = fire.particle
    p.lifetime = 0.8
    p.angle = Math.PI / 2             // pi/2 = up the screen
    p.speed = 1.6
    p.zoom = 0.45                     // no image: a 16px square, scaled
    p.red = 1
    p.green = 0.55
    p.blue = 0.1
    p.mode = 0                        // additive: overlaps brighten like flame

    // Randomize each particle once, right as it spawns (particle age 0).
    fire.addlocalmodifier('once', 0, 0, 'angle', 'replace', Math.PI / 2 - 0.3, Math.PI / 2 + 0.3)
        .addmod('x', 'add', -0.35, 0.35)
        .addmod('speed', 'replace', 1.2, 2.2)

    // Over its 0.8 s life: yellow-orange -> deep red, fading out, shrinking.
    fire.addlocalmodifier('range', 0, 0.8, 'green', 'replace', 0.55, 0.05)
        .addmod('alpha', 'replace', 1, 0)
        .addmod('zoom', 'add', -0.3, -0.3)   // range + add = a rate per second

    // Flicker: every 80-200 ms, re-roll the size of the particles still to come.
    fire.addemitmodifier('impulse', 0.08, 0.2, 'zoom', 'replace', 0.35, 0.6)
}

Reading it top to bottom:

  • Rate. A burst of 2 every 30–60 ms is roughly 45 particles per second. With a 0.8 s lifetime about 36 are alive at once, safely under maxparticles = 80 (bursts are skipped at the cap, never queued).
  • Template. Orange squares moving up at 1.6 tiles/s. mode = 0 is additive blending: where flames overlap they brighten toward yellow-white, which is what makes it read as fire. (1 is normal alpha blending, 2 subtractive.)
  • once at age 0 randomizes each particle as it spawns. One roll per particle per entry, so every flame gets its own angle (±0.3 rad), horizontal offset and speed. x on a live particle is its world position, so 'add' jitters it sideways. .addmod(...) chains more entries onto the same schedule.
  • range over the lifetime. With 'replace', the value is interpolated from valuemin to valuemax across the window: green fades 0.55 → 0.05 (orange to red) and alpha 1 → 0. With 'add', the interpolated value is a rate per second: zoom shrinks by 0.3 per second.
  • addemitmodifier changes the template, not live particles. Every 80–200 ms it re-rolls the zoom of the particles still to come, so the flame size flickers.

Only these variables can be modified: x, y, movex, movey, angle, speed, rotation, spin, stretchx, stretchy, red, green, blue, alpha, zoom. Anything else (like lifetime) throws at the call.

Step 3: Smoke and wind ​

ts
function buildSmoke(cx: number, cy: number) {
    const smoke = anchor(2, cx, cy)
    smoke.emissionoffset = { xd: 0, yd: -0.8 }   // start above the flames
    smoke.delaymin = 0.15
    smoke.delaymax = 0.3

    const p = smoke.particle
    p.lifetime = 3
    p.angle = Math.PI / 2
    p.speed = 0.8
    p.zoom = 0.5
    p.red = 0.35
    p.green = 0.35
    p.blue = 0.38
    p.alpha = 0.5
    p.mode = 1                         // normal blending: smoke darkens

    smoke.addlocalmodifier('once', 0, 0, 'x', 'add', -0.2, 0.2)
        .addmod('spin', 'replace', -1, 1)
    smoke.addlocalmodifier('range', 0, 3, 'zoom', 'add', 0.5, 0.5)   // grows 0.5 -> 2.0
        .addmod('alpha', 'replace', 0.5, 0)

    // Wind: every 1-3 s ONE random gust is applied to all live smoke at once.
    smoke.addglobalmodifier('impulse', 1, 3, 'movex', 'replace', -0.4, 0.8)
}
  • emissionoffset moves the emission point relative to the image (in tiles) without moving the image.
  • Smoke uses normal blending (mode = 1) so it darkens what's behind it, and a random spin so the squares tumble.
  • range + 'add' on zoom at 0.5/s grows each puff from 0.5 to 2.0 over its 3-second life while alpha fades it out.
  • addglobalmodifier hits all live particles at once, on the emitter's clock. Every 1–3 seconds one random movex is rolled and applied to every puff, so the whole column leans together like a gust of wind. A local modifier would roll separately for each particle and look like noise instead.

Step 4: A burst everyone sees ​

Sparks shouldn't fly continuously, only when someone stokes the fire, and everyone nearby should see the same burst.

ts
function buildSparks(cx: number, cy: number) {
    const sparks = anchor(3, cx, cy)
    sparks.emitautomatically = false   // only on emit()
    sparks.nrofparticles = 30          // particles per emit()

    const p = sparks.particle
    p.lifetime = 0.9
    p.zoom = 0.2
    p.red = 1
    p.green = 0.85
    p.blue = 0.4
    p.mode = 0

    // Fan out upward at random speeds, then fall: range + add on movey is
    // a constant downward acceleration (+y is down the screen).
    sparks.addlocalmodifier('once', 0, 0, 'angle', 'replace', 0.4, Math.PI - 0.4)
        .addmod('speed', 'replace', 2, 5)
    sparks.addlocalmodifier('range', 0, 0.9, 'movey', 'add', 8, 8)
    sparks.addlocalmodifier('range', 0.5, 0.9, 'alpha', 'replace', 1, 0)
}

/** Serverside this.level.triggerAction(..., 'sparks') lands here on every client. */
function onActionSparks() {
    findimg(3).emitter.emit()
}
  • emitautomatically = false stops the timed bursts. Each emit() then spawns one burst of nrofparticles (30) immediately.
  • The sparks fan upward (angles 0.4 to π − 0.4) and then fall: range + 'add' on movey at 8/s is a constant downward acceleration, because +y is down the screen.

The burst plays in onActionSparks, which the server triggers. The round trip:

ts
function onKeyPressed(this: NpcThis, key: string) {
    if (key !== 'A' || Math.hypot(player.x - this.x, player.y - this.y) > 3)
        return
    // Ask the server; it relays the burst to everyone in the level.
    triggerAction(this.x + 1, this.y + 1, 'stoke')
}
ts
// Serverside script of the campfire NPC (the "Serverside" tab). It owns the
// hitbox and decides when sparks fly, so every client sees the same bursts.

function onCreated(this: NpcThis) {
    // No image, so give the fire a 2x2-tile hitbox for triggerAction. A shaped
    // NPC also blocks movement by default: nobody walks through the fire.
    this.setShape(0, 0, 32, 32)
    this.lastStoke = 0
}

// A client's triggerAction(x, y, 'stoke') hit our shape.
function onActionStoke(this: NpcThis, player: Player) {
    const now = Date.now()
    if (now - this.lastStoke < 1000)
        return   // one burst per second, however fast people mash the key
    this.lastStoke = now

    // Serverside triggerAction reaches the clientside onActionSparks of
    // every NPC under the point, on every client in the level.
    this.level.triggerAction(this.x + 1, this.y + 1, 'sparks')
}
  1. A player presses A next to the fire. The clientside script calls triggerAction at the fire's center.
  2. The server hit-tests its NPC shapes and calls onActionStoke(player) on the campfire, which rate-limits and then calls this.level.triggerAction(..., 'sparks').
  3. A serverside triggerAction runs the clientside onActionSparks of the NPC under the point, on every client in the level, and each one emits its own burst.

Particle positions are random per client, but the timing and place match for everyone. See Events & triggers for the full routing rules.

Emit on a later frame than you position

The anchor was created and positioned in onCreated, long before emit() runs. Moving an image and calling emit() in the same handler can fire the burst from the image's previous position: image moves are applied at the end of the handler, while emit() is queued right away.

Step 5: Fire-and-forget fireworks ​

A campfire has a fixed home. A firework is a one-off: create an image, launch, clean up. The fireworks weapon handles /firework in chat:

ts
// Each rocket needs its own image id: reusing an id whose particles are
// still flying would wipe them. A rotating pool of 50 is plenty.
const FIRST_ID = 100
const POOL = 50
let next = 0
ts
function launch(x: number, y: number) {
    const img = findimg(FIRST_ID + next)
    next = (next + 1) % POOL
    img.x = x
    img.y = y
    img.visible = false

    const rocket = img.emitter
    // Automatic emission fires its first burst on the next frame; a long
    // delay makes that the ONLY burst.
    rocket.delaymin = 60
    rocket.delaymax = 60
    rocket.nrofparticles = 1
    rocket.particle.lifetime = 1
    rocket.particle.angle = Math.PI / 2
    rocket.particle.speed = 9
    rocket.particle.zoom = 0.3
    rocket.particle.mode = 0
    rocket.addlocalmodifier('range', 0, 1, 'speed', 'replace', 9, 2)   // slows as it climbs

For a single automatic burst, leave emitautomatically on and set a long delay. The first burst fires on the next frame, after the image's position has been applied, and the next would come in a minute. By then the image is long gone. The rocket decelerates from 9 to 2 tiles/s over its one-second life.

ts
// The drop emitter bursts wherever a rocket particle's lifetime runs out.
const burst = rocket.dropemitter
burst.nrofparticles = 60
burst.maxparticles = 200
burst.particle.lifetime = 1.4
burst.particle.zoom = 0.25
burst.particle.mode = 0
// A random color per rocket.
burst.particle.red = 0.5 + Math.random() * 0.5
burst.particle.green = 0.3 + Math.random() * 0.7
burst.particle.blue = 0.3 + Math.random() * 0.7

burst.addlocalmodifier('once', 0, 0, 'angle', 'replace', 0, Math.PI * 2)
    .addmod('speed', 'replace', 2, 6)
burst.addlocalmodifier('range', 0, 1.4, 'movey', 'add', 3, 3)       // gravity
burst.addlocalmodifier('range', 0.8, 1.4, 'alpha', 'replace', 1, 0)

The dropemitter is a sub-emitter that bursts nrofparticles where each parent particle's lifetime runs out. Here that's the rocket's apex. It has its own template and modifiers, and one level of nesting (a drop emitter has no drop emitter of its own). Particles removed by a clipping box don't trigger it, only lifetime expiry does.

ts
// Let the particles outlive the image, then drop the image on a later
// frame: the rocket must have been emitted first, and an emitter that is
// destroyed with no live particles is simply removed.
rocket.continueafterdestroy = true
setTimeout(() => img.destroy(), 200)
  • continueafterdestroy = true lets live particles, including the drop emitter's, finish their lifetimes after the image is destroyed. The emitter stops spawning and its position freezes where the image stood. Without it, destroying the image removes every particle at once.
  • The destroy waits 200 ms so the rocket has been emitted first. An emitter destroyed with no live particles is removed even with the flag.
  • Why a pool of ids? Re-creating an image id while its old particles are still draining starts a fresh emitter and wipes them. Rotating through 50 ids gives each firework time to finish.
  • The ids are only reused by this weapon: image ids are per script, and all of a weapon's images and emitters are swept when it unloads.

Grant the weapon (add "fireworks" to startWeapons, see Weapons) and type /firework.

Sharing fireworks with everyone

This weapon only draws on your own screen. To share it, add a serverside half fireworks.ts whose onActionServerSide(player, 'launch') relays the position to the others. For example, the NPC route from step 4, or a serverr/collection event that every client's fireworks.client.ts reacts to by calling launch(x, y).

Complete files ​

Every file from this tutorial, in full, for copying into GRC.

npcs/campfire.npc.client.ts
ts
// Clientside script of a campfire NPC (paste into the NPC's "Clientside" tab
// in GRC's level editor). Three emitters, each on its own script image:
//   1 fire   — continuous additive flames
//   2 smoke  — slow grey puffs that grow, fade and drift with the wind
//   3 sparks — a one-shot burst, fired when someone stokes the fire

/** An invisible image at (x, y) whose only job is to carry an emitter. */
function anchor(id: number, x: number, y: number): ParticleEmitter {
    const img = findimg(id)
    img.x = x
    img.y = y
    img.visible = false      // an empty image would draw a white 16x16 square
    return img.emitter
}

function onCreated(this: NpcThis) {
    // The fire sits in the middle of the NPC's 2x2-tile shape, a little low.
    const cx = this.x + 1
    const cy = this.y + 1.4
    buildFire(cx, cy)
    buildSmoke(cx, cy)
    buildSparks(cx, cy)
}

function buildFire(cx: number, cy: number) {
    const fire = anchor(1, cx, cy)
    fire.delaymin = 0.03              // a burst every 30-60 ms...
    fire.delaymax = 0.06
    fire.nrofparticles = 2            // ...of two particles
    fire.maxparticles = 80

    // Template for every new particle (tiles, tiles/s, radians).
    const p = fire.particle
    p.lifetime = 0.8
    p.angle = Math.PI / 2             // pi/2 = up the screen
    p.speed = 1.6
    p.zoom = 0.45                     // no image: a 16px square, scaled
    p.red = 1
    p.green = 0.55
    p.blue = 0.1
    p.mode = 0                        // additive: overlaps brighten like flame

    // Randomize each particle once, right as it spawns (particle age 0).
    fire.addlocalmodifier('once', 0, 0, 'angle', 'replace', Math.PI / 2 - 0.3, Math.PI / 2 + 0.3)
        .addmod('x', 'add', -0.35, 0.35)
        .addmod('speed', 'replace', 1.2, 2.2)

    // Over its 0.8 s life: yellow-orange -> deep red, fading out, shrinking.
    fire.addlocalmodifier('range', 0, 0.8, 'green', 'replace', 0.55, 0.05)
        .addmod('alpha', 'replace', 1, 0)
        .addmod('zoom', 'add', -0.3, -0.3)   // range + add = a rate per second

    // Flicker: every 80-200 ms, re-roll the size of the particles still to come.
    fire.addemitmodifier('impulse', 0.08, 0.2, 'zoom', 'replace', 0.35, 0.6)
}

function buildSmoke(cx: number, cy: number) {
    const smoke = anchor(2, cx, cy)
    smoke.emissionoffset = { xd: 0, yd: -0.8 }   // start above the flames
    smoke.delaymin = 0.15
    smoke.delaymax = 0.3

    const p = smoke.particle
    p.lifetime = 3
    p.angle = Math.PI / 2
    p.speed = 0.8
    p.zoom = 0.5
    p.red = 0.35
    p.green = 0.35
    p.blue = 0.38
    p.alpha = 0.5
    p.mode = 1                         // normal blending: smoke darkens

    smoke.addlocalmodifier('once', 0, 0, 'x', 'add', -0.2, 0.2)
        .addmod('spin', 'replace', -1, 1)
    smoke.addlocalmodifier('range', 0, 3, 'zoom', 'add', 0.5, 0.5)   // grows 0.5 -> 2.0
        .addmod('alpha', 'replace', 0.5, 0)

    // Wind: every 1-3 s ONE random gust is applied to all live smoke at once.
    smoke.addglobalmodifier('impulse', 1, 3, 'movex', 'replace', -0.4, 0.8)
}

function buildSparks(cx: number, cy: number) {
    const sparks = anchor(3, cx, cy)
    sparks.emitautomatically = false   // only on emit()
    sparks.nrofparticles = 30          // particles per emit()

    const p = sparks.particle
    p.lifetime = 0.9
    p.zoom = 0.2
    p.red = 1
    p.green = 0.85
    p.blue = 0.4
    p.mode = 0

    // Fan out upward at random speeds, then fall: range + add on movey is
    // a constant downward acceleration (+y is down the screen).
    sparks.addlocalmodifier('once', 0, 0, 'angle', 'replace', 0.4, Math.PI - 0.4)
        .addmod('speed', 'replace', 2, 5)
    sparks.addlocalmodifier('range', 0, 0.9, 'movey', 'add', 8, 8)
    sparks.addlocalmodifier('range', 0.5, 0.9, 'alpha', 'replace', 1, 0)
}

/** Serverside this.level.triggerAction(..., 'sparks') lands here on every client. */
function onActionSparks() {
    findimg(3).emitter.emit()
}

function onKeyPressed(this: NpcThis, key: string) {
    if (key !== 'A' || Math.hypot(player.x - this.x, player.y - this.y) > 3)
        return
    // Ask the server; it relays the burst to everyone in the level.
    triggerAction(this.x + 1, this.y + 1, 'stoke')
}
weapons/fireworks.client.ts
ts
// Clientside weapon: /firework launches a rocket from your position that
// bursts into sparks. Fire-and-forget: each rocket gets its own image, which
// is destroyed right away while its particles finish flying.
//
// Only you see your fireworks — particles are purely clientside. To show them
// to everyone, relay the launch through the server (see the tutorial).

// Each rocket needs its own image id: reusing an id whose particles are
// still flying would wipe them. A rotating pool of 50 is plenty.
const FIRST_ID = 100
const POOL = 50
let next = 0

function onPlayerChats(who: ChatPlayer, chat: string) {
    if (who.id === player.id && chat === '/firework')
        launch(player.x + 1, player.y)
}

function launch(x: number, y: number) {
    const img = findimg(FIRST_ID + next)
    next = (next + 1) % POOL
    img.x = x
    img.y = y
    img.visible = false

    const rocket = img.emitter
    // Automatic emission fires its first burst on the next frame; a long
    // delay makes that the ONLY burst.
    rocket.delaymin = 60
    rocket.delaymax = 60
    rocket.nrofparticles = 1
    rocket.particle.lifetime = 1
    rocket.particle.angle = Math.PI / 2
    rocket.particle.speed = 9
    rocket.particle.zoom = 0.3
    rocket.particle.mode = 0
    rocket.addlocalmodifier('range', 0, 1, 'speed', 'replace', 9, 2)   // slows as it climbs

    // The drop emitter bursts wherever a rocket particle's lifetime runs out.
    const burst = rocket.dropemitter
    burst.nrofparticles = 60
    burst.maxparticles = 200
    burst.particle.lifetime = 1.4
    burst.particle.zoom = 0.25
    burst.particle.mode = 0
    // A random color per rocket.
    burst.particle.red = 0.5 + Math.random() * 0.5
    burst.particle.green = 0.3 + Math.random() * 0.7
    burst.particle.blue = 0.3 + Math.random() * 0.7

    burst.addlocalmodifier('once', 0, 0, 'angle', 'replace', 0, Math.PI * 2)
        .addmod('speed', 'replace', 2, 6)
    burst.addlocalmodifier('range', 0, 1.4, 'movey', 'add', 3, 3)       // gravity
    burst.addlocalmodifier('range', 0.8, 1.4, 'alpha', 'replace', 1, 0)

    // Let the particles outlive the image, then drop the image on a later
    // frame: the rocket must have been emitted first, and an emitter that is
    // destroyed with no live particles is simply removed.
    rocket.continueafterdestroy = true
    setTimeout(() => img.destroy(), 200)
}

Try it ​

  1. Place an NPC in a level (GRC level editor → NPC tool → right-click, double-click to edit). Paste campfire.npc.client.ts into Clientside and campfire.npc.ts into Serverside, then save the level.
  2. Walk up to it: flames flicker, smoke rises and leans with each gust. You can't walk through the fire, since its shape blocks.
  3. Stand next to it and press A: sparks burst and rain down. Open a second client in the same level and both see each burst. Mashing A gives at most one burst per second.
  4. Create fireworks in the Weapons editor, grant it to yourself from the Players window, and type /firework a few times in a row.
  5. Try tweaking a value in the campfire script and saving the level: the NPC is recreated and your change shows immediately.

To debug an effect, read currentparticlecount and emittedparticles once a second and show them on screen or relay them to the server log (see Seeing clientside output). A plateau of roughly lifetime × particles per second means the rate is doing what you think.

Next steps ​

  • Real sprites: set particle.image to a server image asset (for example a soft round puff for smoke). Particles draw it centered and pop in once it has downloaded.
  • Glow: make the campfire a light source too (this.drawaslight(), see the day/night tutorial).
  • Weather: a screen-wide rain emitter with cliptoscreen and wraptoclippingbox, anchored to the camera and updated in onUpdate.
  • Trails: set attachposition on an emitter whose image follows the player, so the particles move with it, and compare the look with the default world-anchored trail.
  • Pause: toggle isfrozen to freeze the fire in place (a time-stop spell).