SvitsGames

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 lifetimeinit(), 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.