Horror game AI functionality questionable at best, often jitters and lags

I am attempting to create a horror game AI that is able to chase players around corners by going to their last known position and seeing if the player has their flashlight on. I wish to use pathfinding even when the player is in sight of the AI, as I don’t want my level design to be limited (such as any form of verticality being a no-go). The problem with this is that it just doesn’t work. Paths being created every frame often means that the AI starts jittering as it starts new paths during previous ones, and the script only becomes more complicated and difficult to work with as I try different solutions, move things around, reorganize and change methods in the hope that something will work. As of now, the game horribly lags as it jankily pathfinds its way to the given position.

I’m not exactly sure what to try, I’m not very experienced with pathfinding or AI in general. I believe that a possible solution could be to have it only recalculate the path once it reaches a waypoint and then once it’s within a few studs of the player move directly to them like some other threads have suggested, though I’m not sure how I’d implement that in the mess of code that I currently have. It’d help as well if someone have any videos or tutorials that give me a base to add mechanics on top of.

I’ll leave the script down below if it makes things clearer, thank you.

-- get services
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PFS = game:GetService("PathfindingService")
local RunService = game:GetService("RunService")

-- npc variables
local npc = script.Parent
local hum = npc:WaitForChild("Humanoid")
local hrp = hum.RootPart

npc.PrimaryPart = hrp

local raycastParams = RaycastParams.new()
raycastParams.FilterDescendantsInstances = {npc}
raycastParams.FilterType = Enum.RaycastFilterType.Exclude

-- pathfinding and movement
local waypoints
local nextWaypointIndex
local reachedConnection
local lastPos

local patrolSections = game.Workspace.NPCWaypoints:GetChildren()
local sectionToPatrol = math.random(1, #patrolSections)

-- flags
local state -- 0 = wandering; 1 = searching; 2 = chasing
local canSeePlayer = false
local prevCanSeePlayer = false
local targetPos

-- find nearest player. if there is one, return the player. if there isn't, return nil
local function canSeeTargetsLight(plr)
	if plr.Character then
		local flashlight = plr.Character.Head:FindFirstChild("Flashlight")
		if flashlight then
			if flashlight.SpotLight.Enabled == true then
				local lightPart = game.Workspace.FilterParts.ClientSpawnables:WaitForChild(plr.Name.."LightHit")

				local lightSightRay = workspace:Raycast(hrp.Position, lightPart.Position - hrp.Position, raycastParams)

				if lightSightRay then
					local distance = lightSightRay.Distance
					local part = lightSightRay.Instance

					if part == lightPart then
						print("I see a light!")
						return true
					else
						return false
					end
				end
			end
		end
	end
end

local function canSeeTarget(plr)
	local char = plr.Character
	local plrHRP = char:WaitForChild("Humanoid").RootPart
	
	local sightRay = workspace:Raycast(hrp.Position, plrHRP.Position - hrp.Position, raycastParams)


	if sightRay then
		local distance = sightRay.Distance
		local part = sightRay.Instance
		
		if part:IsDescendantOf(char) then
			print("I see a player")
			return true
		end
	else
		print("Can't see player")
		return false
	end
end

local function findTarget()
	local plyrs = Players:GetPlayers()
	local nearestTarget
	
	for i,plr in pairs(plyrs) do
		if plr.Character then
			if canSeeTarget(plr) then
				nearestTarget = plr.Character.HumanoidRootPart
				canSeePlayer = true
			elseif canSeeTargetsLight(plr) then
				nearestTarget = game.Workspace.FilterParts.ClientSpawnables:FindFirstChild(plr.Name.."LightHit")
				canSeePlayer = true
			else
				canSeePlayer = false
			end
		end
	end
	
	return nearestTarget
end

local function getPath(pos)
	-- set up pathfinding
	local path = PFS:CreatePath({
		AgentRadius = 2,
		AgentHeight = 6,
		AgentCanJump = true,
		AgentCanClimb = true,
		WaypointSpacing = 2,

	})


	-- compute path
	path:ComputeAsync(npc.PrimaryPart.Position, pos)
	
	print("Path created")
	
	return path
end

local function walkTo(pos)
	
	local path = getPath(pos)
	
	if path.Status == Enum.PathStatus.Success then
		-- get path waypoints
		waypoints = path:GetWaypoints()
		
		local lastPos = waypoints[#waypoints]
		
		for i, nextWaypoint in pairs(waypoints) do
			local waypointVis = Instance.new("Part")
			waypointVis.Parent = workspace
			waypointVis.Position = nextWaypoint.Position
			waypointVis.Anchored = true
			waypointVis.CanCollide = false
			waypointVis.Size = Vector3.new(1, 1, 1)
			waypointVis.Shape = Enum.PartType.Ball
			game.Debris:AddItem(waypointVis, 5)
		end
		
		for i, nextWaypoint in pairs(waypoints) do
			if i == 1 then
				continue
			end
			
			path.Blocked:Connect(function()
				path:Destroy()
			end)
			
			-- move to next waypoint
			hum:MoveTo(nextWaypoint.Position)
			print("Moving to ", nextWaypoint.Position)
			hum.MoveToFinished:Wait()
		end
	end
end

local function patrolSection(section)
	if state == 0 then
		local pointsInSection = patrolSections[section]:GetChildren()
		
		for i,v in pairs(pointsInSection) do
			walkTo(v.Position)
		end
	end
end

RunService.Heartbeat:Connect(function(dT)
	local target = findTarget()

	if canSeePlayer == true then
		targetPos = target.Position
		walkTo(targetPos)
		state = 2
	elseif prevCanSeePlayer == true then
		print("Lost sight of player!")
		walkTo(targetPos)
	else
		state = 0
		patrolSection(sectionToPatrol)
	end
	--print(targetPos)
	
	prevCanSeePlayer = canSeePlayer
	task.wait()
end)```

In case you wish to look into a streamlined solution for state managing with AI characters, I recommend looking into behaviortree5 and its plugin editor:

and a tutorial: