How do I lower my recv? Td game enemy system

So im trying to make a tower defense game, and everything has been going great until I stumbled upon some critical recv problems. I personally don’t know anything about client side rendering for td games so I’m just handling everything on the server. Meaning I spawn, move and render enemies on the server. The problem with this is that I nuke my recv and with like 10 enemies it’s already at 80 providing a rather laggy experience.

Here is my current module code that handles enemies (Im using a handy module to calculate movement, it’s really optimized so im only at like 40-50 ping. That’s not the issue.):
(Color is something specific and not important)
(Script is a module script in server script service)

function spawnEnemyModule.createEnemy(enemy, amount, succesion, color, modifier) -- append waypoints to position table

	
	--// Enemy Data
	local health = enemyListModule[enemy].Health
	local speed = enemyListModule[enemy].Speed
	local height = enemyListModule[enemy].height
	
	
	for i = 1, amount do -- for loop to cicle through amount
		
		local newEnemy = enemyListModule[enemy].model:Clone() -- create new enemy model
		
		newEnemy:SetAttribute("color", color) -- set attribute for color
		newEnemy:SetAttribute("maxHealth", health) -- set attribute for maxHealth
		newEnemy:SetAttribute("health", health) -- set attribute for health
		
		newEnemy:GetAttributeChangedSignal("health"):Connect(function()
			if newEnemy:GetAttribute("health") <= 0 then
				newEnemy:Destroy()
			end
		end)
		
		
		local colorAbleParts = newEnemy:FindFirstChild("ColorableParts")
		if colorAbleParts then
			
			if #colorAbleParts:GetChildren() ~= 0 then -- color stuff
				
				if color == "grey" then
					for i, v in colorAbleParts:GetChildren() do
						v.BrickColor = BrickColor.new("Medium stone grey")
					end
				elseif color == "red" then
					for i, v in colorAbleParts:GetChildren() do
						v.BrickColor = BrickColor.new("Bright red")
					end
				elseif color == "blue" then
					for i, v in colorAbleParts:GetChildren() do
						v.BrickColor = BrickColor.new("Bright blue")
					end
				end
				
			end
			
		else
			warn("no colorable parts")
			return
		end
		
		task.wait(succesion) -- wait succesion before new enemy
		
		newEnemy.Parent = currentEnemiesStorage	
		animateEvent:FireAllClients("walk", newEnemy) -- animateEvent fire

		local NewPath = bezierPath.new(Positions,3) -- create new path for moduel to use

		local startTime = tick()
		local totalTime = NewPath:GetPathLength() / speed

		local movementLoop 

		task.spawn(function() -- function to make enemies move
			movementLoop = runService.Heartbeat:Connect(function()
				local t = (tick() - startTime) / (totalTime)
				
				if newEnemy.PrimaryPart then
					newEnemy:SetPrimaryPartCFrame(NewPath:CalculateUniformCFrame(t) + Vector3.new(0,height,0)) 
				end

				if t >= 1 then -- remove enemy and loop once last waypoint is reached
					newEnemy:Destroy()
					movementLoop:Disconnect()
				end

			end)
		end)

	end
	
end

How can I improve recv the best? Hopefully that gave enough info. Thanks

4 Likes

Client side rendering varies from game to game, however the base principle would be to only store the important variables on the server (i.e. health and position of your enemies), then the server tells the client where to render the enemies and effects.

3 Likes

How would you go about it? So you would use a remote event to pass over stuff like health, speed and position (why position?) to the client local script. Which then creates the enemy and moves it?
Or am I getting anything wrong there?

2 Likes

You got it right, that is how you would do it.

1 Like

Thank you. But I didn’t quite understand why we send over the position of the enemy to the client? Isn’t it just 0,0,0 bc the enemy gets moved and created on the client? Or do I also ever send a remote back to server in someway?

1 Like

You should have a dictionary somewhere in ReplicatedStorage that dictates all the information about the enemies (e.g. health, speed, ID). Then, when you want to spawn an enemy or group of enemies, you only need to send the ID.

The client checks with the dictionary to determine what type of enemy they need to spawn, and then they create the specified enemy at the spawn and move it themselves since they know their speed. You could also send numbers that describe how many enemies to spawn, the delay between each spawn, or even some flags that describe a variant or special property (you would need to use the bit32 library to pack these into a number and read them). I recommend writing all of this information into a buffer to seriously minimize the network traffic.

The buffer structure could look like this:
u8 - 1 byte: enemy ID (256 unique enemy types)

Optional:
u8 - 1 byte: enemy properties (8 properties possible)
u8 - 1 byte: number to spawn (256 maximum)
u8 - 1 byte: delay between each spawn (0.1 seconds minimum and 25.6 seconds maximum, assuming your delays are multiples of 0.1; all you need to do is multiply the delay by 10 to pack and divide by 10 to unpack. If you want something more precise like 0.05, use a u16 instead)

When rendering and moving the enemies on the client, refrain from using Humanoids as they consume a lot of resources.

