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:
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 scene. Use setScene to swap in a new scene:
// Replace the active scene with a new one
await app.scene.setScene(new GameScene());
// Replace with a fade transition
await app.scene.setScene(new GameScene(), { transition: { type: 'fade' } });
setScene ends the current scene — running unload and destroy — and starts the new one. The optional transition argument adds a brief cross-fade so the cut is not jarring.
For a typical app: a MenuScene calls setScene(new GameScene()) when the player clicks Start. app.scene.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.
import { Label, ProgressBar, Scene } from '@codexo/exojs';
class GameScene extends Scene {
init() {
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 — set scene.paused = true. The SceneManager skips update() and the scene’s systems while paused, but the scene keeps drawing. Set it back to false to resume.
A pause overlay is just nodes on scene.ui that you show and hide together with scene.paused:
import { Keyboard, Label, Panel, Scene } from '@codexo/exojs';
class GameScene extends Scene {
init() {
// 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() {
this.paused = !this.paused;
this.pausePanel.visible = this.paused;
this.pauseLabel.visible = this.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(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:
fixedUpdate(delta)— zero or more deterministic steps with a constantdelta, run beforeupdate.update(delta)— advance state.delta.secondsis the elapsed time since the last frame.draw(context)— render the current state.
When the scene is replaced or the application shuts down:
async unload(loader)— 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:
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 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:
fixedUpdate(delta) {
this.world.step(delta.seconds);
}
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:
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.seconds) 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.
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.
fixedUpdate() {
this.hero.prevX = this.hero.x;
this.world.step(1 / 60); // moves the body; the binding updates this.hero.x
}
draw(context) {
const alpha = this.app.frameAlpha;
const renderX = this.hero.prevX + (this.hero.x - this.hero.prevX) * alpha;
this.hero.setPosition(renderX, this.hero.y);
context.backend.clear();
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:
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, () => {
this.togglePause();
});
}
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 is replaced by setScene or when the application shuts down. 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.
Examples
Switching between two scenes — a menu and a game — using app.scene.setScene.
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.
scene.paused gates 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
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.