Skip to content

Police NPCs

Server-side AI for foot-patrol police officers. Lives in src/server/services/AI/PoliceNPC/.

Architecture

The NPC logic uses a Hierarchical State Machine (HSM). There are two levels:

  • State Groups (G_* modules) — high-level operational modes. Each group has a canEnter, an optional Enter/Exit, and a BeginState that names which child state to start in.
  • Child States — concrete behaviors that run every tick. Each has canEnter, tick, and optional Enter/Exit.

The core loop in Core.lua fires every ~100 ms per NPC (staggered by NPC id to spread load). On each tick it:

  1. Checks if the NPC should be cleaned up (dead, despawning, etc.)
  2. Finds a target via Target.find and updates Entity.Distance and Entity.CanSeeTarget
  3. Scans all possible next states (child states listed in nextStates + all groups except the current one) and transitions to the first whose canEnter returns true
  4. Calls State.tick(Entity)

State Groups and Their Children

G_Patrol — no target

The NPC wanders the map when there is no wanted player nearby.

State What it does
Wander Follows path nodes around the map at walk speed
EnterPatrolCar Drives a patrol vehicle along road nodes (up to 2 vehicles patrol simultaneously)

The group starts despawning the NPC after 25 seconds if it stays in Wander with no patrol car, preventing orphaned NPCs.

1_G_Drive — target is in a vehicle and far away

Entered when the target is identified and either in a moving vehicle or more than 300 studs away (or unidentified and the last-known position is more than 500 studs away). The NPC runs to their claimed patrol car and drives it.

State What it does
RunToCar Pathfinds to the patrol car door then sits in the seat

2_G_Investigate — target is unidentified and nearby

The NPC knows a crime occurred but hasn't identified the player yet. Entered when target exists but CrimeRecord.Identified is false and within 400 studs.

State What it does
PatrolArea Patrols around the last-known crime position looking for the suspect
InvestigateClue Moves to a specific clue position

3_G_Search — target identified, search timer active

Entered when the player is identified and ClientSearchTick > 0. The NPC sweeps the area.

State What it does
SearchArea Moves to likely player positions, calling out

4_G_Combat — target identified, armed, not searching, within range

Entered when the player has a HAS_GUN advisory, is identified, not in a search phase, within ~175 studs (300 if in a car), and a cover position exists nearby. The NPC prioritises taking cover and shooting from behind it.

State What it does
TakeCover Finds nearest cover node, moves behind it, fires when line-of-sight is clear

G_Chase — target identified, not armed/in search, within range

The default close-range engagement group when the player doesn't have a gun (or the armed-player conditions for Combat aren't met).

State What it does
RunAfterTarget Sprints directly toward the suspect
TazeTarget Fires taser when close enough
CircleAroundTarget Strafes around the suspect to avoid being an easy target
EjectTargetFromVehicle Reaches into the car and removes the player from the seat
ArrestTarget Handcuffs and arrests the suspect once they stop resisting

State Transition Priority

Groups are evaluated in priority order. The first group whose canEnter returns true wins and its BeginState becomes the new active state. Child states within a group use nextStates to declare legal transitions — only those listed can be entered from within the group.

When a group changes, groupState.Exit fires on the old group and groupState.Enter fires on the new one. When only a child state changes within the same group, only the child Enter/Exit run.

Target Detection

Target.find on each tick looks at the dispatch crime records to find a wanted player. It also cross-checks whether the NPC has line-of-sight to the target's head using PoliceNPCService:CanSeeTarget, which does a dot-product facing check (>0.3) before raycasting.

If the NPC can see a player carrying a weapon (via tool tag check), it advises HAS_GUN to CentralService, which attaches the advisory to the player's runtime crime record.

Entity Setup

Each NPC entity is constructed via the EntityService component system (PoliceNPCs tag). Construct sets up:

  • Pathfinding Path object
  • Weapon table (Pistol, Taser) with ammo counts and cooldowns
  • Animations loaded from ServerStorage.Animations
  • A billboard health bar that appears when the NPC takes damage
  • Ragdoll rig via RagdollModule
  • Network ownership locked to the server

HeartbeatUpdate runs every frame but is throttled per-entity to ~100 ms. It also handles the arm/neck aiming rotation toward the target when a weapon is equipped.

Decision Logging

DecisionLogger records every state transition and notable detection event with a reason string. The debug UI (NPCDebugController + PoliceNPCService.Client:GetDecisionLog) can pull the last 50 log entries for a specific NPC instance. This is gated behind the Cmdr debug permission system.