Toolkitly

How to load a sprite sheet in Phaser 3

Phaser is the odd one out. Godot and Unity have a slicer built into an editor; Phaser has two loader functions and a config object, and the sheet is described in code. That makes it the only one of the three that reads a packed atlas natively — and the only one where the most common failure is that nothing loads at all and the console does not obviously say why.

0. Serve it over HTTP, or none of this works

Put this first because it costs more time than everything else on the page combined. Phaser fetches assets over the network, and browsers refuse cross-origin requests on file:// URLs. Open index.html by double-clicking it and every image fails, the canvas stays blank or shows green boxes, and the console message is about CORS rather than about your sheet.

Run any local server from the project folder — npx serve, python -m http.server, the Live Server extension, or whatever your bundler already provides — and load http://localhost:… instead.

When an asset still does not appear, the browser's Network tab answers it faster than Phaser will: a 404 means the path is wrong relative to the page, not to the source file.

1. Two loaders, and the sheet decides which

Everything downstream follows from this choice, including which function builds the animation frames.

load.spritesheet

An even grid — every frame the same size

You give it the pixel size of one cell. Frames are numbered left to right, top to bottom, starting at 0. No second file involved.

load.atlas

A packed atlas — frames of different sizes

You give it a PNG and a JSON file describing where each frame is. Frames are referenced by name rather than index.

There are more: load.atlasXML for Starling and Adobe formats, load.unityAtlas for Unity's, and load.multiatlas for an atlas split across several images. They are the same idea with a different parser.

2. load.spritesheet — frameWidth is a size, not a count

This is the single most common mistake, and it is a naming collision with other engines. Unity's Sprite Editor offers "Grid by Cell Count" where you type 8 columns. Godot's slice dialog wants 8 and 4. Phaser wants the pixel dimensions of one frame. A 256×128 sheet of 32px frames is 32 and 32, not 8 and 4.

function preload () {
  this.load.spritesheet('hero', 'assets/hero.png', {
    frameWidth: 32,
    frameHeight: 32,
    margin: 0,    // border around the outside of the grid
    spacing: 0    // gap between adjacent frames
  })
}

margin and spacing exist because exporters often leave a pixel or two between cells to stop the GPU sampling across frame boundaries. Leave them out when the sheet has padding and the grid drifts a little further off with every column — check the last frame, not the first, because frame 0 looks correct either way.

startFrame and endFrame can restrict the load to part of the sheet, but there is rarely a reason to; loading everything and picking indices per animation is simpler.

If the sheet is not an even grid, this loader cannot describe it. That is section 4.

3. Build the animation

Loading happens in preload; everything else happens in create. Creating a sprite in preload is a guaranteed "texture missing" error, because the file has not arrived yet.

function create () {
  this.anims.create({
    key: 'run',
    frames: this.anims.generateFrameNumbers('hero', { start: 0, end: 7 }),
    frameRate: 10,
    repeat: -1          // -1 loops forever; the default 0 plays once
  })

  const hero = this.add.sprite(160, 120, 'hero')
  hero.play('run')
}

repeat: -1 is the one people miss. The default is 0, meaning play once and stop on the last frame — which looks exactly like "the animation is broken" when you expected a walk cycle.

Frame indices are row-major and zero-based. On an 8-wide sheet, row 2 is frames 8 to 15. Getting this wrong produces an animation that plays the right number of frames from the wrong row.

Animations are global, not per-scene. this.anims is the game's single Animation Manager. Restart a scene and the second anims.create with the same key warns and does nothing. Guard it:

if (!this.anims.exists('run')) {
  this.anims.create({ /* … */ })
}

For an atlas, swap generateFrameNumbers for generateFrameNames, which builds the names instead of counting:

frames: this.anims.generateFrameNames('hero', {
  prefix: 'run_',
  start: 1,
  end: 8,
  zeroPad: 4,        // run_0001.png
  suffix: '.png'
})

The names have to match the atlas JSON exactly, padding included — this is where an off-by-one in zeroPad produces an empty animation with no error.

Settle frameRate before you are in code. 10 is a reasonable default and 8–12 covers most pixel art, but the number that reads right is something you see rather than calculate. The Sprite Animation Previewer plays a sheet back at any FPS in the browser, so you type the answer once instead of editing and reloading.

4. Packed atlases — the thing Phaser does that the others do not

