Point & Click Movement with Pathfinding using Tilemaps in Phaser 3

Use a mouse click to select a target and breadth-first search to find a path there

by on 11 minute read


Are you are making an action-RPG, RTS, dungeon crawler, or similar type of game using tilemaps in Phaser 3 and want characters to move to where the mouse was clicked?

Maybe you've tried adjusting the velocity or used a tween to move to the mouse position but found that it didn't really work? Especially when there's a wall in the way?

What you need is a pathfinding algorithm!

In this article, we'll show you how to add point & click movement using breadth-first search to discover a path and then have the character move along it.

Example Base Project

We'll be building on top of our Dungeon Crawler Starter Template that uses TypeScript and comes with an 8.5 part YouTube series going over how it was made.

If you are not familiar with TypeScript then we've got a free book to help you get started!

Learn to make an Infinite Runner in Phaser 3 with TypeScript!

Drop your email into the box below to get this free 90+ page book and join our newsletter.

Learn more about the book here.

The code in this article should work for any Phaser 3 project that uses tilemaps but you may have to refer to the Dungeon Crawler Starter Template for additional context.

Start with a Click

Let's start by listening for a POINTER_UP event on the Game Scene. The pointer data will be used to determine which tile was clicked.

This tile will be used as the target location for our character to move to.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
create()
{
	// other code...

	this.input.on(Phaser.Input.Events.POINTER_UP, (pointer: Phaser.Input.Pointer) => {
		const { worldX, worldY } = pointer

		const startVec = groundLayer.worldToTileXY(this.faune.x, this.faune.y)
		const targetVec = groundLayer.worldToTileXY(worldX, worldY)

		// use startVec and targetVec

	})

	// remember to clean up on Scene shutdown
	this.events.on(Phaser.Scenes.Events.SHUTDOWN, () => {
		this.input.off(Phaser.Input.Events.POINTER_UP)
	})
}

The worldX and worldY values from the pointer represent where the player clicked. We use object destructuring on line 6 to get the values.

Note: if you get an error about tslib then you probably need to install it with npm install tslib --save-dev.

Then we use those values on line 9 to get the tile x and y values from the groundLayer which is a StaticTilemapLayer.

We do the same thing with the player (faune) on line 8 to get the starting position.

In breadth-first search, we need a start position to begin traversal and then a target position to know when we're done. The startVec and targetVec variables give us this information.

They are suffixed with Vec because they are of type Phaser.Math.Vector2.

Next, we will implement the breadth-first search algorithm to create a lookup table that can be used to construct a path. This path will be an array of Phaser.Math.Vector2 values representing the world positions the player needs to move along.

Let's add a findPath.ts file to the utils folder like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import Phaser from 'phaser'

interface TilePosition
{
	x: number,
	y: number
}

const toKey = (x: number, y: number) => `${x}x${y}`

const findPath = (start: Phaser.Math.Vector2, target: Phaser.Math.Vector2, groundLayer: Phaser.Tilemaps.StaticTilemapLayer, wallsLayer: Phaser.Tilemaps.StaticTilemapLayer) => {
	// TODO: implement breadth-first search and return path
}

export default findPath

Notice that we declared an interface called TilePosition and created the toKey(x, y) helper function.

These are to help make the implementation code in findPath() easier to understand.

With breadth-first search, we will add the start tile to a queue. Then we will look at each item in the queue and add all its neighbors. As we do this we will construct a lookup table of parent tiles that we can use to generate a path from the target to where the player is.

We recommend taking a look at this video for an in-depth explanation and step-by-step implementation of breadth-first search.

The groundLayer and wallsLayer is passed to this function so that we can check if the target is a valid ground tile for the player to walk to and if there are walls in the way.

