How I optimized my Phaser 3 action game — in 2025

François
11 min readFeb 23, 2025

--

Recently, I started working on a game concept where the player uses two fingers to launch weapons like lasers or lightning bolts. Being mainly a web developer with limited experience in Unity, I decided to build the game using my go-to game engine, Phaser 3. Since the core mechanics involved two-finger touch controls, the game was mobile-first, and I knew optimizing for mobile devices would be crucial.

As the game progressed and I was totally hooked playing the prototype, I shared it with friends and family to get early on feedback. Two common responses stood out: the game was too hard (well, noobs!), and some players mentioned it was lagging.

Lagging? My game only had like four moving sprites on the canvas and I wasn’t even interacting with the game! It ran flawlessly on my Pixel 7 and even on my wife’s older Huawei phone. But to my surprise, a friend with a Pixel 6 — still a relatively new device — experienced noticeable lag. I couldn’t believe that a Pixel 6 would struggle with just a few sprites, especially considering Phaser 3 isn’t exactly an ancient game engine, but heavily optimized for speed.

Although I hadn’t invested that much time into performance optimizations yet, it got me thinking: if the game was lagging on a device like the Pixel 6, there was a real possibility it could struggle on lower-end devices as well. And since I want as many people as possible to enjoy my games, performance optimization became a priority. Games that run smoothly generally have a better chance of success, so I knew I couldn’t just focus on cool features — I had to dig deeper into how I could improve the frame rate and overall performance.

To my surprise, there weren’t that many performance guides out there or very outdated ones. So I thought I am gonna write down everything I know … about making a performant game with phaser.

Alright, let’s gooo

Quick TLDR:

  • Use object pools
  • Cache references
  • Only loop through what you need
  • Compress assets like images and audio
  • Lazy load assets
  • Use texture packer
  • Use a smaller canvas size
  • Try Canvas instead of WebGL (really!)

Add FPS Counter

AI came up with this

The first thing I did was to add a FPS counter to be able to tell if a change actually did something and not only because it felt like it. I read somewhere along the internet that rendering text may be expensive in the canvas and so I used a DOM element:

class MyScene extends Phaser.Scene {
constructor() {
super({ key: 'MyScene' });
}

create() {
this.fpsText = document.getElementById("fps");
}

update() {
this.fpsText.innerHTML = Math.floor(this.game.loop.actualFps);
}
}

Debug strategy

He is planning a strategy now

A great way to identify performance bottlenecks is through an incremental approach: start by disabling all game features, then gradually re-enable them one by one. First, observe the game’s behavior after running only the create() function. Next, uncomment the update() function and check for any performance improvements. Also, test the game without background music or sound effects to see if they’re contributing to the lag. By isolating each element, you can pinpoint exactly what’s impacting performance.

update() {
return;
// Not now
this.checkCollision();
this.doSomeProcessing();
this.spawnBonusPoints();
}

Object pools

Puppy in a pool

In action-packed games, objects like bullets, enemies, and effects are frequently created and destroyed, which can lead to memory leaks and garbage collection pauses. To avoid this, I used object pooling, a technique where objects are reused instead of created / destroyed repeatedly.

class BulletPool {

constructor(scene) {
this.scene = scene;
this.pool = this.scene.add.group({
classType: Bullet,
runChildUpdate: true, // depends on your game
maxSize: 100
});
}

spawn(x, y, velocity) {
let bullet = this.pool.get();
if (bullet) {
bullet.setVisible(true)
bullet.fire(x, y, velocity);
}
}
}

In general you want to reuse objects whenever possible instead of instantiating them from new all the time. As for the object pool make sure that objects are released again — meaning set to active = false as soon as you don’t need them anymore.

You can find various examples on how to use object pools in the Phaser Labs page.

Only render and update what needs to

In my game, as soon as an enemy dies or a unit reaches the base, I remove them from any update or renderer loop:

// Enemy class - enemy collision
onDeath() {
this.setVisible(false);
this.setActive(false);
}

If your sprite is not on the screen then set it to visible = false. If your sprite is not used anymore, deactivate it. Make sure to stop everything attached to it such as tweens or particle emitters:

this.myEmitter.stop();
this.myTween.stop();

// In case you are not gonna reuse it
this.myEmitter = null;

Same for anything which only runs or is displayed once. For example in my game I have an info message and some graphics to educate the player on how to use the weapon. Once the game starts I destroy all of those objects as there is no need to keep them.

Cache references

Instead of using Phaser3 to show the current scores (killed / saved / points multiplier, etc) I used plain HTML and CSS to render them.

