Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our JRPG engine-building journey.
In our last lesson, we successfully implemented collision detection, giving our world a sense of solidity. The player can now navigate the map, sliding gracefully along walls instead of getting stuck or passing through them. The only problem? Our map is likely bigger than our screen, and if the player walks too far, they disappear from view!
Today, we'll solve that by tackling a fundamental component of any exploration-based game: implementing a camera system that smoothly follows the player character. A good camera is crucial for making a game feel polished and playable. By the end of this lesson, you'll have a dynamic camera that keeps the player in view as they explore, creating the illusion of a large, seamless world.
This lesson brings us much closer to the feel of classic JRPGs like Final Fantasy and Breath of Fire, where the camera is your window into a vast world. We will achieve this by:
- Understanding why a simple "pinned" camera feels bad.
- Implementing a
Cameraclass that uses smoothing to follow a target. - Integrating the camera into our rendering pipeline to move the viewport.
1. The Problem with a "Pinned" Camera
Your first instinct might be to simply make the camera's coordinates equal to the player's coordinates at all times. This is often called a "pinned" or "locked" camera. While simple, it often results in a jerky, disorienting experience, especially in a grid-based game where the player's target position changes abruptly.
Instead of me just telling you, let's see it in action.
The video 'How to Make a Good 2D Camera' from the excellent Game Maker's Toolkit channel starts by perfectly illustrating the problem with a pinned camera and introduces the concepts we'll be using to fix it.
Please watch the introduction at the beginning of the video to see why a pinned camera is problematic. Then, around the five-minute mark, watch the section on 'Damping and Dead Zones'. The concept of 'damping' is exactly what we'll be implementing today to create our smooth camera.
As the video explains, damping—or as we'll call it, smoothing—is the key. Instead of instantly snapping to the player's position, the camera will gracefully move towards it over time. This creates a much more fluid and professional feel.
This smoothing function is conceptually similar to a simple digital low-pass filter, a concept you might remember from your radiophysics background. We are essentially filtering out the high-frequency "jitter" from the player's movement to produce a smooth, low-frequency camera motion.
2. The "How": A Smooth Camera Implementation
To achieve this, we'll create a dedicated Camera class. This follows good software design principles by separating the concern of "viewing the world" from the "player character". We'll then use a mathematical technique called linear interpolation to create the smoothing effect.
How to smoothly move camera when the player is climbing ...
This Stack Exchange answer, 'How to smoothly move camera', provides a fantastic breakdown of the two main techniques for camera smoothing: a dead zone and interpolation (smoothing). It includes clear pseudo-code and a C# example using LERP that is almost exactly what we will implement in JavaScript.
Focus on the sections 'Movement of the camera towards its target position' (read the smoothing concept) and the subsequent C# implementation (review the camera code). Note how it uses a _targetPosition and a _currentPosition, and moves the current towards the target using Vector2.Lerp and ElapsedGameTime. This directly maps to our player.position, camera.position, lerp, and deltaTime.
Let's translate these concepts into our JavaScript engine.
2.1. The Camera Class
First, we'll define the structure of our Camera class. It needs to know its position, the size of the viewport, who it's following, and how smoothly to move.
class Camera {
constructor({ target, width, height, worldWidth, worldHeight }) {
this.x = target.pixelX - width / 2; // Start centered on target
this.y = target.pixelY - height / 2;
this.width = width; // Viewport width (canvas.width)
this.height = height; // Viewport height (canvas.height)
this.target = target;
this.smoothingFactor = 5; // A higher number means faster (less smooth) movement
// World boundaries
this.worldWidth = worldWidth;
this.worldHeight = worldHeight;
}
update(deltaTime) {
// ... smoothing logic will go here
}
}
2.2. Linear Interpolation (Lerp)
Linear interpolation, or lerp, is a function that finds a value some percentage of the way between a starting point and an ending point. The formula is start + (end - start) * percent. We'll use this to move our camera's current position a small percentage of the way towards its target position on every frame.
Let's create a helper function for this.
function lerp(start, end, amount) {
return start + (end - start) * amount;
}
2.3. The update Method
Now we can implement the core logic in the camera's update method. On each frame, we will:
- Calculate the camera's desired target position (centering the player).
- Use
lerpto move the camera's currentxandya little bit closer to the targetxandy. We multiply bydeltaTimeto ensure the movement speed is consistent regardless of frame rate. - Clamp the camera's final position to stay within the world boundaries.
// Inside the Camera class
update(deltaTime) {
// 1. Calculate the target position
// We want to center the camera on the player
const targetX = this.target.pixelX - this.width / 2;
const targetY = this.target.pixelY - this.height / 2;
// 2. Linearly interpolate the camera's position towards the target
const smoothing = this.smoothingFactor * deltaTime;
this.x = lerp(this.x, targetX, smoothing);
this.y = lerp(this.y, targetY, smoothing);
// 3. Clamp the camera to the world boundaries
// Prevent the camera from showing empty space outside the map
this.x = Math.max(0, Math.min(this.x, this.worldWidth - this.width));
this.y = Math.max(0, Math.min(this.y, this.worldHeight - this.height));
// To prevent floating point artifacts, you might want to round the final values
this.x = Math.round(this.x);
this.y = Math.round(this.y);
}
3. Integrating with the Rendering Pipeline
We now have a camera object that smoothly calculates its own position every frame. But how do we make our canvas actually use this position?
The answer is to use the canvas context's translate method. Before we draw anything that exists in the game world (the tilemap, the player, NPCs), we will shift the entire canvas's coordinate system by the negative of the camera's position.
It's crucial to wrap this transformation within context.save() and context.restore(). This ensures that the translation only affects our world objects and not fixed UI elements (like a health bar or menu) that you might want to draw later.
Here's how your main game loop's draw function would be modified:
// In your main game file
// Initialization
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
// ... other initializations for player, map, etc.
const camera = new Camera({
target: player,
width: canvas.width,
height: canvas.height,
worldWidth: myTileMap.width * myTileMap.tileWidth,
worldHeight: myTileMap.height * myTileMap.tileHeight
});
// Main game loop function
function gameLoop(timestamp) {
// ... calculate deltaTime
// --- UPDATE PHASE ---
player.update(deltaTime);
camera.update(deltaTime); // Update the camera's position
// --- DRAW PHASE ---
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Save the default (un-translated) context state
ctx.save();
// Apply the camera's translation
ctx.translate(-camera.x, -camera.y);
// Draw everything that exists in the game world
myTileMap.draw(ctx);
player.draw(ctx);
// ... draw other game world objects (NPCs, items) here
// Restore the context to its original state
ctx.restore();
// Draw UI elements here, now unaffected by the camera translation
// e.g., drawHUD(ctx);
requestAnimationFrame(gameLoop);
}
And that's it! With the Camera class updating its position and the draw function applying that position as a translation, your camera will now smoothly follow the player as they explore the world.
Test your understanding!
In our Camera class, we have a smoothingFactor. What would be the observable difference in game if you set this value very low (e.g., 0.5) versus very high (e.g., 20)?
Show answer
-
Low
smoothingFactor(e.g., 0.5): The camera would feel very "laggy" or "floaty." It would take a long time to catch up to the player after they move. Thelerpamount per frame would be very small, resulting in slow camera movement. -
High
smoothingFactor(e.g., 20): The camera would move very quickly to catch up to the player. The movement would be less smooth and would feel "tighter" or more responsive. If the value is high enough, thelerpamount(smoothingFactor * deltaTime)could exceed1.0, causing the camera to snap instantly to the target position, effectively behaving like a non-smoothed, pinned camera.
Conclusion
Congratulations! You've just implemented a system that is fundamental to the feel of almost every 2D RPG. Your character can now freely explore a world that feels much larger than the screen.
Here are our key takeaways from today's lesson:
- The "Why" of Smoothing: A camera rigidly pinned to the player feels jerky. We use damping or smoothing to create a more polished, fluid motion.
- The "How" of Lerp: We created a
Cameraclass that uses linear interpolation (lerp) combined withdeltaTimeto smoothly move from its current position towards a target position each frame. - The "Where" of
translate: We integrated the camera into our rendering loop by usingctx.translate(-camera.x, -camera.y)inside actx.save()andctx.restore()block to shift the entire game world's view.
Preview of the next lesson:
We now have a very solid foundation for map exploration: player movement, collision detection, and a smooth camera. This entire system can be thought of as a single "game state" or "scene." But JRPGs are made of many scenes: the map, the battle screen, the main menu, shops, etc. In our next lesson, we will begin Module 3 by designing a scene manager, an architectural pattern to handle transitions between these different game states, starting with the ability to switch from our map to a main menu.