
Roblox Npcs
- 32 installs
- 11 repo stars
- Updated August 3, 2026
- nonlooped/roblox-suite
Build Roblox NPCs and enemy AI with PathfindingService, agent parameters, waypoint handling, state machines, behavior trees, and humanoid movement.
About
Covers Roblox pathfinding and NPC AI including PathfindingService, agent parameters, waypoint actions, blocked-path handling, PathfindingModifiers, plus NPC design patterns like state machines, behavior trees, and follow/patrol/chase. A developer uses it when building NPCs, enemy AI, companions, or any agent that navigates the 3D world.
- PathfindingService with agent parameters and waypoint actions
- NPC state machines, behavior trees, and obstacle avoidance
Roblox Npcs by the numbers
- 32 all-time installs (skills.sh)
- +14 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #178 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nonlooped/roblox-suite --skill roblox-npcsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 32 |
|---|---|
| repo stars | ★ 11 |
| Last updated | August 3, 2026 |
| Repository | nonlooped/roblox-suite ↗ |
What it does
Build Roblox NPCs and enemy AI with PathfindingService, agent parameters, waypoint handling, state machines, behavior trees, and humanoid movement.
Files
roblox-npcs
Official sources (always check these for the latest):
- https://create.roblox.com/docs/en-us/characters/pathfinding
- https://create.roblox.com/docs/en-us/workspace/streaming
- Engine classes:
PathfindingService,Path,PathWaypoint,PathfindingModifier,PathfindingLink,Humanoid
This skill covers navigation mesh pathfinding and the AI patterns that use it. It does not cover custom A* implementations unless absolutely necessary — PathfindingService is the official, optimized solution.
When to use this skill
Activate when:
- Building zombies, guards, pets, companions, or any AI that walks/follows/patrols.
- Tuning agent size, jump/climb ability, or preferred terrain.
- Handling dynamic obstacles and blocked paths.
- Using
PathfindingModifierregions/links for doors, traps, ladders, boats. - Scaling pathfinding for many agents.
Cross-reference:
- roblox-core/SKILL.md for services and Humanoid basics.
- roblox-networking/SKILL.md for server-authoritative AI.
- roblox-physics/SKILL.md for custom non-humanoid rigs and mover constraints.
- roblox-testing/SKILL.md for profiling AI cost.
PathfindingService basics
Create a path:
local PathfindingService = game:GetService("PathfindingService")
local path = PathfindingService:CreatePath({
AgentRadius = 2,
AgentHeight = 5,
AgentCanJump = true,
AgentCanClimb = false,
WaypointSpacing = 4,
Costs = {
Water = 20,
DangerZone = math.huge,
}
})
path.CalculationSecondsTimeout = 1Compute and follow:
local humanoid = character:WaitForChild("Humanoid")
local rootPart = character:WaitForChild("HumanoidRootPart")
local success, err = pcall(function()
path:ComputeAsync(rootPart.Position, endPos)
end)
if success and path.Status == Enum.PathStatus.Success then
local waypoints = path:GetWaypoints()
-- follow waypoints with Humanoid:Move()
endAgent parameters
| Parameter | Default | Purpose |
|---|---|---|
AgentRadius | 2 studs | Minimum clearance from obstacles |
AgentHeight | 5 studs | Vertical clearance |
AgentCanJump | true | Allows jump waypoints |
AgentCanClimb | false | Allows climbing truss parts |
WaypointSpacing | 4 studs | Distance between intermediate waypoints |
Costs | nil | Material/region/link traversal cost |
Path.CalculationSecondsTimeout limits how long the solver may run per ComputeAsync call. Set it after CreatePath and before computing.
PathWaypoint actions
Each waypoint has a Position and an Action:
Enum.PathWaypointAction.Walk— normal movement.Enum.PathWaypointAction.Jump— trigger jump.- Custom labels like
"Climb"or"UseBoat"from PathfindingModifiers/Links.
Pathfinding modifiers
PathfindingModifier instances on anchored, non-colliding parts let you influence path cost:
Label— key used inCoststable.PassThrough— iftrue, the volume is ignored by the navmesh and treated as traversable empty space (e.g., zombies "hearing" through doors).
Example:
local path = PathfindingService:CreatePath({
Costs = {
Water = 20,
DangerZone = math.huge,
UseBoat = 2,
}
})Pathfinding links
PathfindingLink connects two Attachments with a custom label and cost, allowing paths across normally untraversable gaps.
Use for:
- Boats across water
- Teleporters
- Ladders
- One-way jumps
Your movement code checks the waypoint label and runs the custom traversal logic.
Movement patterns
Follow
Continuously recompute a path to a moving target. Throttle recomputation (e.g., every 0.5–1 s) and only recompute if the target moved far enough.
Patrol
Cycle through a list of fixed points. Recompute when blocked.
Chase
Like follow, but validate line-of-sight and distance server-side. Don't trust client-reported positions for authoritative AI.
State machine
Common NPC states: Idle, Patrol, Chase, Attack, Return. Each state handles its own path computation and Humanoid control.
Streaming compatibility
- Server-side scripts have full world state and can compute paths to any part.
- Client-side scripts may fail if the destination has streamed out. Use
workspace.PersistentLoadedand persistent models for client path destinations. - Recompute paths when dynamic/streamed obstacles block the way.
Limitations
- Direct line-of-sight distance ≤ 3,000 studs.
- Computation node budget ≈ 20,000 nodes.
- Waypoint Y coordinate must be between -65,536 and +65,536 studs.
- Incompatible parameters (e.g.,
AgentCanJump = falseto a jump-only destination) will fail.
Performance at scale
- Recompute paths on a staggered schedule, not every frame.
- Share target positions across similar NPCs when possible.
- Use
WaypointSpacing = math.hugeto reduce intermediate waypoints for long straight runs. - Consider simplifying agent geometry or using fewer active agents.
- For very large worlds, split into regions or use local patrol paths.
Common mistakes this skill prevents
- Computing paths every frame.
- Ignoring blocked-path events and letting NPCs walk into walls.
- Trusting client position for authoritative AI.
- Forgetting
pcallaroundComputeAsync. - Using material names incorrectly in
Costs(must matchEnum.Materialnames as strings).
Scripts
scripts/NPCPathFollower.lua— Humanoid-based path follower with blocked-path recompute, custom-label support, and connection cleanup.scripts/PatrolBehavior.lua— state-driven patrol/chase behavior with spatial detection and throttled recomputation.scripts/PathfindingUtility.lua— helpers for throttled recomputation and waypoint formatting.
Best practices
- Set
Path.CalculationSecondsTimeoutafterCreatePathto cap solver time. - Always set an explicit
Humanoid:MoveTotimeout and cancel it when the waypoint is reached or the follower is stopped. - Detect targets with spatial queries such as
workspace:GetPartBoundsInRadiusinstead of scanning every player each frame. - Stop path followers and clean up
Heartbeatconnections when theHumanoiddies or the NPC is destroyed. - For respawning NPCs, create a new behavior instance for the new character model and
Destroythe old one. - Use
PathfindingLinklabels to trigger custom traversal logic (boats, teleporters, ladders). The follower invokes a registered handler; if none exists, the waypoint falls back to normal movement. - To enable climbing, set
AgentCanClimb = trueand provideTrussPartsurfaces. Climb waypoints have theLabel"Climb". PathfindingModifierparts must beAnchored = trueandCanCollide = false.
How to proceed
1. Define the agent's size and movement abilities. 2. Build the world with modifiers/links for special regions. 3. Implement a path-follower that handles waypoints, jumps, and blocked events. 4. Layer a state machine for complex behaviors. 5. Run on the server for authoritative AI; use client only for visual prediction. 6. Profile with MicroProfiler and stagger recomputation for many agents.
Modifiers, Links, and Streaming
Official guide: https://create.roblox.com/docs/en-us/characters/pathfinding
PathfindingModifier
A PathfindingModifier is an instance placed on an anchored, non-colliding part to influence path cost or traversability.
Properties:
Label— string key referenced inCreatePathCosts.PassThrough— iftrue, the volume is ignored by the navmesh and treated as traversable empty space. The pathfinder can route straight through it; your NPC code is responsible for actually opening the door, climbing, etc.
Region modifier example
To mark a dangerous zone that NPCs should avoid:
1. Create an anchored part covering the region. 2. Set CanCollide = false. 3. Add a PathfindingModifier with Label = "DangerZone". 4. In CreatePath:
Costs = {
DangerZone = math.huge,
}Pass-through example
To let a zombie path through a closed door it cannot actually open:
1. Create an anchored part covering the door. 2. Set CanCollide = false. 3. Add a PathfindingModifier with PassThrough = true.
The path will route through the door; your NPC code can then play an animation or sound.
PathfindingLink
Links connect two Attachments and allow traversal that the navmesh would not normally support.
Setup: 1. Create Attachment0 and Attachment1 on different parts. 2. Create a PathfindingLink in workspace. 3. Set Attachment0, Attachment1, and Label. 4. Optionally set IsBidirectional.
local link = Instance.new("PathfindingLink")
link.Attachment0 = attachmentA
link.Attachment1 = attachmentB
link.Label = "UseBoat"
link.IsBidirectional = true
link.Parent = workspaceIn your pathfinder:
Costs = {
Water = 20,
UseBoat = 2,
}When the waypoint label is "UseBoat", trigger your custom action, then continue along the path:
local function onUseBoat(agent, waypoint)
local boat = findBoatNear(waypoint.Position)
seatAgent(agent, boat)
moveBoat(boat, waypoint.Position)
unseatAgent(agent)
end
-- In your path follower:
if waypoint.Label == "UseBoat" then
onUseBoat(agent, waypoint)
endStreaming compatibility
Server-side pathfinding is unaffected by streaming.
Client-side pathfinding:
- Compute paths to persistent models when possible.
- Listen for
workspace.PersistentLoaded. - Recompute paths if streamed obstacles block the way.
workspace.PersistentLoaded:Connect(function(persistentModel)
if persistentModel.Name == "ImportantDestination" then
-- safe to reference now
end
end)Limitations recap
- Max direct distance: 3,000 studs.
- Max nodes: ~20,000.
- Vertical waypoint range: ±65,536 studs.
- Parameter incompatibility causes failures.
NPC Behavior Patterns
State machine
A state machine is the most maintainable pattern for NPCs:
Idle → Patrol → Alert → Chase → Attack → ReturnEach state:
- Decides when to transition.
- Controls the Humanoid or mover constraints.
- Manages its own path computation.
Follow behavior
Recompute path to a moving target on a staggered interval:
local lastRecompute = 0
local RECOMPUTE_INTERVAL = 0.5
local MIN_MOVE_DIST = 5
RunService.Heartbeat:Connect(function()
local now = time()
if now - lastRecompute < RECOMPUTE_INTERVAL then return end
local hrp = target:FindFirstChild("HumanoidRootPart")
if not hrp then return end
if (hrp.Position - lastTargetPos).Magnitude < MIN_MOVE_DIST then return end
lastRecompute = now
lastTargetPos = hrp.Position
followPath(hrp.Position)
end)Spatial target detection
Instead of scanning every player each frame, use a spatial query and validate the result:
local function findTarget(rootPart, detectionRange)
local characters = {}
for _, player in ipairs(Players:GetPlayers()) do
if player.Character then
table.insert(characters, player.Character)
end
end
if #characters == 0 then return nil end
local params = OverlapParams.new()
params.FilterDescendantsInstances = characters
params.FilterType = Enum.RaycastFilterType.Whitelist
local parts = workspace:GetPartBoundsInRadius(rootPart.Position, detectionRange, params)
for _, part in ipairs(parts) do
local character = part:FindFirstAncestorOfClass("Model")
if not character then continue end
local hrp = character:FindFirstChild("HumanoidRootPart")
local humanoid = character:FindFirstChild("Humanoid")
if hrp and humanoid and humanoid.Health > 0 then
return character
end
end
return nil
endPatrol behavior
Loop through waypoints. Recompute path between points if blocked.
Chase behavior
Server-authoritative chase:
- Detect target on server (line of sight, distance).
- Recompute path toward server-known target position.
- Validate target reach server-side before triggering attack.
Never trust a client that says "I am here, hit me."
Group behavior
For groups of NPCs:
- Share a target position among squad members.
- Use formation offsets so they don't stack.
- Stagger recomputation to avoid spikes.
Death and respawn handling
Stop all pathfinding and AI updates when the NPC dies:
humanoid.Died:Connect(function()
behavior:Destroy()
end)When an NPC respawns with a new character model, create a fresh behavior instance for that model and Destroy the old one. Do not reuse followers across models because connections and Humanoid references become stale.
Cleanup / Destroy pattern
Every behavior that connects to RunService.Heartbeat, Humanoid events, or Path.Blocked should expose a Destroy method that disconnects everything and releases references:
function Behavior:Destroy()
if self.connection then
self.connection:Disconnect()
self.connection = nil
end
if self.follower then
self.follower:Destroy()
self.follower = nil
end
self.model = nil
self.humanoid = nil
endNon-humanoid agents
For agents without a Humanoid, use AlignPosition/AlignOrientation or LinearVelocity to move a root part along waypoints. See roblox-physics/SKILL.md.
Humanoid tuning
Humanoid.WalkSpeedcontrols movement speed.Humanoid.JumpPower/Humanoid.JumpHeightcontrol jumps.- Call
Humanoid:Move(Vector3)for direction-based movement. - Use
Humanoid:MoveTo(position)for simple point-to-point movement without pathfinding.
Common anti-patterns
- Running pathfinding on every Heartbeat.
- Moving NPCs on the client and trusting their position.
- Ignoring
Blockedevents. - Spawning too many agents without staggering.
- Using
Humanoid:MoveTofor long-distance navigation without pathfinding.
Pathfinding Service Details
Official guide: https://create.roblox.com/docs/en-us/characters/pathfinding
Creating a path
local PathfindingService = game:GetService("PathfindingService")
local path = PathfindingService:CreatePath({
AgentRadius = 2,
AgentHeight = 5,
AgentCanJump = true,
AgentCanClimb = false,
WaypointSpacing = 4,
Costs = {
Water = 20,
CrackedLava = 100,
DangerZone = math.huge,
}
})
path.CalculationSecondsTimeout = 1Computing the path
Set a solver timeout after creating the path, then wrap ComputeAsync in pcall:
path.CalculationSecondsTimeout = 1
local rootPart = character:WaitForChild("HumanoidRootPart")
local ok, err = pcall(function()
path:ComputeAsync(rootPart.Position, endPos)
end)
if not ok or path.Status ~= Enum.PathStatus.Success then
warn("Path computation failed:", err or path.Status)
return nil
end
local waypoints = path:GetWaypoints()PathWaypoint structure
for i, waypoint in ipairs(waypoints) do
print(i, waypoint.Position, waypoint.Action, waypoint.Label)
endPosition—Vector3target.Action—Enum.PathWaypointAction.Label— custom string from modifiers/links.
Blocked paths
Connect to path.Blocked and only recompute if the blocked waypoint is ahead. Disconnect the old connection before recomputing to avoid duplicate listeners:
local blockedConnection
local function followPath(targetPosition)
-- compute path, get waypoints, currentWaypointIndex ...
if blockedConnection then
blockedConnection:Disconnect()
blockedConnection = nil
end
blockedConnection = path.Blocked:Connect(function(blockedIndex)
if blockedIndex >= currentWaypointIndex then
blockedConnection:Disconnect()
blockedConnection = nil
followPath(targetPosition)
end
end)
endMoveTo timeout and cancellation
Humanoid:MoveTo has an implicit timeout. Set your own explicit timeout, cancel it when MoveToFinished fires, and retry or fail after repeated misses:
local moveToTimeoutThread
local function moveToWaypoint(waypoint)
humanoid:MoveTo(waypoint.Position)
if moveToTimeoutThread then
task.cancel(moveToTimeoutThread)
end
moveToTimeoutThread = task.delay(6, function()
-- retry or report failure
moveToWaypoint(waypoint)
end)
end
humanoid.MoveToFinished:Connect(function(reached)
if moveToTimeoutThread then
task.cancel(moveToTimeoutThread)
moveToTimeoutThread = nil
end
if reached then
-- advance to next waypoint
end
end)Climbing
Set AgentCanClimb = true in CreatePath to allow routes over TrussPart surfaces. Climb waypoints have Action == Enum.PathWaypointAction.Climb and Label == "Climb". The Humanoid performs the climb automatically when it reaches the truss.
Material costs
Keys are strings matching Enum.Material names:
Costs = {
Water = 20,
CrackedLava = 100,
Slate = 20,
}Use math.huge to forbid traversal entirely.
Common statuses
Enum.PathStatus.Success— path found.Enum.PathStatus.NoPath— no valid path with given parameters.Enum.PathStatus.ClosestNoPath— partial path returned to nearest reachable point.
Debugging
Enable in Studio Visualization Options:
- Navigation mesh
- Pathfinding modifiers
- Pathfinding links
These show traversable areas, modifier labels, and link connections.
Performance and Scaling
Throttle pathfinding
Avoid recomputing every frame. Use:
- Time interval (e.g., every 0.5–1 s).
- Distance threshold (only recompute if target moved > N studs).
- Event-driven recomputation (on
path.Blocked).
Reduce waypoint count
Set WaypointSpacing = math.huge to eliminate intermediate waypoints when straight-line movement is acceptable.
Batch agents
Distribute recomputation across frames using a time budget so a large population cannot stall the frame:
local RunService = game:GetService("RunService")
local agents = {}
local index = 1
local BUDGET_MS = 2
RunService.Heartbeat:Connect(function()
if #agents == 0 then return end
local start = os.clock()
local processed = 0
repeat
local agent = agents[index]
agent:think()
index = (index % #agents) + 1
processed += 1
until processed >= #agents or (os.clock() - start) * 1000 >= BUDGET_MS
end)Use local patrol paths
For large worlds, split the map into regions. NPCs pick patrol paths within their current region unless chasing a target.
Avoid pathfinding when unnecessary
If the target is close and visible, move directly. Use raycasts for simple line-of-sight checks.
Cache paths
If many agents share the same destination, compute once and share waypoints.
Spatial queries for target detection
Use workspace:GetPartBoundsInRadius with an OverlapParams whitelist of player characters instead of iterating every player each frame. Validate hits with FindFirstChild("HumanoidRootPart") and Humanoid.Health checks.
MicroProfiler
Tag AI work with debug.profilebegin/debug.profileend:
debug.profilebegin("NPC Think")
-- pathfinding and state logic
debug.profileend()Limits to keep in mind
- Max path distance: 3,000 studs.
- Max nodes: ~20,000.
- Each
ComputeAsyncis a solver call; treat it as a budgeted operation.
--!strict
--[[
NPCPathFollower.lua
A strict, Humanoid-based path follower with cleanup.
Usage:
local NPCPathFollower = require(path.to.NPCPathFollower)
local humanoid = npcModel:WaitForChild("Humanoid") :: Humanoid
local follower = NPCPathFollower.new(humanoid)
follower:follow(targetPosition)
follower:stop()
follower:Destroy()
]]
local PathfindingService = game:GetService("PathfindingService")
export type PathWaypoint = {
Position: Vector3,
Action: Enum.PathWaypointAction,
Label: string,
}
export type NPCPathFollowerOptions = {
agentRadius: number?,
agentHeight: number?,
agentCanJump: boolean?,
agentCanClimb: boolean?,
waypointSpacing: number?,
costs: {[string]: number}?,
calculationSecondsTimeout: number?,
moveToTimeout: number?,
moveToRetryDelay: number?,
maxMoveFailures: number?,
customActionHandlers: {[string]: (humanoid: Humanoid, waypoint: PathWaypoint, follower: NPCPathFollower) -> ()}?,
}
export type NPCPathFollower = {
humanoid: Humanoid,
options: NPCPathFollowerOptions,
agentRadius: number,
agentHeight: number,
agentCanJump: boolean,
agentCanClimb: boolean,
waypointSpacing: number,
costs: {[string]: number},
calculationSecondsTimeout: number,
moveToTimeout: number,
moveToRetryDelay: number,
maxMoveFailures: number,
customActionHandlers: {[string]: (humanoid: Humanoid, waypoint: PathWaypoint, follower: NPCPathFollower) -> ()},
path: any,
waypoints: {PathWaypoint},
currentIndex: number,
blockedConnection: RBXScriptConnection?,
reachedConnection: RBXScriptConnection?,
diedConnection: RBXScriptConnection?,
moveToTimeoutThread: thread?,
recomputeThread: thread?,
running: boolean,
moveFailCount: number,
}
local NPCPathFollower = {}
NPCPathFollower.__index = NPCPathFollower
function NPCPathFollower.new(humanoid: Humanoid, options: NPCPathFollowerOptions?): NPCPathFollower
local self = setmetatable({}, NPCPathFollower) :: NPCPathFollower
self.humanoid = humanoid
self.options = options or {}
self.agentRadius = self.options.agentRadius or 2
self.agentHeight = self.options.agentHeight or 5
self.agentCanJump = self.options.agentCanJump ~= false
self.agentCanClimb = self.options.agentCanClimb or false
self.waypointSpacing = self.options.waypointSpacing or 4
self.costs = self.options.costs or {}
self.calculationSecondsTimeout = self.options.calculationSecondsTimeout or 1
self.moveToTimeout = self.options.moveToTimeout or 6
self.moveToRetryDelay = self.options.moveToRetryDelay or 0.2
self.maxMoveFailures = self.options.maxMoveFailures or 3
self.customActionHandlers = self.options.customActionHandlers or {}
self.path = nil
self.waypoints = {}
self.currentIndex = 1
self.blockedConnection = nil
self.reachedConnection = nil
self.diedConnection = nil
self.moveToTimeoutThread = nil
self.recomputeThread = nil
self.running = false
self.moveFailCount = 0
self.diedConnection = humanoid.Died:Connect(function()
self:stop()
end)
return self
end
function NPCPathFollower:createPath(): any
local path = PathfindingService:CreatePath({
AgentRadius = self.agentRadius,
AgentHeight = self.agentHeight,
AgentCanJump = self.agentCanJump,
AgentCanClimb = self.agentCanClimb,
WaypointSpacing = self.waypointSpacing,
Costs = self.costs,
})
path.CalculationSecondsTimeout = self.calculationSecondsTimeout
return path
end
function NPCPathFollower:_disconnectReached()
if self.reachedConnection then
self.reachedConnection:Disconnect()
self.reachedConnection = nil
end
end
function NPCPathFollower:_cancelMoveToTimeout()
if self.moveToTimeoutThread then
task.cancel(self.moveToTimeoutThread)
self.moveToTimeoutThread = nil
end
end
function NPCPathFollower:_cancelRecompute()
if self.recomputeThread then
task.cancel(self.recomputeThread)
self.recomputeThread = nil
end
end
function NPCPathFollower:_cleanupPathConnections()
self:_cancelRecompute()
if self.blockedConnection then
self.blockedConnection:Disconnect()
self.blockedConnection = nil
end
self:_disconnectReached()
self:_cancelMoveToTimeout()
end
function NPCPathFollower:follow(targetPosition: Vector3)
if self.humanoid.Health <= 0 then
return
end
self:stop()
self.running = true
self.moveFailCount = 0
self:computeAndMove(targetPosition)
end
function NPCPathFollower:computeAndMove(targetPosition: Vector3)
if not self.running or self.humanoid.Health <= 0 then
self:stop()
return
end
self:_cleanupPathConnections()
self.path = self:createPath()
local character = self.humanoid.Parent
if not character then
self:stop()
return
end
local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if not rootPart then
self:stop()
return
end
local success, err = pcall(function()
self.path:ComputeAsync(rootPart.Position, targetPosition)
end)
if not success or self.path.Status ~= Enum.PathStatus.Success then
warn("NPCPathFollower failed:", err or self.path.Status)
self:stop()
return
end
self.waypoints = self.path:GetWaypoints()
self.currentIndex = 1
self.moveFailCount = 0
self.blockedConnection = self.path.Blocked:Connect(function(blockedIndex: number)
if not self.running or self.humanoid.Health <= 0 then
return
end
if blockedIndex >= self.currentIndex then
self:_cancelRecompute()
self.recomputeThread = task.delay(0, function()
self.recomputeThread = nil
if self.running then
self:computeAndMove(targetPosition)
end
end)
end
end)
self:moveToNextWaypoint()
end
function NPCPathFollower:moveToNextWaypoint()
if not self.running or self.humanoid.Health <= 0 then
self:stop()
return
end
if self.currentIndex > #self.waypoints then
self:stop()
return
end
local waypoint = self.waypoints[self.currentIndex]
self:_disconnectReached()
self:_cancelMoveToTimeout()
if waypoint.Label ~= "" then
local handler = self.customActionHandlers[waypoint.Label]
if handler then
handler(self.humanoid, waypoint, self)
end
end
if waypoint.Action == Enum.PathWaypointAction.Jump then
self.humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
elseif waypoint.Action == Enum.PathWaypointAction.Climb then
-- Climbing truss parts is handled automatically by the Humanoid.
end
self.humanoid:MoveTo(waypoint.Position)
self.moveToTimeoutThread = task.delay(self.moveToTimeout, function()
self:_onMoveToTimeout()
end)
self.reachedConnection = self.humanoid.MoveToFinished:Connect(function(reached: boolean)
self:_cancelMoveToTimeout()
if not self.running then
return
end
if self.humanoid.Health <= 0 then
self:stop()
return
end
if reached then
self.moveFailCount = 0
self.currentIndex += 1
self:moveToNextWaypoint()
else
self.moveFailCount += 1
if self.moveFailCount > self.maxMoveFailures then
warn("NPCPathFollower: MoveTo failed too many times, stopping.")
self:stop()
return
end
task.delay(self.moveToRetryDelay, function()
if self.running then
self:moveToNextWaypoint()
end
end)
end
end)
end
function NPCPathFollower:_onMoveToTimeout()
if not self.running or self.humanoid.Health <= 0 then
self:stop()
return
end
self:_disconnectReached()
self.moveFailCount += 1
if self.moveFailCount > self.maxMoveFailures then
warn("NPCPathFollower: MoveTo timed out too many times, stopping.")
self:stop()
return
end
task.delay(self.moveToRetryDelay, function()
if self.running then
self:moveToNextWaypoint()
end
end)
end
function NPCPathFollower:stop()
self.running = false
self:_cleanupPathConnections()
local rootPart = self.humanoid.RootPart
if rootPart then
self.humanoid:MoveTo(rootPart.Position)
else
self.humanoid:Move(Vector3.zero)
end
end
function NPCPathFollower:Destroy()
self:stop()
if self.diedConnection then
self.diedConnection:Disconnect()
self.diedConnection = nil
end
end
return NPCPathFollower
--!strict
--[[
PathfindingUtility.lua
Helpers for throttled recomputation and waypoint formatting.
Usage:
local util = require(path.to.PathfindingUtility)
local shouldRecompute, newPos, newTime = util.throttleRecompute(
target.Position, lastPos, 0.5, 5, lastTime
)
print(util.formatWaypoint(waypoint))
]]
local PathfindingUtility = {}
function PathfindingUtility.throttleRecompute(
currentPosition: Vector3,
lastPosition: Vector3?,
interval: number,
minDistance: number,
lastTime: number?
): (boolean, Vector3?, number?)
local now = time()
if lastTime and (now - lastTime) < interval then
return false, lastPosition, lastTime
end
if lastPosition and (currentPosition - lastPosition).Magnitude < minDistance then
return false, lastPosition, lastTime
end
return true, currentPosition, now
end
function PathfindingUtility.formatWaypoint(waypoint: {Position: Vector3, Action: any, Label: string}?): string
if not waypoint then
return "Waypoint(nil)"
end
return string.format("Waypoint(%s, %s, %s)",
tostring(waypoint.Position),
tostring(waypoint.Action),
tostring(waypoint.Label)
)
end
return PathfindingUtility
--!strict
--[[
PatrolBehavior.lua
A simple state machine: Idle → Patrol → Chase → Attack → Return.
Usage:
local PatrolBehavior = require(path.to.PatrolBehavior)
local NPCPathFollower = require(path.to.NPCPathFollower)
local behavior = PatrolBehavior.new(npcModel, {
patrolPoints = {
workspace:WaitForChild("PatrolA").Position,
workspace:WaitForChild("PatrolB").Position,
},
detectionRange = 30,
attackRange = 5,
chaseTimeout = 10,
recomputeInterval = 0.5,
recomputeMinDistance = 4,
attackCooldown = 1,
})
behavior:start()
behavior:Destroy()
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local PatrolBehavior = {}
PatrolBehavior.__index = PatrolBehavior
function PatrolBehavior.new(model: Model, config: {[string]: any}?)
local self = setmetatable({}, PatrolBehavior) :: any
self.model = model
self.humanoid = model:WaitForChild("Humanoid") :: Humanoid
self.rootPart = model:WaitForChild("HumanoidRootPart") :: BasePart
self.config = config or {}
self.patrolPoints = self.config.patrolPoints or {}
self.detectionRange = self.config.detectionRange or 30
self.attackRange = self.config.attackRange or 5
self.chaseTimeout = self.config.chaseTimeout or 10
self.recomputeInterval = self.config.recomputeInterval or 0.5
self.recomputeMinDistance = self.config.recomputeMinDistance or 4
self.attackCooldown = self.config.attackCooldown or 1
self.state = "Idle"
self.patrolIndex = 1
self.target = nil
self.chaseTimer = 0
self.follower = nil
self.connection = nil
self.destroyed = false
self.lastRecomputeTime = 0
self.lastRecomputePosition = nil
self.lastAttackTime = 0
self.humanoid.Died:Connect(function()
self:Destroy()
end)
return self
end
function PatrolBehavior:start()
if self.destroyed then return end
if self.connection then return end
self.connection = RunService.Heartbeat:Connect(function(dt: number)
self:tick(dt)
end)
if #self.patrolPoints == 0 then
self:setState("Idle")
else
self:setState("Patrol")
end
end
function PatrolBehavior:stop()
if self.connection then
self.connection:Disconnect()
self.connection = nil
end
if self.follower then
self.follower:stop()
self.follower = nil
end
end
function PatrolBehavior:Destroy()
if self.destroyed then return end
self.destroyed = true
self:stop()
self.model = nil
self.humanoid = nil
self.rootPart = nil
self.target = nil
end
function PatrolBehavior:setState(newState: string)
self.state = newState
if newState == "Patrol" then
self.target = nil
self:nextPatrolPoint()
elseif newState == "Idle" then
self.target = nil
self.humanoid:Move(Vector3.zero)
if self.follower then
self.follower:stop()
self.follower = nil
end
elseif newState == "Chase" then
self.lastRecomputeTime = 0
self.lastRecomputePosition = nil
self.chaseTimer = 0
elseif newState == "Return" then
self.target = nil
self.lastRecomputeTime = 0
self.lastRecomputePosition = nil
end
end
function PatrolBehavior:nextPatrolPoint()
if #self.patrolPoints == 0 then return end
self.patrolIndex = (self.patrolIndex % #self.patrolPoints) + 1
self:moveTo(self.patrolPoints[self.patrolIndex])
end
function PatrolBehavior:moveTo(position: Vector3)
if self.destroyed then return end
if not self.follower then
local NPCPathFollower = require(script.Parent.NPCPathFollower)
self.follower = NPCPathFollower.new(self.humanoid)
end
self.follower:follow(position)
self.lastRecomputeTime = time()
self.lastRecomputePosition = position
end
function PatrolBehavior:findTarget(): Model?
local closest: Model? = nil
local closestDist = self.detectionRange
local characters = {}
for _, player in ipairs(Players:GetPlayers()) do
if player.Character then
table.insert(characters, player.Character)
end
end
if #characters == 0 then
return nil
end
local params = OverlapParams.new()
params.FilterDescendantsInstances = characters
params.FilterType = Enum.RaycastFilterType.Whitelist
params.MaxParts = 100
local parts = workspace:GetPartBoundsInRadius(self.rootPart.Position, self.detectionRange, params)
for _, part in ipairs(parts) do
local character = part:FindFirstAncestorOfClass("Model")
if not character or character == self.model then
continue
end
local hrp = character:FindFirstChild("HumanoidRootPart") :: BasePart?
local humanoid = character:FindFirstChild("Humanoid") :: Humanoid?
if hrp and humanoid and humanoid.Health > 0 then
local dist = (hrp.Position - self.rootPart.Position).Magnitude
if dist < closestDist then
closestDist = dist
closest = character
end
end
end
return closest
end
function PatrolBehavior:shouldRecompute(position: Vector3): boolean
local now = time()
if (now - self.lastRecomputeTime) < self.recomputeInterval then
return false
end
if self.lastRecomputePosition and (position - self.lastRecomputePosition).Magnitude < self.recomputeMinDistance then
return false
end
return true
end
function PatrolBehavior:tick(dt: number)
if self.destroyed then return end
if self.humanoid.Health <= 0 then
self:Destroy()
return
end
if self.state == "Patrol" or self.state == "Idle" then
local target = self:findTarget()
if target then
self.target = target
self:setState("Chase")
return
elseif self.state == "Patrol" and self.follower and not self.follower.running then
self:nextPatrolPoint()
end
elseif self.state == "Chase" then
if not self.target or not self.target:IsDescendantOf(workspace) then
self:setState("Return")
return
end
local hrp = self.target:FindFirstChild("HumanoidRootPart") :: BasePart?
if not hrp then
self:setState("Return")
return
end
local dist = (hrp.Position - self.rootPart.Position).Magnitude
if dist <= self.attackRange then
self:setState("Attack")
return
end
self.chaseTimer += dt
if self.chaseTimer >= self.chaseTimeout then
self:setState("Return")
return
end
if self:shouldRecompute(hrp.Position) then
self:moveTo(hrp.Position)
end
elseif self.state == "Attack" then
if not self.target or not self.target:IsDescendantOf(workspace) then
self:setState("Return")
return
end
local hrp = self.target:FindFirstChild("HumanoidRootPart") :: BasePart?
if not hrp then
self:setState("Return")
return
end
local dist = (hrp.Position - self.rootPart.Position).Magnitude
if dist > self.attackRange then
self:setState("Chase")
return
end
local now = time()
if now - self.lastAttackTime >= self.attackCooldown then
self.lastAttackTime = now
-- trigger server-side attack logic
end
elseif self.state == "Return" then
self.target = nil
if #self.patrolPoints == 0 then
self:setState("Idle")
return
end
local returnPos = self.patrolPoints[self.patrolIndex]
if self:shouldRecompute(returnPos) then
self:moveTo(returnPos)
end
if self.follower and not self.follower.running then
self:setState("Patrol")
end
end
end
return PatrolBehavior