Guide

GuideShippingTroubleshooting

Troubleshooting

Diagnose and fix the most common ExoJS problems: blank canvas, API errors, missing assets, audio, input, and performance.

Intro~9 min read

Troubleshooting

This chapter covers the issues that come up most often when starting with ExoJS or upgrading from an older snippet. Work through the relevant section, check the linked guides for more detail, and open the playground examples to see the correct pattern in action.

Symptom → cause → fix

Start here. Find the symptom that matches what you see, then jump to the section with the full explanation and fix.

Symptom Likely cause Section
Blank or black canvas App not started, missing context.render, or content off-screen Canvas shows nothing
… is not a function from copied code Pre-0.9 API in an old snippet Old API errors
Runs on WebGL2, never WebGPU WebGPU unavailable in this browser/OS/GPU WebGPU unavailable
Asset 404s or never appears Wrong path, public/ prefix, or case mismatch Assets not loading
Code runs but no sound Audio started before a user gesture Audio does not start
Keys or gamepad ignored Canvas not focused, or input read outside update Input does not respond
Text blurry, missing, or unstyled Font not loaded, wrong style key, or DPR scaling Text or fonts look wrong
Low FPS or stutter Too many drawables, filters, or per-frame allocations Performance problems

Canvas shows nothing

A blank or black canvas usually means one of the following:

The application is not started. Register the scene in ApplicationOptions.scenes, then call app.start(SceneConstructor) after creating the application.

The scene is not active. app.start() without a target starts the frame loop but does not activate a scene. Pass a registered scene constructor or key, or navigate later with app.scenes.change(...).

The canvas is not visible. Check that app.element is mounted in the DOM and has non-zero dimensions. If the canvas element is in a display: none container or has zero width/height via CSS, nothing renders.

context.render() is missing from draw. The engine clears the frame for you, but it never renders your scene graph for you — draw has to say what to draw:

examples/guides/troubleshooting/draw-hook.ts
override draw(context: RenderingContext): void {
  context.render(this.root);
}

Without context.render(this.root), the frame is cleared and nothing else happens, so the canvas stays a flat clearColor.

Objects are not added to the root. Sprites and containers only appear if they are part of the rendered tree. Add them to this.root or to a container that is itself in the root.

Objects are outside the visible area. The default view maps world coordinates to the canvas. A sprite at (10000, 10000) on a 800×600 canvas is off-screen. Check position, scale, and anchor values.

See Your first scene for a minimal working example.

Old API errors from older snippets

Pre-0.9 ExoJS snippets commonly use an older draw signature. If you copied code from an older tutorial or GitHub issue, it may look like this:

// old (0.8.x and earlier) — do not use
draw(backend) {
    backend.clear();
    sprite.render(backend);
}

The current API passes a render context, not a bare backend, and renders nodes through the context rather than calling render on the node itself. The manual clear is gone too — the engine clears each frame before draw runs:

examples/guides/troubleshooting/draw-hook.ts
override draw(context: RenderingContext): void {
  context.render(this.root);
}

Current constructor options are grouped under canvas, loader, rendering, and input; see the Application guide for the supported shape.

WebGPU unavailable

WebGPU is not available in all environments. Availability depends on the browser, OS, GPU driver, and whether the hardware accelerated path is enabled.

Current availability (as of 2025):

  • Chrome / Edge 113+ on Windows and macOS: Generally available on hardware with recent GPU drivers.
  • Firefox: Behind a flag as of 2025; not shipped by default.
  • Safari: Available on macOS 14+ and iOS 17+, with some API gaps.
  • Linux: Requires Vulkan support; coverage varies widely.

If WebGPU is unavailable, ExoJS falls back to WebGL2. Most visual features work identically across both backends; particle GPU compute requires WebGPU. See Backend comparison for a full feature-parity table.

To check which backend is active at runtime, enable the performance overlay from @codexo/exojs/debug — it displays the active backend in the overlay header. You can also inspect app.backend.backendType directly: it returns RenderBackendType.WebGpu or RenderBackendType.WebGl2.

If you need WebGPU specifically:

  1. Test in a recent Chrome or Edge release.
  2. Make sure hardware acceleration is enabled in browser settings.
  3. Update your GPU driver.
  4. Open chrome://gpu (Chrome) or about:support (Firefox) to see the active graphics backend.

Assets not loading

Asset load failures typically produce network errors in the browser’s developer console. Common causes:

Wrong relative path. Paths are resolved relative to the page URL, not the source file. If your page is at http://localhost:5173/ and you load 'assets/image.png', the browser requests http://localhost:5173/assets/image.png.

Vite public/ path confusion. Files in public/ are served from the site root. A file at public/assets/bunny.png is available at /assets/bunny.png, not public/assets/bunny.png. Reference it without the public/ prefix:

await loader.load('assets/bunny.png'); // correct
await loader.load('public/assets/bunny.png'); // wrong

Files not included in the build. Only files in public/ or explicitly imported by source code are copied to dist/. Assets referenced by path string (not import) must live in public/.

Case-sensitivity. Linux servers and most hosting providers are case-sensitive. bunny.PNG and bunny.png are different files. Match the exact casing on disk.

file:// execution. Never open index.html directly in the browser with file://. Use npm run dev or npm run preview to serve through a local HTTP server. Many browser security restrictions block resource loads from file:// origins.

CORS on a different origin. If assets are on a different domain, the server must send appropriate Access-Control-Allow-Origin headers.

See Loading and resources for the full loader API.

Audio does not start

Modern browsers require a user gesture before audio can play. Any attempt to start an AudioContext before the first user interaction is silently blocked.

The symptom: audio code runs without errors, but nothing is audible. Often the browser console shows a warning about AudioContext being suspended.

The fix: start audio in response to a click, tap, or keypress:

button.addEventListener('click', () => {
    mySound.play(); // the engine resumes the AudioContext on the first user gesture
});

If you’re using the audio-reactive template from create-exo-app, it already handles this with a start button. The Audio basics guide has a full walkthrough.

Play SoundPointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

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

// A small pool of different UI sounds so repeated taps stay interesting.
const SOUND_KEYS = ['uiClick', 'uiConfirm', 'uiBong', 'impactLight', 'impactHeavy'] as const;

class PlaySoundScene extends Scene {
  private sounds!: Sound[];
  private text!: Text;
  private index = 0;

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

    // Keep example SFX comfortable - full volume is jarring in the docs.
    app.audio.sound.volume = 0.5;

