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.
Scene Stack Recap
Section titled “Scene Stack Recap”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 topengine.scenes.pop(); // remove top sceneengine.scenes.replace(new MenuScene()); // swap top sceneUse 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.
Pause Menu Pattern
Section titled “Pause Menu Pattern”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’supdate()andfixedUpdate()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.
Time Scale
Section titled “Time Scale”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 speedscene.timeScale = 2; // double speedUse 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-moif (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.
Hitstop, Slow Motion, and Freeze Frames
Section titled “Hitstop, Slow Motion, and Freeze Frames”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 Sceneconst 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.timeScalestays the persistent knob. The service reads it and never writes it.- Each
keynames a channel. Within a channel the latest active request wins; when it ends, an older still-active request applies again. A call without akeygets 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 astime.effectiveScale(withtime.isFrozenfor the ×0 case). Entity requests multiply intotime.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
fixedElapsedcan run ahead of itselapsedfrom the first frame. - A frame long enough to hit the loop’s fixed-step cap leaves its unrun steps
for the following frames, so
fixedElapsedfalls 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
fixedElapsedpermanently 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.
Per-Entity Time Scale
Section titled “Per-Entity Time Scale”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 speedentity.timeScale = 2; // ...or double itThe 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.
HUD Across Pause
Section titled “HUD Across Pause”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.
Scene Transitions
Section titled “Scene Transitions”Level Progression
Section titled “Level Progression”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());Dialog / Overlay
Section titled “Dialog / Overlay”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 overlayvoid scenes.push(new InventoryScene());
// Dismiss it (returns to game)void scenes.pop();Full Navigation Reset
Section titled “Full Navigation Reset”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());Cross-Scene Communication
Section titled “Cross-Scene Communication”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.
Pause on Tab Blur
Section titled “Pause on Tab Blur”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: falseDefault 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.