I started this project because every time I tried a web‑based 2D game on my phone, the canvas would either be cut off, the controls were unresponsive, or the frame rate dropped dramatically. The core problem was simple: mobile browsers treat the viewport very differently from desktop, and Phaser’s default settings don’t account for the quirks of touch input and limited GPU bandwidth. I decided to build a tiny prototype from scratch, document every dead‑end, and end up with a game that feels as responsive as a native app.
Canvas Scaling and Mobile Viewports
The first thing I noticed was that the game stretched or added black bars on devices with unusual aspect ratios. Phaser ships with a flexible scaling manager, but I had to configure it explicitly. I settled on Phaser.Scale.FIT because it preserves the aspect ratio while filling as much of the screen as possible. The key lines look like this:
- Set the scale mode to
FITso the canvas scales proportionally. - Enable
autoCenterto keep the game centered horizontally and vertically. - Force the orientation to
PORTRAITfor this particular title, which simplifies touch layout.
I also added a listener for the resize event. Mobile browsers sometimes fire a resize when the address bar hides, and without handling it the canvas would momentarily shrink. The listener forces Phaser to recalculate the scale and keeps the game full‑screen.
Touch Input and Controls
My initial attempt was to reuse the keyboard listeners I had written for the desktop version. Unsurprisingly, nothing happened on a touch screen. I switched to Phaser’s built‑in pointer system, which abstracts mouse and touch events under a single API. The approach I took was:
- Create invisible “button” zones using
Phaser.GameObjects.Zonefor left, right, and jump actions. - Attach
pointerdownandpointeruphandlers to each zone to set a boolean flag in the scene’s state. - In the update loop, read those flags to drive the player’s velocity.
The biggest surprise was the “ghost tap” bug: when a finger slid from one zone to another without lifting, Phaser would fire a pointerup on the first zone and a pointerdown on the second, causing a brief pause in movement. I solved it by checking the pointer.move event and only toggling flags when the pointer actually left the screen bounds of a zone.
Keeping FPS High on Mobile
Even after the canvas filled the screen and the controls worked, the frame rate still dipped to 20‑30 fps on mid‑range Android phones. I profiled the game with Chrome’s performance tools and identified two main culprits: overdraw from off‑screen sprites and texture swaps caused by loading many separate image files.
- Sprite culling: I enabled
cullon the physics world, which tells Phaser to skip rendering objects outside the camera view. Additionally, I manually removed enemies that moved far off‑screen instead of letting them linger. - Texture atlases: I combined all my UI icons and platform tiles into a single atlas using TexturePacker. This reduced the number of texture binds per frame from dozens to a handful.
- Cache the result of expensive calculations: The player’s animation frame index was recomputed each tick based on velocity. Caching the result for a few frames cut down on JavaScript work without affecting visual fidelity.
With these tweaks the game consistently hit 60 fps on a Pixel 4a and stayed above 45 fps on older devices.
Code Walkthrough
Below is the minimal Phaser configuration I used for the prototype. It includes the scaling settings, a simple scene that loads an atlas, creates a player sprite, and sets up the touch zones.
import Phaser from 'phaser';
const config = {
type: Phaser.AUTO,
parent: 'game-container',
width: 800,
height: 600,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
orientation: Phaser.Scale.PORTRAIT,
},
physics: {
default: 'arcade',
arcade: {
gravity: { y: 500 },
debug: false,
},
},
scene: {
preload,
create,
update,
},
};
const game = new Phaser.Game(config);
function preload() {
this.load.atlas('sprites', 'assets/sprites.png', 'assets/sprites.json');
}
function create() {
// Player
this.player = this.physics.add.sprite(400, 300, 'sprites', 'player_idle');
// Touch zones
const leftZone = this.add.zone(0, 0, this.scale.width / 3, this.scale.height)
.setOrigin(0)
.setInteractive();
const rightZone = this.add.zone(this.scale.width * 2 / 3, 0, this.scale.width / 3, this.scale.height)
.setOrigin(0)
.setInteractive();
const jumpZone = this.add.zone(this.scale.width / 3, 0, this.scale.width / 3, this.scale.height / 2)
.setOrigin(0)
.setInteractive();
// State flags
this.inputState = { left: false, right: false, jump: false };
leftZone.on('pointerdown', () => (this.inputState.left = true));
leftZone.on('pointerup', () => (this.inputState.left = false));
rightZone.on('pointerdown', () => (this.inputState.right = true));
rightZone.on('pointerup', () => (this.inputState.right = false));
jumpZone.on('pointerdown', () => (this.inputState.jump = true));
jumpZone.on('pointerup', () => (this.inputState.jump = false));
}
function update() {
const speed = 200;
if (this.inputState.left) {
this.player.setVelocityX(-speed);
this.player.anims.play('player_walk', true);
} else if (this.inputState.right) {
this.player.setVelocityX(speed);
this.player.anims.play('player_walk', true);
} else {
this.player.setVelocityX(0);
this.player.anims.play('player_idle', true);
}
if (this.inputState.jump && this.player.body.blocked.down) {
this.player.setVelocityY(-350);
}
}
The code is deliberately small; the real project adds platforms, enemies, and a simple scoring system, but the scaffolding above demonstrates the three pillars I focused on: scaling, touch, and performance.
Wrap‑up
Getting a Phaser.js game to feel native on a phone took more iteration than I expected. The scaling manager saved me from writing custom CSS hacks, but I still needed to listen for browser‑specific resize events. Touch input forced me to rethink the control model entirely, and performance required a mix of culling, atlasing, and a bit of manual cache work.
Looking ahead, I plan to persist player progress using IndexedDB for offline play and eventually sync high scores to a Node.js backend via WebSockets. The next challenge will be handling real‑time multiplayer without sacrificing the frame rate we just fought hard to achieve.