Phaser has three ways to get animated sprites into a game: a plain spritesheet (a grid of equal frames), a texture atlas (a packed image plus JSON), and an Aseprite export. They all end up in the same Animation Manager, so once you know which loader fits your file, the rest of the code is identical.
This guide targets Phaser 3.60 and later (the code was written against the 3.80–3.90 API). It uses a 64 × 64 character sheet with 8 frames per row and four rows for down, left, right and up, which is how AI Sprite Maker and most hand-made sheets are laid out.
Game config for pixel art
Set this before anything else, or every sprite will be smoothed:
const config = {
type: Phaser.AUTO,
width: 480,
height: 270,
pixelArt: true, // nearest-neighbour filtering + roundPixels
backgroundColor: "#1d1a3a",
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
physics: { default: "arcade", arcade: { debug: false } },
scene: [GameScene],
};
new Phaser.Game(config);
pixelArt: true turns off texture smoothing and turns on roundPixels, so sprites snap to whole pixels. A small base resolution (480 × 270 here) scaled up by the Scale Manager keeps every sprite pixel the same size on screen. FIT can produce non-integer scales on some screens; if shimmering bothers you, use mode: Phaser.Scale.NONE with zoom: 3 (or compute the largest integer zoom that fits the window).
Loading a grid spritesheet
class GameScene extends Phaser.Scene {
constructor() {
super("game");
}
preload() {
this.load.spritesheet("hero", "assets/hero_walk.png", {
frameWidth: 64,
frameHeight: 64,
// margin: 0, // pixels around the whole sheet
// spacing: 0, // pixels between frames
// endFrame: -1 // stop early if the sheet has empty cells
});
}
}
Frames are numbered from 0, left to right, top to bottom. With 8 columns, row r starts at r * 8.
If frames come out shifted by a few pixels or the last column is missing, the sheet has a margin or spacing that you haven’t declared. Measure it in an image editor and set margin and spacing.
Creating animations
Animations are global: create them once (usually in a boot or preload scene’s create) and every sprite can play them.
create() {
const rows = ["down", "left", "right", "up"];
rows.forEach((dir, r) => {
this.anims.create({
key: `walk-${dir}`,
frames: this.anims.generateFrameNumbers("hero", { start: r * 8, end: r * 8 + 7 }),
frameRate: 10,
repeat: -1, // loop forever
});
});
this.player = this.physics.add.sprite(240, 135, "hero", 0);
this.player.setOrigin(0.5, 1); // feet on the position
this.player.play("walk-down");
}
Useful options on anims.create:
| Option | What it does |
|---|---|
frameRate | Frames per second. Match your sheet (8–12 for walks) |
repeat | -1 loops forever, 0 plays once, n repeats n extra times |
yoyo | Plays forward then backward. Handy for breathing idles |
repeatDelay | Milliseconds to wait between loops, e.g. for blinking |
frames: [{ key, frame, duration }] | Explicit frames with per-frame durations in ms |
You can also pick frames out of order: generateFrameNumbers("hero", { frames: [0, 1, 2, 1] }).
Why setOrigin(0.5, 1)
By default a sprite’s origin is its centre. Put it at the bottom centre so the sprite’s y is where its feet are. Depth sorting with setDepth(sprite.y) then works naturally, and switching between animations of different heights doesn’t make the character hop. The physics body is separate; set it with body.setSize() and body.setOffset() to cover just the feet for top-down games:
this.player.body.setSize(20, 10).setOffset(22, 52);
Switching animations from movement
The mistake everyone makes once: calling play() every frame restarts the animation from frame 0, so the walk never gets past its first frame. Pass true as the second argument (ignoreIfPlaying) to keep the current animation running if it’s already the one you asked for.
create() {
// ...animations and player from above...
this.cursors = this.input.keyboard.createCursorKeys();
this.facing = "down";
}
update() {
const speed = 90;
const { left, right, up, down } = this.cursors;
const vx = (right.isDown ? 1 : 0) - (left.isDown ? 1 : 0);
const vy = (down.isDown ? 1 : 0) - (up.isDown ? 1 : 0);
const v = new Phaser.Math.Vector2(vx, vy).normalize().scale(speed);
this.player.setVelocity(v.x, v.y);
if (vx !== 0 || vy !== 0) {
// dominant axis decides the facing for 4-direction art
this.facing = Math.abs(vx) > Math.abs(vy) ? (vx > 0 ? "right" : "left") : (vy > 0 ? "down" : "up");
this.player.play(`walk-${this.facing}`, true);
} else {
this.player.play(`idle-${this.facing}`, true);
}
}
This assumes you also created idle-down, idle-left and so on from an idle sheet. If you don’t have idle art yet, stop the walk on its first frame instead:
this.player.anims.stop();
this.player.setFrame(this.player.anims.currentAnim.frames[0].frame.name);
For side-scrollers with one row of art, use flipX instead of separate left and right animations:
if (vx !== 0) this.player.setFlipX(vx < 0);
Flipping happens around the origin, so a bottom-centre origin keeps the character in place.
One-shot animations and chaining
Attacks, hurts and deaths play once and hand control back. Listen for completion:
this.anims.create({
key: "attack-right",
frames: this.anims.generateFrameNumbers("hero_attack", { start: 16, end: 23 }),
frameRate: 14,
repeat: 0,
});
attack() {
this.isAttacking = true;
this.player.play(`attack-${this.facing}`);
this.player.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
this.isAttacking = false;
this.player.play(`idle-${this.facing}`);
});
}
Guard your movement code with if (this.isAttacking) return; so it doesn’t override the attack with a walk on the next frame.
There’s also an event per animation key, animationcomplete-attack-right, if you prefer one listener per animation. And chain() queues the next animation without an event:
this.player.play("attack-right").chain("idle-right");
To deal damage on a specific frame, listen for Phaser.Animations.Events.ANIMATION_UPDATE and check frame.index.
Loading a texture atlas (PNG + JSON)
Packed atlases save texture memory and let one image hold every animation. Phaser reads TexturePacker-style JSON in both the Hash and Array variants:
preload() {
this.load.atlas("hero", "assets/hero.png", "assets/hero.json");
}
Frames are now referenced by name instead of number. If the JSON names frames walk_down_000.png to walk_down_007.png, generate the list with generateFrameNames:
this.anims.create({
key: "walk-down",
frames: this.anims.generateFrameNames("hero", {
prefix: "walk_down_",
start: 0,
end: 7,
zeroPad: 3,
suffix: ".png",
}),
frameRate: 10,
repeat: -1,
});
zeroPad: 3 turns 0 into 000. Get the prefix, padding or suffix wrong and Phaser silently creates an empty animation, so if nothing shows, log this.textures.get("hero").getFrameNames() and compare.
Atlases can contain trimmed frames (transparent borders removed to save space). Phaser restores the original size from spriteSourceSize and sourceSize, so trimmed frames still line up. If they don’t, the JSON is missing those fields.
Loading Aseprite files
If you animate in Aseprite, skip writing animation code entirely. Export with File → Export Sprite Sheet, tick JSON Data, and enable Tags under Meta. Then:
preload() {
this.load.aseprite("hero", "assets/hero.png", "assets/hero.json");
}
create() {
this.anims.createFromAseprite("hero"); // one animation per tag
this.player = this.add.sprite(240, 135, "hero").setOrigin(0.5, 1);
this.player.play({ key: "walk_down", repeat: -1 });
}
createFromAseprite makes one global animation per frame tag, named after the tag, and uses Aseprite’s per-frame durations. Tag direction pingpong becomes a yoyo. Aseprite tags don’t carry a loop flag, so pass repeat: -1 when you play looping animations. You can also pass a sprite as the third argument (this.anims.createFromAseprite("hero", undefined, this.player)) to make the animations local to that sprite.
AI Sprite Maker’s Aseprite JSON export uses this format with one tag per direction (for example hero_walk_down), so the same two calls work on our sheets.
Spawning many animated sprites
Enemies sharing an animation should start at different frames, or a crowd of 20 slimes bounces in perfect unison:
const slime = this.add.sprite(x, y, "slime").setOrigin(0.5, 1);
slime.play({ key: "slime-idle", startFrame: Phaser.Math.Between(0, 5) });
// or: slime.anims.timeScale = Phaser.Math.FloatBetween(0.9, 1.1);
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Sprites are blurry | Smoothing on | pixelArt: true in the config |
| Animation stuck on frame 0 | play() called every frame | play(key, true) |
| Frames shifted or cut | Undeclared margin or spacing | Set margin / spacing in load.spritesheet |
| Lines from the next frame at the edges | Sampling bleed at non-integer scales | Integer zoom; pack atlases with 2 px padding or extrude |
| Atlas animation shows nothing | Frame names don’t match | Log getFrameNames() and fix prefix, zeroPad or suffix |
| Character hops between animations | Origin at the centre, frames of different heights | setOrigin(0.5, 1) and the same frame size per character |
| Walk looks like ice skating | Move speed doesn’t match the stride | Lower speed or raise frameRate until the planted foot stays still |
Before you write any code, play the sheet in the free Animation Previewer to find a frame rate you like. If your frames are loose PNGs, the Sprite Sheet Maker packs them into a grid or atlas with JSON.
Generating sheets from code
Phaser projects often have a build step, which makes them a good fit for generated art. AI Sprite Maker’s REST API takes a prompt and a library motion and returns a PNG sheet plus JSON with frame_w, frame_h, columns, rows, fps and directions, which map directly onto load.spritesheet and anims.create. A library animation costs 10 credits per direction. If you use a coding agent, the MCP server lets it generate the sheet and write the Phaser loader code in the same step.