NPCs & Pathfinding
NPCKit is helpers, not an AI runtime — the kernel ships syscalls (senses, aim, movement, lifecycle), and chase/cover/flank policy is game rules your own loop writes. Pathfinding follows the same shape: one handler, four methods, and movement helpers your loop steps. Server snippets go in src/Server/Bootstrap.luau — see Getting Started for the bootstrap shape.
Give NPCs brains, aim skill, and movesets
Section titled “Give NPCs brains, aim skill, and movesets”The kit owns a thin spawn/despawn lifecycle plus per-npc state (skill model, senses memory, cooldowns); YOUR loop makes the decisions:
-- src/Server/Bootstrap.luaulocal ServerStorage = game:GetService("ServerStorage")local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal NPCKit = require(Root.Kits.NPCKit)local Pathfinding = require(Root.Pathfinding)local Projectiles = require(Root.Projectiles)
return function(kernel) local PJ = Projectiles.attach(kernel) PJ:define(2, { Speed = 120, MaxLifetime = 3, OnHit = function(ownerSession, result, victimPlayer) if victimPlayer then local Humanoid = victimPlayer.Character and victimPlayer.Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(15) end end end, })
local Paths = Pathfinding.attach(kernel) local Arena = Pathfinding.grid({ Origin = Vector3.new(-200, 0, -200), Width = 100, Height = 100, CellSize = 4 }) local Npcs = NPCKit.attach(kernel, { Pathfinding = Paths, Grid = Arena, Projectiles = PJ, Sounds = { Occlusion = "Path", -- sound goes AROUND obstacles, maze-correct; walls block it. -- "Blocked" = walls stop sound dead; "Through" (default) = walls ignored Topics = { ["Weapon.Fired"] = 60, ["Spell.Cast"] = 30 }, -- player actions that make noise }, })
Npcs:define("Cultist", { Model = ServerStorage.NPC.Cultist, Difficulty = "Medium", -- Perfect | HumanPeak | ReallyGood | Good | Medium | Novice | Bad — or a custom table Moveset = { -- The SAME projectile definition players fire — one server code path, -- two kinds of trigger finger. The npc handle rides as the owner session. Firebolt = { Kind = "Projectile", DefId = 2, Speed = 120, Cooldown = 3 }, }, })
Npcs:spawn("Cultist", CFrame.new(10, 5, -40)) Npcs:spawn("Cultist", CFrame.new(30, 5, -60), { Difficulty = "HumanPeak" }) -- per-spawn skill override
-- The game's brain tick: schedule it yourself, compose the helpers however -- your combat works. This one chases on sight and casts in range. kernel.Scheduler:every(0.15, function() Npcs:update() -- squad director + due melee windups for _, Npc in Npcs:all() 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 - Npc:position()).Magnitude > 40 then Npc:moveTowards(Position) else Npc:faceTowards(Position) if Npc:canReact() and Npc:canSee(Target) then Npc:act("Firebolt", Target) end end end end, kernel.Priority.Low)endThe handle’s helpers are the vocabulary: updateTarget (acquisition, sight memory, graded detection), canSee/sweep (cone-gated eyes, pie-slice corner fans), aimAt (the full skill model), act (movesets), defend (reaction rolls), moveTowards/faceTowards (movement + body tracking), Npcs:findCover/Npcs:findPeekPoint (cover scoring), Npcs:all() (the roster), Npcs:update() (director + windups).
Difficulty is nine knobs, not a vibe: AimErrorDegrees (per-axis spray), ReactionSeconds (delay between noticing and acting), LeadSkill (0..1 of the ideal moving-target intercept), CooldownMultiplier (attack pace), HearingMultiplier, HearingBlurStuds, PathSkill (routing quality), DefenseSkill (0..1 parry/deflect success), and FieldOfViewDegrees (the vision cone — Perfect scans 360, Bad sees 100 in front). Perfect is an aimbot, HumanPeak is plausible-human-limit, Bad can’t hit a barn — and a custom table slots anywhere between. The same knobs shape guns, wizard projectiles, and forest creatures alike: Kind = "Hitscan" resolves instantly and publishes Npc.Hitscan tracers, Kind = "Melee" gates on range and swings Damage/OnHit with an honest WindupSeconds dodge window, and Kind = "Custom" receives a pre-aimed direction so you can point it at the same server function a WeaponKit or SpellKit handler already calls. Npc:setDifficulty("HumanPeak") swaps the whole model live.
Sight is a cone, not a sphere. Npc:canSee(target) gates on range, then FieldOfViewDegrees against the rig’s actual facing, then line of sight — walk past a Bad guard’s back and it never knows. Sight-gated targeting (RequireSight) tracks a briefly-occluded target for MemorySeconds, then DROPS it and hands the last seen position to the hearing memory, so your loop hunts the spot like a soldier instead of pathing omnisciently through walls. NPCs also have ears: listed bus topics emit noise at the acting player’s position, Npcs:emitSound(position, { Range = 80, Loudness = 2 }) emits manually, and the heard fix lands in State.HeardPosition/HeardAt/HeardSource — blurred by the listener’s skill, Perfect pinpoints, Bad hears a vague 32-stud blur. The ear model is the standalone Senses module, so hand-rolled agents and kit npcs share one set of ears.
That is the core recipe. The deep page carries the rest of the arsenal: graded detection that charges toward Npc.Suspicious before acquiring, pain registration (Npc:notifyDamage blooms aim and turns a back-shot guard around), magazines and reloads, cover nodes and peek points, squads with a shared blackboard and a Pressure/Flank/Suppress director, and NPC-versus-NPC combat via Targetable = true. When ad-hoc loop logic stops being enough, BehaviorTree composes Selector/Sequence over Condition/Action leaves with real Running semantics — build one tree per npc and tick it from this same loop. With Zones attached, spawned models fire Zone.EntityEntered/Left (Zones).
Bus: Npc.Spawned/Died/Action/Heard/Suspicious/Damaged/Reload/Windup/Path/Hitscan/Defense/Tactic. Deep dives: NPCKit, BehaviorTree, Projectiles.
Find paths four different ways
Section titled “Find paths four different ways”-- src/Server/Bootstrap.luaulocal Pathfinding = require(game:GetService("ServerScriptService").ChloeKernelServer.Pathfinding)
return function(kernel) local Paths = Pathfinding.attach(kernel) local Grid = Pathfinding.grid({ Origin = Vector3.new(-200, 0, -200), Width = 100, Height = 100, CellSize = 4 })
local A = Vector3.new(-180, 0, -180) local B = Vector3.new(120, 0, 40)
local Ok, Waypoints = Paths:find({ Method = "AStar", Grid = Grid, Start = A, Goal = B }) -- complete grid search Paths:find({ Method = "ThetaStar", Grid = Grid, Start = A, Goal = B }) -- any-angle: best straight lines Paths:find({ Method = "Direct", Start = A, Goal = B }) -- one raycast: "can I walk at it" Paths:find({ Method = "Roblox", Start = A, Goal = B, AgentParams = { AgentRadius = 3 } }) -- engine navmesh; yieldsendOne handler, four methods, each lazily required on first use — a game that never calls AStar never loads it. AStar/ThetaStar run on a rasterized grid with a MaxExpansions budget so a sealed-off goal can’t eat the frame; failures return reasons (GoalBlocked, NoPath, BudgetExhausted, …), and Grid:refresh() re-rasterizes after the map changes — or Grid:refreshRegion(minWorld, maxWorld) drops only the cells a moving wall or gate swept, so a course that never stops moving stays cheap. Roblox wraps PathfindingService when you want the engine navmesh.
Grids account for the real world: the default rasterizer raycasts for ground (Terrain, parts, and model geometry all count — CanCollide = false decor never rasterizes as floor) and requires the agent-height column above it clear of CanCollide obstacles. Bodies (anything under a Humanoid model) and steppable clutter deliberately do NOT rasterize — a cell probed while someone stood on it would cache blocked forever, and a 1-stud lip shouldn’t dead-strip the cells around it. Wedges, cylinders, meshes, and unions confirm against real geometry in a narrow phase, so ramps rasterize as the walkable slopes they are. For user-driven queries, Grid:nearestWalkable(position) is the forgiving front door: it clamps off-grid points into bounds and snaps clicks on top of walls to the nearest standable cell. Cells carry their ground height, so waypoints follow elevation, adjacent cells rising more than MaxStepHeight (default 4) read as cliffs, and Grid:addLink(fromWorld, toWorld) authors off-mesh jump/vault edges the searches traverse but never shortcut across.
Feet and photons are different queries. Grid:lineOfSight answers “can I WALK straight there” — it gates every step along the line by MaxStepHeight, so a Theta* shortcut can never walk an agent off a sheer cliff — while Grid:sightLine answers “can I SEE there”: valleys that block feet stay transparent to eyes. NPCKit vision rides sightLine; movement rides lineOfSight. Paths:distanceField({ Grid = Grid, Origin = origin, MaxDistance = 120 }) Dijkstra-floods route distances from one origin in a single search — it backs emitSound’s Path occlusion, so a gunshot near fifty listeners costs one flood, not fifty searches.
Movement is a helper too, never a baked-in loop — Pathfinding.follower wraps re-pathing and waypoint-following in a per-agent handle YOUR loop steps:
local Rig = workspace.Guard local Humanoid = Rig:FindFirstChildOfClass("Humanoid") local Mover = Pathfinding.follower({ Paths = Paths, Grid = Grid, Method = "ThetaStar" })
kernel.Scheduler:every(0.1, function() local NextPoint, Info = Mover:step(Rig:GetPivot().Position, workspace.Objective.Position) Humanoid:MoveTo(NextPoint) if Info.Jump or Info.Stuck then Humanoid.Jump = true -- waypoint above step reach, or the watchdog fired end end, kernel.Priority.Low)The follower re-paths on goal drift past RepathDistance and on Grid.Version changes (a moved gate invalidates cached routes), backs off for a second after failed searches so an unreachable goal never re-burns the full budget every tick, flags Stuck after a second of zero ground covered, and computes Method = "Roblox" off-thread so a yielding navmesh search never stalls the caller. Npc:moveTowards is a thin wrapper over one of these.
Deep dive: Pathfinding.
Summon familiars and pets
Section titled “Summon familiars and pets”CompanionKit gives sessions an owned NPC follower built from NPCKit archetypes — the witchcraft familiar, the lobby pet:
-- src/Server/Bootstrap.luaulocal ServerStorage = game:GetService("ServerStorage")local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal CompanionKit = require(Root.Kits.CompanionKit)local Effects = require(Root.Effects)local NPCKit = require(Root.Kits.NPCKit)
return function(kernel) local Buffs = Effects.attach(kernel) Buffs:define("CatLuck", { WalkSpeed = 1.1 }) -- no Duration: held for as long as it's applied
local Npcs = NPCKit.attach(kernel) Npcs:define("Cat", { Model = ServerStorage.NPC.BlackCat, WalkSpeed = 20, })
local Companions = CompanionKit.attach(kernel, { Npcs = Npcs, Effects = Buffs, Companions = { BlackCat = { Archetype = "Cat", FollowDistance = 8, TeleportDistance = 60, Aura = "CatLuck" }, }, })
-- gate ownership; the hook covers client intents and server calls alike kernel.Hooks:on("Companion.CanSummon", function(context) if context.CompanionId == "BlackCat" then return context.Session.Data.OwnsBlackCat == true end end)
-- summon from server code — or wire it to an InventoryKit item's OnUse kernel:onSession(function(session) if session.Data.OwnsBlackCat then Companions:summon(session, "BlackCat") end end)end-- src/Client/Bootstrap.luau: summon from a UI buttonlocal NetClient = require(game:GetService("ReplicatedStorage").ChloeKernel.Net.Client)
return function(kernel) local Net = NetClient.new() local Summon = Net:intent("CP_Summon", { "String" }) local Dismiss = Net:intent("CP_Dismiss", {})
-- Summon.fire("BlackCat") / Dismiss.fire() from your UI handlersendOne companion per session — a new summon replaces the old. The follow loop (every 0.25s) walks the companion via Npc:moveTowards past FollowDistance — so it paths around obstacles when the NPCKit has a grid — and pivots it beside the owner past TeleportDistance. A dead companion clears with Companion.Died. Aura holds an Effects buff on the OWNER while summoned and removes it on dismiss or death, which is why the effect is defined without a Duration.
Clients ride CP_Summon/CP_Dismiss fail-closed: the kit’s own validators reject unknown companion ids and dismissals with nothing summoned, and the Companion.CanSummon hook (fail-open) is where ownership rules live — return false to veto. Because the companion is an ordinary NPCKit handle, everything from the previous recipes applies to it: give the archetype a Moveset and your brain tick can make the familiar fight.
Bus: Companion.Summoned(session, companionId, npc), Companion.Dismissed(session, companionId), Companion.Died(session, companionId). Deep dives: CompanionKit, NPCKit.