Pathfinding and the Art of Tweaking

Written around 1999

"There's an art to tweaking." This is one of my favorite quotes from Josh Eustis. Josh did the sound effects and music for Cylindrix and Dead Reckoning, two games for which I was the AI programmer. Josh is a local Techno musician whose studio was located in Goldtree's office during the development of Dead Reckoning. It was a funny contrast: a perfectly groomed stylish DJ hanging around with a bunch of nocturnal pizza-eating game developers. He was fun to have around because he had an amazing command of the English language, complete with slang. Although his "tweaking" comment was in reference to music, it applies to game AI as well.

There are several components of the Dead Reckoning AI: the state machine, the "motor skills" (movement), memory, knowledge of the state of the 3D world (sensory), and finally pathfinding. All of these components deserve special attention in their own right, but in this article I will focus on the 3D pathfinding. The reason I focus on this is that I do not believe I have ever seen a game other than Dead Reckoning approach this problem. Descent2 is the only one I can think of, and it is essentially limited to navigating tunnels (only 1 or 2 possible exits and not too many obstructions in the rooms). Before getting into the hairy details of true 3D pathfinding, I will explain how I avoided it entirely in my first game.

Cylindrix: 3D Pathfinding done lite

For the first game, Cylindrix, the pathfinding was fairly straightforward. All of the cylinders were mathematically generated, and followed a few basic rules. All of the obstructions were solid objects attached to the surface of the cylinder that grew thinner towards the center of the cylinder and terminated at a flat top. All of the geometry was very exact, and there were no tricks. This uniform geometry is why we initially called all of the obstructions "pylons". Because of the geometry we were able to implement a nice hack...we broke the world up into a series of 2D planes. At any time, the AI vehicle would be in only one plane, and any destination by its nature would be in an unobstructed "chunk" of the world. Because of this, the AI could actually move around in this plane and reach any unobstructed point in the world. In addition, the world was fairly small, so the overhead required to compute a path in these 2D planes was negligible. I was able to get away with a very inefficient pathfinding algorithm that I made up. We avoided adding "floating pylons" so we could continue using this simplistic pathfinding model.



(click to see larger image)

Here we see a simplified Cylindrix level. Note how all of the geometry is attached to the cylinder and there are no "floating" objects.



(click to see larger image)

Here the pylons are projected onto a 2D plane and the 3D space is "sliced" into a series of 2D grids, perfect for a 2D pathfinding algorithm.



(click to see larger image)

Once we determine which plane the AI vehicle is on, we can simply use that "slice" of the 3D grid. From there we can use a 2D pathfinding algorithm. Note that the depth of the destination is irrelevant since the game was designed such that an obstruction cannot be between two vehicles in the z direction.

 

This demonstrates an important point: The way you model your worlds will greatly affect the amount of time necessary to implement an acceptable pathfinding scheme. I don't necessarily mean the 3D modeling, but more the game model. Is your game a first person 3D shooter? If so you may be able to get away with strictly 2D pathfinding if you set up the world correctly. If your character is essentially moving on a 2D plane (ala Doom), it is a prime candidate for 2D pathfinding. This is one place where the tweaking comes in. In order to maintain this 2D-ness you may have to build contrived levels to avoid confusing the AI. An example would be organizing your level into a series of 2D planes with only a few ways to move between them (staircases). I imagine that a whole article could be written on keeping 3D levels "logically" 2D for the sake of the AI.

So by the end of Cylindrix I thought I was the pathfinding master...the AI never got "stuck" like so often seen in games, and I was extremely proud of this. There was nowhere that the AI couldn't find you. You couldn't hide. To most people this may seem like a no-brainer, but avoiding "getting stuck" is (IMHO) the most difficult problem to address when dealing with real time 3D game AI.

 

 

Dead Reckoning: Pathfinding on steriods

Enter Nicholas Marks. I spent a several months away from Goldtree during the infancy stages of the new game. The 3D programmer, Tony Thibault, created an entirely new engine for the new game. It supported arbitrary 3D geometry with texture mapping and all of the cool graphics things people said were lacking in Cylindrix. Early on I warned Nick about the way my AI worked...I asked him to keep the levels "2D" and avoid putting important things (like the energy square powerup) off of the surface of the cylinder. By the time I was brought in to implement the AI, the levels had become the most complex assortment of floating shapes I had ever seen, and the energy squares were placed anywhere from in the middle of the cylinder to inside of other objects!

