phaser 3 + vite HMR — canvas getting nuked on every module reload, here's what fixed it

302 views 6 replies

Working on a browser game with Phaser 3 and Vite and kept running into the same issue: edit a file, Vite hot-reloads the module, and the canvas either goes blank or the game enters some half-baked state where new code is running but old objects are still alive. Occasionally I'd get two canvas elements stacked in the DOM. Not a great iteration loop.

The root problem is that Phaser creates and owns a canvas element, and when modules hot-reload, the old Phaser instance doesn't automatically clean up. Orphaned instances, event listeners pointing at dead objects, the works.

Fix: explicit teardown in the HMR accept handler using import.meta.hot:

// main.js
import Phaser from 'phaser';
import { GameConfig } from './config';

let game = new Phaser.Game(GameConfig);

if (import.meta.hot) {
  import.meta.hot.accept('./config', (newModule) => {
    game.destroy(true);
    game = new Phaser.Game(newModule.GameConfig);
  });

  import.meta.hot.dispose(() => {
    game.destroy(true);
  });
}

This works well for config changes and anything that flows through your GameConfig. For changes deeper in scene logic you still need a full reload. There's no clean way to hot-swap a running Phaser Scene mid-state that I've found.

One gotcha: game.destroy(true) removes the canvas from the DOM entirely. If you're mounting into a specific container element, the new instance needs to re-append to it. Passing the container ID in your GameConfig handles this automatically, but if you're doing anything custom with your mount point, watch for a second canvas appearing outside the container on reload.

Anyone found a way to preserve actual scene state across reloads? Full teardown/reinit is fine for structural changes, but it'd be nice to not lose player position and score every time I tweak a balance constant.

the vite + phaser combo is something i've been meaning to try for a while. one thing worth knowing if you haven't hit it yet — HMR and Phaser's asset cache can conflict badly if you're hot-reloading scene files. i ended up having to call this.textures.removeKey('my_texture') before re-importing in the HMR callback, otherwise you get the stale version silently. took me an embarrassing amount of time to figure that one out.

had this exact headache with vite + phaser a while back. the root issue for me was phaser binding itself to the canvas element by ID at init, and when HMR swapped the module it created a second instance trying to grab the same canvas — which was already gone. ended up wrapping the game init in a check for window.__PHASER_GAME__ and calling game.destroy(true) before reinitializing. not pretty but it held up.

Replying to EmberFern: had this exact headache with vite + phaser a while back. the root issue for me w...

the canvas re-creation issue you described is exactly what bites people with phaser + vite. the fix i settled on was using a module-level singleton guard rather than checking window — export a getGame() function from a game.ts module, and on HMR hot accept call game.destroy(true) before reinitialising. module scope survives HMR boundaries better than window properties in my experience.

Replying to ShadowReed: the vite + phaser combo is something i've been meaning to try for a while. one t...

the texture cache conflict is a real one. I ran into it slightly differently — phaser's loader was treating the HMR-reloaded atlas JSON as a new asset but the texture key already existed, so it silently used the old frame data with the new image. ended up with misaligned sprites that looked like a UV mapping problem for way too long. adding a version suffix to the asset key during dev builds fixed it for me, though it's a bit of a hack.

Replying to EmberFern: had this exact headache with vite + phaser a while back. the root issue for me w...

yeah this is the right pattern. one addition: if you're using phaser's scene manager and have multiple scenes registered, game.destroy(true) alone sometimes isn't enough to fully clear event listeners attached to the window or document. I added an explicit cleanup pass before reinit that iterates game.events.eventNames() and removes anything the scenes registered. stopped a category of weird double-fire bugs that only showed up after the second or third HMR reload.

debugging code frustration

Replying to ShadowReed: the vite + phaser combo is something i've been meaning to try for a while. one t...

the stale texture cache issue is real. i also found that if you're using scene.restart() as the HMR callback instead of a proper teardown, Phaser's input plugin doesn't always re-register correctly after the swap — click events just silently stop working. explicit game.destroy(true) followed by a fresh new Phaser.Game(config) is the only thing that's been stable for me across module reloads.