Hello! Welcome back. In our last lesson, we established a solid architectural foundation for character movement using a state machine. Our player character now moves from tile to tile in a controlled, grid-aligned manner, but the movement itself is an instant "teleport."
Today, we will address this by implementing the visual polish that defines the feel of classic JRPGs. Our goal is to implement smooth visual interpolation for character movement between grid cells.
We'll transform the character's abrupt jump into a satisfying slide. This is a common task in interactive applications—not unlike animating a UI element into view in web development. We'll achieve this by separating the character's logical grid position from their visual screen position and smoothly animating the latter over a short period.
The Core Concept: Logical vs. Visual Position
The problem we're solving is a classic one. A developer on a forum described it perfectly when trying to recreate the feel of Final Fantasy.
Tile Based Movement - Game Building Help
This forum post on Construct.net titled 'Tile Based Movement' captures the exact challenge we're tackling. The user wants smooth, pixel-by-pixel movement, but with the critical constraint that the character must always end up perfectly aligned with the grid.
Please read the first two posts in this thread, focusing on the initial request and the follow-up clarification. Pay attention to how the user, zatyka, describes the desired behavior: the character moves smoothly between tiles but cannot end up halfway between them. This is the core principle of our task today.
The solution to this problem is a powerful architectural pattern in game development: decoupling the logical state from the visual representation.
- Logical Position: This is the character's "true" location on the game's grid (e.g.,
gridX = 5,gridY = 10). This is what we use for game rules like collision detection, event triggers, and turn-based mechanics. This position should change instantly and predictably. - Visual Position: This is the character's
(x, y)pixel coordinate on the screen that gets rendered every frame. This position will be animated to create the illusion of smooth motion.
During a move, the character's visual position will travel from its starting tile to its destination tile. Meanwhile, the game logic already knows the character is "on their way" to the destination tile.
Polished Tile-Based Movement in Godot 4 | Roguelike
To see this concept in action in a modern game engine, watch this segment from the video 'Polished Tile-Based Movement in Godot 4'. The presenter implements exactly this separation.
About two minutes in, watch the visual interpolation. Notice how the code uses a 'Tween' to animate the sprite's visual position while the underlying character body (the logical position) remains snappy and grid-based. The video states this is required so collision with things like spikes is precise. We will implement this same idea in our JavaScript code.
The "How": Linear Interpolation (Lerp)
To move our character's visual position smoothly from a starting point A to an ending point B, we need a way to find all the points in between. This technique is called interpolation. The most common form is linear interpolation, or "lerp."
A lerp function takes three arguments: a start value, an end value, and a "time" parameter t that goes from 0.0 to 1.0.
- When
tis 0.0, it returns the start value. - When
tis 1.0, it returns the end value. - When
tis 0.5, it returns the value exactly halfway between them.
The formula is: start + (end - start) * t.
An In-Depth look at Lerp, Smoothstep, and Shaping Functions
This video, 'An In-Depth look at Lerp, Smoothstep, and Shaping Functions' by SimonDev, gives a clear and quick introduction to the concept of lerp.
Please watch the first 39 seconds of the video. It visually explains the lerp formula and how the t parameter controls the interpolation.
In our game, t will represent the progress of our movement animation. If we want a move to take 200 milliseconds, and 100 milliseconds have passed, our t will be 100 / 200 = 0.5.
Step-by-Step Implementation
Let's refactor our Player class to implement this.
1. Update Player Properties
We need to add a few properties to our Player class to manage the animation state.
class Player {
constructor(gridX, gridY, tileWidth, tileHeight) {
// Logical Position
this.gridX = gridX;
this.gridY = gridY;
this.tileWidth = tileWidth;
this.tileHeight = tileHeight;
// Visual Position
this.pixelX = gridX * tileWidth;
this.pixelY = gridY * tileHeight;
// Movement State
this.state = 'IDLE'; // 'IDLE' or 'MOVING'
this.targetGridX = gridX;
this.targetGridY = gridY;
// NEW: Animation properties
this.moveDuration = 200; // ms for one tile move
this.moveProgress = 0; // ms elapsed in current move
this.startPixelX = 0;
this.startPixelY = 0;
}
// ... other methods
}
2. Update startMove()
Our startMove method now needs to kick off the animation by storing the starting visual position and resetting the progress. The logical position (gridX, gridY) will not change here.
startMove(direction) {
// Calculate the destination
const destX = this.gridX + direction.x;
const destY = this.gridY + direction.y;
// FUTURE: Check for collisions at (destX, destY) here.
// If collision, simply `return` and do nothing.
this.targetGridX = destX;
this.targetGridY = destY;
// NEW: Set up the animation
this.startPixelX = this.pixelX;
this.startPixelY = this.pixelY;
this.moveProgress = 0;
this.state = 'MOVING';
}
3. The Magic in update()
This is where we'll use lerp. In the previous lesson, the 'MOVING' state resolved instantly. Now, it will progress frame by frame. Remember that our main game loop passes deltaTime (the time since the last frame) to update.
First, let's add a helper function for lerp somewhere in our project.
function lerp(start, end, t) {
return start + (end - start) * t;
}
Now, let's rewrite the update logic.
update(deltaTime) { // Make sure deltaTime is passed in
if (this.state === 'MOVING') {
// 1. Advance the animation progress
this.moveProgress += deltaTime;
// 2. Calculate interpolation factor 't', clamped between 0 and 1
let t = this.moveProgress / this.moveDuration;
if (t > 1) {
t = 1;
}
// 3. Calculate target pixel positions for clarity
const targetPixelX = this.targetGridX * this.tileWidth;
const targetPixelY = this.targetGridY * this.tileHeight;
// 4. Interpolate the visual position
this.pixelX = lerp(this.startPixelX, targetPixelX, t);
this.pixelY = lerp(this.startPixelY, targetPixelY, t);
// 5. Check if the movement is complete
if (t === 1) {
// The move is finished. Snap everything to the grid.
this.gridX = this.targetGridX;
this.gridY = this.targetGridY;
// Snap visual position to be perfectly on the grid to avoid floating point errors
this.pixelX = this.gridX * this.tileWidth;
this.pixelY = this.gridY * this.tileHeight;
this.state = 'IDLE'; // Ready for the next input
}
}
}
With this change, your draw method doesn't need to change at all. It was already drawing the player at pixelX and pixelY (relative to the camera), which are now being smoothly updated.
Polishing the Movement with Shaping Functions
If you run the code now, you'll see smooth movement! However, it might feel a little robotic because the speed is constant. It starts and stops instantly. We can make it feel more natural by adding "easing" — starting slow, speeding up, and slowing down at the end.
This is where shaping functions come in. We take our linear t (0 to 1) and pass it through another function to change its curve.
An In-Depth look at Lerp, Smoothstep, and Shaping Functions
Let's return to the SimonDev video, which explains this concept beautifully.
About a minute in, watch the explanation of shaping functions, and then skip ahead to practical examples near the four-minute mark. The video shows how functions like t*t or smoothstep can alter the feel of an animation, creating acceleration and deceleration. This is directly analogous to CSS ease-in, ease-out, and ease-in-out transitions you may be familiar with.
A very common and pleasant easing function is "ease-out," where the movement starts fast and decelerates to a stop. A simple way to achieve this is with the formula: 1 - (1 - t) * (1 - t). Let's create a helper function for it.
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}
Now, we just make one small change in our Player.update method:
// Inside Player.update...
// ...
// 4. Interpolate the visual position
const shaped_t = easeOutQuad(t); // Apply the easing function
this.pixelX = lerp(this.startPixelX, targetPixelX, shaped_t);
this.pixelY = lerp(this.startPixelY, targetPixelY, shaped_t);
// ...
This tiny change will make the character's movement feel significantly more polished and satisfying.
Test your understanding!
In our update method, we have a block if (t === 1) where we snap the player's logical (gridX) and visual (pixelX) positions to the final target. Why is this snapping step important? What might happen over many moves if we removed it and just let the lerp function finish on its own?
Show answer
The snapping step is crucial to prevent the accumulation of floating-point inaccuracies. The lerp calculation, especially when combined with a deltaTime that varies slightly from frame to frame, might result in a final pixelX of 159.9999999 instead of 160.
While invisible on a single move, over hundreds or thousands of moves, these tiny errors can accumulate, causing the character's visual position to drift noticeably from the grid. By explicitly snapping the logical and visual positions to their integer-based grid destinations, we guarantee perfect grid alignment at the end of every move.
Conclusion
Fantastic! We have successfully transformed a jarring teleport into a smooth, polished slide, achieving the classic JRPG movement feel. This lesson was a deep dive into a single, but critical, aspect of game development that has a huge impact on player experience.
Key takeaways from today:
- Separate Logic and Visuals: The most important architectural pattern today was decoupling the character's logical grid position from their visual pixel position. This allows for precise game logic and fluid animation simultaneously.
- Interpolation Creates Smoothness: We used linear interpolation (
lerp) as the mathematical tool to find the in-between positions for our animation. - Shaping Functions Add Polish: By applying an easing function (
easeOutQuad) to our interpolation factor, we gave the movement a more natural acceleration and deceleration curve. - The State Machine Was the Enabler: Our
IDLE/MOVINGstate machine from the last lesson provided the necessary structure to manage the start, duration, and end of the movement animation.
Preview of the next lesson:
Our character now glides beautifully across the map. But what happens when they try to move into a wall or a tree? Right now, they'll just pass right through it. In our next lesson, we'll solve this by implementing a fundamental JRPG mechanic: Implement collision detection against the map's collision layer. We will leverage the architecture we built today by checking for collisions at the target logical position before we even begin the smooth visual movement.
Can't find a good explanation? Sign up and we'll make it for you
Sign up