<!-- Very simplified score header -->
<div>
<div id="score-killed">0</div>
<div id="score-saved">0</div>
<div id="score-points">0</div>
</div>

I don’t know if rendering in HTML is faster than rendering in a canvas, but personally I prefer to use HTML/CSS in a web game, instead of having to calculate styles and positions more or less manually.

To update the score elements we could:

Bad:

updateScore(key, value) {
document.getElementById(`score-${key}`).innerHTML = value;
}

Or …

Good:

create() {
this.scoreEleMap = {
"killed": document.getElementById("score-killed"),
"points": document.getElementById("score-points"),
// ...
}
}

updateScore(key, value) {
this.scoreEleMap[key].innerHTML = value;
}

onKill() {
// ...
this.updateScore("killed", newTotalKillsValue);
}

Finding instances or DOM elements, such as with document.getElementById or using Array.find/filter and equivalents is always expensive and therefore you should reference them if possible.

const textureCacheMap = {};

// A helper method to give me the file path for a texture
export function getTextureFile(textureKey) {
// First check the cache
if (textureCacheMap[textureKey]) return textureCacheMap[textureKey];

// Do the actual work
const result = CurrentTheme.Graphics.find((g) => g.key === textureKey);
textureCacheMap[textureKey] = result;

return result;
}

Game update loop

The game loop is what updates and re-renders the game and that happens a lot of times every second. You do not want to have any heavy operations going on in there and so I had a look at what my loop was doing.

In my case it was very simple and I was checking for collisions. My weapons, like the lightning or laser are graphics which I have to render dynamically and so I need to check if any enemies are running into the resulting polygon.

 checkCollisions() {
// Only run if game is ready and live
if (!this.isLive) return;

// Only check collision if the player is currently firing the weapon
if (this.currentWeapon.isOn()) {

// Only get the units and enemies which are "active"
const units = this.Groups.Unit.getMatching("active", true);
const enemies = this.Groups.Enemy.getMatching("active", true);

// => We only loop through the few active instances,
// instead of the whole group
units.forEach((unit) => {
if (this.currentWeapon.isPointInWeapon(unit)) {
unit.collidesWith(this.currentWeapon);
}
});

enemies.forEach((enemy) => {
if (this.currentWeapon.isPointInWeapon(enemy)) {
unit.collidesWith(this.currentWeapon);
}
});
}
}


// Scene update method
update() {
this.checkCollisions();
}

The isPointInWeapon() is the expensive function and we really wanna make sure to only call it if necessary. Besides only checking the active units, I could go further and check if the units are actually in reach. If the weapon is active at the top of the game canvas and the unit is at the bottom, then we can skip the check. We could also cache the active units and invalidate the cache as soon as a unit dies or spawns. Make sure not to “over optimize” — as then you will get the opposite effect.

Compress assets

One of the biggest culprits in slow loading times is large asset sizes. Compressing textures, spritesheets, and audio files is crucial — not just for games but for any type of application. I used tools like Squoosh for images and ffmpeg for audio compression.

# Compress audio using ffmpeg
ffmpeg -i original.mp3 -b:a 128k compressed.mp3

Texture Packer

Texture packer

There is also a way to pack all of your game sprites into one file and the tool for that is literally called “Texture Packer”.

From the phaser creator itself

There is a great article which explains on how to do that:

Packing the single sprite images into a package did not only reduce the initial download size (~300 kb), but lowered the number of requests too.

Less size and less requests

To compress all the other JS / CSS files when you release your game I believe goes without saying. For that I am btw using Vite.

Note: Texture packer has a free trial. After that you have to buy it to export the graphics. Check it out, sometimes they have a discount.

Lazy load assets

Make sure you only load the assets you need. In my game I have 3 background music tracks all of them with a file size of ~2 MB. I only play one track at a time, which means that if I would preload all 3 tracks, I would add 4 MB of unnecessary data, which delays the time to start the game. Therefore I only preload the first track I play and then — on demand — the next tracks. For that I have created a helper function:

import { playSound } from "./audio-utils.js"
const ASSET_SOUND_PATH = "/assets/sfx";

// E.g. in your Phaser Scene:
onGameOver() {
// Lazy load the gameOver sound
playSound(this, "gameOver");
}

// -------------- E.g. audio-utils.js

