SvitsGames

Phaser 4 sizes the canvas mid-rotation

Short version: Phaser 4.1.0 calls refresh() synchronously inside its orientationchange handler, and on a phone that event fires while the viewport is still half-rotated. It sizes the canvas from the stale rect, then — on the last line of the same function — caches the correct one. After that its own recovery check compares the correct cache against the correct parent, sees no difference, and never refreshes again.

The canvas stays the wrong shape until reload. Ours sat at 524x785 inside a 785x524 window, board cut off, dead space down two edges. The fix is Scale.NONE plus a requestAnimationFrame loop that waits for the viewport to hold still and then pushes the size in through scale.resize() yourself.

The symptom: a canvas stuck in the old orientation

Cook Line is a kitchen-rush game — you draw strokes across a grid of tiles to plate orders against a timer. It’s meant to be fully fluid: no fixed base resolution, no letterboxing, the canvas matches its parent in both orientations.

Rotate the phone and it didn’t. The canvas kept the portrait shape it had before the flip while the window around it was correctly landscape. The grid ran off one edge and there was a band of background down the other two. Rotating back didn’t fix it. Rotating again didn’t fix it. Only a reload did.

The thing that makes this hard to chase is that it isn’t reliable. Some flips were fine. In a five-flip test run, three came out correct on their own and two didn’t — which is exactly the pattern that gets a bug written off as “seems okay now” the first three times you look at it.

The trace: a viewport that was neither shape

Instrumenting the events rather than the canvas is what broke it open. Portrait to landscape, timestamps relative to page load:

+10432ms  window:orientationchange    vp=785x785   canvas=524x785
+10433ms  canvas:attr-changed         vp=785x524   canvas=524x785
+10433ms  screen.orientation:change   vp=785x524   canvas=524x785
+10436ms  window:resize               vp=785x524   canvas=524x785

Look at the first line. vp=785x785 is not a shape the device is ever in. It’s a transient in which the width has already rotated and the height hasn’t — the browser updating the two dimensions on different ticks, and an event firing in the gap between them.

One millisecond later the viewport is the correct 785x524, and it stays there. The canvas is 524x785 on every line, including the one where it was rewritten.

This was captured on an emulated iPhone in Chrome DevTools, not on physical hardware. The mechanism below is read from the Phaser source and doesn’t depend on the emulation, but the exact millisecond gaps might.

Why it never recovers

This is the part worth the post. Measuring a transient is bad luck. Not recovering from it is a structure, and the structure is four links long.

One. The orientationchange listener calls refresh() synchronously, inside the event (startListeners):

listeners.orientationChange = function ()
{
    _this.updateBounds();

    _this._checkOrientation = true;
    _this.dirty = true;

    _this.refresh();
};

No delay, no rAF, no waiting for the rotation to finish. Whatever the viewport happens to be at that instant is what gets used.

Two. refresh() calls updateScale(), and under Scale.RESIZE that reads the cached parent size:

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 at this moment still holds the pre-rotation portrait rect. So the canvas is sized to 524x785 — the old orientation — and that is the canvas:attr-changed line in the trace.

Three, and this is the one that closes the trap. The last thing updateScale does before returning is refresh that cache:

//  Update the parentSize in case the canvas / style change modified it
this.getParentBounds();

By the time this line runs, a millisecond has passed and the rotation has completed. So parentSize is updated to the correct 785x524, immediately after being used at its stale value to size the canvas. The cache is now right and the canvas is now wrong, which is precisely the wrong way round.

That line is defensive code. Its comment says so — it exists to catch the case where changing the canvas style changed the parent’s layout. It is doing a sensible job and it is what makes the bug permanent.

Four. The recovery path is gated on that cache. windowResize — the handler for the correct-shaped resize at +10436ms — only sets a flag:

listeners.windowResize = function ()
{
    _this.updateBounds();

    _this.dirty = true;
};

It never calls refresh() itself. Acting on dirty is step()’s job, on the next frame:

if (this.dirty || this._lastCheck > this.resizeInterval)
{
    //  Returns true if the parent bounds have changed size
    if (this.getParentBounds())
    {
        this.refresh();
    }

    this.dirty = false;
    this._lastCheck = 0;
}

And getParentBounds() returns true only when the parent rect differs from parentSize. Parent: 785x524. Cache: 785x524, courtesy of step three. They agree. It returns false. refresh() is never reached.

Note that step() also polls every resizeInterval — 500ms by default — so this isn’t a missed-event problem that a later tick would sweep up. Every subsequent poll runs the same comparison and reaches the same conclusion, forever.

There is one escape hatch and it doesn’t fire either. getParentBounds has a second branch: if the parent size matched but the canvas position moved, it returns true anyway. In a rotation the canvas sits at the top-left corner in both orientations, so its x and y don’t change, and that branch is as silent as the first.

The canvas is the only thing in the system that’s wrong, and it’s the one thing nobody is comparing against.

The fix: measure it yourself, once it stops moving

Two changes, and they’re separable.

The first is Scale.NONE, which we needed anyway — Scale.RESIZE can’t render at device resolution, because it hardcodes gameSize = parentSize in CSS pixels on every refresh. Under NONE, updateScale takes a different branch that doesn’t touch gameSize, baseSize or the canvas dimensions at all. Nothing recalculates the size behind you, which also means nothing re-applies a bad measurement.

