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.

Every scene is a subclass of the Scene base class:

import { Entity, Scene, Transform, Vec2 } from "@yagejs/core";
// An entity type: setup() adds its components.
class Player extends Entity {
setup(params: { position: Vec2 }) {
this.add(new Transform({ position: params.position }));
// ...and the components that hold its logic
}
}
class GameplayScene extends Scene {
readonly name = "gameplay";
readonly pauseBelow = true; // pause scenes underneath
readonly transparentBelow = false; // hide scenes underneath
onEnter() {
// Spawn what the scene starts with. Player's components hold its logic.
this.spawn(Player, { position: new Vec2(400, 300) });
}
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
}
}

onEnter() assembles the scene: it spawns the entities, sets up the camera, and starts the music. It holds no game state and no rules. Player is an entity type, an Entity subclass whose setup() adds its components. Game logic lives in those components. See Entities & Components for both.

Four hooks cover entering, exiting and pausing, and onProgress(ratio) reports asset preload progress from 0 to 1 while the scene’s preload list loads. That is every hook Scene declares:

HookFires
onProgress(ratio)During preloading: with 0 before the first asset loads, then once as each asset in preload resolves
onEnter()Once preloading finishes and the scene is on the stack
onExit()When the scene is popped or replaced
onPause()When the scene becomes effectively paused
onResume()When the scene stops being effectively paused

Subclasses in the engine can add to that set: LoadingScene declares onLoadError(error) for a preload that fails.

A scene has no per-frame hook. update and fixedUpdate are typed as never on Scene, so a subclass that declares either name fails to compile. The per-frame pass ticks components, which leaves two homes for scene-level per-frame logic:

import {
Component,
Entity,
Process,
ProcessSystemKey,
Scene,
Transform,
defineEvent,
makeSceneScopedQueue,
} from "@yagejs/core";
const RoundOver = defineEvent("arena:round-over");
// The backdrop's per-frame drift is a component on its entity.
class ParallaxDrift extends Component {
update(dt: number): void {
// shift the backdrop a few pixels by dt
}
}
class Backdrop extends Entity {
setup() {
this.add(new Transform());
this.add(new ParallaxDrift());
}
}
class ArenaScene extends Scene {
readonly name = "arena";
onEnter() {
this.spawn(Backdrop);
// A queue that pauses and ends with the scene runs the round timer.
// Components that end the round listen for RoundOver.
const queue = makeSceneScopedQueue(this.use(ProcessSystemKey), this);
queue.run(Process.delay(60, () => this.emit(RoundOver)));
}
}

An Entity subclass carries the same two never declarations, for the same reason: put the logic in a component the entity holds.

PropertyDefaultPurpose
name—Unique 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. The debug overlay is not on the scene stack, so it stays visible.

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:

class GameOverScene extends Scene {
readonly name = "game-over";
}
// 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.onProgress(ratio)… → A.onEnter()
push(B) → B.onProgress(ratio)… → B.onEnter() → A.onPause()
pop() → B.onExit() → A.onResume()
replace(C) → C.onProgress(ratio)… → B.onExit() → C.onEnter()

Every push and every replace preloads the scene it is entering before onEnter. onProgress repeats while that scene’s preload assets load, and is skipped for a scene with nothing to preload. No step of this flow is per-frame.

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 scene stack changes all use the same hooks. 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();

scene.findEntities(filter) matches on several criteria at once — name, tags, trait, and a filter predicate — and an entity has to pass every one of them that the call supplies:

import { Transform } from "@yagejs/core";
const closeEnemies = scene.findEntities({
tags: ["enemy"],
filter: (e) => e.get(Transform).position.x < 400,
});

trait is the one field that changes the result type. Given a TraitToken<T>, the call returns (Entity & T)[], so the trait’s own members read off each result without a cast:

import { defineTrait } from "@yagejs/core";
const Openable = defineTrait<{ open(): void }>("Openable");
for (const chest of scene.findEntities({ trait: Openable })) {
chest.open(); // typed through the trait
}

The filter predicate narrows nothing. Its type is (entity: Entity) => boolean, which picks entities and says nothing about their type, so a call carrying one still returns Entity[]. See Traits for how Openable is declared.

Scenes are always Scene subclasses. Override onEnter to spawn entities and resolve services. Game logic, such as reading input, goes in a component on an entity the scene spawns.

An entity that is spawned once and has no behaviour of its own, such as a logo or a background, does not need an entity class. Spawn it by name and add its components. An entity with behaviour, or one you spawn more than once, is an Entity subclass:

import {
Component,
Entity,
Scene,
SceneManagerKey,
Transform,
Vec2,
} from "@yagejs/core";
import { SpriteComponent, texture } from "@yagejs/renderer";
import { InputManagerKey } from "@yagejs/input";
const LogoTex = texture("assets/logo.png");
class GameplayScene extends Scene {
readonly name = "gameplay";
}
// 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 TitleMenu extends Entity {
setup() {
this.add(new TitleController());
}
}
class TitleScene extends Scene {
readonly name = "title";
onEnter() {
// Spawned once, no behaviour of its own: a named spawn is enough.
const logo = this.spawn("logo");
logo.add(new Transform({ position: new Vec2(640, 200) }));
logo.add(new SpriteComponent({ texture: LogoTex }));
this.spawn(TitleMenu);
}
}
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.

import { Scene, SceneManagerKey } from "@yagejs/core";
class GameplayScene extends Scene {
readonly name = "gameplay";
}
class TitleScene extends Scene {
readonly name = "title";
constructor(private readonly skipToGame: boolean) {
super();
}
onEnter() {
// Skip the title screen — fully safe inside onEnter.
if (this.skipToGame) {
void this.use(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.

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/core-concepts.md.