Guide

Guide Assets Loading and resources

Loading and resources

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

Intermediate ~7 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. The same instance is passed into a scene’s load(loader) and init(loader) hooks. You can also access it directly from any scene as this.app.loader once the scene has been registered.

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

Choose the form that fits

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

FormBest for
loader.load(Texture, 'hero.png')One asset, no alias needed
loader.load(Texture, { hero: 'hero.png' })Several assets of the same type
loader.load({ hero: { type: 'texture', … }, … })Mixed types in one call
new Asset(…) / new Assets(…)Reusable, file-level asset references

When in doubt, start simple. Promote to Asset/Assets when the same definition is referenced from more than one place.

Loading a single asset

The path-string form resolves directly to the finished asset:

async load(loader) {
    const texture = await loader.load(Texture, 'image/hero.png');
    this.hero = new Sprite(texture);
}

The trade-off is no alias — the texture is not registered for retrieval by name later. Use the record form when you’ll call loader.get(...) after the promise resolves.

Homogeneous batch

When you need several assets of the same type, pass a record:

async load(loader) {
    await loader.load(Texture, {
        hero:   'image/hero.png',
        ground: 'image/ground.png',
        coin:   'image/coin.png',
    });
}

init(loader) {
    this.hero   = new Sprite(loader.get(Texture, 'hero'));
    this.ground = new Sprite(loader.get(Texture, 'ground'));
}

Keys are aliases — names you choose. The loader.basePath option in ApplicationOptions prepends a base prefix to all relative URLs, so these keys resolve to e.g. assets/image/hero.png.

Calling loader.get(Texture, 'hero') returns the loaded Texture instance synchronously — the promise from loader.load(...) already settled before init ran.

Values can also be config objects when you need type-specific fields alongside the source:

await loader.load(Texture, {
    hero:    'image/hero.png',
    heroAlt: { source: 'image/hero-alt.png', /* type-specific options */ },
});

Multiple resource types in parallel

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

import { Texture, Music, Sound, Json } from '@codexo/exojs';

async load(loader) {
    await Promise.all([
        loader.load(Texture, { sky: 'image/sky.png' }),
        loader.load(Music,   { ambient: 'audio/ambient.ogg' }),
        loader.load(Sound,   { coin: 'audio/coin.wav', jump: 'audio/jump.wav' }),
        loader.load(Json,    { levels: 'data/levels.json' }),
    ]);
}

init(loader) {
    this.sky    = new Sprite(loader.get(Texture, 'sky'));
    this.music  = loader.get(Music, 'ambient');
    this.levels = loader.get(Json, 'levels');
}

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

Mixed asset map

To load different types in a single call and receive a single typed result object, pass an inline config map — a plain object whose values each have a type string:

async load(loader) {
    await loader.load({
        hero: {
            type:   'texture',
            source: 'image/hero.png',
        },
        click: {
            type:   'sound',
            source: 'audio/click.ogg',
        },
    });
}

The result is an object whose keys match the input and whose values are the resolved resource instances. Both loader.get(Texture, 'hero') and loader.get(Sound, 'click') work after the promise resolves.

Reusable asset references

For assets used in multiple scenes, define them as file-level constants with Asset and Assets:

import { Asset, Assets } from '@codexo/exojs';

// A single typed, loadable reference
export const HeroAsset = new Asset({
    type:   'texture',
    source: 'image/hero.png',
});

// A typed group of related assets
export const TitleAssets = new Assets({
    logo:  { type: 'texture', source: '/logo.png' },
    music: { type: 'music',   source: '/title.ogg' },
});

Load them directly:

// Resolves to Texture
const hero = await loader.load(HeroAsset);

// Resolves to { logo: Texture, music: Music }
const title = await loader.load(TitleAssets);

Typed properties on Assets give autocomplete on the container itself:

TitleAssets.logo;   // Asset<Texture>
TitleAssets.music;  // Asset<Music>

Assets also exposes a .entries property for spread composition — useful when you want to merge several asset groups into a single load call without nesting them:

await loader.load({
    ...CommonAssets.entries,
    ...TitleAssets.entries,
});

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

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:

