Help with the client-server optimization

  1. What do you want to achieve? Keep it simple and clear!
    Optimize enemies to have 500+ of them spawning and dying constantly.

  2. What is the issue? Include screenshots / videos if possible!
    RECV skyrockets (~ 21 kb/s) with my system and eventually bugs out the game

  3. What solutions have you tried so far? Did you look for solutions on the Creator Hub?
    I tried removing humanoids, added render distance, removed parts from server (using folders and attributes now), tried implementing octree but gave up, didn’t understand the logic of how to use it

Here’s the system:

Server
local rs = game:GetService("ReplicatedStorage")
local plrs = game:GetService("Players")
local sss = game:GetService("ServerScriptService")
local ss = game:GetService("ServerStorage")
local http = game:GetService("HttpService")
local runs = game:GetService("RunService")
local DEBUG = false

local assets = rs.Assets
local es = require(ss.EnemyStats)
local oc = require(sss.Modules.OrbController)
local octree = require(rs.Libs.Octree)
local packets = require(rs.Libs.Packets)
local enemyContainer = workspace.Enemies

local playerSpawners = {}
local idsInUse = {}
local enemies = {}

math.randomseed(tick())
local grid = octree.new(Vector3.new(0, 0, 0), 1024)
local maxEnemies = 1000
local minDistance = 25
local maxDistance = 25
local enemyCount = 0

local UPDATE_INTERVAL = 1
local CLIENT_UPDATE_RADIUS = 75 
local frameCount = 0

local die = 3

local EnemyManager = {}

local function calculateRandomSpawnPosition(playerPos)
	local randomAngle = math.rad(math.random(0, 360))
	local randomDistance = math.random(minDistance, maxDistance)

	return playerPos + Vector3.new(
		math.cos(randomAngle) * randomDistance,
		-2,
		math.sin(randomAngle) * randomDistance
	)
end

local function debugServerPosition(enemyId, pos)
	local part = workspace:FindFirstChild("Debug_Server_"..enemyId)
	if not part then
		part = Instance.new("Part")
		part.Anchored = true
		part.CanCollide = false
		part.Transparency = 0.5
		part.Size = Vector3.new(1,1,1)
		part.Color = Color3.fromRGB(255, 0, 0)
		part.Name = "Debug_Server_"..enemyId
		part.Parent = workspace
	end
	part.Position = pos
end

local function getNearbyPlayers(enemyPos)
	return grid:QueryRegion(enemyPos, CLIENT_UPDATE_RADIUS)
end


local function updatePlayerSpawners(player)
	if player.Character and player.Character.PrimaryPart then
		playerSpawners[player.UserId] = player.Character.PrimaryPart.Position
	end
end

plrs.PlayerAdded:Connect(function(player)
	local charConnection
	charConnection = player.CharacterAdded:Connect(function()
		updatePlayerSpawners(player)
	end)

	player.AncestryChanged:Connect(function()
		if not player.Parent then
			charConnection:Disconnect()
			playerSpawners[player.UserId] = nil
		end
	end)
end)

plrs.PlayerRemoving:Connect(function(player)
	playerSpawners[player.UserId] = nil
end)

function EnemyManager.SpawnEnemy(target)
	if not target:IsA("Player") then return end
	if enemyCount >= maxEnemies then return end

	updatePlayerSpawners(target)
	local playerPos = playerSpawners[target.UserId]
	if not playerPos then return end

	local spawnPos = calculateRandomSpawnPosition(playerPos)
	--print("[SpawnEnemy] Spawning at:", spawnPos)

	local rand = math.random()
	local selectedTypeId = nil
	for i, typeData in es do
		if rand <= typeData.CumulativeChance then
			selectedTypeId = i
			break
		end
	end
	if not selectedTypeId then return end

	local enemyStats = es[selectedTypeId]
	local enemyId
	repeat
		enemyId = math.random(0, 65535)
	until not idsInUse[enemyId]
	idsInUse[enemyId] = true

	enemies[enemyId] = {
		Name = "Enemy_"..enemyId,
		Type = selectedTypeId,
		Position = spawnPos,
		Health = enemyStats.HP,
		TargetUserId = target.UserId,
		LastAttackTime = 0,
	}
	
	local enemyFolder = Instance.new("Folder")
	enemyFolder.Name = "Enemy_"..enemyId
	enemyFolder:SetAttribute("TargetPos", spawnPos)
	enemyFolder:SetAttribute("Type", selectedTypeId)
	enemyFolder:SetAttribute("Health", enemyStats.HP)
	enemyFolder.Parent = enemyContainer
	
	grid:Insert(target, spawnPos)
	enemyCount += 1
	
	task.delay(die, function()
		EnemyManager.DamageEnemy(enemyId, enemies[enemyId].Health)
	end)	
