---
name: roblox-spray-ui-particles
description: Use when adding particle effects to Roblox UI (sparkles on a button, confetti, embers on a fuse, a burst behind a reward) with the Spray library (mastedore/spray), or when Spray particles look too fast, too big or drift off the art. Covers how Spray maps a 3D ParticleEmitter onto a GuiObject, tuning it in pixels, and placing effects in an overlay ScreenGui without GUI inset bugs.
---

# UI particles with Spray

[Spray](https://github.com/mastedore/spray) plays a real ParticleEmitter's effect inside a GuiObject, drawn with
pooled ImageLabels. You author the effect on a normal ParticleEmitter (Texture, Color, Size, Transparency, Squash,
Speed, Drag, Acceleration, shapes, flipbooks, Rate, TimeScale) and Spray reads it. Docs:
[mastedore.github.io/spray](https://mastedore.github.io/spray/).

## Install

```toml
[dependencies]
Spray = "mastedore/spray@1.0.0"
```

Spray draws on the client: require it from a LocalScript. Put the ParticleEmitter inside the GuiObject that hosts the
effect (a Folder in between is fine). Emitters on Parts or Attachments aren't drawn.

```luau
local Spray = require(ReplicatedStorage.Packages.Spray)

local sparkles = Spray.New(button.Sparkles) -- a ParticleEmitter inside a GuiObject
sparkles:Emit(30)
```

A new Spray starts paused. `:Emit(n)` on a paused Spray rewinds and plays; on a playing one it stacks another burst.
An emitter with `Enabled` on and `Rate` above zero streams while the Spray plays, so `:Resume()` starts it. Other
calls: `:Pause()`, `:Forward(seconds)`, `:SetTime(seconds)`, `:Prewarm(count)`, `:UseRandom(bool)` with
`:RandomSeed(seed)` for repeatable effects, `:Cleanup()`, `:Destroy()`.

## The unit is the host's size, not the screen

This is where most tuning goes wrong. In Spray, **one stud = the parent GuiObject's height in pixels** by default.
Speed, Acceleration, Size and the emission area are all measured in that unit, and re-measured every frame, so the
effect scales with its UI.

- Attribute `SprayUnit` picks the edge: `RelativeYY` (default), `RelativeXX`, `RelativeMin`, `RelativeMax`, or
  `Offset` (1 stud = 1 pixel).
- **Convert to pixels before tuning:** pixels = value × host height. On a 100 px host, Speed 1 is 100 px/s and
  Acceleration 1 is 100 px/s².
- `SprayScale` multiplies size and speed together, so it rescales the whole effect without changing its shape. Try
  it first. `SpraySizeScale` and `SpraySpeedScale` adjust one of them.

**Rule of thumb for a ~100 px host:** Speed about 0.1–0.8, Acceleration 0.3 or less, Drag 2–3, and short lifetimes.
Scale these with the host's size. Too-strong values make particles race off the art: Acceleration 0.9 on a 100 px host
is 90 px/s².

## Drawing effects in an overlay ScreenGui

A common setup puts every Spray effect in its own top-most ScreenGui so it draws above all other UI, and moves a canvas
there each frame to sit over the real host frame. Get the coordinates wrong and every effect floats away from its art.

- `AbsolutePosition` is measured from below the top bar (the GUI inset) in every ScreenGui, whatever its
  `IgnoreGuiInset` says. A ScreenGui that ignores the inset reports its own `AbsolutePosition` with a negative Y equal
  to the inset (-58 px with the current top bar).
- **The rule:** to place something over a GuiObject that lives in another ScreenGui, use
  `target.AbsolutePosition - destinationContainer.AbsolutePosition`, where the container is the overlay ScreenGui (or
  the frame you parent the canvas to). Never add or skip `GuiService:GetGuiInset()` by hand.

```luau
RunService.RenderStepped:Connect(function()
	canvas.Position = UDim2.fromOffset(
		host.AbsolutePosition.X - overlay.AbsolutePosition.X,
		host.AbsolutePosition.Y - overlay.AbsolutePosition.Y
	)
	canvas.Size = UDim2.fromOffset(host.AbsoluteSize.X, host.AbsoluteSize.Y)
end)
```

**A real mistake:** a burning-fuse flame drew its embers and smoke well above the fuse. The main cause was the overlay
code, which added the GUI inset only when the host's ScreenGui didn't ignore it. The game's HUD did ignore the inset,
so every effect was drawn 58 px above its host (hosts at y 10–16, canvases at -42 to -48). The same wrapper looked
right in a game whose HUD kept the inset, which is why it went unnoticed. Subtracting the overlay's AbsolutePosition
fixed it. Over-strong Speed and Acceleration (60–150 px/s and 90 px/s² on a 100 px host) made it worse, as a smaller
second cause.

## Where particles come from and where they go

- Particles spawn at the host's centre. `SprayEmissionSize` (a Vector2 fraction of the host's size, default `0, 0`)
  turns the point into an area. **Emit along the visible art:** size the emission area to it and move the host onto
  the art, rather than emitting from one point next to it.
- `EmissionDirection` Top, Bottom, Left and Right map to the screen; Front and Back fall back to Top. Only the X of
  `SpreadAngle` is used, and 180 covers the whole circle.
- Acceleration's Y is flipped because +Y points down in GUI space, so gravity authored as `(0, -10, 0)` still pulls
  particles down and a positive Y pushes them up the screen.
- Particles are locked to the host. Moving the host drags its live particles with it (`LockedToPart = false` isn't
  supported). For particles that stay behind, put a second emitter higher up the hierarchy.

## Looks and layering

- `ZOffset` is added to a base ZIndex (`SprayZIndex`, default the host's ZIndex). Under `ZIndexBehavior.Sibling` a
  particle can't rise above its host's siblings, so put effects that must sit in front under a host that's in front.
- `SprayIgnoreClips` lets particles leave a frame that clips its descendants.
- GUI has no additive blending, so `LightEmission` is emulated: bright colours tone-map toward white, and
  `SprayGlowLayers` (1–8) adds halo copies. Textures made for additive blending (some built-in ones like `fire_main`,
  `fire_sparks`, `forcefield_glow`) look dark; use textures with a real alpha channel.

## Performance

- `SprayMaxParticles` (default 400) caps live particles; `:Emit()` and the stream rate are limited to it.
- Call `:Prewarm()` (or set `SprayPrewarm`) where a hitch doesn't matter, like a loading screen, so bursts reuse
  ImageLabels instead of creating them.
- Spray remembers the last 64 `:Emit()` calls; for continuous effects use `Rate`, not rapid `:Emit()` calls.

## Previewing

In Studio's command bar, with emitters in StarterGui selected:
`require(game.ReplicatedStorage.Packages.Spray.Preview).Play()`. `.Scrub(t)` freezes them at one moment and
`.Step(dt)` nudges it. Call `.Stop()` before saving, because Preview turns on hidden UI to show the emitters. The Spray
Studio plugin also has a Preview and a Playground.

## Mistakes to avoid

- Positioning an overlay canvas by adding or skipping the GUI inset; subtract the overlay's AbsolutePosition instead.
- Tuning in studs as if it were 3D: always convert to pixels using the host's height first.
- Emitting from a point beside the art instead of an emission area over it.
- Expecting particles to stay where they spawned when the host moves.
- Requiring Spray from a server Script, or parenting the emitter to a Part.
- Using additive-blend textures and wondering why they're dark.
