Log inGet started

Visual effects

Updated October 15, 2026

Find one before you build one

Effects are assets: asset.list("effect") lists the ones this world holds, zeromind.search the published ones, and procgen.search every graph the engine can see, a result inside an effect carrying effect (the effect's identity) and role. An effect's README says what it shows and what each input changes; effectRef:entries() lists each entry with the inputs it takes, their types and defaults.

Make one

asset.create("effect", name) scaffolds an effect playing one entry, named after it, of one graph:

beacons.effect/
  effect.yaml   entry name -> one graph, or a list of graphs played together
  graphs/       the graphs entries play
  parts/        graphs those graphs call, each one piece of the look
  shaders/  materials/  compute/  textures/   what they draw with

Each graph is a .procGraph whose init.luau returns proc.define(function(g) ... end), built the way the procgen guide teaches: nodes from the node catalogue called by family and name, knobs declared with g:input, and what the effect shows handed to io.output. A graph's declared inputs are the inputs of every entry that plays it.

effectRef:newGraph(name) adds a graph under graphs/, setEntry(name, graphs) sets what an entry plays (or adds the entry), and removeEntry, renameEntry and setDefault change the rest of effect.yaml. Inside an effect, ~.x in effect.yaml names the effect's own x, and g["~/name"] in a graph calls a node inside the effect.

A piece worth building and looking at on its own (the crack of a fissure, the spray, the smoke) is a graph under parts/. The graphs the effect plays call it as they call a node, g["~/parts/crack"]({ length = length }), and it answers what it returns; being a whole graph, it previews and plays alone. asset.validate(effectRef) reports an entry that does not resolve, a graph under graphs/ no entry plays, an asset in a folder that holds another kind, and a README still as scaffolded: the README says what the effect shows, what each entry plays and what each input changes.

How an effect is built

  • What the effect shows stands as entities or as instanced draws. Points that carry their own components stand as one entity each, which the scene can pick and script, at a CPU cost every frame and memory per entity. A piece placed on points is one geometry drawn at every point's pose in one instanced draw, each copy facing its velocity when it carries no orient, at a cost that grows with the GPU work. The procgen guide's "Entities and instanced draws" sets the two side by side.
  • Particles are points a solver holds. The sim family births them on any points (an emitter shape, a curve, a surface, the hits of another solver), pushes them with forces and bounces them off colliders; everything that colours, stretches or lights them is attribute work over the whole set of live particles.
  • Time comes from the graph clock. Anything that grows, fades or pulses reads g.clock.time; g:timeline sets whether the graph runs once ({ realTime = true, frames = 120 }) or on until stopped ({ realTime = true }).
  • Materials are records of a shader and its values; a surface .shader of the effect's own becomes one the same way a built-in shader does.
  • A piece used in more than one place is a node, the way a module is: write a .procNode (asset.create("procNode", name)) whenever the same nodes would be built again, a spark streak look or a smoke puff. Any effect or graph calls it by name, and editing it cooks every graph that uses it again.

Play one

From gameplay, play an entry on the entity it belongs to, with the inputs that fit the moment: effectRef:play(entry, opts) takes root, offset, rotation, inputs, ending ("graph", "loop" or "hold"), speed and keep (stay once finished), and the play follows root; with no root it stands in the world at offset. The handle it returns offers set (change an input), pause, resume, seek, getTime, stop, isFinished and onFinished. An input goes to every graph of the entry that declares it, and one no graph declares is refused, naming the inputs the entry takes.

A play finishes, and goes back to be played again, when every graph of its entry reaches the end of its timeline. A graph has an end when it states its frames: a blast is g:timeline({ realTime = true, fps = 60, frames = 120 }). A graph that states realTime alone has no end, and a play of it runs until stop(): a fire, a flow of water, a glow held while a button is.

Plays are kept ready: built ahead of time, handed out by play, and taken back when they finish or stop, so a play costs no build. effectRef:warm(entry, count) sizes the pool of an entry for a burst, at a level's load or before it; the effect's pool setting is the size of every entry nothing sized.

A play belongs to the peer that calls play: it is not sent to the other players. A moment every player sees is shared by the gameplay that makes it, and each peer plays the effect for it. Declare an event Sync on the component the moment happens in, fire it when it happens, and play the entry in a handler connected to it: the handler runs on every peer, each playing it on its own (the components guide's "Events" has replication). An Effect placed in a scene is part of the scene every peer has.

To place an entry in a scene, put an Effect component on an entity (effect, entry, inputs, playOn, ending, speed), or drop the effect into the viewport with effectRef:instantiate. An Effect plays the entry's graphs on Generators under its entity, and the entity's transform is where the effect stands. playOn = "manual" waits for play().

Size

A graph's values (a speed, a lifetime, a distance, a particle's size, a count) are numbers in the graph's own space, and an effect keeps its look at another size only when those numbers change together. Each way of placing an effect carries a different part of the transform it is placed with:

  • An Effect component's entity carries its whole transform, scale included: the Generators under it take the entity's scale, and what they show is drawn at it.
  • A play's root carries its position and rotation, which the play follows with offset and rotation. The root's scale does not reach the play.

An effect meant to be played at several sizes declares the size as an input (g:input("size", 1, ...)) and derives every length, speed and count from it, so a play states its size through inputs (and set while it plays) wherever it stands.

See one

An effect changes from frame to frame, so a screenshot of the scene shows whichever frame it happened to be on. Check it with a picture of the moment and with data:

  • procgen.preview pictures a graph's output alone.
  • procgen.frames pictures a play at the graph times you name: every graph of it is brought to each time, and each picture holds what they draw together, alone, framed to fit, over a grey studio backdrop (or a near-black one with backdrop = "dark", over which glows and sparks read at full brightness). It takes the play's entity, or the entity of a Generator, and each frame's outputs counts the elements drawn by name.
  • procgen.onPresent takes the same entity and fires while the effect plays, on the frame you describe (an output, a graph time or a time within a repeating cycle, an element count); with capture = true its notice carries the picture of that frame.
  • effectRef:preview() renders the default entry to a still.

What it costs

shown:playback() reports where a play stands and what it has cost, its graphs added together: cost.frameMs + cost.cookMs over cost.frames is its cost a frame, and heldFrames says whether every graph plays from the frames the engine holds once its timeline is cooked through. graphs lists each graph with its own playback(), which also says why a graph is not held (notHeldFrames) and how each output is drawn. Measure it at the particle count the effect plays at, and again at ten times that: the cost of one piece placed on many points grows with the GPU work, where an entity per particle grows on the CPU every frame.

Every play of one graph with the same inputs plays the frames the first one cooked, so an effect played every shot cooks once, and the plays warm builds cook ahead of the shot. A graph states what one play was measured to cost with g:cost { gpuMs, vramBytes, cookMs, measuredOn }, recorded in its .metadata.