Here's the breadth-first search implementation that builds a parent lookup table:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
const findPath = (start: Phaser.Math.Vector2, target: Phaser.Math.Vector2, groundLayer: Phaser.Tilemaps.StaticTilemapLayer, wallsLayer: Phaser.Tilemaps.StaticTilemapLayer) => {
	// no path if select invalid tile
	if (!groundLayer.getTileAt(target.x, target.y))
	{
		return []
	}

	// no path if select a wall
	if (wallsLayer.getTileAt(target.x, target.y))
	{
		return []
	}

	const queue: TilePosition[] = []
	const parentForKey: { [key: string]: { key: string, position: TilePosition } } = {}

	const startKey = toKey(start.x, start.y)
	const targetKey = toKey(target.x, target.y)

	parentForKey[startKey] = {
		key: '',
		position: { x: -1, y: -1 }
	}

	queue.push(start)

	while (queue.length > 0)
	{
		const { x, y } = queue.shift()!
		const currentKey = toKey(x, y)

		if (currentKey === targetKey)
		{
			break
		}

		const neighbors = [
			{ x, y: y - 1 },	// top
			{ x: x + 1, y }, 	// right
			{ x, y: y + 1 },	// bottom
			{ x: x - 1, y}		// left
		]

		for (let i = 0; i < neighbors.length; ++i)
		{
			const neighbor = neighbors[i]
			const tile = groundLayer.getTileAt(neighbor.x, neighbor.y)

			if (!tile)
			{
				continue
			}

			if (wallsLayer.getTileAt(neighbor.x, neighbor.y))
			{
				continue
			}

			const key = toKey(neighbor.x, neighbor.y)

			if (key in parentForKey)
			{
				continue
			}

			parentForKey[key] = {
				key: currentKey,
				position: { x, y }
			}

			queue.push(neighbor)
		}
	}

	const path: Phaser.Math.Vector2[] = []

	// TODO: construct path

	return path
}

The bulk of the code is a basic breadth-first search implementation using just the 4-way north, south, east, and west neighbors. Each neighbor is added to the parentForKey lookup table with the current tile as the parent.

Notice that we are not storing the actual Tile reference but just its x and y positions.

Next, we can use the parentForKey lookup to get all the tiles that make a path from the target location to the starting location.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const findPath = (start: Phaser.Math.Vector2, target: Phaser.Math.Vector2, groundLayer: Phaser.Tilemaps.StaticTilemapLayer, wallsLayer: Phaser.Tilemaps.StaticTilemapLayer) => {
	// previous code...

	const path: Phaser.Math.Vector2[] = []

	let currentKey = targetKey
	let currentPos = parentForKey[targetKey].position

	while (currentKey !== startKey)
	{
		const pos = groundLayer.tileToWorldXY(currentPos.x, currentPos.y)
		pos.x += groundLayer.tilemap.tileWidth * 0.5
		pos.y += groundLayer.tilemap.tileHeight * 0.5

		path.push(pos)

		const { key, position } = parentForKey[currentKey]
		currentKey = key
		currentPos = position
	}

	return path.reverse()
}

The goal of this code is to find the lineage that takes us from the target position back to the start position.

We do this by adding the target's parent to the path array and then doing the same for that parent's parent and so forth until we get to the start position.

Another way to look at it:

target -> parent -> grandparent -> great grandparent -> ... -> start

This lineage is what makes up the path array. The position of each ancestor is added to this list and then the entire list reversed.

Moving the Player Along a Path

Now we can give the player the path array and have it move to each position one after the other.

Start by adding these properties and methods to the Faune class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export default class Faune extends Phaser.Physics.Arcade.Sprite
{
	// other properties...

	private movePath: Phaser.Math.Vector2[] = []
	private moveToTarget?: Phaser.Math.Vector2

	// other methods...

	moveAlong(path: Phaser.Math.Vector2[])
	{
		if (!path || path.length <= 0)
		{
			return
		}

		this.movePath = path
		this.moveTo(this.movePath.shift()!)
	}

	moveTo(target: Phaser.Math.Vector2)
	{
		this.moveToTarget = target
	}

	// remove the old update() method and add this one
	update()
	{
		if (this.healthState === HealthState.DAMAGE
			|| this.healthState === HealthState.DEAD
		)
		{
			return
		}

		// TODO: add movement logic
	}
}

This new code should be pretty straight forward. We add the movePath and moveToTarget properties that will hold the path of positions and the current position to move to.

Notice that ! operator on line 18. This is the non-null assertion operator. This lets us tell TypeScript that the result of shift() will return a valid value and not null or undefined.

We are going to replace the existing update(cursors: Phaser.Types.Input.Keyboard.CursorKeys) method with a new one on line 27. The player's movement will be based on moveToTarget instead of CursorKeys so we no longer need cursors to be passed in.

