NPC Pausing at each Waypoint when following a PathfindingService Path

  1. What do you want to achieve? I need the NPCs to follow their pathfinding waypoints without pausing at each waypoint

  2. What is the issue? See video below. The NPCs pathfind perfectly fine when the game has just begun, but overtime, their pathfinding gets progressively laggier - as in, the NPC stalls when beginning the path, and then pauses at each path waypoint.

  3. What solutions have you tried so far? I found a few forum posts that mention this exact issue. The only solution ever provided is to SetNetworkOwner to nil, but that does not solve the problem.

Posted are two videos: one showing the problem, and one walking through the script I use for my NPCs. If you could give me any guidance on fixing this issue, it would be appreciated immensely. I will also be pasting the module script in a codeblock, since the videos will not last on Streamable forever.

https://streamable.com/x4jey2
https://streamable.com/k8q1oz
local npcHandler = {}

local PathfindingService = game:GetService("PathfindingService")
local ServerStorage = game:GetService("ServerStorage")
local PhysicsService = game:GetService("PhysicsService")
local ChatService = game:GetService("Chat")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local GiveCustomerItemEvent = ReplicatedStorage.RemoteEvents.GiveCustomerItem
local npcSpawnPart = workspace.NPCSpawnPart

local unoccupiedSeats = {}
local friendUserIds = {}
local friendDisplayNames = {}
local allFriendsPages = {}

