Phaser's Scale.RESIZE can't use device pixels
Short version: in Phaser 4.1.0, Scale.RESIZE sets gameSize from the parent
element’s size in CSS pixels on every refresh, so any scale.resize() you
call is overwritten and the backing store can never track devicePixelRatio. On
a phone at devicePixelRatio: 3 that’s a 390x844 canvas bilinearly smeared up
to 1170x2532. Use Scale.NONE and own the size yourself.
The symptom: a pixel font that looked wrong on a phone
Cook Line is a kitchen-rush game — you draw strokes across a grid of tiles to plate orders against a timer. It’s drawn in a pixel font, and pixel fonts are unforgiving: every glyph is a deliberate arrangement of hard-edged squares, and anything that softens those edges is instantly visible.
On a laptop it looked exactly right. On a phone it looked like someone had run a very slight blur over the whole game. Not broken. Just soft, in a way that made the text look cheap and the tiles look like JPEGs.
The obvious first suspicion was the font. It’s the thing that looks worst, so it’s the thing you go and stare at.
The wrong diagnosis: “the font is bad”
It isn’t the font. It’s never the font.
The canvas was being drawn at CSS resolution and then stretched to fill a screen with three device pixels per CSS pixel. Every glyph and every tile was being resampled by the browser on its way to the screen. The font was fine; it was just being enlarged 3x with bilinear filtering before anyone saw it.
This is worth naming precisely, because “blurry on mobile” sends you looking at
texture filtering, at pixelArt, at the atlas, at font hinting — at everything
that happens inside your rendering — when the resampling is happening after
you’re done, on a canvas that never had the pixels to begin with.
The check that settles it: compare canvas.width against
canvas.getBoundingClientRect().width. If they’re the same number on a device
where devicePixelRatio is 3, nothing you do to the font will help.
Why Scale.RESIZE can’t fix it
Scale.RESIZE is the mode you reach for when you want a canvas that fills its
parent and reflows, which is exactly what a responsive game wants. It does that
job well. It just also makes device-resolution rendering impossible, and it
does so quietly.
Here’s the relevant branch of ScaleManager.updateScale in
4.1.0:
else if (this.scaleMode === CONST.SCALE_MODE.RESIZE)
{
// Resize to match parent
// This will constrain using min/max
this.displaySize.setSize(this.parentSize.width, this.parentSize.height);
this.gameSize.setSize(this.displaySize.width, this.displaySize.height);
this.baseSize.setSize(this.displaySize.width, this.displaySize.height);
// ...
}
parentSize is the parent element’s bounding rect — CSS pixels, because that’s
what getBoundingClientRect returns. So gameSize is assigned from it, and
gameSize is what the canvas backing store is sized from.
That assignment runs on every refresh. Which means calling
scale.resize(width * 3, height * 3) yourself does work — for as long as it
takes the next refresh to arrive, at which point updateScale sets gameSize
straight back to the parent’s CSS size. The write isn’t rejected. It’s
overwritten, a few milliseconds later, by a line that was always going to run.
One honest caveat, because that comment is in the source and I nearly left it
out. displaySize.setSize clamps through Size.getNewWidth, which
applies minWidth/maxWidth — so setting scale.min above the parent’s size
really does push gameSize past the CSS box. It’s no use here: it’s a fixed
number that knows nothing about devicePixelRatio, and forcing a floor larger
than the parent breaks the fluid reflow that made you pick RESIZE in the first
place. But the accurate claim is the narrow one: there is no configuration of
RESIZE in which the backing store tracks the device pixel ratio.
The fix: Scale.NONE, and size it yourself
Scale.NONE means the size is only ever what you set. Nothing recalculates it
behind you.
new Phaser.Game({
type: Phaser.AUTO,
parent: 'app',
scale: {
mode: Phaser.Scale.NONE,
parent: 'app',
width: '100%',
height: '100%',
},
scene: [MainScene /* ... */],
});
Then something has to do the job RESIZE was doing. In our case that’s a small
module that measures the parent and pushes the result in:
const scale = Math.min(window.devicePixelRatio || 1, MAX_RENDER_SCALE);
setRenderScale(scale);
game.scale.resize(Math.round(width * scale), Math.round(height * scale));
ScaleManager.resize writes canvas.width and canvas.height
directly, so the backing store really is cssSize * scale. Then two lines of
CSS display it back down:
canvas {
display: block;
width: 100%;
height: 100%;
}
Without those the canvas lays out at its attribute size — 1170 CSS pixels wide — and overflows the viewport. With them, one game unit lands on exactly one physical pixel and the browser never resamples anything.
Nothing about fluid layout changes. The same resize events still drive it, and
scenes still reflow off the RESIZE event that refresh emits. You’ve taken
over the sizing, not the reflowing.
Why the cap is 2 and not devicePixelRatio
Cost is quadratic. A dpr-3 phone has nine times the pixels of a dpr-1 one, and every one of them is fill rate you’re spending to hit 60fps on mid-tier hardware.
We cap at 2:
const MAX_RENDER_SCALE = 2;
At 2 the worst remaining upscale on a dpr-3 screen is 1.5x, against 3x with no cap at all — and 1.5x is far less visible, because the softening scales with the ratio.
2 is a chosen default, not a measured threshold — I picked it from the arithmetic and it looked right on the phone I had. It’s one constant, so raising it is a one-line experiment against your own frame times.
One detail that took a monitor-drag to notice: the scale can change without the
CSS size moving at all. Drag a browser window from a retina display to a
non-retina one and the parent rect is identical while devicePixelRatio halves.
So the early-out has to compare the scale too, not just the dimensions:
if (size.width === targetWidth && size.height === targetHeight
&& renderScale() === scale) return;
The trap that follows: two things stay in CSS pixels
This is the part I’d want to read before starting, because it’s the bug that comes after the fix and it doesn’t look like a bug.
Once the game renders in device pixels, one game unit is one physical pixel. Everything you size in game units scales with the device automatically. But a couple of numbers arrive from outside the game already denominated in CSS pixels, and those must be multiplied by the render scale by hand.
There are exactly two in Cook Line:
- Safe-area insets. The browser reports
env(safe-area-inset-*)in CSS px. - The minimum touch target. 44px is an accessibility figure specified in CSS px, because that’s the unit that corresponds to an actual fingertip.
const MIN_TOUCH_TARGET = 44; // CSS px
const minTouch = MIN_TOUCH_TARGET * renderScale();
const iconSide = Math.max(minTouch, Math.round(unit * 0.09));
Miss the multiplication and a 44px button becomes about 15px of real glass on a dpr-3 phone — while still looking completely correct in a screenshot, because everything around it shrank by the same factor. The proportions are right. The thumb is the only thing that disagrees, and thumbs don’t file bug reports; they just miss, and the player concludes the game is finicky.
That’s why the render scale lives in its own tiny module with no Phaser import at all, rather than as a field on the scale-sync code that sets it. The layout code needs the value and is exercised under plain node in our layout checks; keeping it Phaser-free means neither module has to know about the other, and it means the multiplication is a named function call you can grep for.
What I took from this
The two modes aren’t “fluid vs fixed,” which is how the names read. They’re “the engine owns the size” vs “you own the size,” and device-resolution rendering is only available in the second one.
The wider version: when something looks soft, check the resolution it was drawn at before you check the thing that looks soft. I spent real time on a font that was innocent, because the font is what the eye lands on, and the pixel ratio is a number nobody looks at until they have a reason to.
That’s the second time this engine has caught me the same way. The last one was Phaser’s volume setter, which looks like a no-op if you assign to it and read it straight back — same shape of mistake, where the evidence in front of you supports two explanations and the obvious one is wrong. Both times the fix took minutes and finding it took a day.
Everything here is Phaser 4.1.0 specifically — pinned exactly in our
package.json, and every snippet above is from the v4.1.0 tag, so you
can check me. Re-test before copying any of it onto a newer version.
There’s a second half to this story: Scale.NONE fixes the resolution, and it
does not fix what happens when you rotate the phone. Phaser measures the
viewport mid-rotation and then can’t recover, because it caches
the correct size immediately after sizing the canvas from the wrong one.