references/modifiers-links-and-streaming.md
---
read_when: "Route NPCs through special regions or handle streamed geometry"
last_reviewed: 2026-06-17
---
# 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 in `CreatePath` `Costs`.
- `PassThrough`: if `true`, 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`:
```lua
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 `Attachment`s 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`.
```lua
local link = Instance.new("PathfindingLink")
link.Attachment0 = attachmentA
link.Attachment1 = attachmentB
link.Label = "UseBoat"
link.IsBidirectional = true
link.Parent = workspace
```
In your pathfinder:
```lua
Costs = {
Water = 20,
UseBoat = 2,
}
```
When the waypoint label is `"UseBoat"`, trigger your custom action, then continue along the path:
```lua
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)
end
```
## Streaming 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.
```lua
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.
references/npc-behavior-patterns.md
---
read_when: "Implement patrol, chase, combat, or state transitions"
last_reviewed: 2026-06-17
---
# NPC behavior patterns
## State machine
A state machine is the most maintainable pattern for NPCs:
```
Idle → Patrol → Alert → Chase → Attack → Return
```
Each 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:
```lua
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:
```lua
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
end
```
## Patrol 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:
```lua
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:
```lua
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
end
```
## Non-humanoid agents
For agents without a `Humanoid`, use `AlignPosition`/`AlignOrientation` or `LinearVelocity` to move a root part along waypoints. See [roblox-physics/SKILL.md](../../roblox-physics/SKILL.md).
## Humanoid tuning
- `Humanoid.WalkSpeed` controls movement speed.
- `Humanoid.JumpPower` / `Humanoid.JumpHeight` control 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 `Blocked` events.
- Spawning too many agents without staggering.
- Using `Humanoid:MoveTo` for long-distance navigation without pathfinding.
references/pathfinding-service-details.md
---
read_when: "Configure agents, compute paths, or handle blocked waypoints"
last_reviewed: 2026-06-17
---
# Pathfinding service details
Official guide: https://create.roblox.com/docs/en-us/characters/pathfinding
## Creating a path
```lua
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 = 1
```
## Computing the path
Set a solver timeout after creating the path, then wrap `ComputeAsync` in `pcall`:
```lua
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
```lua
for i, waypoint in ipairs(waypoints) do
print(i, waypoint.Position, waypoint.Action, waypoint.Label)
end
```
- `Position`: `Vector3` target.
- `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:
```lua
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)
end
```
## MoveTo 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:
```lua
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:
```lua
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.
references/performance-and-scaling.md
---
read_when: "Reduce pathfinding and simulation cost as NPC counts grow"
last_reviewed: 2026-06-17
---
# 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:
```lua
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`:
```lua
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 `ComputeAsync` is a solver call; treat it as a budgeted operation.
scripts/NPCPathFollower.lua
--!strict
-- Status: experimental
-- Last verified: 2026-06-17
-- Test coverage: no automated coverage
-- Intended use: example; adapt and test before production.
--[[
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")
local NPCPathFollower = {}
NPCPathFollower.__index = NPCPathFollower
export type PathWaypoint = typeof(PathfindingService:CreatePath():GetWaypoints()[1])
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) -> (),
}?,
}
type NPCPathFollowerState =
{
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: Path?,
waypoints: { PathWaypoint },
currentIndex: number,
blockedConnection: RBXScriptConnection?,
reachedConnection: RBXScriptConnection?,
diedConnection: RBXScriptConnection?,
moveToTimeoutThread: thread?,
recomputeThread: thread?,
running: boolean,
moveFailCount: number,
}
export type NPCPathFollower = typeof(setmetatable({} :: NPCPathFollowerState, NPCPathFollower))
function NPCPathFollower.new(humanoid: Humanoid, options: NPCPathFollowerOptions?): NPCPathFollower
local resolvedOptions: NPCPathFollowerOptions = options or {}
local self = setmetatable(
{
humanoid = humanoid,
options = resolvedOptions,
agentRadius = resolvedOptions.agentRadius or 2,
agentHeight = resolvedOptions.agentHeight or 5,
agentCanJump = resolvedOptions.agentCanJump ~= false,
agentCanClimb = resolvedOptions.agentCanClimb or false,
waypointSpacing = resolvedOptions.waypointSpacing or 4,
costs = resolvedOptions.costs or {},
calculationSecondsTimeout = resolvedOptions.calculationSecondsTimeout or 1,
moveToTimeout = resolvedOptions.moveToTimeout or 6,
moveToRetryDelay = resolvedOptions.moveToRetryDelay or 0.2,
maxMoveFailures = resolvedOptions.maxMoveFailures or 3,
customActionHandlers = resolvedOptions.customActionHandlers or {},
path = nil :: Path?,
waypoints = {} :: { PathWaypoint },
currentIndex = 1,
blockedConnection = nil :: RBXScriptConnection?,
reachedConnection = nil :: RBXScriptConnection?,
diedConnection = nil :: RBXScriptConnection?,
moveToTimeoutThread = nil :: thread?,
recomputeThread = nil :: thread?,
running = false,
moveFailCount = 0,
} :: NPCPathFollowerState,
NPCPathFollower
)
self.diedConnection = humanoid.Died:Connect(function()
self:stop()
end)
return self
end
function NPCPathFollower.createPath(self: NPCPathFollower): Path
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(self: NPCPathFollower)
if self.reachedConnection then
self.reachedConnection:Disconnect()
self.reachedConnection = nil
end
end
function NPCPathFollower._cancelMoveToTimeout(self: NPCPathFollower)
if self.moveToTimeoutThread then
task.cancel(self.moveToTimeoutThread)
self.moveToTimeoutThread = nil
end
end
function NPCPathFollower._cancelRecompute(self: NPCPathFollower)
if self.recomputeThread then
task.cancel(self.recomputeThread)
self.recomputeThread = nil
end
end
function NPCPathFollower._cleanupPathConnections(self: NPCPathFollower)
self:_cancelRecompute()
if self.blockedConnection then
self.blockedConnection:Disconnect()
self.blockedConnection = nil
end
self:_disconnectReached()
self:_cancelMoveToTimeout()
end
function NPCPathFollower.follow(self: NPCPathFollower, 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(self: NPCPathFollower, targetPosition: Vector3)
if not self.running or self.humanoid.Health <= 0 then
self:stop()
return
end
self:_cleanupPathConnections()
local path = self:createPath()
self.path = path
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()
path:ComputeAsync(rootPart.Position, targetPosition)
end)
if not success or path.Status ~= Enum.PathStatus.Success then
warn("NPCPathFollower failed:", err or path.Status)
self:stop()
return
end
self.waypoints = path:GetWaypoints()
self.currentIndex = 1
self.moveFailCount = 0
self.blockedConnection = 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(self: NPCPathFollower)
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)
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(self: NPCPathFollower)
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: NPCPathFollower)
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: NPCPathFollower)
self:stop()
if self.diedConnection then
self.diedConnection:Disconnect()
self.diedConnection = nil
end
end
return NPCPathFollower
scripts/PathfindingUtility.lua
--!strict
-- Status: experimental
-- Last verified: 2026-06-17
-- Test coverage: no automated coverage
-- Intended use: example; adapt and test before production.
--[[
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: Enum.PathWaypointAction,
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
scripts/PatrolBehavior.lua
--!strict
-- Status: experimental
-- Last verified: 2026-06-17
-- Test coverage: no automated coverage
-- Intended use: example; adapt and test before production.
--[[
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 NPCPathFollower = require(script.Parent.NPCPathFollower)
type PatrolState = "Idle" | "Patrol" | "Chase" | "Attack" | "Return"
export type PatrolBehaviorConfig = {
patrolPoints: { Vector3 }?,
detectionRange: number?,
attackRange: number?,
chaseTimeout: number?,
recomputeInterval: number?,
recomputeMinDistance: number?,
attackCooldown: number?,
}
type PathFollower = NPCPathFollower.NPCPathFollower
type PatrolBehaviorState = {
model: Model?,
humanoid: Humanoid?,
rootPart: BasePart?,
config: PatrolBehaviorConfig,
patrolPoints: { Vector3 },
detectionRange: number,
attackRange: number,
chaseTimeout: number,
recomputeInterval: number,
recomputeMinDistance: number,
attackCooldown: number,
state: PatrolState,
patrolIndex: number,
target: Model?,
chaseTimer: number,
follower: PathFollower?,
connection: RBXScriptConnection?,
destroyed: boolean,
lastRecomputeTime: number,
lastRecomputePosition: Vector3?,
lastAttackTime: number,
}
local PatrolBehavior = {}
PatrolBehavior.__index = PatrolBehavior
export type PatrolBehavior = typeof(setmetatable({} :: PatrolBehaviorState, PatrolBehavior))
function PatrolBehavior.new(model: Model, config: PatrolBehaviorConfig?): PatrolBehavior
local resolvedConfig: PatrolBehaviorConfig = config or {}
local humanoid = model:WaitForChild("Humanoid") :: Humanoid
local self = setmetatable(
{
model = model,
humanoid = humanoid,
rootPart = model:WaitForChild("HumanoidRootPart") :: BasePart,
config = resolvedConfig,
patrolPoints = resolvedConfig.patrolPoints or {},
detectionRange = resolvedConfig.detectionRange or 30,
attackRange = resolvedConfig.attackRange or 5,
chaseTimeout = resolvedConfig.chaseTimeout or 10,
recomputeInterval = resolvedConfig.recomputeInterval or 0.5,
recomputeMinDistance = resolvedConfig.recomputeMinDistance or 4,
attackCooldown = resolvedConfig.attackCooldown or 1,
state = "Idle" :: PatrolState,
patrolIndex = 1,
target = nil :: Model?,
chaseTimer = 0,
follower = nil :: PathFollower?,
connection = nil :: RBXScriptConnection?,
destroyed = false,
lastRecomputeTime = 0,
lastRecomputePosition = nil :: Vector3?,
lastAttackTime = 0,
} :: PatrolBehaviorState,
PatrolBehavior
)
humanoid.Died:Connect(function()
self:Destroy()
end)
return self
end
function PatrolBehavior.start(self: PatrolBehavior)
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(self: PatrolBehavior)
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(self: PatrolBehavior)
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(self: PatrolBehavior, newState: PatrolState)
self.state = newState
if newState == "Patrol" then
self.target = nil
self:nextPatrolPoint()
elseif newState == "Idle" then
self.target = nil
local humanoid = self.humanoid
if humanoid then
humanoid:Move(Vector3.zero)
end
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(self: PatrolBehavior)
if #self.patrolPoints == 0 then
return
end
self.patrolIndex = (self.patrolIndex % #self.patrolPoints) + 1
self:moveTo(self.patrolPoints[self.patrolIndex])
end
function PatrolBehavior.moveTo(self: PatrolBehavior, position: Vector3)
if self.destroyed then
return
end
if not self.follower then
local humanoid = self.humanoid
if not humanoid then
return
end
self.follower = NPCPathFollower.new(humanoid)
end
local follower = self.follower
if follower then
follower:follow(position)
end
self.lastRecomputeTime = time()
self.lastRecomputePosition = position
end
function PatrolBehavior.findTarget(self: PatrolBehavior): Model?
local model = self.model
local rootPart = self.rootPart
if not model or not rootPart then
return nil
end
local closest: Model? = nil
local closestDist = self.detectionRange
local characters: { Instance } = {}
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.Include
params.MaxParts = 100
local parts = workspace:GetPartBoundsInRadius(rootPart.Position, self.detectionRange, params)
for _, part in ipairs(parts) do
local character = part:FindFirstAncestorOfClass("Model")
if not character or character == 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 - rootPart.Position).Magnitude
if dist < closestDist then
closestDist = dist
closest = character
end
end
end
return closest
end
function PatrolBehavior.shouldRecompute(self: PatrolBehavior, 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(self: PatrolBehavior, dt: number)
if self.destroyed then
return
end
local humanoid = self.humanoid
local rootPart = self.rootPart
if not humanoid or not rootPart then
return
end
if 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 - 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 - 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
SKILL.md
---
name: roblox-npcs
description: "Build or debug Roblox NPC navigation and behavior. Use for PathfindingService, modifiers and links, blocked paths, patrol or chase logic, streaming, and scaling NPC counts."
last_reviewed: 2026-06-17
---
# roblox-npcs
**Official sources:**
- 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`
Use PathfindingService for navigation on Roblox's navigation mesh. Build patrol, chase, and other behavior around its computed paths.
## PathfindingService basics
Create a path:
```lua
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 = 1
```
Compute and follow:
```lua
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()
end
```
## Agent 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 in `Costs` table.
- `PassThrough`: if `true`, the volume is ignored by the navmesh and treated as traversable empty space (e.g., zombies "hearing" through doors).
Example:
```lua
local path = PathfindingService:CreatePath({
Costs = {
Water = 20,
DangerZone = math.huge,
UseBoat = 2,
}
})
```
## Pathfinding links
`PathfindingLink` connects two `Attachment`s 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.PersistentLoaded` and 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 = false` to 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.huge` to 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
- Computing paths every frame.
- Ignoring blocked-path events and letting NPCs walk into walls.
- Trusting client position for authoritative AI.
- Forgetting `pcall` around `ComputeAsync`.
- Using material names incorrectly in `Costs` (must match `Enum.Material` names 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.CalculationSecondsTimeout` after `CreatePath` to cap solver time.
- Always set an explicit `Humanoid:MoveTo` timeout and cancel it when the waypoint is reached or the follower is stopped.
- Detect targets with spatial queries such as `workspace:GetPartBoundsInRadius` instead of scanning every player each frame.
- Stop path followers and clean up `Heartbeat` connections when the `Humanoid` dies or the NPC is destroyed.
- For respawning NPCs, create a new behavior instance for the new character model and `Destroy` the old one.
- Use `PathfindingLink` labels 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 = true` and provide `TrussPart` surfaces. Climb waypoints have the `Label` `"Climb"`.
- `PathfindingModifier` parts must be `Anchored = true` and `CanCollide = 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.
<!-- catalog:references:start -->
## Reference index
- [modifiers-links-and-streaming.md](references/modifiers-links-and-streaming.md): Route NPCs through special regions or handle streamed geometry.
- [npc-behavior-patterns.md](references/npc-behavior-patterns.md): Implement patrol, chase, combat, or state transitions.
- [pathfinding-service-details.md](references/pathfinding-service-details.md): Configure agents, compute paths, or handle blocked waypoints.
- [performance-and-scaling.md](references/performance-and-scaling.md): Reduce pathfinding and simulation cost as NPC counts grow.
<!-- catalog:references:end -->