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).
Give NPCs brains, aim skill and movesets
Section titled “Give NPCs brains, aim skill and movesets”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:
--!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 = 2local 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 alivefunction 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 endend
function CultistService.stop(self: any) if self.Brain then self.Brain:cancel() self.Brain = nil end self.Scope:destroy()end
return CultistServiceThe 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.
Find paths four different ways
Section titled “Find paths four different ways”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 searchPaths:find({ Method = "ThetaStar", Grid = Grid, Start = From, Goal = To }) -- any-angle: straighter linesPaths:find({ Method = "Direct", Start = From, Goal = To, Exclude = { Guard } }) -- one ray against solid geometryPaths:find({ Method = "Roblox", Start = From, Goal = To, AgentParams = { AgentRadius = 3 } }) -- engine navmesh; yieldsOne 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:
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))endfollower: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.
Summon familiars and pets
Section titled “Summon familiars and pets”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:
--!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 PetServiceOne 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.