Also, each enemy spawned should have a unique ID that is synchronized between all clients and the server that the server can use to signal to clients that a specific event is happening to that specific enemy (e.g. a speed boost, that it died, or that it’s beginning to use an attack). You can implement this by incrementing a number by the amount of enemies that need to be spawned whenever you receive a remote event request to spawn an enemy. You don’t have to worry about desynchronization because remote events are reliable and ordered, so they will always be received in the same order you fire them.

Position is not necessary to send unless you are using multiple spawn points, and if you are, you don’t have to send a full Vector3 for that either. Create another dictionary that contains the IDs for different spawn points, and send the ID.

5 Likes

Thanks for the nice explanation, but some questions so I can be sure my smol brain understands everything: I can just not use ids, just names right? (Idk I don’t think it will make recv explode too much). Also do I send a remote every time an enemy reaches the end since the enemy only exists on the client? (It only is a thing on the client right?).

2 Likes

Yes. Enemies should only physically exist on the client and be represented as pure data on the server.

The amount of information being sent will scale based on the length of the strings. It would be best if you used a number ID instead. For example, “Powerful Zombie” is 15 characters and will take 17 bytes to send (1 byte per character and 2 bytes overhead); if you use a number ID in a buffer, you would only send 1 byte.

If you keep all the enemies’ information in a module script, the IDs would be easy to add and manage.

In fact, you probably don’t even need to do any of this if you create a module script that details the wave structure and enemy spawns. The server would just have to alert the client every time a certain wave event happens, and the client could spawn all the enemies under that wave event.

Yes, you can do that.

4 Likes

Hey sorry for asking again. I thought about what you said and It won’t quite get into my head how I “store” the enemies on the server as pure data. Well I could put all enemies into a table (so when the first enemy spawns it would get the value 1 and when the second spawns it will get 2) but I’m not sure how to keep track of what number stands for what enemy. Also if I spawn 10k enemies in a game the table will at the end have very very high numbers in it right?

2 Likes

Use a table (not an array because when an enemy dies you want to clear it so your game doesn’t leak memory) in which the keys are the enemy IDs and the values are the enemy objects. Then getting an enemy by their ID is as simple as local enemy = enemies[id].

10000 is still safely within the bounds of an unsigned 16-bit integer range (0-65535), so you really don’t have to worry about the numbers being too large. If you’re really going to be spawning a ridiculous number of enemies, have the server reset the ID counter back to 0 after reaching a certain threshold (when we’re at enemy number 50000, we can safely assume that enemies 1-25000 are long dead). Make sure the client is informed of the reset before telling them to spawn any more enemies.

1 Like

Oh thats handy. Sorry for another question (I’m not really that experienced with bytes and data compression.) How would I turn a number / string lets say my enemy ID (2) into a 16-bit string so I can buffer less value possible?

1 Like

This is a great way to get that experience then.

If you want to write the number “2” into a buffer as a 16-bit integer, then use buffer.writeu16. There’s no need to convert it to a string.

Here’s an example:

-- Send
local enemyId = 2

local stream = buffer.create(2) -- Whatever size you need
buffer.writeu16(stream, 0, enemyId) -- First 2 bytes of buffer store the number "2"

-- Receive
local enemyId = buffer.readu16(stream, 0) -- Read first 2 bytes of buffer -> enemyId = 2
1 Like

What size do you mean? I know it now stores / buffers the number 2 but what does it do?

Also in my example my id is a string (“1”), so I just use tonumber(enemyId) right?

1 Like

That’s what I did. I think its wrong bc it returns nil lol

local stream = buffer.create(2)
	
	local enemyID = buffer.writeu16(stream, 0, tonumber(enemy))
	print(enemyID)

The number argument in buffer.create is the capacity in bytes of the buffer; you cannot change this amount after creating it. It’s how much data you want to store and transfer. In the example, since I’m only writing a 16-bit integer, I only need 2 bytes of memory.

If you use the example in my first post, since I want to store 4 8-bit integers (1 byte = 8 bits), I am going to have to create a buffer of size 4.

Read the documentations here and here (there aren’t many video tutorials because buffers are very new).

Yes.

That is because the write functions don’t return anything. If you want to get the enemyId back, you’ll have to use the corresponding read function.

2 Likes

Hey thanks. Just for better understanding, so the read function reads the first buffer over it? Bc I can’t see you defining what buffer its supposed to read and it just reads out the enemyId buffer

The read function reads the buffer you pass in as the first argument.

buffer.readu8(stream, 0) gets the 8-bit integer from the first byte of the buffer called stream. buffer.readu16(stream, 1) gets the 16-bit integer starting at the second byte of the buffer called stream.

Does that mean I have to create a buffer for every aspect I’m sending, so like this?

local idStream = 2
local amountStream = 2
local successionStream = 2

print(buffer.readu16(idStream, 0))
print(buffer.readu16(amountStream, 0))

e.g.

?

You can write all of this into one buffer.

Yes, but wouldn’t I have to create a “stream” for each value? (In your example “b” I think)