Hello! Welcome back to our JRPG engine-building course.
In our last lesson, we constructed the heart of our game engine: a robust game loop using requestAnimationFrame and a fixed timestep. We now have a consistent, ticking clock within our Engine class, but its render() method is still an empty placeholder. Our game runs, but it doesn't show us anything.
Today, we're going to bring our engine to life visually. The learning outcome for this lesson is to implement a canvas rendering system for drawing sprites from a tileset and spritesheet. We'll fill in that render() method, get our first graphics on the screen, and establish the fundamental drawing techniques that all visual aspects of our game will be built upon.
The Canvas: Our Digital Easel
In web-based game development, the HTML5 <canvas> element is our primary tool for rendering 2D graphics. Think of it as a blank digital easel. To draw on it, we need to get its "2D rendering context," which is a JavaScript object containing all the drawing methods we'll need.
Let's update our project structure slightly. In your HTML file, add a <canvas> element:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JRPG Engine</title>
<style>
body { margin: 0; background: #000; }
canvas { display: block; margin: 0 auto; }
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<script src="engine.js"></script>
</body>
</html>
Now, in our Engine class, we'll get a reference to this canvas and its context in the constructor.
class Engine {
constructor() {
// --- Canvas and Rendering Context ---
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
// Set canvas dimensions (we can make this dynamic later)
this.canvas.width = 816; // 25.5 tiles * 32px
this.canvas.height = 624; // 19.5 tiles * 32px
// ... (rest of the constructor from the previous lesson)
}
// ...
}
One of the most important methods on the context is clearRect(). Since our render() method will be called on every frame, we must clear the canvas before drawing the new frame. Otherwise, we'll get a "smearing" effect as new frames are drawn on top of old ones.
// Inside the Engine class
render() {
// Clear the entire canvas before drawing a new frame
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// ... Our drawing logic will go here ...
}
Drawing from a Tileset: Building the World
Classic JRPGs build their worlds from tiles. A tileset is a single image file containing all the individual square graphics (grass, dirt, walls, etc.) used to construct a map. Drawing from a tileset is highly efficient, as the browser only needs to load one image.
The key to this process is the drawImage() method on the canvas context. This method comes in a few variations, but the most powerful one for our purposes takes nine arguments.
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
Let's break this down:
image: The source image object (our tileset).sx, sy: The X and Y coordinates of the top-left corner of the sub-rectangle to crop from the source image.sWidth, sHeight: The width and height of the sub-rectangle to crop (the size of one tile).dx, dy: The X and Y coordinates of the top-left corner on the canvas where we want to draw the cropped image.dWidth, dHeight: The width and height to draw the image on the canvas.
This video provides an excellent, practical walkthrough of setting up the canvas, clearing it, and using the 9-argument drawImage to render tiles.
How to build a Tile Set Map Editor using HTML Canvas
The video 'How to build a Tile Set Map Editor using HTML Canvas' by Drew Conley has a fantastic segment that explains exactly how to draw individual tiles from a larger tileset image. It's the core technique we need.
Please watch the draw function. Pay close attention to the breakdown of the drawImage method's nine arguments. The video explains how sourceX/Y and destinationX/Y work together to pick a tile from the sheet and place it on the canvas.
Drawing from a Spritesheet: Bringing Characters to Life
A spritesheet is conceptually the same as a tileset: a single image containing multiple smaller images. The difference is that a spritesheet typically contains the sequential animation frames for a character or object.
For today, we won't be animating the character yet. Our goal is simply to draw a single, static frame from the spritesheet onto the canvas. The technique is identical to drawing a tile: we use drawImage() to crop the specific frame we want.
Let's say our character spritesheet is organized in a grid. To draw a specific frame, we need to calculate its sx and sy coordinates within the sheet. This is a classic problem with a clean mathematical solution.
sx = (frameIndex % columns) * frameWidthsy = Math.floor(frameIndex / columns) * frameHeight
Here, frameIndex is the zero-based index of the frame we want to draw, and columns is the number of frames in each row of the spritesheet. The modulo operator (%) gives us the column, and integer division gives us the row.
This next video provides a superb explanation of this exact calculation.
The video 'JavaScript Sprite Animation' by Franks laboratory has a clear and concise section explaining the math required to find the correct coordinates for any frame on a compact spritesheet.
Starting about 24 and a half minutes into the video, please watch the coordinate calculation. The video demonstrates how to use the modulo operator and Math.floor to calculate frameX and frameY from a single frame number. This is the exact logic we need to select a specific character pose from our spritesheet.
Implementing Our Renderer
Now, let's put this all together in code. We'll create a Sprite class to encapsulate the logic for drawing from an image sheet. This is a good architectural practice that separates drawing logic from the image data itself.
class Sprite {
constructor({ image, frameWidth, frameHeight, columns }) {
this.image = image;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.columns = columns;
}
draw(ctx, frameIndex, destX, destY) {
const col = frameIndex % this.columns;
const row = Math.floor(frameIndex / this.columns);
const sx = col * this.frameWidth;
const sy = row * this.frameHeight;
ctx.drawImage(
this.image,
sx,
sy,
this.frameWidth,
this.frameHeight,
destX,
destY,
this.frameWidth,
this.frameHeight
);
}
}
This Sprite class is beautifully reusable. We can use it for both tilesets and character spritesheets just by passing in the correct image and dimensions.
Now, let's integrate this into our Engine. We need to load our images first. A crucial point is that drawing can only happen after the image has been loaded by the browser. We'll use the onload event for this.
Here is an updated Engine.js. For now, we'll handle image loading directly in the start method. In the next lesson, we'll build a proper asset manager for this.
// Add this class to the top of your engine.js file
class Sprite {
constructor({ image, frameWidth, frameHeight, columns }) {
this.image = image;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.columns = columns;
}
draw(ctx, frameIndex, destX, destY) {
const col = frameIndex % this.columns;
const row = Math.floor(frameIndex / this.columns);
const sx = col * this.frameWidth;
const sy = row * this.frameHeight;
ctx.drawImage(
this.image,
sx,
sy,
this.frameWidth,
this.frameHeight,
destX,
destY,
this.frameWidth,
this.frameHeight
);
}
}
class Engine {
constructor() {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = 816;
this.canvas.height = 624;
this.timeStep = 1000 / 60;
this.lastTime = 0;
this.accumulator = 0;
// We'll store our sprites here after they're loaded
this.sprites = {};
this.gameLoop = this.gameLoop.bind(this);
}
update(deltaTime) {
// No game logic yet
}
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw a grid of grass tiles
const TILES_WIDE = 26;
const TILES_HIGH = 20;
for (let y = 0; y < TILES_HIGH; y++) {
for (let x = 0; x < TILES_WIDE; x++) {
// Use frame 0 (grass) from the tileset
this.sprites.tileset.draw(this.ctx, 0, x * 32, y * 32);
}
}
// Draw the hero character in the middle of the screen
// Using frame 1 for a downward-facing idle pose
this.sprites.hero.draw(this.ctx, 1, this.canvas.width / 2 - 16, this.canvas.height / 2 - 16);
}
gameLoop(currentTime) {
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);
}
// Updated start method to handle image loading
start() {
const imagesToLoad = {
hero: 'path/to/your/hero-spritesheet.png',
tileset: 'path/to/your/tileset.png',
};
const imagePromises = Object.entries(imagesToLoad).map(([id, src]) => {
return new Promise((resolve) => {
const img = new Image();
img.src = src;
img.onload = () => resolve({ id, img });
});
});
Promise.all(imagePromises).then((loadedImages) => {
// Once all images are loaded, create Sprite instances
const heroImage = loadedImages.find(i => i.id === 'hero').img;
this.sprites.hero = new Sprite({
image: heroImage,
frameWidth: 32,
frameHeight: 32,
columns: 3, // Assuming hero sheet has 3 frames per row
});
const tilesetImage = loadedImages.find(i => i.id === 'tileset').img;
this.sprites.tileset = new Sprite({
image: tilesetImage,
frameWidth: 32,
frameHeight: 32,
columns: 8, // Assuming tileset is 8 tiles wide
});
// Now that assets are ready, start the game loop
requestAnimationFrame(this.gameLoop);
});
}
}
// --- To run the engine ---
// Don't forget to update the image paths in the start() method!
const gameEngine = new Engine();
gameEngine.start();
(Note: You will need to find or create your own 32x32 pixel art tileset and character spritesheet images and update the paths in the start() method.)
If you run this, you should see your game canvas filled with a repeating tile and a single character sprite drawn on top. Our engine is now rendering!
Test your understanding!
You have a spritesheet that is 128 pixels wide and contains frames that are 32x32 pixels each. This means each row has 128 / 32 = 4 columns. You want to draw the 7th frame (which has an index of 6).
What are the sx and sy coordinates you would use in drawImage()?
Show answer
The number of columns is 4. The frame index is 6.
col = 6 % 4 = 2row = Math.floor(6 / 4) = 1
Now, we find the pixel coordinates:
sx = col * frameWidth = 2 * 32 = 64sy = row * frameHeight = 1 * 32 = 32
The source coordinates to crop from are (64, 32).
Conclusion
In this lesson, we bridged the gap between our abstract game loop and a visual output. We've laid a critical piece of our engine's foundation.
Here are the key takeaways:
- The HTML5 Canvas 2D Context is our primary interface for drawing.
- We must call
clearRect()at the beginning of each render call to prevent visual artifacts. - The 9-argument
drawImage()method is the workhorse for rendering from both tilesets and spritesheets, allowing us to crop a specific portion of a source image and draw it to the canvas. - We can calculate the source coordinates for any frame in a grid-based spritesheet using the modulo and floor division operators.
- Encapsulating this drawing logic in a reusable
Spriteclass is a clean, architectural approach.
Preview of the next lesson:
Our current image loading solution inside the Engine's start method works, but it's not very scalable. As our game grows, we'll have dozens or even hundreds of assets (images, audio, map data). In the next lesson, we will implement a basic asset manager to handle preloading all our images and data files before the game begins, ensuring a smooth startup experience.
Can't find a good explanation? Sign up and we'll make it for you
Sign up