Log inGet started

Visual effects

Updated 4 September 2026

When to read this

  • You want an explosion (or any other effect) and would otherwise start from an empty emitter.
  • You have an effect and want it bigger, a different colour, or shorter.
  • You want to know what an effect costs before firing several at once.
  • You are authoring a new effect and want to know what the asset has to carry.

Core idea

An effect is an asset, the same way a material or a mesh is. It carries a declaration — its family, its parameters and its measured cost — and a definition that builds it on screen. Gameplay code plays one through the effects global:

effects.play("@builtin::systems.effects.combat.explosion", { position = { 0, 2, 0 } })

That is the whole call. The effect allocates what it needs, plays, and gives it all back when it finishes. Nothing ticks it and nothing tears it down. Every entity it spawns is temporary, so nothing it draws enters a saved scene or replicates to other players.

To stick one on something already in the world:

effects.playOn("@builtin::systems.effects.combat.explosion", drum, { params = { scale = 3 } })

To make the effect belong to that thing — start with it, follow it as it moves, go when it goes, and come back with it out of a saved world — add the Effect component instead of calling anything:

drum.component.add("Effect", {
    effect = "@builtin::systems.effects.combat.explosion",
    scale  = 2.0,
})

That is the durable form of the same playback path. The call is what gameplay code fires; the component is what an entity carries.

effects is where gameplay code lives. The asset itself is still reachable — asset.ref(identity, "effect"):play { … } runs the same effect on the same handle — and the difference is what happens to the memory: effects.play leases its emitters from a shared pool and hands them back, so firing the same effect a hundred times allocates once. ref:play is the unpooled reference path, which is what a preview or a single measurement wants.

Write the identity at the call, the way it is written above. On a component or a scene entrypoint an asset reference is pinned from the source, so an identity that reaches asset.ref through a variable is refused there — a tool may compute one, gameplay code names it.

Identity

Effects are addressed by their scope-qualified identity — what describe().identity reports, and the spelling to write down anywhere you save a reference:

@builtin::systems.effects.combat.explosion

asset.resolve("explosion", "effect") also answers, by short name, where the short name reaches exactly one effect. Where this world authors its own effect under that name, the short name reaches two and the call reports both candidates so you pick — @root::explosion names the world's own, and the scope-qualified identity above names this one. The identity reaches the same effect in every world, so prefer it in anything durable.

Finding what exists

for _, identity in ipairs(effects.list()) do
    print(identity)
end

Effects declare a family, and the catalogue lists filtered by it — so "what is there for a gunfight?" is one call:

for _, f in ipairs(effects.families()) do print(f) end
for _, identity in ipairs(effects.list({ family = "combat" })) do print(identity) end

and to see what one is before you play it:

local d = effects.describe("@builtin::systems.effects.combat.explosion")
print(d.family, d.summary)
print(d.cost.gpuMs, "ms  ", d.cost.vramBytes, "bytes")
for _, p in ipairs(d.params) do
    print(p.name, p.type, p.default, p.min, p.max, p.desc)
end

describe() is the one call to make against an unfamiliar effect. It answers from the asset's own declaration, so what it reports is what the effect was authored and measured as.

The combat family

Six effects cover the gun loop, and every one of them is played the same way — effects.play(identity, { position = …, params = { … } }).

IdentityWhat it is
@builtin::systems.effects.combat.muzzleFlashthe bloom at a barrel the instant a weapon fires
@builtin::systems.effects.combat.tracera round in flight between two world points
@builtin::systems.effects.combat.plasmaBolta slower, self-lit bolt with a pulsing core
@builtin::systems.effects.combat.impactthe burst where a round lands, answering to the surface it hit
@builtin::systems.effects.combat.shockwaveRingthe pressure front a blast pushes out across a surface
@builtin::systems.effects.combat.explosionthe fireball, smoke and debris

@builtin::systems.effects.demo.combat_showcase is a scene that runs the whole loop with no setup — load it to see all of them firing.

A shot, end to end

The flash goes off at the muzzle, the round flies to what it hit, and the impact answers to what that was. The tracer takes both ends of the flight and a muzzle velocity, so its length is the span divided by the speed:

local FLASH = "@builtin::systems.effects.combat.muzzleFlash"
local TRACER = "@builtin::systems.effects.combat.tracer"
local IMPACT = "@builtin::systems.effects.combat.impact"

local hit = physics.raycast(muzzle.position, aim, 200)
local landsAt = if hit then hit.point else { muzzle.position.x + aim.x * 200,
    muzzle.position.y + aim.y * 200, muzzle.position.z + aim.z * 200 }

effects.play(FLASH, { position = muzzle.position, params = { direction = aim, size = 0.28 } })
local round = effects.play(TRACER, { params = { from = muzzle.position, to = landsAt, speed = 260 } })

if hit then
    task.wait(round.duration)
    effects.play(IMPACT, {
        position = hit.point,
        params = { normal = hit.normal, surface = "metal" },
    })
end

normal takes exactly what a cast reports: every physics.raycast / raycastAll / raycastScreen / sphereCast hit carries { entityId, point, normal, distance, startedInside }.

