Skip to content

NPCKit

NPC lifecycle, a nine-knob skill model, movesets, senses, squads and cover scoring, exposed as helpers that a game-owned tick composes.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Kits.NPCKit
Since
v0.3.0
Source
Kits/NPCKit.luau

NPCKit is helpers, not an AI runtime. Chase, cover and flank policy are game rules, so the kit owns no decision loop. It owns a thin spawn and despawn lifecycle, per-NPC state (the skill model, senses memory, cooldowns), and a vocabulary of combat helpers that your own scheduled tick composes: acquisition, cone-gated sight, skill-shaped aim, movesets, defensive reactions, pathfinding movement, squad intel and cover scoring. It is a Kit.attach kit. For a decision layer above plain loop logic, use BehaviorTree, which you also tick yourself. CompanionKit builds owned followers on NPCKit archetypes, and MeleeKit accepts NPC handles as combatants.

The kit has three layers:

  1. The kit instance holds the archetype registry, the roster of active NPCs, squads, sound emission, world queries (hasLineOfSight, raycast) and cover scoring. kit:update() is the only kit-side step function, and you call it: it runs the squad directors and lands due melee windups. It reads the kit clock and uses no task.delay, so specs stay deterministic.
  2. The NPC handle is one table per spawned NPC with Id, Name, Archetype, Difficulty, Target, Alive, Dying, Model, Humanoid, Squad, Home, an Rng (seeded per NPC when the kit has a Seed) and State, the scratch that gives an NPC a memory: Cooldowns, HeardPosition, HeardAt, HeardSource, LastSeenPosition, LastSeenAt, NoticedAt, SuppressedUntil, Detection, Ammo, Reloads, Windups, Facing, Follower, AimDrift, PathLink.
  3. Your loop does the thinking. Nothing ticks unless you schedule it. The usual shape is one kernel.Scheduler:every(cadence, think) job, where think calls kit:update(), walks kit:all() and composes handle helpers per NPC.

Movement and perception use shared systems instead of copies. Hearing runs through Senses, routing through one Pathfinding follower per NPC, Kind = "Projectile" actions fire the same kernel Projectiles definitions that players shoot, spawned models track in Zones when attached, and RagdollOnDeath archetypes go down through Ragdoll.

src/Server/Game/Services/NpcService.luau
--!strict
-- NpcService: the NPC kit, its pathfinding grid, and the tick that drives it.
local ServerScriptService = game:GetService("ServerScriptService")
local NPCKit = require(ServerScriptService.ChloeKernelServer.Kits.NPCKit)
local Pathfinding = require(ServerScriptService.ChloeKernelServer.Pathfinding)
local Ragdoll = require(ServerScriptService.ChloeKernelServer.Ragdoll)
local NpcService = {
Name = "NpcService",
}
function NpcService.init(self: any, kernel: any)
self.Kernel = kernel
self.Npcs = NPCKit.attach(kernel, {
Pathfinding = Pathfinding.attach(kernel),
Grid = Pathfinding.grid({
Origin = Vector3.new(-200, 0, -200),
Width = 100,
Height = 100,
CellSize = 4,
}),
Ragdoll = Ragdoll.attach(kernel),
Sounds = {
Occlusion = "Path",
Topics = { ["Weapon.Fired"] = 60 },
},
})
end
function NpcService.stop(self: any)
self.Npcs:destroy()
end
return NpcService
Option Description
Pathfinding A Pathfinding.attach() instance. With Grid, required for routed movement and "Path" sound occlusion. Without both, moveTowards walks straight.
Grid The Pathfinding.grid() the NPCs walk on. Also backs grid sight lines, grid-cell cover scans and the repath distance.
PathMethod Overrides the skill-tuned "ThetaStar" or "AStar" choice for every NPC. "Roblox" routes on the engine navmesh through PathfindingService and enables AgentParams, Traverse and Npc.PathLink.
Projectiles The kernel Projectiles service. Required for Kind = "Projectile" actions: act errors without it.
Zones A Zones instance. Spawned models track for Zone.EntityEntered and Zone.EntityLeft.
Ragdoll A Ragdoll.attach() instance. RagdollOnDeath archetypes ragdoll through it when they die.
Spatial A Spatial index. kit:update() writes the current target roster’s positions into it and removes ids that left the roster. Target scans then query it with a radius padded by 8 studs before running any sight test, so a scan over hundreds of candidates pays full acquisition math only on the few the broad phase returns. Without it, acquisition checks every roster candidate.
Container Parent for spawned models. Default workspace.
Seed Deterministic Rng derivation: NPC n gets Random.new(Seed + n). Omit for nondeterministic spawns.
GetTargets Replaces the candidate roster. Default: every session player with a character, plus every living, non-dying NPC whose archetype sets Targetable = true.
Clock Injectable time source. Default os.clock. Cooldowns, windups, memory and detection all read it.
Sounds.Occlusion "Through" (default) ignores walls, "Blocked" stops sound at walls, "Path" measures distance around obstacles on the grid.
Sounds.Topics { [busTopic] = baseRange }. Each listed topic emits a sound at the first published argument that resolves to a position. Npc.* topics work too: { ["Npc.Hitscan"] = 60 } makes NPC gunfire alert other NPCs.
HasLineOfSight (from, to) -> boolean injectable. Replaces both the grid sight line and the world ray (specs, custom worlds).
Raycast (origin, displacement, excludeModel?) -> RaycastResult? injectable for hitscan and sweep queries. The default excludes the shooter’s own model.

