Scenes & lifecycle
How scenes split runtime work, compose into a stack, and the order their lifecycle hooks run in.
Scenes & lifecycle
A Scene is the unit of runtime work. It loads the resources it needs, builds the objects it draws, advances state each frame, and renders the result. Most game code lives inside scene subclasses.
The split between application and scene matters: the application is the long-lived host that owns the canvas, the renderer, the input system, and the frame loop. Scenes are the changeable layer on top — menus, levels, cutscenes, pause overlays. The application keeps running as you push, pop, and replace scenes.
Defining a scene
A scene is a class that extends Scene. Override the hooks you need; leave the rest at their defaults:
import { Scene } from '@codexo/exojs';
class TitleScene extends Scene {
init() {
// build state
}
update(delta) {
// per-frame logic
}
draw(context) {
// per-frame rendering
context.backend.clear();
}
}
The application calls these hooks for you. You don’t wire up requestAnimationFrame or manage your own loop — the scene tells the engine what to do at each phase, the engine schedules it.
Scene structure stays in your code. State lives on this, organized however your project prefers — fields on the scene class for top-level state, helper objects for subsystems, or composition with your own classes. ExoJS does not impose a particular pattern.
Running a scene
Hand a scene instance to app.start and the application takes over:
const app = new Application();
app.start(new TitleScene());
From this point on, the application runs the scene’s update and draw once per frame.
Switching scenes
Most non-trivial projects have more than one scene. The application owns a scene manager (app.scene) that drives the active stack. Three operations cover the common cases:
// Replace the active scene with a new one
await app.scene.setScene(new GameScene());
// Stack a new scene on top (pause menu, dialog, modal)
await app.scene.pushScene(new PauseScene());
// Remove the top scene and return to the one below
await app.scene.popScene();
Calling setScene ends the current scene and starts the new one. pushScene keeps the underlying scene loaded and overlays the new one. popScene removes the top scene and resumes the one below.
For a typical app: a MenuScene calls setScene(new GameScene()) when the player clicks Start. The game pushes new PauseScene() when Esc is hit, and pops it when the player resumes.
How stacked scenes interact
When more than one scene is on the stack, each scene declares how it composes with the scenes below it:
'overlay'(default) — both render and update; the scene below stays active.'modal'— the scene below renders but does not update. Use for dialog boxes that should pause world physics.'opaque'— the scene below neither renders nor updates. Use when the top scene fully covers the viewport.
Set the policy when you push a scene, or change it later via scene.stackMode:
const pause = new PauseScene();
pause.stackMode = 'modal';
await app.scene.pushScene(pause);
The lifecycle hooks, in order
A scene has a small set of hooks the engine calls in a defined order. Knowing which hook runs when keeps state setup clean and avoids subtle “this object is undefined here” bugs.
The first time a scene starts:
async load(loader)— declare assets you need. Resolves beforeinit.init(loader)— build state. Loaded assets are available vialoader.get(...).
Then, every frame while the scene is active:
update(delta)— advance state.delta.secondsis the elapsed time since the last frame.draw(context)— render the current state.
When the scene is removed from the stack:
async unload(loader)— release scene-private assets that aren’t shared with another active scene.destroy()— drop scene-graph references and cancel input bindings.
You override the hooks you need. Empty hooks like update and draw do nothing by default, so there is no setup ceremony for scenes that only need one or two phases. Cleanup hooks are different: if you override destroy, keep the built-in cleanup path intact unless the API reference for your version says otherwise.
load: declare assets
The load hook is the async setup hook. Use it to register everything the scene needs and await the loader:
import { Scene, Texture } from '@codexo/exojs';
class GameScene extends Scene {
async load(loader) {
await loader.load(Texture, {
hero: 'image/hero.png',
ground: 'image/ground.png',
});
}
}
The application doesn’t call init until this promise resolves, so by the time you reach init everything you declared is ready.
init: build state
The init hook is where you create the scene’s actual objects. The same loader instance is passed in; calling loader.get(...) returns the already-loaded asset:
init(loader) {
this.hero = new Sprite(loader.get(Texture, 'hero'));
this.hero.setAnchor(0.5);
this.hero.setPosition(400, 300);
this.addChild(this.hero);
}
The init hook runs once per scene-start. If you push the scene again later it runs again on the new instance, not on the old one.
update: per-frame logic
The update hook runs once per frame, before draw. The argument carries the elapsed time since the previous frame:
update(delta) {
this.hero.rotate(120 * delta.seconds);
}
Multiplying by delta.seconds keeps motion frame-rate independent. The same code runs identically at 60 fps and 144 fps.
draw: explicit rendering
This is the hook with the most surprising default: draw does nothing if you don’t override it. The engine never auto-traverses the scene graph for you.
draw(context) {
context.backend.clear();
context.render(this.root);
}
The this.root container is the scene’s structural anchor — every node you add via this.addChild(...) is reachable from there. Calling context.render(this.root) walks the tree and draws every visible node in order.
The trade-off is that you can render selectively when you need to:
draw(context) {
context.backend.clear();
context.render(this.world);
if (this.showHud) {
context.render(this.hud);
}
}
For most scenes, one context.render(this.root) after clear is the right pattern. Reach for selective rendering when you have a reason — debug overlays you toggle, layers that sometimes skip a frame, render-to-texture targets.
When a frame grows into a sequence of distinct phases — render the world, capture it off-screen, composite lighting, draw a HUD, layer in a debug overlay — you can keep that order as data instead of imperative draw code. A RenderPipeline holds an ordered, individually toggleable (pass.enabled), freely nestable list of passes and runs them with a single pipeline.execute(context) call from draw. It is general frame composition, not only a post-processing tool; the Post-processing chapter introduces it in depth.
Input
Scenes receive input through this.inputs, which proxies to the application’s input manager. Register bindings in init:
init() {
this.inputs.onTrigger(Keyboard.Space, () => {
this.player.jump();
});
this.inputs.onTrigger(Keyboard.Escape, async () => {
await this.app.scene.pushScene(new PauseScene());
});
}
Bindings created on this.inputs are automatically disposed when the scene is destroyed — no manual cleanup required.
unload and destroy
The unload hook runs when the scene leaves the stack permanently. Use it to release assets the scene was holding that no other active scene needs.
The destroy hook is the synchronous final step after unload. The default cleanup path disposes scene-owned bindings and graph references. Override only when you have additional cleanup, and keep the built-in cleanup behavior intact.
For overlay scenes (pause menus, dialogs) that you push and pop repeatedly, the default cleanup keeps things lean: you don’t need to manually unload textures shared with the underlying game scene.
Examples
Switching between two scenes — a menu and a game — using the scene manager.
A walkthrough of every hook firing, in order, with on-screen logging.
The update hook is gated; draw keeps running. Demonstrates that the loop continues even when scene state freezes.
The minimum lifecycle that produces motion: init builds the sprite, update rotates it, draw renders.
Try it
Playground
Where to go next
The next chapter, Scene graph, covers how parent-child relationships work — what this.addChild(...) actually does, how transforms cascade, and how to keep the tree composable.