references/scene-flow.md
# Phaser scene flow (Phaser 4.2; core lifecycle also applies to 3.90)
The Scene Manager runs every scene through a small state machine. Knowing which
transition verb does what prevents the most common Phaser bugs: hidden scenes,
stale state after a restart, and duplicate event listeners.
## Lifecycle order
A scene is **booted once** (status `INIT`) and can be **started any number of
times**. On each start it runs:
```
start → (loading, if preload queued anything) → create → running
```
- `init(data)` — runs first on every start. **Reset run-specific state here.**
- `preload()` — queue asset downloads. Optional.
- `create(data)` — build game objects; assets queued in `preload` are now ready.
- `update(time, delta)` — called every frame while the scene is `running`.
The same `data` object is passed to `init` and `create`, coming from
`scene.start(key, data)` / `scene.launch(key, data)`.
## Transition verbs
| Method | Effect on target | Effect on caller |
|--------|------------------|------------------|
| `start(key, data)` | start (or restart) | **stop** the caller |
| `launch(key, data)` | start in parallel; **restarts** it if already running | caller keeps running |
| `run(key, data)` | wake if sleeping, resume if paused, otherwise (re)start | caller keeps running |
| `switch(key)` | wake if sleeping, otherwise start (a paused target restarts) | **sleep** the caller |
| `pause(key?)` | stop updating, keep rendering | — |
| `resume(key?)` | resume updating | — |
| `sleep(key?)` | stop updating AND rendering, keep state | — |
| `wake(key?)` | resume a sleeping scene | — |
| `stop(key?)` | shutdown (frees objects) | — |
All of these are queued: the Scene Manager applies them at the start of the next game
step, so the target scene has not started yet on the line after `start`/`launch`.
Rules of thumb:
- **HUD/overlay** → `launch` it once; a second `launch` or `run` while it is running
restarts it, so guard with `this.scene.isActive('hud')`. Scenes draw in scene-list
order, so it is on top only if it comes after the gameplay scene in the `scene`
config array; otherwise call `this.scene.bringToTop('hud')`. Toggle with
`setVisible()` or `sleep`/`wake`, not `start`.
- **Modal pause menu** → `pause` the gameplay scene and `launch` the menu; `resume`
on close.
- **Title ⇄ gameplay you revisit** → prefer `sleep`/`wake` (state preserved, starts
only once) over `stop`/`start` (fresh each time) when re-initialisation is costly.
## The restart-state bug
A Scene is a long-lived instance. Fields set in the constructor are set **once**,
so they keep their last value after a restart:
```js
// WRONG — gameOver stays true forever after the first game over.
export default class Play extends Phaser.Scene {
constructor() {
super('play');
this.gameOver = false; // runs once, never again
}
}
// RIGHT — init() runs on every (re)start.
export default class Play extends Phaser.Scene {
constructor() { super('play'); }
init() { this.gameOver = false; }
}
```
The same applies to module-level arrays that collect game objects: clear them on
`shutdown`, or they will hold destroyed objects from the previous run.
```js
create() {
this.enemies = [];
// Clean up when the scene stops so the next run starts empty.
this.events.once('shutdown', () => { this.enemies.length = 0; });
}
```
## Duplicate listeners
Listeners added in `create` on a **global** emitter (the registry, the input
manager, another scene) survive scene restarts and stack up. Either:
- add them with `.once(...)`, or
- remove them on `shutdown`:
```js
create() {
const onChange = (p, v) => this.refresh(v);
this.registry.events.on('changedata-coins', onChange);
this.events.once('shutdown', () => {
this.registry.events.off('changedata-coins', onChange);
});
}
```
## Removing or replacing a scene
You cannot reuse a scene key until the original is destroyed. To swap an instance
under the same key, remove it after it finishes shutting down:
```js
this.scene.remove('level'); // remove by key
// then later
this.scene.add('level', NewLevelScene, true); // true = autostart
```
A handy alternative to juggling keys is to store the active level key in the
registry and add/remove by that value.
## Keys stay down after release (Phaser 4.2.1)
The keyboard manager skips events already marked `defaultPrevented`. With its
default `window` event target, a host-page listener earlier in propagation can
cancel `keyup` before Phaser handles it. A tracked `Key` then stays `isDown`;
opposing directions can cancel each other and later presses lose their down edge.
Check the installed version's implementation before applying this diagnosis elsewhere.
1. Inspect `keydown` and `keyup` at window capture and at the configured keyboard
target. Compare `event.code`, `event.defaultPrevented`, and the tracked key state.
2. Temporarily disable host keyboard handlers and test with extensions disabled.
Remove or narrow the handler that cancels releases for game-owned keys.
3. After removing the interference, call the affected Scene's
`this.input.keyboard.resetKeys()` once to clear its stale tracked keys.
Calling it every frame would break held input. Phaser already resets these keys
on game blur, Scene pause, and Scene sleep; adding another blur reset alone
does not repair a release cancelled while the game remains focused.
For a local reproduction with the default keyboard target, temporarily register
this listener, press and release an arrow key, and inspect its `isDown` value:
```js
const cancelRelease = (event) => {
if (event.code === 'ArrowLeft') event.preventDefault();
};
document.addEventListener('keyup', cancelRelease);
// After inspecting the stuck key, remove the diagnostic listener:
// document.removeEventListener('keyup', cancelRelease);
// Then reset the affected Scene's keys once as described above.
```
If the host cannot be changed, replace keyboard handling deliberately: configure
the Game with `input: { keyboard: false }`, remove `createCursorKeys()` and other
keyboard-plugin calls, and own `window` capture listeners for `keydown`/`keyup`.
The replacement reader needs this contract:
- Track held keys by `event.code`; latch press/release edges until a simulation
tick consumes them so a short tap between ticks is retained. Suppress repeated
`keydown` edges while a key is held.
- Clear held state and pending edges on blur or document hiding. Clear and suspend
gameplay input on Scene pause/sleep, resume cleanly on resume/wake, and remove
DOM and Scene listeners on shutdown so restarts do not duplicate them.
- Limit capture to game-owned keys while gameplay owns focus; leave text fields,
editable content, and browser shortcuts usable. Process releases for keys already
held even if focus changed before release.
- Test short taps, opposite directions, key repeat, focus loss, pause/resume,
sleep/wake, and Scene restart. Capture sees events before later cancellation,
but cannot recover an event blocked by an earlier `stopImmediatePropagation()`.
Primary sources: Phaser 4.2.1
[KeyboardManager](https://github.com/phaserjs/phaser/blob/v4.2.1/src/input/keyboard/KeyboardManager.js)
and [KeyboardPlugin](https://github.com/phaserjs/phaser/blob/v4.2.1/src/input/keyboard/KeyboardPlugin.js).
SKILL.md
---
name: phaser-core
description: >
Set up and debug a Phaser 4 game: the Game config, the Scene lifecycle
(init/preload/create/update), the asset loader, cameras, and cross-scene
communication. Use when building or debugging a Phaser game — when the user
mentions Phaser, Phaser.Game, Phaser.Scene, preload/create/update, this.load,
this.add, or scene transitions. For Arcade Physics movement/collisions use
phaser-arcade-physics.
---
# Phaser 4 Core
Set up the foundation of a Phaser game: the `Game` config, the `Scene`
lifecycle, asset loading, cameras, and passing data between scenes. Targets
**Phaser 4.2** for new projects; keep an existing Phaser 3.90 project on its
pinned major unless the user explicitly asks for a migration.
## When to use
- Use when starting a Phaser game, wiring the `Phaser.Game` config, structuring
`Scene`s, loading assets in `preload`, or fixing scene transitions and shared
state.
- Use when the project has `phaser` in `package.json` or `import Phaser from 'phaser'`,
and code uses `preload()`/`create()`/`update()`.
**When *not* to use:** movement, velocity, colliders, gravity, or overlap → use
`phaser-arcade-physics`. Complex rigid-body simulation uses Matter physics (a
separate concern). For cross-engine save/load patterns use `save-systems`.
## Core workflow
1. **Detect the installed major first.** Read `package.json` and the lockfile. Use
Phaser 4.2 for new work; do not silently rewrite a Phaser 3 project as Phaser 4.
2. **Create the game from a config.** `new Phaser.Game(config)` with `type:
Phaser.AUTO` (WebGL with Canvas fallback), a `width`/`height`, and a `scene`
array. The first scene (and any with `active: true`) starts automatically.
3. **Model each screen as a `Scene`.** Subclass `Phaser.Scene`, pass a unique
`key` to `super`, and implement the lifecycle: `init(data)` → `preload()` →
`create(data)` → `update(time, delta)`.
4. **Load assets in `preload`, use them in `create`.** Queued assets are not
available until `create`. The loader is per-scene; the cache it fills is global.
5. **Reset per-run state in `init()`, not the constructor.** A scene instance is
reused across restarts, so constructor-set fields keep stale values.
6. **Move between screens** with `this.scene.start/launch/switch/sleep/wake`.
Share data through `this.registry` (global) or a sibling scene's event emitter.
7. **Run and observe.** Serve the page, open it, and confirm assets load (watch the
Network tab and console) and scenes switch as expected before assuming success.
## Patterns
### 1. Game config + boot (ES module)
```js
// main.js — one Game owns the renderer, loop, cache, and Scene Manager.
import Phaser from 'phaser';
import BootScene from './scenes/BootScene.js';
import PlayScene from './scenes/PlayScene.js';
const config = {
type: Phaser.AUTO, // WebGL if available, else Canvas
width: 800,
height: 600,
backgroundColor: '#1d1d28',
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: [BootScene, PlayScene] // BootScene starts first
};
new Phaser.Game(config);
```
### 2. A Scene with the full lifecycle
```js
// scenes/PlayScene.js
import Phaser from 'phaser';
export default class PlayScene extends Phaser.Scene {
constructor() {
super('play'); // unique scene key
}
init(data) {
// Reset run-specific state HERE so restarts start clean.
this.score = 0;
this.level = data.level ?? 1;
}
preload() {
// Queue downloads. Not usable until create().
this.load.image('player', 'assets/player.png');
this.load.spritesheet('coin', 'assets/coin.png', { frameWidth: 16, frameHeight: 16 });
}
create() {
this.player = this.add.sprite(400, 300, 'player');
this.scoreText = this.add.text(10, 10, 'Score: 0', { fontSize: '20px', color: '#fff' });
this.cursors = this.input.keyboard.createCursorKeys();
}
update(time, delta) {
// delta is milliseconds since last frame; divide by 1000 for seconds.
const speed = 200 * (delta / 1000);
if (this.cursors.left.isDown) this.player.x -= speed;
if (this.cursors.right.isDown) this.player.x += speed;
}
}
```
### 3. Cross-scene data + events
```js
// The registry is a global DataManager shared by every scene.
this.registry.set('coins', 0); // in any scene
const coins = this.registry.get('coins'); // read anywhere
// React to registry changes (e.g. a HUD scene listening to gameplay):
this.registry.events.on('changedata-coins', (parent, value) => {
this.coinText.setText(`Coins: ${value}`);
});
// Talk directly to another running scene via its event emitter:
const ui = this.scene.get('hud');
ui.events.emit('show-message', 'Level cleared!');
```
### 4. Scene transitions (pick the right verb)
```js
this.scene.start('gameover', { score: this.score }); // stop this scene, start target
this.scene.launch('hud'); // run a second scene in parallel (restarts it if already running)
this.scene.run('hud'); // wake if sleeping, resume if paused, else (re)start
this.scene.bringToTop('hud'); // draw order follows the scene list, not launch order
this.scene.switch('menu'); // sleep this scene, start/wake target
this.scene.pause(); // freeze updates but keep rendering (modal)
this.scene.sleep(); // stop updating AND rendering, keep state for wake
```
### 5. A camera that follows the player
```js
this.cameras.main.setBounds(0, 0, 1600, 1200); // world size
this.cameras.main.startFollow(this.player, true, 0.1, 0.1); // smooth lerp follow
this.cameras.main.setZoom(1.5);
```
## Pitfalls
- **A key works once, then stays down (or Left + Right cancel out)** → in Phaser
4.2.1, a `keyup` cancelled before the keyboard manager receives it is ignored
because `event.defaultPrevented` is set. Inspect host-page listeners and browser
extensions before changing movement code. Read the keyboard diagnosis and
capture-phase fallback contract in `references/scene-flow.md`.
- **Assets are `undefined` in `create`/`update`** → you forgot to queue them in
`preload`, or used the wrong key. The loader runs between `preload` and `create`.
- **State leaks across a restart** → you set fields in the constructor. The Scene
instance is reused; reset run state in `init()` and clear arrays on `shutdown`.
- **`this.scene.start` vs `this.scene.launch`** → `start` stops the calling scene;
`launch` runs the target alongside it. Using `start` for a HUD hides the game.
- **HUD loses its state or draws behind the game** → `launch` (and `run`) on a scene
that is already running shuts it down and starts it again; launch the HUD once, or
guard with `if (!this.scene.isActive('hud'))`. Scenes draw in scene-list order, so a
HUD listed before the gameplay scene renders underneath it until you call
`this.scene.bringToTop('hud')`.
- **Scene state read right after `start`/`launch` is stale** → scene operations are
queued and run at the start of the next game step, not synchronously.
- **`this` is wrong in a callback** → arrow functions keep the Scene's `this`; plain
`function` callbacks need a context argument or `.bind(this)`.
- **Phaser 2 tutorials don't work** → "States" were renamed to "Scenes" in Phaser 3,
and each Scene owns its own systems (input, cameras, tweens) rather than a global
Game World.
- **Phaser 3 custom pipelines fail in Phaser 4** → Phaser 4 rebuilt the renderer and
replaced the old FX/pipeline extension points. Migrate custom shaders and renderer
plugins against the Phaser 4 guide; do not mechanically copy internal renderer code.
- **A Phaser 3 snippet runs but looks wrong in Phaser 4** → common renames: `preFX`/
`postFX` and `BitmapMask` became filters (`obj.enableFilters()` then
`obj.filters.internal`/`.external`); `setPipeline('Light2D')` became
`setLighting(true)`; `setTintFill(c)` only logs an error, so use
`setTint(c).setTintMode(Phaser.TintModes.FILL)`; `Phaser.Math.TAU` is now 2π (use
`Phaser.Math.PI_OVER_2` where v3 code meant π/2); `Geom.Point` is gone (use
`Phaser.Math.Vector2`). Canvas rendering still works but is deprecated.
- **Pixel art is blurry or shimmers while moving** → set `pixelArt: true` in the game
config. It turns off antialiasing and turns on `roundPixels`, which defaults to
`false` in Phaser 4.
- **Nothing renders / black screen** → confirm the canvas mounted, `width`/`height`
are set, and a scene actually started (check `game.scene.dump()` output).
- **Two canvases (two games running) after a hot reload or a React/Vue remount** →
a second `new Phaser.Game` ran while the first was still alive. Keep one instance
and call `game.destroy(true)` when the host component unmounts; the teardown
finishes on the next frame. Leave `noReturn` false, or no new game can start on
the page.
## References
- For the full scene state machine (pause/resume vs sleep/wake vs stop/start, the
restart-state bug, removing/replacing scenes, and stuck keyboard input), read
`references/scene-flow.md`.
## Related skills
- `phaser-arcade-physics` — velocity, gravity, colliders, overlap, and groups.
- `input-systems` — rebindable, multi-device input architecture (engine-agnostic).
- `pixijs-rendering` / `threejs-scene-setup` — other browser rendering stacks.
- `platformer` / `puzzle` — genre templates that compose Phaser skills.