Vibe Index
🎯

npc-ai

🎯Skill

from taozhuo/game-dev-skills

VibeIndex|
AI Summary

Generates intelligent behavior and decision-making algorithms for non-player characters in game environments, enabling dynamic and context-aware interactions.

npc-ai

Installation

Install skill:
npx skills add https://github.com/taozhuo/game-dev-skills --skill npc-ai
Stars0
AddedJan 25, 2026

Skill Details

SKILL.md

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)

```