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) — never a raw Texture object — so every sprite
serializes fully for save/load. 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.
Animated Sprites
Section titled “Animated Sprites”For frame-based sprite animations, use AnimatedSpriteComponent together with
an AnimationController. source is required on both — there’s no raw
Texture[] construction path, so every animation is fully serializable.
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", }),);
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:
const anim = entity.get(AnimationController);anim.play("run");anim.playOneShot("jump", { onComplete: () => anim.play("idle") });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 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:
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.
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.
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, computes the duration once on a lead controller,
and cascades lock release via a single master timer:
import { AnimatedSpriteComponent, AnimationController, LayeredAnimationController,} from "@yagejs/renderer";
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">, ], }), ); }}
// Call once, both layers stay in sync:hero.get(LayeredAnimationController).playOneShot("attack", { onComplete: () => hero.get(LayeredAnimationController).play("idle"),});The wrapper computes a single lock duration (from the first controller, or an
explicit duration option), passes Number.POSITIVE_INFINITY to each child
so child timers can never expire independently, and fires onComplete
exactly once when the master timer expires. All layers unlock together
regardless of per-layer frame counts.
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 — the same insight can live as a one-line
helper. Precompute the duration on a “lead” controller and broadcast via
options.duration:
import { AnimationController } from "@yagejs/renderer";
function playOneShotLayered( controllers: AnimationController<string>[], name: string, onComplete?: () => void,) { const duration = controllers[0]!.calcDuration(name); controllers[0]!.playOneShot(name, { duration, onComplete }); for (let i = 1; i < controllers.length; i++) { controllers[i]!.playOneShot(name, { duration }); }}
playOneShotLayered([bodyAnim, headAnim, outfitAnim], "attack", () => { // every layer is back from the one-shot at the same instant});AnimationController.calcDuration(name) is the same calculation
playOneShot does internally — calling it once and broadcasting the result
is the cheapest way to keep layered characters in lockstep without bringing
in LayeredAnimationController.
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.
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 textureKey — 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 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); }});registerTexture("boss-idle", strip);boss.add( new AnimatedSpriteComponent({ source: { sheet: "boss-idle", frameWidth: 32 }, }),);The rules that make this safe:
- Save/load: snapshots store only the key. Re-register the texture under
the same key at boot, before restoring — the same code that registered it
on the first run. For sprites and animations, restoring with a missing key
throws an error naming the key, so nothing silently disappears from a save.
(Particle emitters resolve
textureKeywithout this guard.) - 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).