Skip to content

Tutorials · Particle Effects · Chapter 13

Particles and scripts

30 minutes Uses: ParticleEmitter, burst, restart, set_flag, move_node, set_visible, node, wait, random, move_to, anneal_to

By the end

  • fireworks.rgs: a salvo of 3 shells every 4 seconds, fired with burst
  • wish.rgs: the well’s ring of light, lit where a firefly crosses the fireflies’ Trigger, through on_particle_enter
  • cough.rgs: the soot puff restarted every 3 to 6 seconds
  • shooting_star.rgs: a star thrown across the sky by a script, its sparks carrying its speed, its emitter switched with set_flag
On this page

Particles a script drives

So far the town has run on numbers. A script can do what numbers cannot: fire a burst at a moment of its choosing, restart a one-shot, switch an emitter on and off, and hear particles cross a region. This chapter holds the tutorial’s first Rengy Script: four short files, which the editor runs as they are, with nothing to install and nothing to build.

Each is made the same way. You make the script in Assets, paste the text below into the Code tab, and add the script to a node as a component. A script belongs to the node it is added to and works on that node’s own components, so each goes on the node whose emitter it drives. Where a script needs another node, you hand it one in a field.

Fireworks on a timer

Chapter 12 left fireworks, the launcher, firing a shell now and then on its own Rate. A salvo of three at once, on a beat, needs a script.

  1. In Assets, click Assets in the path bar over the tree, so the script is written at the project’s top, beside art, artifacts and scenes, then press Create, choose New script, type fireworks, and press Enter.
  2. In the Code tab, replace everything in fireworks.rgs with the script below, and press Ctrl+S.
  3. Select fireworks and untick Emitting: from now on it fires only when the script says. Set Spread to 0.5, so that three shells leave in slightly different directions.
  4. Press Add component and choose Fireworks. Every and Shells start at the script’s own 4 and 3: leave them.
rgs fireworks.rgs
/// Fireworks on a timer. Every `every` seconds the launcher fires `shells`
/// shells at once, fanned out by its emitter's Spread; each shell bursts where
/// it dies, as the emitter's On Death says.
component Fireworks {
    /// Seconds from one salvo to the next.
    #[suffix("s")]
    every: f32 = 4.0,
    /// How many shells a salvo fires.
    shells: f32 = 3.0,

    fn loop() {
        await wait(every);
        burst(shells);
    }
}
The Code panel with fireworks.rgs open: a comment on fireworks on a timer, then component Fireworks with its two fields, every and shells, each with a comment, and a loop that waits every seconds and calls burst with shells; the Scripts list at the left names cough.rgs, fireworks.rgs, shooting_star.rgs and wish.rgs

loop runs for as long as the town does. await wait(every) pauses it for every seconds, and when it comes back, burst(shells) adds that many particles to this node’s own emitter at once. Then loop runs again from the top. With Emitting unticked the stream makes none of its own, so this burst is the only way a shell is ever born. Each shell bursts where it dies, as chapter 12’s On Death says.

fireworks selected, its Inspector narrowed with the search box, fireworks, to its Fireworks component: Every 4.00 s and Shells 3.00; in the Viewport the node’s move arrows stand behind the right-hand roofs, with two small blue squares in the sky above them
burst
Fires that many particles from the script node’s own emitter right now, whatever its mode: a stream gets a puff on top of its flow, a one-shot another round. Here shells, three shells at once.
Every
Seconds between one salvo and the next, 4 here. 3 to 8 suits fireworks over a town this size: under that the sky is never empty, and over it the town waits too long.
Shells
How many shells a salvo fires, 3. They are fanned across the emitter’s Spread, 0.5, so each flies a little differently. 2 to 5 reads as a salvo; a dozen would be a crowd.
A patch of night sky over the roofs, close up: three small amber dots, each with a short tail of fainter dots beneath it, climbing side by side, with rain falling past them and a dark smudge at the bottom left
Close up, a second after the first salvo is fired: three shells climbing.
The same patch of sky half a second later: each shell has burst into a ring of cream-coloured sparks, two of the rings overlapping and the third a little way to their right, with rain falling through them
Close up, half a second later: each shell has burst into a ring of sparks.

A well that hears the fireflies

