Skip to content

Sprites & Animation

For most game art — characters, props, tiles, backgrounds — you’ll be displaying a texture. SpriteComponent is the right primitive; reach for AnimatedSpriteComponent + AnimationController when frames change over time.

SpriteComponent displays a texture on an entity. It automatically syncs with the entity’s Transform.

import { SpriteComponent } from "@yagejs/renderer";
entity.add(
new SpriteComponent({
texture: "assets/player.png",
anchor: { x: 0.5, y: 0.5 },
layer: "characters",
tint: 0xffffff,
alpha: 1,
}),
);

All properties are optional except texture. texture is an asset key or a typed handle (TextureRef), not a raw Texture object. A key that isn’t preloaded (or registered, see Runtime-generated textures) throws an error naming the key. The layer property controls z-ordering — see Layers & Draw Order. A blendMode option decides how the sprite combines with what is drawn beneath it — see Blend modes.

Escape hatch: .sprite is the underlying pixi Sprite. Reach for it when you need a pixi feature SpriteComponent doesn’t surface — full pixi API is available. See the pixi Sprite docs.

anchor is a fraction of the texture: { x: 0, y: 0 } is its top-left corner, { x: 0.5, y: 0.5 } its middle, { x: 1, y: 1 } its bottom-right corner. That point of the texture is the one drawn on the entity’s position.

It also decides where the sprite turns. The entity’s rotation turns the sprite’s own space about the entity’s position, so whichever point of the texture the anchor put there is the point the art turns about. A barrel whose art fills the texture rolls in place with anchor: { x: 0.5, y: 0.5 }, and tips from side to side about its base with anchor: { x: 0.5, y: 1 }. Scale behaves the same way: the art grows away from the anchor point.

The two cannot be separated. anchor moves where the art sits and where it turns together, so holding the art at one offset while turning it about a different point is out of reach. For that, parent the sprite’s entity under an entity placed at the turning point and rotate the parent — an entity’s rotation carries every descendant around its own position.

AnimatedSpriteComponent, TextComponent and SplitTextComponent take anchor too. GraphicsComponent has no texture to take a fraction of, and takes a pivot in pixels instead — see Where the drawing turns.

Every visual component has a modifiers host for temporary feedback that must not change gameplay state:

import { SpriteComponent } from "@yagejs/renderer";
const sprite = entity.get(SpriteComponent);
const punch = sprite.modifiers.addTransform({
position: { x: 6, y: 0 },
rotation: 0.05,
scale: 1.1,
});
const dim = sprite.modifiers.addOpacity(0.5);
const hidden = sprite.modifiers.addVisibility(false);
punch.setPosition({ x: 2, y: 0 });
punch.remove();
dim.remove();
hidden.remove();

Position and rotation contributions add. Scale and opacity contributions multiply. Every visibility contribution must permit drawing. Each handle removes only its own contribution and is safe to remove more than once. The component’s alpha and visible properties remain the game’s base values while modifiers affect the computed render values.

The renderer combines the entity’s current world Transform with active visual transform modifiers every frame. It does not write the result back to Transform, so physics, camera following, and depth sorting keep the base position. Modifiers are transient and are not saved.

For frame-based sprite animations, use AnimatedSpriteComponent together with an AnimationController. source is required on both; there is no raw Texture[] construction path.

import {
AnimatedSpriteComponent,
AnimationController,
} from "@yagejs/renderer";
entity.add(
new AnimatedSpriteComponent({
source: { sheet: "hero_idle.png", frameWidth: 48 },
anchor: { x: 0.5, y: 0.5 },
layer: "characters",
speed: 0.1,
}),
);
entity.add(
new AnimationController({
idle: { source: { sheet: "hero_idle.png", frameWidth: 48 }, speed: 0.1 },
run: { source: { sheet: "hero_run.png", frameWidth: 48 }, speed: 0.15 },
jump: {
source: { sheet: "hero_jump.png", frameWidth: 48 },
speed: 0.12,
loop: false,
},
}),
);

Switch animations at runtime:

import { AnimationController } from "@yagejs/renderer";
const anim = entity.get(AnimationController);
anim.play("run");
let inputLocked = true;
anim.playOneShot("jump", {
onComplete: () => {
inputLocked = false;
anim.play("idle");
},
onCancel: () => {
inputLocked = false; // something else ended the jump
},
});
anim.speed = 0.5; // updates the animation already playing

Exactly one of onComplete and onCancel runs per one-shot. onComplete fires when the lock plays out; onCancel fires when another one-shot, forcePlay(), unlock() or destroying the component ends it first. Give the game a way to release whatever the one-shot was holding — an input lock, a hitbox — instead of leaving it stuck when the animation is interrupted.

