Spray
Plays a ParticleEmitter on the screen. You put a regular ParticleEmitter inside a GUI object, set it up in the Properties window the same way you would for a 3D effect, and Spray draws its particles with ImageLabels.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Spray = require(ReplicatedStorage.Packages.Spray)
local button = script.Parent -- a TextButton with a ParticleEmitter called Confetti inside
local confetti = Spray.New(button.Confetti)
button.Activated:Connect(function()
confetti:Emit(40)
end)
Spray draws on the client, so require it from a LocalScript, or from a ModuleScript that a LocalScript requires.
Where the emitter goes
Roblox only draws a ParticleEmitter that's inside a Part or an Attachment, so one inside a GUI does nothing on its own. Spray is what draws it. The emitter needs a GuiObject above it in the Explorer, like a Frame, an ImageLabel or a TextButton. It's usually a direct child, but a Folder in between works too, which is handy for keeping several effects under one button.
Particles come out of the center of that GuiObject and are drawn inside it, so they move,
scale and hide along with it. If you tween the frame, the effect follows. When the
GuiObject, or anything above it, has ClipsDescendants on, particles get cut off at its
edges unless you set the SprayIgnoreClips attribute.
What the effect looks like comes from the emitter's own properties: Texture, Color, Size, Transparency, Squash, Lifetime, Speed, Rotation, RotSpeed, Acceleration, Drag, SpreadAngle, EmissionDirection, Shape, the flipbook settings, Rate, TimeScale and the rest. The few settings a 3D emitter has no property for are attributes, listed further down.
Playing, pausing and the clock
Every Spray has a clock that counts seconds from the start of the effect. What you see on screen is always calculated from that number and nothing else, so an effect can be paused, jumped to any moment, or played backwards, and you still get exactly the frame you'd have seen by playing up to that point.
A Spray is either playing, which means the clock moves forward every frame, or paused. A new Spray starts paused at 0 seconds.
-
Spray:Emit releases particles. On a paused Spray it first rewinds to 0 and starts
playing. On a Spray that's already playing, the new particles join the ones already on
screen, the same as
ParticleEmitter:Emit(). - Spray:Pause stops the clock and Spray:Resume starts it again.
- Spray:Forward and Spray:SetTime move the clock. They don't pause or resume anything.
If the emitter has Enabled on and a Rate above 0, it also releases particles on its
own for as long as the Spray is playing, like a 3D emitter does, so calling
Spray:Resume is enough to start it.
Attributes
These are the settings a 3D emitter has no property for. They all go on the ParticleEmitter itself, and they're all optional. To add one, select the emitter, scroll to the bottom of the Properties window, press the + button next to Attributes, and type the name and type exactly as they appear here.
| Attribute | Type | Default | What it does |
|---|---|---|---|
SprayScale |
number | 1 |
Multiplies size and speed together, so the whole effect gets bigger or smaller without changing its shape. When an effect looks right but is the wrong size, change this one first. |
SpraySizeScale |
number | 1 |
Multiplies size only, on top of SprayScale. |
SpraySpeedScale |
number | 1 |
Multiplies speed only, on top of SprayScale. |
SprayUnit |
string | RelativeYY |
Which side of the GuiObject counts as one stud. RelativeYY is its height, RelativeXX its width, RelativeMin the shorter side and RelativeMax the longer one. Offset makes one stud equal one pixel. |
SprayEmissionSize |
Vector2 | 0, 0 |
Size of the area particles come out of, as a fraction of the GuiObject. 0, 0 is a single point in the middle and 1, 1 covers the whole GuiObject. The emitter's Shape, ShapeStyle and ShapeInOut are only used when this isn't 0, 0. |
SprayMaxParticles |
number | 400 |
The most particles that can be on screen at once. Spray:Emit never releases more than this, and a Rate too high to fit gets lowered. |
SprayGlowLayers |
number | 1 |
How many copies of each particle get drawn, from 1 to 8. The extra copies are a fading halo that imitates LightEmission. Each copy is one more ImageLabel, so it costs performance. |
SprayZIndex |
number | the GuiObject's ZIndex | The ZIndex that ZOffset gets added to. |
SprayFlipbookGrid |
number | 4 |
Frames per row of the flipbook when FlipbookLayout is Custom. The other layouts already say how many frames they have. |
SprayFlipbookResolution |
number | 1024 |
Size of the flipbook texture in pixels. Spray needs it to cut each frame out of the sheet. |
SprayIgnoreClips |
boolean | false |
Lets particles leave the GuiObject even when it, or something above it, has ClipsDescendants on. |
SprayPrewarm |
number | 0 |
How many particles to create ahead of time when the Spray is built. It does the same as calling Spray:Prewarm with that number. |
An attribute with the wrong type, like a string where a number should go, is ignored and the default is used instead.
From 3D to the screen
One stud is the height of the GuiObject in pixels, or whichever side SprayUnit picks. In
a frame 200 pixels tall, a Size of 0.1 gives particles 20 pixels across, and a Speed of 2
moves them 400 pixels per second. Because it's based on the GuiObject and not on the
screen, the effect keeps its look on a phone or after the window is resized.
Acceleration gets its Y flipped because on the screen +Y points down, so gravity written
as 0, -10, 0 still pulls the particles down.
For EmissionDirection, Top, Bottom, Left and Right point where you'd expect on the
screen. Front and Back point into and out of the screen, which a flat GUI can't show, so
both work like Top. Only the X of SpreadAngle is used, and 180 spreads particles all the
way around.
ZOffset is added to the base ZIndex (see SprayZIndex), so emitters in the same
GuiObject stack the way you'd expect. It can't lift particles above the GuiObject's
siblings when the ScreenGui uses ZIndexBehavior.Sibling, though. If an effect has to
show in front of some other part of your UI, put it inside a GuiObject that's already in
front.
GUIs only have normal alpha blending, so LightEmission is imitated. Brightness above 1
pushes colors toward white, which gives you the bright core glowing particles have, and
SprayGlowLayers adds a halo scaled by LightEmission. Overlapping particles still don't
add their light together the way they do in 3D. LightInfluence tints the particles
toward Lighting.Ambient.
Particles always move with their GuiObject, as if LockedToPart were on.
The Spray type
The module exports the type of the object Spray.New returns as Spray.Spray. You only
need it to annotate variables or function parameters in your own code:
local Spray = require(ReplicatedStorage.Packages.Spray)
local effects: { Spray.Spray } = {}
local function PlayAll(list: { Spray.Spray })
for _, effect in list do
effect:Emit(20)
end
end
Properties
Emitter
This item is read only and cannot be modified. Read OnlySpray.Emitter: ParticleEmitterThe ParticleEmitter this Spray plays, the same one you passed to Spray.New. Spray reads it but never changes or destroys it.
Functions
New
Creates a Spray that plays Target.
This reads the emitter's properties and attributes, and adds an empty Frame inside the
GuiObject where the particles will be drawn. The Frame is named Spray_ plus the
emitter's name. Nothing shows up yet, because the Spray starts paused at 0 seconds.
Particles appear after Spray:Emit, or after Spray:Resume on an emitter that uses
Rate.
local sparkles = Spray.New(frame.Sparkles)
sparkles:Emit(30)
Create each Spray once and keep it around. Calling Spray.New every time you want a
burst builds a new Frame and new ImageLabels on every call, and the old ones stay in your
UI until you call Spray:Destroy on the Sprays that made them.
Errors
| Type | Description |
|---|---|
| "Spray.New expects a ParticleEmitter" | What you passed isn't a ParticleEmitter. |
| "Spray: ... has no GuiObject ancestor to draw into" | Nothing above the emitter is a GuiObject, for example because it's inside a Part. |
Emit
Spray:Emit(Count: number--
How many particles to release.
) → ()Releases Count particles at once.
What happens depends on whether the Spray is playing:
- If it's paused, it rewinds to 0 seconds, clears whatever was on screen and starts playing, so the effect runs from the beginning. A new Spray is paused, and so is one you stopped with Spray:Pause. Moving the clock with Spray:SetTime or Spray:Forward first doesn't change this, since those don't start playback.
-
If it's already playing, the new particles join the ones already on screen, the same as
ParticleEmitter:Emit()does in 3D.
button.Activated:Connect(function()
sparkles:Emit(25)
end)
Count is rounded down and capped at SprayMaxParticles. Passing 0 releases nothing, but
a paused Spray still rewinds and starts playing, which is a way to restart only the
Rate stream.
CAUTION
A Spray remembers its last 64 bursts. If you call this more than 64 times within one
particle lifetime, the oldest bursts disappear early. For a steady flow of particles, use
the emitter's Rate instead.
Pause
Spray:Pause() → ()Stops the clock. The particles stay on screen, frozen where they are, until the clock moves again.
fx:Pause()
task.wait(1)
fx:Resume() -- carries on from the same moment
Pausing doesn't hide anything. If you want the particles gone right away, Spray:Cleanup removes them. Does nothing if the Spray is already paused.
Resume
Spray:Resume() → ()Starts the clock again from wherever it is. That's where Spray:Pause stopped it, or wherever Spray:SetTime or Spray:Forward moved it.
Unlike Spray:Emit, this doesn't rewind, so you can use it to start an effect partway
through. On an emitter that uses Rate, that makes it start already full of particles
instead of empty:
local smoke = Spray.New(frame.Smoke)
smoke:SetTime(2)
smoke:Resume() -- plays as if it had been running for 2 seconds
It's also all you need to start a Rate emitter, since the stream runs whenever the
Spray is playing. Does nothing if the Spray is already playing.
Forward
Spray:Forward(DeltaTime: number--
Seconds to move the clock. Negative values go backwards.
) → ()
Moves the clock by DeltaTime seconds and redraws right away. Negative values go
backwards, and the clock never goes below 0.
It doesn't pause or resume the Spray, so a playing Spray keeps playing from the new moment. On a paused one it works like a scrubber:
fx:Emit(30)
fx:Pause()
fx:Forward(0.1) -- 0.1 seconds into the effect
fx:Forward(-0.05) -- back to 0.05
TimeScale changes how fast the clock runs while the Spray is playing, but it doesn't
affect this method, so Forward(1) always moves the clock exactly 1 second.
SetTime
Spray:SetTime(Time: number--
Seconds from the start of the effect.
) → ()
Jumps the clock to Time seconds after the start of the effect and redraws right away.
You get exactly the frame you'd have seen by playing up to that moment.
Like Spray:Forward, it doesn't pause or resume anything, and like Forward it ignores
TimeScale. Negative values count as 0.
fx:Emit(60)
fx:Pause()
fx:SetTime(0.35) -- the effect as it looks 0.35 seconds in
Prewarm
Spray:Prewarm(Count: number?--
How many particles to prepare. Leave it out to use SprayMaxParticles.
) → ()
Creates the ImageLabels for Count particles now, so later bursts reuse them instead of
creating new ones in the middle of gameplay.
Creating ImageLabels is most of what the first burst of an effect costs, and a big burst can cause a small hitch. Calling this during a loading screen, or anywhere a hitch won't be noticed, moves that cost there.
local explosion = Spray.New(frame.Explosion)
explosion:Prewarm() -- room for SprayMaxParticles particles
Count is the total the pool should hold, not how many to add, so calling Prewarm(100)
twice still leaves room for 100. It never goes past SprayMaxParticles. With
SprayGlowLayers above 1, each particle takes that many ImageLabels.
Spray:Cleanup throws the pool away. The SprayPrewarm attribute does the same as this
method every time the Spray is built, including rebuilds after a cleanup.
UseRandom
Spray:UseRandom(Use: boolean?--
true or nil for a new seed on every run, false to always use the fixed seed.
) → ()Chooses whether each run of the effect looks different or exactly the same.
With true, which is the default, the Spray picks a new random seed every time the effect
restarts, so no two runs look alike. That's what a 3D emitter does. With false, it
always uses the seed set with Spray:RandomSeed, so every run is identical, down to the
flipbook frame each particle shows.
fx:UseRandom(false)
fx:RandomSeed(7)
fx:Emit(40) -- looks the same every time
The change applies the next time the effect restarts, which is the next Spray:Emit on a paused Spray. Particles already on screen keep the seed they have.
RandomSeed
Spray:RandomSeed(Seed: number--
Any whole number.
) → ()
Sets the seed used while Spray:UseRandom is false. The same seed always gives the same
particles, down to which flipbook frame each one shows. A different seed gives different
particles, and those repeat exactly too.
Call it before Spray:Emit. If UseRandom(false) is already on, the new seed applies
immediately, even to an effect that's in the middle of playing, so for a clean result set
the seed first and emit after.
Decimals are rounded down. Until you call this, the seed is 0.
Cleanup
Spray:Cleanup() → ()Throws away everything the Spray built: the particles on screen, the Frame and ImageLabels that draw them, and the copy of the emitter's settings. The Spray itself keeps working, but it has to build all of that again the next time you use it.
There are two reasons to call it:
- You changed the emitter's properties or attributes while the game is running. Spray only reads them when it builds, so call this and the next call reads them again.
- You're done with a heavy effect for a while and want its ImageLabels gone.
fx.Emitter.Color = ColorSequence.new(Color3.new(1, 0, 0))
fx:Cleanup()
fx:Emit(20) -- red particles now
After this the Spray is paused at 0 seconds. The next Spray:Emit, Spray:Resume, Spray:Forward, Spray:SetTime or Spray:Prewarm builds everything again, which costs about as much as creating the Spray did, so don't call this between bursts of an effect you'll keep using. Your Spray:UseRandom and Spray:RandomSeed settings are kept.
Destroy
Spray:Destroy() → ()
Cleans up the Spray for good. It does what Spray:Cleanup does and then makes the object
unusable, so anything you do with it afterwards, including calling :Destroy() again,
throws "Spray: this object has been destroyed". That way a leftover reference fails
loudly where you use it instead of misbehaving somewhere else.
The ParticleEmitter isn't touched. Destroy it yourself if you don't need it anymore.
Call this when the UI the effect lives in goes away. If you destroy the UI but not the Spray, a Spray that was playing stays in Spray's update loop and in memory.