NPC movement choppy, not because of server lag

i want my npc movement to be smoother but performant to handle 20 - 30 npcs at once

issue is that the npc moves, stops, moves and so on, very choppy

i tried increasing the update rate, but with more npcs it becomes a bit heavy on the server

-- ServerScriptService/NPCManager
local CollectionService = game:GetService("CollectionService")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Debris = game:GetService("Debris")

local GetPathModule = require(script:WaitForChild("GetPath"))
print("checkpoint")
local GetNearestPlayer = require(script:WaitForChild("GetNearestPlayer"))

local npcFolder = workspace:WaitForChild("Evils")
local activeNPCs = {}



-- Utility
local function destroyAfter(item, time)
	task.delay(time, function()
		if item then item:Destroy() end
	end)
end



-- Registers an NPC
local function registerNPC(npc)
	if not npc:IsDescendantOf(workspace) then return end
	if not npc:FindFirstChild("Humanoid") or not npc:FindFirstChild("HumanoidRootPart") then return end
	if not npc:FindFirstChild("Settings") then return end

	local humanoid = npc.Humanoid
	local root = npc.HumanoidRootPart
	local settings = require(npc.Settings)

	npc.PrimaryPart:SetNetworkOwner(nil)
	root.Anchored = false

	local data = {
		model = npc,
		humanoid = humanoid,
		root = root,
		settings = settings,
		state = {
			attacking = false,
			stunned = false,
			combo = 1,
			parryCooldown = 0,
		},
	}

	-- Optional: idle animation
	if settings.idleAnim and settings.idleAnim ~= 0 then
		local anim = Instance.new("Animation")
		anim.AnimationId = "rbxassetid://" .. settings.idleAnim
		humanoid:WaitForChild("Animator"):LoadAnimation(anim):Play()
	end

	activeNPCs[npc] = data
end

-- Deregisters an NPC
local function removeNPC(npc)
	activeNPCs[npc] = nil
end

-- Initial scan
for _, npc in pairs(CollectionService:GetTagged("NPC")) do
	registerNPC(npc)
end

-- Handle dynamic adds/removals
CollectionService:GetInstanceAddedSignal("NPC"):Connect(registerNPC)
CollectionService:GetInstanceRemovedSignal("NPC"):Connect(removeNPC)

-- Main loop
while task.wait(0.5) do
	for npc, data in pairs(activeNPCs) do
		local model = data.model
		local humanoid = data.humanoid
		local root = data.root
		local settings = require(npc.Settings)
		local state = data.state

		-- Skip dead or invalid NPCs
		if humanoid.Health <= 0 then
			removeNPC(npc)
			model.Parent = workspace:FindFirstChild("Corpses") or workspace
			local weld = model:FindFirstChild("Right Arm") and model["Right Arm"]:FindFirstChildWhichIsA("WeldConstraint")
			if weld then weld:Destroy() end
			Debris:AddItem(model, 10)

			-- Loot drop
			if math.random(1, 4) == 1 then
				local book = ReplicatedStorage.items:FindFirstChild("techbook"):Clone()
				local roll = math.random(1, 100)
				local vol = (roll <= 30 and math.random(1,3)) or (roll <= 60 and math.random(4,6)) or (roll <= 85 and math.random(7,9)) or math.random(10,11)
				book.Name = "Book of Technology Vol " .. vol
				book.PivotTo(model.Head.CFrame)
				book.Handle.Name = "pickupPart"
				book.Parent = workspace:WaitForChild("Loot")
			end
			continue
		end


		local target = GetNearestPlayer.GetNearestPlayer(npc)
		if not target and model:FindFirstChild("Raider") and model.Raider.Value == true then
			local fallback = workspace:FindFirstChild("Target")
			if fallback and fallback:FindFirstChild("PrimaryPart") then
				target = fallback.PrimaryPart
			end
		end

		if not target then continue end

		local distance = (root.Position - target.Position).Magnitude
		local path = GetPathModule.GetPath(npc, target)
		if not path or path.Status ~= Enum.PathStatus.Success then continue end

		local waypoints = path:GetWaypoints()
		if #waypoints < 2 then continue end

		-- Attack range logic
		if distance <= settings.MaxDistanceToKill and not state.attacking and not state.stunned then
			state.attacking = true
			local animID = settings.animations[state.combo]
			if animID then
				local anim = Instance.new("Animation")
				anim.AnimationId = "rbxassetid://" .. animID
				local track = humanoid.Animator:LoadAnimation(anim)
				track:Play()

				-- Placeholder: actual hit logic should use Touched events, Raycasting, etc.
				task.delay(settings.TimeToParry + 0.1, function()
					state.attacking = false
					state.combo = (state.combo % #settings.animations) + 1
				end)
			end
			continue
		end

		-- Movement
		if distance > 6 then
			humanoid.WalkSpeed = state.stunned and (settings.RunSpeed - 11) or settings.RunSpeed
			humanoid:MoveTo(waypoints[2].Position)
		else
			humanoid.WalkSpeed = 0
		end
	end
end

any help or advice appreciated

Could you send a video of the issue?

Also, go into the Visualization Modes settings menu (its the cog in the upper right corner of the main window), and tick on “Network Owners.” When NPCs start to stutter, what is the color of their network ownership outline?

the stutter is always, the outline is white, occasionally red

Try to not update every single npc in one frame and before repathfinding try to raycast to the target and if successful walk straight to it.

2 Likes

thats an interesting idea to spare some pathfinding by using raycast, and spreading the npc updates so its not all at once

1 Like

while task.wait(0.5) do

decrease this
do not do .Heartbeat, just do something like 0.05 (50ms)

1 Like

In my NPC system I use the .Heartbeat and have a .FramesAlive value that I tick up every frame and I do if NPC.FramesAlive % 10 == 0 then Update. With this set up I have no lag at all in my game.

1 Like

i will try to and see how much i can decrease it with reasonable performance

interesting, ill note it, thanks

Just keep in mind if you spawn the NPCs at the same time it will update at the same time

1 Like

the average times between frames in 60 fps (Which i think is what the roblox servers run at) is 16.66ms (or something like that)

50 ms is fine, believe me

1 Like

i will add slight wait between spawning enemy waves then, thanks for the warning

i made it 50ms, the npc is more responsive yes but it still keeps taking tiny breaks,

show video if you’re cool

What have you set your pathfinding Waypoint spacing to? Because as I see it the NPC walks to the second waypoint which is really close, finishes and waits in place for the next update. Pathfinding once, looping over it and raycasting to the target would be the most efficient solution I could think of.

sorry for late response, the video kept compressing too long haha

not sure what have i set the spacing to or where do i set it, the npc system i had was really old and terrible so im remaking it, if you mean the agent settings its this

	local path = PathfindingService:CreatePath({
		AgentHeight = 6,
		AgentRadius = 6,
		AgentCanJump = true
	})

this isn’t a stutter, it’s an issue in pathfinding and animations being played
idk how to help you with neither of those cause i’m bad at it

and i use SimplePath cause i’m too good at coding to create my own pathfinding

1 Like

Try adding WaypointSpacing = 4, or something close

1 Like

Interesting, I’ve never seen it go occasionally red like that. The only colors I was aware of was white (server owned) and green (client owned). @Downrest , you introduced me to this feature, maybe you can tell us what Red is?

Anyways, since it ALSO stutters when it isn’t occasionally red, I’m going to assume it isn’t a network ownership issue. The other commenters here seem to have some good advice though.