const loading = loader.load(TitleAssets);

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

const title = await loading;

LoadingProgress fields:

FieldMeaning
totalTotal assets in this queue
loadedSuccessfully loaded so far
failedFailed so far
pendingNot yet settled (total − loaded − failed)

Start two queues simultaneously and wait for both:

const [title, game] = await Promise.all([
    loader.load(TitleAssets),
    loader.load(GameAssets),
]);

Each queue tracks its own progress independently. The result tuple is typed: title has the shape of TitleAssets, game has the shape of GameAssets.

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(loader) {
    const titleQueue = loader.load(TitleAssets);
    const gameQueue  = 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;
}

Aliases and identity

Aliases come from the keys you supply in a record or Assets container. The same Asset<T> reference can appear under multiple aliases — the loader issues only one network request and stores the result under each alias independently:

const HeroAsset = new Asset({ type: 'texture', source: 'image/hero.png' });

await loader.load({ heroA: HeroAsset, heroB: HeroAsset });

loader.get(Texture, 'heroA'); // Texture
loader.get(Texture, 'heroB'); // same Texture instance

loader.unload(asset) removes all aliases mapped to that asset’s identity at once:

loader.unload(HeroAsset); // removes heroA and heroB

When are resources available?

The lifecycle guarantees that:

  • Inside load, you can await loader.load(...) to register and fetch assets.
  • After load resolves, every alias you registered is available via loader.get(...).
  • init runs once load is complete — it is safe to read assets there.
  • After init, the same loader is still available. Subsequent calls to loader.get(...) from update, draw, or other places work as long as the asset was loaded.

If you call loader.get(SomeType, 'unknown-alias'), the loader throws — there is no silent fallback. This is deliberate: a missing asset usually means you forgot to register it in load, and you want to catch that immediately.

Loading on demand

loader.load(...) works any time, not just inside the load hook:

async _changeLevel(levelName) {
    await this.app.loader.load(Texture, {
        [`level-${levelName}`]: `image/levels/${levelName}.png`,
    });

    this.background.setTexture(
        this.app.loader.get(Texture, `level-${levelName}`)
    );
}

The frame loop continues running while the promise is pending.

Manifests and bundles

For larger projects, declare all assets in a manifest and load bundles on demand:

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

const manifest = defineAssetManifest({
    bundles: {
        menu: [
            { type: Texture, alias: 'logo',  path: 'image/logo.png' },
            { type: Music,   alias: 'theme', path: 'audio/theme.ogg' },
        ],
        'level-1': [
            { type: Texture, alias: 'tiles', path: 'image/level-1/tiles.png' },
            { type: Json,    alias: 'map',   path: 'data/level-1.json' },
        ],
    },
});

loader.registerManifest(manifest);

// Later:
await loader.loadBundle('menu');
await loader.loadBundle('level-1');

defineAssetManifest validates the shape at authoring time — non-empty bundle names, valid entries, no duplicate aliases within a type — so a typo throws where you declare it, not deep inside a load. After a bundle resolves, read its assets by the aliases you declared, exactly like a direct load:

await loader.loadBundle('menu');

this.logo  = new Sprite(loader.get(Texture, 'logo'));
this.theme = loader.get(Music, 'theme');

Bundles are useful when scene loads should be granular — load the menu bundle once and keep it resident, then load level bundles as the player progresses. Treat bundle names and aliases as part of your project’s asset contract.

Progress for a loading screen

loadBundle accepts an onProgress callback that fires after each asset in the bundle settles — drive a progress bar with it:

await loader.loadBundle('level-1', {
    onProgress: (loaded, total) => {
        this.bar.value = loaded / total;
    },
});

For a single screen that reflects every bundle, subscribe to the loader-wide onBundleProgress signal instead. It reports the bundle name alongside the counts:

loader.onBundleProgress.add((name, loaded, total) => {
    console.log(`${name}: ${loaded}/${total}`);
});

Handling failures

If any asset in a bundle fails, loadBundle waits for the rest to settle and then throws a BundleLoadError. Its failures array lists every entry that errored — the type, alias, and underlying error — so you can show a retry prompt or fall back to a placeholder rather than dropping the whole bundle:

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

