Enemy Horde AI Is Clumping Together

I’m trying to make an enemy horde AI, but the zombies are all clumping up together. This is the script with the behaviour. The “GetNearyByEnemies” is useless as of now.

What I’ve tried is getting nearby enemies, and using their positions to add a force onto each zombie to push them away from eachother. All this does it fling a few of them into the void, and the rest are unphased.

I’ve also tried raycasting infront of each enemy to see if there is another enemy infront of them, and then lower their walkspeed to create distance. All this did was create a chain of zombies will exactly the same distance between eachother, which looks really weird.

I also tried a creating pathfinder modifier inside the enemy model, so that the paths don’t go through other zombies, but this didn’t work either.

The only thing that I believe would help is something like this post, but the author and reply didn’t give much information to work off of. Any help is appreciated.

image

--// Variables \\--
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local PathfindingService = game:GetService("PathfindingService")
local ScriptService = game:GetService("ServerScriptService")
local Collection = game:GetService("CollectionService")

local SimplePath = require(ScriptService.Modules.SimplePath)

local Remotes = ReplicatedStorage.Remotes

local Config = {
	SearchLoop = 5,
	PathfindingLoop = 0.25	
}

--// Module \\--
local module = {}

module.	AgentParams = {
	AgentHeight = 6,
	AgentRadius = 3,
	AgentCanJump = true,

	Costs = {
		Enemy = 5
	}
}

function module.Init(Enemy)
	local EnemyCharacter = Enemy.Model
	local EnemyRoot = EnemyCharacter.PrimaryPart

	local TargetCharacter
	local PathfindCoro

	-- finding target loop
	task.spawn(function()
		while task.wait(Config.SearchLoop) do
			local NewTargetCharacter = SimplePath.GetNearestCharacter(EnemyRoot.Position)	

			-- change the pathfinding target
			if NewTargetCharacter ~= TargetCharacter then
				TargetCharacter = NewTargetCharacter

				EnemyCharacter.Humanoid:MoveTo(EnemyRoot.Position) -- stops the past movement

				if PathfindCoro then coroutine.close(PathfindCoro) end

				if TargetCharacter then
					PathfindCoro = coroutine.create(module.Pathfinding)
					coroutine.resume(PathfindCoro, TargetCharacter, EnemyCharacter)
				else
					-- no target, stopped moving, standing still rn
				end
			end
		end
	end)
end

function module.FindDistance(point1, point2)
	if type(point1) ~= Vector3 then point1 = point1.Position end
	if type(point2) ~= Vector3 then point2 = point2.Position end
	return (point1 - point2).Magnitude
end

function module.GetNearbyEnemies(Enemy, Range)
	local NearbyEnemies = {}
	for _, OtherEnemy in pairs(Collection:GetTagged("Enemy")) do
		if OtherEnemy ~= Enemy and module.FindDistance(Enemy.PrimaryPart, OtherEnemy.PrimaryPart) < Range then
			table.insert(NearbyEnemies, OtherEnemy)
		end
	end
	return NearbyEnemies
end

function module.Pathfinding(Target, Enemy)
	local Goal = Target.PrimaryPart
	local Path = SimplePath.new(Enemy, module.AgentParams)

	while task.wait(Config.PathfindingLoop) do
		if not Goal then return end

		-- moving
		Path:Run(Goal.Position +  Goal.AssemblyLinearVelocity * 0.15)
	end 
end

return module

I saw this yesterday and have been thinking on it.

You said before that you had created a collision group and that the zombies cannot collide with each other.

That is why they are all standing/walking in the same spot.

I would allow them to collide.

Also, I would add an invisible box to each zombie torso. Make it as large as you want to keep the zombies spaced apart.

You can put the box in a collision group that collides only with other boxes. That way the zombies can reach the target, but not run into each other.

Yeah I did that before, but I just did it again in case, and I got the same result.


