Skip to content

Assets

YAGE’s asset model is small on purpose. There are three concepts:

  • Asset handles — typed, serializable references to files on disk (texture("hero.png"), sound("hit.ogg"), tiledMap("level.json")).
  • AssetManager — engine-owned cache + loader registry. Plugins register loaders (texture, audio, spritesheet, tiledMap, …) and the manager loads handles concurrently with progress callbacks.
  • Scene.preload — declarative array of handles each scene needs; SceneManager loads them automatically before onEnter() runs.

Everything else is a pattern on top of those three pieces.

Each plugin exposes factories that produce typed handles. Handles are plain serializable objects — pass them around freely, store them in modules, embed them in scene classes.

import { texture, spritesheet } from "@yagejs/renderer";
import { sound } from "@yagejs/audio";
import { tiledMap } from "@yagejs/tilemap";
const HeroTex = texture("assets/hero.png");
const HeroSheet = spritesheet("assets/hero.json"); // atlas JSON; references the texture
const CoinSfx = sound("assets/sfx/coin.ogg");
const Level1Map = tiledMap("assets/levels/1.json");

A handle is just { type, path }. It becomes a loaded asset only when the manager has resolved it — AssetManager.get(handle) throws if you ask for an asset that hasn’t been preloaded.

A few factories take options. texture(path, { scaleMode: "nearest" }) makes that one image sample without smoothing, which is what a pixel-art sheet wants in a project that is otherwise smooth:

const Tiles = texture("assets/tiles.png", { scaleMode: "nearest" });

The setting lands on the loaded image, so every sprite drawing from it samples the same way. Use pixelArtPreset when the whole project is pixel art.

The simplest pattern: each scene declares what it needs.

import { Scene } from "@yagejs/core";
class GameScene extends Scene {
readonly name = "game";
readonly preload = [HeroTex, CoinSfx, Level1Map];
onEnter() {
// All three are guaranteed loaded — Assets.get() is safe
const hero = this.assets.get(HeroTex);
// …
}
}

SceneManager handles the rest:

  • Calls assets.loadAll(this.preload) before onEnter().
  • Skips already-cached handles (re-entering a scene is free).
  • Routes progress to scene.onProgress(ratio) if you override it on the scene class — useful for ad-hoc loading scenes that aren’t using LoadingScene.
  • Throws if a handle’s loader plugin isn’t installed (clear error message naming the missing type).

You don’t need to call loadAll yourself in onEnter. The Loading Scene guide covers progress UI on top of this — LoadingScene overrides onProgress to also emit scene:loading:progress on the event bus, which is what progress components subscribe to.

scenes.preload(scene) loads a scene’s manifest before you push it, so the push enters straight away:

import { Scene } from "@yagejs/core";
import type { UIProgressBar } from "@yagejs/ui";
class Level2 extends Scene {
readonly name = "level2";
}
declare const bar: UIProgressBar;
const level2 = new Level2();
await engine.scenes.preload(level2, (ratio) => bar.update({ value: ratio }));
await engine.scenes.replace(level2);

The push consumes that load rather than loading again, so the manifest is counted once and one unload per handle frees it. LoadingScene uses the same method. A scene you preload and never push holds its references until assets.clear().

Past a few dozen handles, scattering them across scene files becomes painful. The Scene.preload array is the manifest API — there’s no separate manifest abstraction, just an array, and you can build it however you like.

A common pattern is one handle module per content area:

