# Phaser performance optimization: measure first, then fix

> How to make a Phaser 3 or Phaser 4 game run faster: measure real frame times with game.loop.rawDelta and Spector.js, then cut draw calls with texture atlases, pool objects in Groups, use BitmapText for changing numbers, stop per-frame garbage, and tame physics, tilemaps, particles and canvas size. With code you can copy.

Website: https://www.glitchless.gg/phaser-performance-optimization.html · Phaser 3.60 and later, and Phaser 4

Most slow Phaser games are slow for one of a handful of reasons: sprites from separate images that can't share a draw call, objects created and destroyed in bursts, `Text` redrawn every frame, garbage made every frame, physics doing more than it needs to, big tilemaps, particles with no limit, or a canvas with more pixels than a phone can fill. Each one shows up in numbers you can read in a few minutes, so measure before you change anything.

## The short answer

1. **Too many draw calls**: pack sprites that share the screen into a texture atlas, so they share batches
2. **Hitches when many objects appear**: pool bullets, enemies and effects in a Group with `maxSize`, `get()` and `killAndHide()`
3. **Text that changes every frame**: use `BitmapText` for scores, timers and anything else that changes often
4. **Hitches every few seconds**: stop creating objects, arrays and strings in `update()`
5. **Physics doing more work than it needs to**: use Arcade unless you need Matter, make walls static, and use `overlap` where nothing needs pushing apart
6. **Big tilemaps**: keep camera culling on, use fewer layers, and in Phaser 4 draw static layers with `TilemapGPULayer`
7. **Particles without a limit**: cap emitters with `maxAliveParticles`, and reuse one emitter with `explode()`
8. **Fine on desktop, slow on phones**: draw a fixed game size and let the Scale Manager stretch it; cut full-screen layers

## First, measure: real frame times, not FPS

`this.game.loop.actualFps` is an average that Phaser updates once a second, so a half-second freeze barely moves it. The `delta` your scene's `update(time, delta)` gets, and `this.game.loop.delta`, are smoothed too by default: each is the average of the last 10 frames, and a frame longer than 200 ms (from the default `fps.min` of 5) is replaced by an earlier one. That hides exactly the hitches you're looking for. Use `this.game.loop.rawDelta`, the real time since the last frame, and report the typical frame, the 95th percentile and the worst frame.

Then read the shape. Slow everywhere and worst on phones points to draw calls or pixels. Spikes when things appear point to objects or text being made. Small hitches at a steady rhythm point to garbage collection. A game that gets slower the longer you play points to objects or particles piling up.

To see where a long frame went, record a few seconds in Chrome's Performance panel and look at which functions the long frames spent their time in; tick **Memory** to see the JavaScript heap as well. To count draw calls, capture one frame with [Spector.js](https://spector.babylonjs.com/), a browser extension that lists the WebGL calls the frame made.

frame-log.js:

```js
// frame-log.js: call frameLog(this) from your scene's update().
// Every 5 seconds it prints the typical, 95th percentile and worst frame.
const frames = [];
let total = 0;

export function frameLog(scene) {
  const loop = scene.game.loop;
  const ms = loop.rawDelta; // the real time since the last frame; loop.delta is smoothed
  frames.push(ms);
  total += ms;
  if (total < 5000) return;
  frames.sort((a, b) => a - b);
  const at = (q) => frames[Math.min(frames.length - 1, Math.floor(frames.length * q))].toFixed(1);
  console.log(`frame ms: typical ${at(0.5)}, 95th ${at(0.95)}, worst ${at(1)} | ` +
    `fps ${loop.actualFps.toFixed(0)}, display list ${scene.children.length}`);
  frames.length = 0;
  total = 0;
}
```

## Too many draw calls

**What you see.** A Spector.js capture shows hundreds of draw calls a frame, and the game is slow on every device, worst on phones.

**Why.** Phaser draws Images, Sprites and BitmapText in batches, one draw call per batch. A batch ends when it needs more textures than the GPU has texture units (most have 8 or 16), when the shader changes (lights, filters or FX, a `Shader` object), or when it's full. On iOS and Android, Phaser uses one texture per batch by default (the mobile pipeline in Phaser 3.60, `autoMobileTextures` in Phaser 4), so every switch from one image to another starts a new draw call: enemy, coin, enemy, coin is four.

Pack the images that appear together into a texture atlas: one image with many frames and a JSON file that says where each frame is. TexturePacker exports Phaser's format. Load it with `this.load.atlas()` and pick frames by name, so the player, enemies, coins and bullets come from one texture and batch on every device. Leave a pixel of padding around each frame, and keep separate atlases for things that never share the screen, such as menus and each level's art.

Keep lights, filters, FX and `Shader` objects to a few, since each one ends the batch. Phaser 4's own rendering guide says not to use more than a few hundred draw calls a frame.

