How handle a lot of NPC?

I see a lot of games like Zombie Attack that have more than 40 smart npcs without lag, how they achive that? as far i can see, the game dont have custom humanoids, and it does some pathfinding, how i could start making my owns npc, and how handle in a optimized way?

I should put a script for every zombie? or use OOP and module scripts and every 2 frames iterate for every zombie in a table to tell it to move?? coruntines? tricks for optimization?

For now i am thinking to make it server side, using OOP, and no pathfinding to start:
(Later i should make custom pathfinding or roblox default is enough?)

-Players raycast around they head to know if a zombie is hurting it

-Every new zombie created is stored in a table

-A while true do loops the table:

  1. If zombie is dead, delete zombie, set index in the table to nil (before every spawn wave, we clean the empty holes)

  2. Every zombie has a target, if target.Parent == true, we use :MoveTo(Target.Position), else search for new target

  3. If the cicle number % 20 then Zombie:SearchTarget (every 20 loops cicles i want to re-search for new target)

  4. If zombie is attacked, we can change the target to the player that shot it

  5. Raycast from the torso some stud foward, if we hit something make the humanoid jump

Any help or previous experiences to help handling a bunch of AIs are appreciated

You would create a basic script for the zombie, test it, if it works then modify it to work with CollectionService, which is what most people use

2 Likes

If you want to know how I would do this:
Zombie spawns and gets added to tag
Zombie checks for all Player’s characters, finds the closest and checks it’s distance, if it’s within the distance follow it, else wander around (with pathfinding)
If it finds a Player follow the Player, upon touching it, find the Player’s Humanoid and damage it
If it gets shot, make the Player which shot it a “higher priority” and follow that one without following the other target
If it gets killed remove the tag

1 Like

People tend to use CollectionService like what @Dede_4242 said, but usually run them in a constant loop using RunService.Heartbeat or RunService.Stepped, the optimizations are usually derrived from Parralel Lua to run the code alongside the main game, or Efficient Framework as in it is fast, and accurate with its Data.

1 Like

Well, i did something, relatively bennigers friendly code:
(No OOP or module script, just collection service and custom events)

local ServerScript = game:GetService("ServerScriptService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local CollectionService = game:GetService("CollectionService")

local upadateTarget = Instance.new("BindableEvent")
upadateTarget.Name = "updateTarget"
upadateTarget.Parent = script

local upadateMove = Instance.new("BindableEvent")
upadateMove.Name = "upadateMove"
upadateMove.Parent = script

local upadateJump = Instance.new("BindableEvent")
upadateJump.Name = "upadateJump"
upadateJump.Parent = script

local connections = {} --every npc store his connections here

local function findNearstPlayer(pos) --pos = ai position
	local currentDistance = 2000 -- very big number
	local target = nil -- no target
	for _, plr in pairs(Players:GetPlayers()) do
		local char = plr.Character
		if not char then continue end --if player dont have char then continue
		if char.Humanoid.Health <= 0 then continue end --same for dead players
		
		local hrp = char.HumanoidRootPart
		local newDistance = (hrp.Position - pos).Magnitude
		
		if newDistance < currentDistance then --if this player is close than the last, then set the variables
			currentDistance = newDistance
			target = hrp
		end
	end
	return target, currentDistance
end

local function setupHumanoid(humanoid,config)
	if not humanoid then return end
	
	local model = humanoid.Parent --the npc model
	local networkOwner = nil --networkOwnership nil is to give server the handling of the npc
	
	for _, descendant in pairs(model:GetDescendants()) do --set every base part of npc ownership to nil
		-- Go through each part of the model
		if descendant:IsA("BasePart") then
			-- Try to set the network owner
			local success, errorReason = descendant:CanSetNetworkOwnership()
			if success then
				descendant:SetNetworkOwner(networkOwner)
			else
				-- Sometimes this can fail, so throw an error to prevent
				-- ... mixed networkownership in the 'model'
				error(errorReason)
			end
		end
	end
	
	for propertyName, value in pairs(config) do --use the config dictionary to set humanoid properties
		humanoid[propertyName] = value
	end
end

local function moveToTarget(humanoid,target)
	if not target then return end
	humanoid:MoveTo(target.Position)
end

local function startAI(humanoid)	
	local target
	
	connections[humanoid] = {}
	
	connections[humanoid][1] = upadateTarget.Event:Connect(function()
		target = findNearstPlayer(humanoid.Parent.HumanoidRootPart.Position)
	end)
	
	connections[humanoid][2] = upadateMove.Event:Connect(function()
		moveToTarget(humanoid,target)
	end)
	
	connections[humanoid][3] = upadateJump.Event:Connect(function()
		humanoid.Jump = true
	end)
	
	humanoid.Died:Connect(function()
		CollectionService:RemoveTag(humanoid,"ZOMBIE")
		warn("TAG REMOVED")
		wait(4)
		humanoid.Parent:Destroy()
	end)
	
	-- for more optimization, our npc will no be able of climbing, swimming, ect
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Flying, false)
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, false)
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Climbing, false)
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Swimming, false)
	