When I make the coliders collide with each other instead of ust being for the pathfinding modifiers, the enemies are like, smushed together. See how most of them aren’t facing me, they are diagonal because the colliders are squishing them.

Are you using a ball or a block?

It may make a difference. Also, set the collider friction to zero.

Maybe also make the collider larger,

This is what the collider looks like right now
image

Before I had use a big cylinder, but the effect is the exact same but with a little more space between the enemies. I am trying to understand how to implement flocking, but there is close to no information about the algorithms when implemented into studio. I had asked ChatGPT what to do, and thats the result I was talking about in the original post.

Maybe something more like this would work

1 Like

Same thing. I’m very convinced that the flocking algorithm is the way to go, but again, not much to go off of.

I’ve tried out the boids thing like I said, and this is the result:

There is a lot of jittering and glitching throughout the enemies, and the performance isn’t as good as I thought. All the math is putting a lot of stress on the server, and so is replicating the enemies CFrames to the clients. Here is what I have for the AI Behaviour.

--// Variables \\--
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local PathfindingService = game:GetService("PathfindingService")
local ScriptService = game:GetService("ServerScriptService")
local Collection = game:GetService("CollectionService")

local SimplePath = require(ScriptService.Modules.SimplePath)

local Remotes = ReplicatedStorage.Remotes

local Config = {
	SearchLoop = 5,
	PathfindingLoop = 0.25,

	SeperationDistance = 10,
	MaxForceSeperation = 20,
}

--// Module \\--
local module = {}

module.	AgentParams = {
	AgentHeight = 6,
	AgentRadius = 3,
	AgentCanJump = false,

	Costs = {
		Enemy = math.huge
	}
}

function module.Init(Enemy)
	local EnemyCharacter = Enemy.Model
	local EnemyRoot = EnemyCharacter.PrimaryPart

	local TargetCharacter
	local PathfindCoro

	-- finding target loop
	task.spawn(function()
		while true do
			local NewTargetCharacter = SimplePath.GetNearestCharacter(EnemyRoot.Position)	

			-- change the pathfinding target
			if NewTargetCharacter ~= TargetCharacter then
				TargetCharacter = NewTargetCharacter

				EnemyCharacter.Humanoid:MoveTo(EnemyRoot.Position) -- stops the past movement

				if PathfindCoro then coroutine.close(PathfindCoro) end

				if TargetCharacter then
					PathfindCoro = coroutine.create(module.Pathfinding)
					coroutine.resume(PathfindCoro, TargetCharacter, EnemyCharacter)
				else
					-- no target, stopped moving, standing still rn
				end
			end

			task.wait(Config.SearchLoop)
		end
	end)
	
	-- start flocking
	--task.spawn(module.Flocking, EnemyCharacter)
end

function module.FindDistance(point1, point2)
	if type(point1) ~= "vector" then point1 = point1.Position end
	if type(point2) ~= "vector" then point2 = point2.Position end
	return (point1 - point2).Magnitude
end

function module.GetNearbyEnemies(Enemy, Range)
	local NearbyEnemies = {}
	local NearbyEnemiesPositions = {}
	
	for _, OtherEnemy in pairs(Collection:GetTagged("Enemy")) do
		if OtherEnemy.Parent ~= workspace.Camera or not OtherEnemy.PrimaryPart then continue end
		
		local OtherEnemyPosition = OtherEnemy.PrimaryPart.Position
		if OtherEnemy ~= Enemy and (Enemy.PrimaryPart.Position - OtherEnemyPosition).Magnitude < Range then
			table.insert(NearbyEnemies, OtherEnemy)
			table.insert(NearbyEnemiesPositions, OtherEnemyPosition)
		end
	end
	return NearbyEnemies, NearbyEnemiesPositions
end

