Skip to content

NPCs and pathfinding

Recipes for NPC archetypes with skill models and movesets, a brain tick that runs only while NPCs live, four pathfinding methods, and owned companion pets.

NPCKit provides senses, aim, movement, movesets and a spawn and despawn lifecycle, and runs no decision loop of its own. Chase, cover and flank decisions are game rules your own tick makes. Pathfinding has the same shape: one handler, four search methods, and a follower your tick steps. Server examples are services in src/Server/Game/Services/ (see Services and controllers).

The kit owns spawning and per-NPC state (skill model, sight and hearing memory, cooldowns). The service owns the decisions, in a tick that runs only while one of its NPCs is alive:

src/Server/Game/Services/CultistService.luau
--!strict
-- CultistService: cultist NPCs and the tick that drives them.
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
local NPCKit = require(ServerScriptService.ChloeKernelServer.Kits.NPCKit)
local Pathfinding = require(ServerScriptService.ChloeKernelServer.Pathfinding)
local Projectiles = require(ServerScriptService.ChloeKernelServer.Projectiles)
local FireboltId = 2
local TickSeconds = 0.15
local CultistService = {
Name = "CultistService",
}
function CultistService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Projectiles = self.Scope:add(Projectiles.attach(kernel), "destroy")
self.Projectiles:define(FireboltId, {
Speed = 120,
MaxLifetime = 3,
OnHit = function(_owner, _result, victim: Player?)
local Humanoid = victim and victim.Character and victim.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(15)
end
end,
})
self.Npcs = self.Scope:add(NPCKit.attach(kernel, {
Pathfinding = Pathfinding.attach(kernel),
Grid = Pathfinding.grid({ Origin = Vector3.new(-200, 0, -200), Width = 100, Height = 100, CellSize = 4 }),
Projectiles = self.Projectiles,
Sounds = {
Occlusion = "Path", -- sound travels around walls; "Blocked" stops at walls, "Through" (default) ignores them
Topics = { ["Weapon.Fired"] = 60, ["Spell.Cast"] = 30 }, -- player actions heard within these ranges
},
}), "destroy")
self.Npcs:define("Cultist", {
Model = ServerStorage.NPC.Cultist,
Difficulty = "Medium", -- Perfect, HumanPeak, ReallyGood, Good, Medium, Novice, Bad, or a table
Moveset = {
-- The same projectile definition players fire: the NPC handle rides as the owner
Firebolt = { Kind = "Projectile", DefId = FireboltId, Speed = 120, Cooldown = 3 },
},
})
end
function CultistService.start(self: any)
self.Scope:add(self.Kernel.Bus:subscribe("Npc.Spawned", function()
self:_wake()
end))
self.Npcs:spawn("Cultist", CFrame.new(10, 5, -40))
self.Npcs:spawn("Cultist", CFrame.new(30, 5, -60), { Difficulty = "HumanPeak" })
end
-- The brain starts with the first cultist and stops when none is left alive
function CultistService._wake(self: any)
if self.Brain then
return
end
self.Brain = self.Kernel.Scheduler:every(TickSeconds, function()
self:_think()
end, self.Kernel.Priority.Low)
end
function CultistService._think(self: any)
local Roster = self.Npcs:all()
if #Roster == 0 then
self.Brain:cancel()
self.Brain = nil
return
end
self.Npcs:update() -- squad director and due melee windups
for _, Npc in Roster do
Npc:updateTarget(80, 120, { RequireSight = true, MemorySeconds = 3 })
local Target = Npc.Target
if Target == nil then
if Npc.State.HeardPosition then
Npc:moveTowards(Npc.State.HeardPosition) -- investigate the noise
end
continue
end
local Position = Npc:targetPosition(Target)
if Position == nil then
continue
end
if (Position - Npc:position()).Magnitude > 40 then
Npc:moveTowards(Position)
else
Npc:faceTowards(Position) -- a stopped body keeps turning, or its own cone loses the target
if Npc:canReact() and Npc:canSee(Target) then
Npc:act("Firebolt", Target)
end
end
end
end
function CultistService.stop(self: any)
if self.Brain then
self.Brain:cancel()
self.Brain = nil
end
self.Scope:destroy()
end
return CultistService

The handle’s helpers are the vocabulary: updateTarget (acquisition, sight memory, graded detection), canSee and sweep (vision cone, raycast fans across corners), aimAt (the skill model), act (the moveset), defend (reaction rolls), moveTowards and faceTowards (movement and body tracking), Npcs:findCover and Npcs:findPeekPoint (cover scoring), Npcs:all() (living NPCs, dying ones excluded) and Npcs:update(). The kit starts no loop of its own.

