Skip to content

Scene Management

YAGE uses a scene stack to manage game states. This page covers common patterns for pause menus, time control, transitions, and cross-scene communication.

For the foundational API, see Scenes.

The SceneManager maintains a stack of active scenes:

engine.scenes.push(new GameScene()); // add on top
engine.scenes.pop(); // remove top scene
engine.scenes.replace(new MenuScene()); // swap top scene

Only the topmost scene receives input and updates by default. By default scenes below are hidden too: transparentBelow defaults to false, so the renderer skips every below-stack tree (world layers AND screen-space UI/HUD). Opt the lower scene back in by setting transparentBelow = true on the scene you push on top — see the pause-menu pattern below.

The most common pattern is a pause overlay that freezes the game scene below:

class PauseScene extends Scene {
readonly name = "pause-menu";
override readonly pauseBelow = true; // freeze scene below
override readonly transparentBelow = true; // keep rendering scene below
onEnter(): void {
const entity = this.spawn("pause-ui");
const panel = entity.add(new UISurface({
anchor: Anchor.Center,
direction: "column",
gap: 12,
padding: 32,
background: { color: 0x000000, alpha: 0.8, radius: 12 },
}));
panel.text("PAUSED", { fontSize: 28, fill: 0xffffff });
panel.button("Resume", {
width: 200, height: 40,
onClick: () => engine.scenes.pop(),
});
}
}

Push the pause scene from the game scene:

class GameController extends Component {
update(): void {
if (this.input.isJustPressed("pause")) {
engine.scenes.push(new PauseScene());
}
}
}

Key properties:

  • pauseBelow = true — the game scene’s update() and fixedUpdate() stop running. Physics freezes. Entities stop moving.
  • transparentBelow = true — the game scene still renders behind the pause menu, so the player sees the frozen game state.

Each scene has a timeScale property that scales the delta time passed to update() and fixedUpdate():

scene.timeScale = 0.25; // quarter speed (slow-mo)
scene.timeScale = 1; // normal speed
scene.timeScale = 2; // double speed

Use it as the scene’s persistent speed knob: a game-wide speed setting, a speed-up power-up, or a post-goal replay.

Time scale affects the scene’s physics, processes, and tweens — everything that reads dt from the game loop.

// Toggle slow-mo
if (keys.has("1")) scene.timeScale = 0.25;
if (keys.has("2")) scene.timeScale = 1;
if (keys.has("3")) scene.timeScale = 2;

For temporary effects — hitstop, slow motion during an attack — use the SceneTime service below instead of writing timeScale from several places and losing track of the value to restore.

Every scene gets a SceneTime service, resolvable through the scene-scoped SceneTimeKey. Each temporary effect is a request with its own handle, and the service computes the resulting scale from all active requests — two overlapping effects can never corrupt each other’s restore.

import { SceneTimeKey } from "@yagejs/core";
const time = this.use(SceneTimeKey); // from a Component or Scene
// Hitstop / freeze frame: stop the whole scene for 80ms of real time.
time.freezeFor(0.08);
// Bullet time: slow the world until released.
const slow = time.scaleBy(0.25, { key: "slowmo" });
slow.release();
// Timed speed-up: auto-releases after 5 seconds.
time.scaleBy(2, { for: 5, key: "haste" });
// Slow everything except the player.
time.scaleBy(0.25, { key: "slowmo", excludeUpdates: [player] });

How requests combine:

  • scene.timeScale stays the persistent knob. The service reads it and never writes it.
  • Each key names a channel. Within a channel the latest active request wins; when it ends, an older still-active request applies again. A call without a key gets its own private channel.
  • Across channels the winning factors multiply. A freeze is a ×0 factor, so it wins over any slow or speed-up in another channel.
  • The result is scene.timeScale × (channel winners), readable as time.effectiveScale (with time.isFrozen for the ×0 case).

time.elapsed reports the scene’s elapsed simulation time in seconds. It advances once per rendered frame under time.effectiveScale, so stack pause, scene.timeScale = 0, and freeze requests hold the value. The value starts at 0 each time the scene is entered and is not saved.

time.fixedElapsed counts the same simulation seconds but advances one fixed step at a time. It is the reading to stamp and compare against when the timing has to match a fixed-step simulation — how long since a hit landed, or a window that gates a physics-driven mechanic:

// in fixedUpdate, when the window opens
this.windowOpenedAt = time.fixedElapsed;
// ...and later, also in fixedUpdate
const stillOpen = time.fixedElapsed - this.windowOpenedAt < 0.25;

For a duration you schedule rather than compare — a cooldown, an invincibility window — the entity’s ProcessComponent can run it on the same timestep and report its own completion, with slot({ duration, clock: "fixed" }). See Processes and tweens.