The well’s ring of light, well_ring, has burned since chapter 4. Now it will light only when a firefly drifts into the well’s mouth. For that the fireflies need a Trigger, a region that tells their node’s scripts which particles cross it, and a script that listens.

  1. Make wish.rgs the same way, with the script below: Create, New script, wish, Enter, replace its text in the Code tab, and press Ctrl+S.
  2. Select fireflies. In its Trigger group tick Trigger, and set Offset to 0 and 30, Shape to Circle with a Radius of 18, and Report to Enter.
  3. Select well_ring and untick its Visible: the script lights it, so it starts dark.
  4. Select fireflies again, press Add component, and choose Wish. Click the empty Ring slot and choose well_ring from the list, or drag well_ring from the Hierarchy onto the slot. Glow starts at the script’s own 3: leave it.
rgs wish.rgs
/// A wishing well. A firefly that drifts into the well's mouth, this emitter's
/// Trigger, lights the well's ring of light where it crossed in, for `glow`
/// seconds; another firefly arriving lights it again.
component Wish : Emitter {
    /// The node whose particles make the well's ring of light.
    #[node_path]
    ring: i32 = 0,
    /// Seconds the ring stays lit after the last firefly crossed in.
    #[suffix("s")]
    glow: f32 = 3.0,
    /// Seconds left before the ring goes dark.
    #[hidden]
    left: f32 = 0.0,

    fn on_particle_enter(at: Vec2) {
        move_node(node(ring), at);
        set_visible(node(ring), true);
        left = glow;
    }

    fn loop() {
        if left > 0.0 {
            left -= dt;
            if left <= 0.0 {
                set_visible(node(ring), false);
            }
        }
    }
}
The Code panel with wish.rgs open, its first twenty-four lines: a comment on a wishing well, then component Wish : Emitter with a node path field, ring, a glow field of 3.0 seconds and a hidden left, then the hook on_particle_enter with move_node, set_visible and left = glow, and the start of loop; the Scripts list at the left names the four scripts

component Wish : Emitter says the script belongs on a node with a Particle Emitter, and gives it the two hooks an emitter has, on_particle_enter and on_particle_exit. The engine calls on_particle_enter once for every particle that crosses into the Trigger, with at, where it crossed, and it does so before that step’s loop. Here the hook moves the ring node to at, shows it, and sets left to glow, the seconds the ring has to live. loop runs every step and counts left down by dt, the seconds since the last step; when it reaches nothing it hides the ring again. A firefly that crosses while the ring is lit sets left back to glow, so the ring stays lit as long as they keep arriving. left is marked hidden: it is the script’s own counter, and the Inspector does not show it.

fireflies selected, its Inspector narrowed with the search box, trigger, to the Trigger rows: Trigger ticked, Offset x 0.0 and y 30.0, Shape Circle, Radius 18.00 and Report Enter; in the Viewport a thin grey circle lies over the well’s mouth with a small blue square at its middle and another at its right edge, joined by a thin line, the Trigger’s centre and a handle on its Radius, with the fireflies’ own violet circle, their Shape, and its aim lines round their node just above it
fireflies selected, its Inspector narrowed with the search box, wish, to its Wish component: Ring reading well_ring in a slot with a small arrow at its edge, and Glow 3.00 s; in the Hierarchy well_ring is greyed out with a hidden mark beside it
Trigger
A region that reports the emitter’s own particles crossing it, to the scripts on the emitter’s node. Nothing happens to a particle that crosses: it flies on through. Unticked, no region exists and no hook runs. Its three settings follow.
Offset
Where the region sits, measured from the emitter’s node, in world units. 0, 30 puts it 30 units below the fireflies’ node, which hangs just over the well’s mouth, so the region covers the mouth: y grows downward. The region stays where it is while particles drift through it.
Shape (the trigger’s)
The region’s outline. A Circle asks “inside?” with one distance from its centre, its Radius, 18 here, about the width of the well’s mouth. A Box asks it with a rectangle, its Size the full width and height. This is a second Shape, beside the one chapter 4 set: that one decides where particles are born, and this one where they are heard.
Report
Which crossings call a hook: Enter, a particle crossing in; Exit, a particle crossing out; Both. Enter here, so each firefly that drifts in is heard once. One call is made for each crossing.
on_particle_enter
A hook: code the engine runs for you, here once for each particle that crosses into the Trigger, with at, where it was. on_particle_exit is its twin for particles crossing out, and runs when Report is Exit or Both.
move_node, set_visible and node
Move another node, and show or hide it. node finds that other node from a node field, here ring, which is how a script reaches a node that is not its own.
Ring
The node the script lights: well_ring. It is a node field, filled by picking from the list or dropping a node on it.
Glow
Seconds the ring stays lit after the last firefly crossed in: 3 here. 1 to 5 reads well. Under a second the ring blinks, and over five it is lit most of the night.
The same Viewport with the Trigger’s Shape set to Box: a thin grey rectangle that reaches from the well’s roof down to its stones, with a small blue square on each of its four corners and one at its middle
For comparison, the Trigger as a Box of 36 by 36: a handle at each corner, which drags its Size.
Close up of the well at night: pale blue sparks looping over its roof and down its right side, a yellow firefly beside them and two more above, and rain falling past the well
Close up, eight seconds in: a firefly has drifted into the well’s mouth and the ring is lit where it crossed.

