How to create a horde of pathfinding zombies

I want to create a horde of pathfinding zombies. I tried using pathfinding service and tutorials like this one:

Roblox - Zombie Advanced AI Tutorial (Pathfinding, Custom Animations, Raycasting) - YouTube
Just want to clarify that my code is similar to the code in this video but modified (not identical).

Here a video similar to what I am trying to accomplish:
Roblox Zombie AI stuff (Randomized pathing, avoidance, sight checking) - YouTube

So basically how it works is it creates a path, checks if the path is completely clear of obstacles (straight line) and if yes then pathfinding will not be used and the humanoid will just be moved to the target position. If no, pathfinding will be activated and the NPC will follow the waypoints. It works perfectly fine with one or two zombies.

However, it doesn’t seem to work smoothly with a large number of zombies.

There are problems with my script that I am confused on how to fix.

  1. Zombies keep thinking each other as obstacles, and so when the zombie can walk to the player in a straight line, it doesn’t and activates pathfinding, trying to find a way towards me as if the other zombies around it are not moving. I’m using raycast to detect if the zombie can run from a straight line to the player.
    Attempted Solution #1: I tried adding a PathfindingModifier every part of the zombie and setting the PassThrough property to true, however it doesn’t do anything and I was told that the pathfinding service will think of itself as an obstacle (or something like that).
    Attempted Solution #2: I tried making it so that the raycasts will exclude the every zombies’ descendant parts, so the raycast won’t detect them. It worked, but it I could either make them not collide, which would make them all group into one giant blob and it looks horrible, or make them collide and it gets extremely laggy. I need to get them to spread out without causing problems.

  2. Lag. Creating too many zombies will result in massive amounts of lag. Like I said before, it seems to get really laggy when they all start clumping together, so the best option seems to be to get them separated. I don’t know how I could do that.
    Proposed Solution: I could instead of spawning all the zombies at once in the game, make them come in groups over time. I am trying to make a wave-based zombie survival game, and I think that if I allow each wave to slowly allow all the zombies in, that would be optimal, compared to spawning 500 zombies all at once. That would be a nightmare for my CPU. One problem is that if all players die and there is one player left, all the zombies will try to attack the one player and the lag will be unbearable. Is this a good idea?

There are even more problems of the zombies getting stuck when clumping together (as you can see in the video).

Here is a video of the NPCs in action:

As you can see they turn around and try to go around each other, and many just end up stuck.

I thought of creating a hive mind where they all coordinate and attack their target, keeping their distance from each other while moving towards the player, but it is too complicated and I lack the expertise to design it and write the code. I don’t even think I can use PathfindingService to do that.

Are there any options? I am running out.

3 Likes

Here is all my code, stored as a child of each individual zombie:

local PlayerService = game:GetService("Players")
local PS = game:GetService("PathfindingService")

-- gets the main instances of the dummy
local character = script.Parent
local humanoid = character.Humanoid
local rootPart = character.HumanoidRootPart

rootPart:SetNetworkOwner(nil) -- someone on devforum told me to do this

local target = nil
local counter = 0

local function findTarget()
	local players = game.Players:GetPlayers()
	local nearestTarget
	local maxDistance = math.huge
	
	-- finds the player with the least distance away from rootPart.
	for _, player in pairs(players) do
		if player.Character then
			local target = player.Character
			local distance = (target.HumanoidRootPart.Position - rootPart.Position).Magnitude

			if distance < maxDistance then
				nearestTarget = target
				maxDistance = distance
			end
		end
	end
	return nearestTarget -- nearest target is returned, if no target the nil
end

local rcParams = RaycastParams.new()
rcParams.FilterDescendantsInstances = {character} -- raycast won't hit itself

local path:Path = PS:CreatePath({ -- creating new path
	AgentRadius = 3,
	AgentHeight = 6,
	AgentCanJump = false,
	AgentCanClimb = false
})

