Loading assets
The resource descriptor
Every asset is { name, type, src } plus type-specific extras:
import { loader } from "melonjs";
loader.setOptions({ crossOrigin: "anonymous" }); // needed for remote assets
await loader.preload([
{ name: "tiles", type: "image", src: "data/img/tiles.png" },
{ name: "map1", type: "tmx", src: "data/map/map1.tmx" },
{ name: "tileset", type: "tsx", src: "data/map/tileset.tsx" },
{ name: "sfx", type: "audio", src: "data/sfx/" },
{ name: "font", type: "fontface", src: "data/font/PressStart2P.ttf" },
{ name: "atlas", type: "json", src: "data/img/atlas.json" },
]);
preload() has been awaitable since 19.8. The callback form still works and
takes a third argument to suppress the built-in loading screen:
loader.preload(resources, onLoaded, /* switchToLoadState */ false);
The type list
The type strings are melonJS-specific — this is not a generic loader:
| type | for |
|---|---|
"image" |
textures, sprite sheets, normal maps |
"json" |
data, texture-atlas descriptors |
"tmx" / "tsx" |
Tiled maps and external tilesets |
"audio" |
sound — src is a directory, see below |
"video" |
video textures |
"binary" |
raw data, bitmap font .fnt descriptors |
"fontface" |
web fonts (.ttf, .woff) |
"aseprite" |
Aseprite JSON + image |
"shader" |
GLSL/WGSL shader sources — src or inline via data |
"obj" / "mtl" |
Wavefront models and materials |
"gltf" / "glb" |
glTF scenes |
"js" |
scripts |
What belongs in the manifest — and what cannot
Preloading is not only for things with a URL. Three tiers, and the middle one is the one people miss.
1. Assets with a source. Everything in the type table above. Note "shader"
takes inline source as well as a src, via the data field:
import { myRamp } from "./myRamp"; // a GLSL fragment body, in your own module
loader.preload([
{ name: "ramp", type: "shader", data: myRamp },
]);
const fx = loader.getShader("ramp"); // shared, already compiled
This matters more than it looks. A ShaderEffect compiles its program in its
constructor, and compileProgram calls getProgramParameter(LINK_STATUS)
immediately after linkProgram — a blocking call that forces the driver to
finish. So building one during a scene costs that link on the frame it appears,
and building three identical ones costs it three times. Preloaded as an asset it
is compiled at load time, behind the loading screen, and the loader hands out a
shared instance (effect.shared === true), which is what stops one
renderable's teardown freeing it out from under the others. clone() does NOT
help here — it constructs a new effect, and therefore links a new program.
2. Derived assets — build once, after the preload. Canvases you bake,
NoiseTexture2d, TextureAtlas: the loader cannot carry these, but they must
not be rebuilt per scene either. The renderer's texture cache is keyed by the
image object, so a freshly baked canvas is a brand new GPU texture with a
new mip chain — rebuild one per stage and a title → play → game-over cycle
uploads four copies of the same pixels. Build them once after preload
resolves and share them, the way the platformer example builds its
TextureAtlas into a module the scenes read from.
3. The engine's own shader variants — not preloadable at all. The mesh batcher compiles a program per feature combination (lit, instanced, instance colours, instance data, fog — fog joins the key), lazily, on the first draw that needs it. There is no loader type for these and no API to warm the cache: the only lever is to draw that combination once while the loading screen is still up, then throw the scene away — the compiled programs stay in the renderer. Worth doing for a heavy 3D scene, where first-draw linking is easily a few hundred ms and lands as a freeze on the first frame of the first level.
await loader.preload(resources);
buildScene(app); // same geometry the level uses
await twoFrames(); // event.GAME_AFTER_DRAW
removeOnlyWhatYouAdded(app); // keep the programs, drop the scene
state.change(state.PLAY);
It borrows the LOADING stage's world, and that has three teeth, all learned the hard way:
- Never tear down with
app.world.reset(). It clears every child of that world, the loading screen's own included, and the screen goes blank for the rest of the load. Snapshotapp.world.childrenfirst and remove only what you added. - Restore anything the warm-up changes that the loading screen shows — the
renderer's
backgroundColorabove all, or the warm-up flashes its own empty first frame. - Under a
Camera3dit will hide the built-in loading screen, and there is no clean way around it — see below.
DefaultLoadingScreen lives in the WORLD, not on the screen
The built-in loading screen is written for a Camera2d. Its progress bar and
logo are added with app.world.addChild(bar, 1) / addChild(logo, 2) and are
floating === false — ordinary world-space renderables. They read as an
overlay only because that stage's world is otherwise empty.
So with cameraClass: Camera3d, anything you add to that world is in the scene
with them, and a 3D scene simply swallows them: the depth buffer puts terrain
in front of a bar sitting at z = 1. What does NOT rescue it, all tried:
- Raising their
z— for a mesh on theCamera3dpathpos.zIS world depth, so the scene's own z values are geometry, not layering, and the bar still loses to the depth test. moveToTop()— it early-returns for a child already at index 0, which the loading screen's children always are, having been added before anything else. It silently does nothing.- Flipping them to
floating = truefor the duration — the scene still covers them.
If you want a warm-up (or any 3D behind a loading screen), write your own loading stage and draw its progress UI as floating renderables over the scene. That is also the nicer result: the player watches the level assemble instead of a logo. Budget it as real work, not a one-liner — and weigh it against what the warm-up actually saves.
One more limit: a warm-up only covers the combinations it actually draws. Renderables the real level has and the warm-up does not will still link on entry.
Two conventions that produce 404s
Audio src is a directory. The loader appends the asset name plus each
format from audio.init(), and picks the first that decodes:
audio.init("mp3,ogg"); // before preloading audio
{ name: "cling", type: "audio", src: "data/sfx/" } // → data/sfx/cling.mp3
A full filename here gives you a 404. And audio.init() must run before any
audio asset is preloaded.
A Tiled map needs its dependencies listed too — the .tmx, any external
.tsx, and the tileset images. Listing only the map produces a blank level.
Retrieving loaded assets
loader.getImage("tiles");
loader.getJSON("atlas");
loader.getTMX("map1");
loader.getGLTF("diorama");
loader.getVideo("intro");
loader.getFont("font");
loader.getBinary("font-fnt");
Most engine classes take the asset name directly, so you often do not need
these — new Sprite(x, y, { image: "tiles" }) resolves it for you.
Base URLs
Set a prefix per asset type rather than repeating it:
loader.setBaseURL("image", "data/img/");
loader.setBaseURL("audio", "data/sfx/");
loader.setBaseURL("*", "https://cdn.example.com/"); // every 2D type at once
The "*" wildcard deliberately skips obj, mtl, gltf and glb: those
resolve their own internal references (a map_Kd texture, a glTF .bin)
relative to their file, so a global prefix would double-prefix them. Set those
types individually.
Progress and completion
Use the events. The old callback properties (loader.onload, onProgress,
onError) were deprecated in 18.2 and removed in 20.3 — being let
bindings on an ES module namespace they could never be assigned from outside in
the first place, so loader.onProgress = fn always threw a TypeError:
import { event } from "melonjs";
event.on(event.LOADER_PROGRESS, (progress) => { /* 0..1 */ });
event.on(event.LOADER_COMPLETE, () => { /* … */ });
event.on(event.LOADER_ERROR, (res) => { /* … */ });
Unloading
loader.unload({ name: "map1", type: "tmx" });
loader.unloadAll();
unloadAll() also releases audio and GPU resources such as shader programs, so
it is the right teardown for switching between large levels.
Loading between scenes
The default loading screen appears automatically when preload switches state.
For a custom one, register a Stage against state.LOADING:
state.set(state.LOADING, new MyLoadingScreen());
If you preload without a loading stage and then change state immediately, pass
forceChange:
state.change(state.PLAY, true);
Cross-origin and authenticated assets
Two settings, both global and both applying to every asset type — there is no per-asset or per-type spelling to remember:
loader.setOptions({
crossOrigin: "anonymous", // CORS mode for remote assets
withCredentials: true, // send cookies / auth with the request
});
withCredentials is what an asset behind a session cookie or an
Authorization-style login needs. Every fetched asset — image, json, binary,
tmx, tsx, shader, gltf/glb, obj, mtl, aseprite, video — carries it, and so does
buffered audio.
Set them through setOptions. loader.crossOrigin and
loader.withCredentials are read-only module bindings; assigning to them
throws, the same trap as the removed loader.onload / onProgress.
one streamed clip (stream: true / html5: true) ignores withCredentials |
it plays through an <audio> element, which needs a crossorigin attribute rather than fetch credentials. Preload it buffered if it is behind auth |
| credentials silently dropped for audio before 20.4 | the loader forwarded them under a pre-20.3 name the backend never read — a hung preload rather than an error |
Symptom → cause
| symptom | cause |
|---|---|
| audio 404s | src given as a file instead of a directory |
audio fails, preload() rejects with "Failed loading resource" |
audio.init() not called before preloading — the real message is replaced by the loader's |
| blank level, missing tiles | .tsx or tileset image not listed in the resources |
| cross-origin textures fail | missing loader.setOptions({ crossOrigin: "anonymous" }) |
| assets behind a login 401 / audio preload hangs | missing loader.setOptions({ withCredentials: true }) — one switch, all asset types |
TypeError assigning loader.withCredentials / crossOrigin |
read-only module bindings; use loader.setOptions({ … }) |
| preload never finishes, blank screen, no error | a failing sound with audio.setStopOnAudioError(false) before 20.4 — it disabled audio and failed the preload |
| text renders in a fallback font | web font not preloaded as "fontface" |
BitmapText renders nothing |
the .fnt (as "binary") or its image is missing |
TypeError assigning loader.onProgress / onload |
removed in 20.3; they were never writable — use the LOADER_* events |
Related skills
melonjs-getting-started— where preloading sits in the bootstrapmelonjs-tilemaps— the full Tiled asset setmelonjs-audio— formats and the directory convention