I had to admit that the levels looked awesome, and there was no way I was going to convince anyone to simplify them for the sake of the AI. So I set about the task of porting the Cylindrix AI over to the new game. For the most part, I was able to port all of the components of the Cylindrix AI over to Dead Reckoning with minor changes...they essentially worked. However, I ran in to a major problem when using my old pathfinding code in the new game.

Before I discuss these problems I will address the data structures I use for the AI to interface with the 3D world. I find it undesirable to directly access "game data" from the AI. I avoid accessing any 3D data structures, vectors, or entity information directly from the AI. I have middleman functions that loop through data in the 3D world and copy the data into a format easily digestible to the AI. This shields the AI from changes in the internal representation of game data, and also aids in porting the AI to another game. There are many different places I use this idea in the state machine or whatnot, but I will explain specifically how I use it for pathfinding.

All of the pathfinding algorithms I have seen use a grid-based data structure. All of the algorithms mentioned in the October 1996 Game Developer article "Smart Moves: Intelligent Path-Finding" use a grid. The Dead Reckoning engine deals with arbitrary 3D objects, and does not readily lend itself to a grid representation. The first chore in pathfinding was converting from an arbitrary 3D world to a nice 3-dimensional array of grid "blocks" for the AI to work with.

Initially, I had wanted to dynamically compute whether a particular "block" was filled or empty. Unfortunately, doing this proved to be far too expensive. Our next idea was to compute a look-up table when the game started. Computing the lookup table ended up taking several minutes, which is unacceptable load time by any standards. Finally, we made every level file have a pre-generated grid file for the AI to use, and if the grid file is not found it would generate a new one (taking several minutes, but this is better than no pathfinding for the AI!).

The basic algorithm we used for generating the grid is as follows:

for( x in gridxResolution ) {

for( y in gridyResolution ) {

for( z in gridzResolution ) {

//Mark this "block" empty by default

grid[x][y][z] = 0;

for( i in num3DObjects ) {

if( IsObjectInBlock( 3DObjectArray[i], x, y, z ) ) {

grid[x][y][z] = 1;

}

}

}

}

}

(In retrospect it would have been much more efficient to loop through all of the polygons and "project" them into an empty grid, but we were under a deadline and this was the most obvious solution)

In the above code, the IsObjectInBlock() function first uses a mathematical formula to calculate what the bounding box for the grid[x][y][z] block would be, based on the x,y,z coordinates of the block and the x,y,z dimensions of the 3D world, it then loops through all of the polygons in the passed 3D object to test whether they intersect this bounding box over a certain amount. If any polygons from any object intersect with a particular block in the grid, a "1" is stored there. If not, a "0" is stored there.

I really had to tweak the x,y, and z resolutions of the grid. If they are too low, and the resolution of the grid is too low, the geometry of the world will be inadequately represented, and there will be impassable areas where there should be holes, and sharp corners of large objects may be omitted because they do not intersect a block enough, which would make the AI incapable of "seeing" the obstruction. If they are too high and the resolution increases, while the world is more accurately represented, computation of a path becomes extremely costly. What these values should be depends on the complexity and size of the world, and also whether the evil level designer hid powerups in the middle of another 3D object with tiny entrances!

3D "Block" primitives

Once the problem of creating a 3D grid representation of the world was solved, we wrote some primitives for relating these blocks to the real (simulated) world. It is important for the AI to use a clearly defined interface whenever dealing with game data. It should never access the game data directly. I cannot reiterate this enough, because it abstracts the game engine for the AI, allowing it to handle changes in data representation, or even a whole new engine. A few of these primitives are noted below.

int BlockOccupied3D( Block3D block );

This is the function used by the pathfinding algorithm to determine whether a particular node is occupied. This function simply looks in the grid data structure that was loaded from the grid file for the level.

float BlockDistance3D( Block3D block1, Block3D block2 );

I use this function instead of the traditional " Manhattan Distance" in the A* algorithm (see below). This function computes the simulator world 3D center points of the two blocks, and then computes the distance between them.

int BlockVisible3D( Block3D block, Vector vector );

I use this function when actually following the path to make the AI move more smoothly, see below.

The A* pathfinding algorithm

