Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
NPCs and AI
Architecture
- NPC logic runs on the server (authoritative targets, damage, drops). Visual extras (idle
animations, nameplates, effects) can run on clients.
- One NPC service drives all NPCs from a single loop (e.g. 5–10 Hz "think" rate, staggered), not one
script + while true per NPC. Tag NPC models (CollectionService) and keep per-NPC state in a table.
- Server-owned physics:
npc.PrimaryPart:SetNetworkOwner(nil)after spawning, so NPCs don't stutter
when ownership moves between nearby players (and exploiters can't push them).
- Use
Humanoidfor NPCs that need walking/jumping/climbing; for simple movers (turrets, floating
enemies, crowds) use an AnimationController + Animator and move with constraints or CFrames.
Pathfinding
--!strict
local PathfindingService = game:GetService("PathfindingService")
local path = PathfindingService:CreatePath({
AgentRadius = 2,
AgentHeight = 5,
AgentCanJump = true,
AgentCanClimb = false,
WaypointSpacing = 4,
Costs = {
Water = 20, -- avoid water (Enum.Material name)
DangerZone = math.huge, -- PathfindingModifier.Label: never cross
},
})
-- Walk `humanoid` to `goal`. Returns true on arrival, false if unreachable or interrupted.
local function walkTo(humanoid: Humanoid, goal: Vector3): boolean
local root = humanoid.RootPart
if not root then
return false
end
local ok = pcall(function()
path:ComputeAsync(root.Position, goal)
end)
if not ok or path.Status ~= Enum.PathStatus.Success then
return false -- NoPath: try a fallback (direct MoveTo, new goal, teleport if stuck long)
end
local waypoints = path:GetWaypoints()
local blocked = false
local blockedConnection = path.Blocked:Connect(function(blockedIndex)
blocked = true -- an obstacle appeared on a waypoint ahead; caller recomputes
print("path blocked at waypoint", blockedIndex)
end)
for index = 2, #waypoints do
local waypoint = waypoints[index]
if waypoint.Action == Enum.PathWaypointAction.Jump then
humanoid.Jump = true
end
humanoid:MoveTo(waypoint.Position)
local reached = humanoid.MoveToFinished:Wait() -- false after 8 s timeout
if not reached or blocked or humanoid.Health <= 0 then
blockedConnection:Disconnect()
return false
end
end
blockedConnection:Disconnect()
return true
end
return walkToCreatePath+ComputeAsyncis the current API (FindPathAsyncis superseded). ReusePathobjects
per agent type; ComputeAsync yields — never call it every frame for every NPC.
- Chasing moving targets: recompute when the target moved more than a few studs since the last
compute or every ~0.5–1 s, and skip pathfinding entirely when there's a clear line of sight (workspace:Raycast from NPC to target) — just MoveTo the target.
PathfindingModifier(child of a part/region,Labelmatching aCostskey,PassThroughfor doors)
and PathfindingLink (between two attachments, e.g. ladders, teleporters, boats; the waypoint has Label and Action = Custom) shape paths.
- Limits: start→goal straight-line distance ≤ 3,000 studs, 20,000-node budget; far goals need
intermediate targets. Visualize the navmesh with Studio's Navigation Mesh visualization option.
- On clients, pathfinding sees only streamed-in geometry: compute paths on the server.
Behavior: state machine
--!strict
type State = "Idle" | "Chase" | "Attack" | "Return"
type Npc = {
model: Model,
humanoid: Humanoid,
home: Vector3,
state: State,
target: Model?,
lastAttack: number,
}
local AGGRO_RANGE = 40
local ATTACK_RANGE = 5
local LEASH_RANGE = 80
local ATTACK_COOLDOWN = 1.2
local losParams = RaycastParams.new()
losParams.FilterType = Enum.RaycastFilterType.Exclude
local function hasLineOfSight(from: Vector3, target: BasePart, ignore: { Instance }): boolean
losParams.FilterDescendantsInstances = ignore
local result = workspace:Raycast(from, target.Position - from, losParams)
return result == nil or result.Instance:IsDescendantOf(target.Parent :: Instance)
end
local function think(npc: Npc, findTarget: (Vector3, number) -> Model?)
local root = npc.humanoid.RootPart
if not root or npc.humanoid.Health <= 0 then
return
end
local targetRoot = npc.target and npc.target:FindFirstChild("HumanoidRootPart") :: BasePart?
local distance = if targetRoot then (targetRoot.Position - root.Position).Magnitude else math.huge
if npc.state == "Idle" then
npc.target = findTarget(root.Position, AGGRO_RANGE)
if npc.target then
npc.state = "Chase"
end
elseif npc.state == "Chase" then
if not targetRoot or (root.Position - npc.home).Magnitude > LEASH_RANGE then
npc.state = "Return"
elseif distance <= ATTACK_RANGE then
npc.state = "Attack"
elseif hasLineOfSight(root.Position, targetRoot, { npc.model }) then
npc.humanoid:MoveTo(targetRoot.Position)
end -- else: request a path (throttled) via walkTo in a separate thread
elseif npc.state == "Attack" then
if not targetRoot or distance > ATTACK_RANGE * 1.5 then
npc.state = "Chase"
elseif os.clock() - npc.lastAttack >= ATTACK_COOLDOWN then
npc.lastAttack = os.clock()
local targetHumanoid = npc.target and npc.target:FindFirstChildOfClass("Humanoid")
if targetHumanoid then
targetHumanoid:TakeDamage(10)
end
end
elseif npc.state == "Return" then
npc.target = nil
npc.humanoid:MoveTo(npc.home)
if (root.Position - npc.home).Magnitude < 4 then
npc.state = "Idle"
end
end
end
return thinkFor complex AI (many behaviors, priorities), use a behavior tree or utility AI; keep the same "think at a fixed rate, act through small primitives" structure. See references/scaling-npcs.md for spawning, pooling, and performance with hundreds of NPCs.
Perception tips
- Find targets with
workspace:GetPartBoundsInRadiusagainst a collision group/filter containing only
characters, or iterate Players:GetPlayers() (cheaper for small counts) and compare squared distances.
- Add reaction delays and field-of-view checks (
root.CFrame.LookVector:Dot(direction.Unit) > 0.5)
so NPCs feel fair.
- Stagger thinking:
npcIndex % 5 == frame % 5spreads work across frames.
Related skills
roblox-characters-animation (humanoids, animation), roblox-physics (ownership, raycasts), roblox-performance (profiling, parallel Luau), roblox-game-systems (waves, combat, drops).

