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() {
// another scene was pushed on top
}
onResume() {
// the scene on top was popped, this scene is active again
}
}
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()

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.