Phaser 4 reuses the scene across restart()
Short version: scene.restart() does not construct a new scene. Phaser looks the
existing instance up by key and re-runs its lifecycle methods on it, so a
TypeScript field initializer — private things: Thing[] = [] — runs once per
page load, while create() runs once per restart. Anything initialized at the
declaration and appended to in create() therefore grows on every restart, and
half of what it holds are objects the restart already destroyed.
Assign restart-scoped state inside init() or create(), not at the field
declaration.
The symptom: a crash on the second shift, never the first
Cook Line is played in shifts. Finish one, you get a summary screen, and you
start the next — which is a restart() of the main scene rather than a page
load.
The first shift was always fine. The second one crashed before the kitchen finished drawing — reported, from the far side of a play session, as “got to the sushi bar and it’s not loading”:
Uncaught TypeError: Cannot read properties of null (reading 'setSize')
at place (MainScene.ts:1649:15)
at MainScene.ts:1663:13
at Array.forEach (<anonymous>)
at MainScene.layoutModal (MainScene.ts:1660:27)
at MainScene.reflow (MainScene.ts:1017:14)
at MainScene.create (MainScene.ts:759:14)
Read the stack bottom-up and the shape of the bug is already visible:
create() runs the reflow, the reflow lays out the modal, the modal walks a
list, and something in that list is null. It also fired from
ScaleManager2.onResize — any resize after the bad create() threw the same
way, so the game wasn’t merely failing to start, it was failing continuously.
It surfaced during a long play session — chaining shift after shift to build up lifetime coins and exercise the economy — which is exactly the kind of session nobody runs while developing a feature, because during development you reload after every change.
Why reloading between shifts never reproduced it
The trigger isn’t “shift 2” in any game-state sense. It’s a second create()
in one page load — which is why it looked like a content bug for far longer
than it should have. Reload the page between shifts and you get a fresh scene
instance every time, the field initializer runs again, and everything works
forever.
So every attempt to reproduce it deliberately — start the game, play a shift, check the thing, reload, repeat — was structurally incapable of hitting it. The bug needed the one thing a developer never does, which is to keep playing.
It also looked content-specific for a while. Different shifts have different orders, so “it breaks on the second one” invites the theory that some particular order or modal is malformed. It isn’t. It’s shift 2 of any run, and shift 3, and every shift after.
Instance lifetime versus create lifetime
Two lifetimes are at work and they don’t line up.
ScenePlugin.restart() is a stop followed by a start:
restart: function (data)
{
var key = this.key;
this.manager.queueOp('stop', key);
this.manager.queueOp('start', key, data);
return this;
}
And SceneManager.start resolves the key against scenes that already
exist:
var scene = this.getScene(key);
No new. From there bootScene calls the lifecycle hooks on that same
object:
if (scene.init)
{
scene.init.call(scene, settings.data);
}
// ...
if (loader && scene.preload)
{
scene.preload.call(scene);
}
scene.init.call(scene, …), on an instance fetched from a list. The constructor
ran once, when the game was built. It will not run again for the life of the
page.
So the split is:
- Instance lifetime — the constructor body, and every field initializer, because TypeScript compiles those into the constructor. Runs once per page load.
- Create lifetime —
init(),preload(),create(). Runs once per start, and a restart is a start.
Field initializers look like they belong to the scene’s “setup.” They belong to the instance’s setup, which is a different and much longer-lived thing.
What the accumulation actually did
Our modal button pool is three objects:
const MODAL_MAX_BUTTONS = 3;
They were declared with an initializer and appended to in create():
private modalButtons: { /* … */ }[] = [];
for (let index = 0; index < MODAL_MAX_BUTTONS; index += 1) {
// …build rect, label, adMark…
this.modalButtons.push(button);
}
Shift 1: the array holds three live buttons. The shift ends, Phaser’s shutdown
destroys the scene’s display objects. Shift 2: create() pushes three more.
The array now holds six entries — indices 0–2 destroyed, indices 3–5 live — and it’s the destroyed ones that come first.
Then the layout pass walks it against the slots the layout engine produced, of which there are at most three:
this.modalButtons.forEach((button, index) => {
const slot = m.buttons[index];
if (!slot) return;
place(button.rect, button.label, slot);
});
Read what that guard does here. It was written for a dialog using fewer buttons
than the pool holds, and it’s correct for that. Under the accumulation bug it
does something else entirely: indices 0–2 have slots, so the destroyed
buttons get laid out, and indices 3–5 have no slot, so the live ones are
skipped. place() calls setPosition and setSize on a rect that Phaser has
already torn down, and throws.
That’s the Cannot read properties of null (reading 'setSize') at
MainScene.ts:1649 — the line is rect.setPosition(…).setSize(…), and after a
shutdown the chain no longer returns an object to call setSize on.
The guard is precisely backwards through no fault of its own. It selects by index, and the stale objects hold the low indices because they got there first. A defensive check ended up steering the code onto the broken half of the array.
Why TypeScript makes this worse rather than better
Field initializers are the tidy way to declare state in a TypeScript class. They
put the declaration and the default in one place, they let the type be inferred,
and they mean the field is never undefined. Every style guide points you at
them, and in almost every class they’re right.
In a Phaser scene they are the one place restart-scoped state must not go.
That’s an unusually nasty inversion, because the code that’s wrong here is the
code that looks most correct. private modalButtons: Button[] = [] reads as
obviously fine — better than declaring it without an initializer and assigning
it later, which looks like something you’d clean up. The tidier version is the
broken one, and there’s no diagnostic anywhere that says so. The types are
right. Strict mode is happy. It compiles, and it works on shift 1.
The reset block that already existed
create() already had an explicit reset block, and it already carried a comment
naming this exact hazard — which is the part I’d rather not write:
public create(): void {
// Phaser reuses this scene instance after the summary screen. Its previous
// display objects have been destroyed, so discard their stale references.
this.orderViews = [];
this.scoreFloaters = [];
this.scoreFloaterIndex = 0;
this.wasRushActive = false;
this.modalObjects = [];
// …
The mechanism was understood. It was written down. It was written down in the right file, at the right line, with five fields correctly listed.
modalButtons was added later — commit bf2f8c9, 2026-07-30, a broad change
that touched fourteen files and added three hundred lines to this scene — and
never joined the list. The fix, this.modalButtons = [], landed in a later
commit once the crash had been chased down.
The uncomfortable conclusion: a convention enforced by remembering it is a convention with a known failure rate. The block doesn’t fail loudly when something is missing from it, because a list of assignments has no idea what isn’t in it. Every field added to the class after that block was written is a fresh opportunity, and the person adding it is thinking about ads and economy, not about scene lifetimes.
What actually stops it recurring
Three options, in increasing order of how much they rely on you.
Assign in create(). What we did. Cheapest, and it keeps the existing
convention — but it’s the option that just failed, so on its own it’s a promise
to be more careful.
Assign in init(). Better, and I checked this rather than assuming it from
Phaser 3 habit: bootScene calls scene.init.call(scene, settings.data) on
every start, before preload and before create. So init() re-runs on a
restart exactly as create() does, and it runs earliest — meaning state is
clean before any loading or construction can observe it. If a scene has a
meaningful preload, that ordering matters. init() is also a stronger signal
of intent: a reader sees a method whose entire job is per-start state, rather
than assignments at the top of a function that also builds the whole scene.
Remove the opportunity. The structural fix: keep restart-scoped objects in
one container that create() clears wholesale, so adding a new pooled object
doesn’t require adding a new line to a reset list. This is the only option that
doesn’t degrade when someone is in a hurry, because there’s no per-field step to
forget.
We took the first. The third is the right answer and I’d take it if this scene grew much further.
What I took from this
The bug is a lifetime mismatch, and lifetime mismatches are invisible in a diff.
Nothing about private modalButtons: Button[] = [] on one line and
this.modalButtons.push(button) on another looks wrong, in review or in
isolation. You have to know that one of them runs on a different clock.
The generalisable question, for any framework that reuses object instances across a logical restart — Phaser scenes, pooled workers, long-lived request handlers: does this field’s lifetime match the lifetime of the thing that resets it? If a reset list exists, its existence is evidence that the answer is “not automatically,” and every field added afterwards inherits the problem without inheriting the reminder.
And a reproduction step worth keeping: if a bug needs a second pass through a lifecycle in one process, no amount of restarting the process will find it. The reload between attempts is the thing hiding it.
All of this is Phaser 4.1.0, pinned in the game’s package.json, with every
snippet from the v4.1.0 tag.
Four other things in this engine caught us the same way: the volume setter that
looks like a no-op, Scale.RESIZE and device pixels,
pointerup when you release off-canvas, and the canvas sized
mid-rotation. This one is the odd one out — the others were
behaviours documented in the source that we hadn’t read, where this was our own
convention quietly not scaling. A font rule we wrote down and got
wrong is the same species of mistake.