content/heroes.ts
import { texture, spritesheet } from "@yagejs/renderer";
export const HeroTex = texture("assets/hero.png");
export const HeroSheet = spritesheet("assets/hero.json");
content/sfx.ts
import { sound } from "@yagejs/audio";
export const CoinSfx = sound("assets/sfx/coin.ogg");
export const HitSfx = sound("assets/sfx/hit.ogg");
export const JumpSfx = sound("assets/sfx/jump.ogg");
content/level1.ts
import { CoinSfx, HitSfx } from "./sfx.js";
import { HeroSheet } from "./heroes.js";
import { tiledMap } from "@yagejs/tilemap";
export const Level1Map = tiledMap("assets/levels/1.json");
export const Level1Manifest = [
Level1Map,
HeroSheet,
CoinSfx,
HitSfx,
] as const;
scenes/Level1.ts
import { Scene } from "@yagejs/core";
import { Level1Manifest } from "../content/level1.js";
class Level1 extends Scene {
readonly name = "level1";
readonly preload = Level1Manifest;
onEnter() { /* … */ }
}

Two practical wins from this layout:

  • Reuse across scenes. A “load shared HUD assets” manifest can be spread into multiple scenes’ preload arrays.
  • Static analysis. TypeScript catches typos in handle imports immediately — no string-keyed registry to drift out of sync with filenames.

Loaded assets stay in the AssetManager cache until something tells them to leave. The cache is shared across the entire engine — there’s no per-scene scoping.

ActionEffect
assets.loadAll([h1, h2])Loads handles not already cached
assets.get(handle)Reads from cache (throws if absent)
assets.has(handle)true if cached
assets.unload(handle)Drops one handle, calls loader’s unload?() if defined
assets.clear()Drops everything

For a small game, you almost never need to unload — the cache is GPU-cheap (Pixi shares textures on hot atlases) and disk-cheap (the asset is already in memory). Reach for unload/clear when:

  • A level transition replaces a large tilemap and tileset that the next level won’t need.
  • The game streams chapters of content and old chapters can be evicted.
  • The user enters a settings menu and you want to free a music track bound to a specific scene.
import { Scene } from "@yagejs/core";
import { Level1Manifest } from "../content/level1.js";
class Level1 extends Scene {
readonly name = "level1";
readonly preload = Level1Manifest;
onExit() {
// Free this level's textures before the next one preloads
for (const h of Level1Manifest) this.assets.unload(h);
}
}

The default loaders dispose appropriately: unload(textureHandle) calls Pixi’s Assets.unload(path) so the texture can be released and garbage-collected; unload(soundHandle) calls sound.remove(path) to release the @pixi/sound instance.

unload(handle) and clear() return void | Promise<void>. Await the result when subsequent work needs cleanup to finish:

import type { AssetHandle, AssetManager } from "@yagejs/core";
async function reload(assets: AssetManager, map: AssetHandle<unknown>) {
await assets.unload(map);
await assets.loadAll([map]);
}

The last release removes the cache entry immediately. Loading the same handle again waits for any asynchronous cleanup, even when the caller did not await unload. A custom loader may return a promise from unload(path, asset); that promise must cover all resources it releases. Failed cleanup rejects and prevents another load of that path in the same asset manager.

Loads are counted per handle. Every handle passed to loadAll adds one reference once that call resolves, and unload(handle) removes one — the asset is freed only when the count reaches zero. So two scenes preloading the same texture each hold a reference, and the first scene’s onExit unload leaves it standing for the second. clear() ignores the counts and frees everything.

A loadAll that rejects takes no references at all. Its already-loaded siblings stay cached and uncounted, so a retry counts each of them once and one unload per handle still frees them. An asset whose loader loads others, such as a Tiled map and its tileset images, holds them for as long as it is cached; freeing the map entry releases them.

Never unload the handles of a loadAll call that rejected. unload frees an uncounted entry outright, so cleaning up a failed manifest handle by handle can free an asset another scene still holds: scene A holds X at one reference, scene B’s loadAll([X, Y]) fails so B takes none, and B’s cleanup unload(X) drops A’s only reference. Retry the call, or leave the entries to clear().

The manager’s cache is keyed by handle type and path, so two handles of the same type for one path are one entry: the first load’s options win, and a second declaration with different options warns in development and is otherwise ignored.

