Tower Defense Game has High Recv Network Traffic Performance Issue

Hello, I am currently making a Tower Defense Game and my main goal is to optimize the enemy movement system so I’m able to put a large amount of enemy units inside my game while also maintaining a good level of performance in my game.

The current issue is that my game has poor performance, having horrible High Recv Network running at around hundreds of KB/s for around 200 units. While in comparison with other tower defense games, their performance is much better while being able to run even more enemies at the same time.

The current solutions that I’ve tried so far were, reading other articles about tower defense game development, performance related posts, reading the Astro Force performance improvement blog (which made me switch my entire system from actual models on the server to pure tables being used as data).

I’ve tried reaching out to other developers as well as asking other people for help and although I have received a huge amount of help I wasn’t able to really reach my end goal and there wasn’t overall large improvement in the performance.

My enemy units utilize a Catmull Rom Spline node generation system which takes in points pre-defined on the path and then outputs nodes which are stored in a table and those nodes are the nodes at which the enemy units will be travelling towards.

I believe that currently, the issue with my code probably has something to do with the main code execution flow, the while true do… statement which executes all the code a bunch of times, updating the server to client which probably causes most of the performance trouble. And other then that, I’m all but out of ideas.

= = =

Here’s the Server Code:

wait(5)

local UpdateClients = game.ReplicatedStorage:WaitForChild("Spawned")

--- Spline Calculations

-- example for p0 with tension 

local dot = game.ServerStorage.dot

local tableOfPaths = {}

function DrawPath()
	local NodesTable = {}
	
	local tensionMain = math.random(1, 100)/100 -- math.random(90, 100)/100

	local counter = 0

	-- To DO: the point calculation is already in this table, just run DrawPath() for each NPC individually and then have them recalculate where to go
	-- this will cause them to be scattered and different

	local Points = { 
	}

	for	i=1, #game.Workspace.Points:GetChildren(), 1 do
		Points[i] = game.Workspace.Points[i].Position
	end

	-- print("Tension is: " .. tensionMain)

	local NumPoints = #Points

	local LastPoint = Points[1]
	for i = 1, NumPoints - 1 do
		local p0 = Points[i - 1] or Points[1]
		local p1 = Points[i]
		local p2 = Points[i + 1]
		local p3 = Points[i + 2] or Points[NumPoints]
		-- print(i)
		for j = 0, 1, 0.05 do
			local tension = tensionMain

			local t = j
			local t2 =  t * t
			local t3 = t2 * t

			local p = 
				(
					(-tension * t + 2 * tension * t2 - tension * t3) * p0 +
					(2 + (tension - 6) * t2 + (4 - tension) * t3) * p1 + 
					(tension * t - 2 * (tension - 3) * t2 + (tension - 4) * t3) * p2 + 
					(-tension * t2 + tension * t3) * p3
				) * 0.5	
			
			--[[
			-- EnemyPosVal Object Purposes + Visual Demonstration of Points
			local visual_dot = dot:Clone()
			visual_dot.Parent = game.Workspace.Nodes
			visual_dot.Position = p
			-- counter = counter + 1
			visual_dot.Name = counter
			]]
			
			counter = counter + 1
			NodesTable[counter] = CFrame.new(p)
		end
	end	
	
	return NodesTable
end

-- Pre-Process Generate a Bunch of Paths so we don't have to generate new ones later on

for i = 1, 100 do
	tableOfPaths[i] = DrawPath()
end

-- NPC Spawning Table Putting Function

local ServerTableOfEnemies = {}

local EnemyId = "1";

function SpawnNPC()
	
	local EnemyInfo = {
		["EnemyId"] = EnemyId;
		["Node"] = "1";
		-- ["NodeProgress"] = 0;
		["Speed"] = 4;
		["Path"] = tableOfPaths[math.random(1,100)];
		["CFrame"] = CFrame.lookAt(game.Workspace.Points["1"].Position, game.Workspace.Points["2"].Position);
		["Done"] = false;
	}

	ServerTableOfEnemies[EnemyInfo.EnemyId] = EnemyInfo;
	EnemyId = EnemyId + 1;
	-- print(EnemyId)
