Hello! Let's get started.
In our last lesson, we implemented smooth visual interpolation, which is a huge step toward achieving that classic JRPG feel. Your character now glides elegantly from one tile to the next instead of instantly teleporting. However, our world currently has no concept of solidity—walls, trees, and treasure chests are just decorations the player can pass right through.
Today, we will build the other half of JRPG navigation: implementing collision detection against the map's collision layer. By the end of this lesson, you will have a character that respects the boundaries of the world, stopping at walls but smoothly sliding along them when moving diagonally.
This brings us one step closer to your goal of building a complete JRPG engine. This is the fundamental mechanic that makes exploration and puzzle-solving possible in games like Final Fantasy and Breath of Fire.
We will achieve this by:
- Defining a "collision layer" in our map data structure.
- Checking this layer for solid tiles before a move is initiated.
- Implementing "wall sliding" to prevent the "sticky wall" feeling common in naive collision systems.
1. The Concept of a Collision Layer
In modern game development, and especially in tools like RPG Maker, we decouple a tile's appearance from its physical properties. A "tree" tile might be solid, but so might an "invisible wall" tile that looks like empty grass. To do this, we use a separate, invisible layer of data just for collisions.
Your experience with data-driven architecture in web development is relevant here. Just as you might have a data model that is rendered in various ways by different UI components, we will have a collision data layer that our Player object consults to determine its behavior.
Parallax Map Collisions / Passability : 3 different ways! RPG Maker MV Tutorial
The video 'Parallax Map Collisions / Passability' from Starlit Castle perfectly demonstrates this concept within RPG Maker MV. It shows how developers 'paint' passability information onto a map, separate from the visual tiles.
Shortly after the video begins, please watch the collision maps. Pay close attention to how a special, simple tileset (the red, green, and arrow tiles) is used on a separate layer to define where the player can and cannot walk. This is exactly the architectural pattern we are going to implement in our code.
This image provides a clear visual summary of the tile-based collision approach we're taking.

To implement this, we'll update our tile map data structure. Alongside our ground and objects layers, we'll add a collision layer. This will be a simple array of numbers where 0 means passable and 1 means solid.
Here's an example snippet from a hypothetical map.json:
{
"width": 10,
"height": 10,
"tileWidth": 16,
"tileHeight": 16,
"layers": {
"ground": [0, 0, 0, ...],
"objects": [5, 0, 0, ...],
"collision": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 1, 1, 0, 0, 0, 1,
// ...and so on
]
}
}
2. Implementing the Collision Check
Now, let's put this into code. The ideal place to check for a collision is right before we commit to a move. In our last lesson, we created a startMove method on the Player class. We'll now modify it to be aware of the world around it.
First, let's create a helper method on our TileMap class (or as a standalone function) to easily query the collision layer.
// In your TileMap class or a map utility file
function isSolid(map, gridX, gridY) {
// Check map boundaries first
if (gridX < 0 || gridX >= map.width || gridY < 0 || gridY >= map.height) {
return true; // Treat out-of-bounds as solid
}
// Get the tile from the collision layer
const collisionTile = map.layers.collision[gridY * map.width + gridX];
// Assuming 1 is solid, 0 is passable
return collisionTile === 1;
}
With this helper, we can now implement a basic collision check. Let's modify the startMove method from our previous lesson.
// Inside the Player class
startMove(direction, map) { // Add map as an argument
// If we're already moving, don't start a new move
if (this.state === 'MOVING') {
return;
}
// Calculate the destination
const destX = this.gridX + direction.x;
const destY = this.gridY + direction.y;
// --- NEW: COLLISION CHECK ---
// Check if the destination tile is solid
if (isSolid(map, destX, destY)) {
// Destination is blocked, so do nothing.
return;
}
// If we reach here, the path is clear. Proceed with the move.
this.targetGridX = destX;
this.targetGridY = destY;
// Set up the animation properties as before
this.startPixelX = this.pixelX;
this.startPixelY = this.pixelY;
this.moveProgress = 0;
this.state = 'MOVING';
}
Your game loop's input handling would now look something like this:
// In your main game loop's input handling section
if (player.state === 'IDLE') {
let direction = {x: 0, y: 0};
if (input.isDown('UP')) { direction.y = -1; }
else if (input.isDown('DOWN')) { direction.y = 1; }
else if (input.isDown('LEFT')) { direction.x = -1; }
else if (input.isDown('RIGHT')) { direction.x = 1; }
if (direction.x !== 0 || direction.y !== 0) {
player.startMove(direction, myTileMap); // Pass the map object
}
}
This works! Your player will now stop when they hit a wall. But we've introduced a new problem. If you press a diagonal direction (e.g., Down + Right) and run into a horizontal wall, the character will stop completely instead of sliding down along the wall. This feels "sticky" and unresponsive.
3. Solving "Sticky Walls" with Wall Sliding
The most elegant and common solution to this problem in grid-based games is to resolve movement on each axis independently. This allows the player to "slide" along obstacles.
2D Tilemap Collision - Jonathan Whiting
The article '2D Tilemap Collision' by Jonathan Whiting provides an excellent, code-oriented explanation of this exact problem and its solution.
Please read the sections 'A Naive Attempt' and 'Wall Sliding' to learn about collision resolution methods. The article uses GIFs that clearly show the 'sticky' wall problem and how separating the X and Y movement resolves it, creating the desired sliding effect.
To implement wall sliding, we need to refactor our movement logic. Instead of checking a single diagonal destination, we'll determine the final valid destination by checking each axis separately, and then start the interpolation.
Let's create a new method on our Player class called requestMove that contains this smarter logic. This will replace the simple call to startMove.
// In the Player class
// startMove is now a helper, called by requestMove
_startMoveAnimation(targetX, targetY) {
// Only start if we've actually moved
if (targetX === this.gridX && targetY === this.gridY) {
return;
}
this.targetGridX = targetX;
this.targetGridY = targetY;
this.startPixelX = this.pixelX;
this.startPixelY = this.pixelY;
this.moveProgress = 0;
this.state = 'MOVING';
}
requestMove(direction, map) {
if (this.state !== 'IDLE') {
return;
}
let intendedX = this.gridX + direction.x;
let intendedY = this.gridY + direction.y;
let finalX = this.gridX;
let finalY = this.gridY;
const movingDiagonally = direction.x !== 0 && direction.y !== 0;
// Check if the target X position is valid
if (!isSolid(map, intendedX, this.gridY)) {
finalX = intendedX;
}
// Check if the target Y position is valid
if (!isSolid(map, this.gridX, intendedY)) {
finalY = intendedY;
}
// Handle diagonal corner-clipping
if (movingDiagonally && finalX === intendedX && finalY === intendedY) {
// We can move on both axes, but what if the corner tile itself is solid?
// This prevents clipping through the corner of a 1-tile-thick diagonal wall.
if (isSolid(map, intendedX, intendedY)) {
// Blocked at the corner. Revert one of the movements.
// A common strategy is to revert the one that was checked last.
finalY = this.gridY;
}
}
// Now, start the animation to the determined final destination
this._startMoveAnimation(finalX, finalY);
}
Your input handler in the game loop now calls requestMove instead of startMove:
// Updated input handling
if (player.state === 'IDLE') {
let direction = {x: 0, y: 0};
if (input.isDown('UP')) { direction.y = -1; }
if (input.isDown('DOWN')) { direction.y = 1; }
if (input.isDown('LEFT')) { direction.x = -1; }
if (input.isDown('RIGHT')) { direction.x = 1; }
if (direction.x !== 0 || direction.y !== 0) {
player.requestMove(direction, myTileMap); // Call the new method
}
}
With this change, if you move diagonally towards a wall, the movement component into the wall will be cancelled, but the component along the wall will proceed, creating a smooth slide.
Test your understanding!
In our requestMove function, we have a special check for movingDiagonally. Why is this necessary? What would happen if we removed that block of code and just let the separate X and Y checks run? Consider a scenario where you have a single wall tile at (x+1, y+1) relative to the player, and the player tries to move down-right.
Show answer
Without the diagonal check, the code would do the following:
- Check movement on the X-axis:
isSolid(map, x+1, y)would befalse.finalXbecomesx+1. - Check movement on the Y-axis:
isSolid(map, x, y+1)would befalse.finalYbecomesy+1.
The player would then be told to move to (x+1, y+1), even though that tile is solid. The character would appear to clip or cut through the corner of the wall. The movingDiagonally check specifically looks at the destination corner tile only when a valid diagonal path seems open to prevent this exact kind of corner-cutting.
Conclusion
Excellent work! You've now implemented one of the most essential systems in any JRPG engine. Your character not only moves smoothly but also intelligently interacts with the game world's boundaries.
Here are our key takeaways:
- Collision Layers: We separated collision data from visual data by creating a dedicated
collisionlayer in our map, providing architectural flexibility. - Proactive Checking: Collisions are checked before a move starts, preventing the player from ever entering an invalid state. This fits perfectly with our state machine and visual interpolation from previous lessons.
- Axis-Separated Movement: We implemented "wall sliding" by resolving movement on the X and Y axes independently. This is a crucial technique for making character controls feel fluid and responsive rather than "sticky."
Preview of the next lesson:
Our character can now navigate the world, but the camera is static. If the player walks off-screen, we lose them! In our next lesson, we will solve this by implementing a camera system that smoothly follows the player character, keeping them centered and creating a dynamic, explorable world.
Can't find a good explanation? Sign up and we'll make it for you
Sign up