A chimney that coughs

Chapter 9’s soot puff, soot_puff, is a one-shot: it fires its burst once, when the room opens, and is spent. A script can fire it again.

  1. Make cough.rgs the same way, with the script below, and press Ctrl+S.
  2. Select soot_puff, press Add component, and choose Cough. Calm Min and Calm Max start at the script’s own 3 and 6: leave them.
rgs cough.rgs
/// A chimney that coughs. Every few seconds, somewhere between `calm_min` and
/// `calm_max`, this node's one-shot emitter is restarted, so it fires its
/// burst of soot again.
component Cough {
    /// The shortest wait between two coughs, in seconds.
    #[suffix("s")]
    calm_min: f32 = 3.0,
    /// The longest wait between two coughs, in seconds.
    #[suffix("s")]
    calm_max: f32 = 6.0,

    fn loop() {
        await wait(random(calm_min, calm_max));
        restart();
    }
}
The Code panel with cough.rgs open: a comment on a chimney that coughs, then component Cough with calm_min 3.0 and calm_max 6.0, each with a comment, and a loop that waits a random time between the two and then calls restart; the Scripts list at the left names the four scripts
soot_puff selected, its Inspector narrowed with the search box, cough, to its Cough component: Calm Min 3.00 s and Calm Max 6.00 s

loop waits a random time, between calm_min and calm_max seconds, and then calls restart(); and loop runs again. So the third chimney coughs every three to six seconds, never on a fixed beat.

restart
Empties the script node’s emitter and fires it again from the start: a one-shot fires its whole Burst once more, and a stream starts over. It is what the Particles panel’s Restart does, but a script’s restart() does not prewarm.
random
A random number between the two you give it, so random(calm_min, calm_max) is a wait somewhere between 3 and 6. The same town gives the same numbers every run, so the first cough always comes at the same moment.
wait
Pauses a script for that many seconds. It is written await wait(…), and the rest of loop carries on after it.
Calm Min and Calm Max
The shortest and the longest wait between coughs, in seconds: 3 and 6. A wider spread feels more like a living chimney; equal numbers make a metronome.
Close up of two roofs against the mountains: a dense dark blot of soot a little above the right-hand roof’s chimney, with a few specks of rain and chimney smoke near it
Close up, four seconds in, just after the first cough: a dark puff of soot over the third chimney.

A shooting star

The last script moves a node. A star leaves the left edge, crosses the sky in a second, and leaves a trail of sparks, then waits five seconds in the dark and goes again. The star is a Particles node, and the script is what carries it.

  1. Make shooting_star.rgs the same way, with the script below, and press Ctrl+S.
  2. Select Town, press + Add node, and choose Particles. Rename it shooting_star and set its Position to -20 and 40, just off the left edge of the window. Untick Emitting.
  3. Set Rate to 120, Lifetime to 0.8, Speed to 14, Spread to 7 and Gravity to 0.
  4. Set Start Color to 0.9, 0.95, 1, alpha 1, End Color to 0.5, 0.6, 1, alpha 0, Start Size to 5 and End Size to 1.5.
  5. Set Capacity to 100, Sort Layer to -25, Blend to Add and Texture to soft-dot.png. In the Inherit group set Inherit Velocity to 1.
  6. Press Add component and choose ShootingStar. Set From to -20 and 40, and To to 420 and 110. Seconds and Rest start at the script’s own 1 and 5: leave them.
