Skip to content

Animation

Estella has three animation systems, each for a different job:

  • Sprite animation — frame-by-frame sprite-sheet playback (SpriteAnimator).
  • Tweens — interpolate any property over time with easing (Tween).
  • Animator state machine — character animation driven by parameters (Animator).

They compose: a state machine can drive sprite clips, and tweens can juice a UI element while a sprite loops.

The flipbook editor — a 4-frame walk clip with per-frame timing and a live preview

The flipbook editor: a 4-frame walk clip, per-frame durations, and a live preview with FPS / loop / onion-skin.

The SpriteAnimator component plays named sprite-sheet clips (authored as anim-clip assets). Switch clips by writing the component through a Mut query:

import { defineSystem, Query, Mut, SpriteAnimator } from 'esengine';
const setClip = defineSystem([Query(Mut(SpriteAnimator))], (q) => {
for (const [entity, animator] of q) {
animator.clip = 'walk';
animator.speed = 1.5;
animator.playing = true;
}
});
Property Type Default Description
clip asset '' Current clip (an anim-clip asset name).
speed number 1 Playback speed multiplier.
playing boolean true Whether playback is advancing.
loop boolean true Loop the clip vs. hold the last frame.
enabled boolean true Disable to freeze the animator.
finished boolean false Read-only: latched when a one-shot clip completes. Raising playing on a finished animator replays from frame 0.
currentFrame number 0 Read-only: the frame index being shown.
frameTimer number 0 Read-only: time accumulated on the current frame.

For finer control, the SpriteAnimation resource jumps to a specific frame or a named label and registers clips at runtime:

import { defineSystem, Query, Mut, Res, SpriteAnimator, SpriteAnimation } from 'esengine';
const jump = defineSystem([Query(Mut(SpriteAnimator)), Res(SpriteAnimation)], (q, sprites) => {
for (const [entity, animator] of q) {
sprites.gotoFrame(animator, 0, true); // frame index, and play
sprites.gotoLabel(animator, 'attack', true); // named label, and play
}
});
Method Description
gotoFrame(animator, index, andPlay?) Jump to a frame index (optionally start playing).
gotoLabel(animator, label, andPlay?) Jump to a named label in the clip.
registerClip(clip) Register a SpriteAnimClip at runtime.
getClip(name) Look up a registered clip.

Clips carry frame events (SpriteAnimEvent — { frame, name, data? }) that fire when playback reaches a frame: a footstep sound, the hit frame of an attack, the moment a coin flips. Author them on the Events track of the Flipbook editor — pick a frame, Add event, name it; the frame strip marks which frames carry one. Then subscribe:

import { defineSystem, Res, SpriteAnimation } from 'esengine';
const listen = defineSystem([Res(SpriteAnimation)], (anim) => {
anim.onEvent(playerEntity, (e) => {
if (e.name === 'footstep') audio.playSFX('assets/sfx/step.wav');
});
});
Method Description
onEvent(entity, handler) Listen to one entity’s frame events. Returns an unsubscribe function.
onEventGlobal(handler) Listen to every entity’s — the handler receives the entity too.

Clips are .esanim assets you author visually — no JSON by hand:

  1. Either right-click a texture and choose Create Sprite Animation — the Flipbook editor opens with the sheet under a slicing grid (the cell size is guessed from the image; adjust cell width/height, margin, and spacing in the toolbar) — or select several images and choose Create Sprite Animation from N Images, which lines them up in file-name order (counting numerically, so run_2 precedes run_10).
  2. Click or drag across cells to append frames, or drop images onto the timeline to insert them. A block on the timeline is as wide as its frame is long: drag its right edge to retime, drag the block itself to reorder. The viewport draws the current frame at its true size against the clip’s anchor, with the neighbouring frames ghosted in their own places.
  3. Save, then right-click the .esanim → Create Animated Sprite (or drag it into the viewport). You get a Sprite + SpriteAnimator entity posed at frame 0 — it plays in Play mode with zero code.

Selecting an animated sprite loops its flipbook in the viewport while editing (toggle with the Preview FX show flag). If the clip is open in the Flipbook editor at the same time, frame edits animate live as you make them.

