Zombie NPC Enemy lagging behind player when chasing

pls help me help me help me help me help me

I made a zombie npc chase the player but whenever it gets close to the player while I am walking (regardless of if its faster than me) it will always lag a few studs behind.

I have tried to make it so that it cannot collide with my player, but it didnt work (I don’t understand collision groups)

Here is the code so far:

local replicatedStorage = game:GetService("ReplicatedStorage")
local debris = game:GetService("Debris")
local runService = game:GetService("RunService")
local physicsService = game:GetService("PhysicsService")
local zombieSettings = require(script.Parent.Settings)

local humanoid = script.Parent:WaitForChild("Humanoid")
local root = script.Parent:WaitForChild("Torso")
local head = script.Parent:WaitForChild("Head")
local animator = humanoid:WaitForChild("Animator")

local dead = false
local chasing = false
local targetPlayer = nil
local lostSightTime = 0

local wanderCooldown = 0
local soundCooldown = 0

local spottedRecently = false
local clone = script.Parent:Clone()

local sprintAnim = replicatedStorage.animations.zombie.movement:WaitForChild("sprintAnim")
local idleAnim = replicatedStorage.animations.zombie.movement:WaitForChild("idleAnim")

local sprintTrack = animator:LoadAnimation(sprintAnim)
local idleTrack = animator:LoadAnimation(idleAnim)

humanoid.WalkSpeed = zombieSettings.movement.walk.walkSpeed

pcall(function()
	physicsService:CreateCollisionGroup("Zombies")
	physicsService:CreateCollisionGroup("Players")
end)

physicsService:CollisionGroupSetCollidable("Zombies", "Players", false)

for _, part in ipairs(script.Parent:GetDescendants()) do
	if part:IsA("BasePart") then
		physicsService:SetPartCollisionGroup(part, "Zombies")
	end
end

local function playRandomSound(listName)
	local sounds = zombieSettings.sounds[listName]
	if sounds and #sounds > 0 then
		local soundId = sounds[math.random(1,#sounds)]
		local s = Instance.new("Sound")
		s.SoundId = soundId
		s.Parent = head
		s:Play()
		debris:AddItem(s, 6)
	end
end

local function isInFOV(targetPosition)
	local forward = root.CFrame.LookVector
	local toTarget = (targetPosition - root.Position).Unit
	return forward:Dot(toTarget) >= 0.1736
end

local function findPlayer()
	local range = 50
	local closest = nil
	local closestDist = range + 1
	for _, player in ipairs(game.Players:GetPlayers()) do
		if player.Character and player.Character:FindFirstChild("HumanoidRootPart") then
			local targetRoot = player.Character.HumanoidRootPart
			local distance = (root.Position - targetRoot.Position).Magnitude
			if distance <= range and isInFOV(targetRoot.Position) then
				closest = targetRoot
				closestDist = distance
			end
		end
	end
	return closest
end

local function startChasing(playerRoot)
	targetPlayer = playerRoot
	chasing = true
	humanoid.WalkSpeed = zombieSettings.movement.sprint.walkSpeed
	if not sprintTrack.IsPlaying then sprintTrack:Play(0.5) end
	if not spottedRecently then
		playRandomSound("spotted")
		spottedRecently = true
		soundCooldown = 5
	end
end

local function stopChasing()
	targetPlayer = nil
	chasing = false
	spottedRecently = false
	humanoid.WalkSpeed = zombieSettings.movement.walk.walkSpeed
	if sprintTrack.IsPlaying then sprintTrack:Stop() end
	if not idleTrack.IsPlaying then idleTrack:Play(0.5) end
end

local function wander()
	if chasing then return end
	local randX = math.random(-50, 50)
	local randZ = math.random(-50, 50)
	local goal = root.Position + Vector3.new(randX, 0, randZ)
	humanoid:MoveTo(goal)
end

runService.Heartbeat:Connect(function(dt)
	if dead or humanoid.Health <= 0 then return end

	local playerRoot = findPlayer()
	if playerRoot then
		startChasing(playerRoot)
		lostSightTime = 0
	elseif targetPlayer then
		lostSightTime = lostSightTime + dt
		if lostSightTime >= 10 then
			stopChasing()
		end
	elseif not chasing then
		wanderCooldown = wanderCooldown - dt
		if wanderCooldown <= 0 then
			wander()
			wanderCooldown = 3 + math.random() * 2
		end
	end

	soundCooldown = soundCooldown - dt
	if soundCooldown <= 0 then
		if chasing then
			playRandomSound("chasing")
		else
			playRandomSound("idle")
		end
		soundCooldown = 5
	end

	if chasing and targetPlayer and targetPlayer.Parent then
		local targetRoot = targetPlayer
		local toZombie = (root.Position - targetRoot.Position)
		local distance = toZombie.Magnitude

		local playerLook = targetRoot.CFrame.LookVector
		local offsetDistance = math.clamp(distance / 2, 2, 6)
		local chasePosition = targetRoot.Position - (playerLook * offsetDistance)

		humanoid:MoveTo(chasePosition)
	end
end)

humanoid.Died:Connect(function()
	dead = true
	sprintTrack:Stop()
	playRandomSound("death")
	
	wait(20)
	
	clone.Parent = workspace
	debris:AddItem(script.Parent, 0.1)
end)

You should try using Humanoid:Move(Vector3) instead; it works better than MoveTo(), especially with chasing NPCs.

3 Likes

I think, when zombie stops, roblox thinks that he reached you already, while you walked away already. Chase function runs on position you were, you change the position, zombie still think that you are on previous position, stops there, and chase function runs again with updated position and over again.

2 Likes

This is likely due to difference between the client’s player position and the server’s player position. SInce there’s is a slight delay between them, your zombie thinks that it has reached it’s goal, but actually in the client the player keeps moving.

A solution to this is predicting the players position:

local Target = v.Character
local Hum:Humanoid = Target:FindFirstChildWhichIsA("Humanoid")
local Root = Target.HumanoidRootPart
local ping = v:GetNetworkPing()
		
if game["Run Service"]:IsStudio() then--Note that in studio :GetNetworkPing returns 0  but the ping delay is still there
	ping = .125--around my avg ping
end
local PositionToMove = Root.Position+Root.AssemblyLinearVelocity*(ping*2+dt)
--dt is the deltatime in the .HeartBeat cycle, since there is still delay with it

Now, you could use Humanoid:MoveTo but i’ve noticed that it only looks smooth when the NPC’s NetworkOwnerShip is set to the player, but looks “laggy” when it’s set to nil

On the contrary, Humanoid:Move looks smooth when NetworkOwnerShip is set to nil, but looks horrible when set to the player

Here’s an example, the red block you see is the predicted player’s position on the server and the yellow one is the position of the player on the server
It may still look laggy but its just the my client being laggy and not receving the debug parts too smoothly, not the server


This was done using :Move with no NetworkOwnerShip

Also do note that apparently the players can probably change their Ping info, at least that’s something I’ve heard so you might want to do some extra checks for that in the server in case someone is trying to find an exploit. But this is not that problematic for NPCs that just follow you, the problem would be big if you used this position predicts for Hitboxes.

2 Likes

Hello! It’s true that studio :GetNetworkPing() always returns 0, but a way to properly check ping would be to use a remote function to time the send and response time rather than setting ping as a static value, if needed I could do something in relations to help with this ping issue.

2 Likes

I’ll try this when my account gets unbanned