function npcHandler.Main(npc)
	local humanoid:Humanoid = npc:WaitForChild("Humanoid")
	
	for i, obj in npc:GetChildren() do
		if obj:IsA("BasePart") then
			obj:SetNetworkOwner(nil)
		end
	end
	
	--Making sure the players actually have friends to load data from
	if #friendUserIds ~= 0 then
		--Dress up the npc like a friend of a player
		--Pick a randomly userId from the friendUserIds list
		local randomFriendUserId = friendUserIds[math.random(#friendUserIds)]
		--The humanoidDescription is an object that defines a rig's clothes, skintone and accessories
		humanoid:ApplyDescription(Players:GetHumanoidDescriptionFromUserId(randomFriendUserId))
		humanoid.DisplayName = friendDisplayNames[randomFriendUserId]
	end
	
	--First, choose a place to sit
	--The second argument is inclusive, unlike in other languages
	local randomSeat = math.random(#unoccupiedSeats)
	--I dont know if this is assigned as a reference or as a value. I guess I will find out!
	local chosenSeat = unoccupiedSeats[randomSeat]

	--Needs to happen ASAP because as we're removing it, other NPCs are reading from that table, and that's scary
	table.remove(unoccupiedSeats, randomSeat)

	local startTime = npcHandler.WalkTo(npc, chosenSeat)
	npcHandler.OrderFood(npc, startTime, chosenSeat)
end

function npcHandler.WalkTo(npc, destination)
	--Roblox can do explict typecasting using :TypeName
	--This will allow the IDE to give you code suggestions based on the type
	local humanoid:Humanoid = npc.Humanoid
	local humanoidRootPart = npc:WaitForChild("HumanoidRootPart")
	
	--Allows the NPC to move if they were sitting before calling WalkTo
	humanoid.Sit = false
	--We must stop the npc from sitting preemptively on its way to a seat
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, false)
	
	--local path = npcHandler.GetPath(npc, destination)
	--Moved GetPath into this function because you would never do one but not the other
	local pathParams = {
		["AgentCanJump"] = false,
		["Costs"] = {
			--Makes any path that goes over the Wood texture (such as our tables) completely intransversible
			--However, our NPCs will thankfully ignore this once they reach the seat they need to be in...
			--Perhaps the seats are not actually considered as being pathed over, and instead we're just avoiding the tables?
			
			--Even still, setting the pathing cost to 500 still has them path over the tables, despite the high cost and nearby alternate
			--pathes over the floor
			
			--Incredibly, this also fixed our issues of npcs soemtimes not getting close enough to sit on the chairs. Somehow.
			Wood = math.huge
		}
	}

	local path = PathfindingService:CreatePath(pathParams)

	--We must put the Y value at floor level because the pathfinding system thinks the npc cant jump onto the seat
	local flooredDestination = Vector3.new(destination.Position.X, 1, destination.Position.Z)

	path:ComputeAsync(humanoidRootPart.Position, flooredDestination)

	for i, waypoint in pairs(path:GetWaypoints()) do
		humanoid:MoveTo(waypoint.Position)
		humanoid.MoveToFinished:Wait()
	end
	
	--After the npc reaches a seat, allows it to sit
	--This does not force it to sit - only allows it to happen
	--We do this after the NPC has moved so that it doesnt accidentally sit in a different seat on the way to its intended seat
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, true)
	
	--Tells the npc to move to the seat again, just incase its not close enough to sit
	humanoid:MoveTo(flooredDestination)
	
	return os.time()
end

function npcHandler.OrderFood(npc, startTime, chosenSeat)
	local connection
	
	ChatService:Chat(npc.Head, "I want a water!", Enum.ChatColor.White)
	
	connection = GiveCustomerItemEvent.OnServerEvent:Connect(function(sender, cupTool, recievingNPC)
		if npc ~= recievingNPC then return end
		
		--We manually disconnect this connection because it is made by a coroutine within a serverscript
		--Thus it will never be automatically disconnected, even when the NPC is destroyed
		connection:Disconnect()
		
		ChatService:Chat(npc.Head, "Thanks Buddy!", Enum.ChatColor.White)
		
		cupTool.Parent = npc
		
		npcHandler.PayAndLeave(npc, startTime, chosenSeat)
	end)
end

function npcHandler.PayAndLeave(npc, startTime, chosenSeat)
	local totalTime = os.time() - startTime
	print("It took ", totalTime, " seconds for this NPC to be served!")
	
	task.wait(1)
	npcHandler.WalkTo(npc, npcSpawnPart)
	table.insert(unoccupiedSeats, chosenSeat)
	
	task.wait(1)
	npc:Destroy()
end


--*** FUNCTIONS NOT SPECIFIC TO ANY 1 NPC
function npcHandler.Spawn(name, quantity)
	local npcExists = ServerStorage.NPCs:FindFirstChild(name)
	
	if npcExists then
		for i=1, quantity do
			local newNpc = npcExists:Clone()
			newNpc.Parent = workspace.SpawnedNPCs
			newNpc.HumanoidRootPart.CFrame = workspace.NPCSpawnPart.CFrame

			coroutine.wrap(npcHandler.Main)(newNpc)
			
			task.wait(1)
		end
	else
		warn("NPC does not exist: " .. name)
	end
end

function npcHandler.SetUpSeats()
	for i, obj in pairs(workspace.Map:GetDescendants()) do
		if obj:IsA("Seat") then
			table.insert(unoccupiedSeats, obj)
		end
	end
end


--*** CONNECTIONS
Players.PlayerAdded:Connect(function(player)
	--Getting friends returns a FriendsPage object, which is a Page object containing tables of displayname, id, isOnline, and username
	--Its like a 2 dimensional array, but accessed differently
	local friendsPages = Players:GetFriendsAsync(player.UserId)
	local cachedPages = {}
	
	--Inserting the friendsPages object into a table for quick, easy access for when the player leaves
	--allFriendsPages[player.UserId] = friendsPages
	
	while true do
		--Cacheing the each page for future use
		--This creates a 2 dimensional table with the structure: pageNumber, friendNumber, contents
		table.insert(cachedPages, friendsPages:GetCurrentPage())
		
		--We must do this before advancing because if we did it the other way (or had this statement in the while condition)
		--it would skip the last page of friends
		--since it'd technically have reached the end of the pages, but never had run them through the loop
		if friendsPages.IsFinished then break end
		
		friendsPages:AdvanceToNextPageAsync()
	end
	
	for i, page in cachedPages do
		--cachedPages[i] is looping through every friendNumber (aka user table) in a specific page
		--i2 is the index of the Page's table, which contains friendNumbers which contain the userIds we're after
		for i2, user in cachedPages[i] do
			--Can also be accessed like user["Id"], since these are really just dictionaries
			table.insert(friendUserIds, user.Id)
			
			--Since we're here, I'll also grab their display names, to name the npcs just like their friends!
			--For ease of access, we'll need to make this a dictionary
			friendDisplayNames[user.Id] = user.DisplayName
		end
	end
	
	--This creates a 3 dimensional table with the structure: playerUserId, pageNumber, friendNumber, contents
	allFriendsPages[player.UserId] = cachedPages
end)

Players.PlayerRemoving:Connect(function(player)
	--grabs the 2 Dimensional Table that stores the pages of friend data tables of the user that's leaving
	local cachedPages = allFriendsPages[player.UserId]
	
	--removing this player's cachedPages from the allFriendsPages table since theyre leaving
	allFriendsPages[player.UserId] = nil
	
	for i, page in cachedPages do
		for i2, user in cachedPages[i] do
			local indexInTable = table.find(friendUserIds, user.Id)
			table.remove(friendUserIds, indexInTable)
			
			friendDisplayNames[user.Id] = nil
		end
	end
end)

return npcHandler

1 Like

I’d try using humanoid:Move() instead of MoveTo. The difference is that this doesn’t take the position but the direction as a parameter, which would be something like

(destination-hrp.Position)

if I’m not mistaken

With this method the humanoid walks in said direction endlessly and therefore doesn’t stop like it would with MoveTo.

1 Like

For some reason, the videos were linked as downloads instead of just taking you to the site they’re hosted on, I’ll paste them below in a codeblock so you guys can access the videos without having to download a whole 4 minutes of footage

https://streamable.com/x4jey2
https://streamable.com/k8q1oz
1 Like

I can’t seem to make this work with waypoints. I modified my code to do the following:

	local flooredDestination = Vector3.new(destination.Position.X, 1, destination.Position.Z)
	local direction = flooredDestination - humanoidRootPart.Position

	path:ComputeAsync(humanoidRootPart.Position, flooredDestination)

	for i, waypoint in pairs(path:GetWaypoints()) do
		--humanoid:MoveTo(waypoint.Position)
		--humanoid.MoveToFinished:Wait()
		humanoid:Move(direction)
		task.wait(.125)
	end

This makes them move in the direction of each waypoint, which is good, but no matter what value I use for task.wait()'s argument, the NPCs will either undershoot or overshoot the actual waypoint - because they’re created at variable distances

1 Like

It’s doing what you told it to do with… humanoid.MoveToFinished:Wait()

Try something like;

local waypoints = path:GetWaypoints()
local threshold = 1.5

for i = 1, #waypoints do
    local wp = waypoints[i].Position
    humanoid:MoveTo(wp)
    while (humanoidRootPart.Position - wp).Magnitude > threshold do
        task.wait(0.033)
    end
end

Tell it where to go just before it gets to where it’s going. – is basic concept here
(task.wait(0.033) is roughly 30 FPS pacing; full blast makes me nervous.)

1 Like

This doesn’t solve the problem and functions almost identically to just using humanoid.MoveToFinished:Wait(). The stuttering still occurs as the game progresses, identically to what happens in my original video.

Also for the record, it stalling after reaching waypoints is not “doing what you told it to do with… humanoid.MoveToFinished:Wait().” MoveToFinished:Wait() is intended to cease waiting immediately upon reaching a waypoint - not to wait after reaching it.

1 Like

Genuinly curious, could you try ticking this on and playing? See if the NPCs network ownership (NO) stay on the server.

White means server has NO, while green means your client has NO.

e

3 Likes

Alongside that, could you insert these lines into your .WalkTo() function? Just to test it…?

for i, waypoint in pairs(path:GetWaypoints()) do
	humanoid:MoveTo(waypoint.Position)
	humanoid.MoveToFinished:Wait()
	warn("GONE THROUGH WAYPOINT " .. i)
end
	
warn("REACHED FINAL DESTINATION!!!")

Honestly, it really is odd as I’m not able to replicate the stutter – and I’m just using your module without changing anything. I also tried placing “Wood” but it’s just working fine with a loop.

-- the code i used
local m = require(script.ModuleScript)

local char = script.Parent

while true do
	m.WalkTo(char, workspace.Part)
end


Something is happening between .OrderFood() and .PayAndLeave() somehow…? On first glance, there’s not really anything catching my attention that could potentially mess up your pathfinding.

2 Likes

I think you’re onto something!!! When the NPCs spawn, they have Server NO. When they sit, they lose all NO outlines. If they are served soon enough, they will regain the white Server NO outline while leaving, and leave as normal… but, if I wait ~40 seconds before serving the NPC, they will unexpectedly get a Green Client NO
(when entering)

(when waiting a short time before serving)

(when waiting a long time before serving)

I wonder why they’re losing their NO after sitting for so long… from my testing it seems like they lose it at exactly 45 seconds. Why would Roblox forget their NO?
I’m guessing I’d fix this by placing the for loop that sets their BaseParts to nil ownership in the WalkTo function instead of Main

2 Likes

I did this and I think it only prints the “Gone through waypoint X” after the NPC stops stuttering, but I think it really is that Network Ownership issue you exposed. Thank you for telling me about that setting! I don’t think I would’ve ever guessed this could have been happening if you didn’t mention it.

I moved my for loop that sets the BaseParts of the NPC into WalkTo and that appears to have resolved it. For any future devs that suffer this issue: If the NPC ever stops being a physics object (due to being anchored, welded to an anchored part, or sitting), you just need to WAIT 1 HEARTBEAT* and re-set the Network Ownership to the Server before you make them MoveTo the waypoints again. This is because Roblox “forgets” the Network Ownership of anchored objects after 45 seconds of being anchored.

*It is important that you wait at least 1 heartbeat between unseating the NPC and setting its Network Ownership, otherwise Roblox will throw an error because it hasn’t fully unanchored the NPC’s parts yet.

The modified WalkTo function is pasted below, for future reference if anybody needs it:

function npcHandler.WalkTo(npc, destination)
	--Roblox can do explict typecasting using :TypeName
	--This will allow the IDE to give you code suggestions based on the type
	local humanoid:Humanoid = npc.Humanoid
	local humanoidRootPart = npc:WaitForChild("HumanoidRootPart")
	local waypointProximityThreshold = 1.5 + 3
	
	--Allows the NPC to move if they were sitting before calling WalkTo
	humanoid.Sit = false
	--We must stop the npc from sitting preemptively on its way to a seat
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, false)
	
	--We must set network ownership every time this function is called instead of when the NPC is first created
	--This is because Network Ownership is released by the server when the humanoid sits
	--so it becomes owned by the client when it walks again. This sets it back to the server
	
	--We must do this AFTER setting humanoid.Sit = false
	--or else we will get an error for trying to set Network Ownership on a part that is welded to an anchored part (the seat)
	
	--We must wait 1 heartbeat so the server can properly update the fact that the NPC's parts are no longer welded to an anchored part
	task.wait()
	for i, obj in npc:GetChildren() do
		if obj:IsA("BasePart") then
			obj:SetNetworkOwner(nil)
		end
	end
	
	--local path = npcHandler.GetPath(npc, destination)
	--Moved GetPath into this function because you would never do one but not the other
	local pathParams = {
		["AgentCanJump"] = false,
		["Costs"] = {
			--Makes any path that goes over the Wood texture (such as our tables) completely intransversible
			--However, our NPCs will thankfully ignore this once they reach the seat they need to be in...
			--Perhaps the seats are not actually considered as being pathed over, and instead we're just avoiding the tables?
			
			--Even still, setting the pathing cost to 500 still has them path over the tables, despite the high cost and nearby alternate
			--pathes over the floor
			
			--Incredibly, this also fixed our issues of npcs soemtimes not getting close enough to sit on the chairs. Somehow.
			Wood = math.huge
		}
	}

	local path = PathfindingService:CreatePath(pathParams)

	--We must put the Y value at floor level because the pathfinding system thinks the npc cant jump onto the seat
	local flooredDestination = Vector3.new(destination.Position.X, 1, destination.Position.Z)

	path:ComputeAsync(humanoidRootPart.Position, flooredDestination)

	for i, waypoint in pairs(path:GetWaypoints()) do
		humanoid:MoveTo(waypoint.Position)
		humanoid.MoveToFinished:Wait()
	end
	
	--After the npc reaches a seat, allows it to sit
	--This does not force it to sit - only allows it to happen
	--We do this after the NPC has moved so that it doesnt accidentally sit in a different seat on the way to its intended seat
	humanoid:SetStateEnabled(Enum.HumanoidStateType.Seated, true)
	
	--Tells the npc to move to the seat again, just incase its not close enough to sit
	humanoid:MoveTo(flooredDestination)
	
	return os.time()
end
3 Likes

Sound like this isn’t the real problem then.

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