Re-slicing the grid moves every frame consistently — frames reference grid cells, not pixel rectangles. Frames that fall outside the current grid are flagged red in the strip.

The frame under the playhead has its own section in the Details inspector — duration, size, offset, anchor — alongside the clip’s fps, loop mode, and the two switches below.

Drop a .aseprite (or .ase) into the project and the import writes the products for you: the composited sheet as a .png beside it, and one .esanim per tag — hero_walk.esanim, hero_jump.esanim — carrying each frame’s own duration and the direction the tag plays in. A tag with no repeat count loops; one that counts its plays does not. A file with no tags gets a single clip over every frame.

What is composited is what the artist sees: every layer they left visible, with its blend mode and opacity, and nothing from a group they switched off. A tilemap layer is drawn from a tileset rather than from pixels and is reported instead of silently arriving blank.

The sheet’s texture arrives set to nearest filtering with compression off — the two settings pixel art always wants and never gets by default — and with its cell grid filled in, so a Sprite can pick a frame of it straight away. Change either setting and it stays changed; the grid is re-derived on every import, because it is the source’s canvas size rather than a preference.

Editing the file and saving it re-imports it in place: same uuids, same refs, new pixels. Right-click → Reimport does the same on demand.

A sheet’s cells are all the same size, so one Sprite.size fits every frame of it. Separate images are not: a 96×64 attack frame and a 48×96 idle frame drawn at one size are both wrong. Tick Frame Sizes and the clip owns Sprite.size during playback — each frame draws at its own natural size (its texture’s pixels, or its sheet cell), or at a size you give it.

That also unlocks offsets. An offset shifts a frame’s artwork, in the same units as the size, and is folded into the frame’s anchor when the clip loads — so nothing downstream has to know about it. Authoring the shift in units rather than as a raw anchor is the point: the same offset is the same visual shift on frames of any size, which a normalized anchor cannot be. Drag the frame in the viewport to set one, or type it in the inspector.

// step.esanim — three images of different sizes, one of them nudged right
{
"version": "1.5",
"fps": 6,
"frameSizing": "frame",
"pivot": { "x": 0.5, "y": 0 },
"frames": [
{ "texture": "@uuid:…" },
{ "texture": "@uuid:…", "offset": { "x": 20, "y": 0 } },
{ "texture": "@uuid:…", "duration": 0.35 }
]
}

A clip that declares neither leaves Sprite.size exactly as the entity set it, so clips written before this existed play unchanged. Unticking the switch drops the mode and every per-frame size and offset, which is the way back to that.

An anchor is the point a sprite sits, rotates and scales about — Sprite.pivot, normalized inside the frame, (0,0) bottom-left and (0.5,0.5) centered. When the artwork shifts inside its cell from frame to frame, one entity-wide anchor makes the character slide around; anchoring per frame pins whatever has to hold still — feet on the ground, a hand on a weapon.

Tick Anchor in the Flipbook editor’s preview bar and a crosshair appears over the current frame. Drag it (or type the X / Y beside it) to anchor that frame. The hairlines run the width of the stage, so with onion skin on you can line each frame’s feet up against its neighbours. A frame anchored on its own gets an amber dot in the strip, and × clears that override so the frame falls back to the clip’s anchor — which lives in the Details inspector as Default Anchor.

Resolution order: the frame’s own anchor, else the clip’s, else centered. A clip that authors no anchor at all leaves Sprite.pivot exactly as the entity set it, so existing projects are untouched until you tick the box; unticking clears the clip anchor and every frame override and returns to that hands-off behaviour.

// walk.esanim — the clip stands on its feet, one frame leans back
{
"version": "1.4",
"fps": 10,
"pivot": { "x": 0.5, "y": 0 },
"frames": [
{ "cell": 0 },
{ "cell": 1, "pivot": { "x": 0.47, "y": 0 } }
]
}

Anchors are the clip’s business only while it plays a frame: nothing stops a Transform or a tween from moving the entity as usual.

The .esanim document is plain data, so a clip can also be built at runtime: createAnimClip starts a sheet-sliced clip, you append { cell } frames, parseAnimClipData resolves it into a runtime SpriteAnimClip, and registerClip makes the name available to every SpriteAnimator:

