AI unable to catch up to player

I’ve been struggling with this problem for a couple of months now and because of my relative inexperience in this field, I do not know what to try. Because the issue is of fundamental importance to the game I’m making, I haven’t been able to get any real work done on the project since the issue has popped up.

The game I’m making is essentially a zombie survival game with different types of zombie AI to fight against in large numbers. In order for this game to work, the AIs need to pose a serious threat to the survival of the players. As such, I’ve made a type of fast AI that is meant to be able to quickly and accurately catch up to fleeing players and deal chip damage to them. I’ve seen this type of fast-traveling AI countless times in other games without the problem I’m experiencing, so I know it’s possible to do.

Whenever the fastest-traveling AI gets within a few studs of the player while the player is running away, their movement speed essentially slows all the way down to the default speed in that distance and the AI becomes unable to catch up to the player. This trivializes the gameplay because so long as the player is moving away from the AI (which would be really easy to do in the final game because of the size of the maps), the player will never get hurt. It also makes it extremely difficult, almost impossible, to effectively balance these AI because if I balance them according to this current system, they will become punishingly difficult to deal with should the problem be figured out later down the line. This issue seems to be reflected across all enemy types, regardless of movement speed.

Over several threads, I’ve seemed to nail down the issue to being one of the AI not accurately reading the position of the player, with the position that they think the player is located lagging a few studs behind where the player actually is. This is pretty well-showcased by this picture I took here: the green dot is what the pathfinding module that I’m using (SimplePath) recognizes as the final waypoint, or the destination that the AI is meant to go. Notice how it’s behind me as I run away.

This essentially means that the AI’s target is not where I am, but where I was, which means that so long as I’m moving away from them, they will never catch up to me and so they will never hurt me. I don’t want to extend their hitboxes so they can hit me from where they are because as someone who enjoys playing games like the one I’m making, I’ve come to hate unreasonably large hitboxes, and I think the players of my game would too if I were to try to compensate in that way for a distance this large.

Below is the code for the small, fast-traveling enemy. The code is the same for all moving enemy types except for minor differences in recognizing size and such, and the script resides in the AI’s character model. I’ve been told that I should make it more centralized and use CollectionService, but I want to fix this problem first. If that’s what’s needed to fix the problem, then I’ll do it, but fixing this problem supersedes anything else right now.

-- services and modules
local RS = game:GetService("ReplicatedStorage")
local SS = game:GetService("ServerStorage")
local SP = require(SS:WaitForChild("SimplePath"))

-- variables
local sprinter = script.Parent
local hum = sprinter:WaitForChild("Humanoid")
local HRP = sprinter:WaitForChild("HumanoidRootPart")

-- SimplePath path creation
local path = SP.new(sprinter)
path.Visualize = true

-- array of enemy names
local names = {}
for _, name in pairs(RS:WaitForChild("Enemies"):GetChildren()) do
	table.insert(names, name.Name)
end

-- function to check if a humanoid is an enemy humanoid
function checkNames(query)
	for _, name in pairs(names) do
		if query == name then
			return true
		end
	end
	return false
end

-- function to find a target
function findTarget()
	local aggro = 200
	local target
	local blocked = true
	for _, character in pairs(workspace:GetChildren()) do
		local human = character:FindFirstChild("Humanoid")
		local RP = character:FindFirstChild("HumanoidRootPart")
		if human and RP and human.Health > 0 and not checkNames(RP.Parent.Name) then
			if (RP.Position - HRP.Position).Magnitude < aggro then
				aggro = (RP.Position - HRP.Position).Magnitude
				target = RP
				local rayInfo = RaycastParams.new()
				rayInfo.FilterType = Enum.RaycastFilterType.Blacklist
				rayInfo.FilterDescendantsInstances = sprinter:GetChildren()
				local hit = workspace:Raycast(HRP.Position, (RP.Position - HRP.Position).Unit * 200, rayInfo)
				if hit.Instance == RP then
					blocked = false
				end
			end
		end
	end
	return target, blocked
end

-- main function, uses MoveTo() if there's nothing in the way and uses SimplePath if otherwise
while true do
	wait()
	local target, blocked = findTarget()
	if target then
		if not blocked then
			print("not blocked")
			hum:MoveTo(target.Position, workspace.Terrain)
		elseif blocked then
			path:Run(target)
		end
	end
end

Here is the documentation on the pathfinding module I’m using, SimplePath.