end

function EnemyManager.Attack(enemyId, player)
	local enemy = enemies[enemyId]
	if not enemy or not player then return end

	local character = player.Character
	if not character or not character.PrimaryPart then return end

	local enemyStats = es[enemy.Type]
	local now = os.clock()
	
	--print("attack logic")
end

function EnemyManager.MoveEnemy(enemyId, dt)
	local enemy = enemies[enemyId]
	if not enemy then return end

	local player = plrs:GetPlayerByUserId(enemy.TargetUserId)
	if not player or not player.Character or not player.Character.PrimaryPart then return end

	local playerPos = player.Character.PrimaryPart.Position
	local currentPos = enemy.Position
	local dist = (playerPos - currentPos).Magnitude
	local enemyStats = es[enemy.Type]

	local inRange = dist <= enemyStats.Range

	if inRange then
		EnemyManager.Attack(enemyId, player)
	else
		local direction = (playerPos - currentPos).Unit
		local newPos = currentPos + Vector3.new(direction.X, 0, direction.Z) * enemyStats.Speed * dt
		newPos = Vector3.new(newPos.X, 1, newPos.Z)
		enemy.Position = newPos
		
		local enemyFolder = enemyContainer:FindFirstChild("Enemy_"..enemyId)
		if enemyFolder then
			enemyFolder:SetAttribute("TargetPos", newPos)
		end

		if DEBUG then debugServerPosition(enemyId, newPos) end
	end
end

function EnemyManager.DamageEnemy(enemyId, amount)
	local enemy = enemies[enemyId]
	if not enemy or not amount then return end

	enemy.Health -= amount
	-- get the damager from the weapon system

	if enemy.Health <= 0 then
		enemy.Health = 0
		EnemyManager.KillEnemy(enemyId)
	end
end

function EnemyManager.KillEnemy(enemyId)
	local enemy = enemies[enemyId]
	if not enemy then return end

	local enemyFolder = enemyContainer:FindFirstChild("Enemy_"..enemyId)
	if enemyFolder then
		enemyFolder:SetAttribute("Dead", true)
	end

	local enemyTypeName = es[enemy.Type].Name
	oc.SpawnOrb(enemy.Position, "random", plrs:GetPlayerByUserId(enemy.TargetUserId), enemyTypeName)

	grid:Remove(enemyId)
	enemies[enemyId] = nil
	enemyCount -= 1
	task.delay(2, function()
		idsInUse[enemyId] = nil
	end)
end

runs.Heartbeat:Connect(function(dt)
	for enemyId, _ in pairs(enemies) do
		EnemyManager.MoveEnemy(enemyId, dt)
	end
end)

return EnemyManager
Client
local rs = game:GetService("ReplicatedStorage")
local ts = game:GetService("TweenService")
local runs = game:GetService("RunService")
local DEBUG = false

local enemyAssets = require(rs.Modules.EnemyAssets)
local enemyStats = require(rs.Modules.GetEnemyStats)

local damageGuiTemplate = rs.Assets:WaitForChild("DamageGui")
local enemyContainer = workspace:WaitForChild("Enemies")

local enemies = {}

local EnemyClient = {}

local function playAnimation(enemyId, animName, animId, force)
	local enemyData = enemies[enemyId]
	if not enemyData then return end

	local humanoid = enemyData.Model:FindFirstChildOfClass("Humanoid")
	if not humanoid then return end

	if enemyData.CurrentAnimName ~= animName or force then
		if enemyData.CurrentTrack then
			enemyData.CurrentTrack:Stop()
		end

		local anim = enemyData.Animations[animName]
		if not anim then
			local animObj = Instance.new("Animation")
			animObj.AnimationId = animId
			enemyData.Animations[animName] = humanoid:LoadAnimation(animObj)
		end

		enemyData.CurrentTrack = enemyData.Animations[animName]
		enemyData.CurrentAnimName = animName
		enemyData.CurrentTrack:Play()
	end
end

local function debugClientPosition(enemyId, model)
	local part = workspace:FindFirstChild("Debug_Client_"..enemyId)
	if not part then
		part = Instance.new("Part")
		part.Anchored = true
		part.CanCollide = false
		part.Transparency = 0.5
		part.Size = Vector3.new(1,1,1)
		part.Color = Color3.fromRGB(0, 0, 255)
		part.Name = "Debug_Client_"..enemyId
		part.Parent = workspace
	end

	runs.RenderStepped:Connect(function()
		if model and model.PrimaryPart then
			part.Position = model.PrimaryPart.Position
		end
	end)
end

local function handleEnemyFolder(folder:Folder)
	local id = tonumber(folder.Name:match("Enemy_(%d+)"))
	if not id then return end
	if enemies[id] then return end

	local enemyType = folder:GetAttribute("Type")
	if not enemyType then return end

	local enemyTemplate = enemyAssets[enemyType]
	if not enemyTemplate then warn("Missing enemy asset for type:", enemyType) return end

	local model = enemyTemplate.Model:Clone()
	model:PivotTo(CFrame.new(folder:GetAttribute("TargetPos")))
	model.Name = folder.Name
	model:SetAttribute("Type", enemyType)
	model.Parent = enemyContainer

	for _, part in model:GetDescendants() do
		if part:IsA("BasePart") then
			part.CollisionGroup = "Enemies"
		end
	end

	enemies[id] = {
		Model = model,
		State = "Idle",
		Animations = {},
		CurrentAnimName = nil,
		CurrentTrack = nil
	}

	if DEBUG then debugClientPosition(id, model) end
	
	folder:GetAttributeChangedSignal("TargetPos"):Connect(function()
		local pos = folder:GetAttribute("TargetPos")
		if not pos then return end

		local data = enemies[id]
		if data and data.Model then
			data.Model.Humanoid:MoveTo(pos)
			if data.State ~= "Walking" then
				playAnimation(id, "Walk", enemyTemplate.Walk)
				data.State = "Walking"
			end
		end
	end)

	folder:GetAttributeChangedSignal("Health"):Connect(function()
		local hp = folder:GetAttribute("Health")
		if hp <= 0 then
			EnemyClient.EnemyDied(id)
		else
			EnemyClient.EnemyDamaged(id, 1) -- damage
		end
	end)

	folder:GetAttributeChangedSignal("Attacking"):Connect(function()
		if folder:GetAttribute("Attacking") == true then
			EnemyClient.EnemyAttacking(id)
		end
	end)
	
	folder:GetAttributeChangedSignal("Dead"):Connect(function()
		EnemyClient.EnemyDied(id)
	end)

	model:FindFirstChildOfClass("Humanoid").MoveToFinished:Connect(function(reached)
		if reached and enemies[id] and enemies[id].State ~= "Attacking" then
			playAnimation(id, "Idle", enemyTemplate.Idle)
			enemies[id].State = "Idle"
		end
	end)
end

function EnemyClient.EnemyDamaged(enemyId, damage)
	local enemyModel = enemies[enemyId] and enemies[enemyId].Model
	if not enemyModel then return end

	local head = enemyModel:FindFirstChild("Head")
	if not head then return end

	local damageGui = damageGuiTemplate:Clone()
	damageGui.Parent = head
	damageGui.Adornee = head
	damageGui.AlwaysOnTop = true
	damageGui.Size = UDim2.new(5, 0, 5, 0)

	local offset = Vector3.new(math.random(-1, 1), 1.2 + math.random(), math.random(-1, 1))
	damageGui.StudsOffset = offset

	local textLabel = damageGui:FindFirstChild("TextLabel")
	if not textLabel then return end

	textLabel.Text = "-" .. tostring(damage)
	textLabel.TextTransparency = 0
	textLabel.TextScaled = true
	textLabel.BackgroundTransparency = 1
	textLabel.Font = Enum.Font.GothamBlack
	textLabel.TextColor3 = Color3.fromRGB(236, 62, 62)
	textLabel.AnchorPoint = Vector2.new(0.5, 0.5)
	textLabel.Position = UDim2.new(0.5, 0, 0.5, 0)
	textLabel.Size = UDim2.new(0.25, 0, 0.25, 0)

	local uiStroke = textLabel:FindFirstChildOfClass("UIStroke")
	if uiStroke then uiStroke.Thickness = 0.5 uiStroke.Transparency = 0 end

	local popTween = ts:Create(textLabel, TweenInfo.new(0.15), {Size = UDim2.new(1, 0, 1, 0)})
	popTween:Play()

	local guiUpTween = ts:Create(damageGui, TweenInfo.new(0.45), {
		StudsOffset = damageGui.StudsOffset + Vector3.new(0, 1.2, 0)
	})
	local textFadeTween = ts:Create(textLabel, TweenInfo.new(0.45), {TextTransparency = 1})

	popTween.Completed:Connect(function()
		guiUpTween:Play()
		textFadeTween:Play()
	end)

	guiUpTween.Completed:Connect(function()
		if damageGui and damageGui.Parent then
			damageGui:Destroy()
		end
	end)
end

function EnemyClient.EnemyAttacking(enemyId)
	local data = enemies[enemyId]
	if not data then return end
	local enemyType = data.Model:GetAttribute("Type")
	data.State = "Attacking"
	playAnimation(enemyId, "Attack", enemyAssets[enemyType].Attack, true)

	task.delay(0.75, function()
		if enemies[enemyId] and enemies[enemyId].State == "Attacking" then
			playAnimation(enemyId, "Idle", enemyAssets[enemyType].Idle)
			enemies[enemyId].State = "Idle"
		end
	end)
end

function EnemyClient.EnemyDied(enemyId)
	local data = enemies[enemyId]
	if not data then return end
	local model = data.Model

	if data.CurrentTrack then
		data.CurrentTrack:Stop()
	end

	for _, part in model:GetDescendants() do
		if part:IsA("BasePart") or part:IsA("Decal") then
			local tween = ts:Create(part, TweenInfo.new(1), {Transparency = 1})
			tween:Play()
		end
	end

	task.delay(1, function()
		model:Destroy()
	end)

	enemies[enemyId] = nil
end

for _, folder in enemyContainer:GetChildren() do
	handleEnemyFolder(folder)
end

enemyContainer.ChildAdded:Connect(function(folder)
	task.wait()
	handleEnemyFolder(folder)
end)

return EnemyClient
Spawner script (Located in ServerScriptService)
local plrs = game:GetService("Players")

local em = require(script.Parent.Modules.EnemyManager)


task.wait(1.5)
while true do
	for _, target in plrs:GetChildren() do
		if not target then break end

		em.SpawnEnemy(target)
		task.wait(.25)
	end
end

My guess is that constantly removing/adding folders lags out the server, but I was not able to make only client-side implementation and I can’t quite understand how hit detection works in those systems, would appreciate if anyone could help me

1 Like

Forgot to mention, I’ll use this in the future, for now I just want the way to not have the folders/models/parts on the server

:(((

chararasrsrsrsrsrs

1 Like

I dont think it is possible to do rendering wise even.
It is possible using impostors or editable mesh like CloneTrooper did:https://www.youtube.com/watch?v=Yi6tiy-2nIg
I would recomend you to use impostors tho as since rendering actual humanoids is way too expensive to handle.

I also dont think it is possible to do network wise so you have to make it client only or make insanely low level like buffer serialization.

1 Like

Hold data on server, enemy on client, interpolate it’s move points. (server: a - c, client: a - b- c)
DOD (Data Oriented Design) is perfect for this to you.

1 Like

Interesting approach, could you elaborate? I was not able to fully handle enemies on client

i think what you’re trying to achieve is very difficult,
all of the logic is handled in one script, which is going to get difficult to maintain in the future

why are you calling a move function every frame? this is very bad practice (especially when you have bazillion enemies)

call them at intervals, like 0.075 (75ms), via while task.wait do

i think you’re getting something wrong here

the parent of Player will always be players, Character is what you’re trying to use i think, since characters are inserted into workspace, and not into players

1 Like

Thanks for the tips, any ideas about this?

Cool system! I don’t have a direct solution to your problem but Atrazine has a post about their experience with bandwidth optimization, it’s a cool read.
They did a lot of cool stuff with bit packing, but it was much more complicated back then, nowadays I’d use buffers.

1 Like

Thanks, I’ve seen that post but couldn’t really implement an octree as they did. My system relies on the player and only on the player, so NPCs are kinda attached to them ;D Besides an octree, I’ll apply bandwith thing soon

ur mistaken, its much much easier to handle enemy logic with DOD rather than OOP, since DOD is just seperating data and logic

simply ask chatgpt on this specific topic and mention DOD and server having only data.
yes ik im not that helpful here but chatgpt might explain it better

2 Likes

but DOD system on client would be like this

local cachedModels = {}
local chachedPositions = {}

local function system(dt)
   for enemyID in next, data.enemies do
      local position = data.position[enemyID]
      local model = cachedModels[enemyID]
      
      if not position or not model then continue end
      
      if cachedPositions[enemyID] == position then continue end --// don't repeat same position update
      cachedPositions[enemyID] = position
      model:PivotTo(position)
   end
end

Heartbeat:Connect(system)

server basically creates enemies and attaches components (data) to them, replicates updates to desired clients, either in batch or no (recommended in batches if sending large data).

and client just runs systems on heartbeat, dont forget serializing data

1 Like

Thank you for explaining, this will help me a lot!

thread died :pensive:

asdsaasdaasdsad