Skip to content
Phaser Scenes & State Management — Complete Guide with Examples

Phaser Scenes & State Management — Complete Guide with Examples

DodaTech Updated Jun 6, 2026 15 min read

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, and sleep
  • 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.

Prerequisites: Complete the Phaser tutorial first. You should understand Phaser basics: game config, scene lifecycle, asset loading, and keyboard input.

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.

MethodWhen It RunsWhat To DoWhat NOT To Do
init(data)First, before preloadSet state, receive dataCreate game objects
preload()After initLoad assetsUse this.add or this.physics
create()After preload finishesBuild sceneLoad new assets (use a Load scene instead)
update()Every frameMovement, inputLoad assets or set up complex objects
shutdown()When scene stops/startsClean up listenersStart 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

  1. 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.

  2. 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();.

  3. Creating game objects in init() before assets are readypreload() runs after init(). Assets are only available in create(). Use init() only for data setup.

  4. Calling scene.start('CurrentScene') instead of scene.restart() — Starting the same scene destroys and recreates it. Use scene.restart() when you want to reset the current scene — it’s cleaner and more efficient.

  5. Modifying registry objects in place without triggering eventsregistry.get('obj').property = x doesn’t fire change events because the reference hasn’t changed. Use registry.set('obj', modifiedObj) or clone before modifying.

  6. Memory leaks from orphaned timers and tweens — When a scene stops, custom setInterval calls, setTimeout references, or tweens targeting objects from other scenes persist. Always clean them in shutdown().

Practice Questions

  1. What order do lifecycle methods run when a scene starts? init(data)preload()create()update() (repeating). When the scene stops, shutdown() runs.

  2. What’s the difference between scene.start() and scene.launch()? start() stops the current scene and starts a new one. launch() starts a new scene alongside the current one — both run their update() methods simultaneously.

  3. 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’s init(data) with this.finalScore = data.score.

  4. 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.

  5. Challenge: Modify the Coin Runner below to add a “Level Complete” scene. After collecting all coins, transition to a new LevelCompleteScene that shows stats (score, time, coins collected) and offers “Next Level” and “Menu” buttons. Use the registry to store cumulative scores across levels.

FAQ

What is the difference between scene.start and scene.switch?
scene.start stops the current scene and launches a new one. scene.switch does the same but is designed for alternating between two scenes (like menu and settings). Their behavior is nearly identical — use start for most cases.
Can I run multiple scenes at once?
Yes. Use scene.launch('SceneB') to run SceneB alongside the current scene. Both update() methods run simultaneously. Use scene.bringToTop() to control render order.
How do I pause a game scene without stopping its music?
Put the music in a separate scene (e.g., AudioScene) launched alongside the game scene. The audio scene stays active while the game scene is paused.
How do I restart the current scene?
Call this.scene.restart(). This triggers shutdown(), then init(), preload(), and create() in sequence. Pass data: this.scene.restart({ level: 2 }).
What happens to physics bodies when a scene sleeps?
When a scene sleeps, update() stops but physics continues. Bodies still collide and fall. Use scene.pause() to freeze physics.

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

TopicDescription
Phaser Input & AudioGamepad support, touch input, audio sprites, and spatial sound
Phaser TilemapsLevel design with Tiled, tile layers, collision tiles, and procedural generation
Phaser Sprites & PhysicsReview Arcade physics, collisions, and physics groups
Phaser Getting StartedReview fundamentals — game config, scene lifecycle, asset loading
JavaScript ES6 ClassesReview 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