The effects runtime
What effects.play runs on: a backend registry, a pool, a frame driver and a
handle.
A play
play(opts) resolves the caller's overrides against the effect's declaration,
loads the effect's own init.luau, and calls its play(ctx). The definition
builds itself entirely out of ctx:
ctx member | What it gives the effect |
|---|---|
params, position, rotation, direction, target, held | What the caller asked for. |
lease(kind, spec) | A backend instance, from the pool when the play is pooled. |
spawn(name, opts) | An entity the play owns. Always temporary. |
setDuration(seconds) | How long the play lasts. |
onTick(fn) | Called every 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 definition returns nothing; the runtime builds the handle. A definition that
returns a handle of its own — anything written before this runtime existed —
runs its own lifecycle unpooled, and reports itself as pooled = false.
The driver
One coroutine advances every live play. It starts on the first play and ends when the last one does, so an engine with no effects on screen pays nothing for the runtime being present. A play never asks the caller to tick it, and no effect registers a system of its own.
A play ends when its own clock reaches its duration, when cancel() takes it
away, when stop()'s tail runs out, or when the entity it was seated on leaves
the world.
The pool
lease buckets by kind and by the key its backend gives the spec: two specs
landing in one bucket hold the same allocation, so an instance built for either
serves both. An emitter's bucket is its slot count, its mesh, whether it sorts
and whether the driver auto-updates it — everything the buffer sizes depend on.
Everything else about a spec is re-seated onto whatever comes back.
Retention: the pool keeps what it has leased until drain(). No idle timer,
no per-play free. That is what makes repeated firing cost nothing after the
first, and it is why "allocates once" and "back to baseline after every play"
cannot both be asserted — only the first is promised.
The pool is sized by the highest concurrency it has had to cover, not by how
many shots have been fired: five plays at once allocate for five, and the sixth
round of five allocates nothing. observe() carries peakLive and peakLeased
so a reader can tell a pool warming to a wider burst from something leaking; a
drain() resets them alongside the pool it emptied.
An unpooled play — an effect asset's own :play, the reference path — frees
each backend as it gives it back, so it behaves exactly as it did before the
pool existed.
Silence
whySilent resolves nearest cause first: finished, noBackends,
targetMissing, notSimulating, noCamera, backendSilent. One quiet backend
is not a quiet play — an explosion whose debris has not spawned yet is still
drawing its fireball — so backendSilent needs every backend quiet and no
entity of the play's own.
Scoped to this part · feeds back into the world's score.