npc-ai

SKILL.md

Roblox NPC & AI Systems

When implementing AI systems, use these Roblox-specific patterns for performant and intelligent NPCs.

NPC Creation (CRITICAL: Use Modern Patterns)

NEVER build NPCs by manually creating Parts. Use HumanoidDescription + CreateHumanoidModelFromDescriptionAsync.

Creating NPCs with HumanoidDescription

local Players = game:GetService("Players")

-- Create a basic NPC
local function createNPC(config)
    local description = Instance.new("HumanoidDescription")

    -- Customize appearance
    description.HeadColor = config.headColor or Color3.new(1, 0.8, 0.6)
    description.TorsoColor = config.torsoColor or Color3.new(0.2, 0.2, 0.8)
    description.LeftArmColor = config.headColor or Color3.new(1, 0.8, 0.6)
    description.RightArmColor = config.headColor or Color3.new(1, 0.8, 0.6)
    description.LeftLegColor = config.legColor or Color3.new(0.1, 0.1, 0.4)
    description.RightLegColor = config.legColor or Color3.new(0.1, 0.1, 0.4)

    -- Body proportions
    description.BodyTypeScale = config.bodyType or 0.5
    description.HeightScale = config.height or 1
    description.WidthScale = config.width or 1
    description.HeadScale = config.headScale or 1

    -- Accessories (comma-separated asset IDs)
    if config.hat then
        description.HatAccessory = config.hat
    end
    if config.shirt then
        description.Shirt = config.shirt
    end
    if config.pants then
        description.Pants = config.pants
    end

    -- Animations (optional custom animations)
    if config.idleAnimation then
        description.IdleAnimation = config.idleAnimation
    end
    if config.walkAnimation then
        description.WalkAnimation = config.walkAnimation
    end

    -- Create with proper rig (R15 recommended for modern features)
    local npc = Players:CreateHumanoidModelFromDescriptionAsync(
        description,
        config.rigType or Enum.HumanoidRigType.R15
    )
    npc.Name = config.name or "NPC"

    return npc
end

-- Usage
local guard = createNPC({
    name = "Guard",
    headColor = Color3.new(0.8, 0.6, 0.4),
    torsoColor = Color3.new(0.3, 0.3, 0.7),
    hat = "2551510151",  -- Asset ID
    height = 1.1,
    bodyType = 0.3
})
guard:PivotTo(CFrame.new(0, 5, 0))
guard.Parent = workspace.NPCs

Clone Player Appearance for NPC

local function createNPCFromPlayer(player)
    local description = Players:GetHumanoidDescriptionFromUserIdAsync(player.UserId)
    local npc = Players:CreateHumanoidModelFromDescriptionAsync(
        description,
        Enum.HumanoidRigType.R15
    )
    npc.Name = player.Name .. "_NPC"
    return npc
end

-- Create NPC from any user ID
local function createNPCFromUserId(userId, npcName)
    local description = Players:GetHumanoidDescriptionFromUserIdAsync(userId)
    local npc = Players:CreateHumanoidModelFromDescriptionAsync(
        description,
        Enum.HumanoidRigType.R15
    )
    npc.Name = npcName or "NPC"
    return npc
end

Modify Existing NPC Appearance

local function modifyNPCAppearance(npc, modifications)
    local humanoid = npc:FindFirstChildOfClass("Humanoid")
    if not humanoid then return end

    -- IMPORTANT: Get current description, don't create new
    local description = humanoid:GetAppliedDescription()

    -- Apply modifications
    if modifications.hat then
        -- Append to existing accessories
        local existing = description.HatAccessory
        description.HatAccessory = existing ~= "" and (existing .. "," .. modifications.hat) or modifications.hat
    end
    if modifications.torsoColor then
        description.TorsoColor = modifications.torsoColor
    end

    -- Apply updated description
    humanoid:ApplyDescriptionAsync(description)
end

Pathfinding

Basic PathfindingService Usage

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

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

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

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

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

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

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

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

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

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)
Weekly Installs
2
First Seen
Jan 25, 2026
Installed on
windsurf1
opencode1
cursor1
codex1
claude-code1
antigravity1