SvitsGames

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.