Quick navigation

Go to

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 (default true) 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

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try