NPC script lag, server doesnt lag but all npcs do and occasionally spike ping

i want to make a reliable enemy npc script

the issue is that sometimes all npcs are frozen, everything else on server works, only npcs lag

raising their update time, from 0.5 to 1, anything higher than that makes the enemy appear a bit clueless

each enemy contains copy of this script inside them, and theres around 20 enemies at a time



-- [SERVICES] --
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- [REQUIRING MODULE SCRIPTS]
local Settings = require(script.Parent:WaitForChild("Settings"))
local NPC_GetPathModule = require(script:WaitForChild("GetPath"))
local NPC_GetNearestPlayerModule = require(script:WaitForChild("GetNearestPlayer"))

-- [NPC] --
local NPC = script.Parent
local Humanoid = NPC:WaitForChild("Humanoid")
local HRP = NPC:WaitForChild("HumanoidRootPart")
HRP.Anchored = false
NPC.PrimaryPart:SetNetworkOwner(nil)

-- [CONSTANTS] --
local KEEP_BACK = Settings.KeepBackDistance or 5
local AVOID_RADIUS = 5  -- Minimum distance from other NPCs
local MAX_VERTICAL_JUMP = 6 -- Max jump height to avoid jumping on player’s head

-- [TABLES] --
local Waypoints
local index

-- [EVENT CONNECTIONS] --
local BlockedConnection
local Connection
local ReachConnection
local blade = nil

if NPC:WaitForChild("Right Arm"):FindFirstChildWhichIsA("Model") then
	blade = NPC:WaitForChild("Right Arm"):FindFirstChildWhichIsA("Model"):WaitForChild("Blade")
else
	blade = NPC:WaitForChild("Right Arm"):FindFirstChildWhichIsA("MeshPart")
end

wait(1)

-- [ANIMATIONS] --
if Settings.idleAnim ~= 0 then
	local animTrack = Settings.idleAnim
	local g = Instance.new("Animation")
	g.AnimationId = "rbxassetid://" .. animTrack
	local b = Humanoid:WaitForChild("Animator"):LoadAnimation(g):Play(0.1)
end

local attackCol = false
local combo = 1
local animations = Settings.animations
local stunned = false
local pdown = false
local animTrack2 = 91426890984193
local ks = Instance.new("Animation")
ks.AnimationId = "rbxassetid://" .. animTrack2
local attacking = false
local js = Humanoid.Animator:LoadAnimation(ks)

local function Destroy(item, length)
	task.spawn(function()
		wait(length)
		item:Destroy()
	end)
end