function module.Seperation(Enemy, Neighbours)
	local SeperationVector = Vector3.new()
	local Count = 0
	
	local EnemyPos = Enemy.PrimaryPart.Position
	
	for _, OtherEnemyPos in pairs(Neighbours) do
		local FromOther = EnemyPos - OtherEnemyPos 
		
		FromOther = FromOther.unit * 1000 / math.pow((FromOther.magnitude + 1), 1.5)
		SeperationVector = SeperationVector + FromOther
	end
	
	return SeperationVector * Config.MaxForceSeperation
end

function module.Alignment(Enemy, Neighbours)
	local AlignmentVector = Vector3.new()
	local Count = 0

	for _, OtherEnemy in pairs(Neighbours) do
		AlignmentVector += OtherEnemy.PrimaryPart.Velocity
		Count += 1
	end

	if Count > 0 then
		AlignmentVector /= Count
	end
	
	return AlignmentVector
end

function module.Cohesion(Enemy, Neighbours)
	local CohesionVector = Vector3.new()
	local Count = 0

	local EnemyPos = Enemy.PrimaryPart.Position

	for _, OtherEnemyPos in pairs(Neighbours) do
		local FromOther = EnemyPos - OtherEnemyPos

		if FromOther.Magnitude > 0 then
			CohesionVector += OtherEnemyPos
			Count += 1
		end
	end

	if Count > 0 then
		CohesionVector /= Count
		CohesionVector -= EnemyPos
	end

	return CohesionVector
end

function module.Flocking(Enemy, Goal)
	local EnemyForce = Enemy.PrimaryPart.RepulsionForce
	
	while task.wait() do
		if not Enemy then return end
		if not Enemy.PrimaryPart then continue end
		
		-- force only when standing
		if Enemy.Humanoid.FloorMaterial == Enum.Material.Air then
			if EnemyForce.Force ~= Vector3.new() then
				EnemyForce.Force = Vector3.new()
			end
			continue
		end
		
		-- add forces
		local ForcesRange = Config.SeperationDistance

		local NearByEnemies, NearByEnemyPositions = module.GetNearbyEnemies(Enemy, ForcesRange)
		if Goal then
			table.insert(NearByEnemies, Goal.Parent)
			table.insert(NearByEnemyPositions, Goal.Position)
		end
	
		local SeperationVector = module.Seperation(Enemy, NearByEnemyPositions) * 2.5
		local CohesionVector = module.Cohesion(Enemy, NearByEnemyPositions) * 3.5
		local AlignmentVector = module.Alignment(Enemy, NearByEnemies) * 10
		
		local FinalForce = Vector3.new()
		FinalForce += SeperationVector
		FinalForce += CohesionVector
		FinalForce += AlignmentVector
		
		FinalForce = Vector3.new(FinalForce.X, 0, FinalForce.Z)
		if FinalForce.Magnitude == 0 then continue end

		--Enemy.PrimaryPart.VectorForce.Force = SeperationVector
		EnemyForce.Force = FinalForce
	end
end

function module.Pathfinding(Target: Model, Enemy: Model)
	local Goal = Target.PrimaryPart
	local Path = SimplePath.new(Enemy, module.AgentParams)
	
	task.spawn(module.Flocking, Enemy, Goal)

	while task.wait(Config.PathfindingLoop) do
		if not Goal then continue end
		
		if not Enemy.PrimaryPart then continue end

		-- moving
		local EndGoal = (Goal.Position +  Goal.AssemblyLinearVelocity * 0.15)

		local succes, err = pcall(function()
			Path:Run(EndGoal)
		end)

	end 
end

return module

The boids forces are being changed every task.wait() so I’m going to try out a longer wait time, to see how it affects performance.

I am just bumping this post because I’m still stuck on it. I’ve tried everything and the forces are so messed up. Would really appreciate anyone who would help. In the meantime, I’ve gotten my performance pretty good. This is with 150 dudes with animations, but my computer runs worse that an iphone 10, so it’s probably better than it looks in the video.

Just imagine how it would look if they would be spread out, and you can see every single one :heart_eyes: