
Make Your Browser Game Playable on Mobile: Touch Controls, Virtual Joysticks, and Responsive Scaling in Phaser 3
You built a small Phaser 3 game that works great on your desktop. Then you opened it on a phone and everything fell apart: the canvas is the wrong size, the page scrolls when you drag, and there’s no way to move your character. The fix is a well-defined combination: Phaser.Scale.FIT for responsive scaling, pointer events for unified input, a radius-clamped virtual joystick, circular action-button hit areas, and two small HTML/CSS fixes (touch-action: none on the canvas plus the mobile viewport meta tag). This tutorial walks through each piece, one at a time.
This guide is written for a beginner game developer who already has a basic Phaser 3 browser game running and understands enough JavaScript to follow along. If your goal is a playable mobile experience — think “Phaser 3 mobile touch controls virtual joystick responsive scaling tutorial” — this is the recipe.
Why desktop browser games break on mobile
Desktop-click assumptions vs touch reality
Most beginner Phaser games assume a mouse: one pointer, precise clicks, no scrolling, no pinch-to-zoom. Mobile browsers break all of those assumptions. Touch screens can generate multiple simultaneous pointers, browsers intercept drags for scrolling and double-taps for zooming, and the page layout is rarely sized to fit a game canvas. The Scale Manager and the Pointer Events standard exist precisely to handle these situations, and Phaser wraps both.
The three fixes this guide will apply
- Scaling — make the canvas fit the screen with the Scale Manager.
- Input — use Phaser’s unified pointer events, which cover mouse, touch, and pen through one API.
- Page setup — stop the mobile browser from stealing gestures with the viewport meta tag and
touch-action: none.
Responsive scaling with Phaser.Scale.FIT
The scale config object
Phaser’s Scale Manager is configured directly in your game config object. Here is a minimal setup for a fixed 800×600 game world that scales to fit any screen:
const config = {
type: Phaser.AUTO,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
width: 800,
height: 600
},
scene: [BootScene, GameScene]
};
mode: Phaser.Scale.FIT tells Phaser to scale the canvas so the whole 800×600 game fits inside the parent container, preserving aspect ratio. autoCenter: Phaser.Scale.CENTER_BOTH centers the canvas horizontally and vertically wherever there’s leftover space. With width: 800 and height: 600, all your game-world coordinates stay the same no matter what device the game runs on — a huge win for beginners, because you never rewrite positions per device.
FIT vs other scale modes in plain language
The Scale Manager offers several modes, each with a different personality:
NONE— no scaling at all; the canvas stays at its configured size.FIT— shrink or grow the canvas to fit inside the parent while keeping aspect ratio (the usual choice for mobile).ENVELOP— scale so the canvas covers the parent entirely, possibly cropping edges.WIDTH_CONTROLS_HEIGHT/HEIGHT_CONTROLS_WIDTH— one dimension drives the other automatically.EXPAND— the canvas expands to fill the parent, and your game size grows with it (good for UI-heavy games that reposition things onresize).RESIZE— the canvas is resized to exactly match the parent, and it’s up to you to react.
For a first mobile-ready game, FIT with CENTER_BOTH is the least-work option. The Scale Manager docs cover each mode in detail.
Two HTML/CSS lines that keep mobile browsers from stealing the game
The mobile viewport meta tag
Without a viewport meta tag, mobile browsers render your page at a simulated desktop width and then zoom out — making your game tiny. Add this line inside the <head> of your HTML file:
<meta name="viewport" content="width=device-width, initial-scale=1">
This tells the browser to use the device’s actual width at a 1:1 scale, which is exactly what a game canvas wants.
Applying touch-action: none to the canvas
By default, a touch drag on a web page can scroll the page, and a pinch can zoom. That’s catastrophic for a joystick. The CSS touch-action property tells the browser which touch gestures it may handle. Setting it to none on the canvas means the browser hands every touch to your game instead:
canvas {
touch-action: none;
}
With this rule, dragging on the canvas no longer scrolls or pinches — the gesture belongs to Phaser. This one line fixes the single most common “why does my joystick drag the whole page?” complaint.
Pointer events: one input path for mouse, touch, and pen
Adding multiple pointers
Phaser’s input system is built on the browser’s Pointer Events, which unify mouse, touch, and pen into a single event model (MDN Pointer Events). Phaser gives you the same event names you’ll use everywhere: pointerdown, pointermove, and pointerup.
By default Phaser supports 1 mouse pointer plus 1 touch pointer, and you can add extra pointers up to a total of 10 — which matters when a player moves with one thumb and taps a button with the other. Add two more with this.input.addPointer(2). It’s also good manners to stop the browser’s right-click context menu from popping up mid-game with this.input.mouse.disableContextMenu():
this.input.addPointer(2);
this.input.mouse.disableContextMenu();
this.input.on('pointerdown', (pointer) => {
// pointer.id, pointer.x, pointer.y, pointer.isDown
});
this.input.on('pointermove', (pointer) => {
// update active control
});
this.input.on('pointerup', (pointer) => {
// release active control
});
The pointer object fields that matter for touch controls
Each pointer Phaser passes to these handlers carries the fields you need: pointer.id distinguishes one finger from another (essential for multi-touch), pointer.x and pointer.y give the position in game coordinates, and pointer.isDown tells you whether that finger is currently pressed. Use pointer.id to bind a specific finger to the joystick so a second finger tapping a button doesn’t move your thumb.
Build a virtual joystick with two circles
Setting the base and tracking the active pointer
The classic virtual joystick is just two circles: a larger base circle (drawn where the finger first touches) and a smaller thumb circle that follows the finger. On pointerdown, place the base at the touch point and remember which pointer is controlling the stick. On pointermove from that same pointer, update the thumb. On pointerup, hide both and stop the player.
Clamping the thumb and calculating angle/magnitude
The thumb should never leave the base circle, so we clamp its distance to a radius. From the raw offset we compute an angle with Math.atan2(dy, dx) and a magnitude clamped between 0 and 1 (rawDist / radius, capped at 1) — that magnitude works as an analog “how far are you pushing” value:
const dx = pointer.x - base.x;
const dy = pointer.y - base.y;
const rawDist = Math.hypot(dx, dy);
const dist = Math.min(rawDist, radius);
const angle = Math.atan2(dy, dx);
const magnitude = rawDist > 0 ? Math.min(1, rawDist / radius) : 0;
thumb.setPosition(
base.x + Math.cos(angle) * dist,
base.y + Math.sin(angle) * dist
);
sprite.setVelocity(
Math.cos(angle) * speed * magnitude,
Math.sin(angle) * speed * magnitude
);
Read it step by step: (dx, dy) is the finger’s offset from the base, so the movement direction is that offset normalized — effectively the vector (dx, dy)/radius scaled by your speed. Math.min(rawDist, radius) keeps the thumb inside the base, and Math.atan2 converts the offset into an angle that trig functions turn back into x/y velocity components.
Applying the vector to player movement
If your player sprite has an Arcade Physics body, sprite.setVelocity(...) is all you need — the snippet above already does it. Light touches produce small magnitude and slow movement; a full drag pushes magnitude to 1 and full speed. That analog feel is what makes a virtual joystick feel better than eight arrow buttons.
Add on-screen action buttons with circular hit areas
Why circle hit areas feel better on touch screens
Fingers are imprecise, and rectangular hit areas punish slightly-off taps at the corners. A circular hit area matches the round button art and gives a generous, forgiving tap zone. Phaser lets you override the default rectangular hit area with setInteractive plus a geometry object and a contains-check function:
const jumpButton = this.add.circle(720, 480, 44, 0x8844ff);
jumpButton.setInteractive(
new Phaser.Geom.Circle(44, 44, 44),
Phaser.Geom.Circle.Contains
);
jumpButton.on('pointerdown', () => {
player.jump();
});
The circle is defined at local coordinates (r, r) with radius r — here 44 pixels — so it exactly covers the drawn circle graphic. Phaser.Geom.Circle.Contains is Phaser’s built-in test that decides whether a pointer lands inside that circle. See the Geometry Circle docs for the full API.
Wiring pointerdown and pointerup to actions
The pointerdown event on the button is your “press.” If your action is held (like a charge jump), also listen for pointerup on the same object to release it. Because Phaser tracks pointers separately, a thumb on the joystick won’t interfere with a tap on the button.
Handle orientation changes and resize events
resize layout adjustments
When the browser window changes size (rotation, address bar appearing), Phaser emits a resize event on the Scale Manager. Use it to reposition your UI:
this.scale.on('resize', (gameSize) => {
ui.layout(gameSize.width, gameSize.height);
});
With FIT mode your game world stays 800×600, so resize is mostly for HUD elements anchored to screen corners — but if you use EXPAND or RESIZE mode, this event is where you rebuild your whole layout.
orientationchange and responding to portrait/landscape
Phaser also emits an orientationchange event, and exposes this.scale.isPortrait and this.scale.isLandscape so you can branch your setup:
this.scale.on('orientationchange', (orientation) => {
if (orientation === Phaser.Scale.PORTRAIT) {
// show a rotate prompt or adjust UI
}
});
if (this.scale.isLandscape) {
// landscape-specific setup
}
this.scale.setGameSize(800, 600);
Phaser.Scale.PORTRAIT and Phaser.Scale.LANDSCAPE are the two orientation values the event passes in. If your game is designed for landscape, a friendly “please rotate your device” overlay in portrait is a beginner-friendly touch. this.scale.setGameSize(w, h) changes the game dimensions at runtime if you ever need to.
Mobile audio unlock and honest device-testing caveats
Unlocking audio after the first pointer event
Mobile browsers — iOS in particular — block audio from playing until the user performs a first interaction with the page (a tap counts). The standard pattern is to call Phaser’s audio unlock on the very first pointer event:
this.input.once('pointerdown', () => {
this.sound.unlock();
});
once ensures the handler runs exactly one time. After that first tap, background music and sound effects can play normally.
Verified desk research vs physical device testing
A note on verification: this guide is desk research, checked against the official Phaser Scale Manager documentation and MDN web docs on 2026-09-30. It was not tested on a physical phone. The APIs and patterns here come straight from official docs, but real devices vary — test on your own hardware before shipping.
FAQ: mobile controls troubleshooting
Why is the page still scrolling or zooming?
Check two things: the viewport meta tag is present in your <head>, and your CSS includes canvas { touch-action: none; }. If the page still scrolls, make sure the CSS actually loads after the canvas exists — or apply touch-action: none to the canvas’s parent container too.
Why does the joystick jump or stick?
Usually the joystick isn’t bound to a specific pointer.id. If any pointermove moves the thumb, a second finger will yank it across the screen. Store the controlling pointer’s id on pointerdown, only update on moves from that id, and clear it on pointerup.
Why does sound not play until the first tap?
That’s the mobile audio policy described above — iOS and other mobile browsers require a user gesture before audio can start. Add the this.input.once('pointerdown', () => this.sound.unlock()) snippet from the audio section and it resolves itself on first touch.
Why does my button sometimes miss taps?
Your hit area is probably too small or rectangular. Use a circular hit area (new Phaser.Geom.Circle(r, r, r) with Phaser.Geom.Circle.Contains) and make the radius generous — 40+ pixels feels comfortable for thumbs.
What you learned
You now have the full mobile-ready Phaser 3 setup: Phaser.Scale.FIT with autoCenter: Phaser.Scale.CENTER_BOTH keeps a consistent game world that scales to any screen; the viewport meta tag and touch-action: none stop the browser from stealing drags; unified pointer events (pointerdown, pointermove, pointerup) handle mouse, touch, and pen through one code path, with addPointer enabling multi-touch; the joystick uses Math.atan2 and a radius-clamped magnitude for analog movement via setVelocity; action buttons use circular hit areas for forgiving taps; scale resize and orientationchange events keep your UI correct in both orientations; and one pointerdown unlocks audio on mobile. Combine those pieces and your desktop game becomes genuinely playable on a phone.