Create your own
Lesson illustration

Grid-Based Player Movement

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:

  1. The startMove method doesn't change gridX/gridY directly. It sets a target and changes the state.
  2. The update method now contains the logic for what to do while 'MOVING'.
  3. Because the MOVING state 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.
  4. The draw method now needs the camera object 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 IDLE and MOVING gives 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 update loop's job is to execute that move over time.
  • Data-Driven Design is Clean: Using a DIRECTIONS object to map keys to vectors makes the input code more scalable and readable than a large switch statement.

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