Skip to content
AI Sprite Maker

Phaser 3

Phaser 3 Sprite Sheet Animation: Spritesheets, Atlases and Animation States

Published November 6, 20268 min read
On this page
  1. Game config for pixel art
  2. Loading a grid spritesheet
  3. Creating animations
  4. Why setOrigin(0.5, 1)
  5. Switching animations from movement
  6. One-shot animations and chaining
  7. Loading a texture atlas (PNG + JSON)
  8. Loading Aseprite files
  9. Spawning many animated sprites
  10. Troubleshooting
  11. Generating sheets from code

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.

An 8-frame run strip. Loaded with frameWidth 47 and frameHeight 61, these are frames 0 to 7, and generateFrameNumbers({ start: 0, end: 7 }) plays them in order.

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:

OptionWhat it does
frameRateFrames per second. Match your sheet (8–12 for walks)
repeat-1 loops forever, 0 plays once, n repeats n extra times
yoyoPlays forward then backward. Handy for breathing idles
repeatDelayMilliseconds 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.

Fox Adventurer - Idle
Fox Adventurer - Run
Fox Adventurer - Jump
Looping idle and run (repeat: -1) next to a jump that should play once (repeat: 0) and chain back to idle.

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

ProblemCauseFix
Sprites are blurrySmoothing onpixelArt: true in the config
Animation stuck on frame 0play() called every frameplay(key, true)
Frames shifted or cutUndeclared margin or spacingSet margin / spacing in load.spritesheet
Lines from the next frame at the edgesSampling bleed at non-integer scalesInteger zoom; pack atlases with 2 px padding or extrude
Atlas animation shows nothingFrame names don’t matchLog getFrameNames() and fix prefix, zeroPad or suffix
Character hops between animationsOrigin at the centre, frames of different heightssetOrigin(0.5, 1) and the same frame size per character
Walk looks like ice skatingMove speed doesn’t match the strideLower 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.