Monster AI script quite literally getting on my nerves

I’m making a stupid game with my friend and attempted to obtain :no_mouth: an AI script… with the help of AI.

I know a little bit about scripting just not about AI scripting specifically. So I asked… AI to help me :slight_smile:

Well womp womp. After literal hours of messing around with our little game, we’ve decided to actually try to make something at least slightly fun… So we did. We made a very basic horror game.

But…

As I started to finish up on our map, I decided to try to improve the AI. Wellll. It worked. But barely.

I would be very happy if someone could help me understand what is wrong with this awful looking disaster. Please :smiley:

SCRIPT:

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local PathfindingService = game:GetService("PathfindingService")

-- Monster settings
local MONSTER_SPEED = {
	Normal = 12,
	Chasing = 24
}

local VISION_RANGE = 50
local VISION_ANGLE = 90
local KILL_RANGE = 3 -- Currently informational, kill is by Touched event
local WAYPOINT_REACH_DISTANCE = 5
local PATH_UPDATE_INTERVAL = 1 -- Seconds between path updates
local STUCK_THRESHOLD = 2 -- Seconds without moving to be considered stuck
local SEARCH_MODE_DURATION = 10 -- How long to search after losing player

-- Get references
local monster = script.Parent
local humanoid = monster:WaitForChild("Humanoid")
local rootPart = monster:WaitForChild("HumanoidRootPart")
local waypointsFolder = monster.Parent:WaitForChild("Waypoints")
local waypoints = waypointsFolder:GetChildren()


-- State variables
local currentTarget = nil
local isChasing = false
local isSearching = false
local lastPlayerKilled = nil
local lastKillTime = 0
local lastPathUpdate = 0
local currentPath = nil
local waypointIndex = 1
local lastPosition = rootPart.Position
local lastMoveTime = os.time()
local lastSightTime = 0
local lastKnownPlayerPosition = nil

-- Initialize pathfinding
local pathfinder = PathfindingService:CreatePath({
	AgentRadius = 2,
	AgentHeight = 5,
	AgentCanJump = true,
	AgentCanClimb = true
})

-- Follow the computed path
local function followPath(destination)
	if not rootPart or not humanoid or humanoid.Health <= 0 then return false end

	-- Compute new path if needed
	local needsNewPath = (os.time() - lastPathUpdate > PATH_UPDATE_INTERVAL) or (not currentPath)

	if needsNewPath then
		local success, errorMessage = pcall(function()
			pathfinder:ComputeAsync(rootPart.Position, destination)
		end)
		if not success then
			warn("Pathfinding ComputeAsync error for "..monster.Name..": "..errorMessage)
			currentPath = nil
			return false
		end
		lastPathUpdate = os.time()

		if pathfinder.Status == Enum.PathStatus.Success then
			currentPath = pathfinder:GetWaypoints()
			waypointIndex = 1
		else
			-- Path computation failed (e.g., NoPath)
			-- warn("Pathfinding failed for "..monster.Name.." to destination: "..tostring(destination).." Status: "..tostring(pathfinder.Status))
			currentPath = nil
			return false -- Indicate failure to establish a path
		end
	end

	-- Follow current path waypoints
	if currentPath and waypointIndex <= #currentPath then
		local nextWaypoint = currentPath[waypointIndex]

		-- Only update move target if not already moving there
		if (humanoid:GetState() ~= Enum.HumanoidStateType.Running) or
			((humanoid.MoveDirection - (nextWaypoint.Position - rootPart.Position).Unit).Magnitude > 0.1) then
			humanoid:MoveTo(nextWaypoint.Position)
		end

		-- Check if reached waypoint
		if (rootPart.Position - nextWaypoint.Position).Magnitude < WAYPOINT_REACH_DISTANCE then
			waypointIndex = waypointIndex + 1
		end

		-- Movement detection
		local movedDistance = (rootPart.Position - lastPosition).Magnitude
		if movedDistance > 0.1 then -- Threshold for detecting movement
			lastMoveTime = os.time()
			lastPosition = rootPart.Position
		elseif (os.time() - lastMoveTime) > STUCK_THRESHOLD then
			-- Stuck detection
			-- warn(monster.Name.." appears to be stuck, recomputing path to "..tostring(destination))
			currentPath = nil -- Force path recompute by clearing currentPath
			-- lastPathUpdate is NOT reset here, to allow PATH_UPDATE_INTERVAL to govern frequent recomputes if truly stuck.
            -- Or, reset lastPathUpdate = 0 to force immediate recompute next call. Let's keep it as is for now.
			return false -- Indicate failure to make progress
		end
		return true -- Made progress or still on path
	end
	
	-- If no currentPath or path is exhausted
	return false
end