end

-- Actual Movement Handling

function HandleMovement()
	local ClientTableOfEnemies = {}
	
	for i,Enemy in pairs(ServerTableOfEnemies) do
		if (Enemy.Done == true) then
			-- in the future fire some event to notify clients about deleting the NPC since its dead or reached the end
			-- make another check about the NPC having 0 health or less in the future
			-- print("Done")
		else
			Enemy.CFrame = Enemy.CFrame:ToWorldSpace(CFrame.new(0,0,-0.1 * Enemy.Speed))
			local magnitude = (Enemy.CFrame.Position - Enemy.Path[Enemy.Node + 1].Position).Magnitude
			-- print(Enemy.Node)
			if (Enemy.CFrame.Position - Enemy.Path[Enemy.Node + 1].Position).Magnitude <= 1 then
				Enemy.Node += 1
				if Enemy.Node == #Enemy.Path then
					Enemy.Done = true
				else
					Enemy.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position)
				end
			end
			
			local Precision = 10^2
			
			local EulerAnglesThingy = Vector3.new(Enemy.CFrame:ToEulerAnglesXYZ())
			
			local ClientEnemyInfo = {
				["EnemyId"] = Enemy.EnemyId;
				["Vector3Int16_Position"] =  Vector3int16.new(Enemy.CFrame.Position.X * Precision, Enemy.CFrame.Position.Y * Precision, Enemy.CFrame.Position.Z * Precision);
				["Vector3Int16_Rotation"] =  Vector3int16.new(EulerAnglesThingy.X * Precision, EulerAnglesThingy.Y * Precision, EulerAnglesThingy.Z * Precision);
				-- ["Done"] = Enemy.Done;
				-- ["Node"] = Enemy.Node;
				-- ["Path"] = Enemy.Path;
			}

			ClientTableOfEnemies[i] = ClientEnemyInfo
		end
	end 
	UpdateClients:FireAllClients(ClientTableOfEnemies)
end

-- Main Routine

local enemyCount = 200;

task.spawn(function()
	for _ = 1, enemyCount do
		task.wait(0.1) -- Spawn delay between each unit
		SpawnNPC()
	end
end)

while true do
	task.wait(0.05)
	HandleMovement()
end

===

Here’s the Client Code:

game.ReplicatedStorage.Spawned.OnClientEvent:Connect(function(ClientTableOfEnemies)
	local Precision = 10^2
	
	for i, enemy in pairs(ClientTableOfEnemies) do
		local CFrameNoOrientation = CFrame.new(enemy.Vector3Int16_Position.X / Precision, enemy.Vector3Int16_Position.Y / Precision, enemy.Vector3Int16_Position.Z / Precision)
		
		local CFrameWithOrientation = CFrameNoOrientation * CFrame.Angles(enemy.Vector3Int16_Rotation.X / Precision, enemy.Vector3Int16_Rotation.Y / Precision, enemy.Vector3Int16_Rotation.Z / Precision)
		
		if not game.Workspace.Mobs:FindFirstChild(enemy.EnemyId) then
			local cloneClientSide = game.ReplicatedStorage.ClientSide:Clone()
			cloneClientSide.Name = enemy.EnemyId
			cloneClientSide.Parent = game.Workspace.Mobs
			cloneClientSide:PivotTo(CFrame.new(cloneClientSide.Position, CFrameWithOrientation.Position))
		else
			local cloneClientSide = game.Workspace.Mobs[enemy.EnemyId]

			-- local theLerping = cloneClientSide.CFrame:Lerp(enemy.CFrame, Speed) 
			
			cloneClientSide.CFrame = CFrameWithOrientation --CFrame with new position + new rotation
		end
	end
end)

Additionally, I will be attaching a screenshot visualizing how the nodes really look when generated and what the game looks like so far when everything is work, while the performance statistics are show as well.

