Guide

GuideAssetsLoading and resources

Loading and resources

The asset pipeline — how the loader registers, fetches, and resolves resources before your scene starts updating.

Intermediate~28 min read

What you'll learn

  • declare and load assets predictably
  • access loaded resources by name

Before you start

Loading and resources

Most non-trivial scenes need assets — textures, audio, fonts, JSON, video — before they can render. The Loader handles fetching, decoding, and caching those assets, then makes them available to your scene by name.

The contract is simple: declare what you need during load, await the loader, and read the resolved instances out during init. Everything before init runs has finished by the time init is called.

The loader instance

Every application owns one loader, available as app.loader. Inside a scene, reach it two ways: this.loader — a scene-scoped claim view whose assets release automatically when the scene ends — and this.app.loader — the same underlying application-lifetime instance, for assets that must outlive the scene.

For most scenes you don’t need to think about ownership — just use this.loader in load and init.

Choose the form that fits

The loader offers several forms. Pick the smallest one that covers your case:

Form Best for
this.loader.load('hero.png') One asset, path doubles as its identity — no alias needed
Asset.type('texture', path) / Asset.type('sound', path) / Asset.type<T>('json', path) The path is computed at runtime (not a literal), or you want an explicit type
Assets.from({ hero: 'hero.png', … }) A reusable, named group of assets shared across scenes
Assets.from({ hero: 'hero.png', music: Asset.type('music', path) }) Mixed types and type-specific options in one catalog

When in doubt, start with a bare path string. Reach for Assets.from when assets belong together, and Asset.type(...) whenever the path isn’t a string literal or the type should be explicit.

Which call when

The forms above say how to name an asset; these are the calls that fetch, look up and let go of one. Every fetching call claims the asset for its owner (the application, the scene, or a scope you created), and a claim is what keeps it resident.

You want to… Call You get Claim
Use a seamless asset now and let it fill in when it arrives loader.get('hero.png') The handle immediately ('loading' until ready), the same instance for the same source Yes
Read a value asset (json, txt, csv, …) as it becomes available loader.get('level.json') A stable AssetRef whose .value fills in Yes
Wait for one asset, or for a type without a placeholder (music, bmFont, …) await loader.load(Asset.type('music', path)) The resolved resource Yes
Wait for a whole catalog at once await loader.load(catalog) The catalog, its leaves healed in place Yes, one per entry
Unpack a packed .exoa container in one request await loader.loadContainer('pack.exoa') A new scope owning every entry; entries resolve to the same identities as network loads Yes, one per entry, held by that scope
Find out which packs a deployment ships, and where they are now await loader.loadManifest('assets.json') An AssetManifest; manifest.pack(name) addresses one for loadContainer No
Check whether something is already resident without fetching loader.peek('hero.png') The resource, or undefined No
Let one asset go before its owner ends scope.release(handle) Nothing; the handle stays valid and heals again on the next get() Drops this scope’s claim
Let everything an owner holds go scope.destroy() / the scene ending Nothing; assets other owners still hold stay resident Drops all of that owner’s claims

Two rules make the table predictable. First, get() never returns undefined for a seamless type: it hands back a placeholder and starts the fetch, so a wrong path fails at the fetch, not at the call - use peek() when “not loaded” is an answer you want. Second, release() and destroy() only ever drop the caller’s own claims; there is no call that evicts an asset another owner is using, and no way to release an application-lifetime claim except destroying the application. Read on for the forms and their examples, and see Ownership and scopes for what an owner is.

Loading a single asset

The path is the identity — a bare string resolves directly to the finished asset, with its type inferred from the extension:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const texture = await this.loader.load('image/hero.png');
  this.hero = new Sprite(texture);
}

There’s no separate alias step: call this.loader.get('image/hero.png') again anywhere later — same string, same Texture instance. Leaf-capable types such as texture, sound, json, and text resolve this way from their file extension. Types that can’t be inferred from a path (music, video, bmFont, font, …) need an explicit descriptor — see Asset.type(...) below.

Homogeneous batch

When you need several assets of the same type, load them in parallel and keep the returned instances:

examples/guides/loading-and-resources/scenes.ts
async load() {
  [this.hero, this.terrain, this.coin] = await Promise.all([
    this.loader.load('image/hero.png'),
    this.loader.load('image/terrain.png'),
    this.loader.load('image/coin.png'),
  ]);
}

The loader.basePath option in ApplicationOptions prepends a base prefix to all relative paths, so these resolve to e.g. assets/image/hero.png.

Because the path is the identity, the same source path returns the very same Texture instance from anywhere else in your code — this.loader.get('image/hero.png') from init, a later scene, or app.loader.get(...) from a system — as long as the string matches.

Dynamic paths and non-seamless types

A bare string only works for a literal path — the type is inferred at compile time from the extension. When the path is computed at runtime, use the canonical Asset.type(...) descriptor:

A path chosen at runtime
/** Replaces the ground texture with a variant chosen at runtime. */
private async useVariant(variant: string): Promise<void> {
  const app = this.app;

  // The path is computed rather than a literal, so its type cannot be
  // inferred from the extension - name it with `Asset.type(...)`.
  const texture = await app.loader.load(Asset.type('texture', `image/${variant}.png`));

  this.ground.setTexture(texture);
}

Inside every scene lifecycle hook, Scene.app is already available and non-null. Access before attachment, such as from a scene constructor, throws; use Scene.attached only when code genuinely needs a non-throwing attachment probe.

Types that can’t be inferred from a path at all need the same descriptor, even when the path is a literal:

A non-leaf type
// Non-leaf types (`music`, `video`, `bmFont`, `font`, ...) have no
// bare-path form and no placeholder to hand back, even for a literal
// path - they are always loaded by reference and awaited.
this.theme = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg'));

Multiple resource types in parallel

When a scene needs different asset types, load them in parallel with Promise.all:

examples/guides/loading-and-resources/scenes.ts
async load() {
  [this.skyTexture, this.ambient, this.coin, this.jump, this.levels] = await Promise.all([
    this.loader.load('image/sky.png'),
    this.loader.load(Asset.type('music', 'audio/ambient.ogg')),
    this.loader.load('audio/coin.wav'),
    this.loader.load('audio/jump.wav'),
    this.loader.load('data/levels.json'),
  ]);
}

init() {
  this.sky = new Sprite(this.skyTexture);
}

Wrapping the calls in Promise.all lets unrelated asset categories load in parallel.

For value assets such as Json (and TextAsset, CsvAsset, and the other data tokens), loader.load(...) resolves directly to the parsed value. loader.get(...) on the same path hands back a lightweight AssetRef instead — read its .value once .ready is true (see below).

Mixed asset catalog

Build mixed groups with Assets.from(...). This is the canonical catalog API; the old inline-record loader.load({ alias: config }) call shape has been removed.

A mixed catalog
// A catalog is a named, typed group of assets. A bare path infers its type from
// the file extension; anything else takes an explicit `Asset.type(...)`.
const SharedAssets = Assets.from({
  logo: 'image/uv-grid-256.png',
  click: Asset.type('sound', 'audio/ui-click.ogg'),
  atlas: Asset.type<{ frames: Record<string, unknown> }>('json', 'json/buttons.json'),
});

Awaiting the catalog returns an object whose keys match the input and whose values are the resolved resource instances directly, so it destructures:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const { logo, click, atlas } = await this.loader.load(SharedAssets);

  this.logo = new Sprite(logo);
  this.click = click;
}

No separate get() step is needed, though this.loader.get('image/uv-grid-256.png') / this.loader.get('audio/ui-click.ogg') still work afterward since the source path remains the identity.

Reusable asset references

For assets used in multiple scenes, define a named catalog with Assets.from:

examples/guides/loading-and-resources/title-catalog.ts
export const TitleAssets = Assets.from({
  logo: 'sprites/logo.png', // bare path → Texture
  music: Asset.type('music', 'audio/title.ogg'), // explicit non-leaf type
  config: Asset.type<{ startLevel: string }>('json', 'data/config.json'),
});

Every property is a real, usable handle before any loader touches it — TitleAssets.logo is already a Texture (in the 'loading' state), and TitleAssets.config is already an AssetRef. Load the whole catalog, then read the same properties:

examples/guides/loading-and-resources/scenes.ts
async load() {
  // Fetches every entry and heals each handle in place
  await this.loader.load(TitleAssets);

  // Same objects as before load() - now populated
  this.logo = new Sprite(TitleAssets.logo);
  this.startLevel = TitleAssets.config.value.startLevel;
}

Typed properties on the catalog give autocomplete and type inference for free:

examples/guides/loading-and-resources/title-catalog.ts
const logo = TitleAssets.logo; // Texture
const music = TitleAssets.music; // AudioStream
const config = TitleAssets.config; // AssetRef<{ startLevel: string }>

Assets also exposes an .entries record for iteration and inspection. To load several existing catalogs concurrently, keep their types intact and start both queues:

examples/guides/loading-and-resources/scenes.ts
async load() {
  await Promise.all([this.loader.load(CommonAssets), this.loader.load(TitleAssets)]);
}

The key 'entries' is reserved and will throw if you try to use it as an asset name inside an Assets container.

Combining and deriving catalogs

Catalogs compose. Assets.compose(...) merges several existing catalogs into one — the result is an ordinary, fully typed Assets object, so it loads, releases, and autocompletes exactly like a hand-written one:

Merging catalogs
// `compose` merges catalogs into one ordinary, fully typed catalog. It SHARES
// its inputs' handles instead of copying them, so `LevelAssets.logo` is the very
// same Texture object as `SharedAssets.logo`.
const LevelLocalAssets = Assets.from({
  ship: 'image/ship-a.png',
  ground: 'image/hue-ramp.png',
});

const LevelAssets = Assets.compose(SharedAssets, LevelLocalAssets);

A composition shares its inputs’ handles instead of copying them: LevelAssets.logo === SharedAssets.logo, so loading the composition heals the handles the shared catalog already handed out. It adds no ownership of its own — loading and releasing behave exactly as they would for the underlying keys.

Two different catalogs may not define the same key. That ambiguity is caught at compile time (the result types as a message naming the key) and always throws at runtime:

// Assets.compose(): duplicate catalog key "ship" — two catalogs define it,
// use Assets.extend() to override it deliberately.
Assets.compose(LevelLocalAssets, Assets.from({ ship: 'image/other-ship.png' }));

The same catalog arriving twice along different paths — a diamond — is not a conflict and deduplicates:

examples/guides/loading-and-resources/compose-diamond.ts
const Left = Assets.compose(SharedAssets, Assets.from({ tree: 'image/tree.png' }));
const Right = Assets.compose(SharedAssets, Assets.from({ rock: 'image/rock.png' }));

Assets.compose(Left, Right); // { logo, click, atlas, tree, rock } - shared keys counted once

To re-declare a key on purpose, derive with Assets.extend(base, entries). New keys are added, existing keys are deliberately overridden, and the base catalog is never mutated:

Deriving a catalog
// `extend` derives a catalog: listed keys are re-declared deliberately, unknown
// ones are added. The base is never mutated - `LevelLocalAssets.ground` keeps
// pointing at its own texture.
const NightAssets = Assets.extend(LevelLocalAssets, {
  ground: 'image/particle-light.png', // deliberate override
  star: 'image/buttons.png', // new key
});

An override is a new declaration, so composing a derived catalog back together with the base it overrode conflicts on that key — as two independent declarations should.

Progress and parallel loading

Every loader.load(...) call returns a LoadingQueue<T>. It implements PromiseLike<T>, so await and Promise.all both work. It also exposes per-queue progress via onProgress:

Per-queue progress
// Every `load(...)` call returns a LoadingQueue. It is `PromiseLike`, so
// it can be awaited directly, and it reports the progress of this one
// queue through `onProgress`.
const loading = this.loader.load(LevelAssets);

loading.onProgress.add(progress => {
  this.progress = progress.loaded / progress.total;
});

LoadingProgress fields:

Field Meaning
total Total assets in this queue
loaded Successfully loaded so far
failed Failed so far
pending Not yet settled (total − loaded − failed)

Start two queues simultaneously and wait for both:

Two queues, one await
// Independent catalogs get independent queues - start both, await both.
// The result tuple keeps each catalog's shape.
const [day, night] = await Promise.all([loading, this.loader.load(NightAssets)]);

this.dayGround = day.ground;
this.nightGround = night.ground;

Each queue tracks its own progress independently. The result tuple is typed: day has the shape of LevelAssets, night has the shape of NightAssets.

A practical startup pattern — show the title screen as soon as title assets are ready, while game assets continue loading in the background:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const titleQueue = this.loader.load(TitleAssets);
  const gameQueue = this.loader.load(GameAssets);

  titleQueue.onProgress.add(p => {
    this._titleProgress = p.loaded / p.total;
  });
  gameQueue.onProgress.add(p => {
    this._gameProgress = p.loaded / p.total;
  });

  // Title assets must be ready before init runs
  await titleQueue;

  // Game assets continue loading - await them later when entering gameplay
  this._gameReady = gameQueue;
}

Signals for a global loading screen

LoadingQueue.onProgress (above) is scoped to a single loader.load(...) call. When you want a single loading screen that reflects everything the loader is doing — regardless of how many separate loader.load(...) calls triggered it — subscribe to the loader instance’s own signals instead: onLoadStart, onLoadProgress, onLoadComplete, and onLoadError. These track one shared, loader-wide batch: as long as any foreground load is in flight, new loads join the same batch instead of starting a new one.

