Log inGet started

effect

Updated 23 August 2026

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:TakesComes back as
numbera numberheld 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
booleananythingtrue / false
vec2{ x, y } or { [1], [2] }{ x, y }
vec3{ x, y, z } or { [1], [2], [3] }{ x, y, z }
entityan entity proxy or an id stringthe entity's id
enumone of the names in its options listthat 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 memberGives 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:

MemberContract
paramsthe resolved parameter values this play ran with
durationseconds 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

MethodAnswers
: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.
  • asset-type
  • reference