local function checkSight()
	
	-- raycast from left side and right side to make sure NPC will not be stuck on the a wall. This is for when the NPC is moving directly towards the target.
	
	local ray = workspace:Raycast(rootPart.Position,(target.HumanoidRootPart.Position - rootPart.Position).Unit * 1000,
		rcParams)
	
	local rayLeft = workspace:Raycast(character["Left Arm"].Attachment.WorldPosition,
		(target.HumanoidRootPart.Position - character["Left Arm"].Attachment.WorldPosition).Unit * 1000,rcParams)
	
	local rayRight = workspace:Raycast(character["Right Arm"].Attachment.WorldPosition,
		(target.HumanoidRootPart.Position - character["Right Arm"].Attachment.WorldPosition).Unit * 1000,rcParams)
	
	if ray and rayLeft and rayRight then
		local hitPart:BasePart = ray.Instance
		local hitPartLeft:BasePart = rayLeft.Instance
		local hitPartRight:BasePart = rayRight.Instance
		
		if hitPart:IsDescendantOf(target) and hitPartLeft:IsDescendantOf(target) and hitPartRight:IsDescendantOf(target) then
			--print("has line of sight")
			
			return true
		end
	end
	return false
end

-- attack the target
local function attack()
	if (rootPart.Position - target.HumanoidRootPart.Position).Magnitude < 5 then
		if target then
			target.Humanoid.Health -= 1
		end
		wait(0.4)
	end
end

-- creates new waypoints to the player. If it is a straight line (checkSight() function is true) then no waypoints are used, humanoid:MoveTo() is used.
local function findPath()
	path:ComputeAsync(rootPart.Position,target.HumanoidRootPart.Position)
	local waypoints:{PathWaypoint} = path:GetWaypoints()
	
	if path.Status == Enum.PathStatus.Success then
		
		for _, waypoint in pairs(waypoints) do
			humanoid:MoveTo(waypoint.Position)
			local timeOut = humanoid.MoveToFinished:Wait()
			
			if not timeOut then
				humanoid.Jump = true
				--print("finding new path")
				findPath()
				break
			end
			if checkSight() then
				repeat
					--print("moving directly to target")
					humanoid:MoveTo(target.HumanoidRootPart.Position) -- moving directly towards target
					attack()
					wait(0.1) -- update every 0.1 seconds
					if target == nil then
						break
					end
				until not checkSight() or humanoid.Health < 1 or target.Humanoid.Health < 1
				break
			end
			if (rootPart.Position - waypoints[1].Position).Magnitude > 20 then
				--print("target has moved, generating new path")
				findPath()
				break
			end
		end
	elseif path.Status == Enum.PathStatus.NoPath then
		--print("no path")
	end
end

-- refreshes target every 5 seconds, finds a new path every 0.1 seconds.
local function main()
	counter += 0.1
	if counter >= 5 then
		target = findTarget()
	end
	
	if target then
		humanoid.WalkSpeed = 16
		findPath()
	else
		--print("no target")
	end
end

-- everything is run here
while wait(0.1) do
	if humanoid.Health < 1 then
		break
	end
	main()
end
2 Likes

my theory is that the zombies are blocking each other’s raycast and making new and unnecessary paths, i would try to exclude / blacklist the other zombies and make them non collidable with each other and see if that helps, unless you want them to collide with each other which would make everything more complicated and more prone to clumping / getting stuck

1 Like

Here are some suggestions on what can fix your issue:

  • Zombies detecting each other as obstacles: To prevent zombies from detecting each other as obstacles, you can use a combination of collision groups and raycasting. Assign each zombie a unique collision group and configure the raycast to ignore collisions with the zombies in the same group. This way, zombies won’t see each other as obstacles and can move freely.
  • PathfindingModifiers and PassThrough property: While setting the PassThrough property to true for PathfindingModifiers can help zombies move through each other, it won’t prevent them from considering other zombies as obstacles during pathfinding. You can still use PathfindingModifiers to make specific parts of zombies (like hands or weapons) passable through each other, but it won’t solve the overall pathfinding issue.

On the coding part, here are some steps:

  1. Adding collision groups to zombies

This will make zombies walk though each other, so it might be bad, but you have to test this yourself.


