---
title: "effect"
description: "A finished, parameterised visual effect, authored as an asset. An explosion, a muzzle flash, a smoke plume: something an author reaches for and plays, rather than something they rebuild out of…"
section: "Types"
slug: "types-effect"
canonical: "https://origozero.ai/docs/types-effect"
updated: "2026-08-23T05:14:05.563737178+00:00"
tags: ["asset-type", "reference"]
---

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

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

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

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

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

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