Scenes & lifecycle
How scenes split runtime work, switch with transitions, layer a UI on top, 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. The application keeps running as you switch between scenes.
Defining a scene
A scene is a class that extends Scene. Override the hooks you need; leave the rest at their defaults:
class TitleScene extends Scene {
override init(): void {
// build state
}
override update(delta: Seconds): void {
// per-frame logic
}
override draw(context: RenderingContext): void {
// per-frame rendering
context.render(this.root);
}
}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
Register the scene’s constructor and hand it to app.start; the application takes over:
const app = new Application({ scenes: { TitleScene } });
await app.start(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 director (app.scenes) that drives the active scene. Use change to swap in a new scene, and register scene constructors up front so change knows which classes are valid targets:
const app = new Application({ scenes: { GameScene } });
// Replace the active scene with a fresh instance
await app.scenes.change(GameScene);
// Replace with a fade transition
await app.scenes.change(GameScene, { transition: new FadeSceneTransition() });change ends the current scene — running unload and destroy — and starts a fresh instance of the target. The optional transition option (a SceneTransition instance, e.g. FadeSceneTransition) adds a brief cross-fade so the cut is not jarring.
For a typical app: a MenuScene calls this.app.scenes.change(GameScene) when the player clicks Start. app.scenes.currentScene always reflects the currently active scene.
Scene UI layer
Every scene has a built-in scene.ui layer that sits screen-fixed above the scene’s world content. Nodes added to scene.ui are always rendered on top — no explicit render call in draw required — and they are anchored relative to the canvas, not the world.
class GameScene extends Scene {
private healthBar!: ProgressBar;
override init(): void {
const title = new Label('Score: 0', { fontSize: 22 });
title.anchorIn(this.ui, 'top-left', 18, 14);
this.ui.addChild(title);
this.healthBar = new ProgressBar({ width: 240, height: 12, value: 1 });
this.healthBar.anchorIn(this.ui, 'top-left', 18, 48);
this.ui.addChild(this.healthBar);
}
}anchorIn(this.ui, anchor, offsetX, offsetY) positions a node relative to a named corner or edge of the UI container. Available anchors: 'top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right'. The offsets are in pixels.
Available UI widgets: Panel, Button, Label, ProgressBar, Stack. Wire up button interactions with button.onClick.add(callback).
Pausing and overlays
To freeze a scene without leaving it — the canonical pause menu — call app.scenes.pause(). It stops update() and the scene’s systems, but the scene keeps drawing. Call app.scenes.resume() to resume.
A pause overlay is just nodes on scene.ui that you show and hide in response to the state change:
class GameScene extends Scene {
private pausePanel!: Panel;
private pauseLabel!: Label;
override init(): void {
// Pause overlay - hidden until paused
this.pausePanel = new Panel({ width: 360, height: 120, cornerRadius: 12 });
this.pausePanel.anchorIn(this.ui, 'center');
this.pausePanel.visible = false;
this.ui.addChild(this.pausePanel);
this.pauseLabel = new Label('PAUSED', { fontSize: 48, fontWeight: 'bold' });
this.pauseLabel.anchorIn(this.ui, 'center');
this.pauseLabel.visible = false;
this.ui.addChild(this.pauseLabel);
this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause());
}
togglePause(): void {
if (this.app.scenes.paused) {
this.app.scenes.resume();
} else {
this.app.scenes.pause();
}
const paused = this.app.scenes.paused;
this.pausePanel.visible = paused;
this.pauseLabel.visible = paused;
}
}Because draw still runs while paused, the world stays visible behind the overlay. Tweens and app-level systems that do not belong to the scene (for example, a blur tween running on this.tweens) continue animating while the scene is frozen.
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()— declare assets you need viathis.loader. Resolves beforeinit.init(data)— build state. Must be synchronous. Loaded assets are available viathis.loader.get(...); activation data (if the scene declares a data type) arrives here.
Then, every frame while the scene is active:
fixedUpdate(delta)— zero or more deterministic steps with a constantdelta, run beforeupdate.update(delta)— advance state.deltais the elapsed time since the last frame.draw(context)— render the current state.
Two signals bracket activation from the outside rather than being hooks you override: Scene.onActivate fires every time the scene becomes Active (fresh start, a consumed preload, or a restore from retention); Scene.onSuspend fires when the scene is suspended for retention instead of ended permanently.
When the scene is replaced or the application shuts down:
async unload()— release scene-private assets that aren’t needed by the next 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:
class GameScene extends Scene {
override async load(): Promise<void> {
await Promise.all([this.loader.load('image/hero.png'), this.loader.load('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. Read a previously-loaded asset via this.loader:
override init(): void {
this.hero = new Sprite(this.loader.get('image/hero.png'));
this.hero.setAnchor(0.5);
this.hero.setPosition(400, 300);
this.addChild(this.hero);
}The init hook runs once per scene-start. If you start the scene again later it runs again on the new instance, not on the old one.
fixedUpdate: deterministic steps
update runs once per real frame, so its delta varies with the display’s refresh rate and any hitches. fixedUpdate is the other logic hook: the engine runs it a constant number of times per second — independent of frame rate — before update:
override fixedUpdate(delta: Seconds): void {
this.world.step(delta);
}Behind the scenes, the application accumulates each frame’s elapsed time and drains it in fixed-size chunks — 1 / 60 s by default, configurable via fixedTimeStep (seconds) on the Application constructor options:
import { Application } from '@codexo/exojs';
const app = new Application({ fixedTimeStep: 1 / 30 });
At 60 fps, that is one fixedUpdate call per frame, tracking update one-to-one. At 144 fps, some frames get zero calls and others get one, averaging out to the configured rate. On a stalled frame (a GC pause, a dropped tab), it can run several calls back-to-back so simulation time is never silently lost — capped at an internal maximum so a single long freeze cannot spiral into minutes of catch-up.
Because the step size never changes, fixedUpdate is the right place for anything that must behave identically regardless of frame rate — physics stepping is the headline case; see Physics basics for a full world.step(delta) walkthrough. Leave camera movement, UI, and anything purely visual in update: running those at a fixed rate would make them look choppy on high-refresh-rate displays instead of smoother.
Which hook for what
Deterministic simulation — physics, anything that must behave identically across frame rates — belongs in fixedUpdate. Keep camera, UI and purely visual motion in update; a fixed rate makes those look choppy on high-refresh displays.
frameAlpha: smoothing between steps
Because fixedUpdate calls don’t line up one-to-one with rendered frames, there is always a small unrendered remainder — the fraction of the current fixed step already elapsed since the last one ran. The application exposes it as app.frameAlpha, a value in [0, 1).
frameAlpha is not applied automatically — the scene graph never interpolates itself. It exists for you to read and use if you want smooth visual motion between fixed steps: keep the previous and current fixed-step positions and lerp between them by frameAlpha when you draw.
private heroPreviousX = 0;
override fixedUpdate(): void {
this.heroPreviousX = this.hero.x;
this.world.step(1 / 60); // moves the body; the binding updates this.hero.x
}
override draw(context: RenderingContext): void {
const alpha = this.app.frameAlpha;
const renderX = this.heroPreviousX + (this.hero.x - this.heroPreviousX) * alpha;
this.hero.setPosition(renderX, this.hero.y);
context.render(this.root);
}If you don’t need buttery-smooth motion, ignore frameAlpha — most scenes never touch it. It matters once the fixed step is coarser than the display’s frame rate (say fixedTimeStep: 1 / 30 on a 144 Hz screen), where skipping interpolation shows up as visible stutter on fast-moving objects.
update: per-frame logic
The update hook runs once per frame, before draw. The argument carries the elapsed time since the previous frame:
override update(delta: Seconds): void {
this.hero.rotate(120 * delta);
}Multiplying by delta 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.
override draw(context: RenderingContext): void {
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.
An empty draw draws nothing
ExoJS never auto-renders the tree. If draw is missing or omits context.render(this.root), the frame clears to a blank canvas — with no error to point you at the cause.
The trade-off is that you can render selectively when you need to:
override draw(context: RenderingContext): void {
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 system. Register bindings in init:
private bindInput(): void {
this.inputs.onTrigger(Keyboard.Space, () => {
this.player.jump();
});
this.inputs.onTrigger(Keyboard.Escape, () => {
this.app.scenes.pause();
});
}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 ends permanently — replaced by change/restore, discarded via unload(Target), or the application shuts down (not when the scene is only suspended for retention). Use it to release assets the scene was holding that no other 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.
Keep the built-in cleanup when you override destroy
The default destroy disposes scene-owned input bindings and graph references. Override it without running that built-in path and those bindings and references leak. Add your own cleanup, but keep the default.
Examples
Switching between two scenes — a menu and a game — using app.scenes.change.
A walkthrough of every hook firing, in order, with on-screen logging.
A screen-fixed HUD on scene.ui — a label and a live health bar above the world, no separate overlay scene required.
app.scenes.pause()/resume() gate update; draw keeps running. A pause overlay on scene.ui is toggled in sync so the player sees a menu while the world freezes.
The minimum lifecycle that produces motion: init builds the sprite, update rotates it, draw renders.
Try it
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.


