Too many parts lags the game

Alright so I’m the process of making a wave game and what I see is that there’s so many monsters in the studio and the fact there’s so many monsters it causes the game to lag so what I found is that from my research. There are simple tricks you can do the make the game less laggy and optimize

So instead of putting folder some people in the forum recommended using models did that and I still have some lags problem what I did is basically when my monster would clone in the workspace, it would contain each 2 scripts 1 for the Health, and one for pathfinding and rushing to character. So I tried making a whole modulescript that would be responsable for all the slimes that would act as if each slime has the 2 scripts but centralized in one modulescript that could be simply called by 1 ServerScript

Here’s the code of ModuleScript of SlimeController :

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local MiniSplitSlime = ReplicatedStorage.Enemies.MiniSplitSlime


local SlimeController = {}
local EnemiesFolder = nil
local activeSlimes = {}

local spawnRadius = 50
local jumpInterval = 1.5
local jumpDistance = 3
local separationDistance = 10
local tweenInfo = TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut)

local healthUpdateEvent = ReplicatedStorage:WaitForChild("HealthUpdateEvent")
local crystalFolder = ReplicatedStorage:WaitForChild("Crystals")
local crystalModel = crystalFolder:FindFirstChild("Tier 1")
local crystalMeshPart = crystalModel:FindFirstChild("Common Crystal Tier 1")

function SlimeController:CreateHealthBar(enemy, humanoid, rootPart)
	local healthBarGui = Instance.new("BillboardGui")
	healthBarGui.Size = UDim2.new(0, 100, 0, 10) 
	healthBarGui.Adornee = rootPart
	healthBarGui.StudsOffset = Vector3.new(0, 5, 0)
	healthBarGui.Parent = enemy

	local container = Instance.new("Frame")
	container.Size = UDim2.new(1, 0, 1, 0)
	container.BackgroundColor3 = Color3.fromRGB(50, 50, 50)
	container.BorderSizePixel = 0
	container.Parent = healthBarGui

	local redBar = Instance.new("Frame")
	redBar.Size = UDim2.new(1, 0, 1, 0)
	redBar.BackgroundColor3 = Color3.fromRGB(255, 0, 0)
	redBar.BorderSizePixel = 0
	redBar.Parent = container

	local healthBar = Instance.new("Frame")
	healthBar.Size = UDim2.new(1, 0, 1, 0)
	healthBar.BackgroundColor3 = Color3.fromRGB(0, 255, 0)
	healthBar.BorderSizePixel = 0
	healthBar.Parent = container

	return healthBar
end

function SlimeController:HandleDamage(player, enemy, damageAmount, containPoison)
	local slimeData = activeSlimes[enemy]
	if not slimeData then return end

	local humanoid = slimeData.Humanoid
	local healthBar = slimeData.HealthBar

	if humanoid.Health <= 0 then
		local cloneMeshPart = crystalMeshPart:Clone()
		cloneMeshPart.Position = slimeData.HRP.Position
		cloneMeshPart.Anchored = true
		cloneMeshPart.CanCollide = false
		cloneMeshPart.Parent = workspace.Crystals

		local originalPosition = cloneMeshPart.Position
		local crystalTween = TweenService:Create(cloneMeshPart, TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true), {
			Position = originalPosition + Vector3.new(0, 2, 0)
		})
		crystalTween:Play()

		cloneMeshPart.Touched:Connect(function(hit)
			local character = hit.Parent
			local touchingPlayer = Players:GetPlayerFromCharacter(character)
			if touchingPlayer then
				touchingPlayer:WaitForChild("leaderstats"):WaitForChild("ExpPlayer").Value += touchingPlayer:WaitForChild("ExpGainPlayer").Value
				crystalTween:Cancel()
				cloneMeshPart:Destroy()
			end
		end)

		player:WaitForChild("EnemiesKilled").Value += 1
		player:WaitForChild("TotalEnemiesKilled").Value += 1

		enemy:Destroy()
		activeSlimes[enemy] = nil

		if string.find(enemy.Name, "SplitSlime") and not string.find(enemy.Name, "MiniSlime") then
			for i = 1, 2 do
				local cloneSlime = MiniSplitSlime:Clone()
				cloneSlime.Name = enemy.Name .. "_MiniSlime" .. i
				cloneSlime.Parent = workspace.Enemies

				local rootPart = cloneSlime:FindFirstChild("HumanoidRootPart")
				local playerCharacter = player.Character
				local playerRoot = playerCharacter and playerCharacter:FindFirstChild("HumanoidRootPart")

				if rootPart and playerRoot then
					local offset = Vector3.new(
						math.random(5, 10),
						0,
						math.random(5, 10)
					)

					cloneSlime:SetPrimaryPartCFrame(playerRoot.CFrame * CFrame.new(offset))

					local size = rootPart.Size
					rootPart.Size = Vector3.new(size.X, size.Y + 4, size.Z)

				else
					warn("rootPart or playerRoot missing")
				end

			end
		end

		return
	else
		local healthPercentage = math.clamp(humanoid.Health / humanoid.MaxHealth, 0, 1)
		healthBar.Size = UDim2.new(healthPercentage, 0, 1, 0)
	end



