NPC Behaviors
This page explains the behavior-oriented Lua AI model used by Moongate v2 NPC brains.
If you have never authored an NPC before, start with the tutorial path first:
Goal
Keep NPC AI maintainable by separating:
- brain orchestration (
on_think, event routing, priorities) - behaviors (small focused units like follow, evade, hold_position)
- runtime state (blackboard values stored per NPC)
Directory Layout
moongate_data/scripts/ai/
├── behavior.lua # behavior registry
├── runtime/
│ ├── fsm.lua # shared phase-1 FSM helpers
│ ├── movement.lua # shared movement intentions
│ └── targeting.lua # shared fight-mode and targeting helpers
├── runners/
│ └── utility_runner.lua # utility/priority behavior runner
├── behaviors/
│ ├── init.lua
│ ├── evade.lua
│ ├── follow.lua
│ ├── hold_position.lua
│ ├── idle.lua
│ ├── leash.lua
│ ├── ranged_keep_distance.lua
│ ├── self_bandage.lua
│ └── return_home.lua
└── brains/
├── ai_melee.lua
├── ai_archer.lua
├── ai_animal.lua
├── ai_vendor.lua
├── ai_berserk.lua
├── guard.lua
├── undead_melee.lua
└── utility_npc.lua
Brain Contract
Mobile templates bind brains through the canonical ai object:
{
"id": "city_guard",
"ai": {
"brain": "guard"
}
}
ModernUO-aligned standard brains use the same shape with extra AI parameters:
{
"id": "juka_lord_npc",
"ai": {
"brain": "ai_archer",
"fightMode": "closest",
"rangePerception": 10,
"rangeFight": 3
}
}
Custom shard-authored brains continue to use the same field, for example ai.brain = "orion".
Each Lua brain table can expose:
on_think(npc_serial)required for coroutine executionon_event(event_type, from_serial, event_obj)optionalon_in_range(npc_serial, source_serial, event_obj)optionalon_out_range(npc_serial, source_serial, event_obj)optionalon_speech(npc_id, speaker_id, text, speech_type, map_id, x, y, z)optionalon_death(by_character, context)optional
ai.brain = "guard" resolves to Lua table guard.
Canonical AI Fields
The canonical mobile AI contract is:
ai.brainai.fightModeai.rangePerceptionai.rangeFight
Standard normalized brain ids currently used by ModernUO-derived templates are:
ai_meleeai_archerai_animalai_vendorai_berserkai_mageai_healerai_thief
Phase 1 ships full shared support for:
ai_meleeai_archerai_animalai_vendorai_berserk
ai_mage, ai_healer, and ai_thief are currently compatibility aliases to legacy brains while the engine primitives
for full parity are still incomplete.
Behavior Pattern
Behaviors are isolated modules registered by ID.
A behavior usually exposes:
score(npc_serial, ctx)to compute utilityrun(npc_serial, ctx)to execute action and return next delay (ms)on_event(npc_serial, ctx, event_type, from_serial, event_obj)optional
The utility runner selects the highest score, applies anti-jitter hold (min_hold_ms), then executes run.
Guard Brain Example
guard.lua is a custom Lua brain with explicit guard policy, not a standard ai_archer wrapper and not a generic utility-runner behavior set.
The brain owns:
- focus lifecycle through
guards.set_focus(...)andguards.get_focus(...) - melee vs ranged branching through
guard_role - guard recovery / return-home behavior
- teleport and reveal decisions through the
guardsmodule - combat-hook recovery when guards are attacked
At each tick:
- read
ai.rangePerceptionandai.rangeFightfrom the template/runtime data - resolve or refresh the current focus target
- fall back to
guard_role-specific policy inside Lua - decide whether to engage, back off, teleport, or return home
coroutine.yield(TICK_DELAY_MS)
Speech events are still handled separately from combat. In-range events greet players once per source mobile and can arm combat when a hostile target enters range.
Combat hooks (attack, missed_attack, attacked, missed_by_attack, combat) refresh focus directly from the aggressor serial.
Archer guards use guard_role = "ranged" and keep a 4-6 tile spacing band. Melee guards use the same brain but prefer direct closure and home recovery.
Optional Patrol Params
The current guard brain also reads optional patrol settings from params. Patrol is opt-in: if patrol_mode is not set to random_roam, or patrol_radius is missing or non-positive, the guard keeps its existing idle and return-home behavior.
{
"params": {
"patrol_mode": { "type": "string", "value": "random_roam" },
"patrol_radius": { "type": "string", "value": "6" }
}
}
patrol_radius is stored as a string param because mobile template params currently support string, serial, and hue values. guard.lua parses the radius with tonumber(...) at runtime.
home_* remains the patrol center. The guard samples random roam points around the captured home point, and leash_radius remains the hard outer boundary because patrol radius is capped to it and immediate boundary breaches hand control back to the existing home-recovery flow in the same think cycle.
Existing production guard templates in moongate_data/templates/mobiles/guards.json do not set these patrol params, so their current behavior is unchanged.
undead_melee.lua is a simpler fixed-loop brain:
- ticks every
2000ms - calls
perception.find_nearest_player_enemy(npc_serial, 10) - arms combat immediately against the nearest hostile player
- uses
steering.follow(..., 1)so the zombie closes into melee instead of standing still - clears the active combat target and wanders when no hostile is found
Current in_range payload fields include:
listener_npc_idsource_mobile_idsource_namesource_is_playersource_famesource_karmasource_notorietysource_relationsource_is_enemymap_idrangelocation
source_notoriety is viewer-relative: it is resolved from the listener NPC toward the source mobile, not copied raw from the source entity. source_relation is the coarse AI-friendly classification used by brains:
FriendlyNeutralHostile
source_relation also includes faction hostility:
- same-faction mobiles resolve as
Friendly - declared enemy factions resolve as
Hostile - innocent non-faction players remain
Neutralfor guard-style NPCs
Built-In Behaviors
Current built-in behavior modules under moongate_data/scripts/ai/behaviors/ are:
evade- Reads
evade_from_serial,evade_desired_range, andevade_hp_threshold - Moves away from the current threat when the threat is too close or HP is low
- Reads
follow- Reads
follow_target_serialandfollow_stop_range - Chases the target until it reaches the configured stop distance
- Reasserts
combat.set_target(...)while following so the C# combat loop stays armed - Clears
follow_target_serialif the combat target can no longer be armed
- Reads
leash- Reads
follow_target_serial,home_x,home_y, andleash_radius - Drops the current target when the guard has been pulled too far from home
- Clears the active combat target before the brain transitions into return-home logic
- Reads
ranged_keep_distance- Reads
follow_target_serial,preferred_min_range, andpreferred_max_range - If the target is too close, it uses
steering.evade(...) - If the target is too far, it uses
steering.follow(...) - Inside the preferred band, it stops movement and keeps the combat target active
- Clears
follow_target_serialif the combat target can no longer be armed
- Reads
return_home- Reads
home_x,home_y,home_z, andhold_radius - Walks the guard back to its captured home point when no target is active
- Reads
hold_position- Reads
home_x,home_y,home_z, andhold_radius - Replaces random wandering for guards that are already back near home
- Reads
self_bandage- Reads
self_bandage_hp_thresholdandself_bandage_score_bonus - Requires a real
bandagestack in the NPC backpack - Starts a delayed self-heal through the
healingmodule - Keeps a high score while the bandage timer is in flight so the brain does not thrash
- Reads
idle- Fallback behavior when nothing else scores higher
- Keeps the brain alive without chasing or retreating
State (Blackboard)
Behavior state is stored per NPC using canonical npc_state module keys, for example:
follow_target_serialhome_xhome_yhome_zhome_map_idhold_radiusleash_radiusfollow_stop_rangeevade_desired_rangeevade_hp_thresholdpreferred_min_rangepreferred_max_rangeself_bandage_hp_thresholdself_bandage_score_bonusguard_seen_<serial>guard_engaged_<serial>
This keeps behavior logic stateless and reusable.
The guard brain initializes defaults only when a key is missing. That keeps the scripts KISS while still allowing runtime tuning to override blackboard values without being overwritten every tick.
The shared AI runtime also uses canonical blackboard keys:
ai_actionai_target_serial
Legacy aliases from the previous naming (modernuo_action and modernuo_target_serial) are still accepted by npc_state for compatibility. When the runtime reads them, it migrates the value to the canonical key and removes the legacy alias.
Guard Ranges
There are three different ranges involved in guard combat:
- Acquisition range
- This is the distance at which the Lua brain receives
in_rangefor a hostile target. - The runner now reads
ai.rangePerceptionfrom the template data instead of special-casing guards. - Guards still use explicit template values so melee and ranged guards can differ without runner heuristics.
- This is the distance at which the Lua brain receives
- Preferred movement band
- Archer guards try to stay between
4and6tiles from the target. - Melee guards close directly and recover to home when the target escapes.
- Archer guards try to stay between
- Actual weapon attack range
- This is resolved by
CombatServicefrom the equipped weapon profile.
- This is resolved by
Guard Runtime Boundary
The guard brain currently depends on:
guards.set_focus(...)guards.get_focus(...)guards.teleport_to_target(...)guards.try_reveal(...)
The generic runner is intentionally not guard-aware anymore. Guard policy lives in Lua, while C# provides thin primitives for focus, teleport, and reveal.
- The current bow template uses
maxRange = 10. - Melee weapons without explicit range metadata fall back to
1.
These ranges are intentionally separate:
- acquisition determines when the brain reacts
- preferred band determines where the behavior wants to stand
- weapon range determines whether the attack can actually fire
For guards this means:
- warrior guards notice hostiles at
3, chase, and attack in melee - archer guards can notice hostiles earlier, position at
4-6, and still fire out to the bow maximum range
When a hostile leaves out_range, the guard brain only clears the per-source engagement flag. It does not immediately drop focus or clear the active combat target from the event hook. Focus cleanup and home recovery are deferred to the next on_think tick, where the brain revalidates the target and decides whether to continue, teleport, or return home.
Guards also capture a home point once, then use two simple rules:
- if they have no target and drift outside
hold_radius,return_homepulls them back - if they are dragged beyond
leash_radiuswhile chasing,leashdrops the target so they can reset
Runtime Modules Used by Behaviors
Current core modules for behavior scripts:
perception(distance, nearby friend/enemy lookup, range checks)steering(follow/evade/move_to/wander/stop movement primitives)combat(target selection into the server combat loop;set_target/clear_target)healing(self-bandage start/status helpers)
perception.find_nearest_enemy(...) considers:
- viewer-relative hostility, not just raw target notoriety
- recent aggression
- faction hostility
- players and NPCs that resolve as
Hostileto the querying NPC
combat.set_target(...) does not calculate hit or damage in Lua.
It delegates to CombatService, which owns:
- warmode and
CombatantId - swing scheduling through
TimerWheelService - melee hit/damage resolution
- region/map harmful-action gate on actual attack attempt
- lethal handoff into
DeathService, including PvE fame/karma awards for player kills against NPCs - delayed self-bandage completion through
BandageService npc_state(typed state variables)time,random,mobile(general runtime helpers)
Self Bandage Notes
self_bandage is intentionally narrow in v1:
- only self-heal, never target another mobile
- consumes
1bandageimmediately when the timer starts - heals after a short fixed delay in C#
- does not cure poison, resurrect, or create dirty bandages
The behavior does nothing unless the NPC already has a backpack and at least one bandage item inside it.
Best Practices
- Keep each behavior focused on one decision.
- Store tunables in blackboard keys instead of hardcoding in multiple files.
- Use
on_eventfor reactive AI (speech, in-range, out-range), andon_thinkfor tactical polling. - Return explicit delay values from behaviors to control tick frequency.
- For conversational NPCs, prefer
common.npc_dialogueso deterministic dialogue can claim speech beforeai_dialoguefallback.