Keep the cancel body to that release, and do not play another animation from it. The call that interrupted the one-shot has already chosen what plays next, so a play() inside onCancel overwrites it; and a cancel caused by destroying the entity runs after the sprite has been torn down.

Playing a name the controller does not define throws, naming the method, the name and the defined set. anim.has(name) reports whether a name is defined, for animation names that come from data.

Changing AnimationController.speed updates the current sprite immediately. An automatically timed one-shot keeps the same playback progress and adjusts its remaining lock time. Its speed must stay positive because the controller derives the lock duration from that speed. A one-shot with an explicit duration keeps that duration and can use 0 to pause or a negative value to play in reverse.

AnimatedSpriteComponent also takes the same visible / tint / alpha / blendMode / interactive options as SpriteComponent above.

Animated sprites use engine-scaled component time. scene.timeScale and entity.timeScale compose with the animation’s speed: 0.25 plays at quarter speed and 2 plays at double speed. A paused scene, scene.timeScale = 0, or a disabled AnimatedSpriteComponent freezes playback. Put an animation in a separate active overlay scene when it must keep playing while gameplay is frozen.

Escape hatch: .animatedSprite is the underlying pixi AnimatedSprite. Frame selection, playback rate, and restart control are available directly on the component — see below. Use .animatedSprite for other pixi features.

Select and hold any frame when a sprite sheet contains poses that are not separate animations. For example, a movement sheet can also provide frames for falling, gripping a wall, or crouching:

import { AnimatedSpriteComponent } from "@yagejs/renderer";
const sprite = entity.get(AnimatedSpriteComponent);
sprite.gotoFrame(5); // stop and hold the crouching pose
sprite.frame; // 5
sprite.play({ speed: 0.12, loop: false, fromStart: true });

gotoFrame(index) stops playback and holds the chosen frame through later engine updates. A bare play() resumes from the current frame after stop(). Pass fromStart: true to start at frame 0, which is what replaying a completed non-looping animation needs.

import { AnimatedSpriteComponent } from "@yagejs/renderer";
const sprite = entity.get(AnimatedSpriteComponent);
sprite.speed = 0.3; // slower: about one frame every third tick
sprite.speed = 0; // hold the current frame, without stopping playback
sprite.speed = -0.15; // play backwards

speed counts frames advanced per tick at 60 fps, so 1 moves one frame every tick and 0.15 moves one frame about every seventh tick. It defaults to 1. The speed construction option sets the starting rate, which a sprite that plays as soon as it mounts needs. Writing it retimes the clip that is already running, without restarting it, and reads back the same number play({ speed }) writes. A non-finite value throws from both. Scene and entity timeScale multiply it.

With an AnimationController on the entity, speed reads the rate the sprite is actually running at: the animation definition’s speed, times the controller’s speed, times the playOneShot({ speed }) factor. The controller writes the rate again at its next animation switch and whenever its own speed is written. Set AnimationController.speed to retime every animation on the controller, and the component’s speed to retime only the clip on screen — so a stagger reaction can last exactly as long as the hit that caused it.

The component’s speed does not retime a running one-shot’s lock. The lock keeps the duration computed when the one-shot started, so after a component speed write the clip and the lock can end at different times.

AnimationController<T extends string = string> is generic on the animation-name union, which gives play() / playOneShot() strict autocomplete and typo-checking. But the runtime class is non-generic — AnimationController<HeroAnim> doesn’t exist as a value at runtime, so there’s nothing to pass to Component.sibling() or entity.get(). A default AnimationController<string> isn’t sound-assignable to AnimationController<HeroAnim> either: the current: T | "" getter is covariant on T, so a string-returning instance can’t substitute for one promising the narrow union. Cast at the field declaration to recover the narrow type — every call site downstream picks it up for free:

import { Component } from "@yagejs/core";
import { AnimationController } from "@yagejs/renderer";
type HeroAnim = "idle" | "walk" | "attack";
class HeroController extends Component {
private readonly _anim = this.sibling(AnimationController) as
AnimationController<HeroAnim>;
update() {
this._anim.play("walk"); // typed — a typo here is a compile error
}
}

Annotate the field once; the cast is required because the type parameter is type-only and sibling() can only narrow by the runtime class.

playOneShot(name, { startFrame, speed }) plays part of a clip, or plays it faster or slower than its definition, and derives the lock from the frames that actually play:

import { AnimationController } from "@yagejs/renderer";
const anim = entity.get(AnimationController);
// The last 8 frames of a 20-frame wind-up, at double speed.
anim.playOneShot("attack", { startFrame: 12, speed: 2 });

startFrame is an integer within the animation; speed is finite and greater than 0, and multiplies controller.speed for this one-shot only — the next play() runs at the definition’s own speed. The automatic lock duration is ((frames - startFrame) / 60) / (definition speed × controller speed × one-shot speed), so the lock and the visible clip always end together.