    // Path-only get() infers Sound from the .ogg extension - sidesteps a
    // compile-time overload ambiguity between Sound and the Json token form
    // when passing the Sound token explicitly.
    this.sounds = SOUND_KEYS.map(key => this.loader.get(assets.demo.audio[key]));
    this.text = new Text('Click anywhere to play SFX', { fillColor: Color.white, fontSize: 24, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(width / 2, height / 2);
    app.input.onPointerTap.add(() => {
      // Cycle through the pool so each tap plays a different sound.
      const sound = this.sounds[this.index];
      this.index = (this.index + 1) % this.sounds.length;
      app.audio.play(sound);
      this.text.text = `Playing: ${SOUND_KEYS[(this.index + this.sounds.length - 1) % this.sounds.length]}`;
    });
  }

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

const app = new Application({
  scenes: { PlaySoundScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(PlaySoundScene);

Input does not respond

Canvas focus. ExoJS keyboard and gamepad input requires the canvas to have focus. Click the canvas once to give it focus. If your page has other focusable elements that steal focus, keyboard input stops until the canvas is clicked again.

Key name mismatch. ExoJS uses the Keyboard enum — Keyboard.Space, Keyboard.A, Keyboard.Left. Each member is a physical key position resolved from the Web KeyboardEvent.code, mapped to a numeric channel; the arrow keys are Left/Up/Right/Down, not ArrowLeft. Check the Keyboard guide for the full enum.

A key produces a different character than its name. That is intended: the enum names a position by its US-QWERTY legend, so Keyboard.Z is the key printed “Y” on a German QWERTZ keyboard and Keyboard.Semicolon the one printed “ö”. Bindings stay physically where the player expects them. Never use the channel system to read typed text — use a DOM text input for characters, dead keys, and IME composition.

Input not polled in update. Keyboard held-key state (isActive) and gamepad axis values are sampled per-frame inside update. Reading them in init or outside the frame loop gives stale results.

Gamepad requires a button press to activate. Browsers only expose a connected gamepad after the user presses at least one button. Plug in and press any button once; then the gamepad appears in the Gamepad API and ExoJS can read it.

KeyboardKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, type InputBinding, Keyboard, type RenderingContext, Scene, type Seconds } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

// Two ways to read the keyboard, shown side by side:
//
//   - on-event: `inputs.onStart` / `onStop` fire once on the press / release
//     transition. Great for discrete actions (here: a recentre tap on Escape).
//   - per-frame polling: `inputs.onActive` called without a callback just
//     returns the binding, which samples the channel buffer every frame - so
//     reading `binding.active` inside update() gives the live held-state.
//
// Both WASD and the arrow keys drive the same square via a single binding per
// direction (each binding watches two channels at once).
class KeyboardScene extends Scene {
  private square!: Graphics;
  private position = { x: 400, y: 300 };
  private up!: InputBinding;
  private down!: InputBinding;
  private left!: InputBinding;
  private right!: InputBinding;
  private hud!: ReturnType<typeof mountControls>;

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

    this.square = new Graphics();
    this.position = { x: width / 2, y: height / 2 };

    // Per-frame polling source: one binding per direction, each listening to
    // both the WASD key and the matching arrow key. We keep the references
    // and read their live state in update() rather than mutating flags.
    this.up = this.inputs.onActive([Keyboard.W, Keyboard.Up]);
    this.down = this.inputs.onActive([Keyboard.S, Keyboard.Down]);
    this.left = this.inputs.onActive([Keyboard.A, Keyboard.Left]);
    this.right = this.inputs.onActive([Keyboard.D, Keyboard.Right]);

    // On-event source: a discrete tap that snaps the square back to centre.
    this.inputs.onStart(Keyboard.Escape, () => {
      this.position.x = width / 2;
      this.position.y = height / 2;
    });

    this.hud = mountControls({
      title: 'Keyboard',
      controls: [
        { keys: ['W', 'A', 'S', 'D'], action: 'move (per-frame polling)' },
        { keys: ['↑', '↓', '←', '→'], action: 'move (same bindings)' },
        { keys: 'Esc', action: 'recentre (on-event)' },
      ],
      status: 'Held: none',
      hint: 'Click the canvas first so it has keyboard focus.',
    });
  }

  override update(delta: Seconds): void {
    const app = this.app;
    const { width, height } = app;
    const speed = 280 * delta;
    const moveX = (this.right.active ? 1 : 0) - (this.left.active ? 1 : 0);
    const moveY = (this.down.active ? 1 : 0) - (this.up.active ? 1 : 0);

    this.position.x = Math.max(20, Math.min(width - 20, this.position.x + moveX * speed));
    this.position.y = Math.max(20, Math.min(height - 20, this.position.y + moveY * speed));

    const held = [this.up.active && 'Up', this.down.active && 'Down', this.left.active && 'Left', this.right.active && 'Right'].filter(Boolean);

    this.hud.setStatus(`Held: ${held.length ? held.join(' + ') : 'none'}`);
  }

  override draw(context: RenderingContext): void {
    this.square.clear();
    this.square.fillColor = new Color(120, 200, 255);
    this.square.drawRectangle(this.position.x - 20, this.position.y - 20, 40, 40);
    context.render(this.square);
  }
}

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

await app.start(KeyboardScene);
GamepadGamepadOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, type Gamepad, GamepadAxis, GamepadButton, type InputChannel, lerp, type RenderingContext, Scene, Sprite, Spritesheet, type SpritesheetData, Vector } from '@codexo/exojs';

class GamepadScene extends Scene {
  private activePad: Gamepad | null = null;
  private buttons!: Spritesheet;
  private buttonColor = new Color(255, 255, 255, 0.25);
  private mappingButtons = new Map<InputChannel, Sprite>();
  private mappingFunctions = new Map<InputChannel, (value: number) => void>();
  private resetFunctions: (() => void)[] = [];
  private padBindings: { unbind(): void }[] = [];
  private status!: Sprite;
  private container!: Container;

  override async load(): Promise<void> {
    const app = this.app;
    const buttonsData = (await this.loader.load(Asset.type('json', 'json/buttons.json'))) as SpritesheetData;

    this.buttons = new Spritesheet(this.loader.get('image/buttons.png'), buttonsData);
    const { width, height } = app;
    this.status = this.createStatus(width, height);
    this.container = this.createGamepad(width, height);

    for (const sprite of this.mappingButtons.values()) {
      sprite.setTint(this.buttonColor);
    }

    app.input.onGamepadConnected.add(pad => this.handleGamepadConnected(pad));
    app.input.onGamepadDisconnected.add(pad => this.handleGamepadDisconnected(pad));

    for (const pad of app.input.gamepads) {
      if (pad.connected) {
        this.setActivePad(pad);
        break;
      }
    }
  }

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

  private handleGamepadConnected(pad: Gamepad): void {
    if (!this.activePad) {
      this.setActivePad(pad);
    }
  }

  private handleGamepadDisconnected(pad: Gamepad): void {
    if (this.activePad !== pad) {
      return;
    }

    const app = this.app;
    const next = app.input.gamepads.find(other => other !== pad && other.connected) || null;
    this.setActivePad(next);
  }

  private setActivePad(pad: Gamepad | null): void {
    for (const binding of this.padBindings) {
      binding.unbind();
    }
    this.padBindings.length = 0;

    this.activePad = pad;

    if (!pad) {
      this.status.setTint(this.buttonColor);
      this.resetVisualState();
      return;
    }

    this.status.setTint(Color.white);

    for (const [channel, sprite] of this.mappingButtons.entries()) {
      this.padBindings.push(
        pad.onActive(channel, v => {
          sprite.tint.a = lerp(0.25, 1, v);
        }),
        pad.onStop(channel, () => {
          sprite.tint.a = this.buttonColor.a;
        }),
      );
    }

    for (const [channel, fn] of this.mappingFunctions.entries()) {
      this.padBindings.push(
        pad.onActive(channel, v => {
          fn(v);
        }),
        pad.onStop(channel, () => {
          fn(0);
        }),
      );
    }
  }

  private resetVisualState(): void {
    for (const sprite of this.mappingButtons.values()) {
      sprite.tint.a = this.buttonColor.a;
    }

    for (const reset of this.resetFunctions) {
      reset();
    }
  }

  private createStatus(width: number, height: number): Sprite {
    const status = this.buttons.getFrameSprite('status');

    status.setAnchor(0.5);
    status.setPosition(width / 2, height / 5);
    status.setTint(this.buttonColor);

    return status;
  }

  private createGamepad(width: number, height: number): Container {
    const container = new Container();

    container.addChild(this.createDPadField(width, height));
    container.addChild(this.createFaceButtons(width, height));
    container.addChild(this.createShoulderButtons(width, height));
    container.addChild(this.createMenuButtons(width, height));
    container.addChild(this.createJoysticks(width, height));

    return container;
  }

  private createDPadField(width: number, height: number): Container {
    const mappedButtons = this.mappingButtons;
    const container = new Container();
    const dPad = this.buttons.getFrameSprite('dpad');
    const dPadUp = this.buttons.getFrameSprite('DPadUp');
    const dPadDown = this.buttons.getFrameSprite('DPadDown');
    const dPadLeft = this.buttons.getFrameSprite('DPadLeft');
    const dPadRight = this.buttons.getFrameSprite('DPadRight');

    mappedButtons.set(GamepadButton.DPadUp, dPadUp);
    mappedButtons.set(GamepadButton.DPadDown, dPadDown);
    mappedButtons.set(GamepadButton.DPadLeft, dPadLeft);
    mappedButtons.set(GamepadButton.DPadRight, dPadRight);

    dPad.setTint(this.buttonColor);

    dPad.setScale(1.75);
    dPadUp.setScale(1.75);
    dPadDown.setScale(1.75);
    dPadLeft.setScale(1.75);
    dPadRight.setScale(1.75);

    container.addChild(dPad);
    container.addChild(dPadUp);
    container.addChild(dPadDown);
    container.addChild(dPadLeft);
    container.addChild(dPadRight);

    container.setPosition(width / 5, height / 2);

    return container;
  }

  private createFaceButtons(width: number, height: number): Container {
    const mappedButtons = this.mappingButtons;
    const container = new Container();
    const buttonTop = this.buttons.getFrameSprite('FaceTop');
    const buttonLeft = this.buttons.getFrameSprite('FaceLeft');
    const buttonRight = this.buttons.getFrameSprite('FaceRight');
    const buttonBottom = this.buttons.getFrameSprite('FaceBottom');

    mappedButtons.set(GamepadButton.North, buttonTop);
    mappedButtons.set(GamepadButton.West, buttonLeft);
    mappedButtons.set(GamepadButton.East, buttonRight);
    mappedButtons.set(GamepadButton.South, buttonBottom);

    buttonTop.setScale(0.75);
    buttonTop.setPosition(50, 0);

    buttonLeft.setScale(0.75);
    buttonLeft.setPosition(0, 50);

    buttonRight.setScale(0.75);
    buttonRight.setPosition(100, 50);

    buttonBottom.setScale(0.75);
    buttonBottom.setPosition(50, 100);

    container.addChild(buttonTop);
    container.addChild(buttonLeft);
    container.addChild(buttonRight);
    container.addChild(buttonBottom);

    container.setPosition(width * 0.8, height / 2);

    return container;
  }

  private createShoulderButtons(width: number, height: number): Container {
    const mappedButtons = this.mappingButtons;
    const container = new Container();
    const leftButton = this.buttons.getFrameSprite('ShoulderLeftBottom');
    const rightButton = this.buttons.getFrameSprite('ShoulderRightBottom');
    const leftTrigger = this.buttons.getFrameSprite('ShoulderLeftTop');
    const rightTrigger = this.buttons.getFrameSprite('ShoulderRightTop');

    mappedButtons.set(GamepadButton.LeftShoulder, leftButton);
    mappedButtons.set(GamepadButton.RightShoulder, rightButton);
    mappedButtons.set(GamepadButton.LeftTrigger, leftTrigger);
    mappedButtons.set(GamepadButton.RightTrigger, rightTrigger);

    leftButton.setPosition(0, 75);

    rightButton.setAnchor(0.5, 0);
    rightButton.setPosition(width * 0.65, 75);

    rightTrigger.setAnchor(0.5, 0);
    rightTrigger.setPosition(width * 0.65, 0);

    container.addChild(leftButton);
    container.addChild(rightButton);
    container.addChild(leftTrigger);
    container.addChild(rightTrigger);

    container.setPosition(width / 2, height / 5);

    return container;
  }

  private createMenuButtons(width: number, height: number): Container {
    const mappedButtons = this.mappingButtons;
    const container = new Container();
    const selectButton = this.buttons.getFrameSprite('Select');
    const startButton = this.buttons.getFrameSprite('Start');

    mappedButtons.set(GamepadButton.Select, selectButton);
    mappedButtons.set(GamepadButton.Start, startButton);

    startButton.setAnchor(1, 0);
    startButton.setPosition(width * 0.3, 0);

    container.addChild(selectButton);
    container.addChild(startButton);

    container.setPosition(width / 2, height / 2);

    return container;
  }

  private createJoysticks(width: number, height: number): Container {
    const mappedButtons = this.mappingButtons;
    const mappingFunctions = this.mappingFunctions;
    const container = new Container();
    const leftStick = this.buttons.getFrameSprite('LeftStick');
    const rightStick = this.buttons.getFrameSprite('RightStick');
    const startLeft = new Vector(0, 0);
    const startRight = new Vector(width * 0.3, 0);
    const range = 35;

    mappedButtons.set(GamepadButton.LeftStick, leftStick);
    mappedButtons.set(GamepadButton.RightStick, rightStick);

    mappingFunctions.set(GamepadAxis.LeftStickX, (value: number) => (leftStick.x = startLeft.x + value * range));
    mappingFunctions.set(GamepadAxis.LeftStickY, (value: number) => (leftStick.y = startLeft.y + value * range));
    mappingFunctions.set(GamepadAxis.RightStickX, (value: number) => (rightStick.x = startRight.x + value * range));
    mappingFunctions.set(GamepadAxis.RightStickY, (value: number) => (rightStick.y = startRight.y + value * range));

    this.resetFunctions.push(() => {
      leftStick.setPosition(startLeft.x, startLeft.y);
      rightStick.setPosition(startRight.x, startRight.y);
    });

    leftStick.setPosition(startLeft.x, startLeft.y);
    rightStick.setPosition(startRight.x, startRight.y);

    container.addChild(leftStick);
    container.addChild(rightStick);

    container.setPosition(width / 2, height * 0.65);

    return container;
  }
}

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

await app.start(GamepadScene);

Text or fonts look wrong

Font not loaded. Fonts loaded via @font-face or a web font URL must finish loading before ExoJS renders text using them. Load the font explicitly (e.g., via the FontFace API or CSS) and wait for the promise before starting the scene, or load it through ExoJS’s asset loader if supported.

Wrong font path. The same path rules as assets apply. Check case, public/ prefix rules, and whether the font file is in public/.

Wrong style property name. The current TextStyle API uses:

  • fillColor — not fill, not color

Using an old property name silently produces no error but the style is ignored. Check the Text guide for the current TextStyle keys.

DPI / DevicePixelRatio scaling. At high DPI (e.g., Retina displays), text and sprites look soft if the canvas backing store matches the CSS size. By default pixelRatio is the display’s devicePixelRatio clamped to 2, so output is crisp out of the box — if it looks soft, check you haven’t set pixelRatio: 1. On a DPR-3 device that needs full native density, set pixelRatio: window.devicePixelRatio explicitly to bypass the cap. See Resize, DPR & the canvas for the full pattern.

Performance problems

If your scene runs slowly, measure first before guessing. The DebugOverlay from @codexo/exojs/debug shows FPS, frame time, and draw-call count in real time:

import { Application } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

const app = new Application();
const debug = new DebugOverlay(app);
debug.layers.performance.visible = true;

Common causes of poor performance:

Too many drawables. Each sprite, container, and text node that is visible adds render work. For large counts, use a sprite atlas (single texture, many frames) so the renderer can batch them into fewer draw calls.

Expensive filters. Each filter on a node adds a GPU render pass. A container with three filters costs three extra passes every frame. Remove filters you aren’t using, or set cacheAsTexture = true on filtered content that doesn’t change.

Large or unnecessary render targets. RenderTexture allocates GPU memory proportional to its dimensions. Keep sizes matched to their actual use.

Per-frame allocations. Creating objects inside update or draw (e.g., new Color(...), new Vec2(...)) each frame causes garbage collector pressure. Allocate once in init and mutate in-place.

See Performance and Render pipeline debugging for detailed analysis strategies.

Performance OverlayKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

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

// The HUD counts every frame longer than this budget, paints those rows red,
// and plots the budget as the guide line halfway up the sparkline.
const budgets = [Time.seconds(1 / 60), Time.seconds(1 / 30), Time.seconds(1 / 144)];

class PerformanceOverlayScene extends Scene {
  private sprites!: { sprite: Sprite; vx: number; vy: number }[];
  private layer!: Container;
  private budgetIndex = 0;

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

    // All sprites share one texture, so adding them to a single container and
    // rendering it once lets the renderer batch them into a single draw call.
    // Rendering each sprite with its own `context.render(sprite)` call would
    // instead emit one draw call per sprite and tank the frame rate.
    this.layer = new Container();
    this.sprites = Array.from({ length: 1600 }, () => {
      const sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.25);
      sprite.setPosition(Math.random() * width, Math.random() * height);
      this.layer.addChild(sprite);
      return {
        sprite,
        vx: (Math.random() - 0.5) * 120,
        vy: (Math.random() - 0.5) * 120,
      };
    });
    this.inputs.onTrigger(Keyboard.P, () => {
      debug.layers.performance.visible = !debug.layers.performance.visible;
    });
    this.inputs.onTrigger(Keyboard.B, () => {
      this.budgetIndex = (this.budgetIndex + 1) % budgets.length;
      debug.layers.performance.frameBudget = budgets[this.budgetIndex]!;
    });
    this.inputs.onTrigger(Keyboard.A, () => {
      debug.layers.assetCache.visible = !debug.layers.assetCache.visible;
    });
  }

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

    for (const item of this.sprites) {
      item.sprite.move(item.vx * delta, item.vy * delta);
      if (item.sprite.position.x < 0 || item.sprite.position.x > width) item.vx *= -1;
      if (item.sprite.position.y < 0 || item.sprite.position.y > height) item.vy *= -1;
    }
  }

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

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

const debug = new DebugOverlay(app);
debug.layers.performance.visible = true;
// Reports what the loader holds: counts per asset type, estimated resident
// bytes, and the heaviest assets. Snapshots on an interval, not per frame.
debug.layers.assetCache.visible = true;

await app.start(PerformanceOverlayScene);

Where to go next

The next chapter, Deployment, covers building and hosting an ExoJS app for production.