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.
Sprites
Section titled “Sprites”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.
What anchor does
Section titled “What anchor does”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.
Temporary visual modifiers
Section titled “Temporary visual modifiers”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.
Animated Sprites
Section titled “Animated Sprites”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 playingExactly 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.
Hold a frame or restart playback
Section titled “Hold a frame or restart playback”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 posesprite.frame; // 5sprite.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.
Change the playback rate
Section titled “Change the playback rate”import { AnimatedSpriteComponent } from "@yagejs/renderer";
const sprite = entity.get(AnimatedSpriteComponent);
sprite.speed = 0.3; // slower: about one frame every third ticksprite.speed = 0; // hold the current frame, without stopping playbacksprite.speed = -0.15; // play backwardsspeed 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.
Typing the controller
Section titled “Typing the controller”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.
Start partway in, or at a different speed
Section titled “Start partway in, or at a different speed”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.
React to frame changes
Section titled “React to frame changes”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.
Layered characters (head + body + outfit)
Section titled “Layered characters (head + body + outfit)”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 layerlayered.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.
Asset Factories
Section titled “Asset Factories”YAGE provides helper functions to load and define render assets:
import { texture, spritesheet, renderAsset } from "@yagejs/renderer";
// Load a single textureconst 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.
One frame as a texture
Section titled “One frame as a texture”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.
Runtime-generated textures
Section titled “Runtime-generated textures”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 calltexture.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 thesourceof a texture fromcreateTexture. Those pixels live only on the GPU, so the re-upload writes an empty image over them and the texture stays blank. - Give
createTexturea 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.