rgs shooting_star.rgs
/// A shooting star. Every `rest` seconds this node leaps back to `from`, its
/// emitter off so the leap throws no sparks, then streaks to `to` in
/// `seconds` with its emitter on. Its sparks carry its speed, as the
/// emitter's Inherit Velocity says, so they ride along its path.
component ShootingStar {
    /// Where each crossing starts.
    from: Vec2,
    /// Where it ends.
    to: Vec2,
    /// Seconds a crossing takes.
    #[suffix("s")]
    seconds: f32 = 1.0,
    /// Seconds of dark sky between crossings.
    #[suffix("s")]
    rest: f32 = 5.0,

    fn loop() {
        set_flag("ParticleEmitterComponent", "emitting", false);
        move_to(from);
        await wait(rest);
        set_flag("ParticleEmitterComponent", "emitting", true);
        await anneal_to(to, seconds, .InQuad);
    }
}
The Code panel with shooting_star.rgs open: a comment on a shooting star, then component ShootingStar with its fields from, to, seconds and rest, each with a comment, and a loop that calls set_flag to switch the emitter off, move_to from, wait rest, set_flag to switch it on, and anneal_to to with .InQuad; the Scripts list at the left names the four scripts
shooting_star selected, its Inspector narrowed with the search box, shootingstar, to its ShootingStar component: From x -20.0 and y 40.0, To x 420.0 and y 110.0, Seconds 1.00 s and Rest 5.00 s; in the Viewport the node’s move arrows stand at the top left corner of the picture, off the town’s left edge

Each pass of loop is one crossing. set_flag switches the emitter off, by writing false onto its Emitting. move_to(from) puts the node back at the start in a single move. wait(rest) is the dark sky between crossings. set_flag then switches the emitter on, and anneal_to carries the node to to over seconds, along .InQuad, which starts slowly and speeds up. It is the node that moves, and a particle is born moving with its node, as far as Inherit Velocity says.

set_flag
Writes true or false onto a field of another component on this node: here "ParticleEmitterComponent" and "emitting", the emitter’s Emitting. A script can switch an emitter on and off this way, or any other tick box.
move_to and anneal_to
move_to puts the node at a place at once. anneal_to carries it there over a time, along an easing: .InQuad starts slowly and gathers speed, as a falling star does.
Inherit Velocity
The share of the emitter node’s own velocity that a new particle carries, from 0 to 1. At 0 a particle is thrown only by Speed and Direction, and hangs where it was born. At 1 it also takes all of the node’s speed at birth. 0.3 to 0.5 is where sparks off something that runs read as thrown, and the star takes 1: its sparks ride along with it.
From, To
Where each crossing starts and ends, in world units: -20, 40 just off the left edge, and 420, 110, past the middle of the town and lower.
Seconds, Rest
Seconds a crossing takes, 1, and seconds of dark sky between crossings, 5. A star every 5 to 10 seconds is a rarity; every second it is a meteor shower.
The sky over the roofs at the left of the window, half a second into a crossing: a bright white star at the upper left with a short tail of white sparks stretching back from it to the left edge, and at the far right the first rings of the salvo’s bursts
Half a second into the crossing: the star has gone about a quarter of the way, and its sparks trail behind it.
The same sky three quarters of a second into the crossing: the star, bright and at about the middle of the picture, with a long tail of white sparks thinning out behind it toward the left edge, and the salvo’s rings, larger now, at the right
Three quarters of a second in: the star is about half way.
The same moment with Inherit Velocity at 0: a bright dot at about the middle of the picture, and behind it a long, even dotted line of single sparks, hanging in the sky along the star’s whole path to the left edge
For comparison, Inherit Velocity 0: the sparks hang where they were born, a dotted line along the path.

The recipes

The fireworks

FieldValue
Nodefireworks, a Particles node, Position 470, 236
Emittingunticked
Rate0.4
Lifetime1.3
Speed180
Direction-0.08, -1
Spread0.5
Gravity90
Start Color1, 0.85, 0.6, alpha 1
End Color1, 0.6, 0.3, alpha 0.8
Start Size3
Capacity4
Trail4
Sort Layer-22
BlendAdd
Texturesoft-dot.png
On Deathfirework.artifact
FireworksEvery 4, Shells 3

The wishing well

FieldValue
fireflies, Triggerticked: Offset 0, 30, Shape Circle with Radius 18, Report Enter
fireflies, WishRing well_ring, Glow 3
well_ringVisible unticked

The fireflies and the ring are as chapters 4 and 8 left them.

The coughing chimney

FieldValue
soot_puff, CoughCalm Min 3, Calm Max 6

The puff is as chapter 9 left it, a one-shot.

The shooting star

FieldValue
Nodeshooting_star, a Particles node, Position -20, 40
Emittingunticked
Rate120
Lifetime0.8
Speed14
Spread7
Gravity0
Start Color0.9, 0.95, 1, alpha 1
End Color0.5, 0.6, 1, alpha 0
Start Size5
End Size1.5
Capacity100
Sort Layer-25
BlendAdd
Texturesoft-dot.png
Inherit Velocity1
ShootingStarFrom -20, 40, To 420, 110, Seconds 1, Rest 5

Check it works