Pause menu
Pause scene updates while keeping clear visual context for players.
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.
Tweens keep running while paused
app.tweens keeps advancing even while the scene is paused — only update() and scene-level systems freeze. That is what lets a blur ramp or menu transition animate smoothly over the frozen world.
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);Clear filters in destroy
Any filter you set in init or togglePause — the BlurFilter here — must be cleared in destroy. Otherwise it stays attached to scene.root and bleeds into the next scene after app.scenes.change(...).
Modal menus that must block clicks
This recipe’s overlay is informational only — scene.ui widgets stay clickable regardless of pause state, which is fine here since nothing else is interactive underneath. For a menu that must swallow clicks so they never reach gameplay nodes beneath it, wrap it with this.interaction.scope(this.pausePanel) and release the returned handle on resume. An interaction scope is a real focus trap: it confines hit-testing, Tab traversal, and programmatic focus(node) calls to that subtree, blurs anything focused outside it the instant it activates, and restores that prior focus when it releases.
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 skipsupdate()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.tweensstill 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.Activeonly — 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:
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
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.


