Tower Defense Game has High Recv Network Traffic Performance Issue

Yes, but the server isnt predicting, the client is replicating what the server is doing. Basically, all movement code is copied to the client, and the client gets to make their own positions for each enemy, if you’re having multiple paths, you can always send which path you want the enemy to go. Also if you are worried about syncing, send a workspace.DistributedGameTime and compare the delay between server sending and client receiving.

(clienttime - servertime) -- This will get the delay between server and client, and you can multiply this when an enemy first spawns to sync

Also note, if a hacker tampers with the client side, only they can see it. It won’t affect what the server and others see because all the client is doing is trying to replicate what the server is doing when an enemy spawns.

1 Like

Alright, sounds nice, I’m going to attempt to do this.

stop using “while” for endless/infinite looping, use Heartbeat/PostSimulation/RenderStepped instead for better performance. Heartbeat delta is depending server performance frame, but postsimulation (another version of heartbeat) is locked to 60 (0.01666…) and will never change if server frame is decreasing

1 Like

Well… here is what I came up with…

Server Code:

wait(5)

local SyncClients = game.ReplicatedStorage:WaitForChild("SyncClients")

local SpawnedEnemy = game.ReplicatedStorage:WaitForChild("SpawnedEnemy")

--- 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 Precision = 10^2
	
	local EnemyInfo = {
		["EnemyId"] = EnemyId;
		["Node"] = "1";
		["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)
	
	
	local ClientEnemyInfo = {
		[1] = EnemyInfo.EnemyId;
		[2] = Vector3int16.new(EnemyInfo.CFrame.X * Precision, EnemyInfo.CFrame.Y * Precision, EnemyInfo.CFrame.Z * Precision);
		[3] = Vector2int16.new(0, 0);
		[4] = EnemyInfo.Node; -- Node
		[5] = 4; -- Speed
		[6] = tableOfPaths[math.random(1,100)]; -- Path
	}
	
	SpawnedEnemy:FireAllClients(ClientEnemyInfo)
end

-- Actual Movement Handling
local ClientTableOfEnemies = {}

function HandleMovement()

	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 = {
				[1] = Enemy.EnemyId; -- EnemyId
				[2] = Vector3int16.new(Enemy.CFrame.Position.X * Precision, Enemy.CFrame.Position.Y * Precision, Enemy.CFrame.Position.Z * Precision); -- Position
				[3] = Vector2int16.new(0, EulerAnglesThingy.Y * Precision); -- Rotation
				[4] = Enemy.Node; -- Node
			}

			ClientTableOfEnemies[i] = ClientEnemyInfo
		end
	end 
end

-- Main Routine

local enemyCount = 50;

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

local count = 0

while true do
	if count == 100 then
		count = 0
		SyncClients:FireAllClients(ClientTableOfEnemies)
	else
		wait(0.01)
		count += 1
		HandleMovement()
	end
end

Client Code


--- Some client stuff

local Precision = 10^2

local tableOfEnemies = {}

game.ReplicatedStorage.SpawnedEnemy.OnClientEvent:Connect(function(ClientTableEnemyInfo)
	print("i got called")
	local key = ClientTableEnemyInfo[1]
	print(key)
	tableOfEnemies[key] = ClientTableEnemyInfo
	
	local Precision = 10^2

	local CFrameNoOrientation = CFrame.new(ClientTableEnemyInfo[2].X / Precision, ClientTableEnemyInfo[2].Y / Precision, ClientTableEnemyInfo[2].Z / Precision)

	local CFrameWithOrientation = CFrameNoOrientation * CFrame.Angles(0, ClientTableEnemyInfo[3].Y / Precision, 0)

	local cloneClientSide = game.ReplicatedStorage.ClientSide:Clone()
	cloneClientSide.Name = ClientTableEnemyInfo[1]
	cloneClientSide.Parent = game.Workspace.Mobs
	cloneClientSide:PivotTo(CFrame.new(cloneClientSide.Position, CFrameWithOrientation.Position))
end)

game.ReplicatedStorage.SyncClients.OnClientEvent:Connect(function(ClientTableOfEnemies)
	for i, Enemy in pairs(ClientTableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy[1]]
		
		tableOfEnemies[Enemy[1]][2] = Enemy[2];
		tableOfEnemies[Enemy[1]][3] = Enemy[3];
		tableOfEnemies[Enemy[1]][4] = Enemy[4];
		
		local CFrameNoOrientation = CFrame.new(Enemy[2].X / Precision, Enemy[2].Y / Precision, Enemy[2].Z / Precision)

		local CFrameWithOrientation = CFrameNoOrientation * CFrame.Angles(0, Enemy[3].Y / Precision, 0)
		
		clientTarget:PivotTo(CFrame.new(clientTarget.Position, CFrameWithOrientation.Position))
	end
end)

function HandleMovement()
	for i,Enemy in pairs(tableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy[1]]
		
		if (Enemy[4] == #Enemy[6]) 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
			clientTarget.CFrame = clientTarget.CFrame:ToWorldSpace(CFrame.new(0,0,-0.1 * Enemy[5]))
			
			local magnitude = (clientTarget.CFrame.Position - Enemy[6][Enemy[4] + 1].Position).Magnitude
			
			if (magnitude <= 1) then
				Enemy[4] += 1
				
				clientTarget.CFrame = CFrame.new(Enemy[6][Enemy[4]].Position, Enemy[6][Enemy[4] + 1].Position)
			end
		end
	end 
end

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

Now well… the animatronics… uh, they’re acting a little… sus lately…

Edit: I’ll replace the waits with a heartbeat later on, currently I have to figure out why this is happening.

Once one single enemy unit reaches the end, they all freeze and it gives me the error below.

I did quite a bit of changes in your code. But I seemed to have fixed everything and optimized it a bit. Heres how I did it.

Instead of sending the path directly each time the event was called, the client would request to the server for the list of paths as each path is keyed with a number, this would be a huge benefit as all you would need to send for the enemy info is send a single number for the path. Sending the path directly caused the network to be around 10kb~, doing it that way changes it to just under 1kb.

-- Creates the paths
for i = 1, 100 do
	tableOfPaths[i] = DrawPath()
end

-- Client requests the paths
function RequestPath()
	return tableOfPaths
end

game.ReplicatedStorage.RequestPaths.OnServerInvoke = RequestPath

Another thing I did was make the decompiler (just because it was hard to read).

function Decompile(EnemyData)
	return {
		["EnemyId"] = EnemyData[1],
		["EnemyPosition"] = EnemyData[2],
		["EnemyRotation"] = EnemyData[3],
		["Node"] = EnemyData[4],
		["Speed"] = EnemyData[5],
		["Path"] = tableOfPaths[EnemyData[6]], -- obtained by just sending a number between 1-100
		["SyncTime"] = workspace.DistributedGameTime - EnemyData[7], -- will get to this later
		["Done"] = false, -- will get to this later
	}
end

The last thing I did was fix the bug where an enemy would break everything when it gets to the end. I am unsure as to why this bug occurred, but when I recoded a few things fixed it somehow. All I really did was add Enemy.Done like in the server-side code.

function HandleMovement()
	for i,Enemy in pairs(tableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy["EnemyId"]]
		
		if Enemy.Done then
			tableOfEnemies[i] = nil
			
			game.Debris:AddItem(clientTarget, 0)
		else
			clientTarget.CFrame = clientTarget.CFrame:ToWorldSpace(CFrame.new(0, 0, -0.1 * Enemy.Speed))
			
			local magnitude = (clientTarget.CFrame.Position - Enemy.Path[Enemy.Node + 1].Position).Magnitude
			
			if magnitude <= 1 then
				Enemy.Node += 1
				
				if Enemy.Node == #Enemy.Path then
					Enemy.Done = true
				else
					if Enemy.SyncTime then
						clientTarget.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position + (Enemy.Path[Enemy.Node + 1].Position * Enemy.SyncTime))
						Enemy.SyncTime = nil
					else
						clientTarget.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position)
					end
				end
			end
		end
	end 
end

Heres the entire code

Server Side

local SpawnedEnemy = game.ReplicatedStorage:WaitForChild("SpawnedEnemy")

--- 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

function RequestPath()
	return tableOfPaths
end

game.ReplicatedStorage.RequestPaths.OnServerInvoke = RequestPath

task.wait(5)
-- NPC Spawning Table Putting Function

local ServerTableOfEnemies = {}

local EnemyId = "1";

function SpawnNPC()
	local Precision = 10^2

	local EnemyInfo = {
		["EnemyId"] = EnemyId;
		["Node"] = "1";
		["Speed"] = 4;
		["Path"] = 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)


	local ClientEnemyInfo = {
		[1] = EnemyInfo.EnemyId;
		[2] = Vector3int16.new(EnemyInfo.CFrame.X * Precision, EnemyInfo.CFrame.Y * Precision, EnemyInfo.CFrame.Z * Precision);
		[3] = Vector2int16.new(0, 0);
		[4] = EnemyInfo.Node; -- Node
		[5] = 4; -- Speed
		[6] = EnemyInfo.Path; -- Path
		[7] = workspace.DistributedGameTime, -- Time in server, used to sync
	}

	SpawnedEnemy:FireAllClients(ClientEnemyInfo)
end

-- Actual Movement Handling
local ClientTableOfEnemies = {}

function HandleMovement()

	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 - tableOfPaths[Enemy.Path][Enemy.Node + 1].Position).Magnitude
			-- print(Enemy.Node)
			if (Enemy.CFrame.Position - tableOfPaths[Enemy.Path][Enemy.Node + 1].Position).Magnitude <= 1 then
				Enemy.Node += 1
				if Enemy.Node == #tableOfPaths[Enemy.Path] then
					Enemy.Done = true
				else
					Enemy.CFrame = CFrame.new(tableOfPaths[Enemy.Path][Enemy.Node].Position, tableOfPaths[Enemy.Path][Enemy.Node + 1].Position)
				end
			end

			local Precision = 10^2

			local EulerAnglesThingy = Vector3.new(Enemy.CFrame:ToEulerAnglesXYZ())

			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] = Vector2int16.new(0, EulerAnglesThingy.Y * Precision); -- Rotation
				[4] = Enemy.Node; -- Node
			}

			ClientTableOfEnemies[i] = ClientEnemyInfo
		end
	end 