// Helper function which plays a sound or loads and plays it if necessary
export async playSound(scene, soundId, onPlayedCb, opts) {
// Check if we have already loaded this sound
const in_cache = scene.cache.audio.exists(soundId);

// If in cache, play it
if (in_cache) {
try {
let sound = scene.sound.get(soundId);
if (!sound) {
sound = scene.sound.add(soundId);
}
sound.once('complete', () => {
if (onPlayedCb) onPlayedCb();
});
sound.play(opts || {});
} catch(e) {
console.error(e);
}

// Load sound if it is not in le cache and then play it
} else {
await loadAudio(scene, soundId, `${ASSET_SOUND_PATH}/${soundId}.mp3`);
playSound(scene, soundId, onPlayedCb, opts);
}
}

// Helper function to load a sound file
async function loadAudio(scene, soundId, filePath) {
return new Promise((resolve) => {
const inCache = scene.cache.audio.exists(soundId);
if (inCache) {
resolve();
} else {
let audioLoader = scene.load.audio(soundId, [filePath]);
audioLoader.once('complete', async () => {
resolve();
});
audioLoader.start();
}
});
}

You could do the same for images. For example I have 10 images for the rank badges, but only need to render 3 of them in the game-over screen. So no need to load all of them, but only load them at the time I launch the game over scene.

// Lazy load images
await loadImage(scene, key, filePath) {
return Promise((resolve) => {
if (scene.textures.exists('myImageKey')) {
resolve();
} else {
scene.load.on('filecomplete', function(key, type, data) {
resolve();
});
// You may want to also listen to error events

scene.load.image(key, filePath);
scene.load.start();
}
}
}


// Usage in your PhaserScene
async spawnEndboss() {
const imgKey = "endboss1";
await loadImage(this, imgKey, "/assets/endboss1");
this.add.image(100, 200, imgKey);
// and so on ...
}

You can also be a bit smarter and preload images in case you know you are gonna need them soon as the image may need some time to load. E.g. in an endless game, if the player is almost done with level 4 and level 5 is the encounter with the end-boss, then you may already start loading the end-boss image. Otherwise split your game into Phaser Scenes and use the preload function to only load the assets you need.

So far so good, after compressing and lazy-loading assets my game loaded much faster and some minor bottlenecks had been removed by caching references and making sure everything what is not needed is destroyed or made inactive. Still my game didn’t feel that smooth on the Pixel 5 as it did on the Pixel 7. There was still room for improvement.

Size of the canvas

My game’s empress of the enemy

It is a no brainer: the bigger the canvas, the more pixels have to be (re-)drawn. Just to see what happens, I reduced the game canvas to a fixed 300px width and the performance increased. The only issue is that the canvas is now really small and in most cases you want it to be as big as the screen. Therefore you need to scale the canvas up, which brings the problem with it that the graphics look blurry.

CSS “scaling up”

It depends a bit on your game, so you may want to check out Phaser.Scale.

Canvas or WebGL — [fixed it]

Phaser3 provides support for both WebGL and Canvas renderers. WebGL is the faster option for rendering large numbers of objects due to hardware acceleration, but it can also consume more resources. I always set up the renderer dynamically using Phaser.AUTO and mostly it will automatically choose to use WebGL.

const config = {
parent: 'app',
type: Phaser.AUTO | Phaser.CANVAS | Phaser.WEBGL;
// ...
}

I was very surprised to see what happened when I switched to Phaser.CANVAS. The game fps increased by 30% on my older Pixel 5 device and the lagging was gone. In fact the game run so smooth suddenly that units and enemies moved much faster and the game became more difficult to play.

Left WebGL — Right Canvas Mode — 30%

I always thought that using the GPU, when available, should increase performance, but it turned out it consumes more overhead memory. I believe it really depends on your game, but if your game lags, then try to switch to CANVAS and see what happens.

One drawback with switching to canvas is that you can not use WebGL features such as postFX.addGlow.

NOTE: In case you want to use WebGL do not use Phaser.WEBGL but Phaser.AUTO instead. If WebGL is not supported by the device the game will fallback to Canvas.

I have not yet done it, but was thinking to have a PerformanceTestScene which would run the game automatically, save the fps count, reload the page and then tell Phaser explicitly to either use CANVAS or AUTO. All postFX.addGlow will need a try catch though and if you want to have the same gfx in Canvas, you will need to find a workaround.

What else?

  • Try to be up to date with the latest version of your game engine. Often there are performance improvements included
  • Test your game on as many devices as possible, especially those which have the biggest marketshare

The game

Game Story trailer

My game Invaders Must Die can be played on https://invaders-must-die.com — it should hopefully not lag on your device ;)

As those options were mostly coming from my own experience and use cases, I am very happy to receive feedback or more performance tweaks you do with Phaser or in general in game development.

--

--

François
François

Written by François

Serial founding engineer of gaming platforms, Game dev & founder of dibulo.com

No responses yet

Write a response