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;SceneManagerloads them automatically beforeonEnter()runs.
Everything else is a pattern on top of those three pieces.
Asset Handles
Section titled “Asset Handles”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 textureconst 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.
Per-Scene Preload
Section titled “Per-Scene Preload”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)beforeonEnter(). - 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 usingLoadingScene. - 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.
Loading a scene ahead of time
Section titled “Loading a scene ahead of time”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().
Manifests for Larger Games
Section titled “Manifests for Larger Games”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:
import { texture, spritesheet } from "@yagejs/renderer";
export const HeroTex = texture("assets/hero.png");export const HeroSheet = spritesheet("assets/hero.json");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");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;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.
Cache Lifetime
Section titled “Cache Lifetime”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.
| Action | Effect |
|---|---|
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.
Waiting for cleanup
Section titled “Waiting for cleanup”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.
Reference counting
Section titled “Reference counting”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().
One file path, one declaration form
Section titled “One file path, one declaration form”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)besiderenderAsset(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 besidewebFont(P)— bake from thewebFonthandle instead, withwebFont(P, { family, bitmap: true }).
Error Handling
Section titled “Error Handling”An asset that fails to load throws from assets.loadAll. There are two
places to catch it:
- Inside
LoadingScene— overrideonLoadError(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’spreload(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.
Base Path & Bundler Notes
Section titled “Base Path & Bundler Notes”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.jsonMatch 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:
import { defineConfig } from "vite";
export default defineConfig({ base: "/yage/" });/// <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.
Custom Loaders
Section titled “Custom Loaders”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 handlesexport 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.
When to Reach for Each
Section titled “When to Reach for Each”| Need | Use |
|---|---|
| Load assets before a scene starts | Scene.preload |
| Show a progress bar while loading | LoadingScene + progress UI |
| Load extra assets mid-scene | this.assets.loadAll([handles]) |
| Free assets between levels | this.assets.unload(handle) per scene onExit |
| Discard everything (game over → main menu) | this.assets.clear() |
| Add support for a new file type | Custom loader on AssetManager |
| Reference an asset shipped under a sub-path | Prefix 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.