import {
defineSystem, Res, Assets, SpriteAnimation,
createAnimClip, parseAnimClipData,
} from 'esengine';
const registerRun = defineSystem([Res(Assets), Res(SpriteAnimation)], async (assets, sprites) => {
const tex = await assets.loadTexture('assets/textures/run-sheet.png');
// A 32×32 grid over the sheet; frames reference grid cells (row-major).
const data = createAnimClip('assets/textures/run-sheet.png', 32, 32, tex.width, tex.height);
data.fps = 10;
for (let cell = 0; cell < 6; cell++) data.frames.push({ cell });
const handles = new Map([['assets/textures/run-sheet.png', tex.handle]]);
sprites.registerClip(parseAnimClipData('run', data, handles));
// Any SpriteAnimator can now set clip = 'run'.
});
Helper Description
createAnimClip(texture, cellWidth, cellHeight, pageWidth, pageHeight) A fresh sheet-sliced AnimClipAssetData (fps 12, loop: true, margin/spacing 0, no frames yet).
createAnimClipFromTextures(textures) A fresh clip over a sequence of per-frame textures — no sheet, and owning size from the start.
parseAnimClipData(name, data, textureHandles, textureSizes?) Resolve asset data into a runtime SpriteAnimClip — each frame gets its texture handle, UV window, and (for a clip that owns size) the size it draws at. Pass textureSizes so a per-texture frame has a natural size to fall back on.
parseAnimClipAsset(json) / serializeAnimClip(data) Parse / write the .esanim JSON document (the pair round-trips).
animClipCellRect(sheet, cell) / animClipCellUv(sheet, cell) Pixel rect / UV window of a grid cell — what a Sprite shows for that frame.
animClipFramePivot(data, frame, size?) The anchor a frame renders with — its own, else the clip’s, else centered, with the frame’s offset folded in when a size is given. null when the clip authors none (leave Sprite.pivot alone).
animClipDrivesPivot(data) Whether the clip authors anchors at all.
animClipFrameSize(data, frame, natural?) The size a frame draws at — its own, else the natural one you pass. null when the clip does not own size (leave Sprite.size alone).
animClipDrivesSize(data) Whether the clip owns Sprite.size — set outright, or implied by any frame’s size or offset.

Driving clips from a state machine — no code

Section titled “Driving clips from a state machine — no code”

The FSM/BT built-ins drive flipbooks as data. An action’s optional argument carries the clip, so idle/run/attack switching is pure .esfsm on the existing state-machine canvas:

Name Kind Description
spriteAnim.play action Play. With a clip argument, switch to that clip (rewinds to frame 0). Safe on onUpdate — same-clip play while playing is a no-op.
spriteAnim.restart action Unconditional rewind + play (re-trigger a one-shot mid-flight).
spriteAnim.stop action Pause playback.
spriteAnim.finished condition True once a one-shot clip completed — onEnter: spriteAnim.play + a spriteAnim.finished transition is a self-contained attack state.

The Tween resource interpolates a property from one value to another over a duration. to(entity, target, from, to, duration, options?) returns a handle:

import { defineSystem, Res, Tween, TweenTarget, EasingType, LoopMode } from 'esengine';
const animate = defineSystem([Res(Tween)], (tween) => {
tween.to(entity, TweenTarget.PositionX, 0, 200, 1.0, {
easing: EasingType.EaseInOutQuad,
delay: 0.2,
loop: LoopMode.PingPong,
loopCount: 3, // -1 = forever
});
});

TweenTarget selects the property to drive:

Group Values
Position PositionX, PositionY, PositionZ
Scale ScaleX, ScaleY
Rotation RotationZ
Color ColorR, ColorG, ColorB, ColorA
Size SizeX, SizeY
Camera CameraOrthoSize
Option Type Default Description
easing EasingType Linear Easing curve (see below).
delay number 0 Seconds to wait before starting.
loop LoopMode None None / Restart / PingPong.
loopCount number 0 Number of loops; -1 = forever.

EasingType covers Linear, the quad/cubic/back/elastic families (EaseIn* / EaseOut* / EaseInOut*), EaseOutBounce, Step, and CubicBezier — use a custom bezier with the handle’s .bezier(p1x, p1y, p2x, p2y).

