NPC AI is a horrendous experience please help

For the past month or so I have been trying to get my NPC AI into a working state. I am awfully bad at this. I feel I’ve made some fundamental mistake in how I’ve designed my code that makes it very difficult to get working, or otherwise have some critical error that prevents it from working properly. I have made some improvements, for example it actually wanders through waypoints properly (ironically all it took was a check to see if it already has a path), but it’s still far from working even in a simple state. It will navigate along the path to each checkpoint, and when the player is in sight will chase them, but quickly stops. It appears to be related to the for loop through the table of waypoints, continuing through the for loop even as the attack function should be running, due to the outputs below:
image

Another long-term issue I’ve been trying to solve is getting the AI to go back to wandering in a non-janky way after the player is dead/out of sight, without paths beginning to overlap.

As for what I’ve tried, I’ve taken a look at a couple of other AIs I’ve seen as well as some video tutorials, however outside of not wanting to straight up lift other people’s code I also believe that for what I plan to build onto the AI their structures may not work or be ideal (such as slowly navigating to the player’s location and searching for them if they’re in the room or have just been lost). I’m willing to swallow my pride though if there’s a tutorial that just works really well for this.

tl;dr AI stops chasing randomly and also doesn’t go back to wandering around and chasing the player on sight after player dies/is out of sight, I’m super tired of working on this and would like to know what I’ve done wrong.

I’ve taken the liberty of removing the functions that I believe work properly and have nothing to do with the issue, but I can send them if needed.

local function attack(target)
	local plrChar = target.Character

	if plrChar then
		local plrHum = plrChar:WaitForChild("Humanoid")
		local plrHRP = plrHum.RootPart
		lastKnownTargetPos = plrHRP.Position
		
		hum.WalkSpeed = ai_run_speed
		hum:MoveTo(lastKnownTargetPos)
		print("Attacking "..plrChar.Name)

		local dir = plrHRP.Position - hrp.Position
		local attackRay = workspace:Raycast(hrp.Position, dir, params)

		if attackRay then
			if attackRay.Instance:IsDescendantOf(plrChar) and plrHum.Health > 0 then
				if attackRay.Distance < ai_attack_range then
					plrHum.Health = 0
					hum.WalkSpeed = ai_walk_speed
				end
			else
				print("Searching")
				hum.WalkSpeed = ai_walk_speed
				hum:MoveTo(lastKnownTargetPos)
				hum.MoveToFinished:Wait()
				
				hasPath = false
			end
		end
	end
end

local function moveToPosition(position)
	print(hasPath)
	if hasPath == false then
		local path = getPath(position)

		local success, errorMessage = pcall(function()
			path:ComputeAsync(hrp.Position, position)
		end)

		if success and path.Status == Enum.PathStatus.Success then
			hasPath = true
			print("Moving to "..position.X..", "..position.Y..", "..position.Z)
			waypoints = path:GetWaypoints()
			
			for i, waypoint in pairs(waypoints) do
				local part = Instance.new("Part")
				part.Parent = workspace
				part.Position = waypoint.Position
				part.BrickColor = BrickColor.new("Lime green")
				part.CanCollide = false
				part.Anchored = true
				part.Shape = Enum.PartType.Ball

				game.Debris:AddItem(part, 10)
			end
			
			blockedConnection = path.Blocked:Connect(function(blockedWaypointIndex)
				blockedConnection:Disconnect()
				moveToPosition(position)
			end)
			
			table.remove(waypoints, 1)
			
			for i, waypoint in pairs(waypoints) do
				print(i)
				local target = getTarget()
				
				if target and target.Character.Humanoid.Health > 0 then
					attack(target)
				else
					hum:MoveTo(waypoint.Position)
					hum.MoveToFinished:Wait()

					if i == #waypoints then
						hasPath = false
					end
				end
			end
		end
	end
end

local function wander()
	local patrolPoint = mapWaypoints[math.random(1, #mapWaypoints)]

	moveToPosition(patrolPoint.Position)
end

while task.wait(0.01) do
	wander()
end
1 Like

If it’s not already, do it in a module script so it can be reused in other NPC’s.

I would highly recommend using an OOP type script for this. You don’t have to, just recommending you do since it saves you from a lot of teidous work

Anyways, some things you should have before doing this

  1. A ClearPath function I guess to end any running scripts that are making the NPC walk
  2. Function to find players
  3. Function to walk Path if path is found
  4. The way I did my PathFind was continuously pathfinding to a position rather than using the built in “PathBlocked” thing made by roblox, so apologies if this script seems weird
  5. Also used an OOP module : P
local currentPath = nil -- NPC's CurrentPath I guess
local function ClearPath()
	CurrentPath:Destroy()
	-- Other stuff in here that makes it end walking, depends on your script
end

local function WalkPath()
	-- You implement this, would also reccomend you use a task.spawn for this
end

local function FindPlayer()
	-- You implement this
end

I would make a wander and chase mode for the killer. During the wander mode, it wanders the waypoints and checks for enemies waypoints, I’m assuming you already have this done but if you don’t, have a variable local FoundPlayer = nil and in a task.spawn() you check for players, and a while loop, check if FoundPlayer == nil
while not FoundPlayer do (shortcut I guess to saying player not found) and make it wander its waypoints until player is found. After which, end the current Pathwaypoint walking soasdkf whatever and run the Chase Function and putting the foundTarget in the argument
It should look something like this,

local function Wander()
	local foundTarget = nil
	task.spawn(function() -- if you're wondering what task.spawn is, it allows you to run whatever in here without pausing the script
		while not foundTarget do
			local findPlr = FindPlayer() -- function that finds player and returns their HumanoidRootPart
			if findPlr do
				foundTarget = findPlr
			end
		end
	end)
	while not foundTarget do
		local goToPos = waypoints to go to
		WalkPath(goToPos)
	end
	ClearPath()
	Chase(foundTarget)
end

Feel free to try and combine both the while loops, I didn’t since it makes it easier to code

Now onto the chase function
Bah don’t feel like getting into the gritty details with this, anyways, have a in a while loop I kept finding the closest player then I made the NPC walk to it. Reminding you this assumes you are continuously doing a PathFinding to a Position. Once it reaches close enough to PlayerLastSeen Position, If there are players nearby, attack player, if not, then ClearPath and rerun the Wander function.
It should look something like this

local function Chase(Target)
	local goToPos = Target.Position -- Remember, target is the HumanoidRootPart of a Player's Character
	
	while true do
		local findTarget = FindPlayer() -- function that finds player and returns their 
		if findTarget then
			goToPos = findTarget.Position
		end

		local PathToPos = blah blah blah gotoplayers position using pathfindservice
		if PathToPos.Status == Enum.PathStatus.Success then
			if NPC Close To goToPos then
				if NPC Close to a Player then
					-- attack player
				end
				if not FindPlayer() then -- npc reached it's end pos and couldn't find any players
					ClearPath()
					break
				end
			else -- Npc not close to end Pos
				WalkPath()
			end
		end
	end
	Wander()
end

Don’t think this’ll help much, though I was given very little to begin with I did the best I can :d

Feel free to ask questions, I’ll try to answer them

1 Like

I have this script for an AI in one of my games, it works for me and I think it will for you too.

It has some functionality such as the NPC having a “vision cone” and moving to last seen position if it looses sight mid chase. You can remove that if you don’t need it. It needs checkpoints and some animations to work but just run the script and the error will make it obvious what you need to add.

I had it made for a strictly single player game as such it has some weird behavior in multiplayer scenarios, the main one being is that if its moving to the last seen position of player A it will ignore all other players on its way there, I don’t know why it does that but as it’s only in a single player game, the bug remains

task.wait(5)

local PathfindingService = game:GetService("PathfindingService")

local AI = script.Parent
local RP = script.Parent.HumanoidRootPart
local Hum = script.Parent.Humanoid

local Waypoints = game.Workspace:WaitForChild("Waypoints"):GetChildren()

RP:SetNetworkOwner(nil)

local attackAnim = Hum.Animator:LoadAnimation(script.Attack)
local walkAnim = Hum.Animator:LoadAnimation(script.Walk)
local runAnim = Hum.Animator:LoadAnimation(script.Run)

local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Exclude
rayParams.FilterDescendantsInstances = {Hum}

local Damage = 25
local AttackRange = 5


walkAnim.Looped = true
runAnim.Looped = true
walkAnim:Play()

local LastSeenPos

local RayParams = RaycastParams.new()
RayParams.FilterType = Enum.RaycastFilterType.Exclude

local pathParams = {
	AgentHeight = 5,
	AgentRadius = 3,
	AgentCanJump = true,
}

local function getPath(destination)
	local path = PathfindingService:CreatePath(pathParams)

	path:ComputeAsync(RP.Position, destination)

	return path	
end

function lineOfSight(target)
	local rayDirection = target.Position - RP.Position  
	local rayParams = RaycastParams.new()
	rayParams.FilterDescendantsInstances = {RP.Parent}  

	local RayResult = workspace:Raycast(RP.Position, rayDirection, rayParams)

	local Head = AI.Head
	
	local aiToCharacter = (target.Parent.Head.Position - Head.Position).Unit
	local aiLook = Head.CFrame.LookVector
	
	local dotProduct = aiToCharacter:Dot(aiLook)
	
	if RayResult and RayResult.Instance and dotProduct > 0.2 then
		if RayResult.Instance:IsDescendantOf(target.Parent) then
			return true
		else
			return false
		end
	end
end

function getTarget()

	local closestTarget
	local distanceFromClosestTarget = 1000000000

	for i, player in pairs(game.Players:GetChildren()) do
		local distance = (player.Character.HumanoidRootPart.Position - RP.Position).Magnitude

		if distance < distanceFromClosestTarget then
			distanceFromClosestTarget = distance
			closestTarget = player
		end
	end


	return(closestTarget)
end

local db = false

local runAnimPlayingStatus = false
local walkAnimPlayingStatus = false

function chaseTarget(target)
	print('chasing')
	if runAnimPlayingStatus == false then
		walkAnim:Stop()
		runAnim:Play()
		runAnimPlayingStatus = true
		walkAnimPlayingStatus = false
	end
	
	
	local path
	
	path = getPath(target.Character.HumanoidRootPart.Position)
	
	local waypoints = path:GetWaypoints()
	
	Hum:MoveTo(waypoints[2].Position)
			
		
	local humTarget = getTarget()
	local LineOfSight = lineOfSight(humTarget.Character.HumanoidRootPart)
	
	LastSeenPos = humTarget.Character.HumanoidRootPart.Position
	
	if (humTarget.Character.HumanoidRootPart.Position - RP.Position).Magnitude <= 5 and db == false then
		db = true
		attackAnim:Play()
		humTarget.Character.Humanoid:TakeDamage(Damage)
		attackAnim.Ended:Wait()
		db = false
	end
	
	if humTarget and LineOfSight then
		chaseTarget(humTarget)
	else
		moveToLastSeen(LastSeenPos)
	end
end


function moveToLastSeen(location)
	print('fired')
	
	local path = getPath(location)
	for i, waypoint in pairs(path:GetWaypoints()) do

		local humTarget = getTarget()
		local LineOfSight = lineOfSight(humTarget.Character.HumanoidRootPart)

		if humTarget and LineOfSight then
			path:Destroy()
			chaseTarget(humTarget)
			break
		else
			if walkAnimPlayingStatus == false then
				walkAnim:Play()
				walkAnimPlayingStatus = true
			end
			runAnimPlayingStatus = false
			runAnim:Stop()

			Hum:MoveTo(waypoint.Position)
			Hum.MoveToFinished:Wait()


			if i == #path:GetWaypoints() then
				patrol()
			end
		end
	end
end

function moveTo(target)
	local humTarget = getTarget()
	local LineOfSight = lineOfSight(humTarget.Character.HumanoidRootPart)
	
	if humTarget and LineOfSight then
		chaseTarget(humTarget)
	else
		
		local path = getPath(target)
		
		if path.Status == Enum.PathStatus.Success then

			
			for i, waypoint in pairs(path:GetWaypoints()) do
				
				local humTarget = getTarget()
				local LineOfSight = lineOfSight(humTarget.Character.HumanoidRootPart)

				if humTarget and LineOfSight then
					path:Destroy()
					chaseTarget(humTarget)
					break
				else
					if walkAnimPlayingStatus == false then
						walkAnim:Play()
						walkAnimPlayingStatus = true
					end
					runAnimPlayingStatus = false
					runAnim:Stop()

					Hum:MoveTo(waypoint.Position)
					Hum.MoveToFinished:Wait()
	
					
					if i == #path:GetWaypoints() then
						patrol()
					end
				end
			end
		end	
	end
end




function patrol()
	local ChosenWapoint = math.random(1, #Waypoints)
	moveTo(game.Workspace.Waypoints:FindFirstChild(ChosenWapoint).Position)
end



patrol()

Replied to the wrong post earlier whoops.

I think using parts of this actually worked. I’m yet to test every case, but the normal and expected situations of walking around a corner and after dying appears to be working (albeit I’ll need to do some polishing). Thank you!

Bit surprised you managed to get it working considering the code I gave was missing an important piece and there were some grammatical errors that made it more difficult to read (re-read my post just awhile ago…)

anyways if you’re using this part of the code I gave you should replace this with the ones below

Chase() Function

local findTarget = FindPlayer() -- function that finds player and returns their HumanoidRootPart
if findTarget then
	goToPos = findTarget.Position
	Target = findTarget
end

and

if NPC Close To goToPos then
	if NPC Close to Target then -- Player was changed to target cause target was found already
		-- attack Target, or Target.Parent.Humanoid
	end

When you say it’s better to use OOP for programming NPC AI, could you elaborate a little more for someone who isn’t as experienced with OOP usage?

Is that just like creating your own class for an npc and using a metatable (or table) to assign the data/functions to that class so you could do something like the thing below?:

-- Imagine the chase functions etc where all classified in a module and created a new object under the class called NPC

local NpcBehavior = require(npcModule)

local NPC = NpcBehavior.new(whatever_args)

--now you could run the NPC functions and access NPC data under the NPC instance(?)

--like:
NPC:Wander()
-- or
NPC:Chase()

Sorry, I’ve recently started working on NPC AI aswell and was wondering just what you meant when you said you used OOP. Thank you for the help!

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