Guide

GuideRuntimeApplication

Application

How the application owns canvas, render backend, resources, input, scene management, and the frame loop.

Intro~8 min read

What you'll learn

  • create and configure an Application
  • understand how the application owns canvas, sizing, and the frame loop

Before you start

Application

The Application is the runtime host. One instance per browser tab owns the canvas, the render backend, the asset loader, the input system, the scene director, the tween system, and the per-frame loop. Everything else in your project plugs into it.

Construction

Every option is optional. If you pass nothing, ExoJS picks reasonable defaults — an 800×600 canvas it created itself, a cornflowerBlue clear color, and an auto-selected render backend (WebGPU when available, WebGL2 otherwise).

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

const app = new Application();

When you need to control the configuration, pass a grouped options object:

import { Application, Color } from '@codexo/exojs';

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

Options are organised into groups:

  • canvas — canvas element, size, pixel ratio, and rendering hints (CanvasApplicationOptions).
  • loader — base path, fetch options, caching (LoaderOptions).
  • rendering — WebGL2 debug, context attributes, batch sizes (RenderingApplicationOptions).
  • input — gamepad definitions, slot strategy, pointer threshold (InputApplicationOptions).
  • clearColor — the colour the canvas is painted with at the start of each frame.
  • autoClear — whether that per-frame clear happens at all. true by default; false hands the frame to a pipeline that preserves or clears it itself.
  • backend — { type: 'auto' } (default), or pin to 'webgl2' / 'webgpu'.

clearColor is not just a construction-time default — app.clearColor returns the render backend’s live Color instance, so it stays readable and writable for the whole life of the application. Assigning a new Color copies its channels into the backend (effective from the next frame), which makes it a convenient way to react to game state without touching draw code:

import { Application, Color } from '@codexo/exojs';

const app = new Application();

function onLowHealth(isLow: boolean) {
    app.clearColor = isLow ? new Color(0x8b0000) : new Color(0x6495ed);
}

You can also mutate the existing instance in place instead of allocating a new Color each time:

import type { Application } from '@codexo/exojs';

declare const application: Application;

application.clearColor.set(20, 20, 40, 1); // dusk

Choosing the canvas

By default the application creates its own canvas. Point canvas.mount at the element (or CSS selector) that should hold it:

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

const app = new Application({
    canvas: { width: 800, height: 600, mount: document.body },
});

To place it by hand instead, read the element from app.element:

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

const app = new Application({
    canvas: { width: 800, height: 600 },
});

if (app.element !== null) {
    document.body.append(app.element);
}

If your page already has a canvas — for example because the layout is owned by a framework that renders the element for you — pass it in via canvas.element:

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

const canvas = document.querySelector<HTMLCanvasElement>('canvas');
if (!canvas) throw new Error('Expected a canvas element');

const app = new Application({
    canvas: { element: canvas, width: 800, height: 600 },
});

Either way, app.element is the active HTMLCanvasElement. CSS rules, ResizeObserver, event listeners, and getBoundingClientRect() all work as expected.

Rendering without a document

app.canvas is the surface the renderer draws into, and that is not always an element: canvas.element also accepts an OffscreenCanvas, which is what makes it possible to run an application off the main thread.

import { Application, OffscreenPlatform } from '@codexo/exojs';

declare const surface: OffscreenCanvas;

const platform = new OffscreenPlatform(surface);
const app = new Application({
    platform,
    canvas: { element: surface, width: 800, height: 600 },
});

An OffscreenCanvas has no layout box, no styling and no events, so mount, tabIndex and imageRendering do not apply, the document-based canvas.sizing policies reject it outright, and app.element is null. The document keeps ownership of everything the surface lacks: forward pointer and key events into platform.emitSurfaceEvent() / platform.emitWindowEvent() as plain data, keep platform.setSurfaceRect() in step with the on-screen rect so pointer positions map correctly, and report focus and visibility through their setters.

Inside a worker, Capabilities answers for the worker’s realm, not the document’s: capabilities.realm is 'worker', pointer and audio are false, and devicePixelRatio is 1 because there is no document to ask. The host decides the surface’s backing size before transferring it.

High-DPI and pixel ratio

