Best way of controlling mass amounts of AI on server

Hello, I’ve gone through various different approaches to this problem and spent a lot of time researching as well as testing. My AI is not quite the same as a “typical” one. I know exactly when and where the AI will be because the path is predetermined and on flat terrain.

My goal is to maximize efficiency.

There could be potentially hundreds of AI in a small area so just simply creating a humanoid on the server and going through that route is out of the picture as this would be very taxing on the server.

Another route I tried was creating an object to follow the AI’s track and have the client create the humanoid which definitely helps but is still very rough on the server as seen in this video below:
note: I didn’t fully complete this as I could see from just the plain object this wouldn’t work either

I then thought of a different approach that doesn’t involve any server creation of parts and still can’t be exploited. Since I know the exact route that the AI’s will be taking and I know their walkspeed, then based off of how long it’s been since the creation of the AI I could calculate the AI’s position. So I did exactly this and the server’s performance is phenomenal:
note: all damage is being done by the server and then shown to the client through events

here is what the server sees

However, there are some drawbacks to this. Mainly there is no .Touched with this route. This is the main reason I am writing this. I can pretty much see each individual AI’s position and that’s about it. This means the only way to see if an object is being touched would be to loop through the table of every AI, check its position, see if that position is within a certain radius to the part, deal damage and repeat. This seems pretty inefficient and taxing.

Examples of needed .Touched

Below I’ve pasted some of my code. If you have any suggestions on how to replicate the .Touched through this process please let me know! Also, if you’ve found a better way of handling mass AI’s I would love to hear that too, anything can help.

--global enemy table filled with each enemy object 
_G.enemies = {}
--EX when creating 5 new enemies I would do something like this:

for i = 1, 5, 1 do
     local enemy = Enemy.new() --There are arguments passed into the constructor but redundant for this example
     enemies[i] = enemy
     --Fireclient to create this enemy visually
     wait(.5) --Time between each enemy
end

--Get Position function within enemy object module script
--Birth = tick() when object is first created
--Route is a table of vector3's where each value is the change from the last
function Enemy:GetPos()
	local t = tick() - self.Birth	--time since creation
	local dist = t * self.Speed	--distance to be traveled
	local pos = self.Route[1]	--start pos
	for i = 2, #self.Route, 1 do
		local change = self.Route[i].Magnitude
		if dist - change <= 0 then
			pos = pos - (self.Route[i] * dist / change)
			break
		else
			dist = dist - change
			pos = pos - self.Route[i]
		end
		
	end
	return pos
end
2 Likes

Enumerating over all of your AI instances and performing some logic on them to determine damage dealt is fine. I would not worry about the assumed ‘performance’ drop. Computers are very fast these days. I would argue that handling the the logic via some vector operations is better in both performance and maintainability of the code.

1 Like

If you end up having a huge number of NPCs and quite a large map with a lot of damage sources, you could always use some quadtree implementation. This way, for each zombie, you would only need to consider damage sources which are in the same nearby area.

Alternatively, a more specific (and probably contextually better) solution is this. From what I can see of your game, you can only place traps/etc adjacent to certain stretches of the path. For each AI, you could just iterate through all the damage sources that are adjacent to the path that it’s currently on and only do hit logic for those damage sources. The implementation of that should be quite easy, if you store what “road” the trap is placed adjacent to when it’s built. Then, given the current “road” that the AI is on, you now have a much smaller list of traps adjacent to its road which are actually capable of doing damage.

If the performance scales really poorly on the server for a large number of players, you could consider reversing your networking. Make the client calculate hit detection, and invoke the server whenever they think an AI should be hit, which the server then authenticates without having to iterate through every zombie itself. This is more complex and harder to pull off, and I wouldn’t really consider it for this case unless it was that bad.

I wouldn’t put too much complexity into reducing performance until you’re sure it’s a major problem still. The more complex you make your hit detection, the harder it’ll be to change your AI later on, so it might be better to get your game working fully, and then decide how you can optimise the performance of the AI (now that you’ve begun to think of a good system).

2 Likes

Definitely some good ideas that I will look into. Thanks for the detailed response, this helps a lot.

You could try RotatedRegion3.

For one of two things - every part that could be damaged, or every AI that can damage parts - you could use RR3 to detect if something is legitimately colliding with them (that includes touching). You should try it. For small parts, it shouldn’t be too bad. Lemme know how it goes.

