Animation, particles & sound
Animation, particles & sound
The three things that make a scene feel alive, driven from a script.
Part of the scripting guide · every call, as a reference
Contents
- 9. Animation:
node:animator() - 10. Particles:
node:particles() - 11. Audio:
audio.play,node:sound()& the mixer
9. Animation: node:animator()
Any node with an Animation Controller component (or a rigged model with
embedded clips) exposes an animation handle. See docs/animation.md for the
full system (controllers, layers, events, the stepped retro look).
local anim
function start(node)
anim = node:animator()
end
function update(node, dt)
local speed = math.sqrt(node.vx^2 + node.vz^2)
if not node.grounded then anim:play("Jump")
elseif speed > 6 then anim:play("Run")
elseif speed > 0.5 then anim:play("Walk")
else anim:play("Idle") end
if input.pressed("j") then anim:restart("Slash") end -- one-shot attack layer
end
-- called by a ⚑ event key placed on a clip's timeline:
function onSlashHit(node) log("hit frame!") end
| Call | What it does |
|---|---|
anim:play(state [, fade [, layer]]) |
transition (controller decides the fade; safe every frame) |
anim:restart(state [, fade [, layer]]) |
force re-entry (re-trigger a one-shot) |
anim:crossfade(state, fade [, layer]) |
transition with an explicit fade |
anim:stop([layer [, fade]]) |
stop a layer (all if omitted) |
anim:setSpeed(x) |
global speed multiplier |
anim:setLayerWeight(layer, w) |
blend a layer over the ones below (0..1) |
anim:seek(t [, layer]) |
jump the playhead |
anim:state([layer]) / anim:time([layer]) |
what's showing / seconds in (anim:current is an alias of anim:state) |
anim:finished([layer]) |
a one-shot reached its end |
anim:isPlaying([state]) |
is a state (or anything) playing |
anim:clips() / anim:layers() |
available state / layer names |
anim:duration(clip) / anim:events(clip) |
the clip as authored — length in seconds, and its event list |
anim:duration / anim:events read the asset, not playback, so they answer
in start(), before anything has played a frame. events returns
{ {t = seconds, func = "onHitboxStart"}, ... } ascending by t — an unknown
clip is nil, a clip with no events is an empty list.
They exist so an animator can author timing by eye while the game still runs on integer frames. Drop an event on the frame where the strike connects, then bake it once at load:
local dur = anim:duration(clipName)
for _, e in ipairs(anim:events(clipName) or {}) do
if e.func == "onHitboxStart" then
move.startup = math.floor(e.t / dur * move.frames + 0.5)
end
end
Don't let events drive frame-exact gameplay directly: they fire off float
playback time, stepped playback (sample_fps) quantises them to its grid, clip
time and gameplay frame disagree mid-crossfade, and a prediction replay
deliberately doesn't re-fire them. Every machine loads the same .anim.ron, so
a baked number is identical everywhere and constant thereafter.
Conditional expressions. Lua's and/or chain is an inline if/else —
handy for mapping states to values without an if-ladder:
local speed = anim:isPlaying("Running") and 2
or anim:isPlaying("Walking") and 1
or 0
The one gotcha: put the condition first. a and b yields b only when
a is truthy, so 2 and anim:isPlaying("Running") gives you the boolean,
not the 2. (And this only picks non-false values — cond and false or x
always lands on x.) Method names are camelCase: anim:IsPlaying is an
error, and the animator will suggest the spelling it thinks you meant.
Events → functions. Put a ⚑ event on a clip in the ✎ Animating tab and name a function; when the playhead crosses it during Play, that function is called (with the node) on every script attached to the controller's node that defines it.
10. Particles: node:particles()
Any node with a Particle System component exposes a particle handle, so
scripts can fire and stop effects on cue — muzzle flashes, footstep dust,
thruster plumes, pickups. See docs/subsystems/particles-vfx.md for authoring
effects on the ✱ Particles timeline.
function update(node, dt)
local p = node:particles()
-- one-shot burst on each shot (re-fires even mid-play):
if input.clicked(0) then p:restart() end
-- a continuous effect that follows a condition:
local jet = find("Thruster"):particles()
if input.key("w") then jet:play() else jet:stop() end
if p:isPlaying() then log("smoke: " .. p:alive() .. " alive") end
end
| Call | What it does |
|---|---|
p:play() |
start emitting if idle (spawns a fresh instance); no-op if already playing |
p:stop() |
stop + despawn — the live particles vanish |
p:restart() |
re-spawn from t=0 (re-fire a one-shot burst) |
p:setIntensity(i) |
live emission scale 0..~2 — throttle a plume without touching the asset |
p:setBeamEnd(x, y, z) |
aim every Beam track's endpoint at a WORLD point (converted to effect-local, so the beam tracks the target as the node moves) |
p:isPlaying() |
is an instance emitting/ageing right now |
p:alive() |
live particle count across the effect's tracks |
p:asset() |
the effect asset key this node references, or nil |
Handles work cross-node:
find("Campfire"):particles():stop(). A node's Play on start flag is also scriptable —node:getcomponent("ParticleSystem").play_on_start = 1.
spawnEffect — fire a one-shot at a world point
For hits, pickups, footstep poofs — effects that aren't tied to a node — spawn one anywhere in the world and forget it. It plays once and despawns itself:
function update(node, dt)
if input.clicked(0) then
local h = raycast(node.x, node.y, node.z, fx, fy, fz, 100)
if h then spawnEffect("vfx/Impact", h.x, h.y, h.z) end
end
end
spawnEffect(key, x, y, z) — key is the effect asset (project-relative, no
.vfx.ron); the position is world space. Author it as a one-shot effect on
the ✱ Particles timeline so it ends cleanly. That's the whole loop: design it on
the timeline → spawnEffect it from gameplay.
11. Audio: audio.play, node:sound() & the mixer
Playing a sound needs nothing but a clip path — no prefab, no source node, no spawn-then-get-component dance:
audio.play("audio/ding.ogg") -- flat 2D (UI, stingers)
audio.play("audio/hit.ogg", h.x, h.y, h.z) -- 3D at a world point
audio.play("audio/engine.ogg", carNode, {loop = true}) -- follows the node
Sounds default to spatial: they attenuate with distance and pan toward where they are relative to the active camera. Every knob rides in the options table (all optional):
local s = audio.play("audio/roar.ogg", bossNode, {
volume = 0.8, -- linear, 1 = as authored
pitch = 1.1, -- playback rate (also shifts pitch)
mode = "Spatial", -- "Distance" = attenuate only · "Flat" = plain 2D
falloff = "Inverse", -- "Linear" · "Exponential"
minDistance = 2, -- full volume inside this range
maxDistance = 50, -- silent past this range
track = "SFX", -- mixer track to route through (default Master)
endBehavior = "Destroy", -- "Stop" (default) · "Destroy" · "Loop"
})
audio.play returns a sound handle, live until the sound ends:
| Call | What it does |
|---|---|
s:stop() |
fade out (a few ms — never clicks) and end |
s:pause() / s:resume() |
freeze / continue playback |
s:setVolume(v) / s:setPitch(v) / s:setPan(v) |
live tweaks |
s:setTrack("Music") |
re-route through another mixer track |
s:setPosition(x, y, z) |
move the emitter (stops following a node) |
s:seek(secs) |
jump the playhead |
s:isPlaying() / s:position() |
playback state |
endBehavior = "Destroy" on a node-following sound despawns that node when
the sound finishes — spawn a node, hang a sound on it, and it cleans itself up.
The Audio Source component
For authored emitters (ambient loops, music zones, alarm props), add an
Audio Source in the Inspector (➕ Add Component): pick the clip, spatial
mode, falloff, distances, mixer track, end behavior, and Play on start.
Scripts drive it through node:sound():
local alarm = find("Alarm"):sound()
alarm:play() -- restart its clip
alarm:setClip("audio/alarm2.ogg")
alarm:pause() alarm:resume() alarm:stop()
if alarm:isPlaying() then log(alarm:position()) end
Its tunables mirror live through getcomponent (numbers only, like every
component):
| field | Meaning (Inspector: ♪ Audio Source) |
|---|---|
volume |
linear volume 0..2 |
pitch |
playback rate (0.5 = octave down) |
pan |
stereo pan −1..1 (Flat mode) |
minDistance / maxDistance |
the falloff range |
playOnStart |
1/0 — play when Play starts |
mode |
0 = Spatial · 1 = Distance · 2 = Flat |
falloff |
0 = Inverse · 1 = Linear · 2 = Exponential |
endBehavior |
0 = Stop · 1 = Destroy · 2 = Loop |
node:getcomponent("AudioSource").volume = 0.3 -- live while playing
The mixer
Everything audible routes through the 🎧 Mixer tab: named tracks with a
fader, pan, mute/solo, an effect chain (parametric EQ with a draggable curve,
delay, reverb, chorus, flanger, phaser, pitch shift, compressor, limiter,
distortion, utility), and routing — tracks can output into other tracks
(e.g. Footsteps → SFX → Master). The graph saves with the project
(project.ron); anything that doesn't name a track plays on Master.
Scripts get live control that reverts when Play stops:
audio.track("Music"):setVolume(-12) -- duck music (fader dB)
audio.track("SFX"):setPan(0.2)
audio.track("Master"):setMuted(true)
audio.stopAll() -- silence everything
Clips are plain files under assets/audio/ (.wav, .ogg, .mp3,
.flac) — double-click one in the Assets browser to preview it. Clip
references are project-relative paths ("audio/hit.ogg"; the extension may
be omitted).