end

-- Main Routine

local enemyCount = 50;

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.01)
	HandleMovement()
end

Client Side


--- Some client stuff
local Precision = 10^2

local tableOfPaths = game.ReplicatedStorage.RequestPaths:InvokeServer()
local tableOfEnemies = {}

function Decompile(EnemyData)
	return {
		["EnemyId"] = EnemyData[1],
		["EnemyPosition"] = EnemyData[2],
		["EnemyRotation"] = EnemyData[3],
		["Node"] = EnemyData[4],
		["Speed"] = EnemyData[5],
		["Path"] = tableOfPaths[EnemyData[6]],
		["SyncTime"] = workspace.DistributedGameTime - EnemyData[7],
		["Done"] = false,
	}
end

game.ReplicatedStorage.SpawnedEnemy.OnClientEvent:Connect(function(EnemyInfo)
	local ClientTableEnemyInfo = Decompile(EnemyInfo)
	local key = ClientTableEnemyInfo["EnemyId"]
	
	tableOfEnemies[key] = ClientTableEnemyInfo

	local Precision = 10^2

	local CFrameNoOrientation = CFrame.new(ClientTableEnemyInfo["EnemyPosition"].X / Precision, ClientTableEnemyInfo["EnemyPosition"].Y / Precision, ClientTableEnemyInfo["EnemyPosition"].Z / Precision)

	local CFrameWithOrientation = CFrameNoOrientation * CFrame.Angles(0, ClientTableEnemyInfo["EnemyRotation"].Y / Precision, 0)

	local cloneClientSide = game.ReplicatedStorage.ClientSide:Clone()
	cloneClientSide.Name = ClientTableEnemyInfo["EnemyId"]
	cloneClientSide.Parent = game.Workspace.Mobs
	cloneClientSide:PivotTo(CFrame.new(cloneClientSide.Position, CFrameWithOrientation.Position))
end)