end

function SlimeController:RegisterSlimes(monsterName: string)
	EnemiesFolder = workspace:WaitForChild("Enemies")

	for _, descendant in ipairs(EnemiesFolder:GetDescendants()) do
		if descendant:IsA("Model") and descendant.Name:lower():find(monsterName) then
			local hrp = descendant:FindFirstChild("HumanoidRootPart")
			local humanoid = descendant:FindFirstChildOfClass("Humanoid")
			if hrp and humanoid and not activeSlimes[descendant] then
				local healthBar = self:CreateHealthBar(descendant, humanoid, hrp)

				activeSlimes[descendant] = {
					Model = descendant,
					HRP = hrp,
					Humanoid = humanoid,
					HealthBar = healthBar,
					LastJump = 0,
					UniqueOffset = math.random()
				}

				local bg = Instance.new("BodyGyro")
				bg.MaxTorque = Vector3.new(1e5, 1e5, 1e5)
				bg.D = 1000
				bg.P = 10000
				bg.CFrame = hrp.CFrame
				bg.Parent = hrp
				activeSlimes[descendant].Gyro = bg
			end
		end
	end
end

function SlimeController:GetClosestPlayer()
	local closest = nil
	local minDistance = math.huge

	for _, player in ipairs(Players:GetPlayers()) do
		if player.Character and player.Character:FindFirstChild("HumanoidRootPart") then
			local distance = (player.Character.HumanoidRootPart.Position - Vector3.new()).Magnitude
			if distance < minDistance then
				minDistance = distance
				closest = player
			end
		end
	end

	return closest
end

function SlimeController:UpdateAll(deltaTime)
	EnemiesFolder = workspace:WaitForChild("Enemies")
	local player = self:GetClosestPlayer()
	if not player or not player.Character then return end
	local playerPos = player.Character.HumanoidRootPart.Position

	for _, slimeData in pairs(activeSlimes) do
		local slime = slimeData.Model
		local hrp = slimeData.HRP
		local now = tick()

		if now - slimeData.LastJump >= jumpInterval then
			slimeData.LastJump = now

			local angle = now * 0.2 + slimeData.UniqueOffset
			local offset = Vector3.new(math.cos(angle), 0, math.sin(angle)) * 2
			local targetPos = playerPos + offset

			local avoidance = Vector3.zero
			for _, other in ipairs(EnemiesFolder:GetDescendants()) do
				if other ~= slime and other:FindFirstChild("HumanoidRootPart") then
					local dist = (hrp.Position - other.HumanoidRootPart.Position).Magnitude
					if dist < separationDistance then
						local dir = (hrp.Position - other.HumanoidRootPart.Position).Unit
						avoidance += dir * ((separationDistance - dist) / separationDistance)
					end
				end
			end

			local finalDir = (targetPos - hrp.Position).Unit + avoidance
			finalDir = finalDir.Unit
			local jumpVector = finalDir * jumpDistance
			local destination = hrp.Position + jumpVector

			local rayParams = RaycastParams.new()
			rayParams.FilterDescendantsInstances = {slime}
			rayParams.FilterType = Enum.RaycastFilterType.Blacklist

			if not workspace:Raycast(hrp.Position, jumpVector, rayParams) then
				local tween = TweenService:Create(hrp, tweenInfo, {CFrame = CFrame.new(destination)})
				tween:Play()

				if slimeData.Gyro then
					slimeData.Gyro.CFrame = CFrame.new(hrp.Position, hrp.Position + finalDir)
				end
			end
		end
	end
end

