Skip to content

Scenes

A scene is a self-contained slice of your game — a title screen, a gameplay level, a pause menu. Scenes own entities and receive lifecycle callbacks as they are pushed onto or popped from the scene stack.

Extend the Scene base class:

import { Scene, Transform, Vec2 } from "@yagejs/core";
class GameplayScene extends Scene {
readonly name = "gameplay";
readonly pauseBelow = true; // pause scenes underneath
readonly transparentBelow = false; // hide scenes underneath
onEnter() {
const player = this.spawn("player");
player.add(new Transform({ position: new Vec2(400, 300) }));
player.add(new PlayerController());
}
onExit() {
// cleanup when this scene is removed from the stack
}
onPause() {
// the scene became paused: a scene was pushed on top,
// `paused` was set, or the tab blurred with autoPauseOnBlur
}
onResume() {
// the scene is running again: the scene on top was popped,
// `paused` was cleared, or focus returned
}
}
PropertyDefaultPurpose
nameUnique identifier for the scene
pauseBelowtrueWhen true, scenes below this one stop updating. Set to false for HUD or overlay scenes that should let the underlying scene keep running.
transparentBelowfalseWhen true, scenes below this one still render. When false (the default), below-stack scene trees are hidden — both world layers and screen-space UI/HUD. (Detached scenes mounted via _mountDetached, e.g. the debug overlay, are not affected.)

A pause menu inherits the default pauseBelow: true and adds transparentBelow: true — the game world is visible but frozen underneath. A HUD overlay typically sets pauseBelow: false and transparentBelow: true so gameplay continues to tick under it.

transparentBelow composes through the stack: a below scene stays visible only while every scene above it has transparentBelow: true. As soon as one scene above is opaque (transparentBelow: false, the default), everything underneath it is hidden — world layers and any screen-space UI alike. While a scene transition is running both the outgoing and incoming scenes render regardless of the flag, so a crossFade push from a menu into a transparentBelow: false gameplay scene still dissolves cleanly; the visibility chain is reapplied when the transition ends.

The engine exposes a stack-based scene manager:

// Push a new scene onto the stack
engine.scenes.push(new GameplayScene());
// Pop the top scene
engine.scenes.pop();
// Replace the top scene (pop + push in one call)
engine.scenes.replace(new GameOverScene());
// Get the currently active (top) scene
const current = engine.scenes.active;
push(A) → A.onEnter()
push(B) → A.onPause() → B.onEnter()
pop() → B.onExit() → A.onResume()
replace(C) → B.onExit() → C.onEnter()

onPause and onResume fire on every effective pause transition, not just stack changes: setting scene.paused = true from game code, the manager’s autoPauseOnBlur pausing scenes on tab blur, and a snapshot restoring a scene that was saved paused all fire onPause; the matching un-pause fires onResume. The hooks track scene.isPaused — a write that does not change the effective state (re-assigning the same value, or flipping the flag while the scene is already paused by a scene above) fires nothing. Ducking music or muting input in onPause therefore covers manual pauses and tab blur, not only pushed pause menus.

To start a scene already paused, set scene.paused = true before pushing it — the push fires onPause once. Don’t write scene.paused from inside a lifecycle hook (onEnter, onExit, onPause, onResume): that write races the stack transition’s own pause diff, so the hooks can fire twice or unpaired. A dev-mode warning flags this case.

Scenes provide helpers to find entities:

// Find an entity by name (returns first match or undefined)
const player = scene.findEntity("player");
// Find all entities with a given tag
const enemies = scene.findEntitiesByTag("enemy");
// Get all entities in the scene
const all = scene.getEntities();

Scenes are always classes. Override onEnter to spawn entities, resolve services, and wire up the scene:

import { Component, Scene, SceneManagerKey, Transform, Vec2 } from "@yagejs/core";
import { SpriteComponent, texture } from "@yagejs/renderer";
import { InputManagerKey } from "@yagejs/input";
const LogoTex = texture("assets/logo.png");
// Per-frame input polling lives in a Component, not in the Scene.
class TitleController extends Component {
private readonly input = this.service(InputManagerKey);
private readonly scenes = this.service(SceneManagerKey);
update(): void {
if (this.input.isJustPressed("start")) {
this.scenes.replace(new GameplayScene());
}
}
}
class TitleScene extends Scene {
readonly name = "title";
onEnter() {
const logo = this.spawn("logo");
logo.add(new Transform({ position: new Vec2(640, 200) }));
logo.add(new SpriteComponent({ texture: LogoTex }));
this.spawn("controller").add(new TitleController());
}
}
engine.scenes.push(new TitleScene());

push, pop, replace, and popAll are safe to call from inside any scene lifecycle hook — onEnter, onExit, onPause, onResume, or a registered beforeEnter / afterExit hook. The call is queued on the manager’s internal pending chain and runs after the current mutation finishes. The returned promise resolves once the deferred operation completes.

class TitleScene extends Scene {
readonly name = "title";
onEnter() {
// Skip the title screen if a saved game exists — fully safe inside onEnter.
if (saveSystem.hasAutosave()) {
this.context.resolve(SceneManagerKey).replace(new GameplayScene());
}
}
}

In dev builds you’ll see a console.warn when this fires. Reentrant swaps are usually a smell (an onEnter that immediately replaces the scene rarely matches intent, and the dropped-on-the-floor promise can hide errors), so the engine flags it — but it works correctly. Prefer doing the redirect from a component’s update() or a one-shot Process for new code.