An impact answers to the surface it hit

surface is an enum — it takes one of a closed list of names, and a name outside the list raises reporting the whole list. Each name is a different burst, not a different tint:

effects.play(IMPACT, { position = p, params = { normal = n, surface = "metal" } })  -- hot sparks, no dust
effects.play(IMPACT, { position = p, params = { normal = n, surface = "dirt" } })   -- no sparks, a slow cloud

effects.describe(IMPACT) lists the names in that parameter's options, so the choices are readable before you fire one.

Recipes

A grenade and a fuel tanker, from one asset

An effect's parameters are what make one asset cover a range. The builtin explosion takes its blast radius in metres, and proportions its fireball, smoke, debris and light off it:

local BLAST = "@builtin::systems.effects.combat.explosion"

effects.play(BLAST, { position = grenade.position, params = { scale = 1.5 } })
effects.play(BLAST, { position = tanker.position, params = { scale = 6.0, duration = 4.5 } })

Two plays of one asset, not two assets — and the second one costs no new memory, because it re-seats what the first gave back.

Recolouring an effect

Colour parameters take a linear { r, g, b } triple:

-- a plasma charge rather than petrol
effects.play(BLAST, {
    position = { 0, 2, 0 },
    params = { scale = 2.5, coreColor = { 0.35, 0.7, 1.0 }, smokeColor = { 0.1, 0.12, 0.2 } },
})

Stopping one early, moving it, re-tuning it

play returns a handle:

local h = effects.play(BLAST, { position = { 0, 2, 0 } })
print(h.duration)              -- seconds until it has finished on its own
print(h:stats().particlesAlive)

h:setParam("coreColor", { 0.2, 0.5, 1 })   -- re-tune while it runs
h:retarget(rocket)             -- move it: a position, or something in the world
h:stop()                       -- stop producing; what is drawing finishes
h:cancel()                     -- stop and take it away now
print(h:isFinished())

setParam carries whatever the effect declares — a number, a range, a colour, a vec2, a vec3, an entity, a flag, or one name out of an enum's options. A beam's endpoints and an impact's host are not scalars, and the channel carries them as themselves.

An effect that belongs to a thing in the world

A torch that burns while it is carried, a rocket trailing smoke, a wrecked cart still smouldering when the world is loaded again — none of those are a call somebody has to remember to make. They are a component on the entity:

local fx = cart.component.add("Effect", {
    effect      = "@builtin::systems.effects.combat.explosion",
    playOnStart = true,
    looping     = true,
    scale       = 1.6,
    params      = { coreColor = { 0.25, 0.55, 1.0 }, duration = 3.0 },
})

fx:play()                                 -- restart it from the top
fx:setParam("coreColor", { 1, 0.4, 0.1 }) -- re-tune the burning one
fx:stop()                                 -- end it and release what it held
print(fx:describe().overrides)            -- what it is actually sending

The effect follows the entity as it moves, and ends when the entity leaves the world — despawned, cleared with the scene, unloaded with its layer, or the component removed. Nothing is left leased afterwards; effects.observe() reads that back.

scale, intensity and tint are the fields effects share, and they reach the bound effect by name — an effect that declares no parameter of that name has the field reported by describe().ignored rather than silently dropped. Everything else goes in params, by the name the effect declares. All of them are declared fields, which is what makes an authored value survive a save and a load.

Unlike a fired call, a component's effect draws in edit mode too: the component is there every frame, so it owns the effect's clock and walks it forward from editorUpdate.

An effect authored in this world binds exactly the way a builtin one does — effect = "myFire" on the component, the same as effect = "@builtin::systems.effects.combat.explosion". Its init.luau is loaded through the world's own module scope, and editing that file while the component is live re-runs the new definition on the next play().

@builtin::systems.effects.demo.effect_component_showcase is the scene that shows it — three carts, two of them driving across the floor with their blast following.

When nothing appears

Ask, rather than guessing. Every reason comes from a closed set:

local why, detail = h:whySilent()
print(why, detail)                 -- nil while it is producing

local o = effects.observe()
print(o.live, o.silent, o.leased, o.pooled, o.bytes)
for _, r in ipairs(effects.silenceReasons()) do print(r.reason, r.means) end

The first one to expect is notSimulating: particles advance and draw on the play clock, so an effect fired in edit mode holds live particles and submits nothing. Press play, or set engine.mode = "play", before judging an effect.

Firing a lot of them

Repeated firing does not allocate repeatedly. The runtime leases every backend an effect is made of — its emitters, its geometry, its decal, all of it — from one pool, and re-seats them on the next shot. A hundred impacts one after another cost what the first one did.

The pool is sized by concurrency, not by shot count. It grows to the highest number of effects it has had to cover at the same time, and then holds there. Five blasts at once allocate for five; the sixth round of five allocates nothing. So a total that climbs while a burst gets wider is a pool warming up, and a total that climbs while the burst stays the same width is something leaking — and observe() reports both numbers so you never have to guess which you are looking at:

local o = effects.observe()
print(o.live, o.leased, o.pooled, o.bytes)      -- what is held right now
print(o.peakLive, o.peakLeased)                 -- the most it has ever covered

