Paul Briërley

Pathfinding with GameplayKit

Over the years I've had an on-off relationship with GameplayKit. I would start trying to build a game with it, and then end up tying myself in knots trying to get pathfinding working. There aren't many good GameplayKit tutorials on the web, so now that I have it working I'm writing up my findings in case someone else finds them useful.

The short version

GameplayKit pathfinding works as advertised, but I've found the GKAgent is a bit fiddly for actually getting sprites to follow those paths in the desired manner. My end result has been to use GameplayKit to calculate the best path, but then just use SpriteKit SKActions to move my sprites around the screen.

The basics of my app setup

My app is pretty simple - it just displays a single subclass of SKScene on screen. That scene is loaded from a .sks file, and just contains a single SKTileMapNode which fills the entire scene. My game is a top down game - the player will click or tap somewhere on the screen and the player sprite will move there. For a retro vibe I'm constraining everything to a grid - SKTileMapNode works really well for this because you can draw your entire background using just a single node, so it's an efficient way of painting the scene.

Calculating the graph

GameplayKit pathfinding requires you to create a graph of nodes, and then uses this to determine the best route between two points. First lesson - the "nodes" used in these graphs are completely unrelated to the nodes you use to draw your scene. This may be obvious to some readers, but the common terminology can make some of the documentation confusing – especially because you can create pathfinding nodes from sprite nodes if you need to.

The first step to pathfinding is to create the graph. Since I'm constraining movement to a 2D grid, I'll be using GKGridGraph. Note that I use a reference to my SKTileMapNode to get the number of rows and columns in the grid graph.

let gridGraph = GKGridGraph(fromGridStartingAt: vector_int2(x: 0, y: 0),
 width: Int32(floorTileMap.numberOfColumns),
 height: Int32(floorTileMap.numberOfRows),
 diagonalsAllowed: true)

Responding to a tap

In your SKScene you can respond to taps on the screen by using the mouseUp: function for macOS or onTap: for iOS. Use these to call a function on your player - in my code, the player is represented by a GKEntity and the code for responding to the tap is in a GKComponentbelonging to that entity. When I call this code, I pass in the graph and the location which was tapped:

override func mouseUp(with event: NSEvent)
{
 let endPoint = event.location(in: self)
 player.component(ofType: GoToTap.self)?.moveTo(endPoint, onTileMap: self.floorTileMap)
}

Second Lesson - that location is in the scene's coordinate system. We'll need to convert it to the tile map coordinate system before we can use it. Otherwise our sprite will respond to taps, but won't move to the location we expect!

Calculating the path

Calculating the path requires a few steps. First, we need to convert the player's current position in the scene coordinate system into the player's current position in the tile map coordinate system. Note that because we're using the GameplayKit "entity and component" approach, we have to look up the sprite component to find it's current position.

if let spriteComponent = self.entity?.component(ofType: SpriteComponent.self)
{
 let startPosition = spriteComponent.node.scene?.convert(spriteComponent.node.position, to: tileMap)
}

With the starting position, we then work out which row and column of the SKTileMapNode this corresponds with. Note that I'm being a bit lazy with optionals here, and assuming the position will correspond to a location on the tile map. That won't necessarily be the case if you have a tile map which is a different size to your scene.

let startRow = tileMap.tileRowIndex(fromPosition: startPosition!)
let startColumn = tileMap.tileColumnIndex(fromPosition: startPosition!)

We now do the same for our destination, as described by the tap or click location we passed to this function.

let endPosition = tileMap.scene?.convert(point, to: tileMap)
let endRow = tileMap.tileRowIndex(fromPosition: endPosition!)
let endColumn = tileMap.tileColumnIndex(fromPosition: endPosition!)

With starting rows and columns, we now work out which nodes in our graph represent these. Third lesson - on a number of occasions I created nodes and tried to work out the route between them which didn't work because those newly created nodes were not part of the graph. You have to use the nodes which are already in the graph for pathfinding. Again, maybe obvious in hindsight but I was stumped by this for way too long!

let startNode = self.gridGraph.node(atGridPosition: vector_int2(x: Int32(startColumn), y: Int32(startRow)))
let endNode = self.gridGraph.node(atGridPosition: vector_int2(x: Int32(endColumn), y: Int32(endRow)))

And now, finally, we can use our graph to calculate the path between these nodes. This returns an array of GKGridGraphNode which each have a gridPosition property with x and y coordinates. We remove the first item from the array because that represents our starting position.

var nodesToFollow = self.gridGraph.findPath(from: startNode!, to: endNode!) as! [GKGridGraphNode] nodesToFollow.removeFirst()

Following the path

In theory at this point, you would create a GKPath to represent the route. Fourth lesson - a GKPath is not just the array of coordinates we just calculated. It uses Floats, so needs some conversion... and it also uses its own coordinate system. There's a teeny line in the Apple documentation which says you should probably use the coordinate scheme of your scene. Spotting this was a revolution to me – it's up to you to convert the path into coordinates in your scene. I was totally expecting GameplayKit to do this for me.

Once you have your GKPath you can use it to create a GKGoal (e.g. to follow the path), and then set that as a GKBehavior of a GKAgent2D which you also add to your player entity. This works... but no matter how much I tweaked the parameters, I never got it to move naturally. Fifth lesson - if you do decide to use this approach, note that GKAgent2D has a maximumSpeed property. Until I realised this, I couldn't understand why pathfinding was so slow!

Instead, I create a bunch of SKActions and use these to move my player. I found that the precise control this gave me allowed the style of movement I was expecting from the sprite. The code for this is below. Note that there's a little bit of math to convert the integer rows and columns in the found path into the centre of tiles on my 16 x 16 SKTileMapNode.

var animations: [SKAction] = []
for node in nodesToFollow
{
 let nextX = CGFloat(node.gridPosition.x * 16 + 8)
 let nextY = CGFloat(node.gridPosition.y * 16 + 8)
 let nextPoint = CGPoint(x: nextX, y:nextY)
 let nextAction = SKAction.move(to: nextPoint, duration: 0.5)
 animations.append(nextAction)
}
spriteComponent.node.run(SKAction.sequence(animations))

And that's it...

I make no claims that my approach is great. There are probably millions of mistakes I've made and things I could have done better... but my key takeaway is that I got pathfinding working, I managed to use Apple's entity and component system, and my animation works as expected. If you're playing around with GameplayKit and pathfinding, I hope some of the above will be useful to you!