How Can I optimize my entity system?

Hey, I’m Deathbandss and I’m making a tower defense game called Galactic Reckoning and I’ve made an entity system that can handle 500+ enemies before fps drop


image
I want to know if theres any way I can make it so that I can optimize my enemies to handle more than just 500-600.
image
and also if theres a way for me to fix these gaps.
If you have any advice,tips,or suggestions let me know.
(also I wont be using humanoids at all for this so please dont suggest walkto or pathfinding service!)

Server Sided Script:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Nodes = workspace.BaseplateMap.Nodes
local Modules = ReplicatedStorage.Modules
local Events = ReplicatedStorage.Event

local EnemyInfo = require(Modules.EnemyInfo)

local Percision = 10^2
local counter = 0

local StartTime = workspace:GetServerTimeNow()
local TickRate = 1/20
local AccumlatorDelta = 0

local enemy = {}
local currentenemies = {}
function enemy.Spawn(name)
	counter += 1
	local EnemyTable = {
		["Name"] = name;
		["ID"] = counter;
		["ModelID"] = EnemyInfo[name].ModelID;
		["EnemySpawnTime"] = tick(),
		["CFrame"] = CFrame.new(workspace.BaseplateMap.Start.Position,workspace.BaseplateMap.Nodes[1].Position);
		["Node"] = 1;
		["Speed"] = EnemyInfo[name].Speed;
		["Health"] = EnemyInfo[name].Health;
		["MaxHealth"] = EnemyInfo[name].MaxHealth;
		["Buffs"] = EnemyInfo[name].Buffs
	}
	currentenemies[counter] = EnemyTable
	local ClientTable = {
		["CodedVector2"] = Vector2int16.new(EnemyTable.ModelID,EnemyTable.Node)
	}
	Events.SpawnEnemy:FireAllClients(ClientTable)
	coroutine.wrap(ServerSidedMovement)(counter)
end