local function assignCollisionGroup(zombie)
    local collisionGroupName = "ZombieGroup" .. tostring(zombie:GetInstanceID()) -- Unique collision group name for each zombie
    local collisionGroup = Instance.new("CollisionGroup")
    collisionGroup.Name = collisionGroupName
    collisionGroup.Parent = workspace.CollisionGroups
    zombie:SetAttribute("CollisionGroupId", collisionGroup:GetInstanceID()) -- Store the collision group ID in a zombie attribute
end

And call this function for every zombie’s Model.

  1. Make the raycasts ignore the zombies:
local function checkSight()
    local raycastParams = RaycastParams.new()
    raycastParams.FilterType = Enum.RaycastFilterType.Blacklist
    raycastParams.FilterDescendantsInstances = {}

    -- Get the collision group ID of the current zombie
    local collisionGroupId = humanoid:GetAttribute("CollisionGroupId")
    if collisionGroupId then
        local collisionGroupName = "ZombieGroup" .. tostring(collisionGroupId)
        table.insert(raycastParams.FilterDescendantsInstances, workspace.CollisionGroups[collisionGroupName])
    end

    -- Perform the raycast
    local ray = workspace:Raycast(rootPart.Position, (target.HumanoidRootPart.Position - rootPart.Position).Unit * 1000, raycastParams)
    
    -- Rest of the code...
end

1 Like

A relevant topic that might help.

1 Like

I think those ideas are good, and that got me thinking of another way to fix my issue. My goal is to make the zombies spread out and think as a group, not individually, so I created a bunch of parts on the path of the zombie:

So basically what did:

  1. Assign each zombie’s character’s ID attribute by number from 1 - N number of zombies.
  2. In server storage I created a PathBoundary which is a part, and it has a PathfindingModifier with an empty label.
  3. When waypoints are created, the zombie will create parts that are on each waypoint.

When the path is successful when computing:

for i = 1, #waypoints - 1 do

	local PFModifierPart = SS.PathBoundary:Clone()

	local distance = 0
	
	PFModifierPart.ID.Value = character:GetAttribute("ID")	
	PFModifierPart.Pass.Label = "CanPass" .. tostring(character:GetAttribute("ID"))
	distance = (waypoints[i].Position - waypoints[i + 1].Position).Magnitude
	
	PFModifierPart.Size = Vector3.new(distance ,6,3)
	PFModifierPart.CFrame = CFrame.new(waypoints[i].Position, waypoints[i+1].Position)

	PFModifierPart.Parent = workspace.Enemies.NPCAvoid

	table.insert(PFModifierParts,PFModifierPart)
end
  1. When creating the path object, I assigned the costs of the path based on the current character’s ID and the other character’s ID.
local costsArray = {}

for _, enemy in pairs(workspace.Enemies:GetChildren()) do
	
	if enemy:GetAttribute("ID") then
		if enemy:GetAttribute("ID") == character:GetAttribute("ID") then
			costsArray["CanPass" .. enemy:GetAttribute("ID")] = 0
		else
			costsArray["CanPass" .. enemy:GetAttribute("ID")] = math.huge
		end
	end
end

print(costsArray)

local path = PS:CreatePath({
	AgentRadius = 3,
	AgentHeight = 6,
	AgentCanJump = false,
	AgentCanClimb = false,
	Costs = costsArray
})

image

and used PathfiningModifier’s labels and PathfindingService:CreatePath() cost to make it so that only that zombie can create a path that goes through those parts.

I was making something like this:

It works fine with two or three NPCs but with more, the only problem is that they keep creating paths simultaneously, which messes their pathfinding up and they start going all over the place, because the same number of NPCs will create the same path at the same time.

Or is there another problem that I’m not aware of? Right now I’m thinking of creating a central horde controller module in the ServerScriptService that schedule the pathfinding based on what target they have. So if a group of zombies are targeting one player, the controller module will allow one zombie to call the path:ComputeAsync() function and generate the parts marking that this path is taken, and then once that is done allow the second zombie to do the same so the second zombie knows what path to take, and so on.

For larger crowds, I could make zombies behind other zombies just follow the leading zombie that is using the pathfinding algorithm.

Do you think this would work? I’m interested on what others think, this idea just came straight out of my head.