Guide

GuideRecipesPause menu

Pause menu

Pause scene updates while keeping clear visual context for players.

Intermediate~3 min read

Pause menu

A pause menu can freeze the game, display a menu overlay, and resume cleanly when dismissed. In ExoJS, this is done entirely within a single scene: call app.scenes.pause()/resume() to freeze updates, show a pause overlay on scene.ui, and reverse both on resume.

Approach

One scene, one UI layer. The pause overlay (a Panel + Label) lives on scene.ui and is hidden until paused. Pausing stops update() and all scene systems while the scene keeps drawing — so the world stays visible behind the overlay. A BlurFilter on scene.root provides visual separation.

examples/guides/pause-menu/pause-scene.ts
class GameScene extends Scene {
  private player!: Sprite;
  private blur!: BlurFilter;
  private pausePanel!: Panel;
  private pauseLabel!: Label;

  override init(): void {
    // Background - the engine clears to this before every `draw`.
    this.app.clearColor.set(20, 24, 34);

    this.player = new Sprite(this.loader.get('image/hero.png'));
    this.addChild(this.player);
    // ... game setup ...

    this.blur = new BlurFilter({ strength: 0 });

    // Pause overlay on the UI layer, hidden until paused.
    this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18, color: new Color(0, 0, 0, 0.6) });
    this.pausePanel.anchorIn(this.ui, 'center');
    this.pausePanel.visible = false;
    this.ui.addChild(this.pausePanel);

    this.pauseLabel = new Label('PAUSED', { fontSize: 56, fontWeight: 'bold' });
    this.pauseLabel.anchorIn(this.ui, 'center');
    this.pauseLabel.visible = false;
    this.ui.addChild(this.pauseLabel);

    // `SceneAvailability.Always` keeps this binding live in both Active and Paused -
    // otherwise a 'active'-only binding (the default) would stop firing
    // the moment the scene pauses, and Escape could never resume it.
    this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause(), { when: SceneAvailability.Always });
  }

  override update(_delta: Seconds): void {
    // Not called while paused - the director skips update() + systems.
    // ... normal game logic ...
  }

  override draw(context: RenderingContext): void {
    context.render(this.root);
  }

  override destroy(): void {
    this.root.clearFilters();
    super.destroy();
  }

  private togglePause(): void {
    const pausing = !this.paused;

    if (pausing) {
      this.app.scenes.pause();
    } else {
      this.app.scenes.resume();
    }

    this.pausePanel.visible = pausing;
    this.pauseLabel.visible = pausing;

    if (pausing) {
      this.blur.strength = 0;
      this.root.filters = [this.blur];
      this.app.tweens.create(this.blur).to({ strength: 3 }, 0.35).start();
    } else {
      this.root.clearFilters();
    }
  }
}

const app = new Application({ scenes: { GameScene } /* , ...other options */ });

await app.start(GameScene);

How pausing works

app.scenes.pause() sets app.scenes.paused (and scene.paused) to true — the scene’s state stays Active:

  • Freezes update() — the director skips update() and all scene-level systems. No game logic runs.
  • Keeps drawing — the scene still renders every frame, so the world remains visible behind the overlay.
  • Tweens on app.tweens still run — the application-level tween system is unaffected, so blur animations and UI transitions animate smoothly while the game is frozen.
  • Scene input bindings default to SceneAvailability.Active only — pass { when: SceneAvailability.Paused } for a binding that should fire only while paused (e.g. a menu confirm button), or { when: SceneAvailability.Always } for one that must work in both states (the Escape toggle above).

scene.ui widgets are always interactive regardless of pause state, so the pause panel’s buttons remain clickable during the pause.

Cleanup

The destroy hook clears the blur filter from scene.root. Without this, the filter array would linger if the scene is ever replaced via app.scenes.change(...). As a rule, any filter applied in init or togglePause should be cleaned up in destroy.

Switching scenes from the pause menu

If the pause menu offers options like “Return to main menu” or “Quit”, use app.scenes.change(...) to navigate away — it unloads the current scene and loads the next one, optionally with a fade transition:

examples/guides/pause-menu/menu-buttons.ts
class MainMenuScene extends Scene {}

const app = new Application({ scenes: { MainMenuScene } });
const resumeButton = new Button({ label: 'Resume' });
const mainMenuButton = new Button({ label: 'Main menu' });

resumeButton.onClick.add(() => app.scenes.resume());
mainMenuButton.onClick.add(() => {
  void app.scenes.change(MainMenuScene, { transition: new FadeSceneTransition() });
});

Examples

Pause BlurPointerKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, Keyboard, Label, Panel, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const PAUSE_BLUR_STRENGTH = 3;
const PAUSE_FADE_SECONDS = 0.35;

/**
 * Pause without a scene stack: a pause overlay lives on `scene.ui` (always
 * above the world) and is toggled together with a scene-local `frozen` flag,
 * which the scene's own `update()` checks to skip gameplay while it keeps
 * drawing. The blur tween runs on the app-level TweenSystem, so it still
 * animates while the scene is frozen.
 */
class GameScene extends Scene {
  private sprite!: Sprite;
  private time = 0;
  private frozen = false;
  private readonly blur = new BlurFilter({ strength: 0 });
  private pausePanel!: Panel;
  private pauseLabel!: Label;
  private hud!: ReturnType<typeof mountControls>;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.sprite = new Sprite(this.loader.get('image/ship-a.png'))
      .setAnchor(0.5)
      .setScale(2)
      .setPosition(width / 2, height / 2);
    this.addChild(this.sprite);

    // Pause overlay on the UI layer, hidden until paused.
    this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18, color: new Color(0, 0, 0, 0.6) });
    this.pausePanel.anchorIn(this.ui, 'center');
    this.pausePanel.visible = false;
    this.ui.addChild(this.pausePanel);

    this.pauseLabel = new Label('PAUSED', { fontSize: 56, fontWeight: 'bold' });
    this.pauseLabel.anchorIn(this.ui, 'center');
    this.pauseLabel.visible = false;
    this.ui.addChild(this.pauseLabel);

    this.hud = mountControls({
      title: 'Pause Blur',
      controls: [{ keys: 'Esc / Click', action: 'pause / resume' }],
      hint: 'Press Esc or click to pause — the scene blurs up behind the menu.',
    });

    this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause());
    // Same toggle on click/tap so the pause works without a keyboard.
    app.input.onPointerTap.add(() => this.togglePause());
  }

  override update(delta: Seconds): void {
    if (this.frozen) return;

    this.time += delta;
    this.sprite.setRotation(this.time * 80);
  }

  override draw(context: RenderingContext): void {
    context.render(this.root);
  }

  override destroy(): void {
    this.root.clearFilters();
    super.destroy();
  }

  private togglePause(): void {
    this.frozen = !this.frozen;
    this.pausePanel.visible = this.frozen;
    this.pauseLabel.visible = this.frozen;

    if (this.frozen) {
      this.blur.strength = 0;
      this.root.filters = [this.blur];
      this.tweens.create(this.blur).to({ strength: PAUSE_BLUR_STRENGTH }, PAUSE_FADE_SECONDS).start();
    } else {
      this.root.clearFilters();
    }
  }
}

const app = new Application({
  scenes: { GameScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(20, 24, 34),
  loader: {
    basePath: 'assets/',
  },
});

void app.start(GameScene);

A rotating sprite with a pause overlay — press Esc to freeze the game under a blurred background with a PAUSED label, Esc again to resume.

Where to go next

The next recipe, Split screen, covers rendering the same scene from multiple viewpoints into viewport regions.