NPC Animations Stutter in game but not in studio?

So for some reason my NPC default animations stutter in game but then work fine in Studio? I am using the SimplePath module for my pathfinding which just uses ROBLOX’s Pathfinding Service. IT seems when it reaches it’s waypoint the animation stutters?

In Studio:
https://gyazo.com/8d8444c9bbe5cb7412a0085a9870fd78

In game:
https://gyazo.com/0ed97b6b08d01819965d6740e6d5b544

Here is my pathfinding script for my NPC.

local path = SimplePath.new(enemy, {
		AgentRadius = 3,
		AgentHeight = 6,
		AgentCanJump = true,
		AgentCanClimb = true,
		WaypointSpacing = 4,
		Costs = {
			Water = 20,
			DangerZone = math.huge,
		}
	})
	path.Visualize = true
	
	path:Run(patrolNodes["Node" .. enemy:GetAttribute("CurrentPatrolNode")])
	
	path.Error:Connect(function()
		if (targetValue.Value) then
			path:Run(targetValue.Value.PrimaryPart)
		else
			path:Run(patrolNodes["Node" .. enemy:GetAttribute("CurrentPatrolNode")])
		end
	end)
	
	path.Blocked:Connect(function()
		if (targetValue.Value) then
			path:Run(targetValue.Value.PrimaryPart)
		else
			path:Run(patrolNodes["Node" .. enemy:GetAttribute("CurrentPatrolNode")])
		end
	end)
	
	path.WaypointReached:Connect(function()
		findTarget(enemyHumanoidRootPart, chaseRange)
		
		if (targetValue.Value) then --Start chasing target
			
			local distanceToTarget = (enemyHumanoidRootPart.Position - targetValue.Value.PrimaryPart.Position).Magnitude
			
			if (distanceToTarget <= attackRange) then --In attack range so stop path and start the combat behaviour
				
				if (SimplePath.Status ~= SimplePath.StatusType.Idle) then path:Stop() end
				
				repeat task.wait(0.1)
					
					if not(targetValue.Value) or (targetValue.Value.Humanoid.Health <= 0) then break end
					
					if not(enemy:GetAttribute("Stunned")) or not(enemy:GetAttribute("BlockStun")) or not(enemy:GetAttribute("HitStun")) then
						
						AICombatBehaviour[enemy.Name](enemy, enemyHumanoid, enemyHumanoidRootPart, targetValue.Value, targetValue.Value:FindFirstChild("Humanoid"), targetValue.Value.PrimaryPart, attackRange, closestRange)
						
					end
					
				until (enemyHumanoid.Health < 1) or ((enemyHumanoidRootPart.Position - targetValue.Value.PrimaryPart.Position).Magnitude > attackRange)
				
				if (enemyHumanoidRootPart:FindFirstChild("BodyGyro")) then enemyHumanoidRootPart.BodyGyro:Destroy() end
				enemy.Bindables.Block:Fire("End")
				if (targetValue.Value) and (targetValue.Value.Humanoid.Health > 0) then
					
					path:Run(targetValue.Value.PrimaryPart)
					
					
				else
					
					targetValue.Value = nil
					enemy.Bindables.UnEquipWeapon:Fire()
					path:Run(patrolNodes["Node" .. enemy:GetAttribute("CurrentPatrolNode")])
					path.Reached:Wait()
					enemyHumanoid.WalkSpeed = enemyTypes[enemy.Name]:GetAttribute("PatrolSpeed")
					
				end
				
			else --Target not in attack range
				
				path:Run(targetValue.Value.PrimaryPart)
				
			end
			

		else --No target so resume Patrol

			--if (SimplePath.Status ~= SimplePath.StatusType.Idle) then path:Stop() end
			enemy:SetAttribute("IsChasing", false)
			path:Run(patrolNodes["Node" .. enemy:GetAttribute("CurrentPatrolNode")])
			path.Reached:Wait()
			enemy:SetAttribute("IsPatrolling", true)

		end
	end)

	path.Reached:Connect(function()
		if not(targetValue.Value) then
			
			GetPatrolNodes(true)
			if (enemy:GetAttribute("CurrentPatrolNode") == #patrolNodes:GetChildren()) then enemy:SetAttribute("CurrentPatrolNode", 0) end
			enemy:SetAttribute("CurrentPatrolNode", enemy:GetAttribute("CurrentPatrolNode") + 1)
			path:Run(patrolNodes["Node" .. enemy:GetAttribute("CurrentPatrolNode")])
			GetPatrolNodes(false)
			repeat findTarget(enemyHumanoidRootPart, chaseRange) task.wait(0.1) until enemyHumanoid.WalkSpeed > 0
			
		else
			
			path:Run(targetValue.Value.PrimaryPart)
			
		end
	end)

Notice how every time the yellow dots update the animation stutters, those 2 have something in common.
Furthermore I have too little information regarding the script to help you any other way. If this does not fix it I suggest to load the animation on yourself and stop it until the NPC stops moving.

It likely has something to do with replication delays:
a) The NPC has their network ownership given to a player, which means the player handles their physics updates
b) The NPC animations are client-sided, therefore take a bit until the client can see movement again

If the case is A: (you never set NPC network owner to nil), this would be a suggested solution:

-- NPC - the npc in question
local HumanoidRootPart = NPC:WaitForChild("HumanoidRootPart")
if HumanoidRootPart:CanSetNetworkOwnership() then
	HumanoidRootPart:SetNetworkOwner(nil)
end

HOWEVER: Take note that anchoring the HumanoidRootPart will reset the network ownership! You will have to set it again if you do that

If the case is B, it would require more effort to fix it. A couple possible approaches:
a) Move the NPC entirely to the client (if possible)
b) Make the movement animations played by the server

Here is the SimplePath module that I use. That code I originally put is what handles the movement and finding targets. Then when I run a path I call this modules below.

--[[
-------------------------------------------------------------------

Created by: @V3N0M_Z
Reference: https://wicked-wlzard.github.io/simplepath/
License: MIT

---------------------------------------------------------------------
]]

local DEFAULT_SETTINGS = {

	TIME_VARIANCE = 0.07;

	COMPARISON_CHECKS = 1.5;

	JUMP_WHEN_STUCK = true;
}

---------------------------------------------------------------------

local PathfindingService = game:GetService("PathfindingService")
local Players = game:GetService("Players")
local function output(func, msg)
	func(((func == error and "SimplePath Error: ") or "SimplePath: ")..msg)
end
local Path = {
	StatusType = {
		Idle = "Idle";
		Active = "Active";
	};
	ErrorType = {
		LimitReached = "LimitReached";
		TargetUnreachable = "TargetUnreachable";
		ComputationError = "ComputationError";
		AgentStuck = "AgentStuck";
	};
}
Path.__index = function(table, index)
	if index == "Stopped" and not table._humanoid then
		output(error, "Attempt to use Path.Stopped on a non-humanoid.")
	end
	return (table._events[index] and table._events[index].Event)
		or (index == "LastError" and table._lastError)
		or (index == "Status" and table._status)
		or Path[index]
end

--Used to visualize waypoints
local visualWaypoint = Instance.new("Part")
visualWaypoint.Size = Vector3.new(0.3, 0.3, 0.3)
visualWaypoint.Anchored = true
visualWaypoint.CanCollide = false
visualWaypoint.Material = Enum.Material.Neon
visualWaypoint.Shape = Enum.PartType.Ball

--[[ PRIVATE FUNCTIONS ]]--
local function declareError(self, errorType)
	self._lastError = errorType
	self._events.Error:Fire(errorType)
end