The second is to do the measuring, and to wait for the viewport to hold still first. A requestAnimationFrame loop that stops after three consecutive frames with an unchanged parent rect:

const tick = (): void => {
  const { width, height } = measure();
  apply(width, height);

  if (width === lastWidth && height === lastHeight) {
    stableFrames += 1;
  } else {
    stableFrames = 0;
    lastWidth = width;
    lastHeight = height;
  }

  if (stableFrames >= SETTLE_FRAMES || performance.now() >= deadline) {
    frame = 0;
    return;
  }
  frame = requestAnimationFrame(tick);
};

SETTLE_FRAMES is 3 and the deadline is 1000ms after the last signal. The ceiling matters on iOS, which animates the URL bar during a rotate — the viewport genuinely keeps moving for several hundred milliseconds, so a loop that gave up after 100ms would settle on another intermediate value and reintroduce the bug in a new costume.

The result goes in through scale.resize(), which writes canvas.width and canvas.height directly:

this.canvas.width = this.baseSize.width;
this.canvas.height = this.baseSize.height;

To be precise about why that works, because the tempting summary is wrong: resize() does eventually reach getParentBounds(), since it ends with return this.refresh(...) and refresh runs updateScale. The point is that it isn’t gated by it. getParentBounds() decides whether step() calls refresh(); it has no say over a resize() you called yourself. The stale cache can’t suppress a write that never asked its permission.

And because resize() routes through refresh(), it emits the RESIZE event at the end — so scenes reflow through exactly the path they already used. You’ve taken over the measuring, not the reflowing.

Why it applies on every frame, not just at the end

The obvious shape for a settle loop is to wait for quiet and then apply once. This applies on every frame of the settle window instead, which sounds wasteful and isn’t.

The reason is desktop. A window drag is a continuous stream of resize events, and “apply only when it stops” means the canvas visibly lags the window edge for the whole drag and snaps at the end. Applying per frame keeps it glued.

The cost is bounded because apply early-outs when nothing changed:

if (size.width === targetWidth && size.height === targetHeight
    && renderScale() === scale) return;

So a frame in which the viewport didn’t move costs one getBoundingClientRect(). That comparison includes the render scale, not just the dimensions — dragging a window from a retina monitor to a non-retina one changes devicePixelRatio while the CSS rect stays identical, and a check on dimensions alone would skip it.

Two events that never arrive: visualViewport resize and startup

The settle loop is driven by four signals — window resize, window orientationchange, screen.orientation change, and visualViewport resize. The last one is there because mobile URL-bar show/hide resizes the visual viewport without reliably firing a window resize, and on iOS Safari and Chrome Android that happens during ordinary play rather than only at rotation. It’s the same class of bug as the rotation one — the layout moves and the canvas isn’t told — arriving through a different event.

Startup is the other gap, and it’s the one with no event at all. Nothing fires a resize on a page that simply loaded, so the loop has to be kicked once unprompted after new Phaser.Game(...). Without it, Phaser’s own boot sizing stands: under Scale.NONE, boot calls this.resize(this.width, this.height) from the config, in CSS pixels. The first screen a player sees is then the only one never drawn at device resolution — until they happen to rotate or resize, which on a phone might be never.

Both are one line each. Both are the kind of gap you only find by asking “what if no event ever arrives?”, which is not a question the event-driven version of the code invites you to ask.

How fast the settle loop corrects the canvas

Across five rotations on the emulated iPhone, the watcher brought the Phaser canvas back into agreement with the viewport every time, at 1ms, 23ms, 1ms, 15ms and 1ms after the orientation event.

The spread is the interesting bit. The 1ms cases are flips where Phaser happened to measure cleanly and there was nothing to fix — the watcher confirmed the size and early-returned. The 15ms and 23ms cases are the ones where it had actually gone wrong and the settle loop stepped in.

That ratio is the real hazard. Three flips in five look fine without any of this, which is easily enough to convince you on a manual test that the bug isn’t there.

What I took from this

A cache that updates on the way out of a function is a cache that can be correct and useless at the same time. updateScale refreshes parentSize after using it, which means the value that produced the output is never the value left behind to be compared against later. Any recovery check built on that comparison is asking whether the input was right, when the question is whether the output was.

The generalisable version: if a system’s self-correction works by comparing its inputs, it cannot notice a bad output. Phaser knew the parent’s size perfectly at every moment after the flip. It was never wrong about the viewport. It just had no reason left to look at the canvas.

Everything here is Phaser 4.1.0 specifically — pinned exactly in the game’s package.json, and every snippet is from the v4.1.0 tag, so you can check me. Re-test before copying any of it onto a newer version.

This is the direct sequel to Scale.RESIZE can’t use device pixels. That one fixes the resolution; this one fixes what happens when the phone turns. You need both, and Scale.NONE is the shared prerequisite.

There’s a third in the same vein, on input rather than sizing: Phaser drops pointerup if you release off-canvas. Same version, same species of surprise — a documented behaviour sitting in the source that nobody reads until it costs them a day.