Below the manager, Pixi’s own registry is keyed by path alone and is not counted, so reaching one file through two different handle types destroys it for both. Declare each file once:

  • texture(P) beside renderAsset(P) — pick one.
  • A spritesheet’s atlas image beside texture(P) — draw the frames from the sheet rather than also declaring the PNG.
  • installBitmapFont’s source face beside webFont(P) — bake from the webFont handle instead, with webFont(P, { family, bitmap: true }).

An asset that fails to load throws from assets.loadAll. There are two places to catch it:

  • Inside LoadingScene — override onLoadError(err) to render a retry button or skip to a safe scene. See the Loading Scene guide.
  • Around an explicit loadAll — when you load assets outside of a scene’s preload (e.g. a runtime asset bundle), wrap the call in try/catch. The error message names the failing path so you can show it to the user or log it to your telemetry.
import { Entity } from "@yagejs/core";
import { texture } from "@yagejs/renderer";
const LateBundle = texture("assets/bonus-level.png");
class ErrorBanner extends Entity {
setup(params: { message: string }) { /* show params.message */ }
}
try {
await this.assets.loadAll([LateBundle]);
} catch (err) {
this.spawn(ErrorBanner, { message: String(err) });
}

There’s no built-in fallback-asset mechanism — the cache either has the handle or doesn’t. If you need a missing-asset placeholder, ship a known-good fallback in your initial preload and reference it explicitly when the optional asset isn’t available.

Asset paths are resolved relative to the page hosting the game (the HTML file’s URL), not to your source files. With Vite’s default config, files placed under public/ are served at the site root:

public/
assets/
hero.png → fetched as /assets/hero.png
levels/1.json → fetched as /assets/levels/1.json

Match the strings in texture("assets/hero.png") to the served path, not the source path.

If your game ships under a sub-path (e.g. https://example.com/yage/), configure Vite’s base option and prepend it to your handle paths:

vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({ base: "/yage/" });
content/...
/// <reference types="vite/client" />
import { texture } from "@yagejs/renderer";
const HeroTex = texture(`${import.meta.env.BASE_URL}assets/hero.png`);

import.meta.env.BASE_URL resolves to the configured base path at build time and to / in dev. Wrap the prefix once in a helper if you have many assets:

/// <reference types="vite/client" />
import { texture } from "@yagejs/renderer";
const asset = (p: string) => `${import.meta.env.BASE_URL}${p}`;
const HeroTex = texture(asset("assets/hero.png"));

The asset factories don’t perform path normalization — they pass the string straight to the browser’s fetch. Whatever you put in the factory is what gets requested.

If you need a file type none of the plugins handle (a .ron settings file, a binary level format, etc.), register a custom loader against the AssetManager. Loaders are { load(path), unload?(path, asset) }.

import { AssetManagerKey, AssetHandle } from "@yagejs/core";
import type { EngineContext, Plugin } from "@yagejs/core";
interface Level { /* your level data */ }
declare function parseLevel(data: ArrayBuffer): Level; // your format's parser
class MyPlugin implements Plugin {
readonly name = "my-plugin";
readonly version = "1.0.0";
install(context: EngineContext) {
context.resolve(AssetManagerKey).registerLoader("levelfmt", {
async load(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`Failed: ${path}`);
return parseLevel(await res.arrayBuffer());
},
});
}
}
// Factory for typed handles
export function level(path: string): AssetHandle<Level> {
return new AssetHandle("levelfmt", path);
}

Once registered, handles of that type work in Scene.preload and through AssetManager.get like any built-in asset.

NeedUse
Load assets before a scene startsScene.preload
Show a progress bar while loadingLoadingScene + progress UI
Load extra assets mid-scenethis.assets.loadAll([handles])
Free assets between levelsthis.assets.unload(handle) per scene onExit
Discard everything (game over → main menu)this.assets.clear()
Add support for a new file typeCustom loader on AssetManager
Reference an asset shipped under a sub-pathPrefix paths with import.meta.env.BASE_URL

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/assets.md.