Loading and resources
The asset pipeline — how the loader registers, fetches, and resolves resources before your scene starts updating.
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.
Load different types together, not one await at a time
Awaiting loads sequentially makes each one wait for the previous download to finish. Group independent this.loader.load(...) calls in a single Promise.all so unrelated resource types fetch concurrently.
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:
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:
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.
get() returns synchronously for a valid leaf type
For a registered seamless or value suffix, this.loader.get(...) returns synchronously — even before the fetch finishes. Ask for a valid path you haven’t loaded yet and you get a placeholder handle/ref immediately, which then heals once the network request resolves. Invalid input or a missing type/handler still throws synchronously. Check .ready / .state for asynchronous fetch outcomes (see Status channel below).
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:
/** 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:
// 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'));get(Asset.type(...)) is the dynamic-path escape hatch
For seamless and value types, this.loader.get(Asset.type(type, dynamicPath)) returns a handle or AssetRef immediately and starts the asynchronous load. Capture that returned object: each descriptor call creates a fresh leaf, although the backend fetch is still deduplicated. Non-leaf types have no synchronous handle and must use await this.loader.load(Asset.type(...)).
Multiple resource types in parallel
When a scene needs different asset types, load them in parallel with Promise.all:
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 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:
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:
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:
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:
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:
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:
// `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:
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 onceTo 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:
// `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:
// 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:
// 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:
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.
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:
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:
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.
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:
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.loaderis 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:
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:
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:
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.
release() only accepts a real asset identity
Passing something that isn’t a handle, descriptor, catalog, or (type, source) pair — a plain object, a resolved non-seamless resource, anything release() can’t resolve to a claim — throws instead of doing nothing. If you loaded a non-seamless type by reference (load(Asset.type('bmFont', ...))), release it with the same Asset descriptor or the (type, source) pair, not the resolved resource itself.
Non-seamless types have no placeholder
Types that aren’t seamless — AudioStream, Video, BmFont, FontAsset, and custom types — have no placeholder to hand back, so there’s nothing to get() speculatively. Load them by reference and hold onto the resolved value: const music = await this.loader.load(Asset.type('music', 'audio/theme.ogg')). There is no has()/peek() guard — for a seamless handle read .ready / .state; for a non-seamless resource, await the load (or its returned handle’s .loaded promise) before you use it.
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.
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 byvideo.onError/stream.onErrorinstead, 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:
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.
Inspecting what's currently loaded
this.app.loader.inspect() returns a frozen, sorted snapshot — one row per canonical asset, with its state, how many owners hold it, and whether it’s in flight or still queued in the background — for debugging or a support bundle. It’s read-only: nothing you do with the returned array changes what’s actually loaded.
When are resources available?
The lifecycle guarantees that:
- Inside
load, you canawait this.loader.load(...)to fetch assets. initruns onceloadis complete — it is safe to read assets there, and every seamless handle you loaded is already.ready.- After
init,this.loaderis still available. Subsequent calls tothis.loader.get('same/path.png')fromupdate,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.readybefore 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:
/** 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):
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:
// 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:
// 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:
// 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:
// 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:
const loader = new Loader({
basePath: '/assets/',
cache: new IndexedDbStore('my-game'),
});When constructing the loader through Application, pass LoaderOptions under the loader key:
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:
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:
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
CacheOnlyPolicycan tell “this was never written” from “the database is broken”, andCacheFirstPolicycan 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:
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:
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, storesBlobs andArrayBuffers natively.MemoryStore— in-memory and ephemeral, for tests and throwaway data.
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:
// 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:
// 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') });Bare-path inference is built-in only
heightFieldType.asset(...) always works, and so does Asset.type('com.example.height-field', ...) on an application that installed the type. A bare this.loader.load('maps/level-1.hf') also works there once the type claims the suffix and the type system mirrors the mapping:
export class HeightFieldAssetType extends AssetType<string, HeightField> {
public readonly id = 'com.example.height-field';
public override readonly extensions = ['hf'];
public override readonly leaf = heightFieldSeamlessAdapter; // heals in place, like Texture/Sound
/* … */
}
declare module '@codexo/exojs' {
interface ExtensionKindMap {
hf: 'com.example.height-field';
}
}Assets.from('maps/level-1.hf') is the one place this does not reach: a catalog is built with no application, so only the built-in suffixes resolve there. Name the asset with heightFieldType.asset(...) in a catalog instead.
leaf decides what a catalog hands out before the payload arrives: 'ref' (the default) a deferred AssetRef, a SeamlessAdapter the resource itself healing in place, 'none' nothing at all. The built-in texture and sound types are the reference for a seamless resource; json / text for a deferred value.
Examples
The minimum loader workflow — register textures during load, retrieve them during init, track per-queue progress with LoadingQueue.onProgress.
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.
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.