Every EasingType plotted as a curve from 0 to 1

Each easing plotted (progress t left→right, eased value bottom→top) — these are the engine’s exact curves. Back and Elastic overshoot past the 0–1 band; Step holds until the end.

The returned TweenHandle chains and controls playback:

// Chain: run one after another (a squash-and-stretch pop).
tween.to(e, TweenTarget.ScaleX, 1, 1.4, 0.15)
.then(tween.to(e, TweenTarget.ScaleX, 1.4, 1, 0.15))
.bezier(0.2, 0, 0, 1);
tween.parallel([...]); // run several at once (a group)
tween.sequence([...]); // run tween factories in order
tween.delay(0.5); // a timed gap
tween.cancel(handle); // stop one
tween.cancelAll(entity); // stop everything on an entity
Method Description
to(entity, target, from, to, duration, opts?) Tween a component property; returns a TweenHandle.
value(from, to, duration, cb, opts?) Tween a plain number, delivered to cb each frame.
parallel(tweens) Run several tweens together as a group.
sequence(factories) Run tween factories one after another.
delay(seconds) An empty timed gap (for use in sequences).
cancel(handle) / cancelAll(entity) Stop one tween, or all on an entity.

TweenHandle has .then(next), .bezier(...), .pause(), .resume(), and .cancel().

tween.parallel returns a TweenGroup and tween.sequence a TweenSequence — parent-level handles over their members. Both expose state (a TweenState), pause() / resume() / cancel() that fan out to every member, and onComplete(cb):

const popX = tween.to(e, TweenTarget.ScaleX, 1, 1.3, 0.2);
const popY = tween.to(e, TweenTarget.ScaleY, 1, 1.3, 0.2);
tween.parallel([popX, popY]).onComplete(() => spawnSparkles());
tween.sequence([
() => tween.to(e, TweenTarget.PositionY, 0, 60, 0.3),
() => tween.delay(0.2),
() => tween.to(e, TweenTarget.PositionY, 60, 0, 0.3),
]).onComplete(() => landDust());

Always create groups and sequences through the Tween resource — the resource registers them with its internal poller (TweenCompositionManager), which is what fires onComplete and advances a sequence to its next factory. A hand-constructed new TweenGroup(...) is never polled.

tween.value(...) and tween.delay(...) return a ValueTweenHandle — the same control surface (state, .pause() / .resume() / .cancel(), .bezier(...)) plus .then(next), which chains another value tween or anything pausable/resumable (a group, a sequence). TweenHandle.then likewise accepts a ValueTweenHandle, so property tweens and value tweens mix freely in one chain.

Reach for the shorthand helpers (tween.parallel / tween.sequence / tween.delay / handle .then) first; the classes are what those calls hand back — you only name TweenGroup / TweenSequence / ValueTweenHandle when storing a handle to pause, cancel, or observe the composition later.

The animator editor — Idle / Move / Hop states with conditions and a parameters panel

The animator: states (Idle / Move / Hop), transitions with conditions (speed>20, the hop trigger), and the Parameters panel that drives them.

For character animation, an animator controller holds states, transitions, and 1D blend trees driven by named parameters. Author it in the editor (or register one in code), attach an Animator component naming the controller, then set parameters each frame through Res(AnimatorController) — the controller resolves the current state, clip, and blend.

A controller is an .esanimator asset: create one from the Content Browser’s New menu, double-click to open the graph editor above — states with positions, transitions with conditions, and a Parameters panel — and point the Animator component’s controller field at it with the asset picker. The scene preloads it, so it is registered before the first tick. Assigning a plain string instead resolves against controllers registerController(...) put there from code; both kinds coexist.

