Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome to the sixth lesson in our JRPG engine-building course.
In our last lesson, we successfully implemented a rendering system, drawing tiles and sprites to the canvas. We used a temporary method in our Engine.start() function to load images using Promise.all. While functional, this approach of hardcoding asset paths directly into our main engine logic isn't scalable. As our game grows to include dozens of spritesheets, tilesets, map files, and sound effects, this will become unmanageable.
Today, we will address this by building a proper foundation for handling game resources. The learning outcome for this lesson is to implement a basic asset manager for preloading images and data files. This manager will be a centralized system responsible for loading everything our game needs before it starts, ensuring a smooth and error-free experience for the player.
For someone with your background in front-end development, the concept of managing asynchronous resource loading will be familiar. The challenge here is to structure it within the context of a game engine, where preloading is critical for performance and player experience.
Why a Dedicated Asset Manager?
Before we dive into coding, let's establish why a dedicated asset manager is a standard component in game engines.
To get some context on where asset management fits into the bigger picture of engine development, let's start with a short reading.
So, you want to make a game engine | lisyarus blog
The blog post 'So, you want to make a game engine' by lisyarus provides a great overview of the typical features of a game engine. We'll look at the list of expected features and then the section on assets.
First, read the bulleted list under the heading 'What is expected from a game engine?' covering common expected features. Note where 'Managing assets' fits. Then, scroll down to the 'Assets' section and read the asset strategies entirely. This will give you a high-level perspective on different strategies for handling game data.
As you've just read, asset management is a core responsibility of a game engine. A good asset manager provides:
- Centralization: A single point of control for loading, accessing, and managing all external resources.
- Preloading: The ability to load all necessary assets upfront, which is essential for displaying a loading screen and preventing assets from "popping in" during gameplay.
- Abstraction: The rest of your game code doesn't need to worry about how an asset is loaded (e.g., from a file, a ZIP archive, or a database). It simply requests an asset by its ID, like
assetManager.get('hero-sprite'). - Efficiency: It naturally acts as a cache. If multiple game objects need the same tileset, it's loaded from the network only once.
The Challenge of Asset Loading on the Web
Loading assets for a web-based game presents unique challenges compared to native applications. Network latency, browser caching behavior, and storage limitations all come into play. For a deeper look into these challenges, the following article, while a bit dated in its API specifics, still provides an excellent breakdown of the problem space.
Loading large assets in modern HTML5 games
The article 'Loading large assets in modern HTML5 games' by Boris Smus clearly outlines the problems developers face and proposes a robust, manifest-based solution.
Please read the introduction and the sections 'Problems with Application Cache' and 'Designing a game asset loader'. Focus on understanding the problems he identifies and the design pattern he proposes: using a JSON manifest to define asset bundles.
The key takeaway from that article is the concept of a manifest file. Instead of hardcoding asset paths in our JavaScript, we can define them in a separate JSON file. Our asset manager will first load this manifest, and then use it as a "to-do list" for loading all other assets. This data-driven approach is clean, scalable, and a perfect fit for our JRPG engine.
Designing Our Asset Manager
Let's design our AssetManager class based on this manifest-driven approach.
- It will have a method to load a manifest file (
assets.json). - The manifest will list all assets, each with a unique ID, a source path, and a type.
- The manager will iterate through the manifest, creating a loading "job" (a Promise) for each asset.
- It will use
Promise.allto wait for all these jobs to complete. - Once loaded, assets will be stored in an internal object, accessible via a
get(id)method.
First, let's create our manifest file. Create a new file named assets.json in your project's root directory:
assets.json
{
"images": [
{ "id": "hero", "src": "path/to/your/hero-spritesheet.png" },
{ "id": "tileset", "src": "path/to/your/tileset.png" }
],
"data": [
{ "id": "map1", "src": "data/maps/level1.json" }
]
}
(Note: We don't have a level1.json file yet, but we're building the manager to support it for future lessons.)
Implementation
Now, let's write the AssetManager.js file. This class will contain all the logic for parsing the manifest and handling the loading.
AssetManager.js
class AssetManager {
constructor() {
this.assets = {};
this.downloadQueue = [];
this.successCount = 0;
this.errorCount = 0;
}
queueDownload(src) {
this.downloadQueue.push(src);
}
get(id) {
return this.assets[id];
}
loadFromManifest(manifestPath, downloadCallback) {
return fetch(manifestPath)
.then(response => response.json())
.then(manifest => {
const promises = [];
// Load images
if (manifest.images) {
for (const imgInfo of manifest.images) {
const promise = this._loadImage(imgInfo.id, imgInfo.src);
promises.push(promise);
}
}
// Load JSON data
if (manifest.data) {
for (const dataInfo of manifest.data) {
const promise = this._loadJson(dataInfo.id, dataInfo.src);
promises.push(promise);
}
}
return Promise.all(promises);
});
}
_loadImage(id, src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.addEventListener('load', () => {
this.assets[id] = img;
resolve(img);
});
img.addEventListener('error', (err) => {
console.error(`Failed to load image: ${src}`);
reject(err);
});
});
}
_loadJson(id, src) {
return fetch(src)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
this.assets[id] = data;
return data;
})
.catch(err => {
console.error(`Failed to load JSON data: ${src}`);
throw err; // Re-throw to be caught by Promise.all
});
}
}
This class neatly handles the two asset types we care about right now: images and JSON data. The logic for each is contained in its own private helper method, making it easy to add more types (like audio) in the future.
Integrating the Asset Manager into the Engine
Now we can refactor our Engine class to use this new, much cleaner system.
First, include the new script in your index.html file, before engine.js:
<!-- In your index.html, before engine.js -->
<script src="AssetManager.js"></script>
Next, let's update Engine.js.
// At the top of engine.js, assuming Sprite is in the same file for now.
class Engine {
constructor() {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = 816;
this.canvas.height = 624;
this.assetManager = new AssetManager(); // Instantiate the manager
this.timeStep = 1000 / 60;
this.lastTime = 0;
this.accumulator = 0;
this.sprites = {}; // We will still use this to hold initialized Sprite objects
this.gameLoop = this.gameLoop.bind(this);
}
update(deltaTime) {
// Game logic goes here
}
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.sprites.tileset || !this.sprites.hero) {
// Don't try to render if sprites aren't ready
return;
}
// Draw background (same as before)
const TILES_WIDE = 26;
const TILES_HIGH = 20;
for (let y = 0; y < TILES_HIGH; y++) {
for (let x = 0; x < TILES_WIDE; x++) {
this.sprites.tileset.draw(this.ctx, 0, x * 32, y * 32);
}
}
// Draw hero (same as before)
this.sprites.hero.draw(this.ctx, 1, this.canvas.width / 2 - 16, this.canvas.height / 2 - 16);
}
gameLoop(currentTime) {
// ... (game loop logic is unchanged from the previous lesson)
if (this.lastTime === 0) this.lastTime = currentTime;
const deltaTime = currentTime - this.lastTime;
this.lastTime = currentTime;
this.accumulator += deltaTime;
while (this.accumulator >= this.timeStep) {
this.update(this.timeStep);
this.accumulator -= this.timeStep;
}
this.render();
requestAnimationFrame(this.gameLoop);
}
async start() {
console.log('Loading assets...');
await this.assetManager.loadFromManifest('assets.json');
console.log('Assets loaded!');
// --- Post-load setup ---
// Now that assets are loaded, we can create objects that use them.
this._initializeSprites();
// Start the game loop
requestAnimationFrame(this.gameLoop);
}
_initializeSprites() {
// Create Sprite instances using the loaded images
const heroImage = this.assetManager.get('hero');
this.sprites.hero = new Sprite({
image: heroImage,
frameWidth: 32,
frameHeight: 32,
columns: 3,
});
const tilesetImage = this.assetManager.get('tileset');
this.sprites.tileset = new Sprite({
image: tilesetImage,
frameWidth: 32,
frameHeight: 32,
columns: 8,
});
}
}
// --- To run the engine ---
const gameEngine = new Engine();
gameEngine.start();
Notice how clean our start method has become. It has two clear phases:
- Loading: It tells the
AssetManagerto load everything from the manifest and waits for it to finish. - Initialization: Once loading is complete, it calls
_initializeSpritesto create our game objects (Spriteinstances) using the now-available assets.
This separation of concerns is a robust architectural pattern that will serve us well as the engine grows in complexity.
Test your understanding!
Imagine you want to add background music to your game. You have a file dungeon-theme.mp3. Based on the structure of our AssetManager and assets.json, how would you modify them to support loading audio files? You don't need to write the full implementation, just describe the necessary changes.
Show answer
- Update
assets.json: Add a new top-level key, perhaps"audio", with an array of audio file objects:{ "images": [...], "data": [...], "audio": [ { "id": "dungeon-theme", "src": "audio/dungeon-theme.mp3" } ] } - Update
AssetManager.js:- In the
loadFromManifestmethod, add a new block to check formanifest.audioand loop through it, similar to how it handles images and data. - Create a new private helper method,
_loadAudio(id, src). This method would use the Web Audio API (new Audio()), wrap itscanplaythroughanderrorevents in a Promise, and store the resulting audio object inthis.assets.
- In the
Conclusion
Today we've replaced a temporary, hardcoded solution with a scalable, data-driven asset management system. This is a significant step in turning our collection of scripts into a true game engine.
Key takeaways from this lesson:
- An Asset Manager is a crucial engine component for centralizing and abstracting resource loading.
- Using a JSON manifest file to define assets makes the system data-driven, flexible, and easy to manage.
- Wrapping asynchronous loading operations (like
new Image()orfetch) in Promises allows us to easily wait for all assets to be ready usingPromise.all. - The engine's startup sequence is now clearly divided: load assets first, then initialize game objects.
Preview of the next lesson:
With our robust asset manager in place, we can confidently load any spritesheet we need. In the next lesson, we'll put this to good use as we finally bring our character to life. We will implement frame-based sprite animation with timing and directional state, making our hero walk around the screen.