local function SlashSound()
	local sound = nil
	local rand = math.random(1,#ReplicatedStorage.WeaponSounds[Settings.SoundFolder]:GetChildren())
	sound = ReplicatedStorage.WeaponSounds[Settings.SoundFolder][string.format("s%d", rand)]:Clone()
	sound.Parent = NPC.PrimaryPart
	sound:Play()
	Destroy(sound, sound.TimeLength)
end

local function HitSound()
	local sound = nil
	local rand = math.random(1,#ReplicatedStorage[("Hit"..Settings.Type)]:GetChildren())
	sound = ReplicatedStorage[("Hit"..Settings.Type)][string.format("s%d", rand)]:Clone()
	sound.Parent = NPC.PrimaryPart
	sound:Play()
	Destroy(sound, sound.TimeLength)
end

local function parryEF(pos)
	local at  = Instance.new("Attachment")
	local ef = ReplicatedStorage:WaitForChild("SwordEffects"):WaitForChild("ParryEF"):GetChildren()
	for i,v in ef do
		v:Clone().Parent = at
	end
	at.Parent = workspace:WaitForChild("wow")
	at.WorldPosition = pos
	game:GetService("Debris"):AddItem(at, 0.05)
end

-- [AVOIDANCE FUNCTION]
local function GetAvoidanceOffset()
	local offset = Vector3.zero
	local npcs = NPC.Parent:GetChildren()

	for _, other in pairs(npcs) do
		if other ~= NPC and other:FindFirstChild("HumanoidRootPart") then
			local otherHRP = other.HumanoidRootPart
			local dist = (HRP.Position - otherHRP.Position).Magnitude
			if dist < AVOID_RADIUS then
				local pushDir = (HRP.Position - otherHRP.Position).Unit
				offset += pushDir * (AVOID_RADIUS - dist)
			end
		end
	end

	return offset
end

-- [MOVEMENT FUNCTION]
function WalkTo()
	task.wait()
	local target = NPC_GetNearestPlayerModule.GetNearestPlayer()
	if target == nil and script.Raider.Value == true then
		target = workspace:WaitForChild("Target").PrimaryPart
	end

	if target then
		Humanoid.WalkSpeed = stunned and (Settings.RunSpeed - 11) or Settings.RunSpeed

		local Distance = (HRP.Position - target.Position).Magnitude
		if target.Parent.Name == "Target" then
			Distance = math.clamp(Distance, 1, 90)
		end

		local path = NPC_GetPathModule.GetPath(target)

		if Distance <= KEEP_BACK then
			Humanoid.WalkSpeed = 0
		end

		if path.Status == Enum.PathStatus.Success then
			Waypoints = path:GetWaypoints()
			index = 2

			local function MoveToNextWaypoint()
				if index <= #Waypoints then
					BlockedConnection = path.Blocked:Connect(function(blockedIndex)
						if blockedIndex >= index then
							BlockedConnection:Disconnect()
							BlockedConnection = nil 
							MoveToNextWaypoint()
						end
					end)

					if Distance <= Settings.MaxDistanceToKill and not attackCol and not stunned then
						if target.Parent:FindFirstChild("Attacking").Value == true and not pdown then
							js:Play(0.1)
							Humanoid.Parent.Parry.Value = true
							pdown = true
							task.spawn(function()
								task.wait(1)
								pdown = false
							end)
							wait(0.5)
							Humanoid.Parent.Parry.Value = false
							js:Stop()
							attacking = false
							wait(Settings.HitCooldown)
							attackCol = false
							BlockedConnection:Disconnect()
							BlockedConnection = nil
							return
						end

						attacking = true
						local animTrack = animations[combo]
						local s = Instance.new("Animation")
						s.AnimationId = "rbxassetid://" .. animTrack
						local a = Humanoid:WaitForChild("Animator"):LoadAnimation(s)
						local hitConnection = nil
						attackCol = true
						a:Play(0.1)

						task.spawn(function()
							wait(Settings.BeforeSlashSound)
							SlashSound()
							wait(Settings.TimeToParry)

							local cache = {}
							hitConnection = blade.Touched:Connect(function(part)
								if not attacking or cache[part] then return end
								cache[part] = true

								if part.Parent and part.Parent:FindFirstChild("Humanoid") then
									local humanoid = part.Parent.Humanoid
									local parry = part.Parent:FindFirstChild("Parry")
									if parry and parry.Value == true then
										stunned = true
										HRP.AssemblyLinearVelocity = -HRP.CFrame.LookVector * 70
										local marker = part.Parent:FindFirstChild("blahlbvld32")
										if marker then
											local katana = marker:FindFirstChild("Katana")
											if katana then
												parryEF(katana.Position)
											end
										end
										attacking = false
										wait(0.05)
										a:AdjustSpeed(-0.5)
										combo = 1
										script.SParry:Play()
										wait(1)
										attackCol = false
										stunned = false
									else
										if not part.Parent:FindFirstChild("Evil") then
											humanoid:TakeDamage(Settings.Damage / 7)
											HitSound()
										end
										combo = (combo % #animations) + 1
									end
								end
							end)
						end)

						a.Stopped:Connect(function()
							attacking = false
							if hitConnection then hitConnection:Disconnect() end
							wait(Settings.HitCooldown)
							attackCol = false
						end)

						BlockedConnection:Disconnect()
						BlockedConnection = nil
					end

					if Waypoints and Waypoints[index+1] and Waypoints[index+1].Action == Enum.PathWaypointAction.Jump then
						local verticalDelta = Waypoints[index+1].Position.Y - HRP.Position.Y
						if verticalDelta < MAX_VERTICAL_JUMP then
							Humanoid.Jump = true
							local avoidanceOffset = GetAvoidanceOffset()
							Humanoid:MoveTo(Waypoints[index+2].Position + avoidanceOffset)
						else
							index += 2
							return
						end
					elseif Waypoints then
						local avoidanceOffset = GetAvoidanceOffset()
						Humanoid:MoveTo(Waypoints[index].Position + avoidanceOffset)
					end

					index += 1
				end
			end

			MoveToNextWaypoint()
		end
	end
end

local avr = false

-- [MAIN LOOP] --
Connection = RunService.Heartbeat:Connect(function()
	wait(1)
	if Humanoid.Health > 0 then
		WalkTo()
	else
		if avr then return end
		Connection:Disconnect()
		script.Parent.Parent = workspace.Corpses
		if script.Parent:WaitForChild("Right Arm"):FindFirstChildWhichIsA("Model") then
			for _, v in pairs(script.Parent["Right Arm"]:FindFirstChildWhichIsA("Model"):GetChildren()) do
				v.CanCollide = true
				v.Massless = false
			end
		end
		script.Parent["Right Arm"]:WaitForChild("WeldConstraint"):Destroy()
		game:GetService("Debris"):AddItem(script.Parent, 10)

		if math.random(1, 4) == 1 then
			local book = ReplicatedStorage.items:FindFirstChild("techbook"):Clone()
			local volume
			local roll = math.random(1, 100)
			if roll <= 30 then
				volume = math.random(1, 3)
			elseif roll <= 60 then
				volume = math.random(4, 6)
			elseif roll <= 85 then
				volume = math.random(7, 9)
			else
				volume = math.random(10, 11)
			end
			book.Name = "Book of Technology Vol " .. volume
			book:PivotTo(script.Parent.Head.CFrame)
			book.Handle.Name = "pickupPart"
			book.Parent = workspace.Loot
		end

		script:Destroy()
		avr = true
	end
end)

any help appreciated

(yes, some parts from ChatGPT)

Hey! So, after reviewing your script, I can say it’s functional, but there’s a serious issue with scalability and performance — mainly because each NPC runs its own full copy of the script, with individual connections, waits, and combat/movement logic. This becomes a bottleneck when you spawn multiple enemies (like you said, around 20 at a time), which likely explains why your NPCs sometimes freeze or become sluggish — probably due to too many simultaneous threads and pathfinding requests, which are resource-heavy.

What I recommend:

Use CollectionService. Instead of putting a script inside every NPC, you can:

  1. Create a tag called "NPC";
  2. Tag all your NPCs with it;
  3. Write a single centralized script that loops through all tagged NPCs;
  4. Handle behavior for all NPCs from there, avoiding duplication and heavy overhead.

This greatly improves performance, maintainability, and makes future updates much easier. I work a lot with this pattern and highly recommend it.

Here’s Roblox’s official documentation:

CollectionService: CollectionService | Documentation - Roblox Creator Hub

This is just my suggestion and what I like to do most in my projects, I highly recommend you use this, it will avoid you duplicating several scripts that do the same thing for all NPCs.

1 Like

Could you send some video of what is occurring?

Also, a couple of things you can also do while you’re at it:

  • I notice it says wait(1) at the beginning at the RunService.Heartbeat connection. What is the point in having this exactly? RunService.Heartbeat fires every frame, so adding wait() only delays the process. Also wait() is depreciated, use task.wait() instead.
  • Try not to use RunService.Heartbeat for something like this. This is an insane performance hit which can cause many issues for the server down the line. Try and make it so it runs less times so that there is more time for the system to process things.
1 Like

wow, thanks alot for this, i will definitely look into CollectionService and tags, great advice!

1 Like

the point was to make the npc logic run each second once, i guess i didnt think about heartbeat running every heartbeat with the wait, thanks for the advice about the RunService.Heartbeat, i will replace it with while wait, thanks !

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.