---
title: "Visual effects"
description: "Explosions, muzzle flashes, smoke, sparks: the things that happen for a second and then are gone. The engine ships them as effect assets — finished, parameterised effects you play, rather than…"
section: "Topics"
slug: "topics-visual-effects"
canonical: "https://origozero.ai/docs/topics-visual-effects"
updated: "2026-09-04T19:42:52.195439856+00:00"
---

# Visual effects

## 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:

```luau
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:

```luau
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:

```luau
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

```luau
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:

```luau
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:

```luau
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 = { … } })`.

| Identity | What it is |
|---|---|
| `@builtin::systems.effects.combat.muzzleFlash` | the bloom at a barrel the instant a weapon fires |
| `@builtin::systems.effects.combat.tracer` | a round in flight between two world points |
| `@builtin::systems.effects.combat.plasmaBolt` | a slower, self-lit bolt with a pulsing core |
| `@builtin::systems.effects.combat.impact` | the burst where a round lands, answering to the surface it hit |
| `@builtin::systems.effects.combat.shockwaveRing` | the pressure front a blast pushes out across a surface |
| `@builtin::systems.effects.combat.explosion` | the 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:

```luau
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:

```luau
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:

```luau
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:

```luau
-- 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:

```luau
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:

```luau
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:

```luau
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:

```luau
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:

```luau
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:

```luau
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

```luau
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.
