Zombie Pathfinding works like a slideshow

I’m trying to get my zombie OOP module to have smooth pathfinding without lagging too much.

The problem is that when the zombies are actively tracking for players, these zombies stop in between every second when following the moving player. Even with this system, it is still lagging badly with about 20 to 30 zombies.

I tried modifying the pathfinding functions countless times but I just can’t really figure out a way to make the movement not look like a slideshow…

Where I'm creating zombie objects (Server Script)
for i, v in pairs(game.Workspace.CurrentZombies:GetChildren()) do
	
	task.wait(0.5)
			
	local zombie = zombieClass.new(v, 100, 0.7, 15, 3, 500, 10, CFrame.new((math.random(200)-250), 2, (math.random(300)-150)))
	CollisionModule.charCollisionGroup(zombie.char, "Zombies")
		
	zombie:MoveToNearestEntrance(entrances)
	
	zombie:TrackingPathfind()
	
	
end
Zombie Module (There's a section for pathfinding, it's commented)
---------------------------------------------------------------------
--[[
This Server Module handles Zombies

Author: Void_Trader
Last Edit: 7/15/2024
Version: 1.0
]]
---------------------------------------------------------------------

--Services
local CollectionService = game:GetService("CollectionService")
local PathfindingService = game:GetService("PathfindingService")
local ServerStorage = game:GetService("ServerStorage")
local Players = game:GetService("Players")

--Modules
local Modules = ServerStorage:WaitForChild("Modules")

local GeneralModules = Modules:WaitForChild("General")
local EntranceClass = require(GeneralModules:WaitForChild("Entrance"))


--Main module
local zombie = {}
zombie.__index = zombie


--Setting up zombie
function zombie.new(char: Model, health: number, breakSpeed: number, damage: number, attackRange: number, detectRange: number, smoothness: number,position: CFrame)
	
	--Setting up Metatable
	local self = setmetatable({}, zombie)
	
	--Setting up values and variables
	self.char = char
	self.root = char:FindFirstChild("HumanoidRootPart")
	self.humanoid = char:FindFirstChild("Humanoid") :: Humanoid
	self.head = char:FindFirstChild("Head")
	self.torso = char:FindFirstChild("Torso")
	
	self.target = nil
	
	--Animations
	self.animator = self.humanoid:FindFirstChild("Animator")
	self.grabAnim = self.animator:LoadAnimation(self.char:FindFirstChild("Grab"))
		
	--Properties
	self.health = health
	self.humanoid.MaxHealth = health
	self.humanoid.Health = health
	
	self.breakSpeed = breakSpeed
	
	self.damage = damage
	self.attackRange = attackRange
	self.detectRange = detectRange
	self.smoothness = smoothness
	
	--Thread variables
	self.threads = {}
	
	
	self.__type = "Zombie"
	
	--Run code
	self.char.PrimaryPart:SetNetworkOwner(nil)
	self.humanoid:AddTag("Zombie")
	self:position(position)
	
	-------AI-------
	--Entrance is called in main script
	--Tracking
	
	
	return self
	
end


------------------------------------------------------------
--METAMETHODS


------------------------------------------------------------
--MOVEMENT FUNCTIONS


--Move zombie to position instantly
function zombie:position(position: CFrame)
	
	if self.__type ~= "Zombie" then return end

	
	self.root:PivotTo(position)
	
	
	return self
	
end


--------------------------------------------
--AI FUNCTIONS--


--Entrance Functions--

--Break plank
function zombie:BreakPlanks(Entrance)
	
	repeat task.wait(self.breakSpeed) Entrance:Break()
		until (Entrance.currentPlanks == 0) or (self.humanoid.Health <= 0)
	
	
	return self
	
end

--Find nearest entrance
function zombie:MoveToNearestEntrance(Entrances)
	
	if self.__type ~= "Zombie" then return end

	--Variables
	local target = nil
	local dist = self.detectRange
	local entrance = nil
	
	--Finding entrance
	for _, entranceObject in pairs(Entrances) do
		local breakZone = entranceObject.breakZone
		if (breakZone) and (self.checkDist(breakZone, self.root) < dist) then
			dist = self.checkDist(breakZone, self.root)
			target = breakZone
			entrance = entranceObject
		end
	end
	
	self.target = target
	
	
	--Moving to entrance--
	
	--Creating path
	if self.target then
	
		self:threadSpawn(function()	
		
			local path = PathfindingService:CreatePath()
			path:ComputeAsync(self.root.Position, self.target.Position)

			local waypoints = path:GetWaypoints()
			local currentTarget = self.target

			--Setting up pathfinding
			if path.Status == Enum.PathStatus.Success then
				for i, waypoint in pairs(waypoints) do
					--[[
					-----------------
					local part = Instance.new("Part")
					part.CanCollide = false
					part.Anchored = true
					part.Material = "Neon"
					part.BrickColor = BrickColor.new("White")
					part.Position = waypoint.Position + Vector3.new(0, 2, 0)
					part.Shape = "Ball"
					part.Size = Vector3.new(1,1,1)
					part.Parent = game.Workspace
					
					]]-----------------

					--Creating waypoints and moving zombie there
					if waypoint.Action == Enum.PathWaypointAction.Jump then
						self.humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
					else
						self.humanoid:MoveTo(waypoint.Position)

						self:threadSpawn(function()
							task.wait(0.5)
							if self.humanoid.WalkToPoint.Y > self.root.Position.Y then
								self.humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
							end
						end)
					end

					--Makes sure zombie walking is correct
					self.humanoid.MoveToFinished:Wait()

					--Making sure tracking works
					if not self.target then
						print("Lost target")
						break
					elseif (self.checkDist(currentTarget, waypoints[#waypoints]) > self.smoothness) or currentTarget ~= self.target then
						print("?")
						self:MoveToNearestEntrance()
						break
					end

				end

				--Breaking Planks--
				self:BreakPlanks(entrance)

			else
				print("Path unable to be computed")
				self:removeZombie()
			end
			
		end)
		
	end
	
	
	return self
	
end


--------------------------------------------
--TRACKING FUNCTIONS--

--Tracking Pathfind
function zombie:TrackingPathfind()

	if self.__type ~= "Zombie" then return end


	self:threadSpawn(function()

		task.wait(15)

		while task.wait(0.1) do
			
			--Find Target
			self:findTarget()
			
			--Create Path
			if self.target then
				
				local path = PathfindingService:CreatePath()
				path:ComputeAsync(self.root.Position, self.target.Position)
				
				local waypoints = path:GetWaypoints()
				
				for _, waypoint in pairs(waypoints) do
					if self.target and self.target.Parent:FindFirstChildWhichIsA("Humanoid").Health > 0 then
						
						print(self.target.Parent.Name)
						
						--Move towards next waypoint
						if waypoint.Action == Enum.PathWaypointAction.Jump then
							self.humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
						else
							self.humanoid:MoveTo(waypoint.Position)

							self:threadSpawn(function()
								task.wait(0.5)
								if self.humanoid.WalkToPoint.Y > self.root.Position.Y then
									self.humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
								end
							end)
						end

						--Makes sure zombie walking is correct
						self.humanoid.MoveToFinished:Wait()
						
						--Attack
						self:attack()

						--Making sure tracking works
						if not self.target then
							print("Lost target")
							break
						--elseif (self.checkDist(self.target, waypoints[#waypoints]) > 20) then
						--	print("?")
						--	self:TrackingPathfind()
						--	break
						end
						
					else
						print("!!!!!!!!!!!!!!!!!!")
					end
				end
				
			end
		end
	end)


	return self

end


--Find Target
function zombie:findTarget()
	
	if self.__type ~= "Zombie" then return end
	
	--Variables
	local players = Players:GetPlayers()
	local maxDistance = self.detectRange
	self.target = nil
	
	--Check for closest player
	for i, plr in pairs(players) do
		if plr.Character then
			local target = plr.Character
			--local distance = (self.root.Position - target.HumanoidRootPart.Position).Magnitude
			local distance = self.checkDist(self.root, target:FindFirstChild("HumanoidRootPart"))
			
			if distance < maxDistance then
				self.target = target:FindFirstChild("HumanoidRootPart")
				maxDistance = distance
			end
		end
	end
	
	
	return self
	
end


--------------------------------------------
--ATTACK FUNCTIONS--

--Attack
function zombie:attack()
	
	if self.__type ~= "Zombie" then return end
	if self.target == nil then return end
	
	--Get distance
	local distance = self.checkDist(self.root, self.target)
	
	--if distance > self.attackRange then
		--Move zombie to player
	--	self.humanoid:MoveTo(self.target.Position)
	--else
	if distance < self.attackRange then
		--Attack
		self.target.Parent:FindFirstChildWhichIsA("Humanoid").Health -= self.damage
	end
	
	
	return self
	
end

--------------------------------------------
--UTILITY FUNCTIONS--


--Remove zombie
function zombie:removeZombie()

	if self.__type ~= "Zombie" then return end


	task.wait(5)
	
	--Close all threads
	for _, thread in pairs(self.threads) do
		coroutine.close(thread)
		thread = nil
	end
	
	self.char:Destroy()
	
	self = nil
	

	return self

end


--Check distance(NORMAL FUNCTION)
function zombie.checkDist(part1: Part | Vector3, part2: Part | Vector3): number
	
	local part_1
	local part_2
		
	if typeof(part1) ~= Vector3 then part_1 = part1.Position end
	if typeof(part2) ~= Vector3 then part_2 = part2.Position end

	return (part_1 - part_2).Magnitude
end


--Spawn thread
function zombie:threadSpawn(functionObject, ...)
	
	if self.__type ~= "Zombie" then return end
	
	
	local co = coroutine.wrap(functionObject)
	
	table.insert(self.threads, co)
	
	co(...)
	
end


------------------------------------------------------------


return zombie

I’m guessing someone else knows an easy method to this, but I’m far from an expert with pathfinding.

Thanks :happy2:

I’m not 100% sure, but does moving

to the bottom of the else statement fix the issue.

It doesn’t unfortunately,

Here’s what the issue looks like:

1 Like

It looks like the issue is cause because your pathfinding code is looping till it finishes, at which point it then finds the new location and moves towards it

1 Like

I forgot what the exact cause of this issue was, but it’s something do with pathfinding and it taking a while for the path to compute. Basically, the fix is to make the zombie just walk straight at their target without any pathfinding if they don’t need to pathfind.

Though there are more complex solutions to figuring out if walking in a straight line will allow an NPC to reach their target, just checking that the target isn’t too high and the NPC has line of sight works well enough for most games.

EDIT: Now that I think about it, I think the reason it’s stuttering has to do with replication and server-sided pathfinding.

1 Like

Does anyone have a solution with direct code? I probably now understand why it’s laggy looking, but I’m not really sure how to implement this in my code. Thanks :happy2:

  1. Avoid the full pathfinding computation if the target is within a certain distance and the path is clear. This can be done by checking the line of sight between the zombie and the target before attempting the full pathfinding.
function zombie:TrackingPathfind()
    if self.__type ~= "Zombie" then return end

    self:threadSpawn(function()
        while true do
            -- Find Target
            self:findTarget()

            -- Check if target is within a certain distance and there is line of sight
            if target and (target.Position - self.HumanoidRootPart.Position).Magnitude < 20 and not workspace:FindPartOnRayWithIgnoreList(Ray.new(self.HumanoidRootPart.Position, (target.Position - self.HumanoidRootPart.Position).Unit * 20), {self.HumanoidRootPart}) then
                -- Move directly towards the target
                self.Humanoid:MoveTo(target.Position)
                self.Humanoid:ChangeState(Enum.HumanoidStateType.Running)
            else
                -- Create and compute the path
                local path = PathfindingService:CreatePath()
                path:ComputeAsync(self.HumanoidRootPart.Position, target.Position)

                local waypoints = path:GetWaypoints()
                for _, waypoint in pairs(waypoints) do
                    if self.Humanoid and target and target:FindFirstChildWhichIsA("Humanoid").Health > 0 then
                        if waypoint.Action == Enum.PathWaypointAction.Walk then
                            self.Humanoid:MoveTo(waypoint.Position)
                            self.Humanoid:ChangeState(Enum.HumanoidStateType.Running)
                        elseif waypoint.Action == Enum.PathWaypointAction.Jump then
                            self.Humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
                            self.Humanoid:MoveTo(waypoint.Position)
                        end

                        self:Wait()

                        -- Making sure tracking works
                        if not target then
                            print("Lost target")
                            break
                        end
                    else
                        print("!!!!!!!!!!!!!!!!!!")
                        break
                    end
                end
            end

            self:Wait(0.1)
        end
    end)

    return self
end
1 Like

The self:Wait() function doesn’t exist, at least that’s what the output says

Try doing self.root:SetNetworkOwner(nil) on every frame. From the video you sent, it looks like the network owner is being flip flopped between your client and the server.

1 Like

I did that in the creation of the zombie object, but I didn’t know you had to every frame, do you know where to place it (In the code)?

You’re probably fine doing it in the while loop you have under zombie:TrackingPathfind(). If that doesn’t work, then try creating a RunService.Heartbeat connection to call SetNetworkOwner instead.

1 Like

Alright I did this, thanks for helping out, I’m just wondering what the self:Wait() is for, because it doesn’t exist.

Does it work? The self:Wait() seems like he meant to type self.Humanoid.MoveToFinished:Wait().

1 Like

Do you know what the self:Wait(0.1) is?

Honestly, just revert to your original code (and add the SetNetworkOwner fix of course). The code block he sent is kinda confusing to read.

1 Like

After trying both the code from the other programmer and my code, both with the self.root:SetNetworkOwner(nil), it still stops every second exactly how it was in the video I posted above.

I’m thinking there’s something else at play

Ok it’s definitely pathfinding being slow. What you can do is make it immediately call Humanoid:MoveTo() on the player’s position, if they are visible.

You can check if the current target is visible by doing a raycast from the zombie’s position to the player’s position. You can use the function below with the zombie’s position and the player’s position to determine if they are visible.

local function isVisible(position0: Vector3, position1: Vector3): boolean
	local params: RaycastParams = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Include
	params.FilterDescendantsInstances = {} -- Keep a reference to the place where your map is, ideally, a folder
	
	local result: RaycastResult? = workspace:Raycast(position0, position1 - position0, params)
	return (not result)
end

Showcase20

For some reason it won’t pathfind again, although it looks half of what the lag looked like which is good, but it still looks laggy (There was never actual lag and there isn’t now either btw)

Here’s what I added:

if isVisible(self.root.Position, self.target.Position) == true then
					
	self.humanoid:MoveTo(self.target.Position)
					
else
    --Pathfind

end

Does the lag only happen when the zombie is really close to you? That could be the zombie colliding with your server position (which is a little behind your client position because of interpolation and ping).

Not exactly sure what could be causing the pathfinding to fail, you probably want to print what the pathfinding status is.

I think what’s really happening is it’s creating a new path every time I move, but it basically has to move to that new waypoint, until that path is replaced by a new path, making the zombie follow the waypoint of that new path, and that cycle is what’s causing it to look laggy, even when the zombie is far away.

I’m sure there’s a way to fix this, although I’m not sure how