Guide

GuideRuntimeScenes & lifecycle

Scenes & lifecycle

How scenes split runtime work, switch with transitions, layer a UI on top, and the order their lifecycle hooks run in.

Intro~9 min read

What you'll learn

  • split a project into focused scenes and switch between them at runtime
  • order work across load, init, update, and draw
  • separate state updates from rendering
  • release resources in destroy

Before you start

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:

examples/guides/scenes-and-lifecycle/title-scene.ts
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:

examples/guides/scenes-and-lifecycle/title-scene.ts
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:

examples/guides/scenes-and-lifecycle/scene-switching.ts
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.

examples/guides/scenes-and-lifecycle/hud-layer.ts
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:

examples/guides/scenes-and-lifecycle/pause-overlay.ts
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:

  1. async load() — declare assets you need via this.loader. Resolves before init.
  2. init(data) — build state. Must be synchronous. Loaded assets are available via this.loader.get(...); activation data (if the scene declares a data type) arrives here.

Then, every frame while the scene is active:

  1. fixedUpdate(delta) — zero or more deterministic steps with a constant delta, run before update.
  2. update(delta) — advance state. delta is the elapsed time since the last frame.
  3. 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:

  1. async unload() — release scene-private assets that aren’t needed by the next scene.
  2. 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:

examples/guides/scenes-and-lifecycle/load-assets.ts
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:

examples/guides/scenes-and-lifecycle/game-scene.ts
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:

examples/guides/scenes-and-lifecycle/game-scene.ts
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.

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.

examples/guides/scenes-and-lifecycle/frame-alpha.ts
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:

examples/guides/scenes-and-lifecycle/game-scene.ts
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.

examples/guides/scenes-and-lifecycle/game-scene.ts
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.

The trade-off is that you can render selectively when you need to:

examples/guides/scenes-and-lifecycle/selective-draw.ts
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:

examples/guides/scenes-and-lifecycle/game-scene.ts
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.

Examples

Multiple ScenesPointerKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, Text } from '@codexo/exojs';

class MenuScene extends Scene {
  private label!: Text;
  private onTap!: () => void;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    // Each scene owns its background: `init` runs once per activation, so
    // navigating back and forth repaints the frame in this scene's colour.
    app.clearColor.set(18, 38, 72, 1);

    this.label = new Text('MENU\nClick to Start', { align: 'center', fillColor: Color.white, fontSize: 34, fontWeight: 'bold' });
    this.label.setAnchor(0.5);
    this.label.setPosition(width / 2, height / 2);

    this.inputs.onTrigger(Keyboard.Space, () => {
      void app.scenes.change(GameScene);
    });

    this.onTap = () => {
      void app.scenes.change(GameScene);
    };
    app.input.onPointerTap.add(this.onTap);
  }

  override draw(context: RenderingContext): void {
    context.render(this.label);
  }

  override destroy(): void {
    const app = this.app;
    app.input.onPointerTap.remove(this.onTap);
    super.destroy();
  }
}

class GameScene extends Scene {
  private label!: Text;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    app.clearColor.set(24, 72, 42, 1);

    this.label = new Text('GAME\nEsc to Menu', { align: 'center', fillColor: Color.white, fontSize: 34, fontWeight: 'bold' });
    this.label.setAnchor(0.5);
    this.label.setPosition(width / 2, height / 2);

    this.inputs.onTrigger(Keyboard.Escape, () => {
      void app.scenes.change(MenuScene);
    });
  }

  override draw(context: RenderingContext): void {
    context.render(this.label);
  }
}