Note: the DrawPath() function for the node generation system is pre-processed and then stored in a table from which the enemy NPCs later on randomly grab a path from the table and use. So that function has nothing to do with with performance issues as far as I’m concerned.

(Also pay attention that one of the red blob units are stuck at the very start for some weird reason, I don’t know why that is, it’s the unit at index 1 of the table of all the enemy units, when I spawn only 1 unit, the unit moves, but when I spawn more then 1, it doesn’t move for some odd reason.)

2 Likes

bump, still looking for some help with my post

Hello, I notice some things with enemy data. One way I would decrease data transfer is by changing Client Enemy Info keys into numbers.

Your code

local ClientEnemyInfo = {
				["EnemyId"] = Enemy.EnemyId;
				["Vector3Int16_Position"] =  Vector3int16.new(Enemy.CFrame.Position.X * Precision, Enemy.CFrame.Position.Y * Precision, Enemy.CFrame.Position.Z * Precision);
				["Vector3Int16_Rotation"] =  Vector3int16.new(EulerAnglesThingy.X * Precision, EulerAnglesThingy.Y * Precision, EulerAnglesThingy.Z * Precision);
				-- ["Done"] = Enemy.Done;
				-- ["Node"] = Enemy.Node;
				-- ["Path"] = Enemy.Path;
			}

The keys take up more space than they should, I would make sure to number them in order like this:

local ClientEnemyInfo = {
				[1] = Enemy.EnemyId; -- EnemyID
				[2] =  Vector3int16.new(Enemy.CFrame.Position.X * Precision, Enemy.CFrame.Position.Y * Precision, Enemy.CFrame.Position.Z * Precision); -- Position
				[3] =  Vector3int16.new(EulerAnglesThingy.X * Precision, EulerAnglesThingy.Y * Precision, EulerAnglesThingy.Z * Precision); -- Rotation
			}

In my testing, this reduced 3x the data usage from 300~ to around 95~

Edit: There are better ways to do it, but I would recommend Astro Forces Article again in version 3 as he would explain it much better than I can about bit packing.

1 Like

Very nice, thank you.

But I think the majority of my poor performance comes from the main code execution flow which derives from this simple line:

while true do
	task.wait(0.05)
	HandleMovement()
end

When I change the wait time it causes the performance to change obviously due to the amount of times the replication event is called. I was wondering if there’s any way to improve the HandleMovement() function maybe? Perhaps it’s

I’m not entirely sure how it works, as I’ve never used it, but I think this may help?

There are other ways to improve data more, but I’m unsure as to how your game is gonna work. ie. Are there gonna be a variety of enemies, can enemies stop anytime on the track, and more?

Are your enemies going to rotate depending on their movement direction? If not, you only really need one rotation number for rotating the enemies. theoretically, this will reduce data by 28%. If enemies rotate only 2 directions, you can do the same with just vector2.

local ClientEnemyInfo = {
				[1] = Enemy.EnemyId;
				[2] =  Vector3int16.new(Enemy.CFrame.Position.X * Precision, Enemy.CFrame.Position.Y * Precision, Enemy.CFrame.Position.Z * Precision);
				[3] =  EulerAnglesThingy.Y * Precision; -- Sends only Y direction, you can put this number in vector3.new(0, ypos, 0) to decompile it

These below are if there are more than just 1 enemy type/behavior
In case you are going to have a variety of enemies, you can put each enemy in its own enemy table instead of having each enemy table have its own enemy type.

Another way is if enemies stop in the middle of the track like a stun, you can just not send their data at all.

If enemies don’t change speed at all, an easy way is to send each enemy data once instead of updating it and having the client do the movement work. This is a more complicated strategy, but, not only this will save network traffic, but it will be very smooth for the client. The only problem with this strategy is that it gets much more complicated if each enemy can change speed or just stop anytime.

There are a few other ideas I have that could help, but may not be what you need or are just too complicated.

Could it be due to repeatedly looping through the enemies in the client code?

Nice, that’s also what I was thinking, I don’t need the entire rotation matrix but only the Y aspect of it. I’ll use that.

Now, in regards to enemy types, I’ll probably have different enemies and different behavior types as well as different speeds in different scenarios.

I was also thinking about idea of making the client handle all movement work and the server only making to sure to notify the client when an enemy spawns. But, then the client would have to be in charge of telling the server somehow when the enemies reach the end and that poses a risk in my opinion, it’s going to be a multiplayer game and I can’t have the client handle all of that.

EDIT:

I ended up using this:

			local ClientEnemyInfo = {
				[1] = Enemy.EnemyId;
				[2] = Vector3int16.new(Enemy.CFrame.Position.X * Precision, Enemy.CFrame.Position.Y * Precision, Enemy.CFrame.Position.Z * Precision);
				[3] = Vector2int16.new(0, EulerAnglesThingy.Y * Precision)
			}

Because Vector2int16 limits the amount of data size we really need, since the rotation only reaches about 180 degrees there’s no need to use the default int size.

Is the client code only being triggers once, or for each enemy?

Correct, I believe each time

while true do
	task.wait(0.05)
	HandleMovement()
end

This is executed, the event fires to each client every single time. So basically only once every HandleMovement() call.

That could potentially be one of the reasons why.

Yes, you might be right, because of how often I fire the event it does cause a lot of bad performance but I currently have no idea about how I can go around this problem.

Also:

So far the performance dropped down from around 250 KB/s to 75 KB/s. Which is really nice. But other then bitpacking I’m not sure there’s another way to improve the performance unless another function has something to do with the bad performance.

Edit: I was considering modifying the wait() time of the main code execution flow but that causes the animations to be wonky.

Sorry for not clarifying but there is no risk of having the client control the movement for their perspective. What I mean by this is that when an enemy spawns, both the server and client will make their own version of the enemy movement (all you would need to do is give them the speed, maybe let them have information of each enemy speed on a module script?). The server would still do all of the handling of enemy systems, leading to no compromises.

This is exactly what I thinking. Why not handle enemy movements on the server.

I ignored the part of you looping it because It would update each enemy, from my testing, it seems to do little performance hit (0.05% usage).

I think tweening the enemies could help with animations, not the best cause if a player lag spikes you would notice, but its better i guess.

But will the server also store the position of the enemy as well? The enemy movement is already basically handled on the server. via the

Enemy.CFrame = Enemy.CFrame:ToWorldSpace(CFrame.new(0,0,-0.1 * Enemy.Speed))

line, which constantly changes the position of it, and then fires a remote to inform the client where to move the part to.

Tweening on the client side or the server side?

The server handles the actual position of the enemy (so the enemy can stop when the path ends), and the client will control the movement, it would be much smoother and less network traffic as you won’t need to update the position each time. Only problem I see if you want the enemies to stop or slow down.

If you want to update it each time, you can use tween on the client. After getting the next position, you can tween the next position by the speed of how fast the server updates.

1 Like

So this line specifically:

Enemy.CFrame = Enemy.CFrame:ToWorldSpace(CFrame.new(0,0,-0.1 * Enemy.Speed))

Will basically not exist on the server right? Or something along those lines?

Won’t I have to predict the position where the enemy will have to be to be able to sync it with the client every single time?

Maybe I don’t seem to really understand what you’re trying to say. May you please show me an example of what you mean by that? Like I get the general idea but I can’t seem to really imagine the way I would do it.

-- Server
whenEnemySpawns:Connect(function()
  remoteSpawnedEnemy:FireAllClients(enemyId, metadata)
end)

while true do
  task.wait(20) -- arbitrary delay
  local positions = getCurrentPositionsOfEnemies()
  remoteSyncPositions:FireAllClients(positions)
end
-- Client
remoteSpawnedEnemy.OnClientEvent:Connect(function(enemyId, metadata)
  local enemy = createEnemy(enemyId, metadata)
  addEnemyToPathInterpolator(enemy)
end)

remoteSyncPositions.OnClientEvent:Connect(function(positions)
  -- re sync the positions of all enemies
end)

Do you mean something like this with predicting the position at which the client has to be?