I’ve looked at the Developer Hub, especially the pathfinding section, changed from Roblox pathfinding to the pathfinding module above, used several tutorial AI from YouTube videos like those published by TheDevKing and Y3llow Mustang, and I’ve been told to do client-side pathfinding once but could only find one resource on it that I couldn’t understand how to manipulate for my situation. Any other potential solutions I would have to be told of before I could try them because of my relative inexperience with development. Please let me know if there’s anything else I can try.

tl;dr: my AI is supposed to catch me but it can’t because the position of the AI’s target is not where I am but where I was, and it needs to be able to catch me for the game I’m making. I’ve tried everything I can think of trying and nothing’s changed.

5 Likes

Since it’s ran on the server, and the player moves on the client, you can never get the real player’s position, but what you could do is
grab the player’s position, but also utilise which direction the player is moving in.

like move to player.Position + moveDirection, that way instead of stopping at the player’s OLD position, and then try update, it’ll move ahead of where it actually needs to go.

I don’t know if this is the best method but first thing that came to mind so

1 Like

Someone in a previous topic told me this, along with the idea that I should test by using a temporary script that sets the position of a part equal to the position of my character in a while loop. I just went and did that and came up with a script like this:

local part = script.Parent
local HRP = workspace:WaitForChild("Superdestructo09"):WaitForChild("HumanoidRootPart")

while true do
	wait()
	part.Position = HRP.Position + (HRP.Velocity.Unit * 4)
end

Here’s a video showing what that looks like:

The results seem to be pretty good when moving in one direction, but when the player rapidly changes direction, the system almost can’t keep up and the sphere veers drastically off course. I don’t know how that’ll translate to pathfinding but I don’t have confidence it’ll be good because theorizing right now, it’s probably true that if the player ran around in circles, the AI would dance around them in a circle like the green sphere in the video does and never actually hit them. I understand that working on the server, you can never actually get the true position of the player at any one moment unless they’re not moving, but is there a more accurate way of determining the player’s location where the AI isn’t vulnerable to exploits like this?

You could try using Heartbeat instead of a while loop.
That’s what I used for a similar small project of mine a while ago and I dont believe I encountered any of the same problems mentioned above.

It would look something like this in your case:

local debounce = 0
local updateInterval = .15 -- you can tweak the update interval to your liking but beware that lots of updates can create lag.
game:GetService("RunService").Heartbeat:Connect(function()
	if (time() > debounce) then
		
		local target, blocked = findTarget()
		if target then
			if not blocked then
				print("not blocked")
				hum:MoveTo(target.Position, workspace.Terrain)
			elseif blocked then
				path:Run(target)
			end
		end
		
		debounce = time() + updateInterval
	end
end)

The problem could also be that you’re not setting the NPC’s NetworkOwner before using it.
And if you’re not familiar this I recommend checking out this article: Network Ownership

I hope this helps :smiley:

1 Like

Is the debounce necessary for the script to work properly? I set the network owner to nil and implemented Heartbeat and the AI is maybe a little closer now but still nowhere near being able to catch my player.

Also I plan to replicate this to every moving AI in the game, and in the final build there could be maybe hundreds of AI running at a time on this script. Considering what you said about updates, the amount of lag I could possibly get from that worries me. Correct me if I’m wrong.

1 Like

Is there anything else that I can try? I know this is possible, I just don’t know why it’s not working in my case.

1 Like

Would client-side pathfinding even fix this? From what I’ve heard other people say about it, it seems like it would make it so every enemy is in a different spot on the map depending on the player, even if you repeatedly used FireAllClients(). How can I more accurately get the player’s position?

1 Like

As soon as I saw the title of this post I knew that the problem was most likely caused by latency between the client and server.

You should do the pathfinding on the server. If you delegate the pathfinding to the client then exploiters can easily make it so that zombies are pathfinding to other places or just standing in place.

That said, the solution remains the same as that mentioned by others. You need to offset the point in the direction the of the player from the zombie. I’d try using raycasting from the players root part to check if there is a wall or something though. However, this should really only be done when the zombie comes within a certain distance from the player. So maybe something like this:

local getPathfindPoint(zombie, character): Vector3
	local zombiePos = zombie:GetPivot().Position
	local characterPos = character:GetPivot().Position
	
	local direction = (characterPos - zombiePos)
	local distance = direction.Magnitude
	
	if distance < 5 then
		local result = workspace:Raycast(characterPos, direction)
		
		return if result then result.Position else (characterPos + direction)
	elseif distance < 10 then
		local humanoid = character.Humanoid
		direction = (humanoid.MoveDirection*humanoid.WalkSpeed)
		
		local result = workspace:Raycast(characterPos, direction)
		
		return if result then result.Position else (characterPos + direction)
	else
		return characterPos
	end
