Guide

Guide Rendering Animation

Animation

Animate transforms and values with tweens and frame updates.

Intermediate ~7 min read

What you'll learn

  • tween transforms and values over time
  • chain, yoyo, and interrupt tweens

Animation

ExoJS gives you three layers of animation control, each suited to a different kind of motion:

  1. Manual frame-loop animation — you write update(delta) logic that mutates transforms or values directly. Covered in Scenes & lifecycle.
  2. Tweens — automated interpolation of numeric properties over time, with easing, delay, repeat, yoyo, chaining, and lifecycle callbacks. Driven by the application’s TweenManager (app.tweens).
  3. Animated sprites — frame-based sprite animation from named clips (AnimatedSprite), optionally sourced from a Spritesheet.

All three can coexist in one scene. Use tweens for UI transitions and canned motion paths; use manual updates for physics, input-driven movement, and any logic where the next position depends on something other than time alone; use animated sprites for character animation playback.

Tweens

A Tween interpolates numeric properties on any target object from their current value to configured end values over a duration in seconds. Create one through app.tweens.create(target), configure it fluently, and call start():

init(loader) {
    this.sprite = new Sprite(loader.get(Texture, 'hero'));
    this.sprite.setAnchor(0.5);
    this.sprite.setPosition(100, 300);

    this.app.tweens.create(this.sprite.position)
        .to({ x: 700 }, 1.5)
        .easing(Ease.cubicInOut)
        .start();
}

Start values are captured lazily on the first update after start(), so you can configure a tween in init and mutate the target between construction and start() without affecting the baseline.

Tween lifecycle

MethodEffect
.to(properties, duration)Set end values and duration. Replaces any prior to().
.delay(seconds)Wait before interpolation begins. Default 0.
.easing(fn)Easing function. Default Ease.linear.
.repeat(count)Extra cycles after the first. -1 = infinite.
.yoyo(enabled)Reverse direction on each repeat.
.start()Begin playback.
.pause()Freeze at current position.
.resume()Continue from where it paused.
.stop()Halt without finishing. onComplete does not fire.
.chain(nextTween)Automatically start nextTween when this one completes.

State is readable at tween.state: 'idle', 'active', 'paused', 'stopped', or 'complete'. Progress is readable at tween.progress (0–1, already eased).

Callbacks

this.app.tweens.create(sprite)
    .to({ rotation: 360 }, 2)
    .onStart(() => console.log('started'))
    .onUpdate(t => console.log(`progress: ${t}`))
    .onComplete(() => console.log('finished'))
    .onRepeat(() => console.log('cycled'))
    .repeat(2)
    .start();
  • onStart fires once when interpolation begins (after any delay).
  • onUpdate fires every active frame, receiving the eased progress 0–1.
  • onRepeat fires at each repeat cycle boundary.
  • onComplete fires when all cycles finish naturally. Does not fire on stop().

Easing functions

Ease provides 31 standard Penner easing functions (including linear). All accept normalized time t in [0, 1] and return an eased value that starts at 0 and ends at 1:

import { Ease } from '@codexo/exojs';

Ease.linear
Ease.quadIn     Ease.quadOut     Ease.quadInOut
Ease.cubicIn    Ease.cubicOut    Ease.cubicInOut
Ease.quartIn    Ease.quartOut    Ease.quartInOut
Ease.quintIn    Ease.quintOut    Ease.quintInOut
Ease.sineIn     Ease.sineOut     Ease.sineInOut
Ease.expoIn     Ease.expoOut     Ease.expoInOut
Ease.circIn     Ease.circOut     Ease.circInOut
Ease.backIn     Ease.backOut     Ease.backInOut
Ease.bounceIn   Ease.bounceOut   Ease.bounceInOut
Ease.elasticIn  Ease.elasticOut  Ease.elasticInOut

In variants start slow and accelerate. Out variants start fast and decelerate. InOut combines both — slow at both ends, fast in the middle.

Tween chains

Chain tweens to build sequenced motion without nesting callbacks:

const moveRight = this.app.tweens.create(sprite.position)
    .to({ x: 700 }, 1.0)
    .easing(Ease.cubicInOut);

const moveDown = this.app.tweens.create(sprite.position)
    .to({ y: 500 }, 0.8)
    .easing(Ease.cubicOut);

const fadeOut = this.app.tweens.create(sprite)
    .to({ alpha: 0 }, 0.5);

moveRight.chain(moveDown);
moveDown.chain(fadeOut);
moveRight.start();

Because chain() returns the next tween, keep a reference to the first tween and start that first tween:

const first = this.app.tweens.create(sprite.position)
    .to({ x: 700 }, 1.0)

const second = this.app.tweens.create(sprite.position)
    .to({ y: 500 }, 0.8);

const third = this.app.tweens.create(sprite)
    .to({ alpha: 0 }, 0.5);

first.chain(second);
second.chain(third);
first.start();

Only the first tween in the chain needs a start() call — each chained tween automatically starts when its predecessor completes.

Sequencing with TweenSequencer

chain() links exactly one tween to exactly one successor — fine for a short relay, but it can’t express “run these two tweens together, then move on” or “pause for a beat between steps” without extra bookkeeping. TweenSequencer covers those cases. Create one with app.tweens.createSequencer() and build it up with .then() and .wait():

const fadeIn = this.app.tweens.create(sprite)
    .to({ alpha: 1 }, 0.4);

const moveLeft = this.app.tweens.create(sprite.position)
    .to({ x: 200 }, 0.6)
    .easing(Ease.cubicOut);

const scaleUp = this.app.tweens.create(sprite.scale)
    .to({ x: 1.2, y: 1.2 }, 0.6)
    .easing(Ease.cubicOut);

const fadeOut = this.app.tweens.create(sprite)
    .to({ alpha: 0 }, 0.4);

this.app.tweens.createSequencer()
    .then(fadeIn)
    .wait(0.5)
    .then([moveLeft, scaleUp])
    .then(fadeOut)
    .onComplete(() => console.log('sequence done'))
    .start();

Each .then() call adds a stage. Pass a single tween to run it alone, or an array of tweens to run them in parallel — the stage only advances once every tween in it has completed, which is how moveLeft and scaleUp above end up running side by side before fadeOut begins. .wait(seconds) inserts a timed pause as its own stage, without needing a dummy tween to hold up the sequence.

The sequencer also has lifecycle controls of its own, independent of any individual tween’s: .repeat(count) and .yoyo() replay the whole stage sequence (optionally reversed), .onStart() / .onComplete() fire once for the sequence as a whole, and .pause() / .resume() / .stop() act on whichever stage is currently active. createSequencer() binds the sequencer to app.tweens, so — like tweens created via app.tweens.create() — it’s ticked automatically once start() is called; no manual update() calls needed.

Reach for .chain() when you already have two tween references and just need one to follow the other. Reach for TweenSequencer when a step in the sequence involves multiple simultaneous tweens, needs a timed gap, or the sequence itself needs repeat/yoyo/callback control.

Interruption and replacement

Calling start() on an active tween restarts it from zero. To replace a running animation cleanly, stop the old tween and start a new one on the same target:

// Interrupt the current move and slide to a new position
this._activeTween?.stop();
this._activeTween = this.app.tweens.create(sprite.position)
    .to({ x: newTarget }, 0.3)
    .easing(Ease.cubicOut)
    .start();

Only numeric properties interpolate

Tween interpolation is purely numeric. Attempting to tween a non-number property logs a warning and skips it. Use tween targets whose properties are numbers (for example sprite.position with { x, y }), and animate complex objects by their numeric fields.

Animated sprites

AnimatedSprite extends Sprite with frame-based animation playback from named clips. Each clip is a sequence of texture-frame rectangles with a playback speed and repeat count.

Defining clips

import { AnimatedSprite, Rectangle } from '@codexo/exojs';

const player = new AnimatedSprite(texture);

player.defineClip('walk', {
    frames: [
        new Rectangle(0, 0, 32, 32),
        new Rectangle(32, 0, 32, 32),
        new Rectangle(64, 0, 32, 32),
        new Rectangle(96, 0, 32, 32),
    ],
    fps: 10,       // frames per second (default 12)
    repeat: -1,    // full cycles to play; -1 loops indefinitely (default)
});

repeat counts full cycles through every frame: -1 (the default when omitted) loops indefinitely, 1 plays the clip once and stops on its last frame, and any other positive integer plays exactly that many cycles before onComplete fires. This is the same -1 = infinite convention Tween.repeat() uses above — AnimatedSprite.repeat now agrees with Tween on what “loop forever” means.

Multiple clips can be registered on the same AnimatedSprite:

player.defineClip('idle', { frames: [new Rectangle(0, 32, 32, 32)], fps: 1 });              // repeat: -1 (default) — loops forever
player.defineClip('jump', { frames: [new Rectangle(128, 0, 32, 32)], fps: 1, repeat: 1 });  // plays once

Call setClips() to replace all clips at once with a map.

Per-frame timing and offsets

Two optional clip fields cover cases a single fps value can’t express:

  • frameDurations — a per-frame hold time in milliseconds, parallel to frames. When present it drives playback timing directly and fps is ignored while advancing frames (still accepted as a display-only average). Use it for clips with intentionally uneven pacing, such as an impact frame that lingers longer than the rest.
  • frameOffsets — a per-frame local draw offset ({ x, y } in local pixels), parallel to frames. Use it to keep frames anchored to a stable point when the source frames are trimmed to different sizes — without it, differently-trimmed frames jitter around their own top-left corner.
player.defineClip('hit', {
    frames: [frame0, frame1, frame2],
    frameDurations: [80, 40, 200],                                    // ms per frame — frame1 is a quick flash, frame2 lingers
    frameOffsets: [{ x: 0, y: 0 }, { x: -2, y: 1 }, { x: 0, y: 0 }],   // keep the trimmed frame1 anchored
});

Both arrays are usually populated automatically rather than written by hand: the Aseprite guide (@codexo/exojs-aseprite) fills frameDurations from each tag’s per-frame export duration and frameOffsets from trimmed spriteSourceSize data. Set them yourself when building clips directly from a Spritesheet or raw Rectangles and you need the same fidelity.

Playback control

player.play('walk');                        // start from frame 0
player.play('walk', { restart: false });    // resume if same clip
player.play('walk', { repeat: 1 });         // override repeat count for this play

player.pause();    // freeze playback
player.resume();   // continue
player.stop();     // stop and rewind to frame 0

The onFrame signal fires on every frame advance; onComplete fires when the clip completes its final repeat cycle:

player.onFrame.add((clip, frame) => {
    console.log(`Frame ${frame} of clip "${clip}"`);
});

player.onComplete.add(clip => {
    player.play('idle');
});

Call player.update(delta) each frame. The delta can be a Time object (from the scene’s update) or a number in milliseconds:

update(delta) {
    this.player.update(delta);
}

From a Spritesheet

If a Spritesheet has animation data, AnimatedSprite.fromSpritesheet() creates a configured instance directly:

import { AnimatedSprite, Spritesheet } from '@codexo/exojs';

const sheet = new Spritesheet(texture, spritesheetJson);
const hero = AnimatedSprite.fromSpritesheet(sheet);
hero.play('walk');

The spritesheet’s animations map — ordered lists of frame names — becomes named AnimatedSprite clips (repeating indefinitely, since repeat isn’t set), each frame rectangle sourced from the spritesheet’s frames map.

When to use

Use AnimatedSprite when you have frame strips from a texture atlas and want clip-based playback with names, speeds, and repeat counts. Use manual sprite.setTextureFrame() calls in update when you need per-frame logic beyond simple clip playback — responsive frame selection based on game state, for example.

Standing tween vs. frame-loop animation

Tweens are driven by the TweenManager on every frame via app.tweens.update(delta), which happens automatically. You never call update on individual tweens unless you created them standalone (without a manager).

Frame-loop animation — the update(delta) pattern where you read delta.seconds and multiply transforms — is the right choice for continuous motion that doesn’t have a defined endpoint: character movement, physics, any state that evolves based on input or simulation. Tweens are the right choice for motion with a clear start and end: UI transitions, camera pans, scripted sequences, one-shot effects.

Examples

Tween Basics Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, Scene, Sprite, Text, Texture, Tween } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

class TweenBasicsScene extends Scene {
    private sprite!: Sprite;
    private text!: Text;
    private forward!: Tween;
    private backward!: Tween;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const left = width * 0.1;
        const right = width * 0.9;

        this.sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setPosition(left, height / 2);
        this.text = new Text('Tween running', { fillColor: Color.white, fontSize: 18 });
        this.text.setPosition(20, 20);
        this.forward = this.app.tweens.create(this.sprite.position).to({ x: right }, 1.2);
        this.backward = this.app.tweens.create(this.sprite.position).to({ x: left }, 1.2);
        this.forward
            .onComplete(() => {
                this.text.text = 'Completed -> reverse';
                this.backward.start();
            })
            .start();
        this.backward.onComplete(() => {
            this.text.text = 'Completed -> forward';
            this.forward.start();
        });
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.sprite);
        context.render(this.text);
    }
}

app.start(new TweenBasicsScene());

A sprite sliding between two positions with chained tweens and on-screen status text.

Frame Animation Open in Playground View source

Preview is paused until you click Play.

import { AnimatedSprite, Application, Color, Json, Scene, Spritesheet, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

const walkFps = 8;

class FrameAnimationScene extends Scene {
    private sprite!: AnimatedSprite;
    private frameCount = 0;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { characters: 'image/platformer-characters.png' });
        await loader.load(Json, { characters: 'json/platformer-characters.json' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const texture = loader.get(Texture, 'characters');
        const data = loader.get(Json, 'characters');
        const sheet = new Spritesheet(texture, data);

        const walkFrames = ['character_beige_walk_a', 'character_beige_walk_b'].map(name => sheet.getFrame(name));

        this.frameCount = walkFrames.length;
        this.sprite = new AnimatedSprite(texture, { walk: { frames: walkFrames, fps: walkFps } });
        this.sprite.setAnchor(0.5).setScale(3).setPosition(width / 2, height / 2);

        this.hud = mountControls({
            title: 'Frame Animation',
            controls: [{ keys: 'Auto', action: `looping walk cycle @ ${walkFps} fps` }],
            status: `Frame: 1/${this.frameCount}`,
            hint: 'AnimatedSprite steps through spritesheet frames on a timer; onFrame drives the live readout.',
        });

        // The onFrame signal fires on every frame advance with the 0-based index.
        this.sprite.onFrame.add((_clip, frame) => this.hud.setStatus(`Frame: ${frame + 1}/${this.frameCount}`));

        this.sprite.play('walk');
    }

    override update(delta): void {
        this.sprite.update(delta);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.sprite);
    }
}

app.start(new FrameAnimationScene());

Manual frame-loop animation without tweens — comparing delta-based vs. fixed-step motion.

Where to go next

The next chapter, Render targets, covers off-screen rendering — how to draw into a RenderTexture and reuse it as a sprite texture for compositing, caching, or post-processing.