effect
An effect declares what it is and what it costs in effect.yaml, and builds
itself in init.luau. The declaration is machine-readable, which is what lets a
caller ask an unfamiliar effect what knobs it has before touching it, and lets a
standing suite compare a measurement against what the effect claims.
The folder
<name>.effect/
effect.yaml — family, summary, declared parameters, measured cost
init.luau — the definition: play(ctx), previewEntities(ctx)
README.md — what it looks like, what each parameter changes, what it costs
preview.png — the catalogue still
.metadata — tags
effect.yaml is the primary file: asset.ref(identity, "effect") resolves to
the folder and reads it.
Identity
An effect is addressed by its scope-qualified identity — the spelling
describe() reports and every example in the library uses:
local fx = asset.ref("@builtin::systems.effects.combat.explosion", "effect")
asset.resolve("explosion", "effect") also answers, by short name, where the
short name reaches exactly one effect. Where a world authors its own effect
under that name, the short name reaches two and the call reports both
candidates for the caller to pick from — @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 write it in anything you save.
Declaring parameters
params is an ordered list. Each entry declares a name, a type, a
default, a desc, and — for a number — the min and max the effect was
authored and measured against.
params:
- name: scale
type: number
default: 1.5
min: 0.25
max: 12.0
desc: Radius of the blast in metres.
- name: coreColor
type: color
default: [1.0, 0.72, 0.28]
desc: Colour of the hot core at the instant of detonation.
Eight types are declared:
type: | Takes | Comes back as |
|---|---|---|
number | a number | held inside the declared min/max |
range | { min, max }, or one number | { lo, hi }, both held inside the range |
color | { r, g, b } | three numbers clamped to linear 0..1 |
boolean | anything | true / false |
vec2 | { x, y } or { [1], [2] } | { x, y } |
vec3 | { x, y, z } or { [1], [2], [3] } | { x, y, z } |
entity | an entity proxy or an id string | the entity's id |
enum | one of the names in its options list | that name as options spells it |
A beam's endpoints, a shield's impact point and an impact's surface normal are none of them scalars, which is why the vector and entity types are here.
An enum carries the names it accepts in an options list, and its default
is one of them. Matching ignores case and the value that comes back is the
spelling options used, so a caller writing Metal reaches the same profile as
one writing metal. A name outside the list raises and reports the whole list —
which is what makes a surface kind, a shape mode or a preset a value an author
can discover from describe() rather than guess at:
- name: surface
type: enum
options: [metal, stone, dirt, wood]
default: metal
desc: What was hit. Sets the spark colour, count, speed and settle.
:play resolves a caller's overrides against this list. Every declared
parameter gets a value; a number outside its range is held at the bound and the
holding is reported; a name the effect does not declare raises and names the ones
it does.
Parameters are values. A curve an effect walks over a particle's life belongs to the effect — the shape of the fade is what the asset IS. What a caller passes is the endpoints, the sizes and the colours that curve runs between.
Declaring cost
cost:
gpuMs: 0.31
vramBytes: 5406720
measuredOn: "native linux vulkan, debug build, 1280x720"
cost is the one machine-readable home of these numbers. describe().cost
returns it, and the README's cost prose quotes it. Fill it from a real
measurement — profiler.gpuFrame() for the span and the renderer.gpuMemory()
delta across a play for the VRAM — and say what produced them in measuredOn.
The definition
init.luau returns a table with two functions.
return {
play = function(ctx)
-- ctx.params — every declared parameter, resolved and range-held
-- ctx.position — { x, y, z } world position
-- ctx.rotation — { x, y, z, w } the caller asked for, or nil
-- ctx.direction — { x, y, z } or nil
-- ctx.target — the entity id a `playOn` seated this on, or nil
-- ctx.identity — the effect's canonical identity
-- ctx.path — the effect folder, for loading its own assets
ctx.setDuration(2.4)
local emitter = ctx.lease("emitter", { --[[ a particles.create spec ]] })
local shell = ctx.spawn("my_blast_shell")
ctx.onTick(function(t, dt) --[[ t runs 0 → 1 over the duration ]] end)
ctx.report(function() return { --[[ folded into handle:stats() ]] } end)
end,
previewEntities = function(ctx)
-- entity id/proxy, or an array of them, for the catalogue still
end,
}
The definition never allocates directly — everything it is made of comes from
ctx, which is what lets one definition run pooled from effects.play and
unpooled from ref:play. The runtime builds the handle and returns it to the
caller.
ctx member | Gives the effect |
|---|---|
lease(kind, spec) | a backend: "emitter", "geometry", "material", "decal" or "feature" |
spawn(name, opts) | an entity the play owns; always temporary |
setDuration(seconds) | how long the play lasts |
onTick(fn) | called each frame with t in 0..1 and the frame's dt |
onRelease(fn) | called once when the play ends |
onRetarget(fn) | called with the new position when the play moves |
onParam(fn) | called when handle:setParam changes a value |
report(fn) | extra fields folded into handle:stats() |
The handle the runtime returns:
| Member | Contract |
|---|---|
params | the resolved parameter values this play ran with |
duration | seconds until the effect has fully finished |
stop() | stop producing; what is already drawing finishes naturally |
cancel() | stop and take it away now |
retarget(positionOrEntity) | move it |
setParam(name, value) | re-tune a declared parameter while it runs |
isPlaying() / isFinished() | whether it is still going / has ended |
seek(t) | drive the clock forward by hand; needs held = true |
stats() | what the play is doing, plus whatever ctx.report adds |
whySilent() | reason and detail from the closed set, or nil |
An effect gives everything it leased back by the time isFinished() is true:
its entities are despawned, and its backends return to the pool — or, on the
unpooled path, are freed outright.
Everything an effect spawns is marked temporary = true. A saved scene must not
carry the wreckage of an explosion that went off while somebody was authoring,
and a one-shot must not replicate as scene content.
Methods on the ref
| Method | Answers |
|---|---|
:describe() | { identity, family, summary, cost, params } — everything declared |
:params() | the parameter declarations alone |
:cost() | { gpuMs, vramBytes, measuredOn } |
:resolveParams(overrides) | what a given override table resolves to, without playing |
:play(opts) | play it once, unpooled; returns the handle |
:preview(opts) | render the catalogue still through the shared preview rig |
:getReadme() | the effect's own README text |
Writing one
asset.create("effect", "my_blast")
scaffolds the folder from this type's template/ with a working (if plain)
definition, then fill in effect.yaml and init.luau.
See also
guides { path: "topics/visual-effects" }— how to find and play the effects the library already ships, and how the catalogue is organised.man /zero/source/libs/@builtin/systems/effects.package— the builtin effects package: the shared WGSL every effect shader is built on, and the effects themselves.