NPC Delay/Stuttering Always Behind Player

I am doing AI for my npc killer using State machines, but i ran into the classic issue where it stutters behind the player

What solutions have you tried so far? Did you look for solutions on the Creator Hub?

Yes, every single post related to this, But none of the solutions worked, i tried

  • getting the Target rootpart from the client in a renderstepped
  • Setting the second argument of MoveTo()
  • Setting networkship owner to nil (both one time, and in a loop, and even setting all the baseparts)
  • setting networkship owner to the player (both one time, and in a loop)
  • doing MoveTo() from the client, by sending a remoteevent to the client, so the client can move it
  • using both workspace:GetServerTimeNow() and player:GetNetworkPing() to predict the pos as an offset (didn’t work)

I haven’t implemented pathfinding, this is just simple moveto, but i see it doesn’t work

My Chase state :

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RobloxStateMachine = require(ReplicatedStorage.RobloxStateMachine)
local PlayerPos = require("@game/ReplicatedStorage/PlayerPos")


local Chase = RobloxStateMachine.State.new("Chase")

local function hasLineOfSight(fromPos, toPos, ignoreList)
	local direction = toPos - fromPos
	local params = RaycastParams.new()
	params.FilterDescendantsInstances = ignoreList
	params.FilterType = Enum.RaycastFilterType.Exclude
	return workspace:Raycast(fromPos, direction, params) == nil
end

local function getPlayerHRP(player)
	local char = player and player.Character
	if not char then return nil end
	return char:FindFirstChild("HumanoidRootPart")
end


function Chase:OnEnter(data)
	data.humanoid.WalkSpeed = data.chaseSpeed
	data.moveTimer  = 0
	data.hasLoS     = true
	data.inRange    = true
	data.graceTimer = data.graceTime  
	--data.rootPart:SetNetworkOwner(data.targetPlayer)
	print("[KillerAI] Chase -> targeting", data.targetPlayer and data.targetPlayer.Name)
end

function Chase:OnHeartbeat(data, dt)
	local player = data.targetPlayer
	if not player then
		data.hasLoS  = false
		data.inRange = false
		return
	end

	local hrp = getPlayerHRP(player)
	if not hrp then
		data.hasLoS  = false
		data.inRange = false
		return
	end

	local rootPos   = data.rootPart.Position
	local playerPos = hrp.Position
	local dist      = (playerPos - rootPos).Magnitude

	data.inRange = dist <= data.detectionRange

	local ignoreList = { data.killer, player.Character }
	if hasLineOfSight(rootPos, playerPos, ignoreList) then
		data.hasLoS      = true
		data.lastKnownPos = playerPos
		data.graceTimer   = data.graceTime
	else
		data.hasLoS = false
		-- Out of range AND no LoS
		local drainRate = data.inRange and 1 or 3
		data.graceTimer = math.max(0, data.graceTimer - dt * drainRate)
	end
	
	local playerPos = PlayerPos.GetPos(player or data.targetPlayer)

	-- Keep walking toward last known position
	data.moveTimer -= dt
	if data.moveTimer <= 0 then
		data.moveTimer = data.moveUpdateRate
		if data.lastKnownPos then
			if data.hasLoS and data.targetPlayer then
				--	local hrpPos = getPlayerHRP(data.targetPlayer) and getPlayerHRP(data.targetPlayer).Position
				local hrpPos = playerPos
				if hrpPos then
					local dist = (hrpPos - data.rootPart.Position).Magnitude
					if dist > data.stopDistance then
						local dir = (hrpPos - data.rootPart.Position).Unit
						local targetPos = hrpPos - dir * data.stopDistance
						data.humanoid:MoveTo(targetPos)
					else
						data.humanoid:MoveTo(data.rootPart.Position)
					end
				end
			else
				data.humanoid:MoveTo(data.lastKnownPos)
			end
		end
	end
end

function Chase:OnLeave(data)
	-- nothing
end

Chase.Transitions = {
	require(script.Parent.Parent.Transitions.ToSearch)
}

return Chase

Last solution i tried was getting the Position of the target rootpart from the client, results in a weird/inaccurate position

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService        = game:GetService("RunService")
local sendEvent = ReplicatedStorage:WaitForChild("SendRootPart")

local Players = game:GetService("Players")
local plr = Players.LocalPlayer

local character = plr.Character or plr.CharacterAdded:Wait()

RunService:BindToRenderStep("SendRootPart", Enum.RenderPriority.Camera.Value + 5, function(dt)
	local character = plr.Character or character 
	if character then
		local rootpart = character.PrimaryPart
		local cframe = character:GetPivot()
		sendEvent:FireServer(cframe.Position)
	end
end)

PlayerPos Module :

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local playerPos = {}

local sendEvent = ReplicatedStorage:FindFirstChild("SendRootPart")

sendEvent.OnServerEvent:Connect(function(player, pos)
	local currentpos = pos

	playerPos[player] = currentpos
end)

Players.PlayerRemoving:Connect(function(p)
	playerPos[p] = nil
end)

local player_service = {}
function player_service.GetPos(player)
	return playerPos[player] or nil
end

return player_service

This looks like a network lag problem, you can’t really do anything to truely fix it. But there might be some tricks to prevent the NPC from walking in the air :

  • Prevent the NPC from jumping/climbing
  • Disable collisions between players and the NPC (you can use CollisionConstraint)
  • Switch the whole system to client-side, but I don’t recommend it

Try these, if it doesn’t work I don’t think I’ll be able to do something for you, but maybe someone better than me.

1 Like

Here the improved and fixed script the issue was an network lag problem:

local RobloxStateMachine = require(ReplicatedStorage.RobloxStateMachine)
local PlayerPos = require("@game/ReplicatedStorage/PlayerPos")

local Chase = RobloxStateMachine.State.new("Chase")

local MAX_PING_PREDICTION = 0.2
local PING_UPDATE_RATE    = 0.5
local LOS_UPDATE_RATE     = 0.1

local function hasLineOfSight(fromPos, toPos, ignoreList)
	local direction = toPos - fromPos
	local params = RaycastParams.new()
	params.FilterDescendantsInstances = ignoreList
	params.FilterType = Enum.RaycastFilterType.Exclude
	return workspace:Raycast(fromPos, direction, params) == nil
end

local function getPlayerHRP(player)
	local char = player and player.Character
	if not char then return nil end
	return char:FindFirstChild("HumanoidRootPart")
end

local function setNetworkOwnerNil(model)
	for _, part in ipairs(model:GetDescendants()) do
		if part:IsA("BasePart") and part.CanSetNetworkOwnership then
			part:SetNetworkOwner(nil)
		end
	end
end

function Chase:OnEnter(data)
	data.humanoid.WalkSpeed = data.chaseSpeed
	data.hasLoS     = true
	data.inRange    = true
	data.graceTimer = data.graceTime
	data.cachedPing = 0
	data.pingTimer  = 0
	data.losTimer   = 0
	data.losParams  = RaycastParams.new()
	data.losParams.FilterType = Enum.RaycastFilterType.Exclude
	setNetworkOwnerNil(data.killer)
	print("[KillerAI] Chase -> targeting", data.targetPlayer and data.targetPlayer.Name)
end

function Chase:OnHeartbeat(data, dt)
	local player = data.targetPlayer
	if not player then
		data.hasLoS  = false
		data.inRange = false
		return
	end

	local hrp = getPlayerHRP(player)
	if not hrp then
		data.hasLoS  = false
		data.inRange = false
		return
	end

	local rootPos   = data.rootPart.Position
	local targetPos = PlayerPos.GetPos(player) or hrp.Position
	local dist      = (targetPos - rootPos).Magnitude

	data.inRange = dist <= data.detectionRange

	data.pingTimer -= dt
	if data.pingTimer <= 0 then
		data.pingTimer  = PING_UPDATE_RATE
		data.cachedPing = player:GetNetworkPing()
	end

	data.losTimer -= dt
	if data.losTimer <= 0 then
		data.losTimer = LOS_UPDATE_RATE
		data.losParams.FilterDescendantsInstances = { data.killer, player.Character }
		local hit = workspace:Raycast(rootPos, targetPos - rootPos, data.losParams)
		if hit == nil then
			data.hasLoS       = true
			data.lastKnownPos = targetPos
			data.graceTimer   = data.graceTime
		else
			data.hasLoS = false
			local drainRate = data.inRange and 1 or 3
			data.graceTimer = math.max(0, data.graceTimer - LOS_UPDATE_RATE * drainRate)
		end
	end

	local moveTarget = data.lastKnownPos
	if not moveTarget then return end

	if data.hasLoS then
		local vel       = hrp.AssemblyLinearVelocity
		local predicted = targetPos + vel * math.min(data.cachedPing, MAX_PING_PREDICTION)
		local d         = (predicted - rootPos).Magnitude
		if d > data.stopDistance then
			local dir = (predicted - rootPos).Unit
			data.humanoid:MoveTo(predicted - dir * data.stopDistance)
		else
			data.humanoid:MoveTo(rootPos)
		end
	else
		data.humanoid:MoveTo(moveTarget)
	end
end

function Chase:OnLeave(data)
end

Chase.Transitions = {
	require(script.Parent.Parent.Transitions.ToSearch)
}

return Chase
1 Like

Hello, Yes, i tried all of this, except switching everything clienside, i can’t afford it for now but i tried something similar before, but none of that worked

thank you, but this seems similar to before, and seems to be lagging my client a bit

I do admit it floats less

Ok i will fix my script right now and the isssur was that i resfreshed it every frame so thats why it lagged now the script does it every 0.15 secs

1 Like