import { defineSystem, Query, Res, Animator, AnimatorController } from 'esengine';
const drive = defineSystem([Query(Animator), Res(AnimatorController)], (q, anim) => {
for (const [entity] of q) {
anim.setFloat(entity, 'speed', 4.2); // drives a 1D blend / transition
anim.setBool(entity, 'grounded', true);
anim.setTrigger(entity, 'jump'); // one-shot; resetTrigger to clear
const speed = anim.getFloat(entity, 'speed');
}
});
Animator field Type Default Description
controller string '' An .esanimator asset ref (what the editor writes) or a name passed to registerController in code.
currentState string '' Active state, as a /-separated path for nested machines (seeded from the controller’s initial state).
enabled boolean true Disable to freeze the state machine.
AnimatorController method Description
setFloat(entity, name, value) Set a float parameter (blends / transition conditions).
setBool(entity, name, value) Set a bool parameter.
setTrigger(entity, name) Fire a one-shot trigger.
resetTrigger(entity, name) Clear a trigger before it’s consumed.
getFloat(entity, name) / getBool(entity, name) Read a parameter back.

The controller can drive both sprite and Spine animations, so one state machine handles a whole character.

Parameters come in three types (AnimatorParamType): 'float', 'bool', and 'trigger'. Conditions compare them with gt / lt / eq / neq (numeric, with a value), true / false (bool), or trigger (fires once and is consumed). There is no separate int type — drive whole numbers through a float parameter and eq / neq.

A 1D blend on speed selecting idle / walk / run by threshold

A 1D blend: a float parameter picks the clip whose threshold is the highest one at or below it — here idle / walk / run as speed rises, with no transitions between them.

A state can carry a 1D blend instead of a single clip: a float parameter selects between AnimatorBlendThreshold stops. Because the sprite channel plays one clip at a time, this is a selection by threshold — the stop with the greatest value at or below the parameter wins (below all stops, the first one plays) — not a weighted pose blend. Each threshold is { value, clip, speed?, loop? }; per-threshold speed / loop override the state’s own. The selection re-evaluates every frame, so a rising speed parameter walks idle→walk→run with no transitions:

import { defineSystem, Res, AnimatorController, type AnimatorControllerDef } from 'esengine';
const movement: AnimatorControllerDef = {
parameters: [{ name: 'speed', type: 'float', default: 0 }],
initialState: 'move',
states: [{
name: 'move',
blend: {
parameter: 'speed',
thresholds: [
{ value: 0, clip: 'idle' },
{ value: 0.5, clip: 'walk' },
{ value: 4, clip: 'run', speed: 1.2 },
],
},
transitions: [],
}],
};
const setup = defineSystem([Res(AnimatorController)], (anim) => {
anim.registerController('movement', movement);
});

The pure helper selectBlendClip(blend, value) returns the threshold a value selects — handy for tooling or tests.

For skeletal characters, a state can instead carry a spine motion (AnimatorSpineMotion, { animation, loop? }): on entry the controller sets that Spine animation (loop defaults true) and leaves track playback and mixing to the Spine runtime — it never touches SpriteAnimator. States are exclusive: exactly one of clip, blend, spine, or stateMachine.

A state carrying a stateMachine (AnimatorSubMachine) is a container: it plays no motion of its own. On entry the machine descends into the sub-machine’s initialState; the container’s own transitions are the exit edges, evaluated from every leaf inside it. A sub-machine can declare its own anyStateTransitions, checked from all of its sub-states. Animator.currentState holds the full /-separated path (STATE_PATH_SEP), e.g. locomotion/run:

import { defineSystem, Res, AnimatorController, type AnimatorControllerDef } from 'esengine';
const player: AnimatorControllerDef = {
parameters: [
{ name: 'speed', type: 'float', default: 0 },
{ name: 'jump', type: 'trigger' },
],
initialState: 'locomotion',
states: [
{
name: 'locomotion', // container: no motion of its own
stateMachine: {
initialState: 'idle',
states: [
{ name: 'idle', clip: 'idle', transitions: [
{ to: 'run', conditions: [{ param: 'speed', op: 'gt', value: 0.1 }] },
] },
{ name: 'run', clip: 'run', transitions: [
{ to: 'idle', conditions: [{ param: 'speed', op: 'lt', value: 0.1 }] },
] },
],
},
// Exit edges — leave the whole machine from idle OR run.
transitions: [{ to: 'jump', conditions: [{ param: 'jump', op: 'trigger' }] }],
},
{ name: 'jump', clip: 'jump', loop: false, transitions: [
{ to: 'locomotion', conditions: [], hasExitTime: true }, // back in at idle
] },
],
};
const setup = defineSystem([Res(AnimatorController)], (anim) => {
anim.registerController('player', player);
});

