Enemy AI Pathfinding For Tower Defense Game

  1. I want to make an enemy pathfind to a player following a specific track using pathfinding for a tower defense game. The paths are curved (like in kingdom rush) and the player is at the end of the path. When the player dies it’s game over.

  2. The problem I have is that the enemy does not pathfind along the track. Instead it finds the shortes way to the player and I don’t know how to make it follow the track.

  3. I’ve tried using the enemies floor material and Region3 to determine whether it is on the track or not but I’m not sure what to do after that. Should I also try raycasting the enemies position too?

The track is made using EgoMoose’s draw class to create the path

The code I’m using to controll the enemies:

-- This uses collection service to gather all the enemies
local CollectionService = game:GetService("CollectionService")

-- Table used to keep track of all alive enemies
local enemies = {}

local rs = game:GetService("ReplicatedStorage")
-- Raycast hitbox used for enemy attacks
local RaycastHitbox = require(rs.Modules:WaitForChild("RaycastHitbox"))

-- Find the player in game.Players
function findPlayer(target)
	for index, plr in pairs(game.Players:GetPlayers()) do
		if plr.Name == target.Parent.Name then
			return plr
		end
	end
end

-- Calculating The players damage based on their armor defense
function CalculatePlayerDamage(player, min, max)
	local defense = 0
	if game.ReplicatedStorage.Armor:FindFirstChild(player.Stats.CurrentArmor.Value) then
		defense = game.ReplicatedStorage.Armor[player.Stats.CurrentArmor.Value].Defense.Value
	end
	return math.clamp(math.random(min, max)-defense, 1, math.huge)
end

-- Squirts blood effects at a specific location
function squirtBlood(location)
	for i = 1, math.random(1,5) do
		local b = Instance.new("Part")
		b.CanCollide = false
		b.Shape = Enum.PartType.Ball
		b.Size = Vector3.new(math.random(5)/10,0,0)
		local bloodColors = {"Bright red","Really red","Crimson","Maroon"}
		b.BrickColor = BrickColor.new(bloodColors[math.random(#bloodColors)])
		b.Velocity = Vector3.new(math.random(-30,30),math.random(20,30),math.random(-30,30))
		b.CFrame = CFrame.new(location)
		b.Parent = game.Workspace.Debris
		game:GetService("Debris"):AddItem(b,1)
	end
end

--Used to create new threads easily while avoid spawn()
function spawner(func,...)
	local co = coroutine.wrap(func)
	co(...)
end

--------------------------
----Enemy AI Handling----
--------------------------

--Simple function for getting the distance between 2 points
function checkDist(part1,part2)
	if typeof(part1) ~= Vector3 then part1 = part1.Position end
	if typeof(part2) ~= Vector3 then part2 = part2.Position end
	return (part1 - part2).Magnitude 
end


--Loops through the human tag to find the closest valid target
function updateTarget()
	local humans = CollectionService:GetTagged("Human")
	for _,v in pairs(enemies) do
		local target = nil
		local dist = v.Settings.ChaseRange
		for _,human in pairs(humans) do
			local root = human.RootPart
			if root and human.Health > 0 and checkDist(root,v.root) < dist and human.Parent.Name ~= v.char.Name then
				dist = checkDist(root,v.root)
				target = root
			end
		end
		v.target = target
	end
end

--Target updating
spawner(function()
	while wait(1) do
		updateTarget()
	end
end)


--Called to have the enemy path towards it's current target
function pathToTarget(enemy)
	local path = game:GetService("PathfindingService"):CreatePath()
	path:ComputeAsync(enemy.root.Position,enemy.target.Position)
	local waypoints = path:GetWaypoints()
	local currentTarget = enemy.target
	for i,v in pairs(waypoints) do
		if v.Action == Enum.PathWaypointAction.Jump then
			enemy.human.Jump = true
		else
			enemy.human:MoveTo(v.Position)
			spawner(function()
				wait(0.5)
				if enemy.human.WalkToPoint.Y > enemy.root.Position.Y then
					enemy.human.Jump = true
				end
			end)
			enemy.human.MoveToFinished:Wait()
			if not enemy.target then
				break
			elseif checkDist(currentTarget,waypoints[#waypoints]) > 10 or currentTarget ~= enemy.target then
				pathToTarget(enemy)
				break
			end
		end
	end
end

--Simple loop to handle the pathfinding function
function movementHandler(enemy)
	while wait(1) do
		if enemy.human.Health <= 0 then
			break
		end
		if enemy.target then
			pathToTarget(enemy)
		end
	end
end


-- Enemy attack function to be optimized some day
function attack(enemy)
	local human = enemy.target.Parent.Humanoid
	if human.Health <= 0 then return end
	local plr = findPlayer(enemy.target)
	local Hitbox = RaycastHitbox:Initialize(enemy.char, {enemy.char, enemy.root, enemy.Human})
	local Damage = CalculatePlayerDamage(plr, enemy.Settings.MinimumDamage, enemy.Settings.MaximumDamage)
	enemy.attackAnim:Play()
	enemy.AttackSound:Play()
	squirtBlood(enemy.char.RightHand:WaitForChild('DmgPoint').WorldPosition)
	squirtBlood(enemy.char.LeftHand:WaitForChild('DmgPoint').WorldPosition)
	Hitbox:HitStart(Damage,'EnemyHumanoid')
	wait(enemy.Settings.AttackCooldown)
	Hitbox:HitStop()
end

-- Check if enemy is close enough to attack
spawner(function()
	while wait(0.5) do
		for _,v in pairs(enemies) do
			if v.target then
				if checkDist(v.target,v.root) < 8 then
					attack(v)
				end
			end
		end
	end
end)


-------------------------------
----Enemy Table Management----
-------------------------------


--Simple function to check instances for humanoid then tag them if found
function tagHuman(instance)
	local human = instance:FindFirstChildWhichIsA("Humanoid")
	if human then
		CollectionService:AddTag(human,"Human")
	end
end

--Respawning that is tied the to the .Died event
function removeEnemy(enemy)
	local index = table.find(enemies,enemy)
	table.remove(enemies,index)
end

--Adds Enemies to our enemies table, sets up respawning,
--and spawns a pathing thread for each enemy.
function addEnemy(enemyHumanoid)
	-- Enemy Values
	table.insert(enemies,{
		char = enemyHumanoid.Parent,
		root = enemyHumanoid.RootPart,
		human = enemyHumanoid,
		target = nil,
		attackAnim = enemyHumanoid:LoadAnimation(enemyHumanoid.Parent.attackAnim),
		AttackSound = enemyHumanoid.Parent.Head.Attack,
		Settings = require(game.ReplicatedStorage.EnemyModules[enemyHumanoid.Parent.Name])
	})
	for _,enemy in pairs(enemies) do
		if enemy.human == enemyHumanoid then
			enemy.human.Died:Connect(function() removeEnemy(enemy) end)
			for i,v in pairs(enemy.char:GetDescendants()) do
				if v:IsA("BasePart") and v:CanSetNetworkOwnership() then
					v:SetNetworkOwner(nil)
				end
			end
			spawner(movementHandler,enemy)
			break
		end
	end
end

--Checking each object in the workspace for a humanoid as it enters/respawns
workspace.ChildAdded:Connect(tagHuman)

--Whenever something is tagged as a enemy then we add it to our table of alive enemies
CollectionService:GetInstanceAddedSignal("Enemy"):Connect(function(enemyHumanoid)
	addEnemy(enemyHumanoid)
end)

--Ran one time to add all current enemies in the workspace on run
function intialize()
	for _,v in pairs(CollectionService:GetTagged("Enemy")) do
		local found = false
		for _,x in pairs(enemies) do
			if x.human == v then
				found = true
			end
		end
		if not found then
			addEnemy(v)
		end
	end
	for i,v in pairs(workspace:GetChildren()) do
		tagHuman(v)
	end
end

intialize()

If I made any mistakes in the code or if its not optimized then do tell me. Any example scripts would be greatly appreciated and thanks for the help in advance. :happy1:

1 Like

Maybe make an invisible wall on either side of the road, and make a collision group where it can only collide with NPCs. This way they will be forced to walk on the path.

Instead of using Pathfinding, I think you should make make invisible part that present the “custom” path to force the NPC go to the right way.

2 Likes

I have tried making invisible walls using mesh parts but roblox collision hitboxes are terrible so it didn’t work. I have also tried building walls with terrain but it didn’t work as well. :sad:

I have tried making walls around the track using mesh parts and collision groups but roblox hitboxes are terrible so it didn’t work. I have also tried building the walls using terrain but that didn’t work either.

I mean make a bunch of “custom” paths, loop thought the paths and use Humanoid:MoveTo() to make the NPC walk in to the position of the path.

Yeah because unfortunately there isn’t any ignore list (to ignore where the internal navMesh can be “generated”) options for the pathfinding service it makes it somewhat difficult to do stuff like this so here are my proposals:

  1. i don’t know how dynamic your game is or what it looks like but one thing you can do is create a “Pathfinding service path” from where the npc starts to the start of the track and create preset custom paths like @Block_manvn said, and then have the npc follow it to the end of the track, and continue this as new paths arise. So basically a mix of the pathfinding service and preset nodes. (i don’t know how well this works but, maybe it’s an option)

  2. The likely better option (and more fun (at least for me)) , especially if this is a pretty dynamic Tower Defense game is to use/make a custom pathfinding algorithm, likely A* if so there are a few implementations of that specific algorithm already on the devforum (i have one as well in one of my Github repositories) and somewhere out there, there are probably some tutorials too

Ok then thanks for the help! I don’t know much or anything about using A* so I’ll try the other option.

make loop and then move to points,