Signal Payload Fires
onLoadStart (key, url) Once, when the loader goes from idle to active. key/url identify the asset that triggered it.
onLoadProgress (loaded, total, key) After every asset in the batch settles (success or failure). loaded/total are running counts across the whole batch; key is the asset that just settled.
onLoadComplete — Once, when every in-flight foreground load has settled and the batch returns to idle.
onLoadError (key, error) For each asset that fails. Does not prevent onLoadComplete from firing afterward.

Because the batch is shared, total can grow mid-flight: if a new loader.load(...) call starts while others are still pending, its assets are added to the same running total rather than starting a fresh count at 0. That is exactly what you want for a boot screen — one progress bar that stays accurate no matter how many scenes or systems kick off loads concurrently.

Subscribing a boot scene
override init(): void {
  const app = this.app;

  // Per-scene background. `init` runs once per activation, so navigating
  // back here from the play scene repaints the frame in this colour.
  app.clearColor.set(12, 16, 24);

  this.bar = new Graphics();
  this.label = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' });
  this.label.setAnchor(0.5, 0);

  // Every listener is kept in a field so `unload()` can take it off again.
  this.onLoadStart = (key: string) => {
    this.message = `Loading ${key}…`;
  };
  this.onLoadProgress = (loaded: number, total: number, key: string) => {
    this.loaded = loaded;
    this.total = total;
    this.message = `${loaded} / ${total} — ${key}`;
  };
  this.onLoadError = (key: string, error: Error) => {
    // onLoadComplete still fires once the rest of the batch settles.
    this.message = `Failed to load "${key}": ${error.message}`;
  };
  this.onLoadComplete = () => {
    this.enterGame();
  };

  app.loader.onLoadStart.add(this.onLoadStart);
  app.loader.onLoadProgress.add(this.onLoadProgress);
  app.loader.onLoadError.add(this.onLoadError);
  app.loader.onLoadComplete.add(this.onLoadComplete);

  // Trigger loads from anywhere - the signals above see all of them. The
  // claim goes on the application loader so the assets outlive this scene.
  app.loader.load(GameAssets);
}

Unlike LoadingQueue.onProgress, which you attach to the return value of one loader.load(...) call, these four are properties on the Loader itself — subscribe once (for example in your boot scene’s init) and they report every foreground load for the lifetime of that loader.

Guard the final navigation

onLoadComplete can fire after this scene is no longer the active one — a scene switch away from BootScene while a load is still in flight, or an app-level stop()/destroy() mid-load, are both real cases. Check this.state before navigating away, and unsubscribe from the loader signals in unload() so a listener from a torn-down scene never fires change() against a Director that has since moved on:

Unsubscribing and guarding the hand-over
override unload(): void {
  // `this.app` is still valid here - `unload()` runs before the scene is
  // detached, so the listeners can still be removed from the very loader
  // they were added to.
  const app = this.app;

  app.loader.onLoadStart.remove(this.onLoadStart);
  app.loader.onLoadProgress.remove(this.onLoadProgress);
  app.loader.onLoadError.remove(this.onLoadError);
  app.loader.onLoadComplete.remove(this.onLoadComplete);
}

/** Leaves for the game - but only while this scene is still the one on screen. */
private enterGame(): void {
  // Check `attached` first: it never throws, unlike `state`, which does
  // once the scene has been fully detached. `Active` is the only state
  // allowed to navigate - suspended, unloading, or detached must not.
  if (!this.attached || this.state !== SceneState.Active) {
    return;
  }

  const app = this.app;
  void app.scenes.change(PlayScene);
}

Scene.state is a SceneState enum, not a string — compare against SceneState.Active rather than 'active'.

Status channel instead of throwing

For leaf-capable types, the path (or an Asset.type(...) descriptor) identifies the loaded payload — there’s no separate alias to register or forget. Every handle and AssetRef exposes the same small status contract:

The status contract
export interface AssetStatus {
  /** Current load lifecycle: `'idle' | 'loading' | 'ready' | 'failed'`. */
  readonly state: LoadStateValue;
  /** `true` exactly when {@link state} is `'ready'`. */
  readonly ready: boolean;
  /** The error the last load failed with, or `null` outside `'failed'`. */
  readonly error: Error | null;
}

LoadStateValue is 'idle' | 'loading' | 'ready' | 'failed' — 'idle' is the state of a catalog leaf no loader has adopted yet.

examples/guides/loading-and-resources/scenes.ts
async load() {
  const tex = this.loader.get('image/hero.png'); // synchronous for a valid, registered suffix

  if (tex.ready) {
    // draw it
  } else if (tex.state === 'failed') {
    // tex still renders a visible "missing" checker texture
  }

  await tex.loaded; // Promise<this> - resolves once ready, rejects on failure
}

The same .state / .ready / .error / .loaded contract applies to AssetRef values such as JSON or text — read .value once .ready is true. A bare path yields an AssetRef<unknown>, so name the payload shape with Asset.type<T>(...) when you want to read into it:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const config = this.loader.get(Asset.type<{ startLevel: string }>('json', 'data/config.json'));

  await config.loaded;
  console.log(config.value.startLevel);
}

get() returns before network work settles. A later load failure moves the handle/ref to 'failed', rejects its .loaded promise, and is reported through this.app.loader.onError; calling get() again retries and can heal that same source-keyed handle. By contrast, the awaitable returned by load() rejects at the call site on failure and reports the same Error through this.app.loader.onError. Invalid input or a missing type/handler is a synchronous configuration error for either API. A catalog entry’s optional parse transform must be synchronous — if it returns a Promise (or anything else thenable), the ref it belongs to fails with an explicit error instead of silently awaiting it; do any asynchronous decoding inside the asset handler’s load phase instead.

Ownership and scopes

An asset stays in memory for as long as somebody owns it. There are three kinds of owner:

  • the application — anything acquired directly on this.app.loader is held until the app is destroyed;
  • a scene — anything acquired through this.loader (that is, scene.loader) is released when the scene ends;
  • a scope you create yourself — this.app.loader.createScope({ name }) returns an owner you destroy when you are done with it.

Several owners can hold the same asset at the same time. They share one fetch and one resident payload, and one owner letting go never invalidates another:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const level = this.app.loader.createScope({ name: 'level-1' });
  const hud = this.app.loader.createScope({ name: 'ui:hud' });

  const font = level.get('fonts/ui.png');

  hud.get('fonts/ui.png'); // the same instance - one fetch, two independent owners

  level.destroy();
  console.log(font.loadState); // 'ready' - the HUD still owns it

  hud.destroy();
  console.log(font.loadState); // 'loading' - the last owner let go
}

Create a scope whenever an asset’s lifetime is shorter than the application’s — a level, a streamed chunk, a UI panel, a prefetch. Every createScope() call returns a new owner, never an existing one: the name is a diagnostic label for inspect(), never an identifier, so two scopes created under the same name are still two independent owners.

