How can I maximise the computational power spent, whilst keeping a steady 60fps on the server/client

  1. What do you want to achieve?
  • I want to be able to manage a maximum amount of NPCs (very basic roaming zombies) whilst being able to keep a steady 60-65fps on BOTH Client and Server (currently only working on server, but a client compatibility is necessary for what I want to achieve). This system also needs to be able to adjust quickly: For example, if a player’s fps changes to 110, because of streaming, I want to be able to use their extra computational power OR on the contrary, if a player’s fps lowers, I want to be able to lower its computation load.
  1. What have you tried so far?
  • Currently, my system tries to find the amount of time it takes, within each frame, to calculate everything (work time), and also tries to find the amount of time that is considered as sleep (no work done within the frame, just waiting). To figure these two values, I use the events from RunService: RunService.PreAnimation and RunService.PostSimulation. I have not been able to think of any other non-waky way of proceding with this task.
  1. What is the issue?
  • The freetime is completely wrong, since when there is an overload of NPCs, my frames start dropping down under 60fps, yet it says there is still ~4ms of freetime, making my script think it can still load more NPCs. NOTE: It is easy to calculate the average processing time per NPC (Work time / NPCCount) IF the numbers were correct. IMPORTANT: On roblox, the server’s fps (or heartbeat) is capped at 60fps, but on roblox studio, the server fps can get up to 240 fps (so same as client). That is why this problem is so complicated.
  1. What solutions have you tried so far?
  • I have, of course, looked for forums with the intent on being able to better find the values I mensionned above: work and sleep time, to no avail.
  1. What does your code look like?
    Here is the full script so that you can try it out. Note that you NEED to input a valid R6 character (or R15 if you want) AND that it’s a server script.
local RunService = game:GetService("RunService")
local PhysicsService = game:GetService("PhysicsService")

local GroupName = "Zombies"
PhysicsService:RegisterCollisionGroup(GroupName)
PhysicsService:CollisionGroupSetCollidable(GroupName, GroupName, false)


--SETTINGS--
local FrameSafetyPercentage = 5 --The computational should take 1/(60 + FrameSafetyPercentage)
local MovePerFrame = 300 --The average move time in frames
local ZoneSize = 500 --The size eof the zone in which the NPCs walk in
local R6 = workspace.Humanoids["1"].R6
--ends here--

local FrameSafety = 60 * (1 + FrameSafetyPercentage/100)
ZoneSize /= 2

for _, part in pairs(R6:GetChildren()) do
	if part:IsA("Part") then
		part.CollisionGroup = GroupName
	end
end

local function WalkTo(humanoid)
	task.spawn(function()
		local goal = Vector3.new(
			Random.new():NextNumber(-ZoneSize,ZoneSize),
			1,
			Random.new():NextNumber(-ZoneSize,ZoneSize)
		)
		repeat
			humanoid:MoveTo(goal)
			local success = humanoid.MoveToFinished:Wait()
		until success
		humanoid.Parent:SetAttribute("WalkingToGoal", false)
	end)
end

local NPCCount = 0
local lastRemovedAI = 0
local lastAddedAI = 0
local function NewAI()
	print("NewAI Added!")
	local new = R6:Clone()
	new.Parent = workspace
	new:MoveTo(Vector3.yAxis * 3)
	NPCCount += 1
	lastAddedAI = tick()
end

local function RemoveAI()
	print("Removing an AI")
	local ToDestroy = workspace:FindFirstChild("R6")
	if ToDestroy ~= nil then
		ToDestroy:Destroy()
		NPCCount -= 1
		lastRemovedAI = tick()
	end
end

local TotalWorkFrames = 0
local TotalWorkTime = 0
local TotalFreeTime = 0
local TotalFreeFrames = 0
local StartedWork = 0
local StartedFree = 0

local WorkTime
local FreeTime

--Started Simulation
RunService.PreAnimation:Connect(function(deltaTime)
	local now = os.clock()
	StartedWork = now
	local LocalFreeTime = now - StartedFree
	FreeTime = LocalFreeTime
	if StartedFree ~= 0 then
		TotalFreeTime += LocalFreeTime
		TotalFreeFrames += 1
		task.delay(1, function()
			TotalFreeTime -= LocalFreeTime
			TotalFreeFrames -= 1
		end)
	end
end)

--Finished Simulation
RunService.PostSimulation:Connect(function(deltaTimeSim)
	local now = os.clock()
	WorkTime = now - StartedWork
	StartedFree = now
	if StartedWork ~= 0 then
		local LocalWorkTime = WorkTime
		TotalWorkTime += LocalWorkTime
		TotalWorkFrames += 1
		task.delay(1, function()
			TotalWorkTime -= LocalWorkTime
			TotalWorkFrames -= 1
		end)
	end
end)

task.wait(3) --Waiting for everything to load

local TotalTimePerNPCs = 0

local TotalFrames = 0
RunService.Heartbeat:Connect(function(deltaTime)
	if NPCCount > 0 then
		TotalTimePerNPCs += WorkTime/NPCCount
		TotalFrames += 1
		
		task.delay(1, function()
			TotalTimePerNPCs -= WorkTime/NPCCount
			TotalFrames -= 1
		end)
	end
	
	local AverageTimePerNPCs = TotalTimePerNPCs/TotalFrames
	local AverageFreeTime = TotalFreeTime/TotalFreeFrames
	print("Space left for "..AverageFreeTime/AverageTimePerNPCs.." more NPCs", "Average Free Time (in ms): "..AverageFreeTime * 1000, "Average Simulation Time per NPC (in ms) : "..AverageTimePerNPCs * 1000)
	if NPCCount < 100 then
		NewAI()
	elseif AverageFreeTime/AverageTimePerNPCs > 20 then
		NewAI()
	end
	
	--Moves the NPCs if the have finished their previous goal or if a 1/MovePerFrame chance is hit
	for _, char in pairs(workspace:GetChildren()) do
		if char:FindFirstChild("Humanoid") and char:GetAttribute("WalkingToGoal") == false or math.random(1, math.ceil((MovePerFrame/60)/deltaTime)) == 1 then
			char:SetAttribute("WalkingToGoal", true) --Attribute to detect if it has finished its goal
			local humanoid = char.Humanoid
			WalkTo(humanoid)
		end
	end
end)

This post would have to be my last solution to this complicated problem, and I am hoping to be able to get help to find these two values or to get other ideas on how to make this work. Any help is greatly appreciated and I hope I can answer all of your questions as quickly as possible!