RunService.Heartbeat:Connect(function(dt)
	SlimeController:UpdateAll(dt)
end)

healthUpdateEvent.OnServerEvent:Connect(function(player, damageAmount, enemyTouched, containPoison)	
	local slimeData = activeSlimes[enemyTouched]
	if not slimeData then return end

	local humanoid = slimeData.Humanoid
	local healthBar = slimeData.HealthBar

	if humanoid and humanoid.Health > 0 then
		humanoid.Health = math.max(humanoid.Health - damageAmount, 0)
		SlimeController:HandleDamage(player, enemyTouched, damageAmount, containPoison)

		if containPoison then
			local poisonDuration = 5 
			local poisonDamage = 1
			local poisonInterval = 1 


			coroutine.wrap(function()
				for i = 1, poisonDuration do
					if humanoid.Health <= 0 then break end
					task.wait(poisonInterval)
					humanoid.Health = math.max(humanoid.Health - poisonDamage, 0)

					local healthPercentage = math.clamp(humanoid.Health / humanoid.MaxHealth, 0, 1)
					healthBar.Size = UDim2.new(healthPercentage, 0, 1, 0)

					SlimeController:HandleDamage(player, enemyTouched, damageAmount, containPoison)

				end
			end)()
		end
	end
end)

return SlimeController

Here’s the code of ServerScript SlimeMonsters :

local SlimeController = require(script.Parent.Controller.SlimeController)
local Players = game:GetService("Players")

Players.PlayerAdded:Connect(function(player)
	player:WaitForChild("IsPlaying").Changed:Connect(function(value)
		if value == true then
			workspace:WaitForChild("Enemies").DescendantAdded:Connect(function(descendant)
				if descendant:IsA("Model") and descendant.Name:lower():find("slime") then

					task.wait(1) 
					SlimeController:RegisterSlimes("slime") 
				elseif descendant:IsA("Model") and descendant.Name:lower():find("splitslime") then
					
					task.wait(1) 
					SlimeController:RegisterSlimes("splitslime")					
				elseif descendant:IsA("Model") and descendant.Name:lower():find("poisonslime") then

					task.wait(1) 
					SlimeController:RegisterSlimes("poisonslime")
					
				elseif descendant:IsA("Model") and descendant.Name:lower():find("minisplitslime") then

					task.wait(1) 
					SlimeController:RegisterSlimes("minisplitslime")
					
				elseif descendant:IsA("Model") and descendant.Name:lower():find("bigslime") then

					task.wait(1) 
					SlimeController:RegisterSlimes("bigslime") 
					
				end
			end)
		end
	end)
end)



Here’s the result of the code below :

Revised version of your Module Script:
(this should optimize ur game)

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local SlimeController = {}
local EnemiesFolder = nil
local activeSlimes = {}
local slimePool = {}
local healthBarPool = {}

local spawnRadius = 50
local jumpInterval = 1.5
local jumpDistance = 3
local separationDistance = 10
local tweenInfo = TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut)

local healthUpdateEvent = ReplicatedStorage:WaitForChild("HealthUpdateEvent")
local crystalFolder = ReplicatedStorage:WaitForChild("Crystals")
local crystalModel = crystalFolder:FindFirstChild("Tier 1")
local crystalMeshPart = crystalModel:FindFirstChild("Common Crystal Tier 1")

-- Reuse health bars
function SlimeController:CreateHealthBar()
    if #healthBarPool > 0 then
        return table.remove(healthBarPool)
    end

    local healthBarGui = Instance.new("BillboardGui")
    healthBarGui.Size = UDim2.new(0, 100, 0, 10)
    healthBarGui.StudsOffset = Vector3.new(0, 5, 0)

    local container = Instance.new("Frame", healthBarGui)
    container.Size = UDim2.new(1, 0, 1, 0)
    container.BackgroundColor3 = Color3.fromRGB(50, 50, 50)

    local redBar = Instance.new("Frame", container)
    redBar.Size = UDim2.new(1, 0, 1, 0)
    redBar.BackgroundColor3 = Color3.fromRGB(255, 0, 0)

    local healthBar = Instance.new("Frame", container)
    healthBar.Size = UDim2.new(1, 0, 1, 0)
    healthBar.BackgroundColor3 = Color3.fromRGB(0, 255, 0)

    return healthBarGui
end

function SlimeController:DestroyHealthBar(healthBarGui)
    healthBarGui.Parent = nil
    table.insert(healthBarPool, healthBarGui)
end

-- Get slimes from pool
function SlimeController:GetSlimeFromPool(slimeModel)
    if #slimePool > 0 then
        local slime = table.remove(slimePool)
        slime.Parent = workspace.Enemies
        return slime
    end

    return slimeModel:Clone()
end

function SlimeController:ReturnSlimeToPool(slime)
    slime.Parent = nil
    table.insert(slimePool, slime)
end

-- Handle damage and poison
function SlimeController:HandleDamage(player, enemy, damageAmount, containPoison)
    local slimeData = activeSlimes[enemy]
    if not slimeData then return end

    local humanoid = slimeData.Humanoid
    if humanoid.Health <= 0 then
        player:WaitForChild("EnemiesKilled").Value += 1
        self:ReturnSlimeToPool(enemy)
        activeSlimes[enemy] = nil
    else
        humanoid.Health = math.max(humanoid.Health - damageAmount, 0)
    end

    if containPoison then
        self:HandlePoisonDamage(humanoid, 5, 1, 1)
    end
end

function SlimeController:HandlePoisonDamage(humanoid, duration, interval, damage)
    coroutine.wrap(function()
        for _ = 1, duration do
            if humanoid.Health <= 0 then break end
            humanoid.Health = math.max(humanoid.Health - damage, 0)
            task.wait(interval)
        end
    end)()
end

-- Register and manage slimes
function SlimeController:RegisterSlimes(monsterName)
    EnemiesFolder = workspace:WaitForChild("Enemies")

    for _, descendant in ipairs(EnemiesFolder:GetDescendants()) do
        if descendant:IsA("Model") and descendant.Name:lower():find(monsterName) then
            local hrp = descendant:FindFirstChild("HumanoidRootPart")
            local humanoid = descendant:FindFirstChildOfClass("Humanoid")
            if hrp and humanoid and not activeSlimes[descendant] then
                local healthBar = self:CreateHealthBar()
                healthBar.Adornee = hrp
                healthBar.Parent = descendant

                activeSlimes[descendant] = {
                    Model = descendant,
                    HRP = hrp,
                    Humanoid = humanoid,
                    HealthBar = healthBar,
                }
            end
        end
    end
end

-- Update all slimes
function SlimeController:UpdateAll(deltaTime)
    for _, slimeData in pairs(activeSlimes) do
        local hrp = slimeData.HRP
        local now = tick()

        if now - (slimeData.LastJump or 0) >= jumpInterval then
            slimeData.LastJump = now

            -- Simple movement logic
            local jumpVector = Vector3.new(math.random(-jumpDistance, jumpDistance), 0, math.random(-jumpDistance, jumpDistance))
            local destination = hrp.Position + jumpVector
            TweenService:Create(hrp, tweenInfo, {CFrame = CFrame.new(destination)}):Play()
        end
    end
end

RunService.Heartbeat:Connect(function(deltaTime)
    SlimeController:UpdateAll(deltaTime)
end)

healthUpdateEvent.OnServerEvent:Connect(function(player, damageAmount, enemyTouched, containPoison)
    SlimeController:HandleDamage(player, enemyTouched, damageAmount, containPoison)
end)

return SlimeController

And here’s for the serverscriptservice

`local SlimeController = require(script.Parent.Controller.SlimeController)

game.Players.PlayerAdded:Connect(function(player)
    player:WaitForChild("IsPlaying").Changed:Connect(function(value)
        if value then
            workspace:WaitForChild("Enemies").DescendantAdded:Connect(function(descendant)
                if descendant:IsA("Model") then
                    if descendant.Name:lower():find("slime") then
                        task.wait(1)
                        SlimeController:RegisterSlimes("slime")
                    end
                end
            end)
        end
    end)
end)
`

Using a heartbeat loop is not the most efficient approach, as there’s no need to check, update, or perform actions more than 10 times per second. Instead, you could use a while task.wait(0.1) do loop, which is sufficient to handle these slimes while also saving on performances.

The best thing here is to reduce the amount of scripts running in the backround.
Here im gonna start with a first step in your optimization journey.

Optimise the regen script.

--!nocheck
--strategic_oof made this
local Heartbeat=game:GetService("RunService").Heartbeat
Heartbeat:Wait()

local SP=script.Parent

local Humanoid:Humanoid=SP:WaitForChild("Humanoid",.6) or SP:FindFirstChildOfClass("Humanoid") or SP:WaitForChild("Humanoid",8)
if Humanoid~=nil then
	local p=(Humanoid.MaxHealth+.1)/100

	local function Run() 
		p=(Humanoid.MaxHealth+.1)/100
		while Humanoid.Health<=Humanoid.MaxHealth do
			Humanoid.Health+=p
			task.wait(1)
		end
		Heartbeat:Wait()
		Humanoid.HealthChanged:Once(Run)																																																		
	end	
	Humanoid.HealthChanged:Once(Run)

	Humanoid.Died:Once(function()
		Run=function()end
		Heartbeat=workspace.Destroying
		Humanoid=nil
		p=nil
		script:Destroy()
	end)
else
	game:GetService("Debris"):AddItem(script,10)
	warn(Humanoid)
	error("No Humanoid! \ Health: Line 12 \ Humanoid: nil \ Deleting Script in 10 Seconds")	
end

Best i can do.

For the pathfinding only calculate a new path when the target has moved a certain distance or the npc is stuck. Unfortunatley i wont give the code.

What has the regen script has anything to do with the game ? I don’t understand G

I disagree because heartbeat is way smoother generally speaking than a while task.wait(0.1)

Yeah no it still lags even with while task.wait(0.1) wonder how some roblox game have like 50k parts and there are running so smooth and fine

As you can see G’s the FPS is dropping rapidly

Just some random thoughts of mine, so don’t give them to much thought.

-I have had a print statement inside a loop before which caused the game to lag/freeze, but when I removed it, it worked more smoothly. Could all those print statements be contributing to the lag?

-Some games have over 50k parts because they might have adjusted their streaming behavior under workspace. If you adjust that, the client should not have to compute the npcs outside of a radius. If you are using models, you would need to adjust the property on it ModelStreamingMode = Enum.ModelStreamingMode.Atomic.

-My guess is that the main reason why your lagging is due to your update all logic.
-You are looping through all the slimes and moving them every frame due to having to loop inside a ‘loop’ being the heartbeat function., so imagine how costly that can get.
-Which I got no thoughts right now to fix, besides re-writing that logic in its entirety., which I got no brain power for either.

A small fix that may help though is to add a buffer → switch _ to i, and add and this to bottom of your for loop:
(Some reason can’t do if on a [ _ ]?

if i % 50 == 0 then
	task.wait()
end

Should look something like this:

-- Update all slimes
function SlimeController:UpdateAll(deltaTime)
	for i, slimeData in pairs(activeSlimes) do
		local hrp = slimeData.HRP
		local now = tick()

		if now - (slimeData.LastJump or 0) >= jumpInterval then
			slimeData.LastJump = now

			-- Simple movement logic
			local jumpVector = Vector3.new(math.random(-jumpDistance, jumpDistance), 0, math.random(-jumpDistance, jumpDistance))
			local destination = hrp.Position + jumpVector
			TweenService:Create(hrp, tweenInfo, {CFrame = CFrame.new(destination)}):Play()
		end
		if i % 50 == 0 then
			task.wait()
		end

	end
end

Basically instead of just slapping a wait() or whatever inside the for loop or heartbeat function which can cause a disconnect, which could make the npcs look choppy, this should create a buffer that will wait occasionally.

-Hope atleast a bit of that helps.

Yeah let’s try that man I’ll see if it works

Enabling Streaming actually worsen the FPS

Actually it helped a little bit to keep the game smooth let’s try adding some more

No actually no it makes the Slime stop moving which is the opposite of what we want we have to find a medium in which Slime move but the fact that the Slimes move doesn’t lag out the game and that the game is able to manage multiple models

I’m making some research and actually Level Of Detail seems to have a link on how it affects performance

I would suggest using the MicroProfiler and debug labels in order to figure out where your performance issues are actually coming from.

Yeah I don’t have a clue about how to use MicroProfiler unfortunately

Is there actual ways to reduce lag when Part spawns ?

Have you tested in-game platform instead of in studio (since studio uses your PC as the server, and roblox use their server which is insanely strong)
Also why your result seem pretty off even it’s a billboard GUI NPCs which can’t collidable with each other? (probally because of printing or some memory leaks?)

I removed all printing messages I don’t think it’s memory leaks actually

You right it lags way less in Roblox Player however they are still lags