Scopes nest. scope.createScope({ name }) creates a child that claims independently but cannot outlive its parent: destroying the child frees only the child’s claims, while destroying the parent destroys whatever children it still has, recursively. A scope created through this.loader.createScope(...) inside a scene is therefore cleaned up with that scene, even if you never destroy it yourself:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const world = this.loader.createScope({ name: 'world' });
  const chunk = world.createScope({ name: 'chunk:12,8' });

  await chunk.load(ChunkAssets);

  chunk.destroy(); // frees only the chunk's claims
  // the scene ending destroys `world` - and any chunk still alive under it
}

The hierarchy is a lifetime hierarchy only: it never affects asset identity or what a release frees. A child holding the same asset as its parent is two claims, not one.

scope.release(...) drops that scope’s claim on one asset; the object itself keeps its identity and heals back to 'loading' if you get() the same source again. It accepts a handle, an Asset descriptor, a whole catalog, or a (type, source) pair. Releasing a valid form that scope never claimed is a harmless no-op, and releasing twice is always idempotent:

examples/guides/loading-and-resources/scenes.ts
async load() {
  const title = this.app.loader.createScope({ name: 'title' });
  const logo = title.get('ui/logo.png');

  await title.load(TitleAssets);

  title.release(TitleAssets); // releases every leaf in the catalog
  title.release(logo); // a single handle from get()
  title.destroy(); // or just drop everything this scope still holds
}

There is deliberately no way to release an application-lifetime claim: app.loader.get(...) means “I want this for as long as the app runs”. Anything you intend to free later is acquired through a scene or a scope instead.

Streaming media

music and video assets are streamed by the browser: the loader hands the media element the resolved URL and lets it pull the file in as it plays. Nothing is buffered into memory up front, which is what makes a long track or a full-screen video affordable.

examples/guides/loading-and-resources/scenes.ts
async load() {
  const intro = await this.loader.load(Asset.type('video', 'video/intro.mp4'));
  const theme = await this.loader.load(Asset.type('music', 'audio/theme.ogg'));
}

A streamed asset is ready when it can start playing, not when it has fully arrived — the load resolves on the element’s canplay event. Two consequences are worth internalising:

  • Progress for a streamed asset is per asset, not per byte. The loader cannot know how much of a browser-owned transfer has landed, and it does not pretend to.
  • A failure before readiness fails the load and is reported through loader.onError. A failure after readiness — the connection dropping mid-playback — is a runtime media error and is reported by video.onError / stream.onError instead, so one load never appears to fail twice.

Streamed elements get crossOrigin: 'anonymous' by default. This matters for video: a cross-origin element without it plays, but cannot be uploaded as a texture. Pass crossOrigin: null for playback-only media on a host that sends no CORS headers, accepting that it cannot be rendered.

Ask for the complete bytes when you want ExoJS to own them — that is a separate operation, not a variant of the load:

examples/guides/loading-and-resources/scenes.ts
await this.app.loader.cacheSource(Asset.type('video', 'video/intro.mp4'));

cacheSource fetches the whole file through the loader’s cache pipeline and persists it, without building an element or making anything resident. It is on the application’s loader rather than a scene’s: nothing it does belongs to a scope, because nothing it does is owned. That is what makes media available offline, and it is the same thing a container (.exoa) entry does — container bytes are already owned by the application. See Working offline.

The descriptor is the same one you load. Whichever transport an asset arrives through, it is the same canonical asset: a source streamed from the network, one unpacked from a container and one read back from a cache resolve to one identity and one resident resource, never two.

The CORS mode is the exception, because it is baked into the element rather than into the bytes: crossOrigin: null and the default 'anonymous' for one URL are two assets, and so is 'use-credentials'. Nobody is ever handed an element whose CORS mode they did not ask for — a video that cannot be a texture never arrives where a texture was expected.

When are resources available?

The lifecycle guarantees that:

  • Inside load, you can await this.loader.load(...) to fetch assets.
  • init runs once load is complete — it is safe to read assets there, and every seamless handle you loaded is already .ready.
  • After init, this.loader is still available. Subsequent calls to this.loader.get('same/path.png') from update, draw, or other places return the same instance.
  • Calling this.loader.get('a/path/you-never-loaded.png') from anywhere kicks off the fetch itself and hands back a 'loading' placeholder — check .ready before relying on the payload being present.

Loading on demand

this.loader.load(...) (or this.app.loader.load(...) for an app-lifetime claim) works any time, not just inside the load hook — the Asset.type(...) call shown earlier is exactly that case:

Loading outside the load hook
/** Replaces the ground texture with a variant chosen at runtime. */
private async useVariant(variant: string): Promise<void> {
  const app = this.app;

  // The path is computed rather than a literal, so its type cannot be
  // inferred from the extension - name it with `Asset.type(...)`.
  const texture = await app.loader.load(Asset.type('texture', `image/${variant}.png`));

  this.ground.setTexture(texture);
}

The frame loop continues running while the promise is pending.

Loading per game phase

Split a large project into per-phase catalogs and load each one as the player reaches it, instead of fetching everything up front. A catalog is just an Assets.from(...) group (see Reusable asset references):

examples/guides/loading-and-resources/level-catalogs.ts
export const MenuAssets = Assets.from({
  logo: 'image/logo.png',
  music: Asset.type('music', 'audio/theme.ogg'),
});

export const Level1Assets = Assets.from({
  tiles: 'image/level-1/tiles.png',
  map: Asset.type<{ spawn: [number, number] }>('json', 'data/level-1.json'),
});

Load the menu catalog once and keep it resident, then load level catalogs as the player progresses:

examples/guides/loading-and-resources/scenes.ts
// In the menu scene
async load() {
  await this.loader.load(MenuAssets);
}

// Later, when entering gameplay
async _enterLevel1() {
  await this.app.loader.load(Level1Assets);
  this.tiles = Level1Assets.tiles;
  this.map = Level1Assets.map.value;
}

Loading is idempotent: a catalog whose leaves are already resident resolves immediately without re-fetching, so you can call this.app.loader.load(Level1Assets) again from anywhere without a guard. Treat catalog names and their keys as part of your project’s asset contract.

Progress for a loading screen

loader.load(catalog) returns a LoadingQueue — attach onProgress for a per-catalog bar, exactly as in Progress and parallel loading:

A per-catalog bar
// Every `load(...)` call returns a LoadingQueue. It is `PromiseLike`, so
// it can be awaited directly, and it reports the progress of this one
// queue through `onProgress`.
const loading = this.loader.load(LevelAssets);

loading.onProgress.add(progress => {
  this.progress = progress.loaded / progress.total;
});

For one screen that reflects every load regardless of how many catalogs are in flight, use the loader-wide signals from Signals for a global loading screen.

Handling failures

