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: playerTexture,
anchor: { x: 0.5, y: 0.5 },
layer: "characters",
tint: 0xffffff,
alpha: 1,
}),
);

All properties are optional except texture. The layer property controls z-ordering — see Camera & Layers.

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.

import {
AnimatedSpriteComponent,
AnimationController,
} from "@yagejs/renderer";
entity.add(
new AnimatedSpriteComponent({
spritesheet: heroSheet,
defaultAnimation: "idle",
}),
);
entity.add(
new AnimationController({
animations: {
idle: { frames: [0, 1, 2, 3], speed: 0.1 },
run: { frames: [4, 5, 6, 7, 8, 9], speed: 0.15 },
jump: { frames: [10, 11, 12], speed: 0.12, loop: false },
},
}),
);

Switch animations at runtime:

const anim = entity.get(AnimationController);
anim.play("run");
anim.play("jump", { onComplete: () => anim.play("idle") });

Escape hatch: .animatedSprite is the underlying pixi AnimatedSprite.

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, rounded to whole frame-ms. When the layers have different frame counts or speeds, those durations disagree by a millisecond 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.