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:

import { Scene } from "@yagejs/core";
class GameScene extends Scene { readonly name = "game"; }
class MenuScene extends Scene { readonly name = "menu"; }
engine.scenes.push(new GameScene()); // add on top
engine.scenes.pop(); // remove top scene
engine.scenes.replace(new MenuScene()); // swap top scene

Use engine.scenes in your entry point, main.ts. Scenes and components reach the same SceneManager with this.use(SceneManagerKey) or this.service(SceneManagerKey), as the samples below do.

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:

import { Scene, SceneManagerKey } from "@yagejs/core";
import { Anchor, UISurface } from "@yagejs/ui";
export 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 scenes = this.use(SceneManagerKey);
// A one-off UI root, so a named spawn is enough.
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: () => void scenes.pop(),
});
}
}

Push the pause scene from a component in the game scene:

import { Component, SceneManagerKey } from "@yagejs/core";
import { InputManagerKey } from "@yagejs/input";
import { PauseScene } from "./pause-scene";
class PauseOnKey extends Component {
private readonly input = this.service(InputManagerKey);
private readonly scenes = this.service(SceneManagerKey);
update(): void {
if (this.input.isJustPressed("pause")) {
void this.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.

declare const keys: ReadonlySet<string>; // keys held this frame
// 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
const player = this.spawn("player");
const enemy = this.spawn("enemy");
const feedbackHost = this.spawn("feedback");
// 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] });
// Freeze physics and the scene, but keep a presentation host updating.
time.freezeFor(0.08, { excludeUpdates: [feedbackHost] });
// Temporary time effects for one entity's updates only.
time.scaleEntityBy(enemy, 0.25, { for: 0.4, key: "stagger" });
time.freezeEntityFor(enemy, 0.08, { key: "stagger" });

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.
  • Scene requests produce scene.timeScale × (channel winners), readable as time.effectiveScale (with time.isFrozen for the ×0 case). Entity requests multiply into time.effectiveScaleForUpdates(entity) without changing the scene-wide value.

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:

import { Component, SceneTimeKey } from "@yagejs/core";
class ParryWindow extends Component {
private readonly time = this.service(SceneTimeKey);
private windowOpenedAt = 0;
stillOpen = false;
// Call from fixedUpdate when the window opens.
open(): void {
this.windowOpenedAt = this.time.fixedElapsed;
this.stillOpen = true;
}
// ...and later, also in fixedUpdate
fixedUpdate(): void {
if (this.stillOpen) {
this.stillOpen = this.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. Re-issue them if the game rebuilds a scene from saved domain state.

Entity-scoped requests and excludeUpdates entries apply only to the entity’s current life. Releasing a pooled entity ends that association, so the next acquisition does not inherit the previous life’s slow, freeze, or exclusion.

Individual entities carry a persistent timeScale multiplier that composes on top of the scene’s. Use the SceneTime entity request methods above for temporary effects that need independent cleanup.

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.

Include entity.timeScale in the game’s explicit save root only when it is durable gameplay state. Temporary speed effects belong in SceneTime and are recreated by game logic.

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 change the HUD when the game pauses, use the game scene’s own onPause and onResume hooks. onPause runs when the game scene becomes paused, for example when a pauseBelow scene covers it, and onResume runs when it resumes. Each hook calls a method on the HUD’s component, which holds the text:

import { Component, Entity, Scene } from "@yagejs/core";
import { Anchor, UISurface, type UIText } from "@yagejs/ui";
const HUD_KEY = "hud";
class StatusLine extends Component {
private readonly text: UIText;
constructor(text: UIText) {
super();
this.text = text;
}
setPaused(paused: boolean): void {
this.text.setText(paused ? "PAUSED" : "Running");
}
}
class Hud extends Entity {
status!: StatusLine;
setup(): void {
const panel = this.add(new UISurface({ anchor: Anchor.TopLeft }));
this.status = this.add(new StatusLine(panel.text("Running")));
}
}
class GameScene extends Scene {
readonly name = "game";
onEnter(): void {
this.spawn(Hud, { key: HUD_KEY });
// ...
}
onPause(): void {
this.findByKey<Hud>(HUD_KEY)?.status.setPaused(true);
}
onResume(): void {
this.findByKey<Hud>(HUD_KEY)?.status.setPaused(false);
}
}

The pause scene never touches the game scene, so it works over any scene.

Use replace to swap the current scene for the next level. From a component, such as the one on the level’s exit:

import { Scene, SceneManagerKey } from "@yagejs/core";
class Level2Scene extends Scene { readonly name = "level-2"; }
// Transition to next level (old scene is destroyed)
void this.use(SceneManagerKey).replace(new Level2Scene());

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

import { Scene, SceneManagerKey } from "@yagejs/core";
class InventoryScene extends Scene { readonly name = "inventory"; }
const scenes = this.use(SceneManagerKey);
// Show inventory overlay
void scenes.push(new InventoryScene());
// Dismiss it (returns to game)
void 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.

import { Scene, SceneManagerKey } from "@yagejs/core";
class MainMenuScene extends Scene { readonly name = "main-menu"; }
const scenes = this.use(SceneManagerKey);
await scenes.popAll();
await scenes.push(new MainMenuScene());

Access other scenes on the stack through the scene manager’s all list. The Scene API, such as timeScale, works on any scene without a cast:

import { SceneManagerKey } from "@yagejs/core";
const scenes = this.use(SceneManagerKey);
const game = scenes.all.find((s) => s.name === "game");
if (game) {
game.timeScale = 0.25;
}

Don’t cast a scene to its subclass to read its fields. Game state lives in components on the scene’s entities. To tell those components something, emit an event on their scene. Components there hear it with this.listenScene:

import { Component, Scene, SceneManagerKey, defineEvent } from "@yagejs/core";
export const DifficultyChanged = defineEvent<{ level: number }>("settings:difficulty");
// In the options scene, on top of the game:
class OptionsScene extends Scene {
readonly name = "options";
applyDifficulty(level: number): void {
const game = this.use(SceneManagerKey).all.find((s) => s.name === "game");
game?.emit(DifficultyChanged, { level });
}
}
// In a component in the game scene:
class Difficulty extends Component {
level = 1;
onAdd(): void {
this.listenScene(DifficultyChanged, ({ level }) => {
this.level = level;
});
}
}

The engine’s EventBus carries engine events only. It is keyed by string and does not take defineEvent tokens. See Events.

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.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/patterns.md.