Awaiting a catalog rejects if any leaf fails. Wrap the await and read each leaf’s status channel to find which one — a failed seamless handle still renders a visible “missing” fallback, so the scene keeps running:

Finding the leaf that failed
// Awaiting a catalog rejects if any leaf fails. Every leaf still carries
// its own status, so the scene can name the one that broke and keep
// running - a failed seamless handle renders a visible "missing" texture.
try {
  await this.loader.load(SharedAssets);
} catch {
  if (SharedAssets.logo.state === 'failed') {
    this.loadError = `logo failed: ${SharedAssets.logo.error?.message ?? 'unknown error'}`;
  }
}

There is no separate bundle error type — every leaf carries its own .state / .error (see Status channel).

Pre-warming in the background

To fetch a catalog ahead of time without blocking the current scene, pass { priority: LoadPriority.Background }. Every leaf is still claimed and heals in place, but its fetch is routed through a low-priority queue while the frame loop keeps running:

examples/guides/loading-and-resources/scenes.ts
// Kick off the next level while the player is still in this one
this.app.loader.load(Level2Assets, { priority: LoadPriority.Background });

A backgrounded leaf is boosted to fetch immediately if something get()s or foreground-load()s it before the queue reaches it — so there’s nothing to guard against. Request it in the background early, then await this.app.loader.load(Level2Assets) when you actually need it and it resolves as soon as the in-flight fetch finishes. this.app.loader.awaitBackground() resolves once the background queue has fully drained.

Caching

By default the loader fetches from the network every session and keeps nothing between them — no cache is configured, and none of the caching machinery runs. To persist what was acquired across sessions, pass a store:

examples/guides/loading-and-resources/standalone-loader.ts
const loader = new Loader({
  basePath: '/assets/',
  cache: new IndexedDbStore('my-game'),
});

When constructing the loader through Application, pass LoaderOptions under the loader key:

examples/guides/loading-and-resources/loader-options.ts
const loader: LoaderOptions = {
  basePath: '/assets/',
  cache: new IndexedDbStore('my-game'),
};

const app = new Application({ loader });

That is the whole configuration for the usual case: one store, read and written cache-first. On first load an asset is fetched from the network and written to the store; on later sessions it is read back from the store instead. Every asset type is covered, including one an extension installs at runtime — there is nothing to register and no schema to declare.

What is cached, and under what identity

The cache holds the acquired representation, not the runtime resource. That is the value the type’s AssetSourceCodec read off the response, before any interpretation that would have to be redone anyway — the JSON text rather than the parsed object, the bytes rather than the decoded image. A cache hit still runs codec.decode and still builds the resource through the factory, so a factory never sees the persisted form and never learns where the source came from.

Each record is identified by four stable values:

Part Comes from
namespace the asset type’s id
source the request’s SourceKey
layout version the type’s layout.version
record the layout’s own name for it ('value')

The source key alone is deliberately not enough: it carries no asset type, so two types acquiring one URL would otherwise overwrite each other’s representations. Conversely two resources that differ only in how one download is interpreted share a source key — and therefore one cache record and one download.

Raise layout.version when the stored representation changes shape. Records written under the old version stop being found and are re-acquired; there is no migration path, because a cache is reconstructible by definition:

examples/guides/loading-and-resources/custom-asset-type.ts
interface WorldData {
  readonly name: string;
}

class World {
  public constructor(public readonly data: WorldData) {}
}

class WorldAssetType extends AssetType<WorldData, World, undefined, string> {
  public readonly id = 'com.example.world';
  public readonly codec = jsonSourceCodec as AssetSourceCodec<WorldData, string>;

  // Raised because the codec now keeps the response text, where version 1
  // kept the parsed object. Records written under version 1 are re-acquired.
  public override readonly layout = SingleEntryLayout.version<string>(2);

  public createFactory(): AssetFactory<WorldData, World> {
    return { create: data => Promise.resolve(new World(data)) };
  }
}

Policies

A CachePolicy decides only the ORDER in which the cache and the network are consulted. Four are built in:

Policy Reads cache Fetches Writes On failure
CacheFirstPolicy first only on a miss what it fetched a read or write failure degrades; the load still succeeds from the network
NetworkFirstPolicy on fallback always, first what it fetched falls back to the cache only for a transport or HTTP failure
NetworkOnlyPolicy never always never the network failure surfaces
CacheOnlyPolicy only never never a miss rejects with AssetCacheMissError, a broken store with AssetCacheError

CacheFirstPolicy is the default. NetworkFirstPolicy falls back deliberately narrowly: a cancelled load stays cancelled, and a response the codec could not read is a broken source rather than an absent network — serving a stale representation for either would replace a visible failure with a silently wrong asset.

Writing a policy needs nothing but the CacheContext it is handed:

examples/guides/loading-and-resources/cache-policy.ts
class MyCacheFirstPolicy implements CachePolicy {
  public async resolve<T>(context: CacheContext<T>): Promise<T> {
    const cached = await context.read();

    if (cached.hit) {
      return cached.value;
    }

    const value = await context.fetch();

    await context.write(value);

    return value;
  }
}

A policy is never handed a factory, a codec, an asset type or a store handle. It is a stateless object and may be shared between routes and applications: everything one call needs arrives in its context.

Failure semantics

A cache miss and a cache failure are different events, and stay different:

  • context.read() resolves to { hit: false } when no store held the record, and rejects when a store could not answer.
  • context.write() rejects when a store refused the write, after attempting every one of them.
  • A store never swallows a failure to look like an empty cache. Whether to degrade is the policy’s decision — which is why CacheOnlyPolicy can tell “this was never written” from “the database is broken”, and CacheFirstPolicy can treat both as a reason to fetch.

Every store failure is reported on Loader.onCacheError before any policy degrades it, so a store that is quietly refusing every write stays diagnosable:

examples/guides/loading-and-resources/cache-errors.ts
const app = new Application();
const { loader } = app;

loader.onCacheError.add(error => {
  console.warn(`cache ${error.operation} failed for ${error.store}`, error.cause);
});

Several stores, and per-type routes

For more than one tier, configure an AssetCache:

examples/guides/loading-and-resources/cache-routes.ts
const persistent = new IndexedDbStore('my-game');

const loader: LoaderOptions = {
  cache: new AssetCache({
    read: [new MemoryCacheStore(), persistent],
    write: [persistent],
    promote: true,
    routes: [new CacheRoute({ types: ['com.example.config'], policy: new NetworkFirstPolicy(), stores: persistent })],
  }),
};

const app = new Application({ loader });

Read stores are consulted in the order they were given and the first hit wins — never raced, so which store answered, which failure surfaced and what was promoted are the same on every run. Read and write lists are separate, so a route can read a cache shipped with the application without writing to it, and promote: true copies a hit from a later store into the earlier writable ones.

Routes are matched by asset type id, in declaration order; the first route that claims a type wins, and anything no route claims falls to the options given at the top level. A route without types claims everything from its position onwards.

To drop cached records — after a content update, or when a player asks for it — call cache.clear(), or cache.clear(typeId) for one type.

The persistent store

IndexedDbStore keeps every record of every type in a single generic object store, with the namespace as part of the record key rather than as physical schema. That is what lets a type installed at runtime cache immediately: no schema version bump, no object store, no engine-side registration.

Values are stored through the structured-clone algorithm, so strings, ArrayBuffers, typed arrays, Blobs and plain objects all round-trip without a JSON layer. A write resolves only once its transaction has committed.

A database written under an earlier physical schema is emptied on first open. Cached representations are re-fetchable by definition, so they are discarded rather than migrated.

MemoryCacheStore is the in-process counterpart: it holds values by reference for the lifetime of the page, which makes it a good front tier and the obvious choice in tests.

Packs and the asset manifest

A .exoa container is compressed in blocks, and a block boundary always falls on an entry boundary, so changing one asset changes that asset’s blocks and no others. Passing a ContainerBlockStore to loadContainer is what turns that into traffic: the head is read first, and only the blocks the store does not already hold are fetched.

That only pays off if a returning client can tell that the pack changed without downloading it. The asset manifest is what makes it so. exo assets pack <pack-description> --manifest dist/assets.json writes each pack under a name derived from the hash of its own bytes (level1.4b17e9a02c8d1f35.exoa) and keeps a small JSON document beside the packs that says which file currently holds each logical pack:

{
  "version": 1,
  "packs": {
    "level1": {
      "file": "level1.4b17e9a02c8d1f35.exoa",
      "hash": "4b17e9a02c8d1f35...",
      "byteLength": 812345,
      "blockCount": 4,
      "entries": ["images/hero.png", "data/level1.json"]
    }
  }
}

A pack’s URL therefore changes exactly when its bytes change, which means the pack file can be served with an immutable cache lifetime and the manifest is the one URL a client has to re-read. Read it with loadManifest and hand the pack to loadContainer:

import { cacheApiBlockStore, type Loader } from '@codexo/exojs';

export const loadLevel = async (loader: Loader): Promise<void> => {
  const manifest = await loader.loadManifest('assets.json');

  await loader.loadContainer(manifest.pack('level1'), { store: cacheApiBlockStore() });
};

Re-packing after one asset changed produces a new pack name and a manifest that points at it; the blocks that did not change are already in the store, keyed by their own hash, and are never fetched again. Nothing about that is automatic for a pack loaded from a plain URL, which is the reason the manifest exists.

The manifest describes packs without opening them, so manifest.packs, manifest.has(name) and manifest.packFor('images/hero.png') answer before a single pack byte is fetched. A name the manifest does not carry throws and names the ones it does. Pack bytes that disagree with the record - a wrong length, or a digest that is not the one stated - are an AssetDecodeError, the same failure any other unusable asset raises, and they are never written to the cache. How much is checked follows how much is read: the single-request path holds the whole file and verifies the digest, while the block-wise path verifies the length and rests on the content-addressed URL for the rest. The manifest itself is always read from the network, so it is the one asset an offline application cannot get.

Save data with a key-value store

The cache above persists loaded assets. For user save data (settings, profiles, checkpoints) use a KeyValueStore instead — a small key/value surface whose backend you pick by capability:

  • WebStorageStore — localStorage/sessionStorage, JSON-serialized: small, synchronous, strings only.
  • IndexedDbKeyValueStore — IndexedDB via structured clone: large, async, stores Blobs and ArrayBuffers natively.
  • MemoryStore — in-memory and ephemeral, for tests and throwaway data.
examples/guides/loading-and-resources/key-value-store.ts
const saves = new WebStorageStore(localStorage, { prefix: 'my-game:' });

await saves.set('slot-1', {
  level: 4,
  score: 12800,
  options: { music: true, sfx: false },
});

const slot = await saves.get('slot-1'); // object | null
await saves.delete('slot-1');

All three share one async interface, so swapping WebStorageStore for IndexedDbKeyValueStore — when a save outgrows Web Storage’s ~5 MB quota or needs binary data — is a one-line change. To persist an entire scene, pair a store with Scene.serialize().

Custom asset types (advanced)

Teach the loader about a domain-specific resource by writing an AssetType. One value carries everything the loader needs: what the type is called, which suffixes name it, how its data is read, and how a resource is built from it.

First extend AssetDefinitions via declaration merging so the type system knows the new type, then write the type:

examples/guides/loading-and-resources/height-field-type.ts
// 1. The resource your factory produces.
export class HeightField {
  public constructor(public readonly rows: readonly number[][]) {}
}

// 2. Register the type with the type system (declaration merging).
declare module '@codexo/exojs' {
  interface AssetDefinitions {
    heightField: { resource: HeightField; config: { source: string }; isValue: true };
  }
}

// 3. Describe the type. `codec` says how the data is read; `createFactory`
//    turns it into the resource, once per application.
export class HeightFieldAssetType extends AssetType<string, HeightField> {
  public readonly id = 'com.example.height-field';
  public override readonly codec: AssetSourceCodec<string> = textSourceCodec;

  public createFactory(): AssetFactory<string, HeightField> {
    return { create: source => Promise.resolve(new HeightField(parseHeightField(source))) };
  }
}

export const heightFieldType = new HeightFieldAssetType();

function parseHeightField(text: string): number[][] {
  return text.split('\n').map(row => row.split(',').map(Number));
}

The id is the type’s permanent name: it appears in resource identities and in the storage namespace a persistent cache writes under, so it must survive a reload. Reverse-DNS keeps independently authored types apart.

A factory never fetches. It receives source data the loader already acquired, and its only outward reach is dependencies, which acquires other assets:

Member What it gives you
context.options the options this request carried, typed by the asset type
context.source the source as the caller wrote it - resolve relative refs against it
context.dependencies loads assets this resource needs, released together with it
context.signal the load’s cancellation signal

Override resourceIdentity(request) when an option changes the resource the factory builds, and sourceIdentity(request) when it changes which data is acquired - omit both when the source alone identifies the asset.

Install the type by listing it on an Extension passed to ApplicationOptions.extensions. Installing is the only thing that makes it loadable, and it makes it loadable on that application alone - two applications in one process can map the same suffix to different types without seeing each other. Once installed, the type works everywhere the built-ins do:

examples/guides/loading-and-resources/height-field-load.ts
// The type itself gives you a fully typed, loadable descriptor
const field = await this.loader.load(heightFieldType.asset('maps/level-1.hf'));

// Or grouped in a catalog
const World = Assets.from({ level1: heightFieldType.asset('maps/level-1.hf') });

Examples

Texture LoaderOpen in PlaygroundView source

Preview is paused until you click Play.

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

