Animation in Floptle
Animation in Floptle
Floptle's animation system is built around three ideas:
- Baked animation clips (
.anim.ron) — standalone keyframe files, extracted from a model's glTF animations or authored in the editor. Channels bind by node name, so one clip plays on any rig with matching names — and on plain scene nodes (cutscenes, doors, platforms). - Animation Controllers (
.actl.ron) — a visual graph of states with crossfade rules and priority layers. Code triggers states (anim:play("Run")); the controller supplies the blending. - Stepped playback — the retro low-framerate look (8/12 fps "choppy in a good way") is built in, per controller or per state, without ever desyncing transitions or events.
Everything below is reachable from the Window menu: Animation Controller (the graph editor) and ⏱ Animating (the timeline).
1. From a model to playable animations
- Drop a
.glb/.gltfwith animations into your project (e.g.models/). - Select it in Assets — the Inspector lists its packaged animations
(
▶ Animations (6)). - Click ⬇ Extract animations. Each clip becomes its own
animations/<Model>/<Clip>.anim.ronfile — organize them however you like afterwards (controllers find a moved clip by file name as long as it stays unique).
A rigged model plays without any setup: drop it into the scene, press Play,
and its Idle (or first) clip loops. Scripts can drive the embedded clips via
node:animator() immediately. Once a clip is extracted, the .anim.ron
with the same name takes over from the embedded copy everywhere — playback,
preview, and the ⏱ Animating timeline (keys + events become editable) — still
with no controller required. For real control, add a controller:
2. Animation Controllers
Inspector → ➕ Add Component → Animation → Animation Controller (new), or
create one in the Assets browser (right-click → New Animation Controller).
Double-click a .actl.ron (or click ◉ Edit graph on the component) to open
the graph editor:
- Drag clips (▷
.anim.ron) from Assets onto the canvas — each becomes a state node. The first becomes the default (▶) state. - Drag the ○ port on a state's edge onto another state to add a transition arrow. Click an arrow to edit its fade time.
- Fades: the controller has a
default fade; per-arrow overrides beat it; a state's ⇥ override incoming fades beats everything — EVERY transition into that state uses its one fade time. Set it to 0 for a guaranteed instant snap, even with stepped playback on (it lands exactly on frame 0). - Per-state settings (click a node): clip, speed, looped, ⇥ fade-in override, and an optional per-state stepped fps.
- Stepped playback: check stepped in the header (e.g. 12 fps) for the whole controller; a state's own fps overrides it. Time itself keeps flowing smoothly — only the sampling snaps — so transitions and events never drift.
- Culling: the drop-down beside it picks when an animator using this controller does its work. Only when visible (the default for a new controller) skips a model that is hidden, or behind or beside the active camera. When the model comes back into view it catches up in one step to where it would have been, and the events of the time it was skipped don't fire. Reduced when far also poses every third frame past 30 m. Animate always is what a controller made before this option existed does, and it's the one to keep when something rides a bone while nobody looks, such as a hitbox on a hand or a muzzle a gun fires from. A level of 64 enemies, most of them behind the player or past the fog, only pays for the ones on screen.
Layers
Layers are a priority stack: left = base, right = higher priority. A playing state on a higher layer overrides the nodes its clip animates; everything else shows through from below, and the weight slider blends the whole layer.
The classic setup: a Movement base layer (Idle/Walk/Run/Jump with
transitions), plus an Attack layer above it containing one-shot attacks. When
a script calls anim:play("Slash"), the attack takes over; when the one-shot
finishes, the layer releases automatically and movement shows again. If the
attack clip only animates the arms, the legs keep walking.
Four more things a layer and a state can do. The controller editor has no
controls for these yet, so set them in the controller file (.actl.ron):
additive: trueon a layer adds its motion on top of the layers below instead of replacing it. Each clip is measured from its own first frame, so a lean clip that starts upright and ends tilted tilts whatever is under it: a run, a slide, a wallrun.mask: ["Spine1", "Neck", "Head"]on a layer limits it to those bones, whatever its clips key. A look layer can then own the upper spine while a body clip keeps the hips and lower spine.blendon a state mixes several clips by one or two controller parameters instead of playing one. Scripts set the parameters withanim:setParam. The state'sclipstill sets its clock:
(name: "Aim", clip: "animations/Soldier/AimCenter", pos: (0, 0),
blend: Some((params: ["aimX"], points: [
(clip: "animations/Soldier/AimLeft", at: (-1, 0)),
(clip: "animations/Soldier/AimCenter", at: ( 0, 0)),
(clip: "animations/Soldier/AimRight", at: ( 1, 0)),
]))),
With one parameter the points sit on a line and the two either side of the
value mix. With two (params: ["moveX", "moveZ"]) they sit on a plane and
mix by distance, exactly one clip when the value lands on its point.
mirror: trueon a state plays its clip as its left-right mirror image, so a left wallrun serves as the right one. Bones pair by name:Hand.L↔Hand.R,hand_l↔hand_r,LeftHand↔RightHand. A bone with no twin, such as the spine or the head, is reflected onto itself. The reflection is across the character's left-right plane, with the model facing +Z as glTF does. It works even when the two sides' bones were rolled differently in the modelling program.
Each layer also has its own speed: anim:setSpeed(1.5, "Movement") speeds up
the run to match the ground, and a punch on the layer above keeps its own
speed.
3. The ⏱ Animating tab (timeline)
Select a node that has a controller (or a rigged model) and open Window → ⏱ Animating:
- Pick the animation from the dropdown (the controller's states).
- Scrub the ruler to preview; ⏵ plays a live preview; ⏹ restores the scene pose. Previews never dirty the scene — undo/save always see authored transforms.
- Events lane (⚑): Add event at playhead → the flag calls a Lua function (by name) on the node's scripts when the playhead crosses it during Play. Drag flags to retime; right-click to delete. Great for footsteps, hit frames, spawning VFX.
- Key rows: one row per animated node; diamonds are keys (the union of the
T/R/S lanes). Drag to retime, right-click to delete.
snapquantizes to a frame grid (8/12/24/30/60 fps), and with a grid set the ruler counts in frames. ⏵ on a node row opens it into its position, rotation and scale lanes, where a key is dragged, deleted or given an interpolation on that lane alone. - Fit the sheet to the rig: the row height is yours — Alt+wheel over the sheet or type it in the toolbar (4–80 px); packed rows keep their keys clickable and name themselves on hover. The name column resizes by dragging its edge. 🔍 rows shows only the rows whose name matches, so a sixty-bone rig is animated a limb at a time (Ctrl+A then selects what is shown).
- 📈 Curves (or Tab): the same clip as values over time — one node's position, rotation (in degrees) and scale, each key a point on its curve. Drag a point in time and value (Shift keeps the time), right-click empty space to key a lane at that time without changing the motion, right-click a point for its interpolation. The curves are drawn through the same sampler the game uses, so an eased key shows the motion it will produce. One lane shown draws a real value grid that Alt+wheel zooms and Shift+wheel pans; several shown share the height, each on its own range, since metres and degrees do not share an axis.
- Interpolation is per key: right-click a key → Interpolation to choose
how it reaches the next one — Linear, Smooth (a spline through the
neighbouring keys, so the motion has no corner at the key), Ease in, Ease
out, Ease in-out, or Hold (keep the pose, then snap). With several keys
selected the choice applies to all of them. The key's shape says which: a
diamond is linear, a circle smooth, a square a hold, and a dotted diamond an
ease. In the file this is
modes: [(t: 0.5, mode: Smooth)]on the lane, listed by the key's time. - ● Record (scene animation): with record on, pose the node's children with the gizmo or Inspector and keys are written at the playhead for whatever you moved. Scrubbing previews what you've keyed so far, so scrub → pose → scrub → pose is the whole authoring loop. Recording edits the clip, never the scene: turning record off (or ⏹) restores the exact pose the subtree had when you turned it on. Use ✚ New… to start a fresh empty clip (it's added to the controller too).
Property lanes — what can be keyed
✚ Property ▸ node ▸ component ▸ field. Components are grouped, and each component puts the handful of fields people reach for at its top level with the rest one step in (Colour, Surface, Sheet, Layout, Flags), so a material's three dozen animatable fields do not bury its texture and its opacity.
| Component | What you get |
|---|---|
| Material | texture, opacity, ambient; Colour — base r/g/b, emissive, specular, shininess, rim; Sheet — cell, sheetCols, sheetRows; Surface — the maps and their strengths, roughness, metallic, reflectivity, transmission, ior, thickness; Flags — unlit, fog, shading, jitter |
| UiElement | image, opacity, visible, text; Layout, Colour, Text, Sheet |
| PointLight | intensity, range, colour |
| UiSlider | value, min, max |
| Camera | fovY, orthoHeight |
| Sprite | frame — see below; Node — ppu, size, pivotX, pivotY, flipX, flipY |
The names in the table are the groups; the field names are what a .anim.ron
stores, and are what you type if you hand-edit one. Colours are per channel:
r g b for a material's base colour, emissiveR/G/B + emissiveStrength,
specularR/G/B + specularStrength + shininess, rimR/G/B + rimStrength.
A UI element's are fillR/G/B/A, textR/G/B/A, tintR/G/B/A. opacity is a
material's alpha, and orthoHeight is a 2D camera's zoom.
visible, unlit and fog are numbers because a keyframe holds one: 0 is
off and 1 is on, and they are created as stepped lanes so nothing is ever half
on.
● Record picks all of them up, not just the numbers: change a material's
texture with record on and a stepped Material.texture lane appears with a key
at the playhead, exactly as changing its opacity gives you a smooth
Material.opacity lane. Fields whose numbers are indices or flags rather than
quantities — a spritesheet cell, unlit — are created stepped, because
half a cell is not a cell.
Recording authors the clip and never the scene: turning record off puts every value back, paths included.
A field is only animatable if the engine can both read and write it — read so record notices it change, write so a key plays back. A test walks the whole list and asserts both, because each half fails silently on its own.
Sprite lanes
Sprite is one heading with two halves. frame is the picture; Node is
what a ▫ Sprite node does with it.
✚ Property ▸ Sprite.frame adds a sprite lane, on any node wearing a material. Each key holds a whole frame — the image, how it is cut, and which cell — picked together, because they are drawn together: a texture and a grid that land on different keys give you a cell index read against the wrong grid, which draws a slice of the wrong picture and reports nothing.
A sprite lane is a step lane and cannot be anything else — the conversion forces it whatever the file says. Interpolating two frame references is meaningless, and interpolating two cell indices plays every cell in between, which reads as the clip running at the wrong speed rather than as a bug.
✚ Property ▸ Sprite ▸ Node keys the ▫ Sprite node's own numbers: ppu and
size (squash and stretch), pivotX/pivotY (shift the origin for a crouch),
and flipX/flipY (face the other way on a turn). These do nothing on a node
that is not a ▫ Sprite, and frame — which writes the material — works on
anything wearing one.
The flips are stepped, like every other on/off lane: 0 is left alone and 1 is mirrored. An eased flip would turn the sprite round exactly halfway between two keys, at a moment nobody authored.
The other way to author sprite animation is a
.spriteanim.ron — a frame
list with a frame rate, which is how a walk cycle is actually written down. It
loads as an ordinary clip, so both ways end up in the same controller, the same
crossfades and the same script API. Use a lane when the sprite is part of what
is animated (a character whose picture changes and whose node moves is one clip,
not two); use the file when the clip is frames and nothing else.
The Animating tab plays a .spriteanim.ron but does not edit it: the timeline
writes keyed lanes, which is a different shape and a different filename, so
saving one as the other would leave two files claiming one clip.
Two states, one clip
A state in a controller is a name plus a pointer to a clip file. Nothing
stops two states pointing at the same file — that is how you reuse one Hit
clip across three attacks, and it is a real authoring choice. But it also means
those states are one animation: key it under either name and both change.
So the ⏱ Animating tab says so. A shared clip is marked ⚠ shared in the animation dropdown, and selecting one shows a banner naming every state that plays it, with two fixes:
- Give this state its own copy — copies the clip to a new file named after the state and repoints only this state at it. The others keep sharing.
- Split every shared state — one click for a whole controller: the first state to use a clip keeps it, everyone else gets their own copy. This is the one to reach for after generating a controller in bulk (a stack of states wired up by hand or by a script often share a handful of placeholder clips), before you start animating.
Neither is automatic: sharing that you meant stays shared.
Model-embedded clips that haven't been extracted are previewable but not editable — click ⬇ Extract on the model and the timeline opens the extracted files instead (no controller needed). Bone-level re-authoring stays in Blender; events + timing live here.
Scene clips bind channels by node name relative to the controller's node
("" = the node itself), so a DoorOpen clip written against one doorframe
retargets to any other node tree with the same child names.
4. Scripting
-- movement.lua — drive a character's controller from physics state.
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") -- controller fade table applies
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") -- one-shot on the Attack layer
end
end
-- called by a ⚑ event placed on the Slash clip:
function onSlashHit(node)
log("hit frame!")
end
anim:play(state [, fade [, layer]])— safe to call every frame (re-playing the current state never restarts the blend).anim:restart(...)— force re-entry (re-trigger a one-shot).anim:crossfade(state, fade [, layer])— explicit fade.anim:stop([layer [, fade]]),anim:setSpeed(x),anim:setLayerWeight(layer, w),anim:seek(t [, layer]).anim:setEnabled(false)switches the animator off (it holds its last pose and costs nothing);anim:setCulling("always" | "whenVisible" | "reduced")overrides the controller's culling for this one node.- Where the bones are:
node:bonePos(name)(a world vec3),node:boneRot(name)(yaw, pitch, roll) andnode:bones(), on the model's node. They answer where a node attached to that bone would be, so a ragdoll can read the pose at the moment of death without a marker node per bone. - Bones, after the clips:
anim:setBoneRot(bone, yaw, pitch, roll [, w]),anim:addBoneRot(...),anim:setBonePos(bone, v [, w]),anim:addBonePos(...), two-bone IK withanim:reach(tip, target, { pole = p }), andanim:lookAt(bones, target, { limit = 75 }). See Bones from a script. - Many bones at once:
anim:boneIndex(name)gives a bone's number (its place innode:bones()), every bone write takes that number instead of the name, andanim:setBones(bones, positions, rotations [, w])sets a whole list in one call.anim:holdPose(true)keeps the last pose a script wrote while a frame writes nothing. anim:boneWorld(bone)gives the bone's world position, yaw, pitch and roll after all of that, including writes made earlier in the sameupdateorlateUpdate: set a bone, then read it, and you get where you sent it. The writes are counted once: they land on the animator's pose, not on last frame's drawn pose, which already held last frame's writes. Read before any write, it gives the animator's pose for this frame, without last frame's writes, unlessanim:holdPose(true)is keeping them. It works underfloptle runand on a dedicated server too.- Reads:
anim:state([layer]),anim:time([layer]),anim:finished([layer]),anim:isPlaying([state]),anim:clips(),anim:layers(). - Authored data, from the asset rather than playback (so it works in
start()):anim:duration(clip)andanim:events(clip)→{ {t, func}, ... }ascending byt. A game with integer frame data bakes these once at load instead of letting events drive gameplay — see the scripting reference.
Ordering each frame is scripts → animation → physics: what you set this frame shows this frame.
Animation vs physics: because physics integrates last, a node with a dynamic RigidBody is owned by the simulation — scene-animating its transform has no visible effect. Animate plain nodes (doors, platforms, cameras, props) and give them Collidable if things should bump into them; drive rigidbodies from scripts via velocities instead.
Bones from a script
The clips pose the rig first, then a script can change it for the frame:
- Bone writes (
setBoneRot,addBoneRot,setBonePos,addBonePos) run in the order they were called. reachandlookAtrun in the order they were called.
All of them last one frame. Call them every frame you want them held, and stop
calling to hand the bone back to the clips. Or hold them:
anim:holdPose(true) keeps the writes of the last frame that made any, so a
still pose (a canopy lying on the ground) costs nothing to keep. The first
write of a later frame replaces the whole held pose, and anim:holdPose(false)
hands the rig back to the clips that same frame. Angles are radians in the
node.yaw/pitch/roll convention, and positions are in the bone's parent space.
Targets and poles for reach and lookAt are world points. Every call takes a
weight from 0 to 1 that blends from the animated pose. All of it needs a rigged
model (a .glb with a skeleton). A bone the model doesn't have warns once, in
the Console.
reach bends the tip's parent and grandparent: for "Foot", the shin and the
thigh. Name them with root and mid when the rig has a twist bone between.
pole is a point the middle joint bends toward, such as a point in front of
the knee or behind the elbow. A target out of reach straightens the limb
toward it.
A hundred bones a frame. A procedural rig (a cloth canopy, a rope, a
tentacle) is cheaper posed in one call than one call per bone: the name copy,
the lookup and the crossing into the engine are most of each write's cost.
Number the bones once, the first frame the model has posed (boneIndex is
nil until then, so not in start), and send flat lists: x, y, z per bone in
positions and yaw, pitch, roll per bone in rotations. Either list may be
nil to leave that channel alone.
local ids, pos, rot
function lateUpdate(node, dt)
local anim = node:animator()
if not ids then
if not anim:boneIndex("Rib1") then return end -- not posed yet
ids = {}
for i = 1, 100 do ids[i] = anim:boneIndex("Rib" .. i) end
pos, rot = {}, {}
end
for i = 1, #ids do
pos[3 * i - 2], pos[3 * i - 1], pos[3 * i] = 0, 0.1 * i, math.sin(i + os.clock())
rot[3 * i - 2], rot[3 * i - 1], rot[3 * i] = 0, 0.05 * i, 0
end
anim:setBones(ids, pos, rot)
end
Planting feet on uneven ground. Cast a ray down from where each foot stands, drop the hips by the deeper foot's gap, then reach each foot to its hit:
local HIP_DROP_MAX = 0.35
-- Where each foot stands, in the character's own frame.
local STANCE = { LeftFoot = vec3(-0.12, 0, 0.05), RightFoot = vec3(0.12, 0, 0.05) }
function update(node, dt)
local anim = node:animator()
local drop, hits = 0, {}
for foot, offset in pairs(STANCE) do
local p = node:toWorld(offset)
local hit = raycast(p + vec3(0, 0.5, 0), vec3(0, -1, 0), 1.5)
if hit then
hits[foot] = vec3(hit.x, hit.y, hit.z)
drop = math.max(drop, p.y - hit.y)
end
end
drop = math.min(drop, HIP_DROP_MAX)
anim:addBonePos("Hips", vec3(0, -drop, 0))
local fwd = node.forward
for foot, point in pairs(hits) do
anim:reach(foot, point, { pole = point + fwd * 0.5 + vec3(0, 0.5, 0) })
end
end
Take the targets from a fixed stance like this, not from where the feet were
last drawn. A target taken from a foot the reach already moved (last frame's
drawn pose, or anim:boneWorld(foot) read after this frame's reach) starts
from where the reach put it, and on a slope each frame's target lands a little
lower than the one before: the feet creep downhill and the legs stretch out
from under the hips. Read before this frame's reach, boneWorld gives the
animator's pose, which is a fine place to start from. Keep the hips' drop under the length
of a bent leg, or a foot on a deep step can't get there and straightens instead.
Looking at something. Share the turn along the neck so one bone isn't doing it all, and cap it so a target behind the character doesn't snap the head round:
anim:lookAt({ "Spine2", "Neck", "Head" }, target.pos, { limit = 80, weight = 0.8 })
The last bone's forward at rest is +Z. If the rig's head faces another way,
say so with axis = vec3(0, 1, 0).
5. The retro stepped look
Set stepped on a controller (say 12 fps) and every state samples on that frame grid — the classic hand-animated choppiness. Override per state (a snappy 8 fps attack over 24 fps movement is a great combo). Two guarantees:
- Transitions stay exact: a state whose fade-in override is 0 shows its frame 0 the moment it's triggered — the frame grid never delays it.
- Events stay exact: event timing uses real (smooth) time, so a footstep at
t = 0.43sfires at 0.43s regardless of the visual frame rate.
6. File reference
| Asset | Extension | Home (default) | Notes |
|---|---|---|---|
| Animation clip | .anim.ron |
animations/<Model>/ |
self-contained keys + events, name-bound |
| Animation controller | .actl.ron |
animation_controllers/ |
layers, states, fade table, stepped fps |
| Sprite animation | .spriteanim.ron |
beside the art | a frame list with an fps — loads as a clip |
All three are plain RON — hand-editable, diff-able, and discovered anywhere under
assets/ by extension.
Skinning happens on the GPU
A weighted mesh is deformed in the vertex shader. The bind pose is uploaded once, at import, along with each vertex's four joint slots and weights; every frame each character supplies only its bone palette — one matrix per joint — and draws the same shared buffer.
What this changes for a game is the ceiling on how many animated characters can be
on screen. The old path deformed every vertex of every character on the CPU and
re-uploaded the result to a vertex buffer that had to be private per character,
because two characters sharing one .glb would otherwise share one buffer and the
last one posed would win for both. A mid-detail character — 8,000 vertices over 24
joints — cost 0.114 ms of CPU per frame, so fifty of them spent a third of a 60 fps
frame before anything drew. The same fifty now cost the CPU 24 matrix multiplies
each, and because they share one buffer again they are one draw call.
The CPU deform is still there, and still tested, as the fallback for a part the
skinning store cannot take (it is bounded at ~8.3M skinned vertices per scene by
the instance lane that addresses it). A part wearing a custom .flsl material
skins on the GPU too, through that shader's own skinned pipeline. The two paths
are held to producing the same picture by cargo run -p floptle-render --example skin_probe, which renders one posed mesh both ways, with the built-in look and
with a .flsl material, and fails if they disagree.