end

CollectionService:GetInstanceAddedSignal("ZOMBIE"):Connect(function(instance) --instance shuould be a humanoid
	setupHumanoid(instance,{["WalkSpeed"] = 10;})
	startAI(instance)
end)

CollectionService:GetInstanceRemovedSignal("ZOMBIE"):Connect(function(instance) --npc died :(
	if not connections[instance] then return end
	for _, connection in pairs(connections[instance]) do
		connection:Disconnect()
		warn("CONNECTION DISCONNECTED")
	end
end)

while true do
	upadateTarget:Fire()
	wait()
	for i = 0, 30 do
		upadateMove:Fire()	
		wait()
	end	
	upadateJump:Fire()
end

What the code do?
-An humanoid is tagged
-SetupHumanoid is called for changing his prioperties (like walkspeed), and making his ownership nil (this is server handled)
-StartAI is called for, well, start the AI, connecting the functions of findNearstPlayer(), moveToTarget() and a jump function (just for fun) to the script global events, and storing them in the table of connections, with the humanoid as a key, becouse when humanoid died, it will remove his tag, and the :GetInstanceRemovedSignal(“ZOMBIE”) event will fire, to clean the mess (destroy parts, disconnect the connections)

Thats for every NPC, after, we start a while loop, firing the event in order to tell all the zombies what to do (that maybe a problem, because we cant tell a single AI to do something, without all making the same at same time) (will be better to split the ai in parts, and dont make them work all in the same time dodging the lag spikes)

And for adding a zombie, just add a script inside the model

wait(2)
local CollectionService = game:GetService("CollectionService")
CollectionService:AddTag(script.Parent.Humanoid,"ZOMBIE")

I will wating for more optimizations, i was thinking in firing the events with an argument, like a number, and is the npc number is not the same, it will ignore it, letting us run the AIs in sections

Will not help making the zombis invisible on the server too? and the client just making them visible?

Just for a trying, now it makes parts/decals of the model invisible, set their castShadow and canTouch to false, and in the client make them visible again:
(local script in player scripts)

local CollectionService = game:GetService("CollectionService")


CollectionService:GetInstanceAddedSignal("ZOMBIE"):Connect(function(instance)
	local model = instance.Parent
	if not model then return end
	wait(1) -- how i wait it to load??
	for _, descendant in pairs(model:GetDescendants()) do 
		if descendant:IsA("BasePart") and descendant.Name ~= "HumanoidRootPart" then
			descendant.Transparency = 0
		elseif descendant:IsA("Decal") then
			descendant.Transparency = 0
		end
	end 
end)

(new main script)

local CollectionService = game:GetService("CollectionService")


CollectionService:GetInstanceAddedSignal("ZOMBIE"):Connect(function(instance)
	local model = instance.Parent
	if not model then return end
	wait(1) -- how i wait it to load??
	for _, descendant in pairs(model:GetDescendants()) do 
		if descendant:IsA("BasePart") and descendant.Name ~= "HumanoidRootPart" then
			descendant.Transparency = 0
		elseif descendant:IsA("Decal") then
			descendant.Transparency = 0
		end
	end 
end)

Honestly the idea of using OOP to handle NPCs might sound great on paper, but imagine the iteration through 100+ objects from the OOP class, there wouldn’t even be enough time for the frame to finish that process.

My method of handling NPCs is by having an actor to handle each “group” of 25 NPCs, I also use BindableEvents to send processed data from the parallel threads to the main serial Server script where they will use the processed info.
Right now Roblox’s built-in pathfinding isn’t parallel-safe to use unfortunately, so you have to use task.synchronize() to turn the actor script serial before computing a path, use task.desynchorize() to go back to parallel.

In the actor script, you should have a tick rate variable and an accumulator to use that tick rate for optimization reasons.

( I really recommend using a accumulator in a Heartbeat connection! )

local Heartbeat = game.GetService(game, "RunService").Heartbeat
local accumulatorDelta = 0
local tickRate = 1 / 20 -- Compute 20x a second.

Heartbeat:ConnectParallel(function(deltaTime)
	accumulatorDelta += deltaTime

	while accumulatorDelta >= tickRate do
		accumulatorDelta -= tickRate
		
		--// Do something like calculating a NPC's behavior for the 
		--// main SERIAL server to use.
	end
end)

If you want to learn how parallel threads and how to use them, here is a official Roblox documentation: Parallel Luau | Roblox Creator Documentation. Also, if you have any questions let me know :]