kit:define(name, config) registers a template. Defining the same name twice errors. Per-action and per-reaction Difficulty overrides resolve once at define time, and reaction names sort at define time (see Defense).

Field Description
Model Template Model cloned per spawn. Omit it for headless rigs and specs: everything except a physical body still works.
Health Sets Humanoid.MaxHealth and Health on the clone.
WalkSpeed Sets Humanoid.WalkSpeed on the clone.
Difficulty A preset name or a custom table. Default "Medium".
Moveset { [actionName] = Action }. See Movesets.
DespawnSeconds The corpse window between Npc.Died and destroy(). Default 3.
ClientPhysics true leaves network ownership to the engine’s automatic assignment. By default spawned rigs are pinned to the server. See Network ownership.
RagdollOnDeath Sets Humanoid.BreakJointsOnDeath = false on the clone and, when the kit has a Ragdoll, ragdolls the rig before Npc.Died publishes.
Description A HumanoidDescription applied after spawn with Humanoid:ApplyDescriptionAsync(description, Enum.AssetTypeVerification.Always), so catalog assets only. It applies on its own thread and spawn returns at once. A failure warns unless the NPC already despawned or is dying.
AgentParams PathfindingService:CreatePath agent parameters for PathMethod = "Roblox". AgentRadius defaults to half the rig’s widest horizontal extent and AgentHeight to its height, both rounded to integers and at least 1.
Traverse { [linkLabel] = function(npc, from, to) }. Handlers for PathfindingLink crossings. See Movement.
Targetable Joins the default target roster so other NPCs can hunt this kind. NPC-against-NPC combat is opt-in per archetype. An NPC never targets itself.
Faction Same-faction NPCs never target each other. TeamKit also reads it as the NPC’s team.
Squad Joins this named squad on spawn: a shared blackboard and director roles.
Reactions { [reactionName] = Reaction } defensive rolls. See Defense.
GetPosition (npc) -> Vector3 injectable for custom rigs and specs. Default: Model.PrimaryPart.Position, the pivot as a fallback, Home when there is no model.
Move (npc, point) injectable movement. Default Humanoid:MoveTo(point).

Difficulty is nine knobs. Each knob is independent, and per-action and per-reaction overrides let one body carry Perfect defense and Novice offense.

Knob Meaning
AimErrorDegrees Maximum per-axis deviation from the true firing direction.
ReactionSeconds Delay between noticing a target and acting (canReact).
LeadSkill 0 to 1: the fraction of the ideal moving-target intercept applied.
CooldownMultiplier Multiplies action cooldowns and reload times.
HearingMultiplier Scales every sound’s range for this NPC. Custom-table default 1.
HearingBlurStuds Heard-position error at the edge of range (0 is pinpoint). Custom-table default 8.
PathSkill 0 to 1 routing quality. High picks any-angle Theta* and repaths sooner. Low picks plain A* and keeps stale paths longer. Custom-table default 1.
DefenseSkill 0 to 1 chance that a defensive reaction succeeds. Custom-table default 0.45.
FieldOfViewDegrees Horizontal vision cone centered on facing. canSee fails outside it. Hearing stays omnidirectional. Custom-table default 120.

The seven presets in NPCKit.Difficulties:

Knob Perfect HumanPeak ReallyGood Good Medium Novice Bad
AimErrorDegrees 0 0.8 2 4 7 12 20
ReactionSeconds 0 0.12 0.18 0.3 0.5 0.8 1.2
LeadSkill 1 0.95 0.85 0.7 0.5 0.25 0
CooldownMultiplier 1 1 1.1 1.25 1.5 2 3
HearingMultiplier 1.5 1.25 1.1 1 0.9 0.75 0.6
HearingBlurStuds 0 2 4 7 12 20 32
PathSkill 1 0.95 0.85 0.7 0.55 0.35 0.2
DefenseSkill 0.95 0.85 0.75 0.6 0.45 0.25 0.1
FieldOfViewDegrees 360 270 240 200 160 130 100

Perfect is an aimbot with all-round vision. Bad misses often and sees 100 degrees in front. A custom table works anywhere a preset does: archetype Difficulty, per-spawn overrides, per-action and per-reaction Difficulty, and npc:setDifficulty(nameOrTable) for a live swap mid-fight. An unknown preset name errors with the valid list.

Npcs:define("Guard", {
Model = game:GetService("ServerStorage").NPC.Guard,
Health = 120,
WalkSpeed = 14,
Difficulty = "Good",
RagdollOnDeath = true,
DespawnSeconds = 5,
Moveset = {
Rifle = { Kind = "Hitscan", Range = 180, Damage = 12, Cooldown = 0.6, Magazine = 6, ReloadSeconds = 2.2 },
},
})
local Guard = Npcs:spawn("Guard", CFrame.new(10, 5, -40))
local Veteran = Npcs:spawn("Guard", CFrame.new(14, 5, -40), { Difficulty = "HumanPeak" })

spawn(archetypeName, at, overrides?) takes a CFrame or Vector3 and per-spawn overrides for Difficulty and Squad. With a Model, the clone is named {archetype}{id}, pivoted to at, given WalkSpeed and Health, and parented to Container. Two Humanoid connections wire automatically:

  • Humanoid.Died starts the corpse window (below).
  • Humanoid.HealthChanged: any health drop calls npc:notifyDamage(nil), so damage from outside the kit still suppresses and alerts the NPC. The attacker is unknown at this layer; kit actions pass it directly.

The model registers in kit.ByModel (how ray victims resolve back to handles for automatic defenses) and in the list of NPC bodies that sight rays ignore. With Zones attached it tracks for zone events. With a Description, the appearance applies on its own thread. Npc.Spawned publishes last.

By default the engine hands an unanchored rig to the nearest client, and that client can then move it: an exploiter can drag a boss or a companion. NPCKit pins every spawned rig’s root (PrimaryPart, or HumanoidRootPart) to the server with SetNetworkOwner(nil) once it is parented. Anchored rigs, and rigs welded to anchored parts, cannot be owned and are skipped. Set ClientPhysics = true on an archetype to keep the engine’s automatic assignment. Ragdoll pins the limbs of a downed NPC to the server as well, so a ClientPhysics NPC stays server-owned after a ragdoll releases.

Humanoid.Died does not destroy the NPC at once. The kit:

  1. Sets npc.Dying = true and drops any pending melee windups.
  2. For a RagdollOnDeath archetype with a kit Ragdoll, calls Ragdoll:enable(model), so subscribers see the body already down.
  3. Publishes Npc.Died(npc).
  4. Schedules npc:destroy() after DespawnSeconds on the kernel Scheduler. npc:destroy() and kit:destroy() cancel that timer.

Between Npc.Died and despawn, npc.Alive stays true (it guards destroy) and npc.Dying is true. A dying NPC is excluded from kit:all(), kit:targets(), windup landing in kit:update(), sound listeners, squad roles and lane checks, and target acquisition skips it. npc:act() and npc:defend() return false. kit:count() still counts it until despawn. React to deaths through Npc.Died, since the corpse no longer appears in the roster your loop walks.

npc:destroy() is idempotent. It cancels the despawn timer, resets the pathfinding follower (releasing a navmesh route’s Blocked watch), untracks zones, disconnects the Humanoid connections, leaves the squad, deregisters the handle and destroys the model.

kit:targets() returns a fresh candidate roster. kit:all() returns living, non-dying NPCs in id order: the roster your loop walks. kit:count() counts active handles. kit:destroy() destroys every NPC, squad and sound subscription.

A complete loop: acquire on sight, investigate noise, chase, square up, attack. Add this start to the NpcService above and replace its stop, so the job is cancelled when the server closes:

src/Server/Game/Services/NpcService.luau
function NpcService.start(self: any)
local Npcs = self.Npcs
self.Think = self.Kernel.Scheduler:every(0.15, function()
Npcs:update() -- squad directors and 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 == nil then
continue
end
if (Position - Npc:position()).Magnitude > 30 then
Npc:moveTowards(Position)
else
Npc:faceTowards(Position)
if Npc:canReact() then
Npc:act("Rifle", Target)
end
end
end
end, self.Kernel.Priority.Low)
end
function NpcService.stop(self: any)
if self.Think then
self.Think:cancel()
end
self.Npcs:destroy()
end

