npc-ai
π―Skillfrom taozhuo/game-dev-skills
Generates intelligent behavior and decision-making algorithms for non-player characters in game environments, enabling dynamic and context-aware interactions.
Installation
npx skills add https://github.com/taozhuo/game-dev-skills --skill npc-aiSkill Details
Implements NPC and AI systems including pathfinding, behavior trees, state machines, combat AI, perception systems, and NPC management. Use when building enemy AI, friendly NPCs, boss fights, or any autonomous characters.
Overview
# Roblox NPC & AI Systems
When implementing AI systems, use these Roblox-specific patterns for performant and intelligent NPCs.
Pathfinding
Basic PathfindingService Usage
```lua
local PathfindingService = game:GetService("PathfindingService")
local function createPath(agentParams)
return PathfindingService:CreatePath({
AgentRadius = agentParams.radius or 2,
AgentHeight = agentParams.height or 5,
AgentCanJump = agentParams.canJump ~= false,
AgentCanClimb = agentParams.canClimb or false,
WaypointSpacing = agentParams.waypointSpacing or 4,
Costs = agentParams.costs or {
Water = 20, -- Avoid water
Mud = 5, -- Prefer avoiding mud
DangerZone = 100 -- Really avoid danger
}
})
end
local function moveTo(npc, targetPosition)
local humanoid = npc:FindFirstChildOfClass("Humanoid")
local rootPart = npc:FindFirstChild("HumanoidRootPart")
if not humanoid or not rootPart then return false end
local path = createPath({radius = 2, height = 5})
local success, err = pcall(function()
path:ComputeAsync(rootPart.Position, targetPosition)
end)
if not success or path.Status ~= Enum.PathStatus.Success then
-- Direct movement as fallback
humanoid:MoveTo(targetPosition)
return false
end
local waypoints = path:GetWaypoints()
for i, waypoint in ipairs(waypoints) do
if waypoint.Action == Enum.PathWaypointAction.Jump then
humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
end
humanoid:MoveTo(waypoint.Position)
local reached = humanoid.MoveToFinished:Wait()
if not reached then
-- Path blocked, recompute
return moveTo(npc, targetPosition)
end
end
return true
end
```
Dynamic Path Recomputation
```lua
local function followTargetDynamic(npc, target, updateInterval)
updateInterval = updateInterval or 0.5
local path = createPath({radius = 2, height = 5})
local currentWaypointIndex = 1
local waypoints = {}
local humanoid = npc:FindFirstChildOfClass("Humanoid")
local rootPart = npc:FindFirstChild("HumanoidRootPart")
-- Listen for path blocked
path.Blocked:Connect(function(blockedIndex)
if blockedIndex >= currentWaypointIndex then
-- Recompute path
computePath()
end
end)
local function computePath()
local targetPos = target.PrimaryPart and target.PrimaryPart.Position
if not targetPos then return end
path:ComputeAsync(rootPart.Position, targetPos)
if path.Status == Enum.PathStatus.Success then
waypoints = path:GetWaypoints()
currentWaypointIndex = 2 -- Skip first (current position)
moveToNextWaypoint()
end
end
local function moveToNextWaypoint()
if currentWaypointIndex > #waypoints then
computePath() -- Reached end, recompute
return
end
local waypoint = waypoints[currentWaypointIndex]
if waypoint.Action == Enum.PathWaypointAction.Jump then
humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
end
humanoid:MoveTo(waypoint.Position)
end
humanoid.MoveToFinished:Connect(function(reached)
if reached then
currentWaypointIndex = currentWaypointIndex + 1
moveToNextWaypoint()
else
computePath() -- Stuck, recompute
end
end)
-- Periodic recomputation for moving targets
task.spawn(function()
while npc.Parent and target.Parent do
task.wait(updateInterval)
computePath()
end
end)
computePath() -- Initial computation
end
```
Behavior Trees
Behavior Tree Structure
```lua
local BehaviorTree = {}
BehaviorTree.__index = BehaviorTree
-- Node statuses
local Status = {
Success = "Success",
Failure = "Failure",
Running = "Running"
}
-- Base Node
local Node = {}
Node.__index = Node
function Node.new(name)
return setmetatable({name = name}, Node)
end
function Node:tick(blackboard)
return Status.Failure
end
-- Selector: Returns success on first successful child
local Selector = setmetatable({}, {__index = Node})
Selector.__index = Selector
function Selector.new(name, children)
local self = setmetatable(Node.new(name), Selector)
self.children = children
return self
end
function Selector:tick(blackboard)
for _, child in ipairs(self.children) do
local status = child:tick(blackboard)
if status ~= Status.Failure then
return status
end
end
return Status.Failure
end
-- Sequence: Returns failure on first failed child
local Sequence = setmetatable({}, {__index = Node})
Sequence.__index = Sequence
function Sequence.new(name, children)
local self = setmetatable(Node.new(name), Sequence)
self.children = children
return self
end
function Sequence:tick(blackboard)
for _, child in ipairs(self.children) do
local status = child:tick(blackboard)
if status ~= Status.Success then
return status
end
end
return Status.Success
end
-- Condition Node
local Condition = setmetatable({}, {__index = Node})
Condition.__index = Condition
function Condition.new(name, checkFunc)
local self = setmetatable(Node.new(name), Condition)
self.check = checkFunc
return self
end
function Condition:tick(blackboard)
return self.check(blackboard) and Status.Success or Status.Failure
end
-- Action Node
local Action = setmetatable({}, {__index = Node})
Action.__index = Action
function Action.new(name, actionFunc)
local self = setmetatable(Node.new(name), Action)
self.action = actionFunc
return self
end
function Action:tick(blackboard)
return self.action(blackboard)
end
```
Example Combat AI Behavior Tree
```lua
local function createCombatAI(npc)
local blackboard = {
npc = npc,
target = nil,
lastAttackTime = 0,
health = 100
}
local tree = Selector.new("Root", {
-- Priority 1: Flee if low health
Sequence.new("FleeIfLowHealth", {
Condition.new("IsLowHealth", function(bb)
return bb.health < 20
end),
Action.new("Flee", function(bb)
fleeBehavior(bb.npc, bb.target)
return Status.Running
end)
}),
-- Priority 2: Attack if target in range
Sequence.new("AttackSequence", {
Condition.new("HasTarget", function(bb)
return bb.target ~= nil
end),
Condition.new("InAttackRange", function(bb)
local distance = getDistance(bb.npc, bb.target)
return distance < 5
end),
Condition.new("CanAttack", function(bb)
return os.clock() - bb.lastAttackTime > 1
end),
Action.new("Attack", function(bb)
attackTarget(bb.npc, bb.target)
bb.lastAttackTime = os.clock()
return Status.Success
end)
}),
-- Priority 3: Chase target
Sequence.new("ChaseSequence", {
Condition.new("HasTarget", function(bb)
return bb.target ~= nil
end),
Action.new("ChaseTarget", function(bb)
moveTo(bb.npc, bb.target.PrimaryPart.Position)
return Status.Running
end)
}),
-- Priority 4: Patrol
Action.new("Patrol", function(bb)
patrolBehavior(bb.npc)
return Status.Running
end)
})
-- Tick the tree regularly
task.spawn(function()
while npc.Parent do
tree:tick(blackboard)
task.wait(0.1)
end
end)
return blackboard
end
```
State Machines
Finite State Machine
```lua
local StateMachine = {}
StateMachine.__index = StateMachine
function StateMachine.new(initialState)
return setmetatable({
currentState = initialState,
states = {},
transitions = {}
}, StateMachine)
end
function StateMachine:addState(name, callbacks)
self.states[name] = {
enter = callbacks.enter or function() end,
update = callbacks.update or function() end,
exit = callbacks.exit or function() end
}
end
function StateMachine:addTransition(from, to, condition)
self.transitions[from] = self.transitions[from] or {}
table.insert(self.transitions[from], {
target = to,
condition = condition
})
end
function StateMachine:changeState(newState)
if not self.states[newState] then return end
if self.currentState then
self.states[self.currentState].exit(self)
end
self.currentState = newState
self.states[newState].enter(self)
end
function StateMachine:update(dt)
-- Check transitions
local transitions = self.transitions[self.currentState]
if transitions then
for _, transition in ipairs(transitions) do
if transition.condition(self) then
self:changeState(transition.target)
return
end
end
end
-- Update current state
if self.states[self.currentState] then
self.states[self.currentState].update(self, dt)
end
end
-- Example usage for NPC
local function createNPCStateMachine(npc)
local sm = StateMachine.new("Idle")
sm.npc = npc
sm.target = nil
sm.patrolIndex = 1
sm.patrolPoints = getPatrolPoints(npc)
sm:addState("Idle", {
enter = function(self)
self.npc:FindFirstChildOfClass("Humanoid").WalkSpeed = 0
end,
update = function(self, dt)
-- Look around occasionally
if math.random() < 0.01 then
lookAround(self.npc)
end
end
})
sm:addState("Patrol", {
enter = function(self)
self.npc:FindFirstChildOfClass("Humanoid").WalkSpeed = 8
end,
update = function(self, dt)
local target = self.patrolPoints[self.patrolIndex]
if reachedPoint(self.npc, target) then
self.patrolIndex = self.patrolIndex % #self.patrolPoints + 1
end
moveTo(self.npc, target)
end
})
sm:addState("Chase", {
enter = function(self)
self.npc:FindFirstChildOfClass("Humanoid").WalkSpeed = 16
end,
update = function(self, dt)
if self.target then
moveTo(self.npc, self.target.PrimaryPart.Position)
end
end
})
sm:addState("Attack", {
enter = function(self)
self.npc:FindFirstChildOfClass("Humanoid").WalkSpeed = 0
end,
update = function(self, dt)
if self.target then
faceTarget(self.npc, self.target)
attack(self.npc, self.target)
end
end
})
-- Transitions
sm:addTransition("Idle", "Patrol", function(self)
return os.clock() % 10 > 5 -- Alternate idle/patrol
end)
sm:addTransition("Patrol", "Chase", function(self)
return self.target ~= nil
end)
sm:addTransition("Chase", "Attack", function(self)
return self.target and getDistance(self.npc, self.target) < 5
end)
sm:addTransition("Attack", "Chase", function(self)
return self.target and getDistance(self.npc, self.target) > 7
end)
sm:addTransition("Chase", "Patrol", function(self)
return self.target == nil
end)
return sm
end
```
Perception Systems
Vision Cone
```lua
local function canSeeTarget(npc, target, fovAngle, maxDistance)
fovAngle = fovAngle or 90 -- degrees
maxDistance = maxDistance or 50
local npcRoot = npc:FindFirstChild("HumanoidRootPart")
local targetRoot = target:FindFirstChild("HumanoidRootPart")
if not npcRoot or not targetRoot then return false end
local toTarget = targetRoot.Position - npcRoot.Position
local distance = toTarget.Magnitude
-- Distance check
if distance > maxDistance then return false end
-- Angle check (dot product)
local npcForward = npcRoot.CFrame.LookVector
local directionToTarget = toTarget.Unit
local dot = npcForward:Dot(directionToTarget)
local angle = math.deg(math.acos(dot))
if angle > fovAngle / 2 then return false end
-- Line of sight check (raycast)
local rayParams = RaycastParams.new()
rayParams.FilterDescendantsInstances = {npc}
local result = workspace:Raycast(npcRoot.Position, toTarget, rayParams)
if result then
return result.Instance:IsDescendantOf(target)
end
return false
end
```
Hearing System
```lua
local SoundEvents = {}
local function emitSound(position, radius, soundType)
for _, npc in ipairs(activeNPCs) do
local npcPos = npc.PrimaryPart.Position
local distance = (position - npcPos).Magnitude
if distance <= radius then
-- Sound intensity falls off with distance
local intensity = 1 - (distance / radius)
-- Notify NPC's AI
local ai = npc:GetAttribute("AIController")
if ai then
ai:onHearSound(position, soundType, intensity)
end
end
end
end
-- Usage
emitSound(player.Character.PrimaryPart.Position, 30, "Gunshot")
emitSound(player.Character.PrimaryPart.Position, 10, "Footstep")
```
Memory System
```lua
local NPCMemory = {}
function NPCMemory.new(forgetTime)
return {
memories = {},
forgetTime = forgetTime or 30
}
end
function NPCMemory:remember(entityId, data)
self.memories[entityId] = {
data = data,
lastSeen = os.clock()
}
end
function NPCMemory:getLastKnown(entityId)
local memory = self.memories[entityId]
if not memory then return nil end
if os.clock() - memory.lastSeen > self.forgetTime then
self.memories[entityId] = nil
return nil
end
return memory.data
end
function NPCMemory:forgetOld()
local now = os.clock()
for entityId, memory in pairs(self.memories) do
if now - memory.lastSeen > self.forgetTime then
self.memories[entityId] = nil
end
end
end
-- Usage in AI
local function updatePerception(npc, memory)
for _, player in ipairs(Players:GetPlayers()) do
if player.Character and canSeeTarget(npc, player.Character, 90, 50) then
memory:remember(player.UserId, {
position = player.Character.PrimaryPart.Position,
lastSeenTime = os.clock()
})
end
end
end
```
NPC Management
NPC Pooling
```lua
local NPCPool = {}
NPCPool.available = {}
NPCPool.active = {}
NPCPool.template = nil
function NPCPool.init(template, initialSize)
NPCPool.template = template
for i = 1, initialSize do
local npc = template:Clone()
npc.Parent = nil -- Not in workspace
table.insert(NPCPool.available, npc)
end
end
function NPCPool.spawn(position, data)
local npc
if #NPCPool.available > 0 then
npc = table.remove(NPCPool.available)
else
npc = NPCPool.template:Clone()
end
-- Reset NPC state
npc:PivotTo(CFrame.new(position))
local humanoid = npc:FindFirstChildOfClass("Humanoid")
humanoid.Health = humanoid.MaxHealth
-- Apply data
for key, value in pairs(data or {}) do
npc:SetAttribute(key, value)
end
npc.Parent = workspace.NPCs
table.insert(NPCPool.active, npc)
return npc
end
function NPCPool.despawn(npc)
-- Remove from active
local index = table.find(NPCPool.active, npc)
if index then
table.remove(NPCPool.active, index)
end
-- Reset and return to pool
npc.Parent = nil
table.insert(NPCPool.available, npc)
end
```
Performance Budgeting
```lua
local AI_BUDGET_MS = 5 -- Max 5ms per frame for AI
local function updateAllAI()
local startTime = os.clock()
local processed = 0
for _, npc in ipairs(activeNPCs) do
-- Process this NPC's AI
updateNPCAI(npc)
processed = processed + 1
-- Check budget
local elapsed = (os.clock() - startTime) * 1000
if elapsed > AI_BUDGET_MS then
break
end
end
-- Continue next frame with remaining NPCs
-- Use round-robin or priority-based ordering
end
RunService.Heartbeat:Connect(updateAllAI)
```