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) — 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.

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.

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 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.

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.

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.

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.

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 textureKey without this guard.)
  • 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).