Hello! Welcome back to our JRPG engine development course.
In our last lesson, we built a high-performance tile map renderer. We now have a static game world on the screen, rendered efficiently using a camera and viewport culling. It's time to breathe some life into it by adding a character that we can control.
Today's lesson will focus on this learning outcome: Implement grid-based character movement logic controlled by player input.
We will cover the foundational logic of moving a character from one tile to the next on our map's grid. Given your front-end development experience, you can think of this as building a stateful component (Player) that responds to user events (keydown) and updates its position within a parent container (the GameWorld). We'll focus on getting the core architecture right, which will make it easy to add visual polish later.
Step 1: The Simplest Approach to Grid Movement
Let's start by implementing the most basic form of grid movement. The character will "teleport" from one grid cell to the next instantly upon a key press. This helps us establish the basic connections between input, player state, and rendering.
First, we need a Player class. It will store its position in both grid coordinates (gridX, gridY) and pixel coordinates (pixelX, pixelY).
class Player {
constructor(gridX, gridY, tileWidth, tileHeight) {
this.gridX = gridX;
this.gridY = gridY;
this.tileWidth = tileWidth;
this.tileHeight = tileHeight;
this.pixelX = gridX * tileWidth;
this.pixelY = gridY * tileHeight;
// We'll also need a sprite, which we can load via our asset manager
// this.sprite = assetManager.getImage('player_sprite.png');
}
update() {
// Sync pixel position with grid position
this.pixelX = this.gridX * this.tileWidth;
this.pixelY = this.gridY * this.tileHeight;
}
draw(ctx) {
// For now, we'll just draw a simple rectangle
ctx.fillStyle = 'blue';
ctx.fillRect(this.pixelX, this.pixelY, this.tileWidth, this.tileHeight);
}
}
Next, we need to listen for keyboard input. A simple keydown event listener will do. Inside the listener, we'll directly modify the player's grid coordinates.
// In your main Game class's initialization
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowUp':
this.player.gridY -= 1;
break;
case 'ArrowDown':
this.player.gridY += 1;
break;
case 'ArrowLeft':
this.player.gridX -= 1;
break;
case 'ArrowRight':
this.player.gridX += 1;
break;
}
});
// In your game loop
function gameLoop() {
// ...
this.player.update();
// ...
this.clearCanvas();
this.drawMap(this.ctx); // From previous lesson
this.player.draw(this.ctx);
// ...
}
This is the most direct implementation: input immediately changes the state (gridX, gridY), which is then reflected in the next rendered frame. While simple, it has some obvious problems that you've likely already spotted. The movement is jarring, and nothing stops you from pressing keys rapidly, making the character zip around uncontrollably. This doesn't feel like a classic JRPG.
Step 2: Designing a Robust Movement Architecture
To create that deliberate, one-tile-at-a-time movement characteristic of games like Final Fantasy, we need a more robust architecture. The core idea is to use a state machine. The player can only accept input when in an IDLE state. Once a move begins, they transition to a MOVING state, during which further input is ignored.
This architectural pattern is common in game development. The separation of concerns between handling input and executing the resulting action is a powerful concept.
Grid-Based Movement Systems Part 1: Basic Movement
To formalize this idea, let's look at an article that lays out an excellent conceptual foundation for movement systems. 'Grid-Based Movement Systems Part 1' by Kalle Kiiskinen uses C++, but the architectural concepts are universal and highly valuable.
Please read the section titled 'Game State'. Starting just below the heading, read the game states overview. Focus on the purpose of the two game states described: HandleInput and MoveEntities. This separation is the key to preventing unwanted input during a move and will be the foundation for adding smooth animation in our next lesson.
Step 3: Implementing a State-Based System
Inspired by the article, let's refactor our code to use this state-based approach. We'll add a state property to our Player and a method to initiate a move.
First, let's clean up our input handling with a more data-driven approach. Instead of a switch statement, we can use an object to map keys to direction vectors.
const DIRECTIONS = {
'ArrowUp': { x: 0, y: -1 },
'ArrowDown': { x: 0, y: 1 },
'ArrowLeft': { x: -1, y: 0 },
'ArrowRight': { x: 1, y: 0 },
// You can also map WASD keys
'w': { x: 0, y: -1 },
's': { x: 0, y: 1 },
'a': { x: -1, y: 0 },
'd': { x: 1, y: 0 },
};
// New input handler in your Game class
handleInput(e) {
// Only accept input if the player is idle
if (this.player.state === 'IDLE') {
const direction = DIRECTIONS[e.key];
if (direction) {
this.player.startMove(direction);
}
}
}
The video "Grid Aligned Movement" by GMWolf also demonstrates this clean, data-driven way of managing directions, which is a great practice to adopt.
Now, let's update the Player class to incorporate the state machine.
class Player {
constructor(gridX, gridY, tileWidth, tileHeight) {
this.gridX = gridX;
this.gridY = gridY;
this.tileWidth = tileWidth;
this.tileHeight = tileHeight;
this.pixelX = gridX * tileWidth;
this.pixelY = gridY * tileHeight;
this.state = 'IDLE'; // Can be 'IDLE' or 'MOVING'
this.targetGridX = gridX;
this.targetGridY = gridY;
}
startMove(direction) {
// Calculate the destination
const destX = this.gridX + direction.x;
const destY = this.gridY + direction.y;
// In a future lesson, we'll check for collisions here.
// For now, we assume all moves are valid.
this.targetGridX = destX;
this.targetGridY = destY;
this.state = 'MOVING';
}
update() {
if (this.state === 'MOVING') {
// For this lesson, we still move instantly.
// The magic of smooth movement will happen here next time!
this.gridX = this.targetGridX;
this.gridY = this.targetGridY;
this.state = 'IDLE'; // The move is complete, return to idle.
}
// Update pixel position for rendering, now relative to the camera
this.pixelX = this.gridX * this.tileWidth;
this.pixelY = this.gridY * this.tileHeight;
}
draw(ctx, camera) {
// The player's draw position on the canvas is their world position minus the camera's offset
const drawX = this.pixelX - camera.x;
const drawY = this.pixelY - camera.y;
ctx.fillStyle = 'blue';
ctx.fillRect(drawX, drawY, this.tileWidth, this.tileHeight);
}
}
Notice a few key changes:
- The
startMovemethod doesn't changegridX/gridYdirectly. It sets atargetand changes thestate. - The
updatemethod now contains the logic for what to do while'MOVING'. - Because the
MOVINGstate instantly resolves, the effect is still a "teleport," but now it happens within a controlled structure. Input is correctly ignored until the "move" is complete. - The
drawmethod now needs thecameraobject to correctly position the player on the screen, just like we did for the map tiles.
Test your understanding!
Imagine our Player.update() method was changed to this:
update() {
if (this.state === 'MOVING') {
this.gridX = this.targetGridX;
this.gridY = this.targetGridY;
// The line `this.state = 'IDLE';` has been removed!
}
// ... rest of the method
}
What would be the observable behavior in the game after you press an arrow key once?
Show answer
The player would move one tile in the correct direction and then stop. They would not be able to move again.
This is because the handleInput function checks if (this.player.state === 'IDLE'). After the first move, the player's state is permanently stuck on 'MOVING', so no further input is accepted. This demonstrates why correctly managing the transition back to 'IDLE' is crucial.
Conclusion
Excellent work! You have now implemented the logical backbone of grid-based movement. While the visual result is still basic, the underlying architecture is sound and ready for expansion. This state-based approach is fundamental to game programming, managing everything from player movement to menu navigation and combat turns.
Here are our key takeaways:
- State Machines are Essential: Using states like
IDLEandMOVINGgives us precise control over when the character can and cannot act. - Separate Input from Execution: The input handler's job is to signal an intent to move. The
updateloop's job is to execute that move over time. - Data-Driven Design is Clean: Using a
DIRECTIONSobject to map keys to vectors makes the input code more scalable and readable than a largeswitchstatement.
Preview of the next lesson:
Right now, our character snaps from one tile to the next. In the next lesson, we will build upon our new state machine to achieve that satisfying, smooth slide. We will implement smooth visual interpolation for character movement between grid cells. This will involve expanding our 'MOVING' state to track animation progress over time and use interpolation to calculate the player's visual position between two tiles on every frame.
Can't find a good explanation? Sign up and we'll make it for you
Sign up