Guide

Guide Shipping Troubleshooting

Troubleshooting

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

Intro ~8 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.

SymptomLikely causeSection
Blank or black canvasApp not started, missing context.render, or content off-screenCanvas shows nothing
… is not a function from copied codePre-0.9 API in an old snippetOld API errors
Runs on WebGL2, never WebGPUWebGPU unavailable in this browser/OS/GPUWebGPU unavailable
Asset 404s or never appearsWrong path, public/ prefix, or case mismatchAssets not loading
Code runs but no soundAudio started before a user gestureAudio does not start
Keys or gamepad ignoredCanvas not focused, or input read outside updateInput does not respond
Text blurry, missing, or unstyledFont not loaded, wrong style key, or DPR scalingText or fonts look wrong
Low FPS or stutterToo many drawables, filters, or per-frame allocationsPerformance problems

Canvas shows nothing

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

The application is not started. Call app.start(scene) after creating the application.

The scene is not set. app.start() expects a Scene instance. Without one, nothing draws.

The canvas is not visible. Check that app.canvas 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. Every frame you need to both clear the backend and explicitly render your scene graph root:

draw(context) {
    context.backend.clear();
    context.render(this.root);
}

Without context.render(this.root), the frame is cleared but no nodes are drawn. Without context.backend.clear(), the previous frame is never painted over.

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:

draw(context) {
    context.backend.clear();
    context.render(sprite);
}

The Application constructor options also changed shape in 0.9.0. See the v0.8.x to v0.9.0 migration guide for a complete list of renamed options and removed aliases.

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(Texture, { bunny: 'assets/bunny.png' }); // correct
await loader.load(Texture, { bunny: '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', () => {
    app.audio.resume(); // or play your first sound here
    mySound.play();
});

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 Sound Pointer Audio Open in Playground View source
import { Application, Color, Scene, Sound, Text } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
});

class PlaySoundScene extends Scene {
    private sound!: Sound;
    private text!: Text;

    override async load(loader): Promise<void> {
        await loader.load(Sound, { click: assets.demo.audio.uiClick });
    }

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

        this.sound = loader.get(Sound, 'click');
        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);
        this.app.input.onPointerTap.add(() => {
            this.sound.play();
        });
    }

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

app.start(new 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.ArrowLeft — the names match the Web KeyboardEvent.code values but mapped to numeric channels. Check the Keyboard guide for the full enum.

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.

Keyboard Keyboard Open in Playground View source
import { Application, Color, Graphics, Keyboard, Scene } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

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

// 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: the binding returned by `inputs.onActive` samples the
//     channel buffer every frame, so reading `binding.active` inside update()
//     gives the live held-state — no callback bookkeeping required.
//
// 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 };
    // The structural shape of an InputBinding's pollable state (the class itself
    // is internal to the engine, but `active` / `value` are its public surface).
    private up!: { readonly active: boolean; readonly value: number };
    private down!: { readonly active: boolean; readonly value: number };
    private left!: { readonly active: boolean; readonly value: number };
    private right!: { readonly active: boolean; readonly value: number };
    private hud!: ReturnType<typeof mountControls>;

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

        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): void {
        const { width, height } = this.app.canvas;
        const speed = 280 * delta.seconds;
        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): void {
        context.backend.clear();
        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);
    }
}

app.start(new KeyboardScene());
Gamepad Gamepad Open in Playground View source
import { Application, Color, Container, GamepadAxis, GamepadButton, Json, lerp, Scene, Sprite, Spritesheet, Texture, Vector } from '@codexo/exojs';

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

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

    override async load(loader): Promise<void> {
        await loader.load(Texture, { buttons: 'image/buttons.png' });
        await loader.load(Json, { buttons: 'json/buttons.json' });
    }

    override init(loader): void {
        this.buttons = new Spritesheet(loader.get(Texture, 'buttons'), loader.get(Json, 'buttons'));
        this.status = this.createStatus();
        this.container = this.createGamepad();

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

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

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

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

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

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

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

    private setActivePad(pad): 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(): Sprite {
        const { width, height } = this.app.canvas;
        const status = this.buttons.getFrameSprite('status');

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

        return status;
    }

    private createGamepad(): Container {
        const container = new Container();

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

        return container;
    }

    private createDPadField(): Container {
        const { width, height } = this.app.canvas;
        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.setAnchor(0.5);
        container.setPosition(width / 5, height / 2);

        return container;
    }

    private createFaceButtons(): Container {
        const { width, height } = this.app.canvas;
        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.setAnchor(0.5);
        container.setPosition(width * 0.8, height / 2);

        return container;
    }

    private createShoulderButtons(): Container {
        const { width, height } = this.app.canvas;
        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.setAnchor(0.5);
        container.setPosition(width / 2, height / 5);

        return container;
    }

    private createMenuButtons(): Container {
        const { width, height } = this.app.canvas;
        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.setAnchor(0.5);
        container.setPosition(width / 2, height / 2);

        return container;
    }

    private createJoysticks(): Container {
        const { width, height } = this.app.canvas;
        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.setAnchor(0.5, 0);
        container.setPosition(width / 2, height * 0.65);

        return container;
    }
}

app.start(new 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. Set the pixelRatio canvas option to the display’s window.devicePixelRatio so the canvas renders at native resolution. It defaults to 1, so crisp HiDPI output is opt-in — 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 { DebugOverlay } from '@codexo/exojs/debug';

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 cacheAsBitmap = 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 Overlay Keyboard Open in Playground View source
import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

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

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

class PerformanceOverlayScene extends Scene {
    private sprites!: { sprite: Sprite; vx: number; vy: number }[];

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png' });
    }

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

        this.sprites = Array.from({ length: 1600 }, () => {
            const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.25);
            sprite.setPosition(Math.random() * width, Math.random() * height);
            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;
        });
    }

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

        for (const item of this.sprites) {
            item.sprite.move(item.vx * delta.seconds, item.vy * delta.seconds);
            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): void {
        context.backend.clear();
        for (const { sprite } of this.sprites) context.render(sprite);
    }
}

app.start(new PerformanceOverlayScene());

Where to go next

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