kit:update(candidates?, refreshSpatial?) takes two optional arguments for large worlds. candidates restricts windup landing to an awake subset of handles. refreshSpatial = false skips writing positions into the Spatial index, for a game that maintains the shared index itself. In that case target acquisition builds one roster snapshot lazily for this update. Either way, every acquisition scan between two update() calls shares one roster snapshot instead of rebuilding it per NPC. A caller that uses helpers before its first update() gets a live roster read.

Targets are shape-agnostic throughout: Vector3, Player, a character or NPC Model, a BasePart, another NPC handle, a session (resolved through .Player), or any table carrying Position. npc:targetPosition(target) and npc:targetVelocity(target) do the resolution.

npc:aimAt(targetPosition, targetVelocity?, projectileSpeed?, gravity?, skill?) returns a unit direction through the full skill model:

  1. Lead comes from the exact intercept solve (|delta + v*t| = s*t). When no intercept exists (the target outruns the shot), it falls back to distance over speed. The solved lead time scales by LeadSkill, so Bad (0) never leads and Medium (0.5) trails sprinting targets.
  2. With gravity > 0, a low-arc launch solution compensates for drop. Out-of-range shots lob at the 45-degree maximum-range angle.
  3. The error is correlated drift plus near-Gaussian jitter (the average of two uniform samples, so it stays bounded), blended 60/40 and scaled by AimErrorDegrees. Drift carries between shots through an exponential moving average, so a burst walks like a hand. Time on target tightens the cone, down to 0.6 times after two seconds since State.NoticedAt. Suppression widens it 1.5 times while State.SuppressedUntil holds. Error stays within AimErrorDegrees per axis except when suppressed.

act calls aimAt for you with the action’s parameters. Call it directly when a Custom delivery needs a direction on your own terms.

An action is a named entry in the archetype’s Moveset, executed with npc:act(name, target). act returns false when gated: a dying or destroyed NPC, a running cooldown, a reload, out of range, no target position, lane discipline, or a projectile the Projectiles service refused. Gating never spends the cooldown. Execution spends the cooldown window and one magazine round and publishes Npc.Action(npc, name, target). Unknown action names error.

Field Applies to Description
Kind all "Projectile", "Hitscan", "Melee" or "Custom". Anything else errors at execution.
Cooldown all Seconds, scaled by CooldownMultiplier. Default 1.
Difficulty all Per-action skill override (preset name or table), resolved at define time. Governs this action’s aim error, lead, arc and cooldown pace.
DefId Projectile Kernel Projectiles definition id to fire.
Speed Projectile, Custom Projectile speed for target leading.
Gravity Projectile, Custom Enables the ballistic aim solve. Match the projectile definition’s Gravity.
OriginHeight Projectile, Hitscan Muzzle offset above npc:position(). Default 2.
Range Melee, Hitscan Melee reach (default 6), and the hitscan distance cap (default 200).
Damage Melee, Hitscan Humanoid:TakeDamage amount, applied only when there is no OnHit.
OnHit Melee, Hitscan (npc, target). Replaces the default damage. Hitscan passes the victim model, or the raw ray result when nothing (or a deflecting NPC) was hit.
Run Custom (npc, target, direction?). Required for Custom. direction arrives pre-aimed through the skill model, nil with no target.
Magazine any Shots before a forced reload. nil means no reload.
ReloadSeconds any Reload length, scaled by CooldownMultiplier. Default 2.
WindupSeconds Melee Telegraph: damage lands this long after the swing starts, and only if the victim is still in reach.
FriendlyFire Projectile, Hitscan true opts this ranged action out of squad lane discipline.
  • Projectile fires the shared kernel Projectiles service with the NPC handle as the owner session. Projectile.Hit and the definition’s OnHit receive the handle the way they receive a player’s session. Requires Projectiles at attach.
  • Hitscan resolves at once through kit:raycast: aim error only, no lead, no arc. Victims resolve through nested prop models to the Humanoid-bearing ancestor, so a hit on a held tool counts for the character. An NPC victim rolls its reactions first, and a successful deflect records no victim and deals no damage. Npc.Hitscan(npc, origin, hitPosition, victim?) publishes hit or miss: the tracer feed.
  • Melee gates on the 3D distance to the target against Range, then either lands at once or arms a windup. Windups land in kit:update() on the kit clock: reach is checked again at Range * 1.25 (a small grace, so a dodge during the windup escapes), the victim’s reactions roll, then Damage or OnHit applies. Npc.Windup(npc, name, target, seconds) publishes when the swing starts: the telegraph feed.
  • Custom runs your function with a pre-aimed direction. Point it at the server function that already backs a player intent, and NPCs get the same delivery with skill-shaped aim.

