Trying to handle entities on client instead of server

Hi, I’m trying to get 600+ active enemies/mobs without SENT and RECV skyrocketing… Here’s the script:

local ss = game:GetService("ServerStorage")
local rs = game:GetService("ReplicatedStorage")
local plrs = game:GetService("Players")
local ps = game:GetService("PhysicsService")
local sss = game:GetService("ServerScriptService")

local assets = rs:WaitForChild("Assets")

local eAI = require(sss.Modules.EnemyAI)
local oc = require(sss.Modules.OrbController)
local es = require(ss.EnemyStats)
local packets = require(rs:WaitForChild("Libs").Packets)

local enemyContainer = workspace:WaitForChild("Enemies")

local playerSpawners = {}
local enemyConnections = {}
local enemyCount = 0
local minDistance = 20
local maxDistance = 20 
local maxEnemies = 1000

local die = 1.5 -- remove for prod

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,
		1,
		math.sin(randomAngle) * randomDistance
	)
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 spawnpoint = calculateRandomSpawnPosition(playerPos)

	local rand = math.random()
	local selectedType = nil
	for _, typeData in es.Types do
		if rand <= typeData.Chance then
			selectedType = typeData.Name
			break
		end
	end
	--print(rand, es.Types, selectedType)

	local enemyStats = es[selectedType]
	local enemy = assets[selectedType]:Clone()

	enemy:PivotTo(CFrame.new(spawnpoint + Vector3.new(0, 2, 0)))
	enemy:SetAttribute("IsDead", false)
	enemy:SetAttribute("DeathTime", 0)
	enemy:SetAttribute("Type", selectedType)
	enemy.Parent = enemyContainer

	local enemyHum = enemy:WaitForChild("Humanoid")
	enemyHum.BreakJointsOnDeath = false
	enemyHum.MaxHealth = enemyStats.HP
	enemyHum.Health = enemyStats.HP
	enemyHum.WalkSpeed = enemyStats.Speed

	enemyCount += 1
	eAI.StartAI(enemy, target)

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

	local lastHealth = enemyHum.Health

	enemyConnections[enemy] = {}
	local healthChangedConn = enemyHum.HealthChanged:Connect(function(newHealth)
		if newHealth < lastHealth then
			local damage = lastHealth - newHealth

			packets.enemyDamaged:Fire(enemy, damage)
		end
		lastHealth = newHealth
	end)

	table.insert(enemyConnections[enemy], healthChangedConn)

	-- remove for prod
	task.delay(die, function()
		if enemyHum and enemyHum.Health > 0 then
			enemyHum.Health = 0
		end
	end)

	local diedConnection
	diedConnection = enemyHum.Died:Connect(function()
		diedConnection:Disconnect()
		enemyCount -= 1

		if enemyConnections[enemy] then
			for _, conn in enemyConnections[enemy] do
				if typeof(conn) == "RBXScriptConnection" then
					conn:Disconnect()
				end
			end
			enemyConnections[enemy] = nil
		end

		enemy:SetAttribute("IsDead", true)
		enemy:SetAttribute("DeathTime", tick())

		packets.enemyDied:Fire(enemy)

		eAI.StopAI(enemy)
		oc.SpawnOrb(enemy.PrimaryPart.Position, "random", target, enemy:GetAttribute("Type"))
	end)
end

task.spawn(function()
	while true do
		task.wait(.5)
		for _, enemy in enemyContainer:GetChildren() do
			if enemy:GetAttribute("IsDead") and tick() - enemy:GetAttribute("DeathTime") > 1.75 then
				enemy:Destroy()
			end
		end
	end
end)

return EnemyManager
local runs = game:GetService("RunService")
local sss = game:GetService("ServerScriptService")
local ss = game:GetService("ServerStorage")
local plrs = game:GetService("Players")

local pc = require(sss.Modules:WaitForChild("PlayerController"))
local es = require(ss:WaitForChild("EnemyStats"))
local EnemyAI = {}

local activeAIs = {}

function EnemyAI.StartAI(enemy, initialTarget)
	local enemyHumanoid = enemy:WaitForChild("Humanoid")
	local enemyType = enemy:GetAttribute("Type")
	local enemyStats = es[enemyType]
	if not enemyStats then return end

	if activeAIs[enemy] then
		EnemyAI.StopAI(enemy)
	end

	local lastUpdate = 0
	local lastAttack = 0
	local UPDATE_INTERVAL = 0.1

	local function getNearestPlayer(enemyPos)
		local closestPlayer, minDist = nil, enemyStats.Range
		for _, player in plrs:GetPlayers() do
			local char = player.Character
			if char and char.PrimaryPart and char.Humanoid and char.Humanoid.Health > 0 then
				local dist = (char.PrimaryPart.Position - enemyPos).Magnitude
				if dist <= minDist then
					closestPlayer, minDist = player, dist
				end
			end
		end
		return closestPlayer
	end

	local heartbeatConnection
	heartbeatConnection = runs.Heartbeat:Connect(function(dt)
		lastUpdate = lastUpdate + dt
		if lastUpdate < UPDATE_INTERVAL then return end
		lastUpdate = 0

		if not enemy or not enemy.Parent or enemyHumanoid.Health <= 0 then
			EnemyAI.StopAI(enemy)
			return
		end

		local enemyPos = enemy.PrimaryPart and enemy.PrimaryPart.Position
		if not enemyPos then return end

		local target = getNearestPlayer(enemyPos)
		if not target or not target.Character or not target.Character.PrimaryPart then
			EnemyAI.StopAI(enemy)
			return
		end

		local targetHumanoid = target.Character.Humanoid
		local targetPos = target.Character.PrimaryPart.Position
		if targetHumanoid.Health <= 0 then
			EnemyAI.StopAI(enemy)
			return
		end

		local distance = (targetPos - enemyPos).Magnitude
		local currentTime = tick()

		if distance <= enemyStats.Range and currentTime - lastAttack >= enemyStats.AttackSpeed then
			local playerController = pc.Controllers[target]
			if playerController and targetHumanoid.Health > 0 then
				playerController:TakeDamage(enemyStats.Damage)
				lastAttack = currentTime
			end
		elseif distance > enemyStats.Range then
			enemyHumanoid:MoveTo(targetPos)
		end
	end)

	local ancestryConnection
	ancestryConnection = enemy.AncestryChanged:Connect(function()
		if not enemy:IsDescendantOf(game) then
			EnemyAI.StopAI(enemy)
		end
	end)

	activeAIs[enemy] = {
		Heartbeat = heartbeatConnection,
		Ancestry = ancestryConnection
	}
end

function EnemyAI.StopAI(enemy)
	local connections = activeAIs[enemy]
	if not connections then return end

	if connections.Heartbeat and connections.Heartbeat.Connected then
		connections.Heartbeat:Disconnect()
	end
	if connections.Ancestry and connections.Ancestry.Connected then
		connections.Ancestry:Disconnect()
	end

	activeAIs[enemy] = nil
end

return EnemyAI
local ts = game:GetService("TweenService")
local rs = game:GetService("ReplicatedStorage")

local EnemyClient = {}

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

function EnemyClient.EnemyDied(enemy)
	if not enemy or not enemy.Parent or not enemy:GetAttribute("IsDead") then return end

	task.delay(.75, function()
		local hasParts = false
		for _, v in enemy:GetDescendants() do
			if v:IsA("BasePart") then
				hasParts = true
				local tweenInfo = TweenInfo.new(.75, Enum.EasingStyle.Sine, Enum.EasingDirection.In)
				local sinkPos = v.Position + Vector3.new(0, -2, 0)
				local sinkTween = ts:Create(v, tweenInfo, {Position = sinkPos})
				sinkTween:Play()

				sinkTween.Completed:Connect(function()
					enemy:Destroy()
				end)
			end
		end
	end)
end

function EnemyClient.EnemyDamaged(enemy, damage)
	if not enemy or not enemy.Parent then return end

	local head = enemy: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 offsetX = math.random(-10, 10) / 10
	local offsetY = math.random(-10, 10) / 10
	local offsetZ = math.random(-10, 10) / 10
	damageGui.StudsOffset = Vector3.new(offsetX, 1.2 + offsetY, offsetZ)

	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(202, 53, 53)
	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 popInfo = TweenInfo.new(
		0.15,
		Enum.EasingStyle.Quint,
		Enum.EasingDirection.Out
	)
	local popTween = ts:Create(textLabel, popInfo, {
		Size = UDim2.new(1, 0, 1, 0),
		Rotation = math.random(-12.5, 12.5)
	})
	local strokePopTween = uiStroke and ts:Create(uiStroke, popInfo, {
		Thickness = 1.5
	})
	popTween:Play()
	if strokePopTween then strokePopTween:Play() end

	local settleInfo = TweenInfo.new(
		0.4,
		Enum.EasingStyle.Quad,
		Enum.EasingDirection.In
	)
	local settleTween = ts:Create(textLabel, settleInfo, {
		Size = UDim2.new(0.1, 0, 0.1, 0),
		Rotation = 0
	})
	local strokeSettleTween = uiStroke and ts:Create(uiStroke, settleInfo, {
		Thickness = 1
	})

	local driftInfo = TweenInfo.new(
		0.45,
		Enum.EasingStyle.Linear
	)
	local guiUpTween = ts:Create(damageGui, driftInfo, {
		StudsOffset = damageGui.StudsOffset + Vector3.new(0, 1.2, 0)
	})
	local textFadeTween = ts:Create(textLabel, driftInfo, {
		TextTransparency = 1
	})
	local strokeFadeTween = uiStroke and ts:Create(uiStroke, driftInfo, {
		Transparency = 1
	})

	popTween.Completed:Connect(function()
		settleTween:Play()
		if strokeSettleTween then strokeSettleTween:Play() end
		guiUpTween:Play()
		textFadeTween:Play()
		if strokeFadeTween then strokeFadeTween:Play() end
	end)

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

return EnemyClient

As you can see, I tried compressing the data sent between client and server, it kinda helped but still not enough, RECV is at 9-10 KB/s rn and SENT is at 6-7 KB/s, with ONE player on the server.

How can I handle enemies on the client side while not exposing it to exploiters and having sanity checks?

1 Like

Could you provide the units for RECV/SENT?

KB/s, edited the post as well
aaaaaaaaaaaaaaaaaaaaaaaaa

That seems pretty good for over 600 entities, what data are you replicating exactly?

no, those network stats are for about 100-125 mobs max
basically the main problem is that im sending too much packets to the client and vice versa, so thats why i wanted to render enemies on the client instead of the server, but i have no clue how can i do that without exposing it to the client. like how can i handle movement on the client and etc

Well anything you do on the client is exposed to exploiters, can’t really do a whole lot about that.

How are you replicating data, and how often are you doing it?

every frame, sending a packet with enemy id + target pos

Try reducing it to every few frames, and interpolating the results on the client (default physics replication actually does this).

If your entities are far apart, you can possibly get away with cutting down on replication rate even further or stop replication of particular entities entirely.

1 Like

will try that, thanks

aaaaaaaaaaaaaaaa

the main issue is, the SERVER still replicate even you removed it on the CLIENT.

To fix it, You should spawn a NPC on the workspace.CurrentCamera of the SERVER to stop it from being replicated.

Source:

The next part is kinda complex
After that, you could replicate using Packet send some simple data like compressed CFrame. Make the client to update their own NPC.

Even better if you send Vector3, you could even calculate rotation on the client, using the previous position to the new position.

Lerping will make the movement smoother.

The result will make the RECV less and no SENT from being use.
Now you still can make the NPCs calculations, movement, etc … on the server without harming the clients as same as exploit-proof.

You should test it in game because the Server are more powerful than the Server on studio. Since studio server using your PC I think.

2 Likes