-- Vision cone check
local function isInVisionCone(player)
	local character = player.Character
	if not character then return false end

	local humanoidRoot = character:FindFirstChild("HumanoidRootPart")
	if not humanoidRoot then return false end

	local toPlayer = (humanoidRoot.Position - rootPart.Position)
	local distance = toPlayer.Magnitude

	if distance > VISION_RANGE then return false end
	if distance == 0 then return true end -- Already at the player

	local monsterForward = rootPart.CFrame.LookVector
	local toPlayerDirection = toPlayer.Unit
	local dotProduct = monsterForward:Dot(toPlayerDirection)
	
	if dotProduct <= 0 and VISION_ANGLE < 180 then return false end 
	if dotProduct > 1 then dotProduct = 1 elseif dotProduct < -1 then dotProduct = -1 end 

	local angle = math.deg(math.acos(dotProduct))

	if angle > (VISION_ANGLE / 2) then return false end

	local raycastParams = RaycastParams.new()
	raycastParams.FilterDescendantsInstances = {monster}
	raycastParams.FilterType = Enum.RaycastFilterType.Blacklist

	local raycastResult = workspace:Raycast(
		rootPart.Position,
		toPlayerDirection * distance,
		raycastParams
	)

	return not raycastResult or raycastResult.Instance:IsDescendantOf(character)
end

local function canSeePlayer(player)
	if player == lastPlayerKilled and (os.time() - lastKillTime) < 5 then
		return false
	end
	return isInVisionCone(player)
end

local function selectSearchWaypoint()
	if #waypoints == 0 then
		warn("Pathfinding script in "..monster.Name..": No waypoints found in "..waypointsFolder.Name..". Monster will be idle unless a player is seen.")
		return nil
	end

	if isSearching and lastKnownPlayerPosition then
		local closestWaypoint = nil
		local closestDistance = math.huge
		for _, waypoint in waypoints do
			if waypoint:IsA("BasePart") then
				local distance = (waypoint.Position - lastKnownPlayerPosition).Magnitude
				if distance < closestDistance then
					closestDistance = distance
					closestWaypoint = waypoint
				end
			end
		end
		if closestWaypoint then
			return closestWaypoint
		end
	end

	local potentialTargets = {}
	for _, waypoint in waypoints do
		if waypoint:IsA("BasePart") and waypoint ~= currentTarget then
			table.insert(potentialTargets, waypoint)
		end
	end

	if #potentialTargets > 0 then
		return potentialTargets[math.random(1, #potentialTargets)]
	end
	
	if #waypoints > 0 then
		return waypoints[math.random(1, #waypoints)]
	end
	return nil
end

local function updateTarget()
	if humanoid.Health <= 0 then return end 

	local closestPlayer = nil
	local closestDistance = VISION_RANGE + 1

	for _, player in Players:GetPlayers() do
		if player.Character and player.Character:FindFirstChild("HumanoidRootPart") and player.Character:FindFirstChildOfClass("Humanoid") and player.Character.Humanoid.Health > 0 then
			if canSeePlayer(player) then
				local distance = (rootPart.Position - player.Character.HumanoidRootPart.Position).Magnitude
				if distance < closestDistance then
					closestDistance = distance
					closestPlayer = player
				end
			end
		end
	end

	if closestPlayer then
		currentTarget = closestPlayer.Character.HumanoidRootPart
		isChasing = true
		isSearching = false
		humanoid.WalkSpeed = MONSTER_SPEED.Chasing
		currentPath = nil 
		lastSightTime = os.time()
		lastKnownPlayerPosition = closestPlayer.Character.HumanoidRootPart.Position
	else
		if isChasing then 
			isChasing = false
			isSearching = true 
			humanoid.WalkSpeed = MONSTER_SPEED.Normal
			currentTarget = selectSearchWaypoint()
			currentPath = nil
			lastPathUpdate = 0
		elseif isSearching then
			if (os.time() - lastSightTime) > SEARCH_MODE_DURATION then
				isSearching = false 
			end
			if not currentTarget or (currentTarget:IsA("BasePart") and (rootPart.Position - currentTarget.Position).Magnitude < WAYPOINT_REACH_DISTANCE) then
				currentTarget = selectSearchWaypoint()
				currentPath = nil
				lastPathUpdate = 0
			end
		else 
			if not currentTarget or (currentTarget:IsA("BasePart") and (rootPart.Position - currentTarget.Position).Magnitude < WAYPOINT_REACH_DISTANCE) then
				currentTarget = selectSearchWaypoint()
				currentPath = nil
				lastPathUpdate = 0
			end
		end
	end
	
	if not currentTarget and #waypoints > 0 then 
		currentTarget = selectSearchWaypoint()
		currentPath = nil
		lastPathUpdate = 0
	end
end

-- Kill handling
monster.HumanoidRootPart.Touched:Connect(function(hit)
	if not isChasing or humanoid.Health <= 0 then return end

	local hitModel = hit.Parent
	if not hitModel then return end
	
	local player = Players:GetPlayerFromCharacter(hitModel)
	if player and player.Character then
		local targetHumanoid = player.Character:FindFirstChildOfClass("Humanoid")
		if targetHumanoid and targetHumanoid.Health > 0 then
			if (rootPart.Position - player.Character.HumanoidRootPart.Position).Magnitude <= KILL_RANGE + 2 then 
				targetHumanoid.Health = 0
				lastPlayerKilled = player
				lastKillTime = os.time()

				isChasing = false
				humanoid.WalkSpeed = MONSTER_SPEED.Normal
				currentTarget = nil 
				currentPath = nil   
				lastPathUpdate = 0  
				
				task.spawn(updateTarget)
			end
		end
	end
end)

-- Main loop
RunService.Heartbeat:Connect(function(deltaTime)
	if not rootPart or not humanoid or humanoid.Health <= 0 then return end

	if os.time() % 0.5 < (deltaTime * 1.1) then 
		updateTarget()
	end

	if currentTarget then
		if isChasing then
			if currentTarget and currentTarget.Parent and currentTarget:IsA("BasePart") then
				local targetPosition = currentTarget.Position
				local directionToTarget = (targetPosition - rootPart.Position)
				
				if directionToTarget.Magnitude > 0.5 then 
					if humanoid:GetState() ~= Enum.HumanoidStateType.Running or
					   (humanoid.MoveDirection - directionToTarget.Unit).Magnitude > 0.2 then
						humanoid:MoveTo(targetPosition)
					end
				end
			else
				isChasing = false 
				currentTarget = nil 
				currentPath = nil
				lastPathUpdate = 0
				humanoid.WalkSpeed = MONSTER_SPEED.Normal
				updateTarget() 
			end
		else -- Patrolling or searching
			if currentTarget and currentTarget:IsA("BasePart") then
				local pathSuccess = followPath(currentTarget.Position)
				if not pathSuccess then
					-- followPath returned false. This means either:
					-- 1. Monster is stuck (currentPath was cleared by followPath, will recompute for SAME target next tick).
					-- 2. PathfindingService:ComputeAsync failed (e.g., NoPath).
					--    In this case, pathfinder.Status reflects the failure, and currentPath is nil.

					-- Only select a NEW currentTarget if the path was definitively impossible (NoPath).
					if currentPath == nil and pathfinder.Status == Enum.PathStatus.NoPath then
						-- warn(monster.Name .. " found NoPath to " .. currentTarget.Name .. ". Selecting new waypoint.")
						currentTarget = selectSearchWaypoint()
						-- currentPath is already nil from followPath's failure.
						lastPathUpdate = 0 -- Force immediate path computation for the new target.
					end
					-- If it was just "stuck" (and pathfinder.Status wasn't NoPath), 
					-- currentPath is nil (set by followPath's stuck detection or compute failure).
					-- The monster will keep the same currentTarget and followPath will try to recompute on the next Heartbeat.
				end
			elseif not currentTarget and #waypoints > 0 then
				-- No current target, but waypoints exist. Try to get one.
				currentTarget = selectSearchWaypoint()
				currentPath = nil
				lastPathUpdate = 0
			end
		end
	else
		if humanoid:GetState() == Enum.HumanoidStateType.Idle or humanoid:GetState() == Enum.HumanoidStateType.None then
			updateTarget()
		end
	end
end)

-- Initial target setup
if #waypoints > 0 then
	currentTarget = selectSearchWaypoint()
else
	warn(monster.Name .. " has no waypoints for initial setup. Will wait for player or target update.")
end
updateTarget() 


This is the freshly baked AI version. I know most people hate AI, but we were just trying to have fun. I would genuinely love to see this AI work efficiently, and learn something about AI in the process. Just the AI that we’re using has taught me a lot of basic stuff about Pathfinding.

Thanks a tons!

1 Like

I kinda need to know what the issue is? I mean I can just read the entire code and list off several issues that might not be needed?

1 Like

Please do… I need help just as much as you need input lol

  1. Sometimes the AI would flicker back and forth while walking towards a Waypoint.
  2. The AI would chase a Player right up until the Player went on top of an obstacle like a Table and then turn around, walk a few steps, and then stop completely. Just to begin moving again whenever a Player touched it and died.
  3. The AI will just continuously walk into an obstacle such as a wall whenever the targeted player is behind one.
    As you can read… I need significant help. Thank you for your time!

i actually have a solution for your first issue: (it’s due to HumanoidMoveToFinished):

local function MoveFinished(npc, position)

	local max = 5
	
	local start = tick()
	
	local distance = math.huge
	
	repeat
		
		task.wait()
		
		distance = (npc.HumanoidRootPart.Position - position).Magnitude
		
	until distance < max or tick() - start > 8
	
end

Just change the max until this works, and put in the waypoints position, and it should work pretty well (edit, i didnt see you already did something like this)

1 Like

Thanks for this. But I’m still trying to fix the other issues. If you have any info on how to fix the other bugs that would be amazing. But other than that, thanks a ton!