When playOneShot() calculates its own lock duration, the product of the animation definition’s speed, controller.speed and the one-shot speed must be positive, and the calculation must produce a finite duration. Pass an explicit duration when you intentionally pause or reverse the animation.

onFrameChange(listener) on AnimatedSpriteComponent calls back with each new frame index and returns an unsubscribe function. Use it for footstep sounds, frame-synced hitboxes, or an anchor that tracks the current frame:

import { Component } from "@yagejs/core";
import { AudioManagerKey } from "@yagejs/audio";
import { AnimatedSpriteComponent } from "@yagejs/renderer";
class Footsteps extends Component {
onAdd() {
const sprite = this.entity.get(AnimatedSpriteComponent);
this.addCleanup(
sprite.onFrameChange((frame) => {
if (frame === 4) this.use(AudioManagerKey).play("footstep");
}),
);
}
}

Any number of listeners can subscribe. A listener also sees the frame reset an AnimationController switch performs, so it can react to the first frame of a new animation.

A play({ onComplete }) owns its callback: a later play() without one clears it, and an AnimationController animation switch clears it too, so a completion callback never fires for an animation its author never saw.

When a character is built from multiple sprite layers — head + body + outfit, each with its own AnimatedSpriteComponent and AnimationController — every playOneShot call computes its lock duration from frames.length / speed, in whole-frame increments. When the layers have different frame counts or speeds, those durations disagree by a frame or two, the locks expire on different frames, and one sprite snaps back to idle while the others are still mid-swing. The visual giveaway is a single layer flickering at the tail of every attack animation.

The recommended fix is LayeredAnimationController, which wraps the per-layer controllers and uses the first controller’s lock timer to release the whole group:

import { Entity, Transform } from "@yagejs/core";
import {
AnimatedSpriteComponent,
AnimationController,
LayeredAnimationController,
} from "@yagejs/renderer";
class HeroLayer extends Entity {
setup({ sheet }: { sheet: string }) {
this.add(new Transform());
const source = { sheet, frameWidth: 48 };
this.add(new AnimatedSpriteComponent({ source }));
this.add(
new AnimationController({
idle: { source, speed: 0.1 },
attack: { source, speed: 0.2, loop: false },
}),
);
}
}
class Hero extends Entity {
setup() {
this.add(new Transform());
const body = this.spawnChild("body", HeroLayer, { sheet: "body.png" });
const head = this.spawnChild("head", HeroLayer, { sheet: "head.png" });
this.add(
new LayeredAnimationController<"idle" | "attack">({
controllers: [
body.get(AnimationController) as AnimationController<"idle" | "attack">,
head.get(AnimationController) as AnimationController<"idle" | "attack">,
],
}),
);
}
}
const hero = scene.spawn(Hero);
const layered = hero.get(LayeredAnimationController);
layered.speed = 1.5; // applies to every layer
layered.playOneShot("attack", {
onComplete: () => layered.play("idle"),
});

Automatic timing uses the first controller in controllers, so that controller must meet the same positive-speed and finite-duration rule. Pass an explicit duration to the layered call when the lead animation is paused or reversed.

The first controller owns the shared timer. Its automatic lock follows runtime speed changes at the current playback progress. The wrapper passes Number.POSITIVE_INFINITY to every other controller and releases them when the first controller completes. Set layered.speed to apply the same runtime multiplier to every layer. While the wrapper is attached, setting speed on any participating controller also updates the group. All layers unlock together, and exactly one of onComplete and onCancel runs — the wrapper owns that signal, so the individual layers never receive an onCancel.

Every layer must define every name played through the wrapper. play(), playOneShot() and forcePlay() throw naming the layer that is missing the animation, before any layer switches, so the layers can never end up on different animations. Automatically timed one-shots are checked the same way: layers can define the same name with different frame counts, so a startFrame one layer would reject throws before any layer switches.

Manual workaround (when you don’t want a wrapper)

Section titled “Manual workaround (when you don’t want a wrapper)”

For prototypes — or when each layer already has its own custom controller managing more than just animation — a helper can use one controller’s automatic timer and keep the other controllers locked until it completes:

import { AnimationController } from "@yagejs/renderer";
function playOneShotLayered(
controllers: AnimationController<string>[],
name: string,
onComplete?: () => void,
) {
const leader = controllers[0]!;
leader.playOneShot(name, {
onComplete: () => {
for (let i = 1; i < controllers.length; i++) controllers[i]!.unlock();
onComplete?.();
},
});
for (let i = 1; i < controllers.length; i++) {
controllers[i]!.playOneShot(name, { duration: Number.POSITIVE_INFINITY });
}
}
function attack(
bodyAnim: AnimationController,
headAnim: AnimationController,
outfitAnim: AnimationController,
) {
playOneShotLayered([bodyAnim, headAnim, outfitAnim], "attack", () => {
// every layer is back from the one-shot at the same instant
});
}

