Grid-Based Movement in a Top-Down 2D RPG With Phaser 3
By: Johannes Baum
Tags: Game Development, Typescript, JavaScript, Phaser 3, Phaserjs

Movement

This section will cover the tile based movement that claims to be the core of this article.

Let us first add a yet unanimated player to implement to movement. We will come back to collisions and player animations later. We are using a free player sprite from the time fantasy game assets: Download Free Player Sprites. Download the sprite set and save the file as characters.png in your assets folder. The single frames of the character sprites have a width of 52 pixels and a height of 72 pixels. However if we take a close look at the graphic files of the character sprites, we can see, that what looks as one pixel are indeed 4 pixels:

What does that mean for the scaling of our character sprites? Remember that we scaled our map tiles by a factor of 3. This means, that every pixel got scaled into an area of 3x3 pixels. So a previously 16 pixels wide tile got scaled into a 48 pixels wide one. Our character sprite is already scaled by 2 so we need to scale it again by 1.5 to get the desired scale of 3 (because 2*1.5 = 3 ).

That gives us a scaled character sprite frame width of 52 pixels * 1.5 = 78 pixels . As you might have noticed that is much wider than our tile size of 48 pixels. This is because the character sprite frames are a little larger than our tile size and have some transparent space around them as you can see when you open the characters.png image. Since this is only whitespace we can simply shift the player sprite a bit and everything is fine as we will see soon.

Scaled dimensions of our player sprite

Now that you know where all the widths, heights and scale factors are coming from, we can start declaring some constants for them. Add the following code to your main.ts file:

Create a file Player.ts with the following content.

startTilePosX and startTilePosY define the starting position of the player on our tilemap. So the player will start on the 8th tile from the left and the 8th tile from the top.

Next we set the player sprite to the correct position. To get the position in pixels we need to multiply the starting position with the tile size. As discussed before we need to shift the player sprite a bit to cope with the additional whitespace that surrounds our player sprites. We will come to the calculation of the player offset soon.

After adding the sprite we scale it by the previously mentioned scale factor and set the start frame of the sprite which is 55 (starting at 0) in our case. You can also take any other frame if you like.

Next, we need to preload our downloaded player sprite:

Let’s now add our player sprite and set the corresponding depth such that it appears between tile layers 3 and 4.

Further we configure the main camera to follow the player sprite. This will be important when we move the player later. This saves us some logic, because otherwise we would need to move the map (and everything on top of it) instead of the player. Moving the player, however, is much more intuitive.

Finally we create an instance of our Player class, initiating it with the player sprite and the tile coordinates of the player’s starting position.

What about the players offset?

We need to shift our players x-position by half a tile width, because its positioning is based on the sprites center.

The y-offset is a bit different. Our player sprite begins at the bottom of the sprite frame. However, our sprite frame has a scaled height of 72 pixels * 1.5 = 108 pixels but this is more than two tile sizes 2 * 48 = 96 . Since our sprite is positioned based on its center, we need to subtract half of the difference between two tile sizes and our scaled sprite height:

Move the player

Let’s start by moving our player. Before we implement the movement we create an enum for directions in our 2D grid world. Create a file Direction.ts with the following content:

We will create a GridControls class that is responsible for handling the user input and telling our physics engine what to do. That GridControls class will get an instance of our physics engine that we call GridPhysics .

On every update of the Phaser 3 game engine we will check for pressed cursor keys and start a movement in the corresponding direction. We wire the GridControls and GridPhysics classes as follows in our main.ts :

It is as easy as creating both instances in the create() method and calling their update() methods in Phasers update() hook. Phaser calls the update method with two arguments: time and delta . While time represents the current time, delta is specified as “The delta time in ms since the last frame”. We will see later why we need this value.

Grid Physics

Movement on a 2D surface can best be described with two-dimensional vectors. Phaser 3 already offers a Vector2 class with x and y coordinates and many useful calculations. Therefore we will take advantage of that Vector2 class a lot in our implementation. Let’s first take a look at the movePlayer() method of our physics engine.

Whenever a movement is started, the engine checks if there is already an ongoing movement. If so, nothing happens until that movement has finished. That ensures that you can’t change direction until the player has not walked a distance of at least one tile. If there is no ongoing movement, a new one is started by setting a movement direction.

We keep a variable movementDirection holding the current movement direction of the player, while a value of Direction.NONE means that the player is not moving. We initialize it with a Zero-Vector (x = 0, y = 0). Additionally we create a mapping between our Direction enum and their representations in the 2-dimensional vector space. Phaser 3 already offers predefined Vector2 objects for this case. These vectors are of the following shape:

Vector2.UP: (x = 0, y = -1)

Vector2.DOWN: (x = 0, y = 1)

Vector2.LEFT: (x = -1, y = 0)

Vector2.RIGHT: (x = 1, y = 0)

These vectors are special because of their following mathematical property: if we add a multiple of one of these vectors to any position in our coordinate system (which is also a vector), we will get a new vector that is shifted in exactly the corresponding direction. So if our player is standing at position (x = 5, y = 6) and we add Vector2.UP to that position vector, it will have the coordinates (x = 5, y = 6 + (-1)) = (x = 5, y = 5). That is equivalent to moving our player up by one position in our coordinate system. You might ask, why we did not simply take the players y coordinate and added 1 to it. That is a fair question, since the above calculations seem to be more complicated. But in fact these vector calculations will keep our code simpler, shorter and less error prone. You will see later that this more general vector operations will save us writing several if-else statements checking for the movement direction and handling each case separately.

Our update() method contains a similar logic:

If the player is moving it will update his position.

Let’s take a look at updatePlayerPosition() :

We need a constant speedPixelsPerSecond that will determine how many pixels are added to the current player position per second. This will eventually manifest as the speed of the player moving on our map.

As a parameter to our update method we give the delta in milliseconds since the last update. This will be provided to us by Phaser and the exact number depends on the frames per seconds of the machine. Therefore we can’t simply add a fix speed value to the position of our player, unless we want the player to move with different speeds on different machines. We need to update the position of the player with respect to the passed milliseconds since the last update. We use the helper method getSpeedPerDelta() for that.

But unfortunately we can’t just add that value to the players position for two reasons:

1. We want to move in whole tile sizes

We keep a variable tileSizePixelsWalked that keeps track of the pixels of the tile that we already walked. This way we can make sure that we never exceed a tile border. Our method willCrossTileBorderThisUpdate() will tell us if the player will cross a tile border in the current update or not.

Whenever the player will cross a tile border this update, we only move him to the end of the tile. Otherwise we will move him by the speed of the current delta.

2. We want to move in whole pixels

On every update we want to move in whole pixels. For example, if getSpeedPerDelta() will return 5.6 for this update, we want to move only 5 pixels and not 5.6. Otherwise we will get ugly rendering issues. But we can’t simply drop the decimal places. Our movement would not be fluid anymore and also our speed in pixels per second would not be correct. Therefore we need to keep track of the exact movement distance including the decimal places, but only render the integers. We solve this by keeping a variable decimalPlacesLeft that we use to keep the decimal places for the next update:

Let’s now look at the actual movement of the player sprite:

To move the player sprite we take the current pixel position of the player and add the corresponding direction vector multiplied by the movementSpeed . This is a quite elegant way to move the player leveraging simple linear algebra. That saves us juggling with several if-else statements that cover the different cases for each movement direction.

We provide two methods in our Player class to simplify operations on the players position:

We should now be able to move the player on our map in a grid-based style. But it looks very weird if the player is not animated so let us add some animations next.