How do I make a proper Optimized Entity System?

I’m currently making a tower defense game, and when i spawn in multiple enemies (100~ enemies) it lags the game, so i’m trying to achieve a more optimized spawning system, i currently have a simple system that uses pivotto and updating pos+ori values every frame.


i have this currently, i spawned over 500~ enemies

this is over 200~ enemies and counting

and i notice my recv is over 3 digits and the cpu ms being higher than usual, i want a finished product like this (https://media.discordapp.net/attachments/664903629546717218/933694377270845510/aw2_2.gif)

I am using attributes and values to update the enemy position and orientation every frame.
CLIENTSIDED SCRIPT

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local EnemyModels = ReplicatedStorage.Enemy
local EnemyAnims = ReplicatedStorage.Anims

local enemies = {}
local Enemies = workspace.Enemies

local onRenderStepped = function(dt)
	for index, enemy in ipairs(enemies) do
		if not enemy then
			table.remove(enemies,index)
			continue
		end
		
		local Value = enemy.Parent
		local Position = Value:GetAttribute("EnemyPosition")
		local Orientation = Value:GetAttribute("EnemyOrientation")
		
		local PrimaryPart = enemy.PrimaryPart
		PrimaryPart:PivotTo(CFrame.lookAt(Position, Orientation))
	end	
end

local function ChildAdded(child)
	local Name = child.Name
	local Model = EnemyModels[Name]:Clone()
	Model.Parent = child
	
	local Humanoid = Model.Humanoid
	Humanoid:LoadAnimation(EnemyAnims[Name]):Play()
	
	table.insert(enemies,Model)
end

RunService.RenderStepped:Connect(onRenderStepped)
Enemies.ChildAdded:Connect(ChildAdded)

SERVERSIDED SCRIPT

local runservice = game:GetService("RunService")
local rs = game:GetService("ReplicatedStorage")

local Vector3new = Vector3.new
local module = {}
local enemies = {}
runservice.Heartbeat:Connect(function() -- trying to make a heartbeat function that takes not much performance.
	for i,v in ipairs(enemies) do
		local speed = v:GetAttribute("Speed")
		local PW = v:GetAttribute("Pathway")
		local MovingTo = v:GetAttribute("MovingTo") -- these variables can be replaced with a table but instead im going for a created value within the enemymodels folder.

		local map = workspace.map
		local Pathway = map["Pathways"..PW]
		
		if MovingTo > map:GetAttribute("MaxMovingTo") then
			v:Destroy()
			table.remove(enemies,i)
			continue
		end
		
		if Pathway[MovingTo] then
			local lastNode = nil
			local currNode = Pathway[MovingTo]
			if MovingTo - 1 == 0 then
				lastNode = map["Start"..PW]
			else
				lastNode = Pathway[MovingTo - 1]
			end

			if currNode == nil then
				if v ~= nil then
					v:Destroy()
				end

				table.remove(enemies,i)
				continue
			end

			local timecalc = tick() - v:GetAttribute("tick")
			local magn = (currNode.Position - lastNode.Position).Magnitude
			local dt = timecalc * speed/magn

			v:SetAttribute("EnemyPosition", lastNode.Position:Lerp(currNode.Position, dt))
			v:SetAttribute("EnemyOrientation", currNode.Position)
			
			if dt >= 1 then
				v:SetAttribute("MovingTo", MovingTo + 1)
				v:SetAttribute("tick", tick())
			end
		else
			v:Destroy()
			table.remove(enemies,i)
		end
	end
end)

function module.CreateEnemy(Amount,Enemy)
	for i = 1,Amount do
		local Value = script[Enemy]:Clone()
		Value.Parent = workspace.Enemies
		Value:SetAttribute("tick", tick())
		Value:SetAttribute("Pathways",1)
		Value:SetAttribute("MovingTo",1)

		table.insert(enemies,Value)
		
		task.wait(0.25)		
	end
end

return module

please give me anything / any info that could increase performance greatly. I’d love seeing my recv be below 10 kb/s despite there being 600 enemies actively moving.

5 Likes

Workspace:BulkMoveTo(EntityList, CFrameList) might help you.

1 Like

how would I be able to use this? It needs both an instance table and a cframe table which would need me to change my entire code because i am individually moving each enemy using the ancestor of each model.

1 Like

The network usage is probably from the fact that you’re updating the attribute every frame, which then has to replicate to the client. I would probably just use the attributes to store the positions and replicate their positions every couple frames using a remote event.

Btw, this isn’t really necessary, as it’s a microoptimisation.

Edit:
Almost forgot, here’s a really good resource on optimising stuff like this, it’s not about tower defense games, but I think a lot of the lessons can be applied here.

1 Like

interesting, i took a look at it a few minutes earlier and gave me a headache, i’ll check later.

1 Like

came back, i re-read this and this is what caught my eye, how could i do this? i dont know how to manually replicate stuff to the client,

1 Like

Just send the position and orientation through remote events every .10 seconds or so, and have the client use the positions and orientations it receives to update its models.

1 Like

update to this, i have fully converted to using tables, i have 2 separate tables, one for enemy data (for damaging) and one for enemy position.

I went and compared my old version to my new version (tables only) by creating 600 enemies
Because i used tables and only sent the most important information (position, orientation) it decreased network usage from 300kb/s to 100~ kb/s, no instance being made, only passing position and orientation values to the client.

I also created over 1600 enemies, and the results were amazing.
network usage went from 600-1100kb/s to a stable 250~ kb/s.
BEFORE


AFTER

although theres a change, i’m far from a legit optimized entity system. what more can i do to optimize my entity system?

I also switched to BulkMoveTo, it greatly increased performance


7 Likes

if you haven’t already, you can set the material of the parts in the npcs to smoothplastic for a very tiny fps boost
not as good as modifying the scripts for the npcs but if you really want to have 1800 spawned at once, it is useful

i would like to keep no change in part’s properties, altho thx for the tip

You can also use Vector3int16s to send 16bit numbers instead of 64bit lowering your recv data even more.

Then just simply send the enemies data every 20 heartbeats to all clients

With this you should get around 25kb of recv data with 350 enemies.

The only disadvantage with this is that you can’t use decimals in position and the range is a bit low. But you should still try use it for less recv data.

1 Like

already finished with this project, i kept the humanoids because i wanted for accessories and clothing to still be used, it performscwell at 10kb/s at around 5k enemies, got too lazy so i never really used it, much of a hassle to import every single thing

If you want a smoother experience with humanoids you should also try disable all the unnecessary states from humanoid to prevent lag.

Also how can you get 10kb of recv data with 5000 enemies. I’ve tried many ways to make my recv data even lower. I think the lowest recv data with 5000 enemies is around 200kb of recv data which is pretty good for 5k enemies.

1 Like

i use numbers for my entity system, and I only send data necessary (not too much data) and i let client run its “own” movement system which most of the time is accurate

Are you sending data every 20 heartbeats to all clients?

I send data when it is necessary, not constantly.

So you could send data only if enemy has spawned not just sending every heartbeat?

Hey, sorry for the necrobump. How do you compile/add enemies to your positioning table? I have a problem where the client stops moving some enemies, so the enemies appear frozen.

Can u atleast show me the code so ik what ur dealing with

uhh okay, but its kinda big

EnemyClass

--!native
--!strict
--[[
      __   _  _                                   
  _____  __/ /_ | || |  ___                             
 / _ \ \/ / '_ \| || |_/ __|                            
|  __/>  <| (_) |__   _\__ \                            
 \___/_/\_\\___/   |_| |___/      _   _                 
 _ __  _ __ ___   __| |_   _  ___| |_(_) ___  _ __  ___ 
| '_ \| '__/ _ \ / _` | | | |/ __| __| |/ _ \| '_ \/ __|
| |_) | | | (_) | (_| | |_| | (__| |_| | (_) | | | \__ \
| .__/|_|  \___/ \__,_|\__,_|\___|\__|_|\___/|_| |_|___/
|_|                                                     


]]
--[=[
@class Enemy

Enemy Class. Updates the Translated Value of Enemies, 
and are in the .enemies in the enemyReplicator Script.
addEnemy on the enemyReplicator module creates these classes.

]=]
local Enemy = {}
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BezierPath = require(ReplicatedStorage.Packages:WaitForChild("BezierPath"))::any
local BridgeNet = require(ReplicatedStorage.Packages:WaitForChild("bridgenet2"))::any
local enemystats = require(ServerStorage:WaitForChild("EnemyStats"))
local bosshealthbar = ReplicatedStorage.AddHealthBar
local AbilityManager = require(ServerStorage.AbilityManager)
local Enemies = ReplicatedStorage.Enemies
local HashCreator = require(ServerStorage.HashCreator)
local deleteBridge = BridgeNet.ServerBridge("enemyDeletion")
local refresh_rate = 0.1
local deleteEvent = ReplicatedStorage.deletedCompletedEnemy
Enemy.__index = Enemy

export type enemy = {
	speed: number,
	health: number,
	hash: string,
	position: CFrame,
	translated: number,
	class: string,
	renderID: number,
	updateConnection: RBXScriptConnection,
}
--[=[
Creates an enemy
@param enemyName string --The class name of the enemy
@param path table --The path that the enemy follows
]=]
function Enemy.create(enemyName, path)
	ReplicatedStorage.DebugValues.EnemyCount.Value += 1
	local self = setmetatable({}, Enemy)
	self.position = CFrame.new(0, 0, 0)
	self.class = enemyName
	local enemyData = enemystats.get(enemyName)
	self.speed = enemyData.speed
	self.health = enemyData.health
	self.hash = HashCreator.retriveHash()
	local _, boundingBox = Enemies[enemyName]:GetBoundingBox()
	local sizeAddition = boundingBox.Y / 2
	local rotationalOffset
	local offset = math.random(-1, 1)
	if enemyData.modifiers["boss"] then
		bosshealthbar:FireAllClients(enemyData.name, self, self.health)
	end
	if enemyData.rotationOffset ~= nil then
		rotationalOffset = enemyData.rotationOffset
	else
		rotationalOffset = Vector3.zero
	end
	if enemyData["abilities"] then
		task.spawn(function()
			for ability, _ in pairs(enemyData["abilities"]) do
				AbilityManager.register("enemies", ability, self)
			end
		end)
	end
	local rotationalCFrame = CFrame.Angles(rotationalOffset.X, rotationalOffset.Y, rotationalOffset.Z)
	local offsetCFrame = CFrame.new(offset, sizeAddition, 0)
	local pathLength = path["path"]:GetPathLength()
	self.updateConnection = task.spawn(function()
		local PreviousTime = os.clock()
		self.translated = 0
		while self.translated < 1 do
			--task.desynchronize()
			self.translated += (os.clock() - PreviousTime) * self.speed / pathLength
			PreviousTime = os.clock()
			local transformedcframe: CFrame = path["path"]:CalculateUniformCFrame(self.translated)
				* offsetCFrame
				* rotationalCFrame
			self.position = transformedcframe
			task.wait(refresh_rate)
			--task.synchronize()
		end
		ReplicatedStorage.DebugValues.EnemyCount.Value -= 1
		deleteBridge:Fire(BridgeNet.AllPlayers(), self.hash)
		deleteEvent:Fire(self.hash)
		if self.health > 0 then
			ReplicatedStorage.BaseHealth.Value -= self.health
			self.health = 0
		end
	end)
	return self
end

--[=[
Damaging function. Unused function, just use enemyReplicator.enemyEffect(health)
@param damage number --damage that will be given to the enemy
]=]

function Enemy:damage(damage): nil
	local clampedDamage = math.clamp(self.health - damage, 0, 9999999999)
	self.health -= clampedDamage
	if self.health <= 0 then
		self.health = 0
	end
	return nil
end

return Enemy

EnemyReplicator.lua

--!native
--[[
      __   _  _                                   
  _____  __/ /_ | || |  ___                             
 / _ \ \/ / '_ \| || |_/ __|                            
|  __/>  <| (_) |__   _\__ \                            
 \___/_/\_\\___/   |_| |___/      _   _                 
 _ __  _ __ ___   __| |_   _  ___| |_(_) ___  _ __  ___ 
| '_ \| '__/ _ \ / _` | | | |/ __| __| |/ _ \| '_ \/ __|
| |_) | | | (_) | (_| | |_| | (__| |_| | (_) | | | \__ \
| .__/|_|  \___/ \__,_|\__,_|\___|\__|_|\___/|_| |_|___/
|_|                                                     
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local replicator = {}
replicator.enemies = {}
replicator.pathTable = {}
replicator.enemyCount = 0
local BezierPath = require(ReplicatedStorage.Packages:WaitForChild("BezierPath"))::any
local BridgeNet = require(ReplicatedStorage.Packages:WaitForChild("bridgenet2"))::any
local enemyclass = require(ServerStorage:WaitForChild("EnemyClass"))
local packetBridge = BridgeNet.ServerBridge("enemyReplication")
--packetBridge.Logging = true
local createBridge = BridgeNet.ServerBridge("enemyCreation")
local deleteBridge = BridgeNet.ServerBridge("enemyDeletion")
local deleteEvent = ReplicatedStorage.deletedCompletedEnemy

--[=[ 
@class replicator

This handles enemy Replication, sends packets to the client, so the client can render and position
All the functions in this module handle enemies, and do things like damage or heal effects.
This module also handles creating enemies.

]=]

--[=[
@function setUp
This function sets up the Replicator, which connects BindableEvent
to deleteEnemies, and reset the EnemyCount.

@within replicator

]=]
replicator.setUp = function(_)
	deleteEvent.Event:Connect(function(hash)
		replicator.enemies[hash] = nil
		replicator.enemyCount -= 1
	end)
end

--[=[
@function createnavpath
This function creates an path with the BezierPath Module.
This function is called for each path in the map.

@param nodefolder Folder
@param index number

@within replicator
]=]

replicator.createnavpath = function(nodefolder, index)
	local nodePositions = { nodefolder.enemyspawn.Position }
	for i = 1, #nodefolder:GetChildren() - 2 do
		table.insert(nodePositions, nodefolder[i].Position)
	end

	table.insert(nodePositions, nodefolder.exit.Position)
	local BezPath = BezierPath.new(nodePositions, 3)
	local pathtable = {
		["path"] = BezPath,
		["nodefolder"] = nodefolder,
	}
	replicator.pathTable[index] = pathtable
end

--[=[
@function getEnemyCount
Retrives the amount of enemies active

@within replicator
]=]
replicator.getEnemyCount = function()
	return replicator.enemyCount
end

--[=[
@function getTargetingData

Compiles an table with all the enemies, 
with condensed info like the index of translation, the position, the health, and the translated index.

@within replicator
]=]
replicator.getTargetingData = function()
	task.desynchronize()
	local enemyData = {}
	for hash, enemy: enemyclass.enemy in pairs(replicator.enemies) do
		table.insert(enemyData, { hash, enemy.position, enemy.health, enemy.translated })
	end
	task.synchronize()
	return enemyData
end

--[=[
@function enemyEffect

Changes an property in the enemy with the hash applied.
If the effect is health, than it checks if the health is under 0, and if it is, deletes the enemy.
TODO give cash to everyone based on enemy

@param hash string
@param effect any
@param new any

@within replicator
]=]
replicator.enemyEffect = function(hash, effect, new)
	local enemy = replicator.enemies[hash]
	if enemy then
		enemy[effect] = new
		if effect == "health" then
			if enemy["health"] <= 0 then
				deleteBridge:Fire(BridgeNet.AllPlayers(), hash)
				replicator.enemyCount -= 1
				replicator.enemies[hash] = nil
			end
		end
	end
end

--[=[
@function getSingleEnemy

SelfExplanatory, retrives an single enemy with the hash provided

@param enemyHash string

@within replicator
]=]
replicator.getSingleEnemy = function(enemyHash)
	local enemy = replicator.enemies[enemyHash]
	return enemy
end

--[=[
@function update

Compiles all the enemies into one condensed table, with the CFrame. Than, using bridgeNet2, sends
the infomation to the client, so it can be rendered on the client.

@within replicator
]=]
local enemyData = {}
replicator.update = function()
	for hash: string, enemy: enemyclass.enemy in pairs(replicator.enemies) do
		enemyData[hash] = enemy.position
		--print("hash: "..tostring(hash).." ".."position: "..tostring(enemy.position))
	end
	packetBridge:Fire(BridgeNet.AllPlayers(), enemyData)
end

--[=[
@function addEnemy

Creates an enemy, and adds it to the replicator table in the module. Than, 
fires an bridge to the client to create an enemy, and increases the enemyCount.

@param enemyName string
@param pathNum number

@within replicator
]=]

replicator.addEnemy = function(enemyName, pathNum)
	local eClass = enemyclass.create(enemyName, replicator.pathTable[pathNum])
	replicator.enemyCount += 1
	local hash = eClass["hash"]
	replicator.enemies[hash] = eClass
	task.wait()
	createBridge:Fire(BridgeNet.AllPlayers(), { eClass["hash"], eClass["class"] })
end

return replicator

render.client.lua

--!native
local Players = game:GetService("Players")
local player = Players.LocalPlayer
repeat
	task.wait()
until player:WaitForChild("loaded").Value == true
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Map = ReplicatedStorage:WaitForChild("Map")
local Enemies = ReplicatedStorage:WaitForChild("Enemies")
local loadedEnemies = {}
local Workspace = game:GetService("Workspace")
local MapObject = Workspace:WaitForChild(Map.Value)
local BridgeNet2 = require(ReplicatedStorage.Packages.bridgenet2)
local enemyPacketBridge = BridgeNet2.ReferenceBridge("enemyReplication")
local enemyCreationBridge = BridgeNet2.ReferenceBridge("enemyCreation")
local enemyDeletionBridge = BridgeNet2.ReferenceBridge("enemyDeletion")
local RunService = game:GetService("RunService")
local cframeData = {}
local function createEnemy(content)
	local hash, name = content[1], content[2]
	local enemyModel = Enemies:FindFirstChild(name):Clone()
	enemyModel.Name = hash
	enemyModel:SetAttribute("class", name)
	enemyModel.Parent = MapObject.enemies
	loadedEnemies[hash] = enemyModel.PrimaryPart
	local animation = enemyModel.Animations.walk
	local track = enemyModel.AnimationController.Animator:LoadAnimation(animation)
	track:Play()
end

local function deleteEnemy(content)
	local enemy = loadedEnemies[content]
	if enemy then
		enemy.Parent:Destroy()
		loadedEnemies[content] = nil
	end
end

local function updatePosition(delta)
	local Parts = {}
	local CFrames = {}
	local index = 1
	for hash, cframe in pairs(cframeData) do
		local enemyPrimary = loadedEnemies[hash]
		if enemyPrimary then
			local alpha = math.clamp(delta / 0.1, 0, 1)
			Parts[index] = enemyPrimary
			CFrames[index] = enemyPrimary.CFrame:Lerp(cframe, alpha)
			index += 1
		end
	end
	task.synchronize()
	Workspace:BulkMoveTo(Parts, CFrames, Enum.BulkMoveMode.FireCFrameChanged)
end

local function updateData(content)
	cframeData = content
end
enemyCreationBridge:Connect(createEnemy)
enemyPacketBridge:Connect(updateData)
enemyDeletionBridge:Connect(deleteEnemy)
RunService.Heartbeat:ConnectParallel(updatePosition)

replicator.update() is called every 0.1 seconds

1 Like