Difficulty is nine numbers. AimErrorDegrees is the per-axis spread, ReactionSeconds the delay between noticing and acting, LeadSkill the fraction (0 to 1) of the ideal moving-target lead, CooldownMultiplier the attack pace, HearingMultiplier and HearingBlurStuds the ears, PathSkill the routing quality, DefenseSkill the chance a defensive reaction succeeds, and FieldOfViewDegrees the vision cone (Perfect sees 360 degrees, Bad 100, a custom table defaults to 120). The named levels run from Perfect (no aim error, no reaction delay, full lead) to Bad (20 degrees of error, 1.2 seconds to react, no lead), and Npc:setDifficulty("HumanPeak") swaps the model at runtime. The same numbers drive every moveset kind: Projectile leads moving targets and solves a ballistic arc when the action sets Gravity, Hitscan resolves at once and publishes Npc.Hitscan for tracers, Melee swings after an honest WindupSeconds dodge window, and Custom receives a pre-aimed direction for the same server function a player’s WeaponKit or SpellKit handler calls.

Sight is a cone. Npc:canSee(target, maxRange?, fov?) checks range when one is passed, then the cone against the rig’s facing, then line of sight against colliding geometry only, so trigger volumes never blind a guard. RequireSight keeps a briefly hidden target for MemorySeconds, then drops it and hands its last seen position to the hearing memory, so the loop searches that spot instead of pathing through walls. Listed Bus topics make noise at the acting player’s position, Npcs:emitSound(position, { Range = 80, Loudness = 2 }) makes it by hand, and the heard position lands in State.HeardPosition, blurred by skill (Bad hears a 32-stud blur). Npc:notifyDamage(attacker?), automatic for kit hits and Humanoid damage on spawned rigs, sets State.SuppressedUntil 1.5 seconds ahead (aim error grows by half meanwhile) and, when the NPC has no target, writes the attacker’s position into the hearing memory.

The NPCKit page covers the rest: graded detection that publishes Npc.Suspicious before acquiring, magazines and reloads, cover nodes and peek points, squads with a shared blackboard and a Pressure, Flank and Suppress director, NPC-versus-NPC combat with Targetable = true, RagdollOnDeath, Description outfits, and Traverse handlers for PathfindingLink crossings. When the tick’s logic outgrows if statements, BehaviorTree composes selectors and sequences with real Running semantics; build one tree per NPC and tick it from this same loop. NPCs are server-simulated: the kit pins their network ownership to the server unless the archetype sets ClientPhysics = true. Dead NPCs set npc.Dying between Npc.Died and despawn (DespawnSeconds, default 3) and stop acting. Bus: Npc.Spawned, Npc.Died, Npc.Action, Npc.Heard, Npc.Suspicious, Npc.Damaged, Npc.Reload, Npc.Windup, Npc.Path, Npc.Hitscan, Npc.Defense, Npc.Tactic, Npc.PathLink.

Go deeper: NPCKit, Senses, BehaviorTree, Projectiles.

local Paths = Pathfinding.attach(kernel)
local Grid = Pathfinding.grid({ Origin = Vector3.new(-200, 0, -200), Width = 100, Height = 100, CellSize = 4 })
local From, To = Vector3.new(-180, 0, -180), Vector3.new(120, 0, 40)
local Ok, Waypoints = Paths:find({ Method = "AStar", Grid = Grid, Start = From, Goal = To }) -- complete grid search
Paths:find({ Method = "ThetaStar", Grid = Grid, Start = From, Goal = To }) -- any-angle: straighter lines
Paths:find({ Method = "Direct", Start = From, Goal = To, Exclude = { Guard } }) -- one ray against solid geometry
Paths:find({ Method = "Roblox", Start = From, Goal = To, AgentParams = { AgentRadius = 3 } }) -- engine navmesh; yields

One handler, four methods, each loaded on first use. find returns true and the waypoints, or false and a reason (StartOutsideGrid, GoalOutsideGrid, StartBlocked, GoalBlocked, BudgetExhausted, NoPath, Blocked for Direct). A* and Theta* search a rasterized grid under a MaxExpansions budget (8192), so a sealed-off goal cannot take the frame. Grid:refresh() re-rasterizes after the map changes, and Grid:refreshRegion(minWorld, maxWorld) drops only the cells a moving wall or gate swept. Roblox wraps PathfindingService: it also returns the Path (pass it back as Path to recompute instead of creating another), a set of jump waypoint indices and the engine PathWaypoints with their Action and Label.

The rasterizer raycasts for ground (Terrain, parts and models count; CanCollide = false decor never becomes floor) and needs the agent-height column above it clear. Bodies (anything under a Humanoid model) never rasterize, because a cell probed while someone stood on it would stay blocked, and clutter below step height does not block the cells around it. Wedges, cylinders, meshes and unions confirm against real geometry, so ramps rasterize as walkable slopes. Cells carry their ground height, so waypoints follow elevation, and neighbors rising more than MaxStepHeight (4) read as cliffs. Grid:nearestWalkable(position) snaps a click on top of a wall to the nearest standable cell, and Grid:addLink(fromWorld, toWorld) authors jump and vault edges the searches traverse but never cut across. Grid:lineOfSight answers “can I walk straight there” and respects step height; Grid:sightLine answers “can I see there”, so NPC vision uses one and movement the other. Paths:distanceField({ Grid, Origin, MaxDistance }) floods route distances from one origin in a single search, which is what makes Occlusion = "Path" cheap.