--Create visual waypoints
local function createVisualWaypoints(waypoints)
	local visualWaypoints = {}
	for _, waypoint in ipairs(waypoints) do
		local visualWaypointClone = visualWaypoint:Clone()
		visualWaypointClone.Position = waypoint.Position
		visualWaypointClone.Parent = workspace
		visualWaypointClone.Color =
			(waypoint == waypoints[#waypoints] and Color3.fromRGB(0, 255, 0))
			or (waypoint.Action == Enum.PathWaypointAction.Jump and Color3.fromRGB(255, 0, 0))
			or Color3.fromRGB(255, 139, 0)
		table.insert(visualWaypoints, visualWaypointClone)
	end
	return visualWaypoints
end

--Destroy visual waypoints
local function destroyVisualWaypoints(waypoints)
	if waypoints then
		for _, waypoint in ipairs(waypoints) do
			waypoint:Destroy()
		end
	end
	return
end

--Get initial waypoint for non-humanoid
local function getNonHumanoidWaypoint(self)
	--Account for multiple waypoints that are sometimes in the same place
	for i = 2, #self._waypoints do
		if (self._waypoints[i].Position - self._waypoints[i - 1].Position).Magnitude > 0.1 then
			return i
		end
	end
	return 2
end

--Make NPC jump
local function setJumpState(self)
	pcall(function()
		if self._humanoid:GetState() ~= Enum.HumanoidStateType.Jumping and self._humanoid:GetState() ~= Enum.HumanoidStateType.Freefall then
			self._humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
		end
	end)
end

--Primary move function
local function move(self)
	if self._waypoints[self._currentWaypoint].Action == Enum.PathWaypointAction.Jump then
		setJumpState(self)
	end
	self._humanoid:MoveTo(self._waypoints[self._currentWaypoint].Position)
end

--Disconnect MoveToFinished connection when pathfinding ends
local function disconnectMoveConnection(self)
	self._moveConnection:Disconnect()
	self._moveConnection = nil
end

--Fire the WaypointReached event
local function invokeWaypointReached(self)
	local lastWaypoint = self._waypoints[self._currentWaypoint - 1]
	local nextWaypoint = self._waypoints[self._currentWaypoint]
	self._events.WaypointReached:Fire(self._agent, lastWaypoint, nextWaypoint)
end

local function moveToFinished(self, reached)
	
	--Stop execution if Path is destroyed
	if not getmetatable(self) then return end

	--Handle case for non-humanoids
	if not self._humanoid then
		if reached and self._currentWaypoint + 1 <= #self._waypoints then
			invokeWaypointReached(self)
			self._currentWaypoint += 1
		elseif reached then
			self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
			self._target = nil
			self._events.Reached:Fire(self._agent, self._waypoints[self._currentWaypoint])
		else
			self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
			self._target = nil
			declareError(self, self.ErrorType.TargetUnreachable)
		end
		return
	end

	if reached and self._currentWaypoint + 1 <= #self._waypoints  then --Waypoint reached
		if self._currentWaypoint + 1 < #self._waypoints then
			invokeWaypointReached(self)
		end
		self._currentWaypoint += 1
		move(self)
	elseif reached then --Target reached, pathfinding ends
		disconnectMoveConnection(self)
		self._status = Path.StatusType.Idle
		self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
		self._events.Reached:Fire(self._agent, self._waypoints[self._currentWaypoint])
	else --Target unreachable
		disconnectMoveConnection(self)
		self._status = Path.StatusType.Idle
		self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
		declareError(self, self.ErrorType.TargetUnreachable)
	end
end

--Refer to Settings.COMPARISON_CHECKS
local function comparePosition(self)
	if self._currentWaypoint == #self._waypoints then return end
	self._position._count = ((self._agent.PrimaryPart.Position - self._position._last).Magnitude <= 0.07 and (self._position._count + 1)) or 0
	self._position._last = self._agent.PrimaryPart.Position
	if self._position._count >= self._settings.COMPARISON_CHECKS then
		if self._settings.JUMP_WHEN_STUCK then
			setJumpState(self)
		end
		declareError(self, self.ErrorType.AgentStuck)
	end
end

--[[ STATIC METHODS ]]--
function Path.GetNearestCharacter(fromPosition)
	local character, dist = nil, math.huge
	for _, player in ipairs(Players:GetPlayers()) do
		if player.Character and (player.Character.PrimaryPart.Position - fromPosition).Magnitude < dist then
			character, dist = player.Character, (player.Character.PrimaryPart.Position - fromPosition).Magnitude
		end
	end
	return character
end

--[[ CONSTRUCTOR ]]--
function Path.new(agent, agentParameters, override)
	if not (agent and agent:IsA("Model") and agent.PrimaryPart) then
		output(error, "Pathfinding agent must be a valid Model Instance with a set PrimaryPart.")
	end

	local self = setmetatable({
		_settings = override or DEFAULT_SETTINGS;
		_events = {
			Reached = Instance.new("BindableEvent");
			WaypointReached = Instance.new("BindableEvent");
			Blocked = Instance.new("BindableEvent");
			Error = Instance.new("BindableEvent");
			Stopped = Instance.new("BindableEvent");
		};
		_agent = agent;
		_humanoid = agent:FindFirstChildOfClass("Humanoid");
		_path = PathfindingService:CreatePath(agentParameters);
		_status = "Idle";
		_t = 0;
		_position = {
			_last = Vector3.new();
			_count = 0;
		};
	}, Path)

	--Configure settings
	for setting, value in pairs(DEFAULT_SETTINGS) do
		self._settings[setting] = self._settings[setting] == nil and value or self._settings[setting]
	end

	--Path blocked connection
	self._path.Blocked:Connect(function(...)
		if (self._currentWaypoint <= ... and self._currentWaypoint + 1 >= ...) and self._humanoid then
			setJumpState(self)
			self._events.Blocked:Fire(self._agent, self._waypoints[...])
		end
	end)

	return self
end


--[[ NON-STATIC METHODS ]]--
function Path:Destroy()
	for _, event in ipairs(self._events) do
		event:Destroy()
	end
	self._events = nil
	if rawget(self, "_visualWaypoints") then
		self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
	end
	self._path:Destroy()
	setmetatable(self, nil)
	for k, _ in pairs(self) do
		self[k] = nil
	end
end

function Path:Stop()
	if not self._humanoid then
		output(error, "Attempt to call Path:Stop() on a non-humanoid.")
		return
	end
	if self._status == Path.StatusType.Idle then
		output(function(m)
			warn(debug.traceback(m))
		end, "Attempt to run Path:Stop() in idle state")
		return
	end
	disconnectMoveConnection(self)
	self._status = Path.StatusType.Idle
	self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
	self._events.Stopped:Fire(self._model)
end

function Path:Run(target)

	--Non-humanoid handle case
	if not target and not self._humanoid and self._target then
		moveToFinished(self, true)
		return
	end

	--Parameter check
	if not (target and (typeof(target) == "Vector3" or target:IsA("BasePart"))) then
		output(error, "Pathfinding target must be a valid Vector3 or BasePart.")
	end

	--Refer to Settings.TIME_VARIANCE
	if os.clock() - self._t <= self._settings.TIME_VARIANCE and self._humanoid then
		task.wait(os.clock() - self._t)
		declareError(self, self.ErrorType.LimitReached)
		return false
	elseif self._humanoid then
		self._t = os.clock()
	end

	--Compute path
	local pathComputed, _ = pcall(function()
		self._path:ComputeAsync(self._agent.PrimaryPart.Position, (typeof(target) == "Vector3" and target) or target.Position)
	end)

	--Make sure path computation is successful
	if not pathComputed
		or self._path.Status == Enum.PathStatus.NoPath
		or #self._path:GetWaypoints() < 2
		or (self._humanoid and self._humanoid:GetState() == Enum.HumanoidStateType.Freefall) then
		self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
		task.wait()
		declareError(self, self.ErrorType.ComputationError)
		return false
	end

	--Set status to active; pathfinding starts
	self._status = (self._humanoid and Path.StatusType.Active) or Path.StatusType.Idle
	self._target = target

	--Set network owner to server to prevent "hops"
	pcall(function()
		self._agent.PrimaryPart:SetNetworkOwner(nil)
	end)

	--Initialize waypoints
	self._waypoints = self._path:GetWaypoints()
	self._currentWaypoint = 2

	--Refer to Settings.COMPARISON_CHECKS
	if self._humanoid then
		comparePosition(self)
	end

	--Visualize waypoints
	destroyVisualWaypoints(self._visualWaypoints)
	self._visualWaypoints = (self.Visualize and createVisualWaypoints(self._waypoints))

	--Create a new move connection if it doesn't exist already
	self._moveConnection = self._humanoid and (self._moveConnection or self._humanoid.MoveToFinished:Connect(function(...)
		moveToFinished(self, ...)
	end))

	--Begin pathfinding
	if self._humanoid then
		self._humanoid:MoveTo(self._waypoints[self._currentWaypoint].Position)
	elseif #self._waypoints == 2 then
		self._target = nil
		self._visualWaypoints = destroyVisualWaypoints(self._visualWaypoints)
		self._events.Reached:Fire(self._agent, self._waypoints[2])
	else
		self._currentWaypoint = getNonHumanoidWaypoint(self)
		moveToFinished(self, true)
	end
	return true
end

return Path

I do already set the humanoidrootpart to the server. I have thought about doing the animations on the client as the server handles all the movement and attacking. I’m just unsure how I would achieve that.

Another thing to add on, when I am being chased by the NPC the default animations run fine? It seems to be related to the NPC’s WalkSpeed

https://gyazo.com/ec1e7dbb8ad657d6e83cc3f4936be8b7

What I am saying is running :LoadAnimation() yourself and only stopping it once the NPC stops moving.

I am unsure what you mean by running :LoadAnimation() myself? I am just using the default Animate script and have it in a Server script inside the NPC. The NPC has a ChaseSpeed of 14 and PatrolSpeed of 7 and when the NPC is in patrol mode that’s when the stutter begins. However when I set the PatrolSpeed to be 14 the animations work fine.

https://gyazo.com/418046bfa5c05367b8e4bb33abb2414d
Here is the NPC patrolling at 14 WalkSpeed. The run animation doesn’t stutter only when walking does the animation stutter.

1 Like

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