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.