Movement is a helper your tick steps. This patrol runs only while the guard is on its way and ends when it arrives or its rig is destroyed:

src/Server/Game/Services/PatrolService.luau
function PatrolService.send(self: any, rig: Model, goal: Vector3)
if self.Patrol then
self.Patrol:destroy()
end
local Humanoid = rig:FindFirstChildOfClass("Humanoid")
if not Humanoid then
return
end
local Mover = Pathfinding.follower({ Paths = self.Paths, Grid = self.Grid, Method = "ThetaStar" })
local Patrol = self.Kernel:createScope()
self.Patrol = Patrol
Patrol:add(rig.Destroying:Connect(function()
Patrol:destroy()
end))
Patrol:add(self.Kernel.Scheduler:every(0.1, function()
local Position = rig:GetPivot().Position
if (Position - goal).Magnitude < 3 then
Patrol:destroy() -- arrived: the job ends with its scope
return
end
local NextPoint, Info = Mover:step(Position, goal)
Humanoid:MoveTo(NextPoint)
if Info.Jump or Info.Stuck then
Humanoid.Jump = true -- a waypoint above step height, or the stuck watchdog fired
end
end, self.Kernel.Priority.Low))
end

follower:step(position, goal) returns the next point and { Path?, Jump, Stuck, Link? }. The follower re-paths when the goal drifts past RepathDistance (one cell by default) or Grid.Version changes, backs off for one second after a failed search so an unreachable goal does not spend the full budget every tick, flags Stuck after a second with less than a stud covered, and runs Method = "Roblox" searches off-thread so the caller never yields. A Roblox follower keeps one Path built from its AgentParams, and at a PathfindingLink start step returns Link = { Label, From, To } until the agent reaches the landing. Npc:moveTowards wraps one of these.

Go deeper: Pathfinding, Scope.

CompanionKit gives a session one owned NPC follower built from an NPCKit archetype. This one follows a player who owns the Cat item from Give players an inventory:

src/Server/Game/Services/PetService.luau
--!strict
-- PetService: the black cat familiar for players who own one.
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
local CompanionKit = require(ServerScriptService.ChloeKernelServer.Kits.CompanionKit)
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local NPCKit = require(ServerScriptService.ChloeKernelServer.Kits.NPCKit)
local PetService = {
Name = "PetService",
Dependencies = { "EconomyService", "StatusService" },
}
function PetService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Npcs = self.Scope:add(NPCKit.attach(kernel), "destroy")
self.Npcs:define("Cat", { Model = ServerStorage.NPC.BlackCat, WalkSpeed = 20 })
end
function PetService.start(self: any)
local Inventory = self.Kernel:getService("EconomyService").Inventory
-- One Effects instance per game: WalkSpeed is recomputed across all of its effects
local Buffs = self.Kernel:getService("StatusService").Buffs
Buffs:define("CatLuck", { WalkSpeed = 1.1 }) -- no Duration: held while the cat is out
self.Companions = self.Scope:add(CompanionKit.attach(self.Kernel, {
Npcs = self.Npcs,
Effects = Buffs,
Companions = {
BlackCat = { Archetype = "Cat", FollowDistance = 8, TeleportDistance = 60, Aura = "CatLuck" },
},
}), "destroy")
-- Ownership gate for client intents and server calls alike
self.Scope:add(self.Kernel.Hooks:on("Companion.CanSummon", function(context)
if context.CompanionId == "BlackCat" then
return Inventory:count(context.Session, "Cat") > 0
end
return nil
end))
self.Scope:add(Milestones.on(self.Kernel, "Kernel.ProfileLoaded", function(session)
self.Companions:summon(session, "BlackCat") -- the hook refuses players without a cat
end))
end
function PetService.stop(self: any)
self.Scope:destroy()
end
return PetService

One companion per session; a new summon replaces the old one. The follow job runs every 0.25 seconds and only while a companion is out. Past FollowDistance a plain Humanoid companion walks with one Humanoid:MoveTo aimed at the owner’s root, which the engine keeps re-targeting as the owner moves; the kit re-issues it every 7 seconds (the engine gives up after 8) and after a walk that timed out. A companion on an NPCKit with Pathfinding and a Grid, or with a custom archetype Move, steps Npc:moveTowards instead and paths around obstacles. Past TeleportDistance the companion lands on the first of four spots beside the owner that no solid part overlaps, facing the owner, and falls back to Model:MoveTo when every spot is blocked. A dead companion clears at once with Companion.Died. Aura holds an Effects buff on the owner while the companion is out and removes it on dismiss or death, which is why CatLuck has no Duration.

Clients send CP_Summon {companionId} and CP_Dismiss (rate limit 5 each) through fail-closed validators that reject unknown ids and dismissals with nothing summoned. The fail-open Companion.CanSummon hook (Session, CompanionId) holds ownership rules. The companion is an ordinary NPCKit handle, so give its archetype a Moveset and a tick like the one above can make it fight. Bus: Companion.Summoned(session, companionId, npc), Companion.Dismissed(session, companionId), Companion.Died(session, companionId).

Go deeper: CompanionKit, NPCKit, Milestones.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.