class TextureLoaderScene extends Scene {
  private sprites!: Sprite[];
  private textures!: Texture[];
  private bar!: Graphics;
  private label!: Text;
  private barX = 0;
  private barY = 0;
  private barWidth = 0;
  private progress = { loaded: 0, total: 3 };

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

    // Seamless get() returns placeholder handles immediately; each pops in
    // (loadState → 'ready') as its fetch completes, polled in update().
    this.textures = [this.loader.get('image/ship-a.png'), this.loader.get('image/hue-ramp.png'), this.loader.get('image/uv-grid-256.png')];

    // Spread the three textures evenly across the width, one per third.
    this.sprites = this.textures.map((texture, index) => {
      const sprite = new Sprite(texture);
      sprite.setAnchor(0.5);
      sprite.setPosition((width / this.textures.length) * (index + 0.5), height * 0.6);
      return sprite;
    });

    // Centered progress bar in the upper third.
    this.barWidth = width * 0.5;
    this.barX = (width - this.barWidth) / 2;
    this.barY = height * 0.22;

    this.bar = new Graphics();
    this.label = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' });
    this.label.setAnchor(0.5, 0);
    this.label.setPosition(width / 2, this.barY + 40);
  }

  override update(): void {
    this.progress.loaded = this.textures.filter(texture => texture.loadState === 'ready').length;
  }

  override draw(context: RenderingContext): void {
    const { loaded, total } = this.progress;
    this.bar.clear();
    this.bar.fillColor = new Color(60, 60, 60);
    this.bar.drawRectangle(this.barX, this.barY, this.barWidth, 24);
    this.bar.fillColor = new Color(90, 220, 120);
    this.bar.drawRectangle(this.barX, this.barY, total > 0 ? (this.barWidth * loaded) / total : 0, 24);
    context.render(this.bar);
    this.label.text = `Loaded ${loaded} / ${total}`;
    context.render(this.label);

    for (const sprite of this.sprites) {
      context.render(sprite);
    }
  }
}

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

await app.start(TextureLoaderScene);

The minimum loader workflow — register textures during load, retrieve them during init, track per-queue progress with LoadingQueue.onProgress.

Asset CatalogsKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, Assets, type AudioStream, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, Sprite, Text, type Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

// A catalog is a named, typed group of assets. A bare path infers its type from
// the file extension; anything else takes an explicit `Asset.type(...)`.
const SharedAssets = Assets.from({
  logo: 'image/uv-grid-256.png',
  click: Asset.type('sound', 'audio/ui-click.ogg'),
  atlas: Asset.type<{ frames: Record<string, unknown> }>('json', 'json/buttons.json'),
});
// `compose` merges catalogs into one ordinary, fully typed catalog. It SHARES
// its inputs' handles instead of copying them, so `LevelAssets.logo` is the very
// same Texture object as `SharedAssets.logo`.
const LevelLocalAssets = Assets.from({
  ship: 'image/ship-a.png',
  ground: 'image/hue-ramp.png',
});

const LevelAssets = Assets.compose(SharedAssets, LevelLocalAssets);
// `extend` derives a catalog: listed keys are re-declared deliberately, unknown
// ones are added. The base is never mutated - `LevelLocalAssets.ground` keeps
// pointing at its own texture.
const NightAssets = Assets.extend(LevelLocalAssets, {
  ground: 'image/particle-light.png', // deliberate override
  star: 'image/buttons.png', // new key
});
class AssetCatalogsScene extends Scene {
  private logo!: Sprite;
  private ship!: Sprite;
  private ground!: Sprite;
  private summary!: Text;
  private bar!: Graphics;
  private hud!: ReturnType<typeof mountControls>;

  private dayGround!: Texture;
  private nightGround!: Texture;
  private theme!: AudioStream;

  private progress = 0;
  private frameCount = 0;
  private loadError = '';
  private night = false;
  private barX = 0;
  private barY = 0;
  private barWidth = 0;

  override async load(): Promise<void> {
    // Every `load(...)` call returns a LoadingQueue. It is `PromiseLike`, so
    // it can be awaited directly, and it reports the progress of this one
    // queue through `onProgress`.
    const loading = this.loader.load(LevelAssets);

    loading.onProgress.add(progress => {
      this.progress = progress.loaded / progress.total;
    });
    // Independent catalogs get independent queues - start both, await both.
    // The result tuple keeps each catalog's shape.
    const [day, night] = await Promise.all([loading, this.loader.load(NightAssets)]);

    this.dayGround = day.ground;
    this.nightGround = night.ground;
    // Non-leaf types (`music`, `video`, `bmFont`, `font`, ...) have no
    // bare-path form and no placeholder to hand back, even for a literal
    // path - they are always loaded by reference and awaited.
    this.theme = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg'));
    // Awaiting a catalog rejects if any leaf fails. Every leaf still carries
    // its own status, so the scene can name the one that broke and keep
    // running - a failed seamless handle renders a visible "missing" texture.
    try {
      await this.loader.load(SharedAssets);
    } catch {
      if (SharedAssets.logo.state === 'failed') {
        this.loadError = `logo failed: ${SharedAssets.logo.error?.message ?? 'unknown error'}`;
      }
    }
  }

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

    // A catalog's properties are the same objects that existed before the
    // load - now populated. There is no separate `get()` step.
    this.logo = new Sprite(LevelAssets.logo);
    this.ship = new Sprite(LevelAssets.ship);
    this.ground = new Sprite(LevelAssets.ground);

    // A value entry resolves to an AssetRef - read `.value` once `.ready`.
    this.frameCount = Object.keys(LevelAssets.atlas.value.frames).length;

    this.logo
      .setAnchor(0.5)
      .setPosition(width * 0.25, height * 0.55)
      .setScale(0.9);
    this.ship.setAnchor(0.5).setPosition(width * 0.5, height * 0.55);
    this.ground
      .setAnchor(0.5)
      .setPosition(width * 0.75, height * 0.55)
      .setScale(1.4);

    this.barWidth = width * 0.5;
    this.barX = (width - this.barWidth) / 2;
    this.barY = height * 0.16;
    this.bar = new Graphics();

    this.summary = new Text('', { fillColor: Color.white, fontSize: 18, align: 'center' });
    this.summary.setAnchor(0.5, 0).setPosition(width / 2, this.barY + 44);

    this.inputs.onTrigger(Keyboard.N, () => {
      this.night = !this.night;
      this.ground.setTexture(this.night ? this.nightGround : this.dayGround);
    });

    this.inputs.onTrigger(Keyboard.M, () => {
      app.audio.play(this.theme, { volume: 0.5 });
    });

    this.inputs.onTrigger(Keyboard.G, () => {
      void this.useVariant('hue-ramp');
    });