6 Likes

Thanks! I dont understand a lot of threads but is like to be worth the research, i will try later

Note: with 72 npcs, and only a face decal, the script is running below 3% activity (and one player altough)

1 Like

Now that i research a bit, i have some questions, not specifically for you

Why i should split the code in parallels threads? I still dont get it, how it could be faster for the machine jumping from code to code, if at end is the same amount of math and code

And how? with the current code could be possible?
first i have to think how it will be splited:

The only function i could desyncronize was the getNearPlayer:
(now they stopped jump at same time)

local ServerScript = game:GetService("ServerScriptService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local CollectionService = game:GetService("CollectionService")

local upadateTarget = Instance.new("BindableEvent")
upadateTarget.Name = "updateTarget"
upadateTarget.Parent = script

local upadateMove = Instance.new("BindableEvent")
upadateMove.Name = "upadateMove"
upadateMove.Parent = script

local upadateJump = Instance.new("BindableEvent")
upadateJump.Name = "upadateJump"
upadateJump.Parent = script

local connections = {} --every npc store his connections here

local function findNearstPlayer(pos) --pos = ai position
	task.desynchronize()
	local currentDistance = 2000 -- very big number
	local target = nil -- no target
	for _, plr in pairs(Players:GetPlayers()) do
		local char = plr.Character
		if not char then continue end --if player dont have char then continue
		if char.Humanoid.Health <= 0 then continue end --same for dead players
		
		local hrp = char.HumanoidRootPart
		local newDistance = (hrp.Position - pos).Magnitude
		
		if newDistance < currentDistance then --if this player is close than the last, then set the variables
			currentDistance = newDistance
			target = hrp
		end
	end
	return target, currentDistance
end

local function setupHumanoid(humanoid,config)
	task.synchronize()
	if not humanoid then return end
	
	local model = humanoid.Parent --the npc model
	local networkOwner = nil --networkOwnership nil is to give server the handling of the npc
	
	for _, descendant in pairs(model:GetDescendants()) do --set every base part of npc ownership to nil
		-- Go through each part of the model
		if descendant:IsA("BasePart") then
			-- Try to set the network owner
			local success, errorReason = descendant:CanSetNetworkOwnership()
			if success then
				descendant:SetNetworkOwner(networkOwner)
			else
				-- Sometimes this can fail, so throw an error to prevent
				-- ... mixed networkownership in the 'model'
				error(errorReason)
			end
			descendant.Transparency = 1
			descendant.CastShadow = false
			descendant.CanTouch = false
		elseif descendant:IsA("Decal") then
			descendant.Transparency = 1
		end
	end
	
	for propertyName, value in pairs(config) do --use the config dictionary to set humanoid properties
		humanoid[propertyName] = value
	end
end

local function moveToTarget(humanoid,target)
	task.synchronize()
	if not target then return end
	humanoid:MoveTo(target.Position)
end

local function startAI(humanoid)	
	task.synchronize()
	local target
	
	connections[humanoid] = {}
	
	connections[humanoid][1] = upadateTarget.Event:Connect(function()
		target = findNearstPlayer(humanoid.Parent.HumanoidRootPart.Position)
	end)
	
	connections[humanoid][2] = upadateMove.Event:Connect(function()
		moveToTarget(humanoid,target)
	end)
	
	connections[humanoid][3] = upadateJump.Event:Connect(function()
		humanoid.Jump = true
	end)
	
	humanoid.Died:Connect(function()
		CollectionService:RemoveTag(humanoid,"ZOMBIE")
		warn("TAG REMOVED")
		wait(2)
		humanoid.Parent:Destroy()
	end)
	
	-- for more optimization, our npc will no be able of climbing, swimming, ect
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Flying, false)
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, false)
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Climbing, false)
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Swimming, false)
	
