Guide

Guide Rendering Animation

Animation

Animate transforms and values with tweens and frame updates.

Intermediate ~4 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.

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.

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)
    loop: true,    // default true
});

Multiple clips can be registered on the same AnimatedSprite:

player.defineClip('idle', { frames: [new Rectangle(0, 32, 32, 32)], fps: 1, loop: true });
player.defineClip('jump', { frames: [new Rectangle(128, 0, 32, 32)], fps: 1, loop: false });

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

Playback control

player.play('walk');                        // start from frame 0
player.play('walk', { restart: false });    // resume if same clip
player.play('walk', { loop: false });       // override loop setting 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 a non-looping clip reaches its last frame:

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, 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 looping. 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
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
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, loop: true } });
        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.