atlas.js:

```js
// preload(): one atlas image holds the player, enemies, coins and bullets.
this.load.atlas('sprites', 'assets/sprites.png', 'assets/sprites.json');

// create(): each object draws a frame of the same texture, so they share batches.
this.player = this.add.sprite(100, 300, 'sprites', 'player-idle');
this.add.image(400, 300, 'sprites', 'coin');
this.add.image(460, 300, 'sprites', 'enemy');
```

## Hitches when many objects appear

**What you see.** A hitch when a wave spawns or a weapon fires fast, and a display list that keeps growing the longer you play.

**Why.** Every object you create is a new JavaScript object added to the scene's display list, and a Sprite also joins the update list so its animation runs. Every object you destroy is garbage. Objects you no longer need but never removed keep costing every frame: Phaser runs `preUpdate` on every active object on the update list and renders every visible one.

Make what a level needs once and reuse it. A Group with `maxSize` is a pool: `get(x, y)` hands back the first inactive member, or makes a new one while the group is under `maxSize`, and returns `null` when it's full. `killAndHide()` puts a member back by making it inactive and invisible, so it's skipped by both the update list and the renderer.

A reused member comes back as you left it, inactive and hidden, so turn it back on with `setActive(true).setVisible(true)`, or for an Arcade Physics body with `enableBody(true, x, y, true, true)`. With physics, put members away with `disableBody(true, true)`, which also takes the body out of collisions; `killAndHide()` on its own leaves the body in the world.

In Phaser 4, a background of thousands of sprites that rarely change can be one `SpriteGPULayer`, drawn in a single draw call. Changing its contents rewrites its whole buffer, so fill it once.

bullets.js:

```js
class Level extends Phaser.Scene {
  create() {
    // A pool of at most 60 bullets, made as they're needed, then reused.
    this.bullets = this.physics.add.group({ defaultKey: 'sprites', defaultFrame: 'bullet', maxSize: 60 });
  }

  fire(x, y) {
    const bullet = this.bullets.get(x, y);
    if (!bullet) return;                       // all 60 are in flight
    bullet.enableBody(true, x, y, true, true); // a reused bullet comes back inactive and hidden
    bullet.setVelocityY(-600);
  }

  update() {
    // Put away bullets that left the screen instead of destroying them.
    const bullets = this.bullets.getChildren();
    for (let i = 0; i < bullets.length; i++) {
      if (bullets[i].active && bullets[i].y < -16) bullets[i].disableBody(true, true);
    }
  }
}
```

## Text that changes every frame

**What you see.** Frame time goes up while a timer or score is counting, and more with each label that changes.

**Why.** A `Text` object draws its string onto a canvas of its own with the browser's text drawing, and in WebGL uploads that canvas to the GPU as a new texture, every time the string or its style changes. Phaser's docs call this expensive if done often. `setText` with the same string does nothing, but a timer showing tenths of a second changes ten times a second.

Use `BitmapText` for numbers and labels that change. Its letters are frames of a font texture, so it batches like a sprite and changing it involves no canvas drawing or upload. Make the font with a tool such as BMFont or Snow BMF and load it with `this.load.bitmapFont()`.

Keep `Text` for words that rarely change, and call `setText` only when the value does.

score.js:

```js
// preload(): a bitmap font made with a tool such as BMFont or Snow BMF (a PNG and its XML).
this.load.bitmapFont('digits', 'fonts/digits.png', 'fonts/digits.xml');

// create()
this.scoreText = this.add.bitmapText(16, 16, 'digits', '0', 32);

// When the score changes, not every frame:
this.scoreText.setText(String(this.score));
```

## Hitches every few seconds

**What you see.** Small hitches at a steady rhythm, and in Chrome's Performance panel with **Memory** ticked, a JavaScript heap that keeps rising and dropping.

**Why.** Everything made in `update()` is garbage a frame later: a `new Phaser.Math.Vector2()`, an array from `filter` or `map`, a closure, a string built for the HUD. The browser cleans it up in garbage collections, and while it collects, your game's code is paused.

Create vectors, arrays and temporary objects once, outside `update()`, and reuse them. Loop over `group.getChildren()` with a plain `for` loop instead of chaining `filter` and `map` in code that runs every frame, and build HUD strings only when the value changes.

chase.js:

```js
// Made once, reused every frame.
const toPlayer = new Phaser.Math.Vector2();

function chase(enemy, player, speed) {
  toPlayer.set(player.x - enemy.x, player.y - enemy.y).normalize().scale(speed);
  enemy.body.setVelocity(toPlayer.x, toPlayer.y);
}
```

## Physics doing more work than it needs to

**What you see.** Frame time grows with the number of moving bodies, and the Performance panel shows the time going to the physics step.

**Why.** Every physics step moves each dynamic body and runs every collider and overlap you added. Arcade Physics handles only rectangles and circles, and Phaser's docs call it very fast. Matter is a full rigid-body engine, with polygons, constraints and joints, for games that need them.

Use Arcade unless the game needs what only Matter does. Put walls and platforms that never move in a static group (`this.physics.add.staticGroup()`): static bodies sit in a tree that isn't rebuilt every frame. Use `overlap` instead of `collider` for pickups and triggers, which don't need pushing apart, and disable the bodies of pooled objects you've put away.

Arcade keeps moving bodies in a tree too, rebuilt every frame. Phaser's docs say that past about 5,000 moving bodies the rebuild costs more than it saves, so set `useTree: false`. In Matter, turn on `enableSleeping`, so bodies at rest act like static ones until something wakes them.

physics.js:

```js
// Game config: Arcade, unless you need what only Matter does.
const config = {
  physics: {
    default: 'arcade',
    arcade: { useTree: true }, // false once you have more than about 5,000 moving bodies
  },
  // ...
};

// create(): walls and platforms that never move are static bodies.
const walls = this.physics.add.staticGroup();
walls.create(400, 568, 'sprites', 'ground');
this.physics.add.collider(this.player, walls);

// Pickups only need detecting, not pushing apart.
this.physics.add.overlap(this.player, this.coins, this.collect, null, this);
```

## Big tilemaps

**What you see.** Frame time grows with how much of the map is on screen: worse zoomed out, on big screens and with every extra layer.

**Why.** A `TilemapLayer` only draws the tiles inside the camera's view, plus a tile of padding, but it works out each of those tiles on the CPU every frame. Zooming out, a large canvas and several stacked layers all multiply the tiles.

Leave culling on: don't call `setSkipCull(true)` on big layers. Merge decorative layers in your map editor so fewer layers are drawn, and limit how far the camera zooms out.

In Phaser 4, pass `true` as the fifth argument of `createLayer()` to get a `TilemapGPULayer`. It keeps the layer's tiles in a texture and draws the whole layer as one quad with a shader, so its cost depends on the pixels it covers rather than the number of tiles, and Phaser's docs recommend it for large numbers of tiles on phones. It works with orthographic maps, one tileset image and WebGL only, and after you change tiles you have to regenerate its data, so use it for layers that don't change.

tilemap.js:

```js
// create()
const map = this.make.tilemap({ key: 'level1' });
const tiles = map.addTilesetImage('terrain', 'terrain');

// Phaser 3 and 4: culled to the camera by default. Don't call setSkipCull(true) on big layers.
const ground = map.createLayer('Ground', tiles, 0, 0);

// Phaser 4: a layer that doesn't change, drawn as one quad by a shader
// (orthographic maps, one tileset image, WebGL only).
const background = map.createLayer('Background', tiles, 0, 0, true);
```

## Particles without a limit

**What you see.** Frame time climbs during explosions, rain or trails, and keeps climbing while an effect runs.

**Why.** An emitter's `maxParticles` and `maxAliveParticles` both default to 0, which means no limit, so it makes as many particles as its quantity, frequency and lifespan ask for. Every live particle is updated and drawn each frame, and an emitter left running where nobody can see it costs the same.

Set `maxAliveParticles` on every emitter, keep `lifespan` short, and call `stop()` on emitters that are off-screen or finished. For bursts such as hits and explosions, create one emitter with `emitting: false` and call `explode(count, x, y)` each time, rather than making an emitter per explosion. Dead particles go back into the emitter's pool and are reused.

sparks.js:

```js
// create(): one capped emitter for every spark burst.
this.sparks = this.add.particles(0, 0, 'sprites', {
  frame: 'spark',
  lifespan: 400,
  speed: { min: 80, max: 200 },
  maxAliveParticles: 200,
  emitting: false,
});

// On a hit:
this.sparks.explode(12, x, y);
```

## Fine on desktop, slow on phones

**What you see.** Smooth on your computer, slow on phones and big high-resolution screens, and faster when the game is smaller.

**Why.** Phaser draws its canvas at the game's `width` and `height`, and the Scale Manager stretches it to fit with CSS. Size the game from `window.innerWidth * window.devicePixelRatio` and a phone with a pixel ratio of 3 draws nine times the pixels of the same screen at a ratio of 1. Phaser 4's docs name filling pixels as the one place the GPU is likely to be the bottleneck, and full-screen backgrounds, transparent overlays, filters and FX each fill the screen again.

Pick a fixed game size for your art and let `Phaser.Scale.FIT` stretch it to the screen. Don't multiply the size by `devicePixelRatio`. Cut layers that cover the whole screen, and keep filters and FX for moments that need them, especially on phones.

config.js:

```js
const config = {
  type: Phaser.AUTO,
  width: 1280,   // the pixels Phaser draws: not window.innerWidth * window.devicePixelRatio
  height: 720,
  scale: {
    mode: Phaser.Scale.FIT,               // stretch the canvas with CSS to fit the screen
    autoCenter: Phaser.Scale.CENTER_BOTH,
  },
  scene: [Boot, Level1],
};
```

## Check the fix

Run the same scene the same way before and after a change: the same level, the same route, about the same length of time. Compare the typical, 95th percentile and worst frame from the logger above, not the FPS counter, and the draw calls in a Spector.js capture.

Test on the slowest phone you support, in its own browser, not only in your desktop browser's device mode, which shrinks the screen but keeps your computer's GPU.

## What your players' devices do

Your laptop isn't your players' phones. A scene that's smooth for you can drop frames on a mid-range Android phone, and nothing crashes, so nobody tells you.

[Glitchless](https://www.glitchless.gg/index.md) is an open-source SDK for Phaser 3.60 and later and Phaser 4 (and Three.js, Babylon.js and PlayCanvas). `instrumentPhaser(glitchless, { game, Phaser })` times every frame of Phaser's loop in every player's session, split into update and render time, counts draw calls and shader compiles on the WebGL context, and records game objects, particles, physics bodies, tweens and event listeners alongside. Each hitch gets its likely cause: a burst of new objects, a texture made mid-game, a shader compiling, a scene change, your script or the renderer. It all splits by GPU tier, browser and version.

Your coding agent reads the same data over MCP, and with the game open in a browser it can list each scene's display list by type and texture, then measure the scene again to check its own fix. The same SDK records deaths and positions for [death heatmaps](https://www.glitchless.gg/game-death-heatmaps.md).

Ask your coding agent:

```
Use Glitchless to find why my Phaser game is slow for players: show the levels with the worst 95th percentile frame time with their draw calls, game object counts and hitch causes, fix the top problem, and measure the scene again to show the difference.
```

## Frequently asked questions

### Why is my Phaser game slow only on phones?

Usually pixels and textures. Phone GPUs fill pixels more slowly, and on iOS and Android Phaser batches one texture at a time by default, so sprites from separate images each cost a draw call. Draw a fixed game size, stretch it with the Scale Manager, and put sprites that share the screen in one atlas.

### How many draw calls is too many?

Phaser 4's rendering guide says not to use more than a few hundred a frame, and phones want fewer. If a Spector.js capture shows a draw call for nearly every sprite, textures are breaking the batches.

### Should I cap the frame rate?

`fps: { limit: 30 }` in the game config makes Phaser run its step at most 30 times a second. That cuts the work done each second, but it doesn't make a slow frame any faster, so a hitch is still a hitch. Fix what's slow first.

### Should I use Arcade or Matter physics?

Arcade, unless the game needs what only Matter has: shapes other than rectangles and circles, constraints and joints, or rigid-body dynamics. They're two separate systems, and Phaser's docs call Arcade very fast.

## Sources

- [Phaser source: TimeStep (actualFps, delta smoothing, rawDelta)](https://github.com/phaserjs/phaser/blob/master/src/core/TimeStep.js)
- [Phaser 4 Rendering Concepts (batching, texture units, TilemapGPULayer, SpriteGPULayer)](https://github.com/phaserjs/phaser/blob/master/docs/Phaser%204%20Rendering%20Concepts/Phaser%204%20Rendering%20Concepts.md)
- [Phaser 4 Shader Guide (what ends a batch)](https://github.com/phaserjs/phaser/blob/master/docs/Phaser%204%20Shader%20Guide/Phaser%204%20Shader%20Guide.md)
- [Phaser 3.60 source: the mobile pipeline](https://github.com/phaserjs/phaser/blob/v3.60.0/src/renderer/webgl/pipelines/MobilePipeline.js)
- [Phaser docs: Textures and atlases](https://docs.phaser.io/phaser/concepts/textures)
- [Phaser docs: Group](https://docs.phaser.io/phaser/concepts/gameobjects/group)
- [Phaser docs: Text](https://docs.phaser.io/phaser/concepts/gameobjects/text)
- [Phaser docs: Arcade Physics](https://docs.phaser.io/phaser/concepts/physics/arcade)
- [Phaser docs: Matter Physics](https://docs.phaser.io/phaser/concepts/physics/matter)
- [Phaser source: TilemapLayer and TilemapGPULayer](https://github.com/phaserjs/phaser/tree/master/src/tilemaps)
- [Phaser source: ParticleEmitter](https://github.com/phaserjs/phaser/blob/master/src/gameobjects/particles/ParticleEmitter.js)
- [Phaser docs: Scale Manager](https://docs.phaser.io/phaser/concepts/scale-manager)
- [Chrome DevTools: Fix memory problems](https://developer.chrome.com/docs/devtools/memory-problems)
- [Spector.js](https://spector.babylonjs.com/)