With Magazine = n, the nth executed shot empties the magazine and starts a reload: Npc.Reload(npc, name, seconds) publishes and act returns false until ReloadSeconds * CooldownMultiplier passes. That pause is the window players push into.

Ranged actions (Projectile and Hitscan) fired by a squad member hold fire when a living squadmate stands inside the firing lane: between origin and target, within 3 studs of the lane axis on the horizontal plane. The gated attempt returns false without spending cooldown or ammo. FriendlyFire = true opts one action out.

kit:emitSound(position, { Range?, Loudness?, Source? }) broadcasts a sound (default Range 40; Loudness multiplies it), and every living, non-dying NPC rolls its own hearing: range scaled by HearingMultiplier, heard position blurred by up to HearingBlurStuds at the edge of range. The ear model is the standalone Senses module (Senses.hear), so kit NPCs and hand-rolled agents share one set of ears. Occlusion resolves in the kit first:

  • Through (default): a straight-line range check that ignores walls.
  • Blocked: no line of sight from listener to origin means the sound is not heard.
  • Path: distance measured around obstacles. One Pathfinding:distanceField flood per sound gives every listener its route length, so a gunshot near fifty listeners costs one flood. No route, or an origin off the grid or inside a wall, means not heard. An injected finder without distanceField falls back to one A* route per listener.

A heard sound writes State.HeardPosition (already blurred), State.HeardAt and State.HeardSource, clears the State.SearchUntil and State.SearchGoal scratch fields (yours to use; a fresh sound restarts the hunt at the new spot), reports to the squad when the source is known, and publishes Npc.Heard. An NPC ignores its own sounds, so listing Npc.Hitscan in Sounds.Topics makes NPC gunfire alert everyone else.

npc:canSee(target, maxRange?, fovOverride?) checks in cost order: range, then the FieldOfViewDegrees cone against the rig’s actual facing (a dot product, so the ray only runs for things in frame), then line of sight from 2 studs above the NPC’s position. npc:facing() is the PrimaryPart look vector, falling back to State.Facing (written by movement, faceTowards, or directly on headless rigs). An unknown facing leaves the cone open.

Line of sight is kit:hasLineOfSight(from, to, target?): the injected function if there is one, else the grid’s sightLine when both points land on the grid (vision crosses valleys that block feet), else a world ray. The world ray respects CanCollide, so zones and trigger volumes never block sight. It ignores NPC bodies: with a custom Container it excludes that container, and with the default workspace container it excludes each spawned NPC model. A ray that lands on the looked-at target’s own body counts as seeing it. The parameter object is created once and its exclusion list is reassigned only when NPCs spawn or despawn.

Before 0.9.0 the world ray excluded the whole Container, and with the default container that was all of Workspace: NPCs saw through walls and cover scoring never found cover.

npc:sweep(direction, arcDegrees?, rayCount?, range?) fans a horizontal spread of short rays (defaults 90 degrees, 5 rays, 24 studs) and returns the first Humanoid model touched. Fan the unseen angle before the body commits past a corner.

npc:updateTarget(acquireRange, giveUpRange, options?) is the acquisition helper:

Option Description
RequireSight Acquisition and continued tracking need canSee. Default false.
MemorySeconds How long a briefly occluded target stays tracked. Default 3.
DetectionSeconds Enables graded awareness instead of an instant lock.

The rules, in order:

  1. The current target is kept while it stays inside giveUpRange. Without RequireSight that is the whole check. With it, a seen target refreshes State.LastSeenPosition and State.LastSeenAt and reports to the squad. A target unseen for more than 0.35 s that reappears costs half the reaction time again (State.NoticedAt is set back by half of ReactionSeconds): a reappearance is a fresh stimulus.
  2. A briefly occluded target stays tracked for MemorySeconds. Past that the target drops and the last seen position goes into the hearing memory (State.HeardPosition), so your loop hunts the spot instead of pathing through walls to a position it cannot know.
  3. With no target, the NPC acquires the nearest candidate within acquireRange, skipping itself, its own model, dead or dying NPCs, and faction-mates. RequireSight gates candidates through the cone and ray check. Ties at equal distance go to the later candidate in roster order. Acquisition stamps State.NoticedAt (the reaction clock) and reports to the squad.
  4. With DetectionSeconds set, sight charges an accumulator instead of locking. The charge rate scales with proximity (0.5 to 1.5 times across the acquire range) and with target movement (1.5 times against anything faster than 8 studs per second), and decays at the base rate while nothing is visible. Crossing half charge publishes Npc.Suspicious(npc, target, glimpsedPosition) and points the hearing memory at the glimpse. Full charge acquires.

npc:canReact() is the reaction gate: true once ReactionSeconds have passed since State.NoticedAt, and at once for zero-reaction skills with no notice stamp.

Getting hit registers. npc:notifyDamage(attacker?) suppresses aim for 1.5 seconds (State.SuppressedUntil; read it in your loop to duck an exposed peek) and, when the NPC has no target, puts the attacker’s position into the hearing memory: shooting a guard in the back turns it around. Kit Hitscan and Melee actions call it on NPC victims (even when the hit was deflected or blocked), Humanoid health drops on spawned rigs call it with an unknown attacker, and your own damage code should call it too. It publishes Npc.Damaged(npc, attacker?).

npc:moveTowards(goal) is one movement step on a per-NPC Pathfinding follower. Call it every tick the NPC should move; it is not fire-and-forget. With Pathfinding and Grid attached:

  • PathSkill >= 0.7 picks any-angle "ThetaStar", below that plain "AStar" (staircase routes). The kit-wide PathMethod overrides both.
  • Repath eagerness follows RepathDistance = CellSize * (1 + (1 - PathSkill) * 4): high skill repaths when the goal drifts one cell, and Bad keeps a stale path until the goal has drifted about four cells.
  • Fresh routes publish Npc.Path(npc, waypoints): the full route with the NPC’s own position first, for visualization.
  • Follower Stuck and Jump hints set Humanoid.Jump: the unstick hop and navmesh jump waypoints.
  • The follower brings failed-search backoff, Grid.Version invalidation when the map refreshes, and the 1-second stuck watchdog.

The follower is created on the first moveTowards call with the archetype’s AgentParams, sized from the rig’s extents where the archetype leaves AgentRadius or AgentHeight unset. A navmesh Path takes its agent parameters at creation, so they are fixed for that NPC from its first step.

With PathMethod = "Roblox", a route can cross a PathfindingLink (a ladder, a jump pad, a zipline). When the follower reports that the NPC stands at a link start, the kit:

  1. Publishes Npc.PathLink(npc, label, from, to) once for that link.
  2. Runs the archetype’s Traverse[label](npc, from, to) on its own thread, if one exists for the link’s Label.
  3. While the link is active and has a handler, returns from moveTowards without moving the NPC. The handler owns movement until the follower reports the landing reached.

A link with no handler still publishes the topic, and the NPC keeps walking toward the landing.

Npcs:define("Scout", {
Model = game:GetService("ServerStorage").NPC.Scout,
AgentParams = { AgentCanJump = true, AgentCanClimb = true },
Traverse = {
Zipline = function(npc, _from, to)
npc.Model:PivotTo(CFrame.new(to + Vector3.new(0, 3, 0)))
end,
},
})

Without a grid the NPC walks straight at the goal. The horizontal step direction writes State.Facing, which keeps the vision cone current on headless rigs. The final point applies through the archetype’s Move injectable or Humanoid:MoveTo.

Reactions are the archetype’s defensive vocabulary: parries, deflection wards, counter-spells.

Npcs:define("Duelist", {
Difficulty = "Good",
Reactions = {
Deflect = {
Against = { "Hitscan", "Spell" }, -- attack Types this answers; nil answers anything
Cooldown = 0.8, -- seconds between attempts, success or failure. Default 1
Difficulty = "HumanPeak", -- per-reaction skill; falls back to the NPC's
Run = function(Npc, Context) -- runs on a successful defense only
-- counter effect, riposte, reflected bolt
end,
},
},
})

npc:defend(context) rolls the reactions and returns true when the attack is blocked. Call it before your own damage lands:

if not TargetNpc:defend({ Type = "Spell", Attacker = CasterSession }) then
ApplyDamage(TargetNpc)
end

The rules:

  • A dying NPC returns false without rolling.
  • Reactions are checked in name order, resolved at define time. Hash order would make the answering reaction nondeterministic and break seeded replays.
  • A reaction matches when Against is nil or contains context.Type. Non-matching reactions and reactions on cooldown are skipped.
  • The first matching, ready reaction attempts. The attempt spends its cooldown (Cooldown * CooldownMultiplier) whether it succeeds or fails, success rolls DefenseSkill, and Npc.Defense(npc, name, success, context) publishes either way, so deflects and fumbles both get effects. Run runs on its own thread on success.
  • One attempt per attack. A failed roll returns false at once, and later reactions do not try.

Kit Hitscan and Melee actions roll the victim’s reactions when the victim is an NPC. MeleeKit closes the loop from the other side: a combatant with a defend function and no armed manual parry rolls defend({ Type = "Melee", Attacker, Action }) when a swing’s hit frame reaches it, and success counts as a full parry (the attacker staggers).

Give archetypes Faction (faction-mates never target each other, even from a custom roster) and Squad (joined on spawn; or kit:squad(name):add(npc) and squad:remove(npc) by hand).

The blackboard holds only what senses produced. Members report sight fixes exactly and heard positions blurred by the listener’s skill, through squad:report(reporter, target, position, velocity?); updateTarget and hearing report automatically. squad:lastKnown(target) returns the newest entry ({ Position, Velocity?, At, Reporter }). npc:lastIntel(target) compares the NPC’s own last sighting with the squad’s report and returns the fresher one, never the live position. Entries expire after 30 seconds, so departed players are not retained.

Each kit:update(), per engaged target, the director gives the closest living member "Pressure" and alternates the rest between "Flank" and "Suppress". Flankers split sides: squad.FlankSigns[npc] is 1 or -1, alternating, so the pincer closes from both directions. Role changes publish Npc.Tactic(npc, role). Roles clear on disengage and for dying members. Your loop reads squad:role(npc) and acts it out: fire npc:act at npc:lastIntel(target).Position to pin, move to a point off the pressure axis to flank.

Cover is scoring only. Your loop decides when to hide, hold, peek and move.

kit:findCover(npc, threat, { SearchRadius?, SpotTag?, BackAway? }) returns the nearest position the threat cannot see, or nil when nothing breaks line of sight. Candidates are parts tagged SpotTag within SearchRadius (default 40), or grid cells when no tag is given. The score is distance from the NPC, plus SearchRadius when a squadmate already stands within 6 studs of the spot (spread out), plus 2 * SearchRadius for spots closer to the threat when BackAway is set. A tagged node with a Direction attribute (the facing of the wall protecting it) counts only when the threat is on its covered side (dot product of at least 0.2 against the threat axis).

kit:findPeekPoint(spot, threat) finds the corner beside a cover spot: the nearest lateral offset (perpendicular to the threat axis, at one or two grid cells, or 3-stud steps without a grid) that is walkable and has eye-level line of sight to the threat.

When loop logic grows (“am I suppressed? is a squadmate distracting them? was that corner cleared?”), compose the same helpers under a BehaviorTree. Build one tree per NPC, use the handle (or a table wrapping it) as the blackboard, and tick from your loop with the kit clock, BehaviorTree.tick(Tree, Npc, Dt, Npcs.Clock), so Cooldown nodes stay deterministic under specs. Call BehaviorTree.reset(Tree) when the target changes. The kit never ticks trees itself.

TeamKit resolves an NPC handle’s team through Archetype.Faction, so a Faction = "Raiders" NPC and a player on the Raiders team read as teammates in every friendly-fire veto. Inside NPCKit, Faction also filters target acquisition between NPCs. Use the same string in both places.

Member Description
NPCKit.attach(kernel, options?) -> kit Constructs the kit and wires sound-topic subscriptions. Starts no loop.
NPCKit.Difficulties The preset table, Perfect to Bad.
kit:define(name, config) Registers an archetype. Duplicate names error.
kit:spawn(archetypeName, at, overrides?) -> npc Spawns at a CFrame or Vector3. Overrides: Difficulty, Squad. Publishes Npc.Spawned.
kit:all() -> { npc } Living, non-dying NPCs in id order.
kit:count() -> number Active handle count, corpses included.
kit:targets() -> { any } A fresh candidate roster (players plus Targetable NPCs, or GetTargets).
kit:update(candidates?, refreshSpatial?) Steps squad directors and lands due melee windups. Refreshes the shared roster snapshot and, unless refreshSpatial is false, the Spatial index.
kit:squad(name) -> squad Gets or creates a named squad.
kit:emitSound(position, { Range?, Loudness?, Source? }?) Broadcasts a sound. Every living NPC rolls its own hearing.
kit:hasLineOfSight(from, to, target?) -> boolean Injected function, then grid sightLine, then a world ray that respects CanCollide and ignores NPC bodies. A hit on target counts as clear.
kit:raycast(origin, displacement, excludeModel?) -> RaycastResult? The hitscan and sweep query.
kit:findCover(npc, threat, options?) -> Vector3? Nearest position the threat cannot see, or nil.
kit:findPeekPoint(spot, threat) -> Vector3? The corner beside a cover spot with eyes on the threat.
kit:destroy() Destroys every NPC, squad and sound subscription.
Member Description
npc:position() -> Vector3 GetPosition injectable, then PrimaryPart or pivot, then Home.
npc:targetPosition(target) -> Vector3? Resolves any target shape to a position.
npc:targetVelocity(target) -> Vector3? AssemblyLinearVelocity for characters, .Velocity for tables.
npc:aimAt(position, velocity?, speed?, gravity?, skill?) -> Vector3 Unit direction through the full skill model.
npc:act(name, target) -> boolean Executes a moveset action. false means gated and nothing was spent.
npc:defend(context) -> boolean Rolls defensive reactions. true means blocked.
npc:notifyDamage(attacker?) Registers a hit: suppression, hearing memory, Npc.Damaged.
npc:updateTarget(acquireRange, giveUpRange, options?) Acquisition, sight memory, graded detection.
npc:canReact() -> boolean Reaction-time gate since State.NoticedAt.
npc:canSee(target, maxRange?, fov?) -> boolean Range, then vision cone, then line of sight.
npc:facing() -> Vector3? Rig orientation, else State.Facing. nil leaves the cone open.
npc:faceTowards(point) Turns a standing rig: pivots it and writes State.Facing.
npc:sweep(direction, arc?, rays?, range?) -> Model? Ray fan. First Humanoid model touched. Defaults 90 degrees, 5 rays, 24 studs.
npc:lastIntel(target) -> { Position, At }? The fresher of its own sighting and the squad report. Never the live position.
npc:moveTowards(goal) One movement step on the pathfinding follower.
npc:setDifficulty(nameOrTable) Live skill swap.
npc:destroy() Idempotent teardown. Cancels a pending despawn.
npc.Id, npc.Name, npc.Archetype, npc.Difficulty, npc.Target, npc.State, npc.Alive, npc.Dying, npc.Model, npc.Humanoid, npc.Squad, npc.Home, npc.Rng Handle fields. State is the per-NPC memory scratch. Dying is true during the corpse window.
Member Description
squad:add(npc), squad:remove(npc) Membership. Archetype Squad joins on spawn.
squad:report(reporter, target, position, velocity?) Writes senses-produced intel. Newer overwrites older per target.
squad:lastKnown(target) -> entry? { Position, Velocity?, At, Reporter }. Entries expire after 30 s.
squad:role(npc) -> string? "Pressure", "Flank" or "Suppress", or nil when disengaged.
squad.FlankSigns[npc] 1 or -1: the side this flanker takes.

NPCKit defines no hook points. It publishes:

Topic Args When
Npc.Spawned npc After spawn wiring completes.
Npc.Died npc Humanoid death, after a RagdollOnDeath ragdoll engages. Starts the DespawnSeconds corpse window.
Npc.Action npc, actionName, target A moveset action executed (not gated attempts).
Npc.Heard npc, heardPosition, source? A sound survived range and occlusion. The position is already blurred by skill.
Npc.Suspicious npc, target, glimpsedPosition Graded detection crossed half charge.
Npc.Damaged npc, attacker? notifyDamage: kit hits, Humanoid health drops, or your call.
Npc.Defense npc, reactionName, success, context A defensive reaction attempt, deflect or fumble.
Npc.Reload npc, actionName, seconds A magazine emptied and a reload started.
Npc.Windup npc, actionName, target, seconds A telegraphed melee swing started.
Npc.Path npc, waypoints A fresh route was computed (full list, own position first).
Npc.PathLink npc, label, from, to The NPC reached the start of a PathfindingLink crossing. Once per link.
Npc.Hitscan npc, origin, hitPosition, victim? A hitscan action fired, hit or miss.
Npc.Tactic npc, role A director role change: Pressure, Flank or Suppress.

Topics consumed: every topic listed in Sounds.Topics, as sound sources. With Zones attached, spawned models also produce Zone.EntityEntered and Zone.EntityLeft through that system.

  • Kind = "Projectile" actions error at execution without Projectiles at attach, and Custom actions error without Run. Both are authoring mistakes and fail loudly.
  • Pass Seed at attach and inject Clock to make whole engagements replayable. Per-NPC Rng derivation, name-ordered reactions and the clock-driven kit:update() exist so seeded specs reproduce a fight decision for decision.

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