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.
Mental model
Section titled “Mental model”The kit has three layers:
- 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 notask.delay, so specs stay deterministic. - The NPC handle is one table per spawned NPC with
Id,Name,Archetype,Difficulty,Target,Alive,Dying,Model,Humanoid,Squad,Home, anRng(seeded per NPC when the kit has aSeed) andState, the scratch that gives an NPC a memory:Cooldowns,HeardPosition,HeardAt,HeardSource,LastSeenPosition,LastSeenAt,NoticedAt,SuppressedUntil,Detection,Ammo,Reloads,Windups,Facing,Follower,AimDrift,PathLink. - Your loop does the thinking. Nothing ticks unless you schedule it. The usual shape is one
kernel.Scheduler:every(cadence, think)job, wherethinkcallskit:update(), walkskit: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.
Attaching
Section titled “Attaching”--!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 NpcServiceAttach options
Section titled “Attach options”| 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. |
Archetypes
Section titled “Archetypes”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). |
The skill model
Section titled “The skill model”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.
Spawning and lifecycle
Section titled “Spawning and lifecycle”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.Diedstarts the corpse window (below).Humanoid.HealthChanged: any health drop callsnpc: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.
Network ownership
Section titled “Network ownership”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.
The corpse window
Section titled “The corpse window”Humanoid.Died does not destroy the NPC at once. The kit:
- Sets
npc.Dying = trueand drops any pending melee windups. - For a
RagdollOnDeatharchetype with a kitRagdoll, callsRagdoll:enable(model), so subscribers see the body already down. - Publishes
Npc.Died(npc). - Schedules
npc:destroy()afterDespawnSecondson the kernel Scheduler.npc:destroy()andkit: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.
The game-owned tick
Section titled “The game-owned tick”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:
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()endkit: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.
Aiming
Section titled “Aiming”npc:aimAt(targetPosition, targetVelocity?, projectileSpeed?, gravity?, skill?) returns a unit direction through the full skill model:
- 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 byLeadSkill, soBad(0) never leads andMedium(0.5) trails sprinting targets. - With
gravity > 0, a low-arc launch solution compensates for drop. Out-of-range shots lob at the 45-degree maximum-range angle. - 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 sinceState.NoticedAt. Suppression widens it 1.5 times whileState.SuppressedUntilholds. Error stays withinAimErrorDegreesper 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.
Movesets
Section titled “Movesets”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. |
The four kinds
Section titled “The four kinds”Projectilefires the shared kernel Projectiles service with the NPC handle as the owner session.Projectile.Hitand the definition’sOnHitreceive the handle the way they receive a player’s session. RequiresProjectilesat attach.Hitscanresolves at once throughkit: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.Meleegates on the 3D distance to the target againstRange, then either lands at once or arms a windup. Windups land inkit:update()on the kit clock: reach is checked again atRange * 1.25(a small grace, so a dodge during the windup escapes), the victim’s reactions roll, thenDamageorOnHitapplies.Npc.Windup(npc, name, target, seconds)publishes when the swing starts: the telegraph feed.Customruns 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.
Magazines and lane discipline
Section titled “Magazines and lane discipline”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.
Senses
Section titled “Senses”Hearing
Section titled “Hearing”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. OnePathfinding:distanceFieldflood 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 withoutdistanceFieldfalls 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.
Vision
Section titled “Vision”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.
Targeting and memory
Section titled “Targeting and memory”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:
- The current target is kept while it stays inside
giveUpRange. WithoutRequireSightthat is the whole check. With it, a seen target refreshesState.LastSeenPositionandState.LastSeenAtand reports to the squad. A target unseen for more than 0.35 s that reappears costs half the reaction time again (State.NoticedAtis set back by half ofReactionSeconds): a reappearance is a fresh stimulus. - 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. - With no target, the NPC acquires the nearest candidate within
acquireRange, skipping itself, its own model, dead or dying NPCs, and faction-mates.RequireSightgates candidates through the cone and ray check. Ties at equal distance go to the later candidate in roster order. Acquisition stampsState.NoticedAt(the reaction clock) and reports to the squad. - With
DetectionSecondsset, 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 publishesNpc.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?).
Movement
Section titled “Movement”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.7picks any-angle"ThetaStar", below that plain"AStar"(staircase routes). The kit-widePathMethodoverrides both.- Repath eagerness follows
RepathDistance = CellSize * (1 + (1 - PathSkill) * 4): high skill repaths when the goal drifts one cell, andBadkeeps 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
StuckandJumphints setHumanoid.Jump: the unstick hop and navmesh jump waypoints. - The follower brings failed-search backoff,
Grid.Versioninvalidation 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.
PathfindingLink crossings
Section titled “PathfindingLink crossings”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:
- Publishes
Npc.PathLink(npc, label, from, to)once for that link. - Runs the archetype’s
Traverse[label](npc, from, to)on its own thread, if one exists for the link’sLabel. - While the link is active and has a handler, returns from
moveTowardswithout 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.
Defense: Reactions and defend()
Section titled “Defense: Reactions and defend()”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)endThe rules:
- A dying NPC returns
falsewithout 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
Againstisnilor containscontext.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 rollsDefenseSkill, andNpc.Defense(npc, name, success, context)publishes either way, so deflects and fumbles both get effects.Runruns on its own thread on success. - One attempt per attack. A failed roll returns
falseat 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).
Squads
Section titled “Squads”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 helpers
Section titled “Cover helpers”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.
Brains: BehaviorTree
Section titled “Brains: BehaviorTree”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.
Teams and factions
Section titled “Teams and factions”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.
API reference
Section titled “API reference”| 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. |
NPC handle
Section titled “NPC handle”| 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. |
Hooks and Bus topics
Section titled “Hooks and Bus topics”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.
Design notes
Section titled “Design notes”Kind = "Projectile"actions error at execution withoutProjectilesat attach, andCustomactions error withoutRun. Both are authoring mistakes and fail loudly.- Pass
Seedat attach and injectClockto make whole engagements replayable. Per-NPCRngderivation, name-ordered reactions and the clock-drivenkit:update()exist so seeded specs reproduce a fight decision for decision.