end

Please let me know if this works for you or if there are any bugs!

1 Like

I’m not sure I implemented it in the way that you were thinking, but here’s my code from what I interpreted your function as:

local RS = game:GetService("ReplicatedStorage")
local SS = game:GetService("ServerStorage")
local SP = require(SS:WaitForChild("SimplePath"))

local sprinter = script.Parent
local hum = sprinter:WaitForChild("Humanoid")
local HRP = sprinter:WaitForChild("HumanoidRootPart")

local path = SP.new(sprinter)
path.Visualize = true

local names = {}
for _, name in pairs(RS:WaitForChild("Enemies"):GetChildren()) do
	table.insert(names, name.Name)
end

function checkNames(query)
	for _, name in pairs(names) do
		if query == name then
			return true
		end
	end
	return false
end

sprinter.PrimaryPart:SetNetworkOwner(nil)

function findTarget()
	local aggro = 200
	local target
	local blocked = true
	for _, character in pairs(workspace:GetChildren()) do
		local human = character:FindFirstChild("Humanoid")
		local RP = character:FindFirstChild("HumanoidRootPart")
		if human and RP and human.Health > 0 and not checkNames(RP.Parent.Name) then
			if (RP.Position - HRP.Position).Magnitude < aggro then
				aggro = (RP.Position - HRP.Position).Magnitude
				target = RP
				local rayInfo = RaycastParams.new()
				rayInfo.FilterType = Enum.RaycastFilterType.Blacklist
				rayInfo.FilterDescendantsInstances = sprinter:GetChildren()
				local hit = workspace:Raycast(HRP.Position, (RP.Position - HRP.Position).Unit * 200, rayInfo)
				if hit.Instance == RP then
					blocked = false
				end
			end
		end
	end
	return target, blocked
end

function getPathfindingPosition(agent, target)
	local agentPos = agent:GetPivot().Position
	local targetPos = target:GetPivot().Position
	
	local direction = (agentPos - targetPos)
	local distance = direction.Magnitude
	
	if distance < 5 then
		local result = workspace:Raycast(targetPos, direction)
		return if result then result.Position else (targetPos + direction)
	elseif distance < 10 then
		local humanoid = target.Parent.Humanoid
		direction = (humanoid.MoveDirection*humanoid.WalkSpeed)
		
		local result = workspace:Raycast(targetPos, direction)
		return if result then result.Position else (targetPos + direction)
	else
		return targetPos
	end
end

game:GetService("RunService").Heartbeat:Connect(function()
	local target, blocked = findTarget()
	if target then
		if not blocked then
			print("not blocked")
			hum:MoveTo(getPathfindingPosition(sprinter, target), workspace.Terrain)
		elseif blocked then
			path:Run(getPathfindingPosition(sprinter, target))
		end
	end
end)

From the looks of it, it seems to have only made it slower, even though the main loop is still running on the Heartbeat function. The AI seems to sometimes turn straight to one side now or the other whenever it stops, which might be a consequence of the code you suggested.

1 Like

You can check when the zombie gets within the radius and raycast to check if it will walk through anything so you don’t have to make a new path

1 Like

From the video it looks like the dummy is walking for a bit and then ending at a spot behind the character before resuming again. This is most likely caused by me getting the direction wrong. My bad! I can never keep it straight, which vector to subtract from the other. Just try replacing this line:

with this maybe?

local direction = (zombiePos - characterPos)

Let me know if it works!

1 Like

I made the change you suggested and the AI seems to be making changes in its pathfinding a little faster and getting a little closer, but it still can’t catch up to me.

1 Like

Try multiplying the direction by two or something then.

EDIT: Also, I noticed that the npc is stopping before resuming. This shouldn’t be happening. You should be updating the path or whatever enough that the npc always has a goal it’s working towards.

1 Like

I multiplied the direction by two and this is the result. I can’t tell if anything is different, though I don’t have the sharpest eye when it comes to something like this, so maybe you can spot something I can’t.