function HandleMovement()
	for i,Enemy in pairs(tableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy["EnemyId"]]
		
		if Enemy.Done then
			tableOfEnemies[i] = nil
			
			game.Debris:AddItem(clientTarget, 0)
		else
			clientTarget.CFrame = clientTarget.CFrame:ToWorldSpace(CFrame.new(0, 0, -0.1 * Enemy.Speed))
			
			local magnitude = (clientTarget.CFrame.Position - Enemy.Path[Enemy.Node + 1].Position).Magnitude
			
			if magnitude <= 1 then
				Enemy.Node += 1
				
				if Enemy.Node == #Enemy.Path then
					Enemy.Done = true
				else
					if Enemy.SyncTime then
						clientTarget.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position + (Enemy.Path[Enemy.Node + 1].Position * Enemy.SyncTime))
						Enemy.SyncTime = nil
					else
						clientTarget.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position)
					end
				end
			end
		end
	end 
end

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

All you would need to add is a Remote function named RequestPaths

There are a few more things that can be changed to further optimize, but at this point, it’s more of a micro-optimization.

  1. If you have enemy variety, you can remove speed and put it in a module script that both sides can access, that will lower it a little more.
  2. The final thing I think you could remove is EnemyRotation, I’m unsure if you need that if the enemy movement can be done on the client.

Hopefully, this helps!

3 Likes

Awesome, I’ve tested the code and it looks pretty good.

I really like the way you did the path selection from the table by storing a number and then taking it out of the table using the specific number, instead of storing the entire path inside the table, that’s really nice.

Now there’s barely any network traffic even with 1000 units.

Although I do have a couple questions.

Why did you remove the SyncClients event? Is it unnecessary? What if something goes wrong with the client movement calculation? Shouldn’t we be syncing with the client every now and then?

For example, this is completely useless now in the server side script:

			local Precision = 10^2

			local EulerAnglesThingy = Vector3.new(Enemy.CFrame:ToEulerAnglesXYZ())

			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] = Vector2int16.new(0, EulerAnglesThingy.Y * Precision); -- Rotation
				[4] = Enemy.Node; -- Node
			}

			ClientTableOfEnemies[i] = ClientEnemyInfo

Should I remove it? Or should I still come up with some sync or is it unnecessary?

Edit: Also, yeah I decided to remove the orientation part from the table as it was completely unnecessary.

I don’t believe you need to sync clients every second.

It should be event based rather than polling every few second.

You can rely on other methods such as workspace:GetServerTimeNow() to account for the initial spawn offset as tower defence movement is pretty deterministic ( at x= 5 seconds the xyz position of enemy is at position Y). This is another optimization trick to reduce network lag.

Only if you add more complex mechanics such as enemy abilities or tower stunning will then you need to fire this event.

1 Like

After all the enemies spawn, it would try to sync every second, which I think was not the best case as you would send the client a ton of data at once (which if enough enemies, could cause lag spikes for some people). I think it would be better to send the time the server made the enemy, compare it to the player time, and add the extra position to the enemy spawn position, making it synced.

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.