function ServerSidedMovement(EnemyID)
	local Connection
	local EnemyTable = currentenemies[EnemyID]
	if EnemyTable == nil then return end
	local Start = workspace.BaseplateMap.Start
	for index,value in ipairs(Nodes:GetChildren()) do
		local ReachedWaypoint = false
		local NextWaypoint = Nodes:FindFirstChild(tostring(index))
		local CurrentWaypoint = Nodes:FindFirstChild(tostring(index - 1))
		if CurrentWaypoint == nil then
			CurrentWaypoint = Start	
		end
		local timehappened = 0
		local function MoveEnemy(DeltaTime)
			if CurrentWaypoint ~= Nodes:FindFirstChild(tostring(#Nodes:GetChildren())) and ReachedWaypoint == false then
				timehappened += DeltaTime
				local Speed = EnemyTable.Speed
				local distance = (CurrentWaypoint.Position - NextWaypoint.Position).Magnitude
				local Time = distance/Speed
				local alpha = (timehappened/Time) % 1
				EnemyTable.CFrame = CurrentWaypoint.CFrame:Lerp(NextWaypoint.CFrame,alpha)
				EnemyTable.CFrame = CFrame.new(EnemyTable.CFrame.Position,NextWaypoint.Position)
				if alpha >= 0.989 then
					ReachedWaypoint = true
					Connection:Disconnect()
				end
			end
			task.wait(0.024)
		end
		Connection = RunService.Heartbeat:Connect(MoveEnemy)
		repeat task.wait(0.2) until ReachedWaypoint
	end
	Connection:Disconnect()
	Events.EnemyDie:FireAllClients(EnemyID,EnemyTable.Health,true)
end

return enemy

Client Sided:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local EnemiesFolder = workspace.Enemy
local RunService = game:GetService("RunService")
local Percision = 10^2
local players = game:GetService("Players")
local player = players.LocalPlayer
local GUI = player.PlayerGui
local LastUpdate = workspace:GetServerTimeNow()
local Nodes = workspace.BaseplateMap.Nodes
local Enemies = ReplicatedStorage.Enemies
local Events = ReplicatedStorage.Event
local counter = 0
--[[function quadBezier(t, p0, p1, p2)
	return (1 - t)^2 * p0 + 2 * (1 - t) * t * p1 + t^2 * p2
end]]
function ClientEnemyMovement(Enemy,EnemySpawnTime)
	local Start = workspace.BaseplateMap.Start
	local Connection
	if Enemy == nil or Enemy.PrimaryPart == nil then return end
	local EnemySpawnTime = EnemySpawnTime
	for index,value in ipairs(Nodes:GetChildren()) do
		local ReachedWaypoint = false
		local NextWaypoint = Nodes:FindFirstChild(tostring(index))
		local CurrentWaypoint = Nodes:FindFirstChild(tostring(index - 1))
		if CurrentWaypoint == nil then
			CurrentWaypoint = Start	
		end
		local timehappened = 0
		local function MoveEnemy(DeltaTime)
			if CurrentWaypoint ~= Nodes:FindFirstChild(tostring(#Nodes:GetChildren())) and ReachedWaypoint == false then
				timehappened += DeltaTime
				local Speed = Enemy:GetAttribute("Speed")
				local distance = (CurrentWaypoint.Position - NextWaypoint.Position).Magnitude
				local Time = distance/Speed
				local alpha = (timehappened/Time) % 1
				if Enemy.PrimaryPart == nil then
					Connection:Disconnect()
				end
				Enemy.PrimaryPart.CFrame = CurrentWaypoint.CFrame:Lerp(NextWaypoint.CFrame,alpha)
				Enemy.PrimaryPart.CFrame = CFrame.new(Enemy.PrimaryPart.Position,NextWaypoint.Position)
				if alpha >= 0.989 then
					ReachedWaypoint = true
					Connection:Disconnect()
				end
			end
			task.wait(0.024)
		end
		Connection = RunService.Heartbeat:Connect(MoveEnemy)
		repeat task.wait(0.2) until ReachedWaypoint
	end
	Connection:Disconnect()
end

Events.SpawnEnemy.OnClientEvent:Connect(function(ClientTable)
	counter += 1
	if not EnemiesFolder:FindFirstChild(counter) then
		for i,v in pairs(Enemies:GetChildren()) do
			if v:GetAttribute("ModelID") == ClientTable.CodedVector2.X then
				local lilbuddy = Enemies[tostring(v)]:Clone()
				for i, v in pairs(v:GetChildren()) do 
					if v:IsA("BasePart") or v:IsA("MeshPart") then 
						v.CollisionGroup = "Mobs"
					end
				end
				lilbuddy:SetAttribute("EnemyID",counter)	
				lilbuddy.Parent = EnemiesFolder
				ClientEnemyMovement(lilbuddy)
			end
		end
	end
end)

1 Like

Not sure about optimization, but IMO you don’t need 500-600 enemy slots, the most you’ll probably ever need is like 100-150.

2 Likes

for my game i’m gonna need them 100%

1 Like

I found out it happen because of the teleport (after they reach the part it teleport them)
you should edit it

1 Like

how do you think i would be able to fix this?

1 Like
  1. Instead of using so many loops, try using 1 loop for all them.
  2. use signals.
  3. find a better way to change their position
1 Like

Signal module:

local TestService = game:GetService("TestService")

local SignalStatic = {}
SignalStatic.__index = SignalStatic
SignalStatic.__type = "Signal"
local ConnectionStatic = {}
ConnectionStatic.__index = ConnectionStatic
ConnectionStatic.__type = "SignalConnection"

export type Signal = {
	Name: string,
	Connections: {[number]: Connection},
	YieldingThreads: {[number]: BindableEvent}
}

export type Connection = {
	Signal: Signal?,
	Delegate: any,
	Index: number	
}

local ERR_NOT_INSTANCE = "Cannot statically invoke method '%s' - It is an instance method. Call it on an instance of this class created via %s"

function SignalStatic.new(signalName: string): Signal
	local signalObj: Signal = {
		Name = signalName,
		Connections = {},
		YieldingThreads = {}
	}
	return setmetatable(signalObj, SignalStatic)
end

local function NewConnection(sig: Signal, func: any): Connection 
	local connectionObj: Connection = {
		Signal = sig,
		Delegate = func,
		Index = -1
	}
	return setmetatable(connectionObj, ConnectionStatic)
end

local function ThreadAndReportError(delegate: any, args: GenericTable, handlerName: string)
	local thread = coroutine.create(function ()
		delegate(unpack(args))
	end)
	local success, msg = coroutine.resume(thread)
	if not success then 
		TestService:Error(string.format("Exception thrown in your %s event handler: %s", handlerName, msg))
		TestService:Checkpoint(debug.traceback(thread))
	end
end

function SignalStatic:Connect(func)
	assert(getmetatable(self) == SignalStatic, ERR_NOT_INSTANCE:format("Connect", "Signal.new()"))
	local connection = NewConnection(self, func)
	connection.Index = #self.Connections + 1
	table.insert(self.Connections, connection.Index, connection)
	return connection
end

function SignalStatic:Fire(...)
	assert(getmetatable(self) == SignalStatic, ERR_NOT_INSTANCE:format("Fire", "Signal.new()"))
	local args = table.pack(...)
	local allCons = self.Connections
	local yieldingThreads = self.YieldingThreads
	for index = 1, #allCons do
		local connection = allCons[index]
		if connection.Delegate ~= nil then
			ThreadAndReportError(connection.Delegate, args, connection.Signal.Name)
		end
	end
	for index = 1, #yieldingThreads do
		local thread = yieldingThreads[index]
		if thread ~= nil then
			coroutine.resume(thread, ...)
		end
	end
end

function SignalStatic:FireSync(...)
	assert(getmetatable(self) == SignalStatic, ERR_NOT_INSTANCE:format("FireSync", "Signal.new()"))
	local args = table.pack(...)
	local allCons = self.Connections
	local yieldingThreads = self.YieldingThreads
	for index = 1, #allCons do
		local connection = allCons[index]
		if connection.Delegate ~= nil then
			connection.Delegate(unpack(args))
		end
	end
	for index = 1, #yieldingThreads do
		local thread = yieldingThreads[index]
		if thread ~= nil then
			coroutine.resume(thread, ...)
		end
	end
end

function SignalStatic:Wait()
	assert(getmetatable(self) == SignalStatic, ERR_NOT_INSTANCE:format("Wait", "Signal.new()"))
	local args = {}
	local thread = coroutine.running()
	table.insert(self.YieldingThreads, thread)
	args = { coroutine.yield() }
	table.removeObject(self.YieldingThreads, thread)
	return unpack(args)
end

function SignalStatic:Dispose()
	assert(getmetatable(self) == SignalStatic, ERR_NOT_INSTANCE:format("Dispose", "Signal.new()"))
	local allCons = self.Connections
	for index = 1, #allCons do
		allCons[index]:Disconnect()
	end
	self.Connections = {}
	setmetatable(self, nil)
end

function ConnectionStatic:Disconnect()
	assert(getmetatable(self) == ConnectionStatic, ERR_NOT_INSTANCE:format("Disconnect", "private function NewConnection()"))
	table.remove(self.Signal.Connections, self.Index)
	self.SignalStatic = nil
	self.Delegate = nil
	self.YieldingThreads = {}
	self.Index = -1
	setmetatable(self, nil)
end

return SignalStatic
2 Likes

I’m wondering why did you do a client sided movement while they already move in server side ?

2 Likes

server side run “fake” enemies so the server won’t lag out (client is running everything)

2 Likes

Oh alright, so the enemies count gap and fps drop entirely depends of the client device power.

2 Likes

theres got to be a way to make the enemies go smoother regardless of device power, tds and tbz do a good job at that.

1 Like

wdym so many loops i only use a loop for the nodes. what do you think a better way to change the pos would be?

i also dont understand what a signal/signal module even is this kinda makes 0 sense

1 Like

I’m not talking about the client scripts, but how much objects are moving into the workspace.
Optimized client scripts don’t do that much change on fps, so of course it is moving smoother, but more there is moving objects (client or server), more fps will drop.

1 Like

so do you think theres a way to be able to not use as much memory? or do you think it’s impossible to make it that way.

1 Like

Additional memory mostly depend of textures, how detailed your map is, how much tris your 3D models have, how much objects are moving, how much parts collision there are, how much humanoid there are ect…

1 Like

I will provide some optimizations in a lil while after analyzing the code but before that, have you considered using smth like matter? It is a modern ECS library and seems perfect for your use case. According to the benchmarks, the module maintained an avg of 0.65 ms per frame with:

  • World with 1000 entities
  • Between 2-30 components on each entity
  • 300 unique component types
  • 200 systems
  • Each system queries between 1 and 10 components
1 Like

personally I make a serverheart module for every game I create. I use this module to run code in a heartbeat and I think it could solve your problem by looping through every enemy and calculating the next position with delta time time between frames and setting the position in the heartbeat.

1 Like