Guide

Guide Recipes Pause menu

Pause menu

Pause scene updates while keeping clear visual context for players.

Intermediate ~2 min read

Pause menu

A pause menu can freeze the game, display a menu overlay, and resume cleanly when dismissed. In ExoJS, this is a scene-stack operation: push a PauseScene on top of the game scene with 'overlay' mode so the game remains visible.

Approach

Two scenes, one reference. The game scene holds a reference so the pause scene can apply visual effects (like a blur) to the game scene’s root before pushing itself. The pause scene pops itself on Esc to resume.

class GameScene extends Scene {
    init(loader) {
        this.player = new Sprite(loader.get(Texture, 'hero'));
        // ... game setup ...

        this.inputs.onTrigger(Keyboard.Escape, async () => {
            if (pauseScene.app !== null) return; // already paused
            await this.app.scene.pushScene(pauseScene, {
                mode: 'overlay',
            });
        });
    }

    update(delta) {
        // Game logic — this runs even while the pause scene is up,
        // but you can gate it on a flag if you prefer to freeze.
    }

    draw(context) {
        context.backend.clear(new Color(20, 24, 34));
        context.render(this.root);
    }
}

class PauseScene extends Scene {
    init() {
        // Apply blur to the game scene for visual separation
        this._blur = new BlurFilter({ radius: 5, quality: 2 });
        gameScene.root.filters = [this._blur];

        this._text = new Text('PAUSED', { fill: 'white', fontSize: 64 });
        this._text.setPosition(280, 250);

        this.inputs.onTrigger(Keyboard.Escape, async () => {
            await this.app.scene.popScene();
        });
    }

    destroy() {
        gameScene.root.clearFilters(); // remove blur on resume
        super.destroy();
    }
}

const gameScene = new GameScene();
const pauseScene = new PauseScene();
await app.start(gameScene);

Stack semantics

pushScene(pauseScene, { mode: 'overlay' }):

  • mode: 'overlay' — the game scene below continues to render, so the player sees gameplay behind the pause text. If you prefer the game to stop rendering, use 'opaque' instead (but then the blur effect on the game root becomes invisible — you’d apply the blur to a captured RenderTexture of the last frame instead).

Input bindings created via this.inputs on both the game scene and the pause scene are always active — use an explicit flag to gate gameplay logic while paused (shown in the example above with if (pauseScene.app !== null) return).

If you want the game to truly freeze (no update calls), set the game scene’s own stackMode or use a flag:

// In GameScene.update:
update(delta) {
    if (pauseScene.app !== null) return; // freeze when paused
    // ... normal update logic ...
}

This is simpler than changing stack semantics at runtime and gives you explicit control.

Cleanup

The destroy hook on PauseScene removes the blur filter. Without this, the game scene would remain blurred after resume. popScene() calls destroy on the popped scene automatically.

If you need to capture the last game frame and blur it without leaving the game scene’s filter array polluted, render the game scene root into a RenderTexture during PauseScene.init, apply the blur, and display the result. A one-pass RenderNodePass captures the root subtree off-screen; the blur is a plain filter.apply into a second texture:

// Alternative: capture + blur without touching game scene's own filters
init() {
    this._capture = new RenderTexture(800, 600);
    this._blurred = new RenderTexture(800, 600);
    this._blur = new BlurFilter({ radius: 5, quality: 2 });

    // Capture the game's last frame: render the root subtree into _capture off-screen.
    const context = this.app.rendering; // the Application's RenderingContext
    new RenderNodePass(gameScene.root, { target: this._capture, clear: Color.black }).execute(context);
    this._blur.apply(context.backend, this._capture, this._blurred);

    this._bg = new Sprite(this._blurred);
    this._text = new Text('PAUSED', { fill: 'white', fontSize: 64 });
}

RenderNodePass with { target } redirects the node’s render into the texture (and clear wipes it first). Here it is executed directly — a single off-screen capture — rather than added to a long-lived pipeline. This approach leaves the game scene’s filters untouched and is more robust if multiple systems may add or remove filters from the game root.

Examples

Pause Blur Keyboard Open in Playground View source
import { Application, BlurFilter, Color, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

const PAUSE_BLUR_RADIUS = 6;
const PAUSE_FADE_SECONDS = 0.35;

class GameScene extends Scene {
    private sprite!: Sprite;
    private time = 0;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { ship: 'image/ship-a.png' });
    }

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

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

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

        this.inputs.onTrigger(Keyboard.Escape, async () => {
            if (pauseScene.app !== null) return;
            await this.app.scene.pushScene(pauseScene, { mode: 'overlay' });
        });
    }

    override update(delta): void {
        this.time += delta.seconds;
        this.sprite.setRotation(this.time * 80);
    }

    override draw(context): void {
        context.backend.clear(new Color(20, 24, 34));
        context.render(this.root);
    }
}

class PauseScene extends Scene {
    private blur!: BlurFilter;
    private text!: Text;

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

        // Start fully sharp and tween the radius up so the blur genuinely fades
        // in rather than snapping on. The global TweenManager keeps ticking while
        // this overlay scene is on the stack.
        this.blur = new BlurFilter({ radius: 0, quality: 2 });
        gameScene.root.filters = [this.blur];
        this.tweens.create(this.blur).to({ radius: PAUSE_BLUR_RADIUS }, PAUSE_FADE_SECONDS).start();

        this.text = new Text('PAUSED', { fillColor: Color.white, fontSize: 64, fontWeight: 'bold', align: 'center' });
        this.text.setAnchor(0.5, 0.5);
        this.text.setPosition(width / 2, height / 2);

        this.inputs.onTrigger(Keyboard.Escape, async () => {
            await this.app.scene.popScene();
        });
    }

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

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

const gameScene = new GameScene();
const pauseScene = new PauseScene();

void app.start(gameScene);

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

Where to go next

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