canvas.pixelRatio scales the backing buffer relative to logical CSS pixels. By default it is the host’s window.devicePixelRatio clamped to 2, so rendering is crisp on Retina/HiDPI screens out of the box. Pass an explicit value to override:

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

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        pixelRatio: window.devicePixelRatio, // full native density (uncapped)
    },
});
  • width / height are the logical canvas dimensions in CSS pixels — also exposed as app.width / app.height.
  • The backing buffer is width × pixelRatio by height × pixelRatio; app.pixelRatio holds the resolved ratio.
  • canvas.style.width / height is always set to the logical dimensions.
  • app.resize(w, h) takes logical dimensions and re-applies the stored pixel ratio.

Pass pixelRatio: 1 to force logical-pixel rendering, or window.devicePixelRatio to opt into full native density above the default 2× cap (the cap keeps DPR-3 phones from paying a 9× fill-rate cost).

For pixel-art games where you want crisp upscaling without browser blurring, combine pixelRatio with the imageRendering hint:

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

const app = new Application({
    canvas: {
        width: 320,
        height: 240,
        imageRendering: 'pixelated',
    },
});

imageRendering is a CSS hint on the canvas element that controls how the browser upscales the canvas in the page. It does not change engine texture filtering.

Starting the frame loop

The frame loop only runs once you give the application a scene to run:

examples/guides/application/start-loop.ts
class HelloScene extends Scene {
  override draw(context: RenderingContext): void {
    context.render(this.root);
  }
}

const app = new Application({ scenes: { HelloScene } });
await app.start(HelloScene);

The app.start(scene) call is asynchronous — it initializes the render backend, runs the scene’s load hook, then init, then drives the frame loop until you stop it. You normally don’t need to await the returned promise; fire-and-forget is the common pattern.

To stop the loop, call app.stop(). To release the canvas and all GPU resources, call app.destroy().

Subsystems on the application

Once an application exists, its subsystems are accessible as properties:

  • app.canvas — the render surface, an HTMLCanvasElement or an OffscreenCanvas.
  • app.element — the same surface as a document canvas, or null when there is none.
  • app.loader — the Loader used by every scene to register and resolve assets.
  • app.input — the InputSystem for keyboard, pointer, and gamepad routing.
  • app.scenes — the SceneDirector that holds the single active scene; switch scenes via change (with an optional transition).
  • app.audio — the AudioSystem for playback, busses, and the spatial listener.
  • app.systems — the SystemRegistry for app-scoped System instances.
  • app.tweens — the global TweenSystem.
  • app.coroutines — the CoroutineSystem that spreads heavy work over frames.

Most code reaches these through the active scene (this.app.input, this.app.tweens) rather than holding a top-level reference, but both work.

Spreading work over frames

update and draw are synchronous on purpose: a frame has a fixed budget, and an async update would resume in a later microtask, after the frame that started it. Work that does not fit into one frame (building a world, computing hundreds of paths, rebuilding a visibility index) goes through a coroutine instead: a generator whose every yield hands control back to the frame. scene.coroutines is the scene-bound view of the one driver the application owns as app.coroutines.

The driver runs in the postFrame phase, after the backend has flushed, so its work overlaps the GPU drawing the frame just submitted — and, unlike a fixed budget guessed up front, it knows how much of the frame is actually left. Each coroutine is stepped at most once per frame, and the first step of a frame happens whatever the budget says, so a coroutine always makes progress.

That one rule is what lets the same primitive serve two shapes of work. A body that never looks at the budget advances exactly one unit per frame, which is what sequencing means:

declare function toggle(): void;

function* blinkThreeTimes(): Generator<void, void> {
    for (let i = 0; i < 3; i++) {
        toggle();
        yield;
    }
}

A body that does look at it fills whatever the frame has left. It is written curried so the call site needs no wrapper lambda, and what it yields becomes coroutine.progress — a loading bar needs no side channel:

import type { FrameBudget, Scene } from '@codexo/exojs';

declare const scene: Scene;
declare const chunks: readonly { place(): void }[];

const buildWorld = (parts: readonly { place(): void }[]) =>
    function* (budget: FrameBudget): Generator<number, number> {
        let placed = 0;

        while (placed < parts.length) {
            do {
                parts[placed++]!.place();
            } while (placed < parts.length && budget.timeRemaining() > 0);

            yield placed / parts.length;
        }

        return placed;
    };