    this.hud = mountControls({
      title: 'Asset Catalogs',
      controls: [
        { keys: 'N', action: 'swap the ground texture for the derived night catalog' },
        { keys: 'G', action: 'load a ground texture by a computed path' },
        { keys: 'M', action: 'play the streamed theme (a non-leaf asset)' },
      ],
      hint: 'Three catalogs — a shared one, a composed one, and one derived with extend — loaded through two parallel queues.',
    });
  }

  /** Replaces the ground texture with a variant chosen at runtime. */
  private async useVariant(variant: string): Promise<void> {
    const app = this.app;

    // The path is computed rather than a literal, so its type cannot be
    // inferred from the extension - name it with `Asset.type(...)`.
    const texture = await app.loader.load(Asset.type('texture', `image/${variant}.png`));

    this.ground.setTexture(texture);
  }
  override draw(context: RenderingContext): void {
    this.bar.clear();
    this.bar.fillColor = new Color(48, 52, 62);
    this.bar.drawRectangle(this.barX, this.barY, this.barWidth, 22);
    this.bar.fillColor = new Color(110, 200, 255);
    this.bar.drawRectangle(this.barX, this.barY, this.barWidth * this.progress, 22);
    context.render(this.bar);

    const failure = this.loadError === '' ? '' : `\n${this.loadError}`;
    this.summary.text =
      `LevelAssets = compose(SharedAssets, LevelLocalAssets) — ${Object.keys(LevelAssets.entries).length} keys\n` +
      `atlas frames: ${this.frameCount}   ground: ${this.night ? 'night' : 'day'}   queue: ${Math.round(this.progress * 100)}%${failure}`;
    context.render(this.summary);

    context.render(this.logo);
    context.render(this.ship);
    context.render(this.ground);
  }

  override destroy(): void {
    this.hud.dispose();
    super.destroy();
  }
}

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

await app.start(AssetCatalogsScene);

The catalog snippets on this page, end to end: a mixed Assets.from group, a composition, a derived catalog, two parallel queues, and a runtime-computed path.

Loading ScreenKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, Assets, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, SceneState, Sprite, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const GameAssets = Assets.from({
  ship: 'image/ship-a.png',
  grid: 'image/uv-grid-256.png',
  ramp: 'image/hue-ramp.png',
  click: Asset.type('sound', 'audio/ui-click.ogg'),
});

/**
 * One progress bar for everything the loader is doing, then a hand-over to the
 * game scene. Nothing is awaited in `load()`: the bar is driven by the loader's
 * own signals, which see every `load(...)` call from every scene and system -
 * not just this scene's.
 */
class BootScene extends Scene {
  private bar!: Graphics;
  private label!: Text;

  private loaded = 0;
  private total = 0;
  private message = 'Waiting for the first request…';

  private onLoadStart!: (key: string, url: string) => void;
  private onLoadProgress!: (loaded: number, total: number, key: string) => void;
  private onLoadError!: (key: string, error: Error) => void;
  private onLoadComplete!: () => void;

  override init(): void {
    const app = this.app;

    // Per-scene background. `init` runs once per activation, so navigating
    // back here from the play scene repaints the frame in this colour.
    app.clearColor.set(12, 16, 24);

    this.bar = new Graphics();
    this.label = new Text('', { fillColor: Color.white, fontSize: 20, align: 'center' });
    this.label.setAnchor(0.5, 0);

    // Every listener is kept in a field so `unload()` can take it off again.
    this.onLoadStart = (key: string) => {
      this.message = `Loading ${key}…`;
    };
    this.onLoadProgress = (loaded: number, total: number, key: string) => {
      this.loaded = loaded;
      this.total = total;
      this.message = `${loaded} / ${total} — ${key}`;
    };
    this.onLoadError = (key: string, error: Error) => {
      // onLoadComplete still fires once the rest of the batch settles.
      this.message = `Failed to load "${key}": ${error.message}`;
    };
    this.onLoadComplete = () => {
      this.enterGame();
    };

    app.loader.onLoadStart.add(this.onLoadStart);
    app.loader.onLoadProgress.add(this.onLoadProgress);
    app.loader.onLoadError.add(this.onLoadError);
    app.loader.onLoadComplete.add(this.onLoadComplete);

    // Trigger loads from anywhere - the signals above see all of them. The
    // claim goes on the application loader so the assets outlive this scene.
    app.loader.load(GameAssets);
  }
  override unload(): void {
    // `this.app` is still valid here - `unload()` runs before the scene is
    // detached, so the listeners can still be removed from the very loader
    // they were added to.
    const app = this.app;

    app.loader.onLoadStart.remove(this.onLoadStart);
    app.loader.onLoadProgress.remove(this.onLoadProgress);
    app.loader.onLoadError.remove(this.onLoadError);
    app.loader.onLoadComplete.remove(this.onLoadComplete);
  }

  /** Leaves for the game - but only while this scene is still the one on screen. */
  private enterGame(): void {
    // Check `attached` first: it never throws, unlike `state`, which does
    // once the scene has been fully detached. `Active` is the only state
    // allowed to navigate - suspended, unloading, or detached must not.
    if (!this.attached || this.state !== SceneState.Active) {
      return;
    }

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

    const barWidth = width * 0.5;
    const barX = (width - barWidth) / 2;
    const barY = height / 2;
    const ratio = this.total > 0 ? this.loaded / this.total : 0;

    this.bar.clear();
    this.bar.fillColor = new Color(40, 46, 58);
    this.bar.drawRectangle(barX, barY, barWidth, 26);
    this.bar.fillColor = new Color(110, 220, 150);
    this.bar.drawRectangle(barX, barY, barWidth * ratio, 26);
    context.render(this.bar);

    this.label.text = this.message;
    this.label.setPosition(width / 2, barY + 44);
    context.render(this.label);
  }
}

class PlayScene extends Scene {
  private ship!: Sprite;
  private label!: Text;
  private hud!: ReturnType<typeof mountControls>;

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

    app.clearColor.set(16, 26, 22);

    // Already resident: BootScene claimed the catalog on the application
    // loader, so reading the same handles here costs nothing.
    this.ship = new Sprite(GameAssets.ship).setAnchor(0.5).setPosition(width / 2, height / 2);

    this.label = new Text('Loaded — press Space to boot again.', { fillColor: Color.white, fontSize: 22, align: 'center' });
    this.label.setAnchor(0.5, 0).setPosition(width / 2, height * 0.68);

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

    this.hud = mountControls({
      title: 'Loading Screen',
      controls: [{ keys: 'Space', action: 'return to the boot scene' }],
      hint: 'The boot scene drives its bar from the loader-wide signals, then navigates once the shared batch drains.',
    });
  }

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

  override destroy(): void {
    this.hud.dispose();
    super.destroy();
  }
}

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

await app.start(BootScene);

The boot-scene snippets on this page — one bar driven by the loader-wide signals, unsubscribed in unload, with a guarded hand-over to the game scene.

Where to go next

That closes the runtime and asset model. The next part, Rendering, covers what you can put on screen — graphics primitives, sprites, text, animation, and render targets.