Enemy AI: chasing a player without Navigation2D or A* pathfinding

Making a game where your enemies need to chase the player?  This starts out easy, make the enemy run towards the player!  But what happens when they are behind a tree?  Or around the corner of a wall?  Well, now your enemy looks quite silly as it is stuck against the object, running in place.  Not good!

To solve this, you could use the Navigation2D or AStar nodes built into Godot (here's a tutorial by GDQuest covering both of them).  But for Helms of Fury we used a different method which works great for our game and we wanted to share in this tutorial.  Here's how it looks:

Getting Started

We are going to assume you are making your enemies as KinematicBody2D objects, and that you are using a State Machine to manage their states.  Not sure what a State Machine is?  I like this article explaining State Machines and why you would use them - and here's another article on implementing simple State Machines in Godot.

To begin with, here is a simple Chase state for a dumb enemy that just runs towards its target, and probably gets stuck on something along the way:

# ChaseState.gd

func _init(enemy, params):
  enemy.dir = (enemy.target.position - enemy.position).normalized()

func _physics_process(delta):
  var motion = enemy.dir * enemy.speed
  enemy.move_and_slide(motion)

Scent Trails

To improve this we make the player leave a trail of their past positions as they move.  This way, if the enemy cannot see the player, it can check if it can see any of these past positions, then follow them to the player.  Since this is similar to how a dog would track something, we'll call it a scent trail.

So to make this scent trail work we need to add a Timer node to our player, make it autostart and set a wait_time (we used 0.1s), then add some code so when it times out it drops a scent.

# Player.gd
extends KinematicBody2D

const scent_scene = preload("res://Player/Scent.tscn")

var scent_trail = []

func _ready():
  $ScentTimer.connect("timeout", self, "add_scent")

func add_scent():
  var scent      = scent_scene.instance()
  scent.player   = player
  scent.position = player.position

  Game.level.effects.add_child(scent)
  scent_trail.push_front(scent)

Then we have to make our actual Scent.tscn that gets dropped.  This is just a simple Node2D scene that includes a Timer, so that the scent can expire.

# Scent.gd
extends Node2D

var player

func _ready():
  $Timer.connect("timeout", self, "remove_scent")

func remove_scent():
  player.scent_trail.erase(self)
  queue_free()

If you want the scents to be visible while debugging, you can add a ColorRect node to them, then just hide it once its working.  With this in place you should see scent's being left behind your player as you run around.

Now we want our enemies to embrace their inner bloodhounds and follow these new scents when they cannot see the player.  But to make that work, we will need to add a RayCast2D node to our enemies, and setup Physics Layers in Godot so our ray knows what it can collide with.

Physics Layers

To set up Physics Layers in Godot you want to click on Project from the top menu, then Project Settings, then near the bottom on the left side is a Layer Names section, and you want to choose 2D Physics.

Name these whatever you like for your game.  Once you have them named, go through the objects in your game, and in the Property Inspector sidebar you can expand Collision then click ·· to assign them.  For your objects you want to assign them as Layers.


With Physics Layers assigned to your objects, now you want to update the RayCast2D on your enemies to check against the layers enemies cannot move through (in our case its solid, object, crate, hole, gate_closed)

And with our Physics Layers all setup, our last step is to update our Chase state.

# ChaseState.gd

func _init(enemy, params):
  chase_target()

func chase_target():
  var look     = enemy.get_node("RayCast2D")
  look.cast_to = (enemy.target.position - enemy.position)
  look.force_raycast_update()

  # if we can see the target, chase it
  if !look.is_colliding():
    enemy.dir = look.cast_to.normalized()

  # or chase first scent we can see
  else:
    for scent in enemy.target.scent_trail:
      look.cast_to = (scent.position - enemy.position)
      look.force_raycast_update()

      if !look.is_colliding():
        enemy.dir = look.cast_to.normalized()
        break

func _physics_process(delta):
  var motion = enemy.dir * enemy.speed
  enemy.move_and_slide(motion)

So now when an enemy enters the Chase state it tries to raycast to the player and if nothing is in the way - chase em!  If something is in the way, it goes through the scent trail in order and tries to raycast to each scent until it can see one, then - chase it!

And it now works, your enemies are bloodhounds!  To improve this further you may want collision avoidance between enemies, but perhaps that's a tutorial for another day ;)

That's a Wrap!

If you liked this tutorial and want more, follow us on twitter @abitawake.  And if you're a fellow indie developer, consider giving our ManaKeep web service a try =)

Curious about the game we are working on?  Check out helmsoffury.com (spark some joy and wishlist us on Steam!)