NPCs & player models
NPC AI, combat and voice control
Control AI, combat, perception and voice lines per NPC with authoritative server Lua APIs.
On this page7 sections
Control individual spawned NPCs from server Lua with Open77.npcs. Behavior settings apply to the selected NPC, not its gang, other NPCs using the same record, player characters or global audio.
Available in client and server 2.31.13+op77.55, using protocol 1.24. Both sides must be updated to apply the behavior policy. Find spawn IDs in the NPC record catalogue.
Spawn a passive, silent NPC
-- Server manifest: permission "world.npcs"
local npc, reason = Open77.npcs.create({
record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa",
position = { x = 381.0, y = -2400.0, z = 182.0 },
aiMode = Open77.npcs.ai.tasks,
behavior = {
combatEnabled = false,
voiceEnabled = false,
},
})
if not npc then print(reason); return end
-- Optional: pause both the native AI agent and Open77 task execution.
local ok, error = Open77.npcs.setAIEnabled(npc, false)
if not ok then print(error) end
-- Resume the selected aiMode, without changing the passive/silent options.
Open77.npcs.setAIEnabled(npc, true)
The record and its assets must exist on each client. A successful create returns a
canonical server ID, not proof that the engine has spawned the body. See
NPC lifecycle and readiness.
Independent controls
All four options default to true. Omitting an option preserves its current value when updating.
| Option | Server helper | Effect of false |
|---|---|---|
aiEnabled |
Open77.npcs.setAIEnabled(id, false) |
Disables the native AI agent and its perception; suspends Open77 tasks and revokes its simulation lease. Rendering/collision are not disabled. |
combatEnabled |
Open77.npcs.setCombatEnabled(id, false) |
Clears tracked threats, disables threat acquisition/reactions and blocks aggression and outgoing hit processing. The movement agent remains available for scripted tasks. |
perceptionEnabled |
Open77.npcs.setPerceptionEnabled(id, false) |
Disables autonomous sensory/stimulus acquisition, including Open77's automatic target seeding. It does not clear a previously assigned combat target; use combatEnabled=false to stop combat. |
voiceEnabled |
Open77.npcs.setVoiceEnabled(id, false) |
Disables the NPC's native voiceset lines and grunts, including combat/search barks that bypass scripts. Script-triggered voiceovers and native dialogue routed through subtitles are also filtered. Footsteps, weapons and other sound effects are not muted. |
Each helper requires a boolean, not 0, 1, or "false". Use one atomic update for
several options:
local ok, reason = Open77.npcs.setBehavior(npc, {
aiEnabled = true,
combatEnabled = false,
perceptionEnabled = false,
voiceEnabled = false,
})
local behavior = Open77.npcs.getBehavior(npc)
-- Equivalent read: Open77.npcs.get(npc).behavior
setBehavior and the four setters return true, or false with a validation reason
when supplied data is invalid. Unknown/not-owned NPCs and missing permission return false.
getBehavior returns a detached table or nil when unavailable. Malformed options return
npc_behavior_invalid; unknown option names return npc_behavior_unknown_option.
Open77.npcs.update(id, { behavior = {...} }) accepts the same partial object. Changing the
loadout/appearance, changing bucket, or reviving the NPC does not reset these settings.
They are not stored in client KVP and do not constitute disk persistence across server restarts.
Two further options govern damage rather than AI: vehicleContactEnabled (default false,
below) and hitPricing (default "resource",
below). They are set the same way, at creation or later,
and read back from getBehavior:
local npc, reason = Open77.npcs.create({
record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa",
position = { x = 381.0, y = -2400.0, z = 182.0 },
damagePolicy = Open77.npcs.damage.mortal,
behavior = { vehicleContactEnabled = true, hitPricing = "platform" },
})
if not npc then print(reason); return end
print(Open77.npcs.getBehavior(npc).hitPricing) --> platform
Vehicle contact damage
behavior.vehicleContactEnabled defaults to false. The NPC's creating server resource may opt
in with Open77.npcs.setBehavior(id, { vehicleContactEnabled = true }). This replaces native
vehicle-hit damage pricing with server-priced contact damage, and it keeps the NPC's damage
policy (invulnerable, immortal or mortal). The client simulating the NPC reports the
contact; the server checks its current lease, world readiness, revision, bucket and the
vehicle's recent trajectory, with a two-second contact cooldown per NPC. Damage uses the same
speed curve as car impacts on players (the combat.vehicleHitDamage* server options), derived
from accepted positions. Nothing scans the world for contacts every frame.
An accepted contact adopts an existing native fall or requests a native NPC knockdown. Identical ragdoll trajectories on every screen are not guaranteed.
Who prices player hits: hitPricing
An NPC's health and death belong to the server. No client lets the game change a network NPC's health or kill it on its own: a shot plays its hit reaction, and the NPC loses health or dies only when the server's ledger says so, on every client at once.
By default the resource that created an NPC prices player hits on it, typically from the
client event open77:npcHit it forwards to its own server script and a weapon table. A resource
that prices nothing leaves players unable to kill its NPCs: they react to shots but never lose
health.
A resource can instead opt in to the platform's pricing for a mortal NPC
(damagePolicy = Open77.npcs.damage.mortal). The shooter's client then sends the engine's own
price for each hit, and the server applies it with Open77.npcs.applyDamage semantics.
onNpcDamaged and onNpcDied receive the source "player:<id>" and the cause "player_hit".
-- A guard that players may kill, with no hit pricing of your own.
local guard = Open77.npcs.create({
record = "Character.cpz_maelstrom_grunt1_ranged1_lexington_wa",
position = { x = 381.0, y = -2400.0, z = 182.0 },
damagePolicy = Open77.npcs.damage.mortal,
behavior = { hitPricing = "platform" },
})
-- or later: Open77.npcs.setBehavior(guard, { hitPricing = "platform" })
Resources that create mortal NPCs and do not price open77:npcHit should opt in, or players
cannot kill those NPCs. The opt-in exists because the amount is still the shooter's own claim:
every report must ride shots the server itself admitted (see below), but the server has no weapon
damage table, so a modified client could still claim one maximum per admitted shot. Price hits in
your resource when that matters.
The server checks each report before applying it:
- the shooter is alive, has a weapon drawn according to its own recent player snapshot, has a fresh position in the NPC's routing bucket and is within 150 m of it;
- the report names at least one shot (a spent round or a melee swing) that the server admitted
for that drawn weapon at the weapon class's fire rate, fired within the last 3 s (1.5 s for a
swing) from within 150 m (12 m for melee) of the NPC, and not yet used against this NPC. Each
shot prices a given NPC once. The server option
combat.handheldDischarges(defaulttrue) runs this admission; turning it off turns platform pricing off; - the NPC is alive, mortal and opted in;
- one report is worth at most the NPC's maximum health;
- a player may apply at most 40 reports and 6 Γ maximum health per second across all NPCs;
- an NPC may receive at most 24 reports and 2 Γ maximum health per second from all players.
Immortal and invulnerable NPCs are never priced this way.
hitPricing |
Player hits on a mortal NPC |
|---|---|
"resource" (default) |
Never priced by the platform; only what the resource applies changes the NPC. open77:npcHit still fires on the shooter's client. |
"platform" |
Priced by the server as above. open77:npcHit still fires as well; do not also price it, or each shot counts twice. |
The value must be one of these two strings; anything else returns npc_behavior_invalid.
Open77.npcs.getBehavior(id).hitPricing reads it back. The bundled Cordon, Deathmatch and
Freeroam PvP bots state "resource" explicitly.
Damage no player caused is always reported by the client that simulates the NPC: environment, falls, fire, explosions without a player instigator, and the NPC's own grenade. Vehicle collisions are not player hits; opt in to vehicle contact damage for those.
Server configuration
Both options live in the combat section of server.jsonc and are read at startup:
"combat": {
"handheldDischarges": true, // default: admit handheld shots, required by "platform" pricing
"handheldVehicleDamage": false // default: see Armed vehicles & Lua API
}
handheldDischarges (default true) makes the server bind each living body and admit fire
declarations against the drawn catalogued weapon and a per-class cadence. A receipt damages
nothing by itself; platform-priced NPC hits require one. With false, no binding is sent and
platform-priced NPCs cannot be priced from a player's hit. handheldVehicleDamage is described in
Armed vehicles & Lua API.
Speech: one line, on demand
Open77.npcs.speak(id, voice, options) is the ON switch beside setVoiceEnabled's OFF: one
voice-over line from the NPC's own voiceset, queued on every client that has the body
streamed. It is the counterpart of FiveM's PlayPedAmbientSpeechNative. What is proven is the
plumbing β the SoundPlayVo event reaches each viewer's puppet, and a muted NPC refuses β not
the sound: the test loop has no audio capture, so whether a given line is heard on a given
record is something to check by ear before shipping it.
-- A guard greets whoever walks up, and warns them off when they linger.
local guard = Open77.npcs.create({ record = "Character.Judy", position = gate })
AddEventHandler("onNpcInteracted", function(npcId, playerId)
if npcId ~= guard then return end
local ok, reason = Open77.npcs.speak(guard, "greeting")
if not ok then print("guard stayed silent: " .. reason) end
end)
Open77.npcs.speak(guard, "rep_ask_to_leave", { ignoreDistance = true })
voice is a voContext name β the same word vanilla passes to
GameObject.PlayVoiceOver, resolved by the engine against the puppet's voiceset
(Character.*.voiceTag) β not a Wwise event; Open77.effects.sound remains the way to play a
sound bank event on an NPC. Open77.npcs.voices() lists the generic barks that can be
pointed at in the game's own 2.31 sources, each with the file and line that plays it:
| Name | When vanilla plays it |
|---|---|
greeting |
look-at reaction to a friendly passer-by |
bump |
bumped into by the player |
fear_beg, fear_run, fear_foll |
a civilian threatened: begging, fleeing, complying |
stlh_curious, stlh_curious_grunt, stlh_investigate, stlh_search, stlh_patrol_back, stlh_call, stlh_death |
the stealth ladder: noticing, investigating, searching, giving up, calling a friend, finding one dead |
start_alerted, start_combat, combat_ended, danger, enemy_warning, combat_target_hit, combat_target_sight_lost, crowd_combat |
alert and combat state changes |
attack_short, attack_long, enemy_melee_charge, battlecry_curse |
attack shouts |
hit_reaction_light, hit_reaction_heavy, vo_any_damage_hit |
taking hits |
grenade, grenade_throw, coop_reports_kill |
grenades and kill calls |
rep_ask_to_leave, rep_ask_to_holster, rep_final_warning, rep_call_grd, rep_complies |
a guard's escalation ladder |
hurry_up, phone_start, taunt_hidden_player, pedestrian_hit, vehicle_bump |
miscellany |
The list is documentation, not an allowlist: any identifier is accepted (letters, digits,
underscore, at most 64), because a record may carry lines no vanilla script calls. A name the
voiceset does not have is silent, and nothing can report that β the engine drops it without
a word, so true means "queued on every viewer", not "heard". Test the names you ship on the
records you ship; a character whose record has no voiceTag never speaks.
Options: ignoreFrustum (default true) plays even when the NPC is off-screen;
ignoreDistance (default false) skips the engine's distance cull. Refusals, by name:
| Reason | Meaning |
|---|---|
invalid_voice |
Not an identifier, empty, or longer than 64. |
invalid_npc_id, npc_not_found, npc_not_owned |
Bad id, unknown id, another resource's NPC. |
npc_dead |
A dead body has nothing to say. |
voice_disabled |
setVoiceEnabled(id, false) is in force; lift it first. |
npc_not_streamed |
No client has reported the body ready yet β nobody could hear it. |
npc_voice_busy |
A line was accepted on this NPC less than 400 ms ago. |
Requires world.npcs and ownership, like every NPC mutator. The line rides the existing
effect one-shot to every client in the NPC's routing bucket; a client that does not have the
body streamed resolves no target and drops it, which is the same rule an entity-bound sound
follows. On the client the bundled open77_effects resource queues the engine's own
SoundPlayVo event on the puppet through Open77.sfx.playVoice, which a client resource may
also use for a purely local bark (see Effects).
Tasks, authority and replication
aiMode and behavior are different controls. frozen suspends Open77's task scheduler;
use setAIEnabled(id, false) when the native agent must also stop. Re-enabling AI does
not change aiMode: a frozen task scheduler stays frozen until its mode is changed.
Tasks resume with renewed timing rather than expiring because of the paused interval.
The server validates resource ownership and replicates the full policy in reliable NPC state,
including to late joiners, observers and newly streamed incarnations. Engine mutations run on
the game thread. An unavailable native/component is logged as behavior pending, not reported
as a successful application. Applying these options requires the matching updated client and
server; the existing 1.24 wire layout is unchanged, but older clients ignore the new policy.
Clients with npcs.read can inspect Open77.npcs.get(id).behavior and .all(), but cannot
override the server policy. Custom network event handlers must validate ownership themselves;
never forward an arbitrary client-supplied NPC ID to these setters.
If your gamemode accepts NPC hit reports, re-check current canonical behavior before applying
damage: ignore reports when aiEnabled or combatEnabled is false. A delayed report is not
permission to damage a player after a server-side policy change.
Boundaries
- These APIs control Open77-created NPCs, not arbitrary ambient world handles.
- Voice suppression is prospective. It does not promise to interrupt every line already playing, or audio emitted outside the game's voice/dialogue pipelines.
- Disabling AI is not time dilation, a pose freeze, ragdoll, invulnerability or hiding a character. Use the dedicated animation and damage APIs for those effects.
- Perception off does not override explicit script commands. Combat off does not prevent your own Lua code from deliberately calling a damage API or playing a custom attack animation.
- Vehicle auto-driving belongs to
Open77.vehicles.ai; disabling a seated NPC's agent is not a substitute for stopping the vehicle's drive command.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