Entering locomotion lands on locomotion/idle; the jump trigger exits from either leaf; when the one-shot jump clip ends, hasExitTime re-enters the container at its initial state. Transition priority, highest first: top-level any-state → each enclosing machine’s any-state (outermost in) → the leaf’s own transitions → container exit edges (innermost out).

The path machinery is exposed as pure functions (an AnimatorScope is the shape shared by the top-level controller and every sub-machine):

import { enterStatePath, leafStateOf, evaluateAnimatorPath } from 'esengine';
enterStatePath(player, 'locomotion'); // ['locomotion', 'idle']
leafStateOf(player, 'locomotion/run')?.clip; // 'run'
evaluateAnimatorPath(player, 'locomotion/idle', { speed: 3 }, new Set());
// → { nextPath: 'locomotion/run', consumedTriggers: [] }
Helper Description
enterStatePath(scope, name) Descend from a state to its concrete leaf; returns the path segments.
leafStateOf(def, path) The motion-bearing leaf state of a path, or null.
evaluateAnimatorPath(def, path, params, triggers, clipFinished?) One pure evaluation step over a path → { nextPath, consumedTriggers }.
STATE_PATH_SEP The path separator, '/'.

A clip can name the moments inside it. A customEvent track in an .estimeline carries { time, name, payload }, and the animator reports each one on the frame whose step crossed it — never the frame that landed on it, which a fixed timestep does not do. They arrive on the AnimatorEvent bus:

import {
defineSystem, EventReader, GetWorld, Res,
AnimatorEvent, Particle, resolveChildEntity,
} from 'esengine';
const footsteps = defineSystem(
[EventReader(AnimatorEvent), GetWorld(), Res(Particle)],
(events, world, particles) => {
for (const event of events) {
if (event.name !== 'footstep') continue;
const dust = resolveChildEntity(world, event.entity, 'FootDust');
if (dust !== null) particles.play(dust);
}
},
{ name: 'FootstepSystem' },
);
Field Description
entity The animated entity — who this happened to.
name The event’s name as the clip declared it.
value Its numeric payload (payload.value); 0 when the clip states none.
text Its string payload (payload.text); empty when the clip states none.

What the animator promises:

  • An event fires on the interval (previous, current] a step covered, so a long frame never steps over one and never reports one twice.
  • A loop wrap is two intervals — the tail of the lap, then the head of the next — so an event near either end fires exactly once per lap.
  • Several events in one step fire in the order the clip declares them.
  • During a crossfade only the state being entered reports events. The one fading out keeps its clock and says nothing, so an attack cancelled into a dodge does not go on landing its hit.

The animator calls into no particle system, no audio and no gameplay: it states the event, and whoever owns those things answers it.

A clip that animates the entity it is played on is not posing a skeleton — it is saying where the character travels. A state declares that:

{
"name": "Dodge",
"motion": { "kind": "timeline", "clip": "dodge.estimeline", "loop": false },
"rootMotion": true,
"transitions": [{ "to": "Idle", "conditions": [], "hasExitTime": true }]
}

The animator then keeps that root track’s position and rotation out of the pose — the clip never moves the entity — and publishes what it asked for on the entity’s AnimatorRootMotion component:

Field Description
enabled Authoring: whether the request is published at all.
active A state declaring root motion is driving this frame.
deltaPosition The displacement asked for, in world space.
deltaRotation The turn asked for, in the character’s own frame.
deltaTime The seconds the two deltas cover.

deltaTime comes with the deltas because the animator runs on the frame clock while a character controller usually steps on a fixed one: form a rate from the pair. A consumer that simply added the displacement would move twice on a frame with two fixed steps and not at all on a frame with none.

Read active rather than “is the delta non-zero” — a dodge has a still moment in the middle, and the stick must not take it back there. Without an AnimatorRootMotion component the state still refuses to pose the character’s own transform; the clip simply plays on the spot.

  • Spine Animation — skeletal animation, also driveable by the state machine.
  • Timeline — keyframed cutscenes and sequenced events.
  • Camera — tween CameraOrthoSize for smooth zooms.