end

CollectionService:GetInstanceAddedSignal("ZOMBIE"):Connect(function(instance) --instance shuould be a humanoid
	setupHumanoid(instance,{["WalkSpeed"] = 10;})
	startAI(instance)
end)

CollectionService:GetInstanceRemovedSignal("ZOMBIE"):Connect(function(instance) --npc died :(
	if not connections[instance] then return end
	for _, connection in pairs(connections[instance]) do
		connection:Disconnect()
		warn("CONNECTION DISCONNECTED")
	end
end)

debug.profilebegin("NPC STUFF")
while true do
	upadateTarget:Fire()
	wait()
	for i = 0, 30 do
		upadateMove:Fire()	
		wait()
	end	
	upadateJump:Fire()
end
1 Like

If you use Parallel LuaU you can run different chunks of code which run better when together together, for example:
Code 1 runs better alongside Code 2, but I can’t run them together, so I split them, before:
Code 1 runs, Code 2 runs
After:
Code 1 runs
Code 2 runs
Not sure if you understood

With 144 npcs without face, and one player

if i deselect the npcs, fps go from 24 to 41

I dont see how that apply with my code, how it could run better in chunks? what i am supposed to split?

Sorry but I am 100% the wrong person to ask, I don’t have any idea of how to do these things, sorry :sweat_smile:
But I know someone that might help
I could @ him, but he told me not to
Here’s his topic about Parallel LuaU (only one I found) Parallel Luau seemingly Slowing down Game Startup Time

1 Like

2023-04-03T03:00:00Z Update: As you can see in previus screen shots, the main frame drops are related to render probles, after changing the light to voxel mode and removing npc faces and not getting good enough performance, i get an idea:

image

An one part humanoid, only with a invisible HumanoidRootPart of a size of (3,5,0.1) and two decals, still droping frames a bit if too many zombies jump in your face, but is a very high perfomance upgrade.

If you want to make an humanoid like these, create a model, add a part named “HumanoidRootPart”, set the part size and transparency, set the model PrimaryPart to the HumanoidRootPart, and add an humanoid in the model. (I recommend adding at least 0.1 of HipHeight to the Humanoid, becouse 0 will make the part collide with the ground and make the npc “slip”, my humanoid has 0.2)

image

150 npc jumping in my face

and now, 150 more far away:
image

The only problem with this solution is the collision between npcs, now they varely had depth, letting they get together very close, this could be a bad thing for game mechanics, maybe given them random walkspeeds will spread them

1 Like