We'll use logic that mimics keys being pressed depending on where moveToTarget is relative to the player's position. We've left this method relatively similar to the original so that you can more easily compare and see the differences.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
update()
{
	if (this.healthState === HealthState.DAMAGE
		|| this.healthState === HealthState.DEAD
	)
	{
		return
	}

	let dx = 0
	let dy = 0

	if (this.moveToTarget)
	{
		dx = this.moveToTarget.x - this.x
		dy = this.moveToTarget.y - this.y

		if (Math.abs(dx) < 5)
		{
			dx = 0
		}
		if (Math.abs(dy) < 5)
		{
			dy = 0
		}

		if (dx === 0 && dy === 0)
		{
			if (this.movePath.length > 0)
			{
				this.moveTo(this.movePath.shift()!)
				return
			}
			
			this.moveToTarget = undefined
		}
	}

	// this logic is the same except we determine
	// if a key is down based on dx and dy
	const leftDown = dx < 0
	const rightDown = dx > 0
	const upDown = dy < 0
	const downDown = dy > 0

	const speed = 100

	if (leftDown)
	{
		this.anims.play('faune-run-side', true)
		this.setVelocity(-speed, 0)

		this.flipX = true
	}
	else if (rightDown)
	{
		this.anims.play('faune-run-side', true)
		this.setVelocity(speed, 0)

		this.flipX = false
	}
	else if (upDown)
	{
		this.anims.play('faune-run-up', true)
		this.setVelocity(0, -speed)
	}
	else if (downDown)
	{
		this.anims.play('faune-run-down', true)
		this.setVelocity(0, speed)
	}
	else
	{
		const parts = this.anims.currentAnim.key.split('-')
		parts[1] = 'idle'
		this.anims.play(parts.join('-'))
		this.setVelocity(0, 0)
	}
}

We use dx and dy to represent the difference in x and y between moveToTarget and the player's position.

The player is considered to have arrived at the target if dx and dy are within 5 pixels of the target location on either side. This check happens on lines 18 and 22.

Then on line 31, a new moveToTarget position is set by taking the next one from this.movePath. If there are no more positions left then moveToTarget is set to undefined and the player will stop moving.

The subsequent leftDown, rightDown, upDown, and downDown variables that were used to denote arrow keys being pressed is now set based on whether dx and dy is positive or negative.

Lastly, the same animation and velocity logic is used to move the player each frame.

Putting it Together

We created a bunch of code, methods, and functions so let's use them together to achieve point & click movement!

Back in the create() method of the Game Scene we need to use findPath() and call this.faune.moveAlong():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// import at top of file
import findPath from '../utils/findPath'

// then in create()...
create()
{
	// previous code...

	this.input.on(Phaser.Input.Events.POINTER_UP, (pointer: Phaser.Input.Pointer) => {
		// tslib error here
		const { worldX, worldY } = pointer

		const startVec = groundLayer.worldToTileXY(this.faune.x, this.faune.y)
		const targetVec = groundLayer.worldToTileXY(worldX, worldY)

		// generate the path
		const path = findPath(startVec, targetVec, groundLayer, wallsLayer)

		// give it to the player to use
		this.faune.moveAlong(path)
	})
}

You should end up with something like this that will find an appropriate path around walls and move the player along that path:

Next Steps

This was a meaty article full of code so take some time to digest it!

Try using the NE, SE, SW, and NW diagonal neighbors in the breadth-first search algorithm to see how that changes the path.

We've completely omitted the logic for collecting treasure chests for simplicity so feel free to see how you can add that back!

Be sure to sign up for our newsletter so you don't miss any future Phaser 3 game development tips and techniques!

Drop your email into the box below.

Don't miss any Phaser 3 game development content

Subscribers get exclusive tips & techniques not found on the blog!

Join our newsletter. It's free. We don't spam. Spamming is for jerks.

Phaser 3 dungeon crawler pathfinding breadth-first search algorithms

Want tips and techniques more suited for you?


You may also like...


Video Guides


Beginner Guides


Articles Recommended For You

How to Load Images Dynamically in Phaser 3

by on

Does your game have a lot of images that not every player sees? Maybe a collectible card game? Loading those images …

6 minute read

Fix Stretched Image Distortions in Phaser 3 with 9-Slice Scaling

by on

Are you having image distortion problems when scaling to make a button or panel graphic bigger? Multiple versions of the …

5 minute read

Command Pattern to Undo Player Actions

by on

Are you looking for a clean and reusable way to implement undo for player actions? Perhaps you are making a turn-based …

15 minute read

Advanced Logging with the Strategy Pattern

by on

Have you ever tried debugging a problem with your game that only seems to happen in production? The Developer Tools …

7 minute read

Didn't find what you were looking for?


comments powered by Disqus