The Thrill of the Chase

I've gotten to a point in MMMbezzlement where the enemy AI does what I want almost all of the time. That's better than I thought I could do when I started this game, so I will document that minor miracle here.

Here's what I needed my fellow coworkers to do as I stole their lunches all over the office floor:

  • Walk a basic path back and forth or in a circuit depending on the route.
  • If I am caught in an enemy's vision cone while eating food, game over.
  • If I am caught in an enemy's vision cone at any other time, the enemy should chase me for three seconds.
  • If I am able to stay out of any chasing enemy's vision cone for three seconds, that enemy will return to the point where they left their path and resume it.

When you look at that short list of behaviors they seem simple. And, once implemented, the code was in fact pretty straightforward. Setting paths for enemies in GameMaker (v1.4.1772) is trivial. Even getting the enemy to chase the player isn't too bad since GML has built-in functions for that. The tricky part turned out to be combining these behaviors and building a level that allows them to work.

Walking the Path

The default state of all the NPCs in a level is to walk a handmade path. I wanted every NPC to have their own path, but I also wanted only one enemy object. Setting a path in the Drag 'n' Drop UI is easy, there's an action explicitly for it. However you can only set the path per object, not per instance. To do this I created an invisible object that executes the following code for its Create event:

// Creates enemy instances and sets their paths
// This code will break if NUMBER_OF_PATHS is higher than the number of path assets!

NUMBER_OF_PATHS = 4;

var i = 0;  
var enemy = -1;  
repeat(NUMBER_OF_PATHS)  
    {
    enemy = instance_create(0, 0, obj_enemy);
    with (enemy) 
        {
        path_start(asset_get_index("pat_enemy" + string(i)), 2, path_action_reverse, true);
        // used in obj_enemy to return an enemy to a path after they have lost the player
        path = path_index;
        }
    i++;
    }

This will spawn one enemy instance for each path resource I have created and set them on it. This does require you to change the NUMBER_OF_PATHS variable every time you create or remove another path. It also sets each path by its name, so to iterate through them you should use a naming standard that includes a number. path_start and asset_get_index are both built-in GML functions.

The last thing I do for each enemy is save their path_index to a different variable. This is necessary for the way I implement the enemies returning to their path once they have "lost" the player.

Giving Chase

Every enemy has a field of vision (fov) instance attached to them. If the player is ever caught inside that fov and is not holding food, then the enemy chases them. If they catch the player it's game over. I'm not going to cover how it works in this post, but when an enemy sees the player the fov's can_see_player variable is set to true. That triggers the following code which runs under the Step event:

if (fov.can_see_player)  
    {
    fov.image_blend = c_red;
    chase_time = room_speed * 3;
    path_end();
    mp_potential_step_object(obj_player.x, obj_player.y, 4, obj_wall);
    }

Let's go through this line-by-line.

  • fov.image_blend = c_red simply changes the fov color from white to red to make it clear to the player that an enemy is chasing them and which enemy it is.
  • chase_time = room_speed * 3; starts the timer of how long the enemy is going to chase the player. This will only count down when the player leaves the fov and will restart if they re-enter it.
  • path_end() does exactly what it sounds like. This has to be called or the next line will be ignored. This is also why I saved path_index to path in the earlier code, because calling this function clears path_index.
  • mp_potential_step_object(obj_player.x, obj_player.y, 4, obj_wall) makes the enemy follow the player. The last parameter tells the enemy to avoid all instances of the wall object in the room.

Finally, the most complex enemy code runs when the player is not within its fov. This is immediately after the previous code:

else  
    {
    chase_time--;
    // chase player if they have not been out of fov longer than chase_time
    if (chase_time > 0)
        {
        mp_potential_step_object(obj_player.x, obj_player.y, 4, obj_wall);   
        }
    // if enemy has lost player, return enemy to point where they left path and resume
    else
        {
        fov.image_blend = c_white;
        if (path_index == -1)
            {
            // The path variable is set in obj_enemypathset to ensure path is not -1. 
            if (mp_potential_step_object(path_get_x(path, prev_path_pos), path_get_y(path, prev_path_pos), 4, obj_wall))
                {
                path_start(path, 2, path_action_reverse, true);
                path_position = prev_path_pos;
                }
            }
        }
    }

Chase time begins to be decremented at every step, but until it reaches zero, the same mp_step_potential_object function is called. If the player stays out of sight long enough, the enemy's fov turns back to white and they are sent back to the spot on their path where they left to chase the player. I needed to provide x and y coordinates to mp_step_potential_object, so I used path_get_x and path_get_y. These functions require the path index which was saved to path, and the position in that path to check. Path position is a value between 0 and 1 and path_position is a variable all instances with a path have. To keep up with this, I just have a line at the top of the Step event code that says prev_path_pos = path_position.

Finally, when the enemy reaches the point where they left the path (mp_potential_step_object returns true when this happens) they are restarted on that path at that point. This is where I use the path variable that I saved back in my enemy path set object.

Result

Enemy Chase Demo

So, the player gets caught by one enemy and avoids them long enough for the chase timer to run out. That enemy returns to their path and resumes it. A second enemy catches the player and chases them for much longer because the player keeps getting caught in its fov.

This is a rough and stripped down interpretation of what happens in a lot of stealth games. It works pretty well for the simple kind of game I am making.

Issues

While this system is passable, there are definitely some problems.

  • Enemies get stuck when navigating. This is less of a problem with the current map layout. It used to be much worse. I think this is just a limitation of mp_potential_step_object.
  • Paths are predictable. This could certainly be randomized by making more paths and spawning fewer enemies than paths each time. I could also program my own pathfinding algorithm.
  • mp_potential_step_object is resource intensive. It's not a problem yet, but I could get into a situation where a lot of enemies are chasing the player at once and the function is being called every step for all of them. A way to fix this might be to make it so that mp_potential_step_object is only called every few steps.

Please let me know if you have any questions about this system or ideas for how to improve it!