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:
engine.scenes.push(new GameScene()); // add on topengine.scenes.pop(); // remove top sceneengine.scenes.replace(new MenuScene()); // swap top sceneOnly 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:
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’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.
// 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 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.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.
- The result is
scene.timeScale × (channel winners), readable astime.effectiveScale(withtime.isFrozenfor 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 opensthis.windowOpenedAt = time.fixedElapsed;
// ...and later, also in fixedUpdateconst 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
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, and they are
not saved; re-issue them after loading a snapshot.
Per-Entity Time Scale
Section titled “Per-Entity Time Scale”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 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.
entity.timeScale is captured by the save system and restored on load.
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 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"); }}Scene Transitions
Section titled “Scene Transitions”Level Progression
Section titled “Level Progression”Use replace to swap the current scene for the next level:
// Transition to next level (old scene is destroyed)engine.scenes.replace(new Level2Scene());Dialog / Overlay
Section titled “Dialog / Overlay”Use push for overlays that should dismiss back to the previous scene:
// Show inventory overlayengine.scenes.push(new InventoryScene());
// Dismiss it (returns to game)engine.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.
await engine.scenes.popAll();await engine.scenes.push(new MainMenuScene());Cross-Scene Communication
Section titled “Cross-Scene Communication”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 scenethis.context.resolve(EventBusKey).emit(GamePaused);
// Listen from game scenethis.context.resolve(EventBusKey).on(GamePaused, () => { this.statusText.setText("PAUSED");});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.