Godot has no importer for atlas JSON at all. Unity has one only for its own format. Phaser reads the TexturePacker JSON that most packers emit, in either the Array or Hash layout, with two arguments:

this.load.atlas('hero', 'assets/hero.png', 'assets/hero.json')

That format carries more than positions — trimmed, spriteSourceSize and sourceSize let a packer strip the transparent border off each frame and have Phaser put it back at draw time, so a sheet of mostly-empty frames gets much smaller without the sprites shifting.

Our Sprite Sheet Generator exports that format. Set Manifest format to TexturePacker (JSON Array) — the default — download the PNG and the JSON, and the two-argument call above is the whole integration. Turning trim on is then safe: the manifest records what was cut and Phaser puts it back, so the sheet shrinks and nothing moves on screen.

If you have a manifest in some other shape — your own exporter, a format nothing parses — you do not need a converter either. Phaser will take frame rectangles directly:

function preload () {
  this.load.image('hero', 'assets/hero.png')
  this.load.json('heroFrames', 'assets/hero.json')
}

function create () {
  const texture = this.textures.get('hero')
  for (const f of this.cache.json.get('heroFrames').frames) {
    texture.add(f.filename, 0, f.x, f.y, f.w, f.h)
  }

  // the frames are now addressable by name, same as a real atlas
  this.add.sprite(160, 120, 'hero', 'idle_01.png')
}

Twelve lines and no build step. What it cannot express is trimming — there is nowhere to say a frame was cropped — so pack without trimming if you go this route.

Worth saying plainly: pack the sheet, do not ship loose PNGs. Sixty load.image calls are sixty requests and sixty GPU textures, and every draw that switches texture breaks batching. One sheet is one request and one texture, and Phaser batches the whole scene. If your frames are currently separate files, that is what the Sprite Sheet Generator is for; if you were handed one big sheet and need the frames out of it, the Sprite Sheet Cutter goes the other way.

5. Keeping pixel art crisp

One config flag does most of the work. pixelArt: true turns off texture smoothing across the game, which is the WebGL equivalent of Godot's Nearest filter and Unity's Point filter mode.

const config = {
  type: Phaser.AUTO,
  width: 320,
  height: 180,
  zoom: 4,             // whole number — 1280x720 on screen
  pixelArt: true,
  roundPixels: true,
  scene: { preload, create }
}

Design at the small resolution and zoom up. A 320×180 game world at zoom: 4 fills 1280×720 with every pixel exactly four screen pixels square. Setting width: 1280 and scaling sprites up by 4 instead gets you the same picture with worse numbers everywhere and a lot more room to end up at a fractional scale by accident.

roundPixels forces sprites to draw at whole pixel positions. Without it, a sprite at x = 100.5 is rendered across two pixel columns and appears to shimmer as it moves — visible in motion, invisible in a screenshot.

Watch the Scale Manager. Phaser.Scale.FIT resizes the canvas to fill the window and will happily land on a scale of 2.37, undoing the above. For pixel art either leave scaling off and pick a fixed zoom, or use FIT and accept that the crispness is approximate. There is no setting that gives you both.

Also check setScale calls and camera zoom for non-integers — a tween easing a sprite from 1 to 1.2 passes through every fractional value in between.

Common problems

Blank canvas, CORS errors in the console

The page is on file://. Serve the folder over HTTP.

Green boxes where sprites should be

Phaser's missing-texture placeholder. The key is wrong, or the file 404'd — check the Network tab before checking your code.

The whole sheet appears as one giant sprite

frameWidth and frameHeight were omitted, so the loader treated the image as a single frame.

Frames are offset, and worse further along the sheet

The sheet has spacing or a margin that was not declared. Cumulative error — inspect the last column.

The animation plays once and stops

repeat defaults to 0. Set repeat: -1.

Animation key already exists warning after a scene restart

Animations live on the global manager. Wrap creation in an anims.exists check.

An atlas animation is empty and nothing errors

generateFrameNames built names that are not in the JSON — usually zeroPad or the suffix. Log this.textures.get(key).getFrameNames() and compare.

Pixel art is blurry

pixelArt: true is missing from the game config.

Sprites shimmer while moving

Fractional positions or a fractional scale. Turn on roundPixels and use a whole number zoom instead of Scale.FIT.

Tools used here