I used the A* algorithm for pathfinding in Dead Reckoning. I chose this algorithm because the article I mentioned earlier from Game Developer said it was a good performer in a variety of different situations. I've never seen any explanation of how to use it with a 3D grid, but making it work in 3D was fairly straightforward.

The earlier Game Developer article has a description of the A* algorithm, but I will summarize it here for those of you who do not have that issue. The algorithm moves through the grid data structure from the given starting point to the given destination. Each "step" it makes it stores in a node. In this node data structure is stored the coordinates of the current block, the distance to the goal from this block, and this block's parent block. As the algorithm moves towards the destination, it stores these nodes into two stacks: the open and the closed stack. Once the destination block is reached, we traverse upwards through the nodes (using the pointer to the parent nodes) and we have our path!

Insert the start point onto the open stack

while( nodes left on open stack ) {

//Pop first (closest) node off of open stack

node = openstack.pop

if( node.bDestination == 1 ) {

Loop upwards through data structure to generate path

exit function

}

//If we get here, this node isn't the destination

for( up, down, forward, backward, left, right in grid ) {

//GetNewNode returns null if block is occupied or in //closed stack

newnode = GetNewNode( currentDirection )

if( newnode ) {

//This InsertSorted function inserts the node //sorted based on the block's distance from the //destination

openstack.InsertSorted(newnode);

}

}

//Once a node is on the closedstack it is no longer used //(unless one of its children is the destination)

closedstack.push( node );

}