AnimationController.calcDuration(name, options?) exposes the same validated calculation that automatic one-shots use, and takes the same startFrame and speed.

YAGE provides helper functions to load and define render assets:

import { texture, spritesheet, renderAsset } from "@yagejs/renderer";
// Load a single texture
const bg = texture("assets/background.png");
// Load a spritesheet (atlas JSON; the JSON references the texture image)
const heroSheet = spritesheet("assets/hero.json");
// Generic render asset (auto-detects type)
const asset = renderAsset("assets/tileset.png");

These return handles that are resolved during scene loading, so textures are available by the time setup() runs. A handle goes where a TextureRef goes: a SpriteComponent’s texture, and the sheet of an animation frame source. An atlas animation source takes the atlas key.

Sheet slicing — sliceGrid, sliceSheet, sliceTextureFrames, and a { sheet, frameWidth } animation source — validates its options before it cuts anything: every field must be a finite number at or above its minimum, and the grid must fit inside the texture. A failure throws naming the function and the offending field instead of producing frames that read past the image.

Slicing leaves texture sampling alone. For crisp pixel art, turn on pixelArtPreset in the renderer config, which switches every texture to nearest-neighbour sampling and rounds sprite positions. For one pixel-art sheet in a project that is otherwise smooth, ask for the sampling on the handle instead:

import { texture } from "@yagejs/renderer";
const tiles = texture("assets/tiles.png", { scaleMode: "nearest" });

The setting lands on the loaded image, so every sprite drawing from that sheet samples the same way.

resolveFrames(source) turns either frame-source shape — { sheet, ... } or { atlas, animation } — into the Texture[] an animation would play, for code that wants the textures rather than the animation. A UIImage takes a single texture, so index the result:

import { resolveFrames } from "@yagejs/renderer";
import { UIImage } from "@yagejs/ui";
const icon = new UIImage({
texture: resolveFrames({ atlas: "ui.json", animation: "coin" })[0]!,
});

The call is synchronous and reads already-loaded assets, so the sheet or atlas has to be in the scene’s preload. An unloaded sheet, a missing atlas, an unknown animation name, and an animation with no frames each throw naming the key.

Textures created at runtime (procedural art, dynamically baked frames) have no asset path, so they can’t be preloaded. Register them under a key of your choosing with registerTexture and every key-based surface — sprite texture, animation FrameSource, particle emitter texture — resolves them exactly like a preloaded asset:

import {
AnimatedSpriteComponent,
registerTexture,
RendererKey,
SpriteComponent,
} from "@yagejs/renderer";
// Inside a Scene: draw a texture, register it, reference it by key.
const entity = this.spawn("marker");
const boss = this.spawn("boss");
const renderer = this.context.resolve(RendererKey);
registerTexture(
"marker",
renderer.createTexture((g) => g.circle(8, 8, 8).fill(0xff0000)),
);
entity.add(new SpriteComponent({ texture: "marker" }));
// Runtime animation: bake the frames as ONE horizontal strip
// (frame i at x = i * frameWidth), then reference it as a strip source.
const strip = renderer.createTexture(
(g) => {
for (let i = 0; i < 4; i++) {
g.circle(i * 32 + 16, 16, 6 + i * 2).fill(0xffcc00);
}
},
{ width: 128, height: 32 }, // the strip's region, so cells match the circles
);
registerTexture("boss-idle", strip);
boss.add(
new AnimatedSpriteComponent({
source: { sheet: "boss-idle", frameWidth: 32 },
}),
);

The rules that make this safe:

  • Register runtime-generated textures before constructing components that reference their keys. A missing key throws an error naming the key.
  • Registered keys are engine-global and live until unregisterTexture(key). Unregistering never destroys the texture — you created it, so call texture.destroy() when nothing draws it anymore.
  • Registering a key that collides with a loaded asset’s path throws; re-registering your own key replaces the entry (components built earlier keep the old texture instance).
  • Never call update() on the source of a texture from createTexture. Those pixels live only on the GPU, so the re-upload writes an empty image over them and the texture stays blank.
  • Give createTexture a size whenever the texture is sliced into frames. Without one the texture measures the drawn bounds and starts at the top-left of the drawing, so the four circles above would bake into a 114 x 24 texture whose 32-pixel cells sit 10 pixels off the circles.

A runtime texture also works as a fill for a procedural shape, where it tiles across the shape — see texture fills.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/renderer.md.