const build = scene.coroutines.queue(buildWorld(chunks), { name: 'world' });

build.done.then(placed => {
    console.log(`placed ${placed} chunks`);
});

queue() also takes a ready-made iterator (scene.coroutines.queue(blinkThreeTimes())) for the sequencing case, which has no use for a budget.

The handle works from both worlds. Frame code polls status, progress and result; async code awaits done, which rejects when the body throws or the coroutine is cancelled. cancel() stops it at its current yield and runs its finally blocks, priority is writable so work the player is now waiting on can be promoted, and { signal } reaches an existing AbortSignal in. A coroutine queued on scene.coroutines stops advancing while the scene is paused or suspended, resumes exactly where it left off, and is cancelled when the scene ends.

The budget defaults to a quarter of what the frame has left, capped at 4 ms, so a frame that is already behind withdraws instead of being handed more work. Set app.coroutines.budget to an absolute Seconds value if you want a fixed figure instead.

Yield often enough that a single step stays well inside the slice: the driver cannot interrupt a step, only decline to start the next one. A step that overruns by a wide margin is reported in development, naming the coroutine’s name.

Per-frame signals

The application emits a small set of signals you can subscribe to without subclassing:

import type { Application } from '@codexo/exojs';

declare const application: Application;

application.onFrame.add(time => {
    console.log(`Frame at ${time.toFixed(3)}s`);
});

application.onResize.add((width, height) => {
    // canvas resolution changed
});

For most projects the scene’s update and draw hooks are enough; reach for these signals when you need to coordinate work that lives outside the scene.

Pausing while the tab is hidden

Browsers throttle background tabs to roughly 1 fps. For games this usually shows up as motion artifacts when the user comes back. Toggle pauseOnHidden to skip update + render entirely while the tab is hidden:

import type { Application } from '@codexo/exojs';

declare const application: Application;

application.pauseOnHidden = true;

Tools and background-active simulations should leave it off; games and animated demos should turn it on.

Examples

Hello WorldOpen in PlaygroundView source

Preview is paused until you click Play.

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

class HelloWorldScene 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 draw(context: RenderingContext): void {
    context.render(this.sprite);
  }
}
const app = new Application({
  scenes: { HelloWorldScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(HelloWorldScene);

The minimum complete application: construct, start one scene, draw a single sprite.

Resize and DPROpen in PlaygroundView source

Preview is paused until you click Play.

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

class ResizeScene extends Scene {
  private sprite!: Sprite;
  private info!: Text;

  override init(): void {
    this.sprite = new Sprite(this.loader.get('image/ship-a.png'));
    this.sprite.setAnchor(0.5);

    this.info = new Text('', { fillColor: Color.white, fontSize: 16 });
    this.info.setAnchor(0.5, 0);

    this.layout();
  }

  override update(): void {
    this.layout();
  }

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

  private layout(): void {
    const app = this.app;
    const { width, height } = app;
    const dpr = Math.max(1, window.devicePixelRatio || 1);

    this.sprite.setPosition(width / 2, height / 2);
    this.info.setPosition(width / 2, 12);
    this.info.text = `${width}x${height} @ DPR ${dpr.toFixed(2)}`;
  }
}

const app = new Application({
  scenes: { ResizeScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
    pixelRatio: window.devicePixelRatio || 1,
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});
document.body.style.margin = '0';

// This example demonstrates manual resize handling: the canvas is resized to
// fill the window on every `resize` event. (For a hands-off alternative, pass a
// `ResponsiveCanvasSizing` as the `canvas.sizing` option and let it track the
// parent element instead.)
window.addEventListener('resize', () => {
  app.resize(window.innerWidth, window.innerHeight);
});

app.resize(window.innerWidth, window.innerHeight);
await app.start(ResizeScene);

Same shape, with the canvas auto-resizing to the window and rendering at the device’s pixel ratio for sharp output on high-DPI displays.

Where to go next

Scenes are where actual game logic lives. The next chapter, Scenes & lifecycle, covers what a scene is, how it differs from the application, and the order its hooks run in.