A measurement of "does this get heavier?" wants real time between shots, the way a player produces them. A synchronous loop with no waiting fires every shot into one frame, which is a wider burst than the effect ever meets in play — the pool grows once to cover that peak and holds, and read in a hurry that plateau looks like a leak.

The pool keeps what it has leased for as long as the engine runs. That is the whole retention policy — there is no idle timer — and effects.drain() is the one call that gives it back:

effects.drain()                                 -- give it all back

Reading the same frame twice

An effect played normally runs on the clock, which is no use to a screenshot or a test. Start it held and drive the clock yourself:

local blast = asset.ref(BLAST, "effect")
local h = blast:play { position = { 0, 2, 0 }, params = { scale = 3 }, held = true }
h:seek(0.4)                -- the same frame every time
h:seek(0.9)                -- forward again, to a later one

Seeks run forward. An emitter simulates in steps and has no reverse, so a target behind the handle's own clock raises — play the effect again and seek to it.

What it costs

local c = effects.describe(BLAST).cost
print(c.gpuMs, c.vramBytes, c.measuredOn)

The numbers come from the asset's own declaration — a measurement its author took and wrote down — so you can budget how many to fire at once before you fire them.

Authoring an effect

asset.create("effect", "my_blast")

scaffolds my_blast.effect/ from the type's template: effect.yaml (the declaration), init.luau (the definition), and a README. Fill both in and it plays like any builtin one.

The definition builds itself out of the ctx the runtime hands it — ctx.lease for a backend, ctx.spawn for an entity it owns, ctx.setDuration for its lifetime, ctx.onTick for what changes each frame. It never allocates directly, which is what lets the same definition run pooled from effects.play and unpooled from ref:play.

An effect is not always particles. The runtime hosts five backend kinds, and an effect leases whichever ones it is made of:

ctx.lease(kind, …)What it is
"emitter"a particle or mesh emitter
"geometry"generated geometry the effect owns — a ribbon between two points
"material"a material applied to a mesh the caller already has
"decal"an oriented projector stamped onto whatever it covers
"feature"a render feature held for the length of the play

effects.backends() lists what is registered, and effects.registerBackend(kind, backend) adds one — a family that needs a way of drawing the runtime does not ship registers it rather than widening the runtime.

The type's own reference — the folder shape, the parameter vocabulary, the handle contract and how the cost declaration is meant to be filled — is at man /zero/source/libs/@builtin/assetTypes/effect.assetType.

Effects are built programmatically where that is the better tool: noise, signed distance fields, gradients and animated masks computed in WGSL rather than sampled from authored flipbooks. The shared maths every effect shader draws on lives in @builtin::systems.effects.fx_noise, …fx_sdf and …fx_shaping.

A family shares its look rather than re-rolling it. The five combat effects above all draw their lit parts through one additive material, @builtin::systems.effects.combat.materials.combat_glow, which carries four forms — a bloom, a ring, a streak and a bolt — chosen per entity on an instance-data lane. @builtin::systems.effects.combat.glow is the module that leases the card a form is drawn on and writes those lanes, and it is the pattern to copy for the next family.

Gotchas

  • A returned screenshot is not evidence. A fully transparent emitter reports live particles, reports that it drew, and changes the image hash. Read a magnitude out of the frame and compare it against a control that is known to move it.
  • The effect owns its curves. What a caller passes are values — a size, a colour, a count, a duration. The shape of the fade over a particle's life is what the asset IS, and it is not a per-play override.
  • Slots, not particles, cost memory. An emitter allocates for its declared maximum, so an effect's VRAM figure is the same whether one particle is alive or all of them.
  • A pool warming up is not a leak. The pool is sized by how many effects run at once, so a burst wider than any before it allocates once and then holds. Compare observe().bytes against observe().peakLeased before concluding anything is growing.
  • ParticleEmitter is ambiguous between two builtin components, and component.add("ParticleEmitter", …) is refused for that reason. Effects do not go through it.
  • A fired effect draws nothing in edit mode. effects.play leaves its clock to the runtime's driver, which advances on the play clock — so an effect fired while the engine is in edit holds live particles and submits nothing, and handle:whySilent() answers notSimulating. The Effect component is the exception: it advances its own play from editorUpdate, so an effect an author attaches in the editor is visible there. whySilent() still answers notSimulating for it, because that reason is read off the engine's mode rather than off the play — read stats().age climbing instead.
  • An effect far from the world origin can report live particles and draw nothing. If a blast reads as alive but the frame is empty, move the test closer to the origin before concluding the effect is broken.

See also

  • man /zero/source/libs/@builtin/assetTypes/effect.assetType — the effect asset type: folder shape, parameter vocabulary, handle contract.
  • man effects — the global's own reference.
  • man /zero/source/libs/@builtin/systems/effects.package — the runtime, its five backends, its pool, and the shared WGSL its shaders are built on.
  • guides { path: "topics/rendering" } — materials, shaders and the per-entity instance-data lane an effect's material reads.
  • man particles — the emitter substrate underneath the particle-backed effects.