const app = new Application({
  scenes: { MenuScene, GameScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(MenuScene);

Switching between two scenes — a menu and a game — using app.scenes.change.

Scene LifecycleOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Text, Time, Timer } from '@codexo/exojs';

// The scene lifecycle hooks, in the order the engine calls them:
//   - `async load()`   - one-shot async setup, called once before `init()`.
//                        Fetch/await assets here (`await this.loader.load(...)`),
//                        then build the scene graph in `init()`.
//   - `init()`         - one-shot sync setup, called once `load()` resolves.
//                        Must be synchronous - async work belongs in `load()`.
//   - `destroy()`      - one-shot teardown, called once when the scene ends
//                        permanently.
// `fixedUpdate`/`update`/`draw` run every frame in between.
// Two signals bracket the same span from the outside: `onActivate` fires
// every time the scene transitions into `Active` (fresh activation, a
// consumed preload, or a restore from retention) and `onSuspend` fires when
// the scene is suspended for retention (not on permanent teardown) - a hook
// point for cross-cutting concerns (audio cues, analytics, HUD toggles) that
// shouldn't live inside `init`/`destroy` themselves.
class LifecycleScene extends Scene {
  private events!: string[];
  private counter = 0;
  private drawCount = 0;
  private timer!: Timer;
  private text!: Text;

  override async load(): Promise<void> {
    // This scene is procedural - nothing to fetch - but a real scene would
    // resolve its assets here before touching the scene graph, e.g.:
    //   const data = (await this.loader.load(Asset.type('json', 'level.json'))) as LevelData;
    this.events = ['load'];
  }

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.events.push('init');

    this.onActivate.add(() => {
      this.events.push('onActivate');
    });

    this.onSuspend.add(() => {
      this.events.push('onSuspend');
    });

    this.timer = new Timer(Time.seconds(1), true);

    this.text = new Text('', { fillColor: Color.white, fontSize: 18 });
    this.text.setAnchor(0.5);
    this.text.setPosition(width / 2, height / 2);
  }

  override update(): void {
    if (this.timer.expired) {
      this.counter++;
      this.events.push(`update ${this.counter}`);
      this.timer.restart();
    }
  }

  override draw(context: RenderingContext): void {
    this.drawCount++;
    this.text.text = [...this.events.slice(-8), `draw ${this.drawCount}`].join('\n');
    context.render(this.text);
  }

  override destroy(): void {
    // destroy() is the final teardown hook - no separate unload() step
    // needed here since this scene holds no scene-private assets.
    this.events.push('destroy');
  }
}

const app = new Application({
  scenes: { LifecycleScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(LifecycleScene);

A walkthrough of every hook firing, in order, with on-screen logging.

Hud Overlay SceneOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, Label, ProgressBar, type RenderingContext, Scene, type Seconds } from '@codexo/exojs';

/**
 * A screen-fixed HUD on `scene.ui` sits above the world automatically - no
 * separate overlay scene or stack. The world (a spinning arc) is drawn from
 * `scene.root`; the HUD (a label + a live health bar) lives on `scene.ui` and
 * is auto-rendered on top.
 */
class GameScene extends Scene {
  private angle = 0;
  private time = 0;
  private ring!: Graphics;
  private health!: ProgressBar;

  override init(): void {
    this.ring = new Graphics();

    const title = new Label('HUD Overlay', { fontSize: 22 });
    title.anchorIn(this.ui, 'top-left', 18, 14);
    this.ui.addChild(title);

    this.health = new ProgressBar({ width: 240, height: 12, value: 1 });
    this.health.anchorIn(this.ui, 'top-left', 18, 48);
    this.ui.addChild(this.health);
  }

  override update(delta: Seconds): void {
    this.angle += delta * 90;
    this.time += delta;
    this.health.value = (Math.sin(this.time) + 1) / 2;
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    const { width, height } = app;

    this.ring.clear();
    this.ring.lineWidth = 20;
    this.ring.lineColor = new Color(90, 180, 255);
    this.ring.drawArc(width / 2, height / 2, 160, 0, (this.angle * Math.PI) / 180);
    context.render(this.ring);
  }
}
const app = new Application({
  scenes: { GameScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(20, 32, 58),
});

void app.start(GameScene);

A screen-fixed HUD on scene.ui — a label and a live health bar above the world, no separate overlay scene required.

Pause and ResumePointerKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Text } from '@codexo/exojs';

class PauseResumeScene extends Scene {
  private sprite!: Sprite;
  private label!: Text;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.sprite = new Sprite(this.loader.get('image/ship-a.png'));
    this.sprite.setAnchor(0.5);
    this.sprite.setPosition(width / 2, height / 2);

    this.label = new Text('Space or click: pause update', { fillColor: Color.white, fontSize: 16 });
    this.label.setAnchor(0.5, 0);
    this.label.setPosition(width / 2, 16);

    this.inputs.onTrigger(Keyboard.Space, () => {
      this.toggle();
    });

    // Same toggle on click/tap so the pause works without a keyboard.
    app.input.onPointerTap.add(() => {
      this.toggle();
    });
  }

  private toggle(): void {
    if (this.app.scenes.paused) {
      this.app.scenes.resume();
    } else {
      this.app.scenes.pause();
    }

    this.label.text = this.app.scenes.paused ? 'Paused (draw running)' : 'Running';
  }

  override update(delta: Seconds): void {
    this.sprite.rotate(delta * 180);
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite);
    context.render(this.label);
  }
}

const app = new Application({
  scenes: { PauseResumeScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(PauseResumeScene);

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.

Game LoopOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs';

class GameLoopScene extends Scene {
  private sprite!: Sprite;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.sprite = new Sprite(this.loader.get('image/ship-a.png'));
    this.sprite.setAnchor(0.5);
    this.sprite.setPosition(width / 2, height / 2);
  }

  override update(delta: Seconds): void {
    this.sprite.rotate(delta * 120);
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite);
  }
}

const app = new Application({
  scenes: { GameLoopScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(GameLoopScene);

The minimum lifecycle that produces motion: init builds the sprite, update rotates it, draw renders.

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.