As for the stopping and starting, I’m not sure what could be causing that. The script should be checking whether there is anything in the way of the bot and its target and then choosing a pathfinding solution accordingly for every cycle, and the loop runs via the Heartbeat function. I don’t understand how I could get the function to run any faster than that, or how it could be not finding pathways unless they were just straight up not possible, which shouldn’t be the case in all the videos I’ve posted.

1 Like

I decided to look through the documentation and examples for SimplePath.

I think it’d be simpler if you simply used the path events rather than a loop. This is based on personal preference that’s based on experience, as the more logic you put into something the more difficult it is to understand. SimplePath is meant to make pathfinding simpler.

--Computes path again if something blocks the path
Path.Blocked:Connect(function()
	Path:Run(Goal)
end)

--If the position of Goal changes at the next waypoint, compute path again
Path.WaypointReached:Connect(function()
	Path:Run(Goal)
end)

--Computes path again if an error occurs
Path.Error:Connect(function(errorType)
	Path:Run(Goal)
end)

Also, I think you might want to try replacing the Vector3 goal that you pass to Run and replace it with the RootPart of the target character instead. So something like this:

local RS = game:GetService("ReplicatedStorage")
local SS = game:GetService("ServerStorage")
local SP = require(SS:WaitForChild("SimplePath"))

local sprinter = script.Parent
local hum = sprinter:WaitForChild("Humanoid")
local HRP = sprinter:WaitForChild("HumanoidRootPart")
sprinter.PrimaryPart:SetNetworkOwner(nil) -- set sprinter network owner to server

local path = SP.new(sprinter)
path.Visualize = true


local names = {}
for _, name in pairs(RS:WaitForChild("Enemies"):GetChildren()) do
	table.insert(names, name.Name)
end

function checkNames(query)
	for _, name in pairs(names) do
		if query == name then
			return true
		end
	end
	return false
end

function findTarget()
	local aggro = 200
	local target
	local blocked = true
	for _, character in pairs(workspace:GetChildren()) do
		local human = character:FindFirstChild("Humanoid")
		local RP = character:FindFirstChild("HumanoidRootPart")
		if human and RP and human.Health > 0 and not checkNames(RP.Parent.Name) then
			if (RP.Position - HRP.Position).Magnitude < aggro then
				aggro = (RP.Position - HRP.Position).Magnitude
				target = RP
				local rayInfo = RaycastParams.new()
				rayInfo.FilterType = Enum.RaycastFilterType.Blacklist
				rayInfo.FilterDescendantsInstances = sprinter:GetChildren()
				local hit = workspace:Raycast(HRP.Position, (RP.Position - HRP.Position).Unit * 200, rayInfo)
				if hit.Instance == RP then
					blocked = false
				end
			end
		end
	end
	return target, blocked
end

local target: BasePart, heartbeat: RBXScriptConnection
heartbeat = game:GetService("RunService").Heartbeat:Connect(function()
	target = findTarget()
	if target then
		heartbeat:Disconnect()
	end
end)

-- Recompute Path:
local function recomputePath()
	path:Run(target)
end

-- Event Connections:
path.Error:Connect(recomputePath)
path.Blocked:Connect(recomputePath)
path.WaypointReached:Connect(recomputePath)
Path.Reached:Connect(recomputePath)

path:Run(target) -- compute path

This may not solve your issue, but it may make it simpler to script the pathfinding. If it doesn’t solve, then please tell me. I have one more idea for how to fix it.

2 Likes

I tested your code just now and the AI doesn’t seem to be moving at all. Did you mean to put the initial “path:Run(target)” in the conditional in the Heartbeat function? I checked and it is getting inside that condition when I get in range, but it’s not doing anything after that.

2 Likes

I’m basing it off one of the examples the authors provided, so no. I edited the code a teeny bit now, but not much. If it doesn’t work then it just means the module isn’t working as intended. That would be a bad thing because it would mean the module is unreliable.

2 Likes

If it would be easier for you to diagnose, we could switch back to the default pathfinding system and recreate the script using it. I just tried out your revised code and I got the same result. The only reason I used SimplePath was that it was less complex and required fewer lines of code to run properly. At this rate, I’ll take any solution that works.

I had tried to use the object-oriented pathfinding (using an instance as the target instead of a vector3) starting out but it hadn’t really changed anything.

2 Likes

Use a while loop and change all the waits to task.wait so it doesn’t yield. If you are still having trouble its probably because of your pathfinding method, not the loop.

2 Likes

I fixed this by setting my AI’s networkownership to the currently chasing player, No clue if it would work for you though.

3 Likes