In this algorithm the tricky part is actually in the InsertSorted() method of the openstack. You sort the nodes by their distance to the destination. This is the most important part of the algorithm, because the order in which the algorithm picks nodes to search is based on this sorting. Traditionally, (at least in the examples I've seen) you use the Manhattan Distance, which is the distance in grid blocks from the destination. I tweaked this distance function, and instead used the 3D distance of the centerpoint of the current block to the destination (using BlockDistance3D). For whatever reason this made the algorithm work better in my case...it consistantly searched less nodes than using the manhattan distance.

A* + 5 AI's + Huge world = Expensive

Just getting A* to reach the destination in 3D was a triumph, but that was only the tip of the iceberg. I ran into several performance issues almost immediately: Huge open lists, all 5 AI's computing a path at the same time, and finally actually following the path.

The sheer number of nodes you must search to reach a destination in 3D is huge. The one thing you want to avoid is large, flat planes in the world that have important stuff on the other side of them. This causes a huge "ball" of nodes on one side of the plane (see deadreck3.jpg). As you linearly increase the size of a plane, you square the number of nodes you must search to get around it. (I never did the math so I may be off, but let's just say it increases really fast). When playing Dead Reckoning, you may notice that the Guardian cylinder is chock full of large flat planes with pylons in between them. Let's just say that the level designer liked to challenge me :)

I quickly realized that I had to come up with some sort of tweak to speed up pathfinding. I basically implemented time-sharing of the pathfinding algorithm for the different AI's. I created a semaphore variable that one of the AI's could lock to keep the other AI's from using the pathfinding while it was computing a path. Below is a snippet of psuedocode demonstrating this concept:

for( i in loop through all AI's ) {

if( bSemaphore == 0 ) {

if( NeedsPath(AiArray[i] ) {

bSemaphore = 1;

FindPath( AiArray[i] );

}

}

}

bSemaphore = 0;

This allows only one AI to compute one path per frame. This keeps all 5 AI's from trying to compute a path in the same frame, which could potentially cause an erratic frame rate. In addition, I only allow the pathfinding algorithm a certain number of nodes it may process per frame. If the node list gets too large, it waits for the next frame to continue finding the path. This helps reduce the cost of a large number of nodes being processed. As a last resort for when the number of nodes gets out of hand, I have a cap on the number of nodes allowed open when computing a path. If this cap is hit without reaching the destination, the AI will simply fly straight towards its destination, hoping for better luck the next time around. Surprisingly, this almost never happens...or if it does it's when the user isn't looking ;) Once again, in order to get acceptable behavior out of the pathfinding I resorted to tweaking several max value variables. If the "max nodes per frame" is too low, it takes too long (human time) to compute a path, and AI's have to sit in line while the one is computing a path. If the value is too high, sporadic pausing can occur when the AI is attempting to compute a particularly hopeless path. If the node cap is too high, the time it takes to simply add a node to the open list can get huge, but if the value is too low the AI will be unable to compute more difficult paths.

Once the path has been found, the AI must follow it. How this is done depends heavily on how the AI character is allowed to move. There is one trick that I have learned, however, that makes following paths much smoother. Rather than make your AI character physically move to each node in the path, figure out the last unobstructed node in the path. I use the abovementioned BlockVisible3D function to determine this in Dead Reckoning. Because BlockVisible3D is a very expensive function in Dead Reckoning (as visibility tests in 3D often are) I only allow each AI character to increment one node in the path per frame. This avoids calling it hundreds of times in one frame if the path is particularly long and unobstructed until the end. This is plenty fast enough, as it allows the AI to increment roughly 30 nodes in the path per second. Below is some psuedocode describing this (without the restriction on calling BlockVisible3D).

LastVisibleNode = FirstNode

CurrentNode = FirstNode

while( BlockVisible3D( CurrentNode, AIPosition ) {

if( CurrentNode == LastNode ) {

//if the last node is visible, we have reached our destination

exit path

return

}

LastVisibleNode = CurrentNode;

CurrentNode = CurrentNode + 1;

}

//Now we have the node we should be heading towards

FirstNode = LastVisibleNode;

 

(click to see larger image)

Here the red spheres are the obstructions, the blue sphere is the start point, and the pink sphere is the destination. The path curves around the three obstructions. The four white spheres are nodes of the path that passed the visibility test, and the five yellow spheres are nodes that are not visible. The path following algorithm skips the first three nodes, even though it hasn't reached them, because they are visible. It sets it's target node as the last visible node in the path.

 

Debugging your pathfinding

Debugging the pathfinding code was both the most frustrating and fun part of writing the Dead Reckoning AI. It was frustrating because every time I thought I had it solved, the levels would become ever more complex and special cases would pop up (this was invariably in the Guardian cylinder...I still have nightmares about that ankh). What made it so fun though was a cool debugging tool that we put into the engine.

Since the 3D grid is a representation of the world, there is no reason that you cannot map a block back into the real world. And that is exactly what we did. In the screenshots of Dead Reckoning you can see a bunch of smiley-faced boxes snaking through the level. This is a graphical representation of where the A* algorithm looked during pathfinding.

If you plan on implementing real time 3D pathfinding in your game, I highly recommend graphically displaying your paths in the 3D world. Without this visual aid, I would never have been able to tweak the pathfinding the way I did. This display makes it easy to see areas that the AI is having trouble dealing with, and you can make the choice of either adjusting the pathfinding or adjusting your level. Not only that, but it sure does impress your friends when they see the complex paths the AI has to generate when going anywhere.

Tweaking, man, tweaking!

Which brings me to my final point. I have developed a very different approach to AI than I started with four years ago. When I started programming Game AI I was "into" genetic algorithms, neural nets and other such "real" AI subjects. Over time I came to realize that it isn't high falootin' learning that people want out of games. People want the illusion of intelligence. It doesn't matter how you arrive at the result, what matters it the appearance of thought. The way you obtain this illusion is through complexity and tweaking.

Unpredictability is important. It is important that the AI be capable of doing something that the user isn't expecting. When this happens, the user assumes an intelligent agent is causing the action. If there are enough different stimuli in your AI state machine, and enough different possible actions, you can tweak a believable AI out of it.

When I watch people play Dead Reckoning, I love it when they try to hide in the ankh room, assuming that like all other game AI's the computer won't be able to find them, only to have three bad guys swarm into the room and kill them. Over the years people have figured out tricks to beat video games. People memorize patterns in Mortal Kombat, figure out ways to get bad guys stuck behind a corner, figure out that hogging the rocket launcher makes you win every time...people figure out all kinds of patterns. That is what the human mind does best. Good pathfinding nullifies many of the tricks people use to beat AI players in 3D games.



(click to see larger image)

Look where the evil level designer hid the energy powerup! Not only is it an ankh, but it's an ankh in a room with a tiny entrance barely big enough to fit through. How will the poor AI vehicle handle it? The power of A* prevails! However, notice how many nodes were searched to find that entrance!



(click to see larger image)

Here is the same path, but with the unused nodes deleted, what a nice path!



(click to see larger image)

Here is a Dead Reckoning AI vehicle using an old fashioned Cylindrix style path hack. To find something on the surface of the cylinder it uses a "slice" of the 3D grid. (This hacked slice is shaped like a donut, so the curve is logically actually a flat plane to the AI)