try {
    await loader.loadBundle('level-1');
} catch (error) {
    if (error instanceof BundleLoadError) {
        for (const failure of error.failures) {
            console.warn(`Failed: ${failure.alias}`, failure.error);
        }
        // retry, substitute a fallback asset, or surface a message
    }
}

Pre-warming in the background

To load a bundle ahead of time without blocking the current scene, pass background: true. The bundle loads through a low-priority queue while the frame loop keeps running:

// Kick off the next level while the player is still in this one
loader.loadBundle('level-2', { background: true });

Guard against re-loading with loader.hasBundle(name), which returns true once every asset in the bundle is resident in memory:

if (!loader.hasBundle('level-2')) {
    await loader.loadBundle('level-2');
}

Caching and IndexedDB

By default the loader fetches from the network every session. To persist processed assets across sessions, pass an IndexedDbStore:

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

const loader = new Loader({
    basePath: '/assets/',
    cache: new IndexedDbStore('my-game'),
});

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

const app = new Application({
    loader: {
        basePath: '/assets/',
        cache: new IndexedDbStore('my-game'),
    },
});

On first load, assets are fetched from the network and written to IndexedDB. On subsequent sessions they load from the database instead. The default store configuration covers all built-in asset types and the fetch* context helpers used by custom handlers.

If you supply a custom storeNames array, include '__ctx_text', '__ctx_json', and '__ctx_binary' if your custom asset handlers call context.fetchText, context.fetchJson, or context.fetchArrayBuffer.

Save data with JsonStore

For user save data (settings, profiles, checkpoints), use JsonStore. It is a thin JSON-first layer on IndexedDB:

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

const saves = new JsonStore('my-game-saves');

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');

JsonStore stores one JSON payload per key. Non-serializable values throw at write time.

Custom asset types (advanced)

Register a string type name to teach the loader about domain-specific resources. First, extend AssetDefinitions via declaration merging:

declare module '@codexo/exojs' {
    interface AssetDefinitions {
        tileMap: {
            resource: TileMapData;
            config: {
                source: string;
                format: 'tmx' | 'tiled-json';
            };
        };
    }
}

Then register the handler:

loader.registerAssetType('tileMap', {
    getIdentityKey(config) {
        // Two configs with the same source but different format are distinct assets
        return `${config.source}:${config.format}`;
    },

    async load(config, context) {
        const text = await context.fetchText(config.source);
        return parseTileMap(text, config.format);
    },
});

The handler context provides:

MethodReturns
context.fetchText(source)Promise<string>
context.fetchJson<T>(source)Promise<T>
context.fetchArrayBuffer(source)Promise<ArrayBuffer>
context.identityKeystring — the active in-flight key for diagnostics

Using context.fetch* routes through the loader’s cache strategy and IDB stores. getIdentityKey controls in-flight deduplication — omit it when source alone determines the result; supply it when extra config fields change the output.

Once registered, all Asset and Assets APIs work for your type:

const map = new Asset({
    type:   'tileMap',
    source: 'maps/level-1.tmx',
    format: 'tmx',
});

const tileMap: TileMapData = await loader.load(map);

Examples

Texture Loader Open in Playground View source
import { Application, Color, Graphics, Scene, Sprite, Text, Texture } from '@codexo/exojs';

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

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

    override async load(loader): Promise<void> {
        const loading = loader.load(Texture, {
            bunny:    'image/ship-a.png',
            gradient: 'image/hue-ramp.png',
            uvGrid:   'image/uv-grid-256.png',
        });

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

        await loading;
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const textures = [loader.get(Texture, 'bunny'), loader.get(Texture, 'gradient'), loader.get(Texture, 'uvGrid')];

        // Spread the three textures evenly across the width, one per third.
        this.sprites = textures.map((texture, index) => {
            const sprite = new Sprite(texture);
            sprite.setAnchor(0.5);
            sprite.setPosition((width / 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 draw(context): void {
        context.backend.clear();
        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);
        }
    }
}

app.start(new TextureLoaderScene());

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

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.