Both readings hold under stack pause, scene.timeScale = 0, and freeze requests, and both start at 0 on scene entry and are not saved. They advance on different cadences, so at any moment they can differ by one or more fixed steps in either direction:

  • The loop’s fixed-step accumulator is engine-wide. A scene entered mid-run starts counting against the time already in it, so its fixedElapsed can run ahead of its elapsed from the first frame.
  • A frame long enough to hit the loop’s fixed-step cap leaves its unrun steps for the following frames, so fixedElapsed falls behind and then advances several steps at once.
  • Time waiting in the accumulator is converted at the scale in force when its step runs, not at the scale of the frame it arrived in. Releasing a slow motion request after a long frame leaves fixedElapsed permanently ahead.

Stamp and compare against the same reading. Subtracting fixedElapsed from elapsed does not give a meaningful lag.

fixedElapsed advances under the whole-scene time.effectiveScale, so it does not follow entity.timeScale or an excludeUpdates exclusion. The player in the excludeUpdates example above still gets full-speed fixedUpdate calls while this reading advances at the slowed scene rate, so a window measured against it stays open longer for that entity. Time an entity that runs at its own rate against its own ProcessComponent, which composes both factors.

Request durations (for, and the freezeFor duration) count real seconds: they are unaffected by the scaling itself, so a freeze reliably ends on time. A stack-paused scene holds its effects — opening a pause menu does not consume a running hitstop. All requests are released when the scene exits, and they are not saved; re-issue them after loading a snapshot.

Individual entities also carry a timeScale multiplier that composes on top of the scene’s:

entity.timeScale = 0; // freeze this one entity (scene keeps running)
entity.timeScale = 0.5; // this entity runs at half the scene's speed
entity.timeScale = 2; // ...or double it

The engine feeds each entity’s components the delta time dt * effectiveScale * entity.timeScale, where effectiveScale is scene.timeScale composed with any active SceneTime requests. The multiplier affects the entity’s component update() / fixedUpdate(), its ProcessComponent (tweens), and its particle emitters — handy for a single frozen boss, a hasted player, or a bullet-time target while everything else moves normally.

entity.timeScale is captured by the save system and restored on load.

If your HUD lives in the game scene, it continues rendering while paused (rendering is not affected by pauseBelow). However, HUD updater components will stop receiving update() calls.

To update HUD text when pausing, modify it directly from the pause scene:

class PauseScene extends Scene {
onEnter(): void {
const game = engine.scenes.all.find(s => s.name === "game") as GameScene;
game.statusText.setText("PAUSED");
}
onExit(): void {
const game = engine.scenes.all.find(s => s.name === "game") as GameScene;
game.statusText.setText("Running");
}
}

Use replace to swap the current scene for the next level:

// Transition to next level (old scene is destroyed)
engine.scenes.replace(new Level2Scene());

Use push for overlays that should dismiss back to the previous scene:

// Show inventory overlay
engine.scenes.push(new InventoryScene());
// Dismiss it (returns to game)
engine.scenes.pop();

Replace all scenes to return to the main menu. Use popAll() rather than a manual pop loop — it routes through the same transition queue as the other scene mutations, so it’s safe to call during a transition without racing against in-flight pushes/pops.

await engine.scenes.popAll();
await engine.scenes.push(new MainMenuScene());

Access other scenes on the stack via engine.scenes.all:

const gameScene = engine.scenes.all.find(
(s) => s.name === "game"
) as GameScene | undefined;
if (gameScene) {
gameScene.timeScale = 0.25;
}

For loose coupling, use the engine-level EventBus instead of direct references:

import { EventBusKey, defineEvent } from "@yagejs/core";
const GamePaused = defineEvent("game:paused");
// Emit from pause scene
this.context.resolve(EventBusKey).emit(GamePaused);
// Listen from game scene
this.context.resolve(EventBusKey).on(GamePaused, () => {
this.statusText.setText("PAUSED");
});

SceneManager can automatically pause every active scene when the tab is hidden, and restore them on return. Opt in per engine instance:

import { SceneManagerKey } from "@yagejs/core";
const scenes = engine.context.resolve(SceneManagerKey);
scenes.autoPauseOnBlur = true; // default: false

Default is false because freezing simulation mid-combat because someone alt-tabbed is surprising — opt in only when the game design wants it.

Only scenes paused by this mechanism are restored on focus. Scenes the user had already paused (manual scene.paused = true or a pauseBelow cascade from a modal stacked on top) are never touched, so a game-over modal that was paused before the blur stays paused after the return. Toggling the flag off mid-blur unpauses the tracked scenes immediately.

Muting audio on blur is a separate concern handled by AudioManager.autoMuteOnBlur (default true) — see the Audio guide.