Correct me if I’m wrong but, don’t region3’s need a part rather than a vector3 point(which is what I’m using here.

You can make a RotatedRegion3 from a CFrame and a Size.

FromPart is actually only a helper method that tries to make a RR3 matching a given part. RR3 does not depend on a part reference at all.

Hey! I figured this out almost 5 days later and would like to give an update on my solution for anyone who happens to be in search for something similar.

I initially tried what I originally said wouldn’t work, just looping through every enemy’s position and comparing that to each part that the enemy would take damage from. Ultimately, the moving parts were what caused trouble. Every time a moveable part changed position I would have to update the table that stored each damage region. This worked on the small scale however, once many(hundreds) enemies were introduced this failed to work as effectively and in the end was ruled out. I’ve pasted some of that code below if anyone is curious on my approach.

Looping Approach
--_G.bases[baseID] is the table of enemies currently spawned in for a particular player
function createRegion(part)
	local dmg = 0
	if part.Parent ~= nil and part.Name ~= "Goal" then
		dmg = part.Parent.Damage.Value
	end
	local pos = part.Position
	local size = part.Size
	local lower = Vector3.new(pos.X - size.X/2, 0, pos.Z - size.Z/2)
	local higher = Vector3.new(pos.X + size.X/2, 0, pos.Z + size.Z/2)
	return {lower, higher, dmg}
end

function CollisionDetection(player)
	local base = player.Info.base.Value.Ignores
	local baseID = player.Info.base.Value.ID.Value
	
	local hitRegions = {}
	local moveParts = {}
	local hits = {{}}
	
	hitRegions[1] = createRegion(base.AI.Goal)
	for i,region in pairs(base.PlayerMap:GetDescendants()) do
		if region.Name == "hitRegion" then
			local index = #hitRegions + 1
			hitRegions[index] = createRegion(region)
			hits[index] = {}
			if region.Parent.Name == "Cannon" or region.Parent.Name == "Wrecking Ball" then
				moveParts[#moveParts + 1] = {region, index}
			end
		end
	end
	
	while #_G.bases[baseID] > 0 do
		for i = 1, #moveParts, 1 do
			local region = moveParts[i][1]
			local index = moveParts[i][2]
			hitRegions[index] = createRegion(region)
		end
		
		for i = 1, #_G.bases[baseID], 1 do
			local enemy = _G.bases[baseID][i][1]
			if enemy ~= nil then
				local pos = enemy:GetPos()
				for j = 1, #hitRegions, 1 do
					local alreadyHit = false
					for k = 1, #hits[j], 1 do
						if hits[j][k] == i then
							alreadyHit = true
							break
						end
					end
					if not alreadyHit then
						local region = hitRegions[j]
						if pos.X < region[2].X and pos.X > region[1].X then
							if pos.Z < region[2].Z and pos.Z > region[1].Z then
								if j == 1 then
									_G.bases[baseID] = {}
									endStage:FireAllClients(player, "Lost")
									player.Info.isFighting.Value = false
									return
								else
									hits[j][#hits[j] + 1] = i
									damage(player, region[3], enemy, i)
								end
							end
						end
					end
				end
			end
		end
		wait()
	end
end

After this attempt failed I thought of another solution, that being the same solution I had towards my AI’s taking up too many resources on the server. If I could calculate the distance needed to be traveled in order to take damage, I can then divide this distance by the AI’s speed. This results in a time value of when the AI needs to take damage. This was pretty tricky to do for moving parts because I treated them as multiple damage regions. These regions are only active for a short period of time and then turned off so the equation to figure out if a particular region is active when the AI reaches it was confusing to say the least. Below I’ve pasted some of the code for anyone interested as well as a video with visuals of a moving part region.

Time Approach
function getDist(pos, route, path)
	for i = 1, #path, 1 do
		local currentRegion = path[i]
		if pos.X <= currentRegion[2][1] and pos.X >= currentRegion[1][1] then
			if pos.Z <= currentRegion[2][2] and pos.Z >= currentRegion[1][2] then
				pos = Vector3.new(pos.X, 5, pos.Z)
				local dist = 0
				for j = 1, i-1, 1 do
					dist = dist + route[j+1].Magnitude
				end
				local change = (pos - base.AI.Tracks[tostring(i)].Position).Magnitude
				local t = change + dist
				return t
			end
		end
	end
	return 0
end

function getCollides(player, route)
	base = player.Info.base.Value.Ignores
	local path = {}
	local hitRegions = {}
	
	for i = 1, #base.AI.Arrows:GetChildren(), 1 do
		local arrow = base.AI.Arrows[tostring(i)]
		local pos = arrow.Position
		local size = arrow.Size
		
		local from = {pos.X - size.X/2, pos.Z - size.Z/2}
		local to = {pos.X + size.X/2, pos.Z + size.Z/2}
		path[#path + 1] = {from, to}
	end
	
	for _,region in pairs(base.PlayerMap:GetDescendants()) do
		if region.Name == "hitRegion" then
			if region:IsA("Model") then
				local period = region.Period.Value
				local timeActive = region.Delay.Value
				for _,moveable in pairs(region:GetChildren()) do
					if moveable:IsA("Part") then
						local tActive = timeActive * tonumber(moveable.Name)
						local pos = moveable.Position
						local dist = getDist(pos, route, path)
						if dist > 0 then
							hitRegions[#hitRegions + 1] = {dist, tActive, period}
						end
					end
				end
			else
				local pos = region.Position
				local dist = getDist(pos, route, path)
				if dist > 0 then
					hitRegions[#hitRegions + 1] = {dist}
				end
			end
		end
	end
	
	local dist = 0
	for i = 2, #route, 1 do
		dist = dist + route[i].Magnitude
	end
	hitRegions[#hitRegions + 1] = {dist}
	
	return hitRegions
end

function CalcTimes(hitRegions, speed, offset)
	local hitTimes = {}
	local currentTime = tick()
	for i = 1, #hitRegions, 1 do
		local region = hitRegions[i]
		local dist = region[1]
		if region[2] ~= nil then
			local reachTime = dist / speed
			local timeActive = region[2]
			local period = region[3]
			local timeFromRegion = math.abs(((reachTime + offset) * 1000) % period - timeActive)
			if timeFromRegion < speedIntervals[speed]/2 then
				hitTimes[#hitTimes + 1] = reachTime + currentTime
			end
		else
			hitTimes[#hitTimes + 1] = dist / speed + currentTime
		end
	end
	return hitTimes
end

It’s still not perfect yet however it will definitely work so I’m happy with the results so far.

3 Likes