From phaser4-gamedev
Phaser 4 animation skill covering spritesheet and texture atlas frame animations, tweens, and common patterns for idle, walk, jump, attack animations.
How this skill is triggered — by the user, by Claude, or both
Slash command
/phaser4-gamedev:phaser-animationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Phaser 4 has two distinct animation systems: **frame-based sprite animations** (flip through frames in a texture atlas or spritesheet) and **tweens** (interpolate numeric properties over time). Use both together for polished game feel.
Phaser 4 has two distinct animation systems: frame-based sprite animations (flip through frames in a texture atlas or spritesheet) and tweens (interpolate numeric properties over time). Use both together for polished game feel.
A spritesheet packs multiple frames into a single image in a regular grid. Define animations in AnimationManager using frame indices.
// preload()
preload(): void {
this.load.spritesheet('player', 'assets/player.png', {
frameWidth: 48,
frameHeight: 48,
});
}
// create() — or PreloaderScene.create() for global animations (see below)
create(): void {
this.anims.create({
key: 'player-idle',
frames: this.anims.generateFrameNumbers('player', { start: 0, end: 3 }),
frameRate: 8,
repeat: -1, // -1 = loop forever
});
this.anims.create({
key: 'player-walk',
frames: this.anims.generateFrameNumbers('player', { start: 4, end: 11 }),
frameRate: 12,
repeat: -1,
});
this.anims.create({
key: 'player-jump',
frames: this.anims.generateFrameNumbers('player', { start: 12, end: 15 }),
frameRate: 10,
repeat: 0, // 0 = play once
});
this.anims.create({
key: 'player-attack',
frames: this.anims.generateFrameNumbers('player', { start: 16, end: 23 }),
frameRate: 16,
repeat: 0,
});
}
this.anims.generateFrameNumbers('texture', {
start: 0, // first frame index
end: 7, // last frame index (inclusive)
first: 0, // override which frame plays first
frames: [0, 2, 4], // manual frame list (use instead of start/end)
});
Texture atlases store frames with named keys rather than grid positions. Use generateFrameNames for these:
// preload()
this.load.atlas('hero', 'assets/hero.png', 'assets/hero.json');
// create()
this.anims.create({
key: 'hero-run',
frames: this.anims.generateFrameNames('hero', {
prefix: 'run_', // frame names are run_01, run_02, ...
start: 1,
end: 8,
zeroPad: 2, // zero-pad the number to 2 digits
suffix: '', // optional suffix after the number
}),
frameRate: 12,
repeat: -1,
});
// Manual frame list from atlas
this.anims.create({
key: 'hero-die',
frames: [
{ key: 'hero', frame: 'die_01' },
{ key: 'hero', frame: 'die_02' },
{ key: 'hero', frame: 'die_03' },
],
frameRate: 8,
repeat: 0,
});
Define animations in PreloaderScene.create() — not in each individual scene. Animations registered on the global AnimationManager are available in every scene without re-registering:
// src/scenes/PreloaderScene.ts
export class PreloaderScene extends Phaser.Scene {
preload(): void {
this.load.spritesheet('player', 'assets/player.png', { frameWidth: 48, frameHeight: 48 });
this.load.atlas('enemies', 'assets/enemies.png', 'assets/enemies.json');
}
create(): void {
// All anims defined here are available in GameScene, UIScene, etc.
this.anims.create({ key: 'player-idle', /* ... */ });
this.anims.create({ key: 'player-walk', /* ... */ });
this.anims.create({ key: 'enemy-walk', /* ... */ });
this.scene.start('GameScene');
}
}
If an animation only makes sense in a single scene (a cutscene animation, for example), define it in that scene's create().
// Basic play
sprite.play('player-walk');
// Play but don't restart if already playing this animation
sprite.play('player-walk', true); // ignoreIfPlaying = true
// Play starting from a specific frame
sprite.playFromFrame('player-walk', 3);
// Stop on a specific frame number
sprite.stopOnFrame(this.anims.get('player-attack').frames[7]);
// Reverse playback
sprite.playReverse('player-walk');
// Check state
sprite.anims.isPlaying; // boolean
sprite.anims.currentAnim?.key; // string | undefined
sprite.anims.currentFrame?.index; // current frame index
Listen for animation lifecycle events on the sprite (not the AnimationManager):
// Fires when any animation completes on this sprite
sprite.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (anim, frame, gameObject) => {
console.log('animation complete:', anim.key);
});
// Fires when a SPECIFIC animation completes (preferred — avoids key checks)
sprite.on(
Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + 'player-attack',
(anim, frame, gameObject) => {
this.player.returnToIdle();
}
);
// Other events
sprite.on(Phaser.Animations.Events.ANIMATION_START, cb); // animation started
sprite.on(Phaser.Animations.Events.ANIMATION_REPEAT, cb); // loop restarted
sprite.on(Phaser.Animations.Events.ANIMATION_RESTART, cb); // play() called while already playing
sprite.on(Phaser.Animations.Events.ANIMATION_STOP, cb); // stop() called
sprite.on(Phaser.Animations.Events.ANIMATION_UPDATE, cb); // every frame change
Always remove listeners when the sprite is destroyed to prevent memory leaks:
sprite.on(Phaser.Animations.Events.ANIMATION_COMPLETE, this.onAnimComplete, this);
// In shutdown():
sprite.off(Phaser.Animations.Events.ANIMATION_COMPLETE, this.onAnimComplete, this);
Play a sequence of animations one after another:
// Chain via array — plays 'attack', then 'idle' automatically
sprite.chain(['player-attack', 'player-idle']);
sprite.play('player-attack');
// Chain via ANIMATION_COMPLETE event
sprite.play('player-jump');
sprite.once(
Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + 'player-jump',
() => sprite.play('player-fall')
);
For characters with idle/walk/jump/attack states, use an explicit state machine in update(). This prevents impossible state transitions and makes animation logic readable.
type CharState = 'idle' | 'walk' | 'jump' | 'attack' | 'hurt';
export class Player extends Phaser.Physics.Arcade.Sprite {
private state: CharState = 'idle';
setState(newState: CharState): void {
if (this.state === newState) return;
this.state = newState;
switch (newState) {
case 'idle': this.play('player-idle', true); break;
case 'walk': this.play('player-walk', true); break;
case 'jump': this.play('player-jump', true); break;
case 'attack': this.play('player-attack', true); break;
case 'hurt':
this.play('player-hurt', true);
this.once(
Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + 'player-hurt',
() => this.setState('idle')
);
break;
}
}
update(cursors: Phaser.Types.Input.Keyboard.CursorKeys): void {
const body = this.body as Phaser.Physics.Arcade.Body;
if (this.state === 'attack' || this.state === 'hurt') return; // locked states
if (!body.blocked.down) {
this.setState('jump');
} else if (cursors.left.isDown || cursors.right.isDown) {
this.setState('walk');
} else {
this.setState('idle');
}
if (Phaser.Input.Keyboard.JustDown(cursors.space)) {
this.setState('attack');
this.once(
Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + 'player-attack',
() => this.setState('idle')
);
}
}
}
When a one-shot animation (boss intro, death sequence, dungeon entry) plays for one frame then reverts to idle, the cause is always the entity's update() running its state-machine logic one tick after your forced play() call and overwriting it.
Fix — add a cinematicMode flag as the very first guard in update():
export class Player extends Phaser.Physics.Arcade.Sprite {
private state: CharState = 'idle';
private cinematicMode = false;
setCinematicMode(active: boolean, forcedAnimKey?: string): void {
this.cinematicMode = active;
if (active && forcedAnimKey) {
this.anims.stop(); // RC7: always stop before play on state switch
this.play(forcedAnimKey, true);
this.once(
Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + forcedAnimKey,
() => { this.cinematicMode = false; }
);
}
}
update(cursors: Phaser.Types.Input.Keyboard.CursorKeys): void {
if (this.cinematicMode) return; // MUST be first line — blocks state logic
// ... rest of state machine
}
}
Clear cinematicMode in the ANIMATION_COMPLETE_KEY handler, not synchronously after play() — in RC7 the completion event fires one tick after the last frame renders. Clear it synchronously and your forced animation exits one frame early.
See references/state-machine-patterns.md for the full canonical implementation, worked dungeon-entry example, and the RC7 ANIMATION_COMPLETE timing-drift fix.
Adding a new animation state without auditing all other states' transition lists is a silent bug — no error is thrown; the state machine simply fails to reach the new state or gets stuck in the wrong one.
When adding any new state (e.g. 'dodge', 'interact'):
CharState union type.case for it in setState().The transition table in references/state-machine-patterns.md makes missing transitions obvious on read.
sprite.stop(); // stop and stay on current frame
sprite.anims.pause(); // pause on current frame (resumable)
sprite.anims.resume(); // resume paused animation
sprite.anims.restart(); // restart from frame 0
Tweens interpolate any numeric property on any object over time. They are Phaser's primary tool for UI animations, cutscenes, and visual feedback.
this.tweens.add({
targets: sprite, // one object, an array, or a group
x: 400, // tween x to 400
y: 300,
alpha: 1,
duration: 800, // milliseconds
ease: 'Quad.Out', // easing function
delay: 0, // ms before starting
repeat: 0, // 0 = once; -1 = infinite
yoyo: false, // reverse back to start after completing
hold: 0, // ms to hold at end before yoyo
onStart: () => {}, // fires when tween starts
onUpdate: () => {}, // fires every frame
onComplete: () => {}, // fires on completion
});
sprite.setAlpha(0);
this.tweens.add({ targets: sprite, alpha: 1, duration: 400, ease: 'Linear' });
this.tweens.add({
targets: sprite,
alpha: 0,
duration: 300,
ease: 'Linear',
onComplete: () => sprite.destroy(),
});
this.tweens.add({
targets: sprite,
scaleX: 1.3,
scaleY: 1.3,
duration: 80,
ease: 'Quad.Out',
yoyo: true,
});
// Slide in from left
sprite.setX(-100);
this.tweens.add({
targets: sprite,
x: 400,
duration: 500,
ease: 'Back.Out',
});
sprite.setY(targetY - 100);
this.tweens.add({
targets: sprite,
y: targetY,
duration: 600,
ease: 'Bounce.Out',
});
See references/easing-reference.md for the complete guide with all easing functions and use cases.
Quick reference:
'Linear' — constant speed; mechanical, UI bars'Quad.Out' — fast start, decelerates; most natural movement'Quad.In' — accelerates; falling objects, winding up'Quad.InOut' — symmetric ease; camera moves'Back.Out' — overshoots target then settles; UI popups, dialog slides'Bounce.Out' — bounces at destination; objects hitting ground'Elastic.Out' — spring oscillation; comic, bouncy UISequence multiple tweens without nesting onComplete callbacks:
this.tweens.timeline({
tweens: [
{
targets: panel,
alpha: 1,
duration: 200,
},
{
targets: panel,
y: 300,
duration: 400,
ease: 'Back.Out',
},
{
targets: title,
alpha: 1,
duration: 300,
offset: '-=100', // start 100ms before previous tween ends (overlap)
},
{
targets: button,
alpha: 1,
duration: 200,
// no offset = starts after previous completes
},
],
});
offset controls timing relative to the previous tween:
'-=200' — overlap by 200ms'+=200' — add 200ms gapFor burst effects (explosions, pickups, impacts), use the built-in particle system:
// One-shot burst
this.add.particles(x, y, 'spark', {
speed: { min: 50, max: 200 },
angle: { min: 0, max: 360 },
scale: { start: 1, end: 0 },
lifespan: 600,
quantity: 12,
emitting: false, // don't start automatically
}).explode(12); // emit 12 particles immediately then stop
// Persistent emitter (fire, rain)
const emitter = this.add.particles(x, y, 'flame', {
speed: 30,
lifespan: 1200,
scale: { start: 0.8, end: 0 },
alpha: { start: 1, end: 0 },
frequency: 80, // ms between emissions
});
// Stop later:
emitter.stop();
references/animation-api.md — Complete AnimationManager, AnimationConfig, Animation events, TweenManager, and Timeline API referencereferences/easing-reference.md — All built-in easing functions with descriptions, use cases, and code examplesreferences/state-machine-patterns.md — State-machine discipline for characters: cinematicMode flag, canonical state list, transition table, RC7 stop()/play() ordering rule, and ANIMATION_COMPLETE timing drift. Read when building any character with more than idle+walk, or when forced animations play for one frame and revert.npx claudepluginhub yakoub-ai/phaser4-gamedev --plugin phaser4-gamedevCreates and configures sprites, images, text, graphics, and physics-enabled objects in Phaser 4 scenes. Covers `this.add.*` and `this.physics.add.*` usage.
Creates and manages pixel art sprite animations: adds, deletes, duplicates frames; sets durations; creates/deletes tags; links cels using Aseprite tools.
Animate in Godot 4.x using AnimationPlayer (keyframed clips), AnimationTree (state machines/blend spaces), and Tween (procedural UI tweens).