Phaser Scenes & State Management — Complete Guide with Examples
Phaser Scenes & State Management — Complete Guide with Examples
Master Phaser scenes and state management: understand the full scene lifecycle, create and switch between multiple scenes, pass data between them, persist game state, and build a complete game with menu, gameplay, and game-over screens.
What You’ll Learn
- How scenes work — think of them as acts in a theater production
- The scene lifecycle:
init,preload,create,update,shutdown - Switching between scenes with
start,launch,pause, andsleep - Passing data between scenes via
init()and the game registry - Saving high scores with localStorage for persistence
- Building a multi-scene “Coin Runner” game from scratch
Phaser scenes are the building blocks of any non-trivial game. Every screen — menu, gameplay, pause, game over — is a scene.
Why Scenes & State Management Matters
Imagine a game with just one scene. Your menu, gameplay, and game-over screen are all crammed into the same file, with messy if statements controlling what’s visible. Now multiply that across 10 screens. It becomes unmanageable.
Scenes let you organize your game like rooms in a house. Each room has a purpose, its own furniture (game objects), and its own rules. The Scene Manager is the hallway connecting them.
At DodaTech, security training simulators use scenes to separate learning modules: an intro scene explains the scenario, a gameplay scene runs the simulation, a results scene shows pass/fail metrics. This scene-based architecture was built by the developers of Doda Browser and Durga Antivirus Pro.
Learning Path
flowchart LR
A[Getting Started] --> B[Sprites & Physics]
B --> C[Scenes & State]
C --> D[Input & Audio]
D --> E[Tilemaps & Level Design]
C -->|You are here| C
Scenes Are Like Theater Acts
Think of a play. Act 1 introduces the characters (Menu scene). Act 2 is the main story (Gameplay scene). Act 3 is the resolution (Game Over scene). Between acts, the curtain falls and the stage resets.
Phaser scenes work the same way:
- Menu Scene — Shows title, high score, “Start” button
- Game Scene — Runs the actual gameplay
- Pause Scene — Overlays the game, freezing action
- Game Over Scene — Shows results, offers restart
Each scene has its own preload(), create(), and update(). Assets can be shared (loaded once in a Boot scene) or loaded per-scene.
Scene Lifecycle — The Five Stages
Every scene progresses through these stages in order:
class MyScene extends Phaser.Scene {
constructor() {
super({ key: 'MyScene' }); // 0. Scene key (identifier)
}
init(data) {
// 1. Receive data from the previous scene. Reset variables here.
// Runs before preload. Assets are NOT ready yet.
this.score = data.score ?? 0;
}
preload() {
// 2. Load assets specific to this scene.
// ONLY use this.load methods here.
}
create() {
// 3. Build game objects, UI, input handlers.
// All assets from preload() are now available.
}
update(time, delta) {
// 4. Game loop — runs every frame while scene is active.
// Movement, AI checks, collision responses go here.
}
shutdown() {
// 5. Cleanup when scene stops or restarts.
// Remove event listeners, stop timers, kill tweens.
}
}Why init() exists: It runs before preload(), so you can set initial state and receive data before assets start loading. For example, which level to load: init(data) { this.level = data.level || 1; }.
Why shutdown() matters: Phaser removes display objects automatically when a scene stops, but it does NOT remove custom event listeners, WebSocket connections, or intervals you created. These cause memory leaks if not cleaned up.
| Method | When It Runs | What To Do | What NOT To Do |
|---|---|---|---|
init(data) | First, before preload | Set state, receive data | Create game objects |
preload() | After init | Load assets | Use this.add or this.physics |
create() | After preload finishes | Build scene | Load new assets (use a Load scene instead) |
update() | Every frame | Movement, input | Load assets or set up complex objects |
shutdown() | When scene stops/starts | Clean up listeners | Start new scenes (use a callback instead) |
Scene Transitions — The Six Ways to Move
scene.start() — Full Switch
Stops the current scene and starts a new one. The current scene runs shutdown(), then the new scene runs init(), preload(), create():
this.scene.start('GameScene');
this.scene.start('GameScene', { level: 2 }); // With data
scene.launch() — Run Alongside
Starts a new scene WITHOUT stopping the current one. Both scenes’ update() methods run simultaneously. Useful for HUD overlays, pause menus, or audio managers:
this.scene.launch('HUDScene'); // HUD runs on top of game
scene.switch() — Alternate
Similar to start() but designed for alternating between scenes (like a main menu and settings screen). Preserves the calling scene’s state:
this.scene.switch('SettingsScene');scene.pause() and scene.resume()
Pause freezes update() for a scene — it still renders but doesn’t process logic. Resume re-enables it:
this.scene.pause('GameScene'); // Freezes logic, keeps image
this.scene.resume('GameScene'); // Unfreezes
scene.sleep() and scene.wake()
Sleep is like pause but more aggressive — the scene doesn’t render, doesn’t update, and releases GPU memory. Wake restores it:
this.scene.sleep('GameScene'); // Full hibernation
this.scene.wake('GameScene'); // Wake up
Sleep vs Pause: Use pause() for temporary freezes (like a quick inventory check). Use sleep() for scenes you’ll return to later but don’t need active (like a paused game on mobile that might get killed by the OS).
scene.stop() — Destroy
Removes the scene entirely. All display objects are destroyed, and shutdown() is called:
this.scene.stop('HUDScene');
this.scene.stop(); // Stop current scene
Passing Data Between Scenes
You have three ways to share data between scenes. Each has a different use case:
Method 1: Via init() Data (One-Time Pass)
Use this when starting/launching a scene and you need to pass initial values:
// In Scene A:
this.scene.start('SceneB', { score: 100, level: 3 });
// In Scene B's init():
init(data) {
this.score = data.score ?? 0; // ?? provides default if undefined
this.level = data.level ?? 1;
}Why ?? 0 instead of || 0? The nullish coalescing operator (??) only replaces null or undefined. The OR operator (||) replaces any falsy value including 0, which would incorrectly overwrite a score of 0.
Method 2: Via the Registry (Global Storage)
The registry is a shared key-value store that ALL scenes can read and write. Think of it as a whiteboard in the office hallway — anyone can write to it and read from it:
// Set in any scene (e.g., GameScene when player scores)
this.registry.set('highScore', 5000);
this.registry.set('playerName', 'Alex');
// Get in any scene (e.g., GameOverScene to display)
const score = this.registry.get('highScore'); // 5000
const name = this.registry.get('playerName'); // 'Alex'
// Listen for changes
this.registry.events.on('changedata-highScore', (parent, value, previousValue) => {
console.log(`High score changed: ${previousValue} → ${value}`);
});
// Clear everything
this.registry.reset();Method 3: Via scene.get() (Direct Access)
Access another scene’s methods directly. Use sparingly — it creates tight coupling between scenes:
const gameScene = this.scene.get('GameScene');
gameScene.addScore(50); // Call method on another scene
Which method should you use? Use init() for one-time pass data (like level number). Use the registry for global state (high scores, player name, settings). Use scene.get() only when you need to call a specific method on another scene.
Camera Transition Effects
Abrupt scene switches feel jarring. Camera transitions smooth the experience:
// In outgoing scene — fade out before switching
this.cameras.main.fadeOut(500, 0, 0, 0); // 500ms fade to black
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('NextScene');
});
// In incoming scene — fade in from black
this.cameras.main.fadeIn(500); // 500ms fade from black
Other camera effects for visual flair:
this.cameras.main.shake(300, 0.02); // Screen shake (death, explosion)
this.cameras.main.flash(200, 255, 255, 255); // White flash (damage)
State Persistence with localStorage
The registry lives only in memory. When the page refreshes, it’s gone. Use localStorage to save data across sessions:
// Save
const scores = this.registry.get('highScores') || [];
localStorage.setItem('phaser_scores', JSON.stringify(scores));
// Load (in a BootScene or MenuScene create)
const saved = localStorage.getItem('phaser_scores');
if (saved) {
this.registry.set('highScores', JSON.parse(saved));
}Why JSON.stringify and JSON.parse? localStorage only stores strings. If you save a number, it comes back as a string. Always serialize complex data (arrays, objects) with JSON.
You Might Be Wondering…
“Can I run multiple scenes at the same time?” Yes. scene.launch('SceneB') runs SceneB alongside the current scene. Both update() methods run simultaneously. Use scene.bringToTop() to control which renders on top.
“What happens to physics when a scene sleeps?” Physics continues running even when a scene sleeps — bodies still collide and respond to gravity. Use scene.pause() to freeze physics as well.
“How do I restart the current scene?” Call this.scene.restart(). This runs shutdown(), then init(), preload(), and create() again. Optionally pass data: this.scene.restart({ level: 2 }).
Common Mistakes
Loading the same assets in every scene — Load shared assets once in a
BootScene, then transition. Loading the same image in 3 different scenes wastes bandwidth and memory. Pattern: BootScene loads shared assets → starts MenuScene.Not cleaning up event listeners in
shutdown()— Scene shutdown doesn’t automatically remove listeners on external objects (DOM elements, custom event emitters). Always clean up:this.input.keyboard.removeAllListeners(); this.time.removeAllEvents();.Creating game objects in
init()before assets are ready —preload()runs afterinit(). Assets are only available increate(). Useinit()only for data setup.Calling
scene.start('CurrentScene')instead ofscene.restart()— Starting the same scene destroys and recreates it. Usescene.restart()when you want to reset the current scene — it’s cleaner and more efficient.Modifying registry objects in place without triggering events —
registry.get('obj').property = xdoesn’t fire change events because the reference hasn’t changed. Useregistry.set('obj', modifiedObj)or clone before modifying.Memory leaks from orphaned timers and tweens — When a scene stops, custom
setIntervalcalls,setTimeoutreferences, or tweens targeting objects from other scenes persist. Always clean them inshutdown().
Practice Questions
What order do lifecycle methods run when a scene starts?
init(data)→preload()→create()→update()(repeating). When the scene stops,shutdown()runs.What’s the difference between
scene.start()andscene.launch()?start()stops the current scene and starts a new one.launch()starts a new scene alongside the current one — both run theirupdate()methods simultaneously.How would you pass a player’s score from GameScene to GameOverScene? Call
this.scene.start('GameOverScene', { score: this.score })in GameScene, then access it in GameOverScene’sinit(data)withthis.finalScore = data.score.Why should you avoid
scene.get()for frequent data sharing? It creates tight coupling between scenes, making the code harder to refactor. The registry is a better choice for shared state because scenes don’t need to know about each other’s internal structure.Challenge: Modify the Coin Runner below to add a “Level Complete” scene. After collecting all coins, transition to a new
LevelCompleteScenethat shows stats (score, time, coins collected) and offers “Next Level” and “Menu” buttons. Use the registry to store cumulative scores across levels.
FAQ
Try It Yourself
Here’s a complete multi-scene game with menu, gameplay, pause, and game-over screens. It includes camera transitions, score persistence, and all 5 scenes working together:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Scene Game</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #0f0f23;
}
</style>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script>
class BootScene extends Phaser.Scene {
constructor() { super({ key: 'BootScene' }); }
preload() { this.createPlaceholderAssets(); }
createPlaceholderAssets() {
const pC = this.textures.createCanvas('player', 30, 40);
const pCtx = pC.context;
pCtx.fillStyle = '#ff6b35'; pCtx.fillRect(0, 0, 30, 40);
pCtx.fillStyle = '#ffd166'; pCtx.fillRect(5, 5, 20, 15); pC.refresh();
const eC = this.textures.createCanvas('enemy', 28, 28);
const eCtx = eC.context;
eCtx.fillStyle = '#e63946'; eCtx.beginPath(); eCtx.arc(14, 14, 12, 0, Math.PI * 2); eCtx.fill(); eC.refresh();
const cC = this.textures.createCanvas('coin', 20, 20);
const cCtx = cC.context;
cCtx.fillStyle = '#ffd700'; cCtx.beginPath(); cCtx.arc(10, 10, 8, 0, Math.PI * 2); cCtx.fill();
cCtx.fillStyle = '#ffaa00'; cCtx.beginPath(); cCtx.arc(10, 10, 5, 0, Math.PI * 2); cCtx.fill(); cC.refresh();
const gC = this.textures.createCanvas('ground', 64, 32);
const gCtx = gC.context;
gCtx.fillStyle = '#3a7d32'; gCtx.fillRect(0, 0, 64, 32);
gCtx.fillStyle = '#2d5a27'; gCtx.fillRect(0, 0, 64, 5); gC.refresh();
}
create() { this.scene.start('MenuScene'); }
}
class MenuScene extends Phaser.Scene {
constructor() { super({ key: 'MenuScene' }); }
create() {
this.cameras.main.setBackgroundColor('#1a1a2e');
this.add.text(400, 150, 'COIN RUNNER', {
fontSize: '56px', fill: '#ffd700', fontFamily: 'monospace', stroke: '#000', strokeThickness: 6
}).setOrigin(0.5);
this.add.text(400, 240, 'Collect coins — avoid enemies', {
fontSize: '18px', fill: '#aaa', fontFamily: 'monospace'
}).setOrigin(0.5);
const highScore = localStorage.getItem('coinRunnerHighScore') || 0;
this.add.text(400, 290, `High Score: ${highScore}`, {
fontSize: '20px', fill: '#4ecdc4', fontFamily: 'monospace'
}).setOrigin(0.5);
const startBtn = this.add.text(400, 380, '[ START GAME ]', {
fontSize: '28px', fill: '#ffffff', fontFamily: 'monospace', stroke: '#000', strokeThickness: 4
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
startBtn.on('pointerover', () => startBtn.setScale(1.1));
startBtn.on('pointerout', () => startBtn.setScale(1));
startBtn.on('pointerdown', () => {
this.cameras.main.fadeOut(400, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('GameScene'); });
});
}
}
class GameScene extends Phaser.Scene {
constructor() { super({ key: 'GameScene' }); }
init() { this.score = 0; this.lives = 3; this.gameOver = false; }
create() {
this.cameras.main.fadeIn(400);
this.physics.world.setBounds(0, 0, 800, 600);
this.platforms = this.physics.add.staticGroup();
for (let i = 0; i < 13; i++) { this.platforms.create(64 + i * 64, 568, 'ground'); }
this.platforms.create(200, 400, 'ground');
this.platforms.create(400, 320, 'ground');
this.platforms.create(600, 400, 'ground');
this.player = this.physics.add.sprite(100, 500, 'player');
this.player.setCollideWorldBounds(true);
this.player.setBounce(0.1);
this.player.body.setGravityY(500);
this.coins = this.physics.add.staticGroup();
const coinSpots = [[200, 350], [400, 270], [600, 350], [150, 200], [350, 150], [650, 200], [100, 520], [700, 520]];
coinSpots.forEach(([x, y]) => this.coins.create(x, y, 'coin'));
this.enemies = this.physics.add.group();
for (let i = 0; i < 3; i++) {
const enemy = this.physics.add.sprite(300 + i * 200, 200, 'enemy');
enemy.setBounce(1); enemy.setCollideWorldBounds(true);
enemy.body.setGravityY(500); enemy.setVelocityX(100 * (i % 2 === 0 ? 1 : -1));
this.enemies.add(enemy);
}
this.physics.add.collider(this.player, this.platforms);
this.physics.add.collider(this.enemies, this.platforms);
this.physics.add.collider(this.enemies, this.enemies);
this.physics.add.overlap(this.player, this.coins, this.collectCoin, null, this);
this.physics.add.overlap(this.player, this.enemies, this.hitEnemy, null, this);
this.cursors = this.input.keyboard.createCursorKeys();
this.scoreText = this.add.text(16, 16, 'Score: 0', {
fontSize: '22px', fill: '#fff', fontFamily: 'monospace', stroke: '#000', strokeThickness: 3
});
this.livesText = this.add.text(680, 16, 'Lives: 3', {
fontSize: '22px', fill: '#fff', fontFamily: 'monospace', stroke: '#000', strokeThickness: 3
});
this.input.keyboard.on('keydown-P', () => { this.scene.pause(); this.scene.launch('PauseScene'); });
}
update() {
if (this.gameOver) return;
const speed = 250;
if (this.cursors.left.isDown) { this.player.setVelocityX(-speed); }
else if (this.cursors.right.isDown) { this.player.setVelocityX(speed); }
else { this.player.setVelocityX(0); }
if (this.cursors.up.isDown && this.player.body.touching.down) { this.player.setVelocityY(-400); }
this.enemies.children.each(enemy => {
if (enemy.x < 50 || enemy.x > 750) { enemy.setVelocityX(-enemy.body.velocity.x); }
});
}
collectCoin(player, coin) { coin.destroy(); this.score += 10; this.scoreText.setText(`Score: ${this.score}`); }
hitEnemy(player, enemy) {
this.lives--; this.livesText.setText(`Lives: ${this.lives}`);
player.setVelocityY(-250); player.setTint(0xff0000);
this.time.delayedCall(500, () => player.clearTint());
if (this.lives <= 0) {
this.gameOver = true; this.player.setTint(0xff0000);
this.player.body.setVelocity(0, 0); this.player.body.moves = false;
const prev = parseInt(localStorage.getItem('coinRunnerHighScore') || '0');
if (this.score > prev) { localStorage.setItem('coinRunnerHighScore', this.score.toString()); }
this.time.delayedCall(800, () => {
this.cameras.main.shake(300, 0.02);
this.cameras.main.once('camerashakecomplete', () => { this.scene.start('GameOverScene', { score: this.score }); });
});
}
}
}
class PauseScene extends Phaser.Scene {
constructor() { super({ key: 'PauseScene' }); }
create() {
this.add.rectangle(400, 300, 800, 600, 0x000000, 0.5);
this.add.text(400, 260, 'PAUSED', { fontSize: '48px', fill: '#fff', fontFamily: 'monospace' }).setOrigin(0.5);
const resume = this.add.text(400, 340, '[ Resume ]', {
fontSize: '24px', fill: '#ffd700', fontFamily: 'monospace'
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
resume.on('pointerdown', () => { this.scene.resume('GameScene'); this.scene.stop(); });
this.input.keyboard.on('keydown-P', () => { this.scene.resume('GameScene'); this.scene.stop(); });
}
}
class GameOverScene extends Phaser.Scene {
constructor() { super({ key: 'GameOverScene' }); }
init(data) { this.finalScore = data.score ?? 0; }
create() {
this.cameras.main.setBackgroundColor('#1a1a2e');
this.add.text(400, 160, 'GAME OVER', {
fontSize: '52px', fill: '#e63946', fontFamily: 'monospace', stroke: '#000', strokeThickness: 5
}).setOrigin(0.5);
this.add.text(400, 260, `Score: ${this.finalScore}`, {
fontSize: '32px', fill: '#fff', fontFamily: 'monospace'
}).setOrigin(0.5);
const highScore = localStorage.getItem('coinRunnerHighScore') || 0;
const isNew = parseInt(highScore) === this.finalScore && this.finalScore > 0;
this.add.text(400, 310, `Best: ${highScore}${isNew ? ' ★ NEW!' : ''}`, {
fontSize: '22px', fill: '#4ecdc4', fontFamily: 'monospace'
}).setOrigin(0.5);
const restartBtn = this.add.text(400, 420, '[ PLAY AGAIN ]', {
fontSize: '26px', fill: '#ffd700', fontFamily: 'monospace', stroke: '#000', strokeThickness: 3
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
restartBtn.on('pointerover', () => restartBtn.setScale(1.1));
restartBtn.on('pointerout', () => restartBtn.setScale(1));
restartBtn.on('pointerdown', () => {
this.cameras.main.fadeOut(300, 0, 0, 0);
this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('GameScene'); });
});
const menuBtn = this.add.text(400, 480, '[ Menu ]', {
fontSize: '20px', fill: '#aaa', fontFamily: 'monospace'
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
menuBtn.on('pointerdown', () => { this.scene.start('MenuScene'); });
}
}
const config = {
type: Phaser.AUTO, width: 800, height: 600,
backgroundColor: '#1a1a2e',
physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },
scene: [BootScene, MenuScene, GameScene, PauseScene, GameOverScene]
};
new Phaser.Game(config);
</script>
</body>
</html>This demonstrates: 5 connected scenes (Boot → Menu → Game → Pause → GameOver), data passing via init() and the registry, camera fade transitions, scene pause/resume, high score persistence with localStorage, and the full scene lifecycle in practice.
What’s Next
| Topic | Description |
|---|---|
| Phaser Input & Audio | Gamepad support, touch input, audio sprites, and spatial sound |
| Phaser Tilemaps | Level design with Tiled, tile layers, collision tiles, and procedural generation |
| Phaser Sprites & Physics | Review Arcade physics, collisions, and physics groups |
| Phaser Getting Started | Review fundamentals — game config, scene lifecycle, asset loading |
| JavaScript ES6 Classes | Review class syntax, constructor, and super() used in scene definitions |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Scene-based architecture powers DodaTech’s security training modules — each exercise is a self-contained scene that can be paused, resumed, and scored independently.
What’s Next
Congratulations on completing this Phaser Scenes State tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro