Advanced AI problem, stucks inside wall


The npc is working well in chase, but when i running to small rooms or inside part the npc just afk…

heres script

local NPC = script.Parent
local Humanoid: Humanoid = NPC:WaitForChild("Humanoid")
local HRP: BasePart = NPC:WaitForChild("HumanoidRootPart")

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

local rng = Random.new()

local PATROL_MIN_DELAY = 3
local PATROL_MAX_DELAY = 10
local PATROL_STEP_MIN = 7
local PATROL_STEP_MAX = 12
local PATROL_SLICE_MIN = 0.7
local PATROL_SLICE_MAX = 1.2
local PATROL_TRIES = 6

local DETECTION_RANGE = 30
local DROP_RANGE = 500
local REQUIRE_LINE_OF_SIGHT = true
local FOV_DEG = 120

local WALK_SPEED = 12
local CHASE_SPEED_MIN = 22
local CHASE_SPEED_MAX = 22
local CHASE_SPEED_BUFFER = 2

local DIRECT_UPDATE_DT = 0.12
local LEAD_TIME = 0.22

local PATH_REPATH_COOLDOWN = 0.22
local PATH_TARGET_MOVE = 2.8
local PATH_WP_REACH = 2.0
local PATH_MOVE_REISSUE = 0.65

local TARGET_REEVALUATE_DT = 0.35
local LOSE_TARGET_AFTER = 3.0

local SEARCH_MAX_TIME = 6.0
local SEARCH_WANDER_RADIUS = 10
local SEARCH_SLICE = 1.2

local STUCK_TIMEOUT = 0.85
local STUCK_MIN_MOVE = 0.12
local UNSTUCK_SIDESTEP = 3.8
local UNSTUCK_BACKSTEP = 2.8
local UNSTUCK_JUMP_COOLDOWN = 0.8
local UNSTUCK_TELEPORT_COOLDOWN = 1.4

pcall(function()
	HRP:SetNetworkOwner(nil)
end)

Humanoid.WalkSpeed = WALK_SPEED
Humanoid.AutoRotate = true
Humanoid.AutoJumpEnabled = false

local headPart: BasePart? = NPC:FindFirstChild("Head") :: any

local excludeSelf = { NPC }

local losParams = RaycastParams.new()
losParams.FilterType = Enum.RaycastFilterType.Exclude
losParams.FilterDescendantsInstances = excludeSelf

local groundParams = RaycastParams.new()
groundParams.FilterType = Enum.RaycastFilterType.Exclude
groundParams.FilterDescendantsInstances = excludeSelf

local overlapParams = OverlapParams.new()
overlapParams.FilterType = Enum.RaycastFilterType.Exclude
overlapParams.FilterDescendantsInstances = excludeSelf

local cosHalfFOV = math.cos(math.rad(FOV_DEG * 0.5))
local detectionRangeSq = DETECTION_RANGE * DETECTION_RANGE
local dropRangeSq = DROP_RANGE * DROP_RANGE

local function isAliveHumanoid(h: Humanoid?)
	return h ~= nil and h.Parent ~= nil and h.Health > 0
end

local function getModelHumanoidRoot(model: Model)
	local hum = model:FindFirstChildOfClass("Humanoid")
	local root = model:FindFirstChild("HumanoidRootPart")
	if hum and root and root:IsA("BasePart") then
		return hum, root
	end
	return nil, nil
end

local function hasLineOfSight(targetRoot: BasePart)
	if not headPart or not headPart.Parent then
		headPart = NPC:FindFirstChild("Head") :: any
	end
	local origin = (headPart and headPart:IsA("BasePart")) and headPart.Position or HRP.Position
	local direction = targetRoot.Position - origin
	local hit = workspace:Raycast(origin, direction, losParams)
	if not hit then
		return true
	end
	return hit.Instance and hit.Instance:IsDescendantOf(targetRoot.Parent)
end

local function inFOV(targetPos: Vector3)
	local toTarget = targetPos - HRP.Position
	local mag = toTarget.Magnitude
	if mag < 0.001 then
		return true
	end
	return HRP.CFrame.LookVector:Dot(toTarget) >= (mag * cosHalfFOV)
end

local function snapToGround(pos: Vector3): Vector3
	local origin = pos + Vector3.new(0, 10, 0)
	local hit = workspace:Raycast(origin, Vector3.new(0, -250, 0), groundParams)
	if hit then
		return Vector3.new(pos.X, hit.Position.Y, pos.Z)
	end
	return pos
end

local function canDetect(root: BasePart)
	local diff = root.Position - HRP.Position
	local d2 = diff:Dot(diff)
	if d2 > detectionRangeSq then
		return false
	end
	if not inFOV(root.Position) then
		return false
	end
	if REQUIRE_LINE_OF_SIGHT and not hasLineOfSight(root) then
		return false
	end
	return true
end

local function pickNearestTarget()
	local bestHum: Humanoid? = nil
	local bestRoot: BasePart? = nil
	local bestDistSq = math.huge
	local hrpPos = HRP.Position

	for _, plr in ipairs(Players:GetPlayers()) do
		local char = plr.Character
		if char and char ~= NPC then
			local hum, root = getModelHumanoidRoot(char)
			if isAliveHumanoid(hum) then
				local diff = root.Position - hrpPos
				local d2 = diff:Dot(diff)
				if d2 < bestDistSq then
					bestDistSq = d2
					bestHum = hum
					bestRoot = root
				end
			end
		end
	end

	return bestHum, bestRoot, (bestDistSq < math.huge) and math.sqrt(bestDistSq) or math.huge
end

local function buildPath()
	return PathfindingService:CreatePath({
		AgentRadius = 3,
		AgentHeight = 5,
		AgentCanJump = true,
		AgentCanClimb = true,
		WaypointSpacing = 4,
	})
end

local lastJumpAt = 0.0
local lastTeleportAt = 0.0

local function trySafeNudge()
	local now = os.clock()
	if now - lastTeleportAt < UNSTUCK_TELEPORT_COOLDOWN then
		return false
	end

	local base = HRP.Position
	local dirs = {
		Vector3.new(1,0,0), Vector3.new(-1,0,0),
		Vector3.new(0,0,1), Vector3.new(0,0,-1),
		Vector3.new(1,0,1).Unit, Vector3.new(-1,0,1).Unit,
		Vector3.new(1,0,-1).Unit, Vector3.new(-1,0,-1).Unit,
	}
	local radii = { 3.5, 5.5, 7.5 }

	for _, r in ipairs(radii) do
		for _, d in ipairs(dirs) do
			local flat = Vector3.new(d.X, 0, d.Z)
			local candXZ = base + flat * r
			local ground = snapToGround(Vector3.new(candXZ.X, base.Y, candXZ.Z))
			local gy = ground.Y
			local y = gy + Humanoid.HipHeight + (HRP.Size.Y * 0.5) + 0.6
			local cand = Vector3.new(candXZ.X, y, candXZ.Z)

			local parts = workspace:GetPartBoundsInBox(CFrame.new(cand), Vector3.new(3, 5, 3), overlapParams)
			local blocked = false
			for _, p in ipairs(parts) do
				if p and p:IsA("BasePart") and p.CanCollide then
					if p.Position.Y > gy + 0.25 then
						blocked = true
						break
					end
				end
			end

			if not blocked then
				lastTeleportAt = now
				local cf = CFrame.new(cand, cand + HRP.CFrame.LookVector)
				NPC:PivotTo(cf)
				return true
			end
		end
	end

	return false
end

local function doUnstuck()
	local now = os.clock()
	if now - lastJumpAt >= UNSTUCK_JUMP_COOLDOWN then
		lastJumpAt = now
		Humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
	end

	local right = HRP.CFrame.RightVector
	local forward = HRP.CFrame.LookVector

	local sideSign = (rng:NextInteger(0, 1) == 0) and -1 or 1
	local side = Vector3.new(right.X, 0, right.Z) * (UNSTUCK_SIDESTEP * sideSign)
	local back = Vector3.new(forward.X, 0, forward.Z) * (-UNSTUCK_BACKSTEP)

	Humanoid:MoveTo(HRP.Position + side)
	task.wait(0.12)
	Humanoid:MoveTo(HRP.Position + back)
	task.wait(0.12)

	if trySafeNudge() then
		task.wait(0.08)
	end
end

local function followPathSlice(destination: Vector3, maxTime: number, shouldAbort: (() -> boolean)?)
	local path = buildPath()
	local ok = pcall(function()
		path:ComputeAsync(HRP.Position, destination)
	end)
	if not ok or path.Status ~= Enum.PathStatus.Success then
		return false
	end

	local waypoints = path:GetWaypoints()
	if #waypoints == 0 then
		return false
	end

	local blocked = false
	local blockedConn = path.Blocked:Connect(function()
		blocked = true
	end)

	local function cleanup()
		if blockedConn then
			blockedConn:Disconnect()
		end
	end

	local started = os.clock()
	local stuckT = 0.0
	local lastPos = HRP.Position

	for _, wp in ipairs(waypoints) do
		if os.clock() - started >= maxTime then
			cleanup()
			return true
		end
		if shouldAbort and shouldAbort() then
			cleanup()
			return false
		end
		if blocked then
			cleanup()
			return false
		end

		if wp.Action == Enum.PathWaypointAction.Jump then
			Humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
		end

		local t = 0.0
		while (HRP.Position - wp.Position).Magnitude > PATH_WP_REACH do
			local dt = task.wait(0.05)

			if os.clock() - started >= maxTime then
				cleanup()
				return true
			end
			if shouldAbort and shouldAbort() then
				cleanup()
				return false
			end
			if blocked then
				cleanup()
				return false
			end

			local moved = (HRP.Position - lastPos).Magnitude
			lastPos = HRP.Position
			if moved < STUCK_MIN_MOVE then
				stuckT += dt
			else
				stuckT = 0
			end

			if stuckT >= STUCK_TIMEOUT then
				cleanup()
				doUnstuck()
				return false
			end

			t += dt
			if t >= PATH_MOVE_REISSUE then
				t = 0
				Humanoid:MoveTo(wp.Position)
			elseif t <= 0.06 then
				Humanoid:MoveTo(wp.Position)
			end
		end
	end

	cleanup()
	return true
end

local function directChaseStep(targetRoot: BasePart, maxTime: number, shouldAbort: (() -> boolean)?)
	local started = os.clock()
	local stuckT = 0.0
	local lastPos = HRP.Position

	while os.clock() - started < maxTime do
		if shouldAbort and shouldAbort() then
			return false
		end

		local diff = targetRoot.Position - HRP.Position
		if diff:Dot(diff) > dropRangeSq then
			return false
		end

		local v = targetRoot.AssemblyLinearVelocity
		local lead = Vector3.new(v.X, 0, v.Z) * LEAD_TIME
		Humanoid:MoveTo(targetRoot.Position + lead)

		local dt = task.wait(DIRECT_UPDATE_DT)

		local moved = (HRP.Position - lastPos).Magnitude
		lastPos = HRP.Position
		if moved < STUCK_MIN_MOVE then
			stuckT += dt
		else
			stuckT = 0
		end

		if stuckT >= STUCK_TIMEOUT then
			doUnstuck()
			return false
		end
	end

	return true
end

local state = "PATROL"
local nextPatrolAt = os.clock() + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)

local chaseHum: Humanoid? = nil
local chaseRoot: BasePart? = nil

local lastSeenAt = 0.0
local lastKnownPos = HRP.Position

local lastTargetEval = 0.0
local lastPathAt = 0.0
local lastPlannedPos = HRP.Position

local searchStartedAt = 0.0
local searchAnchor = HRP.Position

while isAliveHumanoid(Humanoid) do
	local now = os.clock()

	if state == "PATROL" then
		Humanoid.WalkSpeed = WALK_SPEED

		local hum, root, _ = pickNearestTarget()
		if hum and root and canDetect(root) then
			state = "CHASE"
			chaseHum, chaseRoot = hum, root
			lastSeenAt = now
			lastKnownPos = root.Position
			lastPathAt = 0
			lastPlannedPos = root.Position
		else
			if now >= nextPatrolAt then
				nextPatrolAt = now + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)

				local step = rng:NextNumber(PATROL_STEP_MIN, PATROL_STEP_MAX)
				local dir = Vector3.new(rng:NextNumber(-1, 1), 0, rng:NextNumber(-1, 1))
				if dir.Magnitude < 0.05 then
					dir = Vector3.new(1, 0, 0)
				end
				dir = dir.Unit

				local dest = HRP.Position + dir * step
				dest = snapToGround(Vector3.new(dest.X, HRP.Position.Y, dest.Z))
				local slice = rng:NextNumber(PATROL_SLICE_MIN, PATROL_SLICE_MAX)

				local tries = PATROL_TRIES
				while tries > 0 do
					local th, tr, _ = pickNearestTarget()
					if th and tr and canDetect(tr) then
						state = "CHASE"
						chaseHum, chaseRoot = th, tr
						lastSeenAt = now
						lastKnownPos = tr.Position
						lastPathAt = 0
						lastPlannedPos = tr.Position
						break
					end

					local ok = followPathSlice(dest, slice, function()
						local th2, tr2, _ = pickNearestTarget()
						return th2 and tr2 and canDetect(tr2)
					end)

					if ok then
						break
					end

					tries -= 1
					local ndir = Vector3.new(rng:NextNumber(-1, 1), 0, rng:NextNumber(-1, 1))
					if ndir.Magnitude < 0.05 then
						ndir = Vector3.new(0, 0, 1)
					end
					ndir = ndir.Unit
					dest = HRP.Position + ndir * step
					dest = snapToGround(Vector3.new(dest.X, HRP.Position.Y, dest.Z))
				end
			end
		end

	elseif state == "CHASE" then
		if not (chaseHum and chaseRoot and isAliveHumanoid(chaseHum)) then
			state = "SEARCH"
			searchStartedAt = now
			searchAnchor = lastKnownPos
			chaseHum, chaseRoot = nil, nil
		else
			if now - lastTargetEval >= TARGET_REEVALUATE_DT then
				lastTargetEval = now
				local nh, nr, _ = pickNearestTarget()
				if nh and nr and isAliveHumanoid(nh) then
					local curD = (chaseRoot.Position - HRP.Position).Magnitude
					local newD = (nr.Position - HRP.Position).Magnitude
					if newD + 3 < curD then
						chaseHum, chaseRoot = nh, nr
					end
				end
			end

			local diff = chaseRoot.Position - HRP.Position
			if diff:Dot(diff) > dropRangeSq then
				state = "PATROL"
				chaseHum, chaseRoot = nil, nil
				nextPatrolAt = now + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)
			else
				local los = (not REQUIRE_LINE_OF_SIGHT) or hasLineOfSight(chaseRoot)
				if los then
					lastSeenAt = now
				end
				lastKnownPos = chaseRoot.Position

				if now - lastSeenAt > LOSE_TARGET_AFTER then
					state = "SEARCH"
					searchStartedAt = now
					searchAnchor = lastKnownPos
					chaseHum, chaseRoot = nil, nil
				else
					local targetSpeed = chaseHum.WalkSpeed
					local desiredSpeed = math.clamp(math.max(CHASE_SPEED_MIN, targetSpeed + CHASE_SPEED_BUFFER), CHASE_SPEED_MIN, CHASE_SPEED_MAX)
					Humanoid.WalkSpeed = desiredSpeed

					if los then
						directChaseStep(chaseRoot, DIRECT_UPDATE_DT, function()
							return chaseRoot == nil or not chaseRoot.Parent
						end)
					else
						if now - lastPathAt >= PATH_REPATH_COOLDOWN then
							local planned = chaseRoot.Position
							if (planned - lastPlannedPos).Magnitude >= PATH_TARGET_MOVE then
								lastPlannedPos = planned
							end
							lastPathAt = now

							followPathSlice(lastPlannedPos, 0.9, function()
								if not (chaseHum and chaseRoot and isAliveHumanoid(chaseHum)) then
									return true
								end
								if (chaseRoot.Position - HRP.Position).Magnitude > DROP_RANGE then
									return true
								end
								if (not REQUIRE_LINE_OF_SIGHT) or hasLineOfSight(chaseRoot) then
									return true
								end
								if (chaseRoot.Position - lastPlannedPos).Magnitude > PATH_TARGET_MOVE then
									return true
								end
								return false
							end)
						else
							task.wait(0.05)
						end
					end
				end
			end
		end

	elseif state == "SEARCH" then
		Humanoid.WalkSpeed = WALK_SPEED

		local hum, root, _ = pickNearestTarget()
		if hum and root and canDetect(root) then
			state = "CHASE"
			chaseHum, chaseRoot = hum, root
			lastSeenAt = now
			lastKnownPos = root.Position
			lastPathAt = 0
			lastPlannedPos = root.Position
		else
			if now - searchStartedAt >= SEARCH_MAX_TIME then
				state = "PATROL"
				nextPatrolAt = now + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)
			else
				local target = searchAnchor
				if (HRP.Position - searchAnchor).Magnitude < 3.5 then
					local a = rng:NextNumber(0, math.pi * 2)
					local r = rng:NextNumber(4, SEARCH_WANDER_RADIUS)
					local off = Vector3.new(math.cos(a) * r, 0, math.sin(a) * r)
					target = searchAnchor + off
					target = snapToGround(Vector3.new(target.X, HRP.Position.Y, target.Z))
				end

				followPathSlice(target, SEARCH_SLICE, function()
					local th2, tr2, _ = pickNearestTarget()
					return th2 and tr2 and canDetect(tr2)
				end)
			end
		end
	end

	task.wait(0.03)
end

1 Like

i always use hitboxes that are only collidable with the NPC itself via collision groups so that this wont happen

1 Like

This sure does not look like three. What is up with those parts? Try a 5 there.

There is one trick that takes a bit of work but solves this completely.
Make one of your materials you are not using look like that floor, then use it to line the floor around the parts.

local function buildPath()
	return PathfindingService:CreatePath({
		AgentRadius = 3,
		AgentHeight = 5,
		AgentCanJump = true,
		AgentCanClimb = true,
		WaypointSpacing = 4,
		Costs = {
			[Enum.Material.Concrete] = math.huge
		}
	})
end

Now the NPCs can’t walk on that material at all. You can use that as a touch up with some work to cover problem spots.

1 Like

does it even work?
I remember playing with the value, but path around corners was always the same with different values.

1 Like

I updated script the ai is better than old but still dumb (btw its handler for all npc)

--!strict

local Players = game:GetService("Players")
local PathfindingService = game:GetService("PathfindingService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local PhysicsService = game:GetService("PhysicsService")

local EnemySystem = ReplicatedStorage:WaitForChild("EnemySystem")

local CombatLock = require(EnemySystem:WaitForChild("CombatLock"))
local EnemyMechanicsConfig = require(EnemySystem:WaitForChild("EnemyMechanicsConfig"))
local GrabService = require(EnemySystem:WaitForChild("GrabService"))
local Ragdoll = require(EnemySystem:WaitForChild("Ragdoll"))

local EnemyController = {}

type Profile = EnemyMechanicsConfig.Profile

type Controller = {
	npc: Model,
	humanoid: Humanoid,
	root: BasePart,
	profile: Profile,
	rng: Random,
	alive: boolean,
	busy: boolean,
	busyFreeze: boolean,
	parryActive: boolean,
	ignoreHealth: boolean,
	lastHealth: number,
	lastHitAt: number,
	lastParryAt: number,
	lastEvadeAt: number,
	nextHitIndex: number,
	currentTarget: Player?,
	externalMovement: boolean,
}

local controllersByNpc = setmetatable({}, { __mode = "k" }) :: { [Model]: Controller }

local function now(): number
	return os.clock()
end

local function isAliveHumanoid(h: Humanoid?): boolean
	return h ~= nil and h.Parent ~= nil and h.Health > 0
end

local function getHumanoidAndRoot(model: Model): (Humanoid?, BasePart?)
	local humanoid = model:FindFirstChildOfClass("Humanoid")
	local root = model:FindFirstChild("HumanoidRootPart") :: BasePart?
	return humanoid, root
end

local function loadTrack(humanoid: Humanoid, animId: number, looped: boolean?): AnimationTrack?
	local animator = humanoid:FindFirstChildOfClass("Animator")
	if not animator then
		animator = Instance.new("Animator")
		animator.Parent = humanoid
	end

	local animation = Instance.new("Animation")
	animation.AnimationId = "rbxassetid://" .. tostring(animId)

	local ok, track = pcall(function()
		return animator:LoadAnimation(animation)
	end)

	animation:Destroy()

	if not ok or not track then
		return nil
	end

	track.Priority = Enum.AnimationPriority.Action
	if looped ~= nil then
		track.Looped = looped
	end
	track:Play(0.05, 1, 1)
	return track
end

local function playStunAndFreeze(ctrl: Controller, animId: number)
	ctrl.busyFreeze = true

	local track = loadTrack(ctrl.humanoid, animId, false)
	local wt = 1.0
	if track then
		wt = math.clamp(track.Length, 0.3, 5.0)
	end

	task.wait(wt)
	stopTrack(track)
end


local function stopTrack(track: AnimationTrack?)
	if not track then
		return
	end
	pcall(function()
		track:Stop(0.1)
	end)
end

local function freezeNpc(ctrl: Controller, face: Vector3?)
	ctrl.humanoid.WalkSpeed = 0
	ctrl.humanoid:Move(Vector3.zero, false)

	ctrl.root.AssemblyLinearVelocity = Vector3.zero
	ctrl.root.AssemblyAngularVelocity = Vector3.zero

	if face then
		ctrl.root.CFrame = CFrame.new(
			ctrl.root.Position,
			Vector3.new(face.X, ctrl.root.Position.Y, face.Z)
		)
	end
end

local function maybeRest(ctrl: Controller, face: Vector3?)
	if ctrl.rng:NextNumber() >= ctrl.profile.RestChance then
		return
	end
	local secs = ctrl.rng:NextNumber(ctrl.profile.RestSecondsMin, ctrl.profile.RestSecondsMax)
	local t0 = now()
	while ctrl.alive and ctrl.busy and now() - t0 < secs do
		freezeNpc(ctrl, face)
		task.wait(0.05)
	end
end

local function startBusy(ctrl: Controller, freeze: boolean, fn: () -> ())
	if ctrl.busy or not ctrl.alive then
		return
	end

	ctrl.busy = true
	ctrl.busyFreeze = freeze
	ctrl.npc:SetAttribute("BusyMechanic", true)

	task.spawn(function()
		local ok, err = pcall(fn)
		if not ok then
			warn("[EnemyController] mechanic failed:", ctrl.npc:GetFullName(), err)
		end

		ctrl.npc:SetAttribute("BusyMechanic", false)
		ctrl.busy = false
		ctrl.busyFreeze = true
	end)
end

local function setGrabLight(npc: Model, lightName: string, enabled: boolean)
	local torso = npc:FindFirstChild("Torso") or npc:FindFirstChild("UpperTorso")
	if not torso then
		return
	end
	local light = torso:FindFirstChild(lightName)
	if light and light:IsA("PointLight") then
		light.Enabled = enabled
	end
end


local CORPSE_GROUP = "Corpse"

local function ensureCorpseGroup()
	pcall(function()
		PhysicsService:CreateCollisionGroup(CORPSE_GROUP)
	end)
	pcall(function()
		PhysicsService:CollisionGroupSetCollidable(CORPSE_GROUP, CORPSE_GROUP, false)
	end)
	pcall(function()
		PhysicsService:CollisionGroupSetCollidable(CORPSE_GROUP, "Default", true)
	end)
end

ensureCorpseGroup()

local function isNoCollideName(partName: string): boolean
	return partName == "HumanoidRootPart"
		or partName == "Torso"
		or partName == "UpperTorso"
		or partName == "LowerTorso"
end

local function setupCorpsePhysics(model: Model)
	for _, d in ipairs(model:GetDescendants()) do
		if d:IsA("BasePart") then
			d.Anchored = false

			d.CanCollide = (d.Name == "Torso")

			d.CanTouch = false
			d.CanQuery = false

			pcall(function()
				PhysicsService:SetPartCollisionGroup(d, CORPSE_GROUP)
			end)
		end
	end
end


local function fadeOutCorpse(model: Model, delaySeconds: number, fadeSeconds: number)
	task.delay(delaySeconds, function()
		if not model or not model.Parent then
			return
		end

		for _, d in ipairs(model:GetDescendants()) do
			if d:IsA("BasePart") then
				if d.Name ~= "HumanoidRootPart" then
					TweenService:Create(
						d,
						TweenInfo.new(fadeSeconds, Enum.EasingStyle.Linear),
						{ Transparency = 1 }
					):Play()
				end
			elseif d:IsA("Decal") or d:IsA("Texture") then
				TweenService:Create(
					d,
					TweenInfo.new(fadeSeconds, Enum.EasingStyle.Linear),
					{ Transparency = 1 }
				):Play()
			end
		end

		task.delay(fadeSeconds + 0.1, function()
			if model and model.Parent then
				model:Destroy()
			end
		end)
	end)
end

local function hideNpcInstant(model: Model)
	for _, d in ipairs(model:GetDescendants()) do
		if d:IsA("BasePart") then
			d.Transparency = 1
			d.CanCollide = false
			d.CanTouch = false
			d.CanQuery = false
		elseif d:IsA("Decal") or d:IsA("Texture") then
			d.Transparency = 1
		end
	end
end

local function spawnCorpseFromNpc(npcModel: Model, impulse: Vector3)
	local corpse = npcModel:Clone()
	corpse.Name = npcModel.Name .. "_Corpse"
	corpse:SetAttribute("IsCorpse", true)

	for _, d in ipairs(corpse:GetDescendants()) do
		if d:IsA("Script") or d:IsA("LocalScript") then
			d:Destroy()
		end
	end

	corpse.Parent = workspace

	setupCorpsePhysics(corpse)

	local hrp = corpse:FindFirstChild("HumanoidRootPart")
	if hrp and hrp:IsA("BasePart") then
		pcall(function()
			hrp:SetNetworkOwner(nil)
		end)
	end

	Ragdoll.Apply(corpse, impulse)

	fadeOutCorpse(corpse, 3, 5)
end


local function doHit(ctrl: Controller, targetPlayer: Player, targetHum: Humanoid, targetRoot: BasePart)
	startBusy(ctrl, false, function()
		local char = targetPlayer.Character
		if not char then
			return
		end
		if not CombatLock.TryLock(char, ctrl.npc, 2.0) then
			return
		end

		ctrl.lastHitAt = now()

		local hitAnimId = ctrl.profile.HitAnims[ctrl.nextHitIndex] or ctrl.profile.HitAnims[1]
		ctrl.nextHitIndex += 1
		if ctrl.nextHitIndex > #ctrl.profile.HitAnims then
			ctrl.nextHitIndex = 1
		end

		local track = loadTrack(ctrl.humanoid, hitAnimId, false)

		task.wait(math.max(0, ctrl.profile.HitWindup))

		ctrl.busyFreeze = true
		local stillClose = (targetRoot.Position - ctrl.root.Position).Magnitude <= (ctrl.profile.AttackRange + 1.5)
		if isAliveHumanoid(targetHum) and stillClose and ctrl.alive then
			local dmg = ctrl.rng:NextNumber(ctrl.profile.HitDamageMin, ctrl.profile.HitDamageMax)
			targetHum:TakeDamage(dmg)
		end
		task.wait(0.10)
		ctrl.busyFreeze = false

		local finishWait = 0.4
		if track then
			finishWait = math.clamp(track.Length, 0.2, 2.0)
		end
		task.wait(math.max(0, finishWait - 0.10))

		ctrl.busyFreeze = true
		maybeRest(ctrl, targetRoot.Position)
		ctrl.busyFreeze = false

		stopTrack(track)
		CombatLock.Unlock(char, ctrl.npc)
	end)
end

local function doParry(ctrl: Controller)
	startBusy(ctrl, false, function()
		ctrl.lastParryAt = now()
		ctrl.parryActive = true

		local track = loadTrack(ctrl.humanoid, ctrl.profile.ParryAnim, false)
		local waitT = 1.0
		if track then
			waitT = math.clamp(track.Length, 0.4, 3.0)
		end

		task.wait(waitT)
		stopTrack(track)

		ctrl.parryActive = false
	end)
end

local function doGrab(ctrl: Controller, targetPlayer: Player, targetRoot: BasePart)
	startBusy(ctrl, false, function()
		local char = targetPlayer.Character
		if not char then
			return
		end
		if not CombatLock.TryLock(char, ctrl.npc, 4.0) then
			return
		end

		local windup = loadTrack(ctrl.humanoid, ctrl.profile.GrabWindupAnim, false)
		setGrabLight(ctrl.npc, ctrl.profile.GrabLightName, true)

		local windupWait = 0.8
		if windup then
			windupWait = math.clamp(windup.Length, 0.3, 2.5)
		end

		task.wait(windupWait)

		setGrabLight(ctrl.npc, ctrl.profile.GrabLightName, false)
		stopTrack(windup)

		local stillClose = (targetRoot.Position - ctrl.root.Position).Magnitude <= ctrl.profile.GrabRange
		if not stillClose or not ctrl.alive then
			CombatLock.Unlock(char, ctrl.npc)
			return
		end

		ctrl.busyFreeze = true

		local ok, reason = GrabService.Start(ctrl.npc, targetPlayer, ctrl.profile)
		if not ok then
			CombatLock.Unlock(char, ctrl.npc)
			ctrl.busyFreeze = false
			return
		end

		ctrl.busyFreeze = false

		if reason == "Released" then
			playStunAndFreeze(ctrl, ctrl.profile.ParryStunAnim)

		end
	end)
end


local function start(ctrl: Controller)
	ctrl.alive = true
	ctrl.busy = false
	ctrl.busyFreeze = true
	ctrl.parryActive = false
	ctrl.ignoreHealth = false
	ctrl.lastHealth = ctrl.humanoid.Health

	pcall(function()
		ctrl.root:SetNetworkOwner(nil)
	end)

	ctrl.humanoid.AutoRotate = true
	ctrl.humanoid.AutoJumpEnabled = true

	ctrl.humanoid.Died:Connect(function()
		ctrl.alive = false
		ctrl.busy = false
		ctrl.busyFreeze = true
		ctrl.parryActive = false

		local impulse = (-ctrl.root.CFrame.LookVector * 45 + Vector3.new(0, 18, 0)) * ctrl.root.AssemblyMass

		spawnCorpseFromNpc(ctrl.npc, impulse)

		hideNpcInstant(ctrl.npc)
		task.defer(function()
			if ctrl.npc and ctrl.npc.Parent then
				ctrl.npc:Destroy()
			end
		end)
	end)

	ctrl.humanoid.HealthChanged:Connect(function(newHealth)
		if not ctrl.alive then
			return
		end
		if ctrl.ignoreHealth then
			ctrl.lastHealth = newHealth
			return
		end

		local old = ctrl.lastHealth
		ctrl.lastHealth = newHealth

		if newHealth >= old then
			return
		end

		local t = now()

		if ctrl.parryActive and ctrl.currentTarget and ctrl.currentTarget.Character then
			local tool = ctrl.currentTarget.Character:FindFirstChildOfClass("Tool")
			local toolName = tool and tool.Name or ""
			if ctrl.profile.ParryAllowedTools[toolName] then
				ctrl.ignoreHealth = true
				ctrl.humanoid.Health = old
				ctrl.ignoreHealth = false
				task.spawn(function()
					pcall(function()
						(GrabService :: any).StunPlayer(ctrl.currentTarget :: Player, ctrl.profile.ParryStunAnim)
					end)
				end)
				return
			end
		end

		if (t - ctrl.lastEvadeAt) >= ctrl.profile.EvadeCooldown and (not ctrl.busy) then
			if ctrl.rng:NextNumber() < ctrl.profile.EvadeChance then
				ctrl.lastEvadeAt = t
				ctrl.ignoreHealth = true
				ctrl.humanoid.Health = old
				ctrl.ignoreHealth = false

				startBusy(ctrl, false, function()
					local track = loadTrack(ctrl.humanoid, ctrl.profile.EvadeAnim, false)
					local wt = 0.8
					if track then
						wt = math.clamp(track.Length, 0.3, 2.5)
					end
					task.wait(wt)
					stopTrack(track)
				end)
			end
		end
	end)


	if not ctrl.externalMovement then
		local NPC = ctrl.npc
		local Humanoid = ctrl.humanoid
		local HRP = ctrl.root
		local rng = ctrl.rng

		local PATROL_MIN_DELAY = 3
		local PATROL_MAX_DELAY = 10
		local PATROL_STEP_MIN = 7
		local PATROL_STEP_MAX = 12
		local PATROL_MOVE_SLICE_MIN = 0.8
		local PATROL_MOVE_SLICE_MAX = 1.4
		local PATROL_TRIES = 5

		local DETECTION_RANGE = ctrl.profile.AggroRange
		local REQUIRE_LINE_OF_SIGHT = true
		local FOV_DEG = 140
		local CLOSE_DETECT_OVERRIDE = 12

		local WALK_SPEED = 12
		local CHASE_MIN_SPEED = 22
		local CHASE_MAX_SPEED = 34
		local CHASE_SPEED_BUFFER = 2

		local TARGET_SCAN_DT = 0.35
		local VISION_DT = 0.18

		local DIRECT_UPDATE_DT = 0.12
		local LEAD_TIME = 0.18

		local TRAIL_SAMPLE_DT = 0.20
		local TRAIL_MIN_DELTA = 1.8
		local TRAIL_MAX_POINTS = 40
		local TRAIL_REACH = 2.2

		local REPATH_COOLDOWN = 0.28
		local REPATH_TARGET_MOVE = 3.0
		local WAYPOINT_REACH = 2.2
		local MOVETO_REISSUE = 0.55

		local STUCK_CHECK_DT = 0.22
		local STUCK_MIN_MOVE = 0.12
		local STUCK_TIMEOUT = 0.9
		local UNSTUCK_SIDESTEP = 3.6
		local UNSTUCK_BACKSTEP = 2.8
		local UNSTUCK_JUMP_COOLDOWN = 0.8
		local ESCAPE_SLICE = 0.9
		local ESCAPE_RADIUS_MIN = 5
		local ESCAPE_RADIUS_MAX = 11
		local ESCAPE_TRIES = 10

		Humanoid.WalkSpeed = WALK_SPEED

		local Head = NPC:FindFirstChild("Head") :: BasePart?
		local excludeSelf = { NPC }

		local losParams = RaycastParams.new()
		losParams.FilterType = Enum.RaycastFilterType.Exclude
		losParams.FilterDescendantsInstances = excludeSelf

		local groundParams = RaycastParams.new()
		groundParams.FilterType = Enum.RaycastFilterType.Exclude
		groundParams.FilterDescendantsInstances = excludeSelf

		local overlapParams = OverlapParams.new()
		overlapParams.FilterType = Enum.RaycastFilterType.Exclude
		overlapParams.FilterDescendantsInstances = excludeSelf

		local detectionRangeSq = DETECTION_RANGE * DETECTION_RANGE
		local closeOverrideSq = CLOSE_DETECT_OVERRIDE * CLOSE_DETECT_OVERRIDE
		local cosHalfFOV = math.cos(math.rad(FOV_DEG * 0.5))

		local function isAlive(h: Humanoid?): boolean
			return h ~= nil and h.Parent ~= nil and h.Health > 0
		end

		local function getHumRoot(model: Model): (Humanoid?, BasePart?)
			local hum = model:FindFirstChildOfClass("Humanoid")
			local root = model:FindFirstChild("HumanoidRootPart")
			if hum and root and root:IsA("BasePart") then
				return hum, root
			end
			return nil, nil
		end

		local function snapToGround(pos: Vector3): Vector3
			local hit = workspace:Raycast(pos + Vector3.new(0, 10, 0), Vector3.new(0, -260, 0), groundParams)
			if hit then
				return Vector3.new(pos.X, hit.Position.Y, pos.Z)
			end
			return pos
		end

		local function hasLOS(targetRoot: BasePart): boolean
			if not Head or not Head.Parent then
				Head = NPC:FindFirstChild("Head") :: BasePart?
			end
			local origin = (Head and Head:IsA("BasePart")) and Head.Position or HRP.Position
			local direction = targetRoot.Position - origin
			local hit = workspace:Raycast(origin, direction, losParams)
			if not hit then
				return true
			end
			return hit.Instance ~= nil and hit.Instance:IsDescendantOf(targetRoot.Parent)
		end

		local function inFOV(targetPos: Vector3): boolean
			if FOV_DEG >= 360 then
				return true
			end
			local toT = targetPos - HRP.Position
			local mag = toT.Magnitude
			if mag < 0.001 then
				return true
			end
			return HRP.CFrame.LookVector:Dot(toT / mag) >= cosHalfFOV
		end

		local function canDetect(root: BasePart, doLOS: boolean): boolean
			local diff = root.Position - HRP.Position
			local d2 = diff:Dot(diff)
			if d2 > detectionRangeSq then
				return false
			end
			if d2 > closeOverrideSq then
				if not inFOV(root.Position) then
					return false
				end
				if REQUIRE_LINE_OF_SIGHT and doLOS and not hasLOS(root) then
					return false
				end
			end
			return true
		end

		type Candidate = { player: Player, hum: Humanoid, root: BasePart, d2: number }

		local MAX_DETECT_CANDIDATES = 6
		local function pickNearestDetectable(doLOS: boolean): (Player?, Humanoid?, BasePart?)
			local pos = HRP.Position
			local cands: { Candidate } = {}

			for _, plr in ipairs(Players:GetPlayers()) do
				local char = plr.Character
				if char and char ~= NPC then
					local hum, root = getHumRoot(char)
					if isAlive(hum) and root then
						local diff = root.Position - pos
						local d2 = diff:Dot(diff)
						cands[#cands + 1] = { player = plr, hum = hum :: Humanoid, root = root, d2 = d2 }
					end
				end
			end

			table.sort(cands, function(a: Candidate, b: Candidate)
				return a.d2 < b.d2
			end)

			local n = math.min(#cands, MAX_DETECT_CANDIDATES)
			for i = 1, n do
				local c = cands[i]
				if canDetect(c.root, doLOS) then
					return c.player, c.hum, c.root
				end
			end

			return nil, nil, nil
		end

		local agentRadius = math.clamp((HRP.Size.X + HRP.Size.Z) * 0.25 + 0.25, 1.6, 2.8)
		local pathObj = PathfindingService:CreatePath({
			AgentRadius = agentRadius,
			AgentHeight = 5,
			AgentCanJump = false,
			AgentCanClimb = false,
			WaypointSpacing = 3,
		})

		local pathWaypoints: { PathWaypoint } = {}
		local wpIndex = 1
		local pathBlocked = false
		local blockedConn: RBXScriptConnection? = nil
		local lastMoveToAt = 0.0

		local function clearPath()
			pathWaypoints = {}
			wpIndex = 1
			pathBlocked = false
			if blockedConn then
				blockedConn:Disconnect()
				blockedConn = nil
			end
		end

		local function computePath(dest: Vector3): boolean
			clearPath()
			local ok = pcall(function()
				pathObj:ComputeAsync(HRP.Position, dest)
			end)
			if not ok or pathObj.Status ~= Enum.PathStatus.Success then
				return false
			end
			pathWaypoints = pathObj:GetWaypoints()
			wpIndex = 1
			if #pathWaypoints == 0 then
				return false
			end
			blockedConn = pathObj.Blocked:Connect(function(idx)
				if idx >= wpIndex then
					pathBlocked = true
				end
			end)
			return true
		end

		local function stepPath(tNow: number): boolean
			if wpIndex > #pathWaypoints then
				return true
			end
			if pathBlocked then
				return false
			end

			local wp = pathWaypoints[wpIndex]
			if wp.Action == Enum.PathWaypointAction.Jump then
				Humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
			end

			local dist = (HRP.Position - wp.Position).Magnitude
			if dist <= WAYPOINT_REACH then
				wpIndex += 1
				return wpIndex > #pathWaypoints
			end

			if tNow - lastMoveToAt >= MOVETO_REISSUE then
				lastMoveToAt = tNow
				Humanoid:MoveTo(wp.Position)
			end

			return false
		end

		local trail: { [number]: Vector3? } = {}
		local trailHead = 0
		local trailTail = 0
		local trailCount = 0

		local function trailClear()
			for i = trailHead + 1, trailTail do
				trail[i] = nil
			end
			trailHead, trailTail, trailCount = 0, 0, 0
		end

		local function trailPush(v: Vector3)
			trailTail += 1
			trail[trailTail] = v
			trailCount += 1
			if trailCount > TRAIL_MAX_POINTS then
				trailHead += 1
				trail[trailHead] = nil
				trailCount -= 1
			end
			if trailHead > 20000 then
				local new: { [number]: Vector3? } = {}
				local n = 0
				for i = trailHead + 1, trailTail do
					n += 1
					new[n] = trail[i]
				end
				trail = new
				trailHead = 0
				trailTail = n
			end
		end

		local function trailPeek(): Vector3?
			if trailCount <= 0 then
				return nil
			end
			return trail[trailHead + 1]
		end

		local function trailPop(): Vector3?
			if trailCount <= 0 then
				return nil
			end
			trailHead += 1
			local v = trail[trailHead]
			trail[trailHead] = nil
			trailCount -= 1
			return v
		end

		local lastTrailAt = 0.0
		local lastTrailPos: Vector3? = nil

		local function trailRecord(tNow: number, pos: Vector3)
			if tNow - lastTrailAt < TRAIL_SAMPLE_DT then
				return
			end
			lastTrailAt = tNow
			if lastTrailPos == nil or (pos - lastTrailPos).Magnitude >= TRAIL_MIN_DELTA then
				trailPush(pos)
				lastTrailPos = pos
			end
		end

		local lastJumpAt = 0.0
		local function doUnstuck()
			local tNowU = now()
			if tNowU - lastJumpAt >= UNSTUCK_JUMP_COOLDOWN then
				lastJumpAt = tNowU
				Humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
			end

			local right = HRP.CFrame.RightVector
			local forward = HRP.CFrame.LookVector
			local sideSign = (rng:NextInteger(0, 1) == 0) and -1 or 1

			local side = Vector3.new(right.X, 0, right.Z) * (UNSTUCK_SIDESTEP * sideSign)
			local back = Vector3.new(forward.X, 0, forward.Z) * (-UNSTUCK_BACKSTEP)

			Humanoid:MoveTo(HRP.Position + side)
			task.wait(0.12)
			Humanoid:MoveTo(HRP.Position + back)
			task.wait(0.12)
		end

		local function findEscapePoint(): Vector3?
			local base = HRP.Position
			for _ = 1, ESCAPE_TRIES do
				local a = rng:NextNumber(0, math.pi * 2)
				local r = rng:NextNumber(ESCAPE_RADIUS_MIN, ESCAPE_RADIUS_MAX)
				local candXZ = base + Vector3.new(math.cos(a) * r, 0, math.sin(a) * r)
				local ground = snapToGround(Vector3.new(candXZ.X, base.Y, candXZ.Z))
				local y = ground.Y + Humanoid.HipHeight + (HRP.Size.Y * 0.5) + 0.6
				local cand = Vector3.new(candXZ.X, y, candXZ.Z)

				local parts = workspace:GetPartBoundsInBox(
					CFrame.new(cand),
					Vector3.new(agentRadius * 2.2, 5, agentRadius * 2.2),
					overlapParams
				)

				local blocked = false
				for _, p in ipairs(parts) do
					if p and p:IsA("BasePart") and p.CanCollide then
						if p.Position.Y > ground.Y + 0.25 then
							blocked = true
							break
						end
					end
				end
				if not blocked then
					return cand
				end
			end
			return nil
		end

		local function escapeMove(sliceTime: number): boolean
			local p = findEscapePoint()
			if not p then
				return false
			end
			if not computePath(p) then
				Humanoid:MoveTo(p)
				task.wait(sliceTime)
				return true
			end
			local untilT = now() + sliceTime
			while now() < untilT do
				local tNowE = now()
				if pathBlocked then
					break
				end
				local done = stepPath(tNowE)
				if done then
					break
				end
				task.wait(0.03)
			end
			clearPath()
			return true
		end

		local function updateChaseSpeed(targetHum: Humanoid)
			local ts = targetHum.WalkSpeed
			Humanoid.WalkSpeed =
				math.clamp(math.max(CHASE_MIN_SPEED, ts + CHASE_SPEED_BUFFER), CHASE_MIN_SPEED, CHASE_MAX_SPEED)
		end

		local state = "PATROL_IDLE"

		local nextPatrolAt = now() + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)
		local patrolEndAt = 0.0
		local patrolGoal = HRP.Position
		local patrolTriesLeft = 0

		local targetPlayer: Player? = nil
		local targetHum: Humanoid? = nil
		local targetRoot: BasePart? = nil

		local lastScanAt = 0.0
		local lastVisionAt = 0.0
		local cachedLOS = false

		local lastKnownPos = HRP.Position
		local lastDirectAt = 0.0
		local lastRepathAt = 0.0
		local lastPlannedGoal = HRP.Position

		local lastStuckCheckAt = 0.0
		local stuckT = 0.0
		local lastMovePos = HRP.Position
		local stuckStrikes = 0

		local function tryMechanics()
			if not (targetPlayer and targetHum and targetRoot) then
				return
			end
			if not ctrl.alive or ctrl.busy then
				return
			end

			ctrl.currentTarget = targetPlayer

			local char = targetPlayer.Character
			if char and CombatLock.IsLocked(char, ctrl.npc) then
				return
			end

			local dist = (targetRoot.Position - HRP.Position).Magnitude
			local tNow2 = now()

			local canAttack = (tNow2 - ctrl.lastHitAt) >= ctrl.profile.HitCooldown
			local inHitRange = dist <= ctrl.profile.AttackRange
			local inGrabRange = dist <= ctrl.profile.GrabRange

			if canAttack and inGrabRange and ctrl.rng:NextNumber() < ctrl.profile.GrabChance then
				doGrab(ctrl, targetPlayer, targetRoot)
			elseif canAttack and inHitRange then
				doHit(ctrl, targetPlayer, targetHum, targetRoot)
			elseif (tNow2 - ctrl.lastParryAt) >= ctrl.profile.ParryCooldown
				and ctrl.rng:NextNumber() < ctrl.profile.ParryChance then
				doParry(ctrl)
			end
		end

		task.spawn(function()
			while ctrl.alive and isAlive(Humanoid) do
				local tNow = now()

				if ctrl.busy and ctrl.busyFreeze then
					freezeNpc(ctrl, targetRoot and targetRoot.Position or nil)
					task.wait(0.03)
					continue
				end

				if state == "PATROL_IDLE" then
					Humanoid.WalkSpeed = WALK_SPEED

					if tNow - lastScanAt >= TARGET_SCAN_DT then
						lastScanAt = tNow
						local plr, hum, root = pickNearestDetectable(true)
						if hum and root and plr then
							targetPlayer, targetHum, targetRoot = plr, hum, root
							trailClear()
							lastTrailPos = nil
							clearPath()
							lastKnownPos = root.Position
							state = "CHASE"
						end
					end

					if state == "PATROL_IDLE" and tNow >= nextPatrolAt then
						nextPatrolAt = tNow + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)
						patrolEndAt = tNow + rng:NextNumber(PATROL_MOVE_SLICE_MIN, PATROL_MOVE_SLICE_MAX)
						patrolTriesLeft = PATROL_TRIES

						local step = rng:NextNumber(PATROL_STEP_MIN, PATROL_STEP_MAX)
						local dir = Vector3.new(rng:NextNumber(-1, 1), 0, rng:NextNumber(-1, 1))
						if dir.Magnitude < 0.05 then
							dir = Vector3.new(1, 0, 0)
						end
						dir = dir.Unit

						local dest = HRP.Position + dir * step
						patrolGoal = snapToGround(Vector3.new(dest.X, HRP.Position.Y, dest.Z))

						if computePath(patrolGoal) then
							state = "PATROL_MOVE"
						end
					end

				elseif state == "PATROL_MOVE" then
					Humanoid.WalkSpeed = WALK_SPEED

					if tNow - lastScanAt >= TARGET_SCAN_DT then
						lastScanAt = tNow
						local plr, hum, root = pickNearestDetectable(true)
						if hum and root and plr then
							targetPlayer, targetHum, targetRoot = plr, hum, root
							trailClear()
							lastTrailPos = nil
							clearPath()
							lastKnownPos = root.Position
							state = "CHASE"
						end
					end

					if state == "PATROL_MOVE" then
						if tNow >= patrolEndAt then
							clearPath()
							state = "PATROL_IDLE"
						else
							local done = stepPath(tNow)
							if done then
								clearPath()
								state = "PATROL_IDLE"
							elseif pathBlocked then
								clearPath()
								patrolTriesLeft -= 1
								if patrolTriesLeft <= 0 then
									state = "PATROL_IDLE"
								else
									local step2 = rng:NextNumber(PATROL_STEP_MIN, PATROL_STEP_MAX)
									local nd = Vector3.new(rng:NextNumber(-1, 1), 0, rng:NextNumber(-1, 1))
									if nd.Magnitude < 0.05 then
										nd = Vector3.new(0, 0, 1)
									end
									nd = nd.Unit
									local dest2 = HRP.Position + nd * step2
									patrolGoal = snapToGround(Vector3.new(dest2.X, HRP.Position.Y, dest2.Z))
									computePath(patrolGoal)
								end
							end
						end
					end

				elseif state == "CHASE" then
					if not (targetHum and targetRoot and isAlive(targetHum) and targetRoot.Parent) then
						targetPlayer, targetHum, targetRoot = nil, nil, nil
						trailClear()
						lastTrailPos = nil
						clearPath()
						state = "PATROL_IDLE"
						nextPatrolAt = tNow + rng:NextNumber(PATROL_MIN_DELAY, PATROL_MAX_DELAY)
					else
						if tNow - lastVisionAt >= VISION_DT then
							lastVisionAt = tNow
							cachedLOS = (not REQUIRE_LINE_OF_SIGHT) or hasLOS(targetRoot)
						end

						lastKnownPos = targetRoot.Position
						trailRecord(tNow, lastKnownPos)
						updateChaseSpeed(targetHum)

						if tNow - lastStuckCheckAt >= STUCK_CHECK_DT then
							lastStuckCheckAt = tNow
							local moved = (HRP.Position - lastMovePos).Magnitude
							lastMovePos = HRP.Position
							if moved < STUCK_MIN_MOVE then
								stuckT += STUCK_CHECK_DT
							else
								stuckT = 0
								stuckStrikes = 0
							end
							if stuckT >= STUCK_TIMEOUT then
								stuckT = 0
								stuckStrikes += 1
								doUnstuck()
								clearPath()
								if stuckStrikes >= 2 then
									escapeMove(ESCAPE_SLICE)
								end
							end
						end

						if cachedLOS then
							clearPath()
							local v = targetRoot.AssemblyLinearVelocity
							local lead = Vector3.new(v.X, 0, v.Z) * LEAD_TIME
							local aim = targetRoot.Position + lead
							if tNow - lastDirectAt >= DIRECT_UPDATE_DT then
								lastDirectAt = tNow
								Humanoid:MoveTo(aim)
							end
						else
							local goal = trailPeek() or lastKnownPos
							if goal and (HRP.Position - goal).Magnitude <= TRAIL_REACH then
								trailPop()
								goal = trailPeek() or lastKnownPos
							end

							local needRepath = false
							if tNow - lastRepathAt >= REPATH_COOLDOWN then
								if pathBlocked then
									needRepath = true
								elseif (goal - lastPlannedGoal).Magnitude >= REPATH_TARGET_MOVE then
									needRepath = true
								elseif #pathWaypoints == 0 then
									needRepath = true
								end
							end

							if needRepath then
								lastRepathAt = tNow
								lastPlannedGoal = snapToGround(Vector3.new(goal.X, goal.Y, goal.Z))
								local ok = computePath(lastPlannedGoal)
								if not ok then
									clearPath()
									Humanoid:MoveTo(goal)
								end
							end

							if #pathWaypoints > 0 then
								local done = stepPath(tNow)
								if done then
									clearPath()
								end
							end
						end

						tryMechanics()
					end
				end

				task.wait(0.03)
			end
		end)
	end
end

function EnemyController.Attach(npcModel: Model)
	assert(RunService:IsServer(), "EnemyController.Attach must be called on server")
	if controllersByNpc[npcModel] then
		return
	end

	local humanoid, root = getHumanoidAndRoot(npcModel)
	if not (humanoid and root) then
		return
	end

	local profile = EnemyMechanicsConfig.GetProfile(npcModel)
	if not profile.Enabled then
		return
	end

	humanoid.BreakJointsOnDeath = false

	local externalMovement = false
	for _, child in ipairs(npcModel:GetChildren()) do
		if child:IsA("Script") and child.Enabled then
			externalMovement = true
			break
		end
	end

	local ctrl: Controller = {
		npc = npcModel,
		humanoid = humanoid,
		root = root,
		profile = profile,
		rng = Random.new(math.floor(now() * 1000) % 1000000),
		alive = true,
		busy = false,
		busyFreeze = true,
		parryActive = false,
		ignoreHealth = false,
		lastHealth = humanoid.Health,
		lastHitAt = 0,
		lastParryAt = 0,
		lastEvadeAt = 0,
		nextHitIndex = 1,
		currentTarget = nil,
		externalMovement = externalMovement,
	}

	controllersByNpc[npcModel] = ctrl
	start(ctrl)
end

return EnemyController

I’m not sure If that is set up right. I know the Costs thing is worth looking into. There are always dead spots.

I made NPC passengers for an airport game.

Hitboxes are easy and scalable solutions since i do not trust robloxs AI Pathfinding to not find any random spots inside some shelf or something. Rather have clean limitations than just hoping that roblox just wont see a path trough a small hole he cant fit through.

If you dont